2026-06-15 15:38:03 +03:00
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
set -Eeuo pipefail
|
|
|
|
|
|
|
2026-06-15 15:43:01 +03:00
|
|
|
|
APP_NAME="xtui"
|
|
|
|
|
|
STATE_DIR="${HOME}/.xtui"
|
2026-06-15 15:38:03 +03:00
|
|
|
|
CONFIG_FILE="${STATE_DIR}/config"
|
|
|
|
|
|
SUB_JSON="${STATE_DIR}/sub.json"
|
|
|
|
|
|
BACKUP_DIR="${STATE_DIR}/backups"
|
|
|
|
|
|
CACHE_TTL_SECONDS=3600
|
|
|
|
|
|
DEFAULT_XRAY_CONFIG="/etc/xray/config.json"
|
|
|
|
|
|
DEFAULT_XRAY_SERVICE="xray.service"
|
|
|
|
|
|
|
|
|
|
|
|
# jq helper: normalize common JSON subscription containers to an array of profiles.
|
|
|
|
|
|
PROFILES_DEF='
|
|
|
|
|
|
def profiles:
|
|
|
|
|
|
if type == "array" then .
|
|
|
|
|
|
elif type == "object" and ((.configs? | type) == "array") then .configs
|
|
|
|
|
|
elif type == "object" and ((.profiles? | type) == "array") then .profiles
|
|
|
|
|
|
elif type == "object" and ((.servers? | type) == "array") then .servers
|
|
|
|
|
|
elif type == "object" and ((.items? | type) == "array") then .items
|
|
|
|
|
|
elif type == "object" and (has("outbounds") or has("protocol")) then [.]
|
|
|
|
|
|
else [] end;
|
|
|
|
|
|
'
|
|
|
|
|
|
|
|
|
|
|
|
if [[ -t 1 ]]; then
|
|
|
|
|
|
C_RESET=$'\033[0m'
|
|
|
|
|
|
C_BOLD=$'\033[1m'
|
|
|
|
|
|
C_DIM=$'\033[2m'
|
|
|
|
|
|
C_RED=$'\033[31m'
|
|
|
|
|
|
C_GREEN=$'\033[32m'
|
|
|
|
|
|
C_YELLOW=$'\033[33m'
|
|
|
|
|
|
C_BLUE=$'\033[34m'
|
|
|
|
|
|
C_MAGENTA=$'\033[35m'
|
|
|
|
|
|
C_CYAN=$'\033[36m'
|
|
|
|
|
|
C_INV=$'\033[7m'
|
|
|
|
|
|
else
|
|
|
|
|
|
C_RESET=""; C_BOLD=""; C_DIM=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE=""; C_MAGENTA=""; C_CYAN=""; C_INV=""
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
say() { printf '%b\n' "$*"; }
|
|
|
|
|
|
info() { say "${C_CYAN}›${C_RESET} $*"; }
|
|
|
|
|
|
ok() { say "${C_GREEN}✓${C_RESET} $*"; }
|
|
|
|
|
|
warn() { say "${C_YELLOW}!${C_RESET} $*"; }
|
|
|
|
|
|
die() { say "${C_RED}✗${C_RESET} $*" >&2; exit 1; }
|
|
|
|
|
|
|
|
|
|
|
|
usage() {
|
|
|
|
|
|
cat <<EOF_USAGE
|
|
|
|
|
|
${APP_NAME}: selector and service controller for JSON Xray subscriptions
|
|
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
|
${0##*/} run TUI selector
|
|
|
|
|
|
${0##*/} --setup run initial setup again
|
|
|
|
|
|
${0##*/} --refresh force subscription refresh and exit
|
|
|
|
|
|
${0##*/} --help show this help
|
|
|
|
|
|
|
|
|
|
|
|
Files:
|
|
|
|
|
|
${CONFIG_FILE} settings
|
|
|
|
|
|
${SUB_JSON} cached subscription JSON
|
|
|
|
|
|
EOF_USAGE
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
elevate_available() { [[ -n "${ELEVATE_CMD:-}" ]]; }
|
|
|
|
|
|
|
|
|
|
|
|
detect_elevate() {
|
|
|
|
|
|
local cmd
|
|
|
|
|
|
for cmd in doas run0 pkexec sudo; do
|
|
|
|
|
|
if command -v "$cmd" >/dev/null 2>&1; then
|
|
|
|
|
|
ELEVATE_CMD="$cmd"
|
|
|
|
|
|
ELEVATE_NAME="$cmd"
|
|
|
|
|
|
return 0
|
|
|
|
|
|
fi
|
|
|
|
|
|
done
|
|
|
|
|
|
ELEVATE_CMD=""
|
|
|
|
|
|
ELEVATE_NAME=""
|
|
|
|
|
|
return 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
run_elevated() {
|
|
|
|
|
|
if ! elevate_available; then
|
|
|
|
|
|
die "Нет доступного инструмента для повышения привилегий (doas/run0/pkexec/sudo)."
|
|
|
|
|
|
fi
|
|
|
|
|
|
"$ELEVATE_CMD" "$@"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
check_deps() {
|
|
|
|
|
|
local missing=() cmd
|
|
|
|
|
|
for cmd in bash curl jq systemctl date stat mktemp sha256sum cmp install dirname basename cut mkdir chmod cp cat printf; do
|
|
|
|
|
|
command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
|
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
|
|
if ((${#missing[@]})); then
|
|
|
|
|
|
say "${C_RED}Не хватает зависимостей:${C_RESET} ${missing[*]}"
|
|
|
|
|
|
say "Для Arch Linux обычно достаточно:"
|
|
|
|
|
|
say " sudo pacman -S --needed bash curl jq coreutils systemd"
|
|
|
|
|
|
say ""
|
|
|
|
|
|
say "Если конфиг Xray или служба требуют root-прав, понадобится один из:"
|
|
|
|
|
|
say " doas, run0, pkexec, sudo"
|
|
|
|
|
|
exit 1
|
|
|
|
|
|
fi
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ensure_state_dir() {
|
|
|
|
|
|
mkdir -p "$STATE_DIR" "$BACKUP_DIR"
|
|
|
|
|
|
chmod 700 "$STATE_DIR" "$BACKUP_DIR" 2>/dev/null || true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
expand_path() {
|
|
|
|
|
|
local p="$1"
|
|
|
|
|
|
[[ "$p" == "~" ]] && p="$HOME"
|
|
|
|
|
|
[[ "$p" == "~/"* ]] && p="${HOME}/${p#~/}"
|
|
|
|
|
|
printf '%s\n' "$p"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg_exists() {
|
|
|
|
|
|
[[ -s "$CONFIG_FILE" ]] && jq -e 'type == "object" and (.sub_url // "") != "" and (.xray_config // "") != "" and (.service // "") != ""' "$CONFIG_FILE" >/dev/null 2>&1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg_get() {
|
|
|
|
|
|
local key="$1"
|
|
|
|
|
|
jq -r --arg key "$key" '.[$key] // empty' "$CONFIG_FILE" 2>/dev/null || true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg_set() {
|
|
|
|
|
|
local key="$1" value="$2" tmp
|
|
|
|
|
|
tmp="$(mktemp)"
|
|
|
|
|
|
jq --arg key "$key" --arg value "$value" '.[$key] = $value' "$CONFIG_FILE" >"$tmp"
|
|
|
|
|
|
install -m 600 "$tmp" "$CONFIG_FILE"
|
|
|
|
|
|
rm -f "$tmp"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
write_cfg() {
|
|
|
|
|
|
local sub_url="$1" xray_config="$2" service="$3" tmp
|
|
|
|
|
|
tmp="$(mktemp)"
|
|
|
|
|
|
jq -n \
|
|
|
|
|
|
--arg sub_url "$sub_url" \
|
|
|
|
|
|
--arg xray_config "$xray_config" \
|
|
|
|
|
|
--arg service "$service" \
|
2026-06-15 15:43:01 +03:00
|
|
|
|
'{sub_url:$sub_url, xray_config:$xray_config, service:$service, selected_index:"", selected_fp:"", selected_name:""}' >"$tmp"
|
2026-06-15 15:38:03 +03:00
|
|
|
|
install -m 600 "$tmp" "$CONFIG_FILE"
|
|
|
|
|
|
rm -f "$tmp"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
profile_count_file() {
|
|
|
|
|
|
local file="$1"
|
|
|
|
|
|
jq -r "${PROFILES_DEF} profiles | length" "$file"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
profile_count() {
|
|
|
|
|
|
profile_count_file "$SUB_JSON"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
validate_subscription_file() {
|
|
|
|
|
|
local file="$1" count
|
|
|
|
|
|
jq -e . "$file" >/dev/null || return 1
|
|
|
|
|
|
count="$(profile_count_file "$file")" || return 1
|
|
|
|
|
|
[[ "$count" =~ ^[0-9]+$ ]] && (( count > 0 ))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fetch_subscription() {
|
|
|
|
|
|
local url="$1" tmp count
|
|
|
|
|
|
tmp="$(mktemp)"
|
|
|
|
|
|
|
|
|
|
|
|
info "Скачиваю подписку…"
|
|
|
|
|
|
if ! curl -fLsS --connect-timeout 10 --max-time 45 --retry 2 --retry-delay 1 \
|
|
|
|
|
|
-H 'Accept: application/json, */*' \
|
2026-06-15 15:43:01 +03:00
|
|
|
|
-A 'xtui/1.2' \
|
2026-06-15 15:38:03 +03:00
|
|
|
|
"$url" -o "$tmp"; then
|
|
|
|
|
|
rm -f "$tmp"
|
|
|
|
|
|
return 1
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
if ! validate_subscription_file "$tmp"; then
|
|
|
|
|
|
rm -f "$tmp"
|
|
|
|
|
|
return 1
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
install -m 600 "$tmp" "$SUB_JSON"
|
|
|
|
|
|
rm -f "$tmp"
|
|
|
|
|
|
count="$(profile_count)"
|
|
|
|
|
|
ok "Подписка валидна, профилей: ${count}"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
maybe_refresh_subscription() {
|
|
|
|
|
|
local url="$1" now mtime age
|
|
|
|
|
|
if [[ ! -s "$SUB_JSON" ]]; then
|
|
|
|
|
|
fetch_subscription "$url" || die "Не удалось скачать и валидировать подписку."
|
|
|
|
|
|
return
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
now="$(date +%s)"
|
|
|
|
|
|
mtime="$(stat -c %Y "$SUB_JSON")"
|
|
|
|
|
|
age=$(( now - mtime ))
|
|
|
|
|
|
if (( age > CACHE_TTL_SECONDS )); then
|
|
|
|
|
|
fetch_subscription "$url" || warn "Не удалось обновить подписку; оставляю старый кэш."
|
|
|
|
|
|
fi
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
normalize_service_name() {
|
|
|
|
|
|
local svc="$1"
|
|
|
|
|
|
svc="${svc%.service}.service"
|
|
|
|
|
|
printf '%s\n' "$svc"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
validate_service() {
|
|
|
|
|
|
local svc="$1"
|
|
|
|
|
|
systemctl --no-ask-password cat "$svc" >/dev/null 2>&1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ensure_target_parent() {
|
|
|
|
|
|
local path="$1" dir
|
|
|
|
|
|
dir="$(dirname "$path")"
|
|
|
|
|
|
[[ -d "$dir" ]] && return 0
|
|
|
|
|
|
|
|
|
|
|
|
if mkdir -p "$dir" 2>/dev/null; then
|
|
|
|
|
|
return 0
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
elevate_available || return 1
|
|
|
|
|
|
run_elevated mkdir -p "$dir"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
validate_target_path() {
|
|
|
|
|
|
local path="$1" dir
|
|
|
|
|
|
[[ -n "$path" ]] || return 1
|
|
|
|
|
|
[[ "$path" = /* ]] || return 1
|
|
|
|
|
|
|
|
|
|
|
|
ensure_target_parent "$path" || return 1
|
|
|
|
|
|
dir="$(dirname "$path")"
|
|
|
|
|
|
|
|
|
|
|
|
if [[ -e "$path" ]]; then
|
|
|
|
|
|
[[ -f "$path" ]] || return 1
|
|
|
|
|
|
if [[ -w "$path" ]]; then
|
|
|
|
|
|
return 0
|
|
|
|
|
|
fi
|
|
|
|
|
|
elevate_available && run_elevated test -w "$path"
|
|
|
|
|
|
return $?
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
if [[ -w "$dir" ]]; then
|
|
|
|
|
|
return 0
|
|
|
|
|
|
fi
|
|
|
|
|
|
elevate_available && run_elevated test -w "$dir"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
prompt_nonempty() {
|
|
|
|
|
|
local prompt="$1" default="${2:-}" value
|
|
|
|
|
|
while true; do
|
|
|
|
|
|
if [[ -n "$default" ]]; then
|
|
|
|
|
|
read -r -p "${prompt} [${default}]: " value
|
|
|
|
|
|
value="${value:-$default}"
|
|
|
|
|
|
else
|
|
|
|
|
|
read -r -p "${prompt}: " value
|
|
|
|
|
|
fi
|
|
|
|
|
|
[[ -n "$value" ]] && { printf '%s\n' "$value"; return 0; }
|
|
|
|
|
|
done
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
wizard() {
|
|
|
|
|
|
ensure_state_dir
|
|
|
|
|
|
say "${C_BOLD}${C_MAGENTA}Первоначальная настройка ${APP_NAME}${C_RESET}"
|
|
|
|
|
|
say ""
|
|
|
|
|
|
|
|
|
|
|
|
local sub_url xray_config service
|
|
|
|
|
|
while true; do
|
|
|
|
|
|
sub_url="$(prompt_nonempty 'Ссылка на JSON-подписку')"
|
|
|
|
|
|
if [[ ! "$sub_url" =~ ^https?:// ]]; then
|
|
|
|
|
|
warn "Нужна http(s)-ссылка."
|
|
|
|
|
|
continue
|
|
|
|
|
|
fi
|
|
|
|
|
|
if fetch_subscription "$sub_url"; then
|
|
|
|
|
|
break
|
|
|
|
|
|
fi
|
|
|
|
|
|
warn "Не удалось скачать или распознать JSON-подписку. Проверьте URL."
|
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
|
|
while true; do
|
|
|
|
|
|
xray_config="$(prompt_nonempty 'Путь к JSON-конфигу Xray для выбранного сервера' "$DEFAULT_XRAY_CONFIG")"
|
|
|
|
|
|
xray_config="$(expand_path "$xray_config")"
|
|
|
|
|
|
if validate_target_path "$xray_config"; then
|
|
|
|
|
|
ok "Путь доступен для записи: ${xray_config}"
|
|
|
|
|
|
break
|
|
|
|
|
|
fi
|
|
|
|
|
|
warn "Нет прав записи или путь некорректен. Укажите другой путь или настройте sudo."
|
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
|
|
while true; do
|
|
|
|
|
|
service="$(prompt_nonempty 'Название systemd-службы Xray' "$DEFAULT_XRAY_SERVICE")"
|
|
|
|
|
|
service="$(normalize_service_name "$service")"
|
|
|
|
|
|
if validate_service "$service"; then
|
|
|
|
|
|
ok "Служба найдена: ${service}"
|
|
|
|
|
|
break
|
|
|
|
|
|
fi
|
|
|
|
|
|
warn "Служба ${service} не найдена через systemctl cat."
|
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
|
|
write_cfg "$sub_url" "$xray_config" "$service"
|
|
|
|
|
|
ok "Настройки сохранены в ${CONFIG_FILE}"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
profile_summary() {
|
|
|
|
|
|
local idx="$1"
|
|
|
|
|
|
jq -r --argjson i "$idx" "${PROFILES_DEF}"'
|
|
|
|
|
|
def outbounds($x):
|
|
|
|
|
|
if (($x.outbounds? | type) == "array") then $x.outbounds else [$x] end;
|
|
|
|
|
|
def addr($o):
|
|
|
|
|
|
$o.settings.vnext[0].address? //
|
|
|
|
|
|
$o.settings.servers[0].address? //
|
|
|
|
|
|
$o.settings.peers[0].endpoint? //
|
|
|
|
|
|
$o.address? // $o.server? // "";
|
|
|
|
|
|
def port($o):
|
|
|
|
|
|
$o.settings.vnext[0].port? //
|
|
|
|
|
|
$o.settings.servers[0].port? //
|
|
|
|
|
|
$o.port? // "";
|
|
|
|
|
|
profiles
|
|
|
|
|
|
| .[$i] as $p
|
|
|
|
|
|
| (outbounds($p)
|
|
|
|
|
|
| map(select(((.protocol? // "") != "freedom") and ((.protocol? // "") != "blackhole")))
|
|
|
|
|
|
| .[0] // {}) as $o
|
|
|
|
|
|
| ($p.remarks? // $p.remark? // $p.name? // $p.ps? // $p.tag? // $o.tag? // ("Профиль " + (($i + 1) | tostring))) as $name
|
|
|
|
|
|
| [$name, ($o.protocol? // "?"), (addr($o)), ((port($o)) | tostring)]
|
|
|
|
|
|
| @tsv
|
|
|
|
|
|
' "$SUB_JSON"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
profile_fingerprint() {
|
|
|
|
|
|
local idx="$1"
|
|
|
|
|
|
jq -c --argjson i "$idx" "${PROFILES_DEF}"'
|
|
|
|
|
|
profiles
|
|
|
|
|
|
| .[$i]
|
|
|
|
|
|
| {remarks:(.remarks? // .remark? // .name? // .ps? // null), outbounds:(.outbounds? // [.])}
|
|
|
|
|
|
' "$SUB_JSON" | sha256sum | cut -d ' ' -f1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
find_saved_index() {
|
2026-06-15 15:43:01 +03:00
|
|
|
|
local saved_index saved_fp count i fp
|
|
|
|
|
|
saved_index="$(cfg_get selected_index)"
|
2026-06-15 15:38:03 +03:00
|
|
|
|
saved_fp="$(cfg_get selected_fp)"
|
|
|
|
|
|
count="$(profile_count)"
|
|
|
|
|
|
|
2026-06-15 15:43:01 +03:00
|
|
|
|
if [[ "$saved_index" =~ ^[0-9]+$ ]] && (( saved_index < count )); then
|
|
|
|
|
|
echo "$saved_index"
|
|
|
|
|
|
return
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
if [[ -n "$saved_fp" ]]; then
|
|
|
|
|
|
for ((i=0; i<count; i++)); do
|
|
|
|
|
|
fp="$(profile_fingerprint "$i")"
|
|
|
|
|
|
if [[ "$fp" == "$saved_fp" ]]; then
|
|
|
|
|
|
cfg_set selected_index "$i"
|
|
|
|
|
|
echo "$i"
|
|
|
|
|
|
return
|
|
|
|
|
|
fi
|
|
|
|
|
|
done
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
2026-06-15 15:38:03 +03:00
|
|
|
|
echo 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:43:01 +03:00
|
|
|
|
migrate_saved_index() {
|
|
|
|
|
|
[[ -s "$SUB_JSON" ]] || return 0
|
|
|
|
|
|
validate_subscription_file "$SUB_JSON" || return 0
|
|
|
|
|
|
find_saved_index >/dev/null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:38:03 +03:00
|
|
|
|
build_selected_config() {
|
|
|
|
|
|
local idx="$1" out="$2"
|
|
|
|
|
|
jq -e --argjson i "$idx" "${PROFILES_DEF}"'
|
|
|
|
|
|
def allowed_top:
|
|
|
|
|
|
["log", "api", "dns", "routing", "policy", "inbounds", "outbounds", "transport", "stats", "reverse", "fakedns", "metrics", "observatory", "burstObservatory", "geodata", "version", "browserForwarder"];
|
|
|
|
|
|
def clean_full:
|
|
|
|
|
|
with_entries(select(.key as $k | allowed_top | index($k)));
|
|
|
|
|
|
def clean_outbound:
|
|
|
|
|
|
del(.remarks, .remark, .name, .ps) + {tag:(.tag // "proxy")};
|
|
|
|
|
|
profiles
|
|
|
|
|
|
| .[$i] as $p
|
|
|
|
|
|
| if (($p | type) != "object") then
|
|
|
|
|
|
error("selected profile is not an object")
|
|
|
|
|
|
elif ($p | has("outbounds")) then
|
|
|
|
|
|
($p | clean_full)
|
|
|
|
|
|
elif ($p | has("protocol")) then
|
|
|
|
|
|
{
|
|
|
|
|
|
log: {loglevel: "warning"},
|
|
|
|
|
|
inbounds: [
|
|
|
|
|
|
{listen: "127.0.0.1", port: 10808, protocol: "socks", settings: {udp: true}},
|
|
|
|
|
|
{listen: "127.0.0.1", port: 10809, protocol: "http"}
|
|
|
|
|
|
],
|
|
|
|
|
|
outbounds: [
|
|
|
|
|
|
($p | clean_outbound),
|
|
|
|
|
|
{protocol: "freedom", tag: "direct"},
|
|
|
|
|
|
{protocol: "blackhole", tag: "block"}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
error("selected profile has neither outbounds nor protocol")
|
|
|
|
|
|
end
|
|
|
|
|
|
| if ((.outbounds | type) == "array" and (.outbounds | length) > 0) then . else error("resulting Xray config has no outbounds") end
|
|
|
|
|
|
' "$SUB_JSON" >"$out"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
install_xray_config() {
|
|
|
|
|
|
local src="$1" dest="$2" dir
|
|
|
|
|
|
ensure_target_parent "$dest" || die "Не удалось создать каталог для ${dest}"
|
|
|
|
|
|
|
|
|
|
|
|
if [[ -e "$dest" ]]; then
|
|
|
|
|
|
if [[ -w "$dest" ]]; then
|
|
|
|
|
|
install -m 0644 "$src" "$dest"
|
|
|
|
|
|
else
|
|
|
|
|
|
elevate_available || die "Нет прав записи в ${dest}, не найден doas/run0/pkexec/sudo."
|
|
|
|
|
|
run_elevated install -m 0644 "$src" "$dest"
|
|
|
|
|
|
fi
|
|
|
|
|
|
else
|
|
|
|
|
|
dir="$(dirname "$dest")"
|
|
|
|
|
|
if [[ -w "$dir" ]]; then
|
|
|
|
|
|
install -m 0644 "$src" "$dest"
|
|
|
|
|
|
else
|
|
|
|
|
|
elevate_available || die "Нет прав записи в ${dir}, не найден doas/run0/pkexec/sudo."
|
|
|
|
|
|
run_elevated install -m 0644 "$src" "$dest"
|
|
|
|
|
|
fi
|
|
|
|
|
|
fi
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
restart_xray_service() {
|
|
|
|
|
|
local service="$1"
|
|
|
|
|
|
info "Перезапускаю ${service}…"
|
|
|
|
|
|
if systemctl --no-ask-password restart "$service" 2>/dev/null; then
|
|
|
|
|
|
ok "Служба ${service} перезапущена."
|
|
|
|
|
|
return 0
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
elevate_available || die "Не удалось перезапустить ${service}: нет прав и не найден doas/run0/pkexec/sudo."
|
|
|
|
|
|
run_elevated systemctl --no-ask-password restart "$service"
|
|
|
|
|
|
ok "Служба ${service} перезапущена."
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
apply_selection() {
|
2026-06-15 15:43:01 +03:00
|
|
|
|
local idx="$1" dest service tmp fp old_index name proto addr port changed=1 service_state
|
2026-06-15 15:38:03 +03:00
|
|
|
|
dest="$(cfg_get xray_config)"
|
|
|
|
|
|
service="$(cfg_get service)"
|
2026-06-15 15:43:01 +03:00
|
|
|
|
old_index="$(cfg_get selected_index)"
|
2026-06-15 15:38:03 +03:00
|
|
|
|
fp="$(profile_fingerprint "$idx")"
|
|
|
|
|
|
IFS=$'\t' read -r name proto addr port < <(profile_summary "$idx")
|
|
|
|
|
|
|
|
|
|
|
|
tmp="$(mktemp)"
|
|
|
|
|
|
if ! build_selected_config "$idx" "$tmp"; then
|
|
|
|
|
|
rm -f "$tmp"
|
|
|
|
|
|
die "Не удалось извлечь выбранный профиль в формат Xray config JSON."
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
jq -e . "$tmp" >/dev/null || { rm -f "$tmp"; die "Сгенерированный JSON невалиден."; }
|
|
|
|
|
|
|
|
|
|
|
|
if [[ -r "$dest" ]] && cmp -s "$tmp" "$dest"; then
|
|
|
|
|
|
changed=0
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
if (( changed )); then
|
|
|
|
|
|
install_xray_config "$tmp" "$dest"
|
|
|
|
|
|
ok "Выбранный сервер записан в ${dest}: ${name}"
|
|
|
|
|
|
else
|
|
|
|
|
|
ok "В ${dest} уже записан этот конфиг: ${name}"
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
2026-06-15 15:43:01 +03:00
|
|
|
|
cfg_set selected_index "$idx"
|
2026-06-15 15:38:03 +03:00
|
|
|
|
cfg_set selected_fp "$fp"
|
|
|
|
|
|
cfg_set selected_name "$name"
|
|
|
|
|
|
rm -f "$tmp"
|
|
|
|
|
|
|
|
|
|
|
|
service_state="$(service_state_raw "$service")"
|
2026-06-15 15:43:01 +03:00
|
|
|
|
if (( changed )) || [[ "$old_index" != "$idx" ]]; then
|
2026-06-15 15:38:03 +03:00
|
|
|
|
if [[ "$service_state" == "active" || "$service_state" == "activating" ]]; then
|
|
|
|
|
|
restart_xray_service "$service"
|
|
|
|
|
|
else
|
|
|
|
|
|
info "Служба ${service} не запущена, перезапуск не требуется."
|
|
|
|
|
|
fi
|
|
|
|
|
|
else
|
|
|
|
|
|
info "Сервер не изменился, службу не перезапускаю."
|
|
|
|
|
|
fi
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
clear_screen() { printf '\033[H\033[2J'; }
|
|
|
|
|
|
hide_cursor() { [[ -t 1 ]] && printf '\033[?25l' || true; }
|
|
|
|
|
|
show_cursor() { [[ -t 1 ]] && printf '\033[?25h' || true; }
|
|
|
|
|
|
goto_col() { printf '\033[%dG' "$1"; }
|
|
|
|
|
|
|
|
|
|
|
|
format_epoch() {
|
|
|
|
|
|
local epoch="${1:-0}"
|
|
|
|
|
|
if [[ "$epoch" =~ ^[0-9]+$ ]] && (( epoch > 0 )); then
|
|
|
|
|
|
date -d "@${epoch}" '+%Y-%m-%d %H:%M:%S %Z'
|
|
|
|
|
|
else
|
|
|
|
|
|
printf 'никогда'
|
|
|
|
|
|
fi
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
format_age() {
|
|
|
|
|
|
local seconds="${1:-0}" d h m
|
|
|
|
|
|
(( seconds < 0 )) && seconds=0
|
|
|
|
|
|
d=$(( seconds / 86400 ))
|
|
|
|
|
|
h=$(( (seconds % 86400) / 3600 ))
|
|
|
|
|
|
m=$(( (seconds % 3600) / 60 ))
|
|
|
|
|
|
|
|
|
|
|
|
if (( d > 0 )); then
|
|
|
|
|
|
printf '%d д %d ч назад' "$d" "$h"
|
|
|
|
|
|
elif (( h > 0 )); then
|
|
|
|
|
|
printf '%d ч %d мин назад' "$h" "$m"
|
|
|
|
|
|
elif (( m > 0 )); then
|
|
|
|
|
|
printf '%d мин назад' "$m"
|
|
|
|
|
|
else
|
|
|
|
|
|
printf 'только что'
|
|
|
|
|
|
fi
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
subscription_update_info() {
|
|
|
|
|
|
local mtime now age
|
|
|
|
|
|
if [[ -s "$SUB_JSON" ]]; then
|
|
|
|
|
|
mtime="$(stat -c %Y "$SUB_JSON" 2>/dev/null || echo 0)"
|
|
|
|
|
|
now="$(date +%s)"
|
|
|
|
|
|
age=$(( now - mtime ))
|
|
|
|
|
|
printf '%s • %s' "$(format_epoch "$mtime")" "$(format_age "$age")"
|
|
|
|
|
|
else
|
|
|
|
|
|
printf 'никогда'
|
|
|
|
|
|
fi
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
xray_socks_endpoint() {
|
|
|
|
|
|
local config="$1"
|
|
|
|
|
|
if [[ ! -r "$config" ]]; then
|
|
|
|
|
|
printf 'недоступен'
|
|
|
|
|
|
return
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
jq -r '
|
|
|
|
|
|
first(
|
|
|
|
|
|
.inbounds[]?
|
|
|
|
|
|
| select((.protocol? // "") == "socks")
|
|
|
|
|
|
| [(.listen? // "0.0.0.0"), (.port? // empty)]
|
|
|
|
|
|
| select(.[1] != null and .[1] != "")
|
|
|
|
|
|
| "\(.[0]):\(.[1])"
|
|
|
|
|
|
) // "не найден"
|
|
|
|
|
|
' "$config" 2>/dev/null || printf 'не найден'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
service_state_raw() {
|
|
|
|
|
|
local service="$1"
|
|
|
|
|
|
systemctl --no-ask-password is-active "$service" 2>/dev/null || true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
service_enabled_raw() {
|
|
|
|
|
|
local service="$1"
|
|
|
|
|
|
systemctl --no-ask-password is-enabled "$service" 2>/dev/null || true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
service_state_colored() {
|
|
|
|
|
|
local state="$1"
|
|
|
|
|
|
case "$state" in
|
|
|
|
|
|
active) printf '%bactive%b' "$C_GREEN" "$C_RESET" ;;
|
|
|
|
|
|
activating) printf '%bactivating%b' "$C_YELLOW" "$C_RESET" ;;
|
|
|
|
|
|
inactive) printf '%binactive%b' "$C_DIM" "$C_RESET" ;;
|
|
|
|
|
|
failed) printf '%bfailed%b' "$C_RED" "$C_RESET" ;;
|
|
|
|
|
|
deactivating) printf '%bdeactivating%b' "$C_YELLOW" "$C_RESET" ;;
|
|
|
|
|
|
*) printf '%bunknown%b' "$C_RED" "$C_RESET" ;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
service_enabled_colored() {
|
|
|
|
|
|
local enabled="$1"
|
|
|
|
|
|
case "$enabled" in
|
|
|
|
|
|
enabled) printf '%benabled%b' "$C_GREEN" "$C_RESET" ;;
|
|
|
|
|
|
disabled) printf '%bdisabled%b' "$C_YELLOW" "$C_RESET" ;;
|
|
|
|
|
|
static) printf '%bstatic%b' "$C_BLUE" "$C_RESET" ;;
|
|
|
|
|
|
masked) printf '%bmasked%b' "$C_RED" "$C_RESET" ;;
|
|
|
|
|
|
*) printf '%b%s%b' "$C_DIM" "${enabled:-unknown}" "$C_RESET" ;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pause_screen() {
|
|
|
|
|
|
say ""
|
|
|
|
|
|
read -rsn1 -p "Нажмите любую клавишу, чтобы вернуться в меню…" _ || true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
run_service_action() {
|
|
|
|
|
|
local action="$1" service="$2"
|
|
|
|
|
|
show_cursor
|
|
|
|
|
|
say ""
|
|
|
|
|
|
info "systemctl ${action} ${service}"
|
|
|
|
|
|
|
|
|
|
|
|
if systemctl --no-ask-password "$action" "$service" 2>/dev/null; then
|
|
|
|
|
|
ok "Готово: ${service} → ${action}."
|
|
|
|
|
|
pause_screen
|
|
|
|
|
|
hide_cursor
|
|
|
|
|
|
return 0
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
elevate_available || {
|
|
|
|
|
|
warn "Не удалось выполнить действие без root-прав, а doas/run0/pkexec/sudo не найдены."
|
|
|
|
|
|
pause_screen
|
|
|
|
|
|
hide_cursor
|
|
|
|
|
|
return 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if run_elevated systemctl --no-ask-password "$action" "$service"; then
|
|
|
|
|
|
ok "Готово: ${service} → ${action}."
|
|
|
|
|
|
else
|
|
|
|
|
|
warn "systemctl ${action} завершился с ошибкой. Проверьте статус службы."
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
pause_screen
|
|
|
|
|
|
hide_cursor
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
show_service_status_screen() {
|
|
|
|
|
|
local service="$1"
|
|
|
|
|
|
show_cursor
|
|
|
|
|
|
clear_screen
|
|
|
|
|
|
say "${C_BOLD}${C_MAGENTA}✦ Статус службы ${service}${C_RESET}"
|
|
|
|
|
|
say ""
|
|
|
|
|
|
if ! systemctl --no-ask-password --no-pager --full status "$service" -n 18; then
|
|
|
|
|
|
warn "Не удалось прочитать статус через systemctl."
|
|
|
|
|
|
fi
|
|
|
|
|
|
pause_screen
|
|
|
|
|
|
hide_cursor
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
shorten() {
|
|
|
|
|
|
local s="$1" max="$2"
|
|
|
|
|
|
if ((${#s} > max)); then
|
|
|
|
|
|
printf '%s…' "${s:0:max-1}"
|
|
|
|
|
|
else
|
|
|
|
|
|
printf '%s' "$s"
|
|
|
|
|
|
fi
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
render_table_header() {
|
|
|
|
|
|
printf '%b' "$C_DIM"
|
|
|
|
|
|
printf ' № Сервер'
|
|
|
|
|
|
goto_col 46; printf 'Протокол'
|
|
|
|
|
|
goto_col 58; printf 'Адрес'
|
|
|
|
|
|
goto_col 79; printf 'Порт'
|
|
|
|
|
|
printf '%b\n' "$C_RESET"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
render_profile_row() {
|
2026-06-15 15:43:01 +03:00
|
|
|
|
local idx="$1" cursor="$2" saved_index="$3"
|
|
|
|
|
|
local name proto addr port pointer mark row_style name_short addr_short
|
2026-06-15 15:38:03 +03:00
|
|
|
|
|
|
|
|
|
|
IFS=$'\t' read -r name proto addr port < <(profile_summary "$idx")
|
|
|
|
|
|
|
|
|
|
|
|
pointer=" "
|
|
|
|
|
|
mark=" "
|
|
|
|
|
|
row_style=""
|
|
|
|
|
|
[[ "$idx" -eq "$cursor" ]] && { pointer="➤"; row_style="$C_INV"; }
|
2026-06-15 15:43:01 +03:00
|
|
|
|
[[ "$saved_index" =~ ^[0-9]+$ && "$idx" -eq "$saved_index" ]] && mark="✓"
|
2026-06-15 15:38:03 +03:00
|
|
|
|
|
|
|
|
|
|
name_short="$(shorten "$name" 31)"
|
|
|
|
|
|
addr_short="$(shorten "$addr" 20)"
|
|
|
|
|
|
|
|
|
|
|
|
printf '%b' "$row_style"
|
|
|
|
|
|
printf '%b%-2s%b %b%-2s%b %2d. %s' "$C_CYAN" "$pointer" "$C_RESET$row_style" "$C_GREEN" "$mark" "$C_RESET$row_style" "$((idx + 1))" "$name_short"
|
|
|
|
|
|
goto_col 46; printf '%b%s%b' "$C_BLUE" "$proto" "$C_RESET$row_style"
|
|
|
|
|
|
goto_col 58; printf '%b%s%b' "$C_DIM" "$addr_short" "$C_RESET$row_style"
|
|
|
|
|
|
goto_col 79; printf '%b%s%b' "$C_DIM" "$port" "$C_RESET$row_style"
|
|
|
|
|
|
printf '%b\n' "$C_RESET"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
render_menu() {
|
2026-06-15 15:43:01 +03:00
|
|
|
|
local cursor="$1" count saved_index i cache_info selected_name
|
2026-06-15 15:38:03 +03:00
|
|
|
|
local service service_state service_enabled xray_config socks_endpoint
|
|
|
|
|
|
|
|
|
|
|
|
count="$(profile_count)"
|
2026-06-15 15:43:01 +03:00
|
|
|
|
saved_index="$(cfg_get selected_index)"
|
2026-06-15 15:38:03 +03:00
|
|
|
|
selected_name="$(cfg_get selected_name)"
|
|
|
|
|
|
service="$(cfg_get service)"
|
|
|
|
|
|
xray_config="$(cfg_get xray_config)"
|
|
|
|
|
|
socks_endpoint="$(xray_socks_endpoint "$xray_config")"
|
|
|
|
|
|
cache_info="$(subscription_update_info)"
|
|
|
|
|
|
service_state="$(service_state_raw "$service")"
|
|
|
|
|
|
service_enabled="$(service_enabled_raw "$service")"
|
|
|
|
|
|
|
|
|
|
|
|
clear_screen
|
|
|
|
|
|
say "${C_BOLD}${C_MAGENTA}✦ Xray JSON TUI${C_RESET}"
|
|
|
|
|
|
say "${C_DIM}Подписка:${C_RESET} ${SUB_JSON}"
|
|
|
|
|
|
say "${C_DIM}Последнее обновление:${C_RESET} ${C_CYAN}${cache_info}${C_RESET}"
|
|
|
|
|
|
say "${C_DIM}Конфиг Xray:${C_RESET} ${xray_config}"
|
|
|
|
|
|
say "${C_DIM}SOCKS:${C_RESET} ${C_GREEN}${socks_endpoint}${C_RESET}"
|
|
|
|
|
|
say "${C_DIM}Служба:${C_RESET} ${service} • состояние: $(service_state_colored "$service_state") • автозапуск: $(service_enabled_colored "$service_enabled")"
|
|
|
|
|
|
say "${C_DIM}Текущий выбор:${C_RESET} ${selected_name:-нет}"
|
|
|
|
|
|
say ""
|
|
|
|
|
|
say "${C_CYAN}↑/↓${C_RESET} или ${C_CYAN}j/k${C_RESET} — навигация • ${C_GREEN}Enter${C_RESET} — выбрать сервер • ${C_YELLOW}r${C_RESET} — обновить подписку • ${C_MAGENTA}q${C_RESET} — выйти"
|
|
|
|
|
|
say ""
|
|
|
|
|
|
say "${C_BOLD}${C_MAGENTA}Управление ядром${C_RESET}"
|
|
|
|
|
|
say "${C_GREEN}s${C_RESET} — start • ${C_RED}o${C_RESET} — stop • ${C_YELLOW}e${C_RESET} — restart • ${C_BLUE}v${C_RESET} — status службы"
|
|
|
|
|
|
say ""
|
|
|
|
|
|
|
|
|
|
|
|
render_table_header
|
|
|
|
|
|
for ((i=0; i<count; i++)); do
|
2026-06-15 15:43:01 +03:00
|
|
|
|
render_profile_row "$i" "$cursor" "$saved_index"
|
2026-06-15 15:38:03 +03:00
|
|
|
|
done
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
read_key() {
|
|
|
|
|
|
local key ch
|
|
|
|
|
|
IFS= read -rsn1 key || return 1
|
|
|
|
|
|
|
|
|
|
|
|
# Arrow keys may arrive as ESC [ B, ESC O B, or longer CSI sequences.
|
|
|
|
|
|
# Read a short burst after ESC so Down works reliably even in application cursor mode.
|
|
|
|
|
|
if [[ "$key" == $'\e' ]]; then
|
|
|
|
|
|
while IFS= read -rsn1 -t 0.08 ch; do
|
|
|
|
|
|
key+="$ch"
|
|
|
|
|
|
((${#key} >= 8)) && break
|
|
|
|
|
|
done
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
printf '%s' "$key"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
menu() {
|
|
|
|
|
|
local count cursor key sub_url service
|
|
|
|
|
|
count="$(profile_count)"
|
|
|
|
|
|
(( count > 0 )) || die "В подписке нет профилей."
|
|
|
|
|
|
cursor="$(find_saved_index)"
|
|
|
|
|
|
sub_url="$(cfg_get sub_url)"
|
|
|
|
|
|
service="$(cfg_get service)"
|
|
|
|
|
|
|
|
|
|
|
|
hide_cursor
|
|
|
|
|
|
trap 'show_cursor' EXIT
|
|
|
|
|
|
|
|
|
|
|
|
while true; do
|
|
|
|
|
|
render_menu "$cursor"
|
|
|
|
|
|
key="$(read_key || true)"
|
|
|
|
|
|
case "$key" in
|
|
|
|
|
|
$'\e[A'|$'\eOA'|$'\e['*A|k|K)
|
|
|
|
|
|
if (( cursor > 0 )); then
|
|
|
|
|
|
cursor=$((cursor - 1))
|
|
|
|
|
|
else
|
|
|
|
|
|
cursor=$((count - 1))
|
|
|
|
|
|
fi
|
|
|
|
|
|
;;
|
|
|
|
|
|
$'\e[B'|$'\eOB'|$'\e['*B|j|J)
|
|
|
|
|
|
if (( cursor < count - 1 )); then
|
|
|
|
|
|
cursor=$((cursor + 1))
|
|
|
|
|
|
else
|
|
|
|
|
|
cursor=0
|
|
|
|
|
|
fi
|
|
|
|
|
|
;;
|
|
|
|
|
|
r|R)
|
|
|
|
|
|
show_cursor
|
|
|
|
|
|
say ""
|
|
|
|
|
|
fetch_subscription "$sub_url" || warn "Обновление не удалось."
|
|
|
|
|
|
count="$(profile_count)"
|
|
|
|
|
|
(( cursor >= count )) && cursor=$((count - 1))
|
|
|
|
|
|
pause_screen
|
|
|
|
|
|
hide_cursor
|
|
|
|
|
|
;;
|
|
|
|
|
|
s|S)
|
|
|
|
|
|
run_service_action start "$service"
|
|
|
|
|
|
;;
|
|
|
|
|
|
o|O)
|
|
|
|
|
|
run_service_action stop "$service"
|
|
|
|
|
|
;;
|
|
|
|
|
|
e|E)
|
|
|
|
|
|
run_service_action restart "$service"
|
|
|
|
|
|
;;
|
|
|
|
|
|
v|V)
|
|
|
|
|
|
show_service_status_screen "$service"
|
|
|
|
|
|
;;
|
|
|
|
|
|
q|Q)
|
|
|
|
|
|
clear_screen
|
|
|
|
|
|
show_cursor
|
|
|
|
|
|
exit 0
|
|
|
|
|
|
;;
|
|
|
|
|
|
""|$'\n'|$'\r')
|
|
|
|
|
|
show_cursor
|
|
|
|
|
|
say ""
|
|
|
|
|
|
apply_selection "$cursor"
|
|
|
|
|
|
pause_screen
|
|
|
|
|
|
hide_cursor
|
|
|
|
|
|
;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
done
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
main() {
|
|
|
|
|
|
check_deps
|
|
|
|
|
|
ensure_state_dir
|
|
|
|
|
|
detect_elevate
|
|
|
|
|
|
|
|
|
|
|
|
case "${1:-}" in
|
|
|
|
|
|
--help|-h)
|
|
|
|
|
|
usage
|
|
|
|
|
|
exit 0
|
|
|
|
|
|
;;
|
|
|
|
|
|
--setup)
|
|
|
|
|
|
wizard
|
|
|
|
|
|
;;
|
|
|
|
|
|
--refresh)
|
|
|
|
|
|
cfg_exists || wizard
|
2026-06-15 15:43:01 +03:00
|
|
|
|
migrate_saved_index
|
2026-06-15 15:38:03 +03:00
|
|
|
|
fetch_subscription "$(cfg_get sub_url)" || die "Не удалось обновить подписку."
|
|
|
|
|
|
exit 0
|
|
|
|
|
|
;;
|
|
|
|
|
|
"")
|
|
|
|
|
|
cfg_exists || wizard
|
|
|
|
|
|
;;
|
|
|
|
|
|
*)
|
|
|
|
|
|
usage >&2
|
|
|
|
|
|
exit 2
|
|
|
|
|
|
;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
|
2026-06-15 15:43:01 +03:00
|
|
|
|
migrate_saved_index
|
2026-06-15 15:38:03 +03:00
|
|
|
|
maybe_refresh_subscription "$(cfg_get sub_url)"
|
|
|
|
|
|
validate_subscription_file "$SUB_JSON" || die "Кэш подписки повреждён: ${SUB_JSON}. Запустите ${0##*/} --refresh"
|
|
|
|
|
|
menu
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
main "$@"
|