commit 8c0aa5b8b39d6e18578bfc095b7e536dfb993127 Author: Vyacheslav K Date: Mon Jun 15 15:38:03 2026 +0300 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3235dc --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# xray-sub + +Небольшой TUI-скрипт для выбора сервера из JSON-подписки Xray. + +## Установка на Arch Linux + +Установите зависимости: + +```bash +sudo pacman -S --needed bash curl jq coreutils systemd +``` + +Для действий с системными файлами нужен один из инструментов повышения прав: `sudo`, `doas`, `run0` или `pkexec`. + +Сделайте скрипт исполняемым: + +```bash +chmod +x main.sh +``` + +По желанию установите как команду: + +```bash +sudo install -m 755 main.sh /usr/local/bin/xray-sub +``` + +## Запуск + +Из папки проекта: + +```bash +./main.sh +``` + +Если установлено в систему: + +```bash +xray-sub +``` + +Первый запуск попросит ссылку на JSON-подписку, путь к конфигу Xray и имя systemd-службы. + +Полезные команды: + +```bash +xray-sub --setup # заново пройти настройку +xray-sub --refresh # обновить подписку +xray-sub --help # справка +``` + +## Что делает + +Скрипт скачивает JSON-подписку, показывает список серверов в терминальном меню, записывает выбранный сервер в конфиг Xray и при необходимости перезапускает службу Xray. + +В меню можно выбрать сервер, обновить подписку, а также запустить, остановить, перезапустить службу или посмотреть её статус. + +## Где хранит файлы + +Пользовательские файлы хранятся в `~/.xray-sub/`: + +- `config` — настройки скрипта и последний выбранный сервер. +- `sub.json` — кэш скачанной подписки. +- `backups/` — папка для резервных файлов. + +Конфиг Xray записывается туда, куда вы указали при настройке. По умолчанию это `/etc/xray/config.json`. diff --git a/main.sh b/main.sh new file mode 100644 index 0000000..c015f13 --- /dev/null +++ b/main.sh @@ -0,0 +1,797 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +APP_NAME="xray-sub" +STATE_DIR="${HOME}/.xray-sub" +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 </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" \ + '{sub_url:$sub_url, xray_config:$xray_config, service:$service, selected_fp:"", selected_name:""}' >"$tmp" + 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, */*' \ + -A 'xray-sub-selector/1.2' \ + "$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() { + local saved_fp count i fp + saved_fp="$(cfg_get selected_fp)" + count="$(profile_count)" + [[ -n "$saved_fp" ]] || { echo 0; return; } + + for ((i=0; i 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() { + local idx="$1" dest service tmp fp old_fp name proto addr port changed=1 service_state + dest="$(cfg_get xray_config)" + service="$(cfg_get service)" + old_fp="$(cfg_get selected_fp)" + 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 + + cfg_set selected_fp "$fp" + cfg_set selected_name "$name" + rm -f "$tmp" + + service_state="$(service_state_raw "$service")" + if (( changed )) || [[ "$old_fp" != "$fp" ]]; then + 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() { + local idx="$1" cursor="$2" saved_fp="$3" + local fp name proto addr port pointer mark row_style name_short addr_short + + IFS=$'\t' read -r name proto addr port < <(profile_summary "$idx") + fp="$(profile_fingerprint "$idx")" + + pointer=" " + mark=" " + row_style="" + [[ "$idx" -eq "$cursor" ]] && { pointer="➤"; row_style="$C_INV"; } + [[ -n "$saved_fp" && "$fp" == "$saved_fp" ]] && mark="✓" + + 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() { + local cursor="$1" count saved_fp i cache_info selected_name + local service service_state service_enabled xray_config socks_endpoint + + count="$(profile_count)" + saved_fp="$(cfg_get selected_fp)" + 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= 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 + fetch_subscription "$(cfg_get sub_url)" || die "Не удалось обновить подписку." + exit 0 + ;; + "") + cfg_exists || wizard + ;; + *) + usage >&2 + exit 2 + ;; + esac + + maybe_refresh_subscription "$(cfg_get sub_url)" + validate_subscription_file "$SUB_JSON" || die "Кэш подписки повреждён: ${SUB_JSON}. Запустите ${0##*/} --refresh" + menu +} + +main "$@"