Files
xtui/main.sh

843 lines
25 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -Eeuo pipefail
APP_NAME="xtui"
STATE_DIR="${HOME}/.xtui"
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" "$@"
}
# systemctl on some systems logs "Failed to add a watch ... inotify watch limit reached"
# to stderr when it talks to PID 1. The operation itself succeeds; suppress the
# noisy line so it does not pollute the TUI.
run_systemctl() {
local line rc
while IFS= read -r line; do
[[ "$line" == *"Failed to add a watch"*"inotify watch limit reached"* ]] && continue
printf '%s\n' "$line"
done < <(systemctl "$@" 2>&1) || true
rc=${PIPESTATUS[0]}
return "$rc"
}
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_index:"", 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 'xtui/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"
run_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_index saved_fp count i fp
saved_index="$(cfg_get selected_index)"
saved_fp="$(cfg_get selected_fp)"
count="$(profile_count)"
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
echo 0
}
migrate_saved_index() {
[[ -s "$SUB_JSON" ]] || return 0
validate_subscription_file "$SUB_JSON" || return 0
find_saved_index >/dev/null
}
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 run_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 run_systemctl --no-ask-password restart "$service"
ok "Служба ${service} перезапущена."
}
apply_selection() {
local idx="$1" dest service tmp fp old_index name proto addr port changed=1 service_state
dest="$(cfg_get xray_config)"
service="$(cfg_get service)"
old_index="$(cfg_get selected_index)"
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_index "$idx"
cfg_set selected_fp "$fp"
cfg_set selected_name "$name"
rm -f "$tmp"
service_state="$(service_state_raw "$service")"
if (( changed )) || [[ "$old_index" != "$idx" ]]; 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"
run_systemctl --no-ask-password is-active "$service" 2>/dev/null || true
}
service_enabled_raw() {
local service="$1"
run_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 run_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 run_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 ! run_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_index="$3"
local name proto addr port pointer mark row_style name_short addr_short
IFS=$'\t' read -r name proto addr port < <(profile_summary "$idx")
pointer=" "
mark=" "
row_style=""
[[ "$idx" -eq "$cursor" ]] && { pointer="➤"; row_style="$C_INV"; }
[[ "$saved_index" =~ ^[0-9]+$ && "$idx" -eq "$saved_index" ]] && 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_index i cache_info selected_name
local service service_state service_enabled xray_config socks_endpoint
local now
count="$(profile_count)"
saved_index="$(cfg_get selected_index)"
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)"
# Polling systemd on every keypress can exhaust inotify watches quickly.
# Cache the state for a short interval inside the menu loop.
now="$(date +%s)"
if [[ -z "${_MENU_STATE_CACHE_TS:-}" || $(( now - _MENU_STATE_CACHE_TS )) -gt 2 ]]; then
_MENU_STATE_CACHE_TS="$now"
_MENU_STATE_CACHE_VALUE="$(service_state_raw "$service")"
_MENU_ENABLED_CACHE_VALUE="$(service_enabled_raw "$service")"
fi
service_state="${_MENU_STATE_CACHE_VALUE}"
service_enabled="${_MENU_ENABLED_CACHE_VALUE}"
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_index"
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"
unset _MENU_STATE_CACHE_TS _MENU_STATE_CACHE_VALUE _MENU_ENABLED_CACHE_VALUE
;;
o|O)
run_service_action stop "$service"
unset _MENU_STATE_CACHE_TS _MENU_STATE_CACHE_VALUE _MENU_ENABLED_CACHE_VALUE
;;
e|E)
run_service_action restart "$service"
unset _MENU_STATE_CACHE_TS _MENU_STATE_CACHE_VALUE _MENU_ENABLED_CACHE_VALUE
;;
v|V)
show_service_status_screen "$service"
unset _MENU_STATE_CACHE_TS _MENU_STATE_CACHE_VALUE _MENU_ENABLED_CACHE_VALUE
;;
q|Q)
clear_screen
show_cursor
exit 0
;;
""|$'\n'|$'\r')
show_cursor
say ""
apply_selection "$cursor"
unset _MENU_STATE_CACHE_TS _MENU_STATE_CACHE_VALUE _MENU_ENABLED_CACHE_VALUE
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
migrate_saved_index
fetch_subscription "$(cfg_get sub_url)" || die "Не удалось обновить подписку."
exit 0
;;
"")
cfg_exists || wizard
;;
*)
usage >&2
exit 2
;;
esac
migrate_saved_index
maybe_refresh_subscription "$(cfg_get sub_url)"
validate_subscription_file "$SUB_JSON" || die "Кэш подписки повреждён: ${SUB_JSON}. Запустите ${0##*/} --refresh"
menu
}
main "$@"