Initial commit

This commit is contained in:
2026-06-15 15:38:03 +03:00
commit 8c0aa5b8b3
2 changed files with 862 additions and 0 deletions

65
README.md Normal file
View File

@@ -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`.

797
main.sh Normal file
View File

@@ -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 <<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" \
'{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<count; i++)); do
fp="$(profile_fingerprint "$i")"
if [[ "$fp" == "$saved_fp" ]]; then
echo "$i"
return
fi
done
echo 0
}
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() {
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<count; i++)); do
render_profile_row "$i" "$cursor" "$saved_fp"
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
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 "$@"