# -*- coding: utf-8 -*-
"""Claude Proxy Control — простой пошаговый интерфейс."""
from __future__ import annotations

import base64
import json
import os
import subprocess
import sys
import threading
import time
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk

import customtkinter as ctk

APP_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(APP_DIR))

# Stated once, in one place. The app used to require 3.12 implicitly (via PEP 701
# f-strings) while advertising nothing, so an older interpreter failed with a bare
# SyntaxError and no explanation.
MIN_PYTHON = (3, 10)
if sys.version_info < MIN_PYTHON:
    _need = ".".join(str(n) for n in MIN_PYTHON)
    _have = ".".join(str(n) for n in sys.version_info[:3])
    sys.stderr.write(
        f"Claude Proxy Control requires Python {_need}+ (running {_have}: {sys.executable})\n"
    )
    try:
        import ctypes

        ctypes.windll.user32.MessageBoxW(
            None,
            f"Нужен Python {_need} или новее.\nСейчас запущено: {_have}\n{sys.executable}",
            "Claude Proxy Control",
            0x10,
        )
    except Exception:
        pass
    raise SystemExit(1)

from nodes_db import (  # noqa: E402
    count_nodes,
    country_label,
    enrich_missing_geo,
    get_active,
    import_existing_captures,
    init_db,
    list_nodes,
    set_active,
)
from ui_shell import SettingsWindow, apply_ctk_globals, build_main  # noqa: E402
from ui_shell import button as ui_button  # noqa: E402
from version import app_version, build_info, is_newer  # noqa: E402
from ui_theme import BUTTONS  # noqa: E402
from ui_theme import COLORS as THEME  # noqa: E402
from ui_theme import label as btn_label  # noqa: E402
from traffic_live import parse_live_traffic  # noqa: E402
from net_monitor import load_last, run_checks  # noqa: E402
from app_rules import (  # noqa: E402
    CLAUDE_EXES,
    apps_awaiting_apply,
    clear_apply_pending,
    export_channels,
    import_channels,
    ingest_live_rows,
    is_active,
    is_recent_top,
    list_apps,
    pending_apps,
    set_channel,
)
from cpc_debug import clog, clog_banner, clog_exc, log_path  # noqa: E402
from settings_store import (  # noqa: E402
    load_settings,
    save_settings,
    settings_ready,
    sync_proxy_env,
)
from license_client import (  # noqa: E402
    license_info,
    activate as activate_license,
    open_magic_cabinet,
    send_heartbeat,
)
from claude_detect import claude_desktop_installed  # noqa: E402
from status import (  # noqa: E402
    SQUID_DOWN,
    SQUID_OK,
    VPN_OK,
    channel_note,
    probe_and_save,
    squid_state,
    vpn_state,
)
from single_instance import acquire_or_focus_existing  # noqa: E402
from tun_ready import format_report_text, run_tun_ready_check  # noqa: E402
import webbrowser  # noqa: E402

HOME = Path(os.environ["USERPROFILE"])
# Product build — separate from personal ~/.claude-proxy
PROXY = Path(os.environ.get("CLAUDE_PROXY_HOME") or (HOME / ".claude-proxy-control"))
MERGED = PROXY / "merged"
TASK_WATCHER = "CPC-SotaCaptureWatcher"
# Сколько ждать первый снимок конфига Sota, прежде чем поднимать туннель без
# канала VPN. Ожидание было бесконечным, и на машине без Sota программа
# оставалась стоять без маршрутизации до перезапуска.
CAPTURE_WAIT_SEC = 120.0
PWSH = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
# Hide console for all background subprocesses (fixes flashing PowerShell windows)
CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
# Куда ведут ссылки с главного экрана.
SITE_URL = "https://proxy.mindq.ru"
SUPPORT_URL = "https://t.me/claude_support_ru_bot?start=claude-proxy"
CLAUDE_DOWNLOAD_URL = "https://claude.ai/download"
os.environ.setdefault("CLAUDE_PROXY_HOME", str(PROXY))


def publish_runtime_paths() -> None:
    """Tell the elevated PowerShell side where the desktop code and OUR interpreter live.

    Find-CpcPython used to fall back to a hardcoded developer path (E:\\Python). Writing
    sys.executable here means the elevated builder always runs on the same interpreter
    the UI validated, on any machine.
    """
    try:
        PROXY.mkdir(parents=True, exist_ok=True)
        (PROXY / "desktop-path.txt").write_text(str(APP_DIR), encoding="utf-8")
        # The UI runs under pythonw.exe; hand the console build to the PowerShell side so
        # builder output can actually be captured and logged.
        exe = Path(sys.executable)
        if exe.name.lower() == "pythonw.exe":
            console = exe.with_name("python.exe")
            if console.is_file():
                exe = console
        (PROXY / "python-path.txt").write_text(str(exe), encoding="utf-8")
    except Exception:
        pass


def rotate_logs(max_mb: float = 8.0, keep: int = 2) -> None:
    """Cap the log files that otherwise grow forever.

    Only the sing-box tun log was ever rotated, and only when an apply happened - so on a
    machine that never applies routes, the trace and monitor history just kept growing.
    """
    targets = [
        PROXY / "logs" / "cpc-trace.log",
        MERGED / "monitor-history.jsonl",
        MERGED / "vpn-claude-tun.log",
    ]
    limit = int(max_mb * 1024 * 1024)
    for path in targets:
        try:
            if not path.is_file() or path.stat().st_size <= limit:
                continue
            oldest = path.with_suffix(path.suffix + f".{keep}")
            if oldest.exists():
                oldest.unlink()
            for n in range(keep - 1, 0, -1):
                src = path.with_suffix(path.suffix + f".{n}")
                if src.exists():
                    src.replace(path.with_suffix(path.suffix + f".{n + 1}"))
            path.replace(path.with_suffix(path.suffix + ".1"))
        except Exception:
            # Rotation is housekeeping - never let it stop the app from starting.
            pass


def _run_hidden(args: list[str], timeout: int = 15) -> str:
    """Консольная команда без окна. Вывод декодируем кодировкой консоли.

    text=True берёт локальную ANSI-кодировку (на русской Windows cp1251), а
    schtasks и tasklist пишут в OEM (cp866) — кириллица приезжала кракозябрами.
    Кодек "oem" в Python на Windows означает ровно текущую кодовую страницу
    консоли, какой бы она ни была.
    """
    try:
        p = subprocess.run(
            args,
            capture_output=True,
            timeout=timeout,
            creationflags=CREATE_NO_WINDOW,
        )
    except Exception:
        return ""
    raw = p.stdout or b""
    for codec in ("oem", "utf-8", "latin-1"):
        try:
            return raw.decode(codec).strip()
        except Exception:
            continue
    return ""


def _csv_rows(out: str) -> list[list[str]]:
    """Строки `tasklist /FO CSV /NH` полями. Структура от языка не зависит."""
    rows = []
    for line in out.splitlines():
        line = line.strip()
        if not line or not line.startswith('"'):
            continue  # локализованное «ИНФОРМАЦИЯ: задачи не найдены» и прочий текст
        rows.append([p.strip().strip('"') for p in line.split('","')])
    return rows


def _pid_alive(pid: int) -> bool:
    if not pid or pid <= 0:
        return False
    out = _run_hidden(
        ["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
        timeout=8,
    )
    # Раньше искали номер подстрокой и отсекали ответ «нет задач» по слову
    # "INFO:" — на русской Windows там «ИНФОРМАЦИЯ:», и отсечка не срабатывала.
    # Сравниваем поле с номером процесса, а не текст целиком.
    for row in _csv_rows(out):
        if len(row) >= 2 and row[1].isdigit() and int(row[1]) == pid:
            return True
    return False


def _image_running(image: str) -> bool:
    out = _run_hidden(
        ["tasklist", "/FI", f"IMAGENAME eq {image}", "/FO", "CSV", "/NH"],
        timeout=8,
    )
    return any(row and row[0].lower() == image.lower() for row in _csv_rows(out))


# Состояние задания на разных языках Windows называется по-разному. Приводим к
# одному слову, чтобы остальной код не гадал.
_TASK_STATES = {
    "running": "Running",
    "выполняется": "Running",
    "ready": "Ready",
    "готово": "Ready",
    "готов": "Ready",
    "disabled": "Disabled",
    "отключено": "Disabled",
    "отключен": "Disabled",
}


def _task_state(name: str) -> str:
    """Состояние задания планировщика: Running / Ready / Disabled / "" (нет).

    Здесь разбирался вывод `schtasks /FO LIST` по строке, начинающейся с
    "Status:". На русской Windows она называется «Состояние:», и функция всегда
    возвращала пустоту — программа не видела ни одного своего задания и писала
    «ловец не установлен» даже тогда, когда он был установлен и работал.

    Формат CSV от языка не зависит: три поля, состояние — третье. Само значение
    переведено, поэтому приводим его к одному слову по таблице выше.
    """
    out = _run_hidden(
        ["schtasks", "/Query", "/TN", name, "/FO", "CSV", "/NH"],
        timeout=12,
    )
    for line in out.splitlines():
        parts = [p.strip().strip('"') for p in line.split('","')]
        if len(parts) < 3:
            continue
        raw = parts[-1].strip().strip('"')
        if not raw or raw.lower() in {"status", "состояние"}:
            continue
        # Незнакомый перевод — не выдумываем: задание есть, значит оно есть.
        return _TASK_STATES.get(raw.lower(), raw)
    return ""


def _ps_encoded_command(command: str) -> str:
    """UTF-16LE base64 for powershell -EncodedCommand (avoids $env expansion in outer shell)."""
    return base64.b64encode(command.encode("utf-16-le")).decode("ascii")


def _elevated_ps_body(
    script: Path,
    *,
    nopause: bool = True,
    exit_with_code: bool = False,
    extra_env: dict[str, str] | None = None,
) -> str:
    home = str(PROXY).replace("'", "''")
    script_s = str(script).replace("'", "''")
    nop = "1" if nopause else ""
    body = (
        f"$env:CLAUDE_PROXY_HOME='{home}'; "
        f"$env:CLAUDE_PROXY_NOPAUSE='{nop}'; "
    )
    for key, value in (extra_env or {}).items():
        safe = str(value).replace("'", "''")
        body += f"$env:{key}='{safe}'; "
    body += f"& '{script_s}'"
    if exit_with_code:
        body += "; exit $LASTEXITCODE"
    return body


UAC_DECLINED = 1223  # ERROR_CANCELLED: человек нажал «Нет» в окне Windows


def _elevated_logged_body(
    script: Path, log: Path, extra_env: dict[str, str] | None = None
) -> str:
    """Тело для elevated-процесса: транскрипт, запуск, честный код возврата.

    Текст ошибки кладём не только в транскрипт, но и в отдельный файл рядом.
    Транскрипт заканчивается служебной шапкой («End time: …»), и вытащить из
    него последнюю осмысленную строку — гадание; отдельный файл даёт ровно то
    сообщение, которое стоит показать человеку.

    Вынесено отдельно, чтобы это можно было проверить без повышения прав: сам
    RunAs автотестом не проверишь, а что скрипт запускается, что ошибка
    доезжает и что код возврата не теряется — вполне.
    """
    def q(value: object) -> str:
        return str(value).replace("'", "''")

    result = log.with_suffix(".result.json")
    body = f"$env:CLAUDE_PROXY_HOME='{q(PROXY)}'; $env:CLAUDE_PROXY_NOPAUSE='1'; "
    for key, value in (extra_env or {}).items():
        body += f"$env:{key}='{q(value)}'; "
    body += (
        f"try {{ Start-Transcript -Path '{q(log)}' -Force | Out-Null }} catch {{}}; "
        "$rc = 0; $err = ''; $global:LASTEXITCODE = 0; "
        f"try {{ & '{q(script)}' }} "
        "catch { $err = $_.Exception.Message; $rc = 1; Write-Host ('ОШИБКА: ' + $err) }; "
        "if ($LASTEXITCODE) { $rc = $LASTEXITCODE }; "
        "try { Stop-Transcript | Out-Null } catch {}; "
        "try { [IO.File]::WriteAllText("
        f"'{q(result)}', "
        "(ConvertTo-Json @{ rc = $rc; error = $err }), "
        "(New-Object Text.UTF8Encoding $false)) } catch {}; "
        "exit $rc"
    )
    return body


def run_ps_elevated_wait(
    script: Path,
    *,
    log_name: str,
    extra_env: dict[str, str] | None = None,
    timeout: int = 240,
) -> tuple[int, str]:
    """Запустить скрипт с правами администратора, дождаться и вернуть (код, лог).

    Установка ловца делалась «в никуда»: Start-Process -Verb RunAs, а вывод и код
    возврата выбрасывались. Со стороны это выглядело как «что-то мигнуло и всё» —
    ни причины, ни следа, и разбираться было не по чему. Теперь elevated-процесс
    пишет транскрипт в logs\\, а код возврата доезжает обратно.
    """
    log = PROXY / "logs" / log_name
    try:
        log.parent.mkdir(parents=True, exist_ok=True)
    except Exception:
        pass
    enc = _ps_encoded_command(_elevated_logged_body(script, log, extra_env))
    outer = (
        f"try {{ $p = Start-Process -FilePath '{PWSH}' -Verb RunAs -PassThru -WindowStyle Hidden "
        f"-ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-EncodedCommand','{enc}'); "
        f"$p.WaitForExit(); exit $p.ExitCode }} catch {{ exit {UAC_DECLINED} }}"
    )
    try:
        done = subprocess.run(
            [str(PWSH), "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", outer],
            timeout=timeout,
            creationflags=CREATE_NO_WINDOW,
            capture_output=True,
        )
        code = int(done.returncode)
    except subprocess.TimeoutExpired:
        code = -1
    except Exception:
        code = 1

    # Сообщение об ошибке — из файла результата: там ровно то, что упало.
    message = ""
    try:
        data = json.loads(
            log.with_suffix(".result.json").read_text(encoding="utf-8-sig")
        )
        message = str(data.get("error") or "").strip()
    except Exception:
        pass
    if not message:
        # Запасной путь: хвост транскрипта без его служебной обвязки.
        skip = ("**", "Windows PowerShell transcript", "End time:", "Start time:",
                "Username:", "RunAs User:", "Machine:", "Host Application:",
                "Process ID:", "PSVersion:", "PSEdition:", "BuildVersion:",
                "CLRVersion:", "WSManStackVersion:", "PSRemotingProtocolVersion:",
                "SerializationVersion:", "Configuration Name:", "PSCompatibleVersions:")
        try:
            lines = log.read_text(encoding="utf-8-sig", errors="replace").splitlines()
            useful = [
                x.strip() for x in lines
                if x.strip() and not x.strip().startswith(skip)
            ]
            message = useful[-1] if useful else ""
        except Exception:
            pass
    clog(
        "elevated.done",
        "elevated script finished",
        script=script.name,
        code=code,
        error=message,
    )
    return code, message


def run_ps_elevated(
    script: Path,
    no_exit: bool = True,
    nopause: bool = True,
    *,
    hidden: bool = False,
    extra_env: dict[str, str] | None = None,
) -> None:
    """Run script elevated. Pass product home + NOPAUSE into the elevated process
    (RunAs does not inherit our env otherwise — old prompts would hang).

    Uses -EncodedCommand so the launcher PowerShell cannot expand $env:CLAUDE_PROXY_*
    before Start-Process (that bug turned `$env:HOME=...` into a bare path assignment).
    hidden=True → no console window (progress goes to GUI via start-progress.json).
    """
    enc = _ps_encoded_command(_elevated_ps_body(script, nopause=nopause, extra_env=extra_env))
    args = "@('-NoProfile','-ExecutionPolicy','Bypass'"
    if no_exit:
        args += ",'-NoExit'"
    if hidden:
        args += ",'-WindowStyle','Hidden'"
    args += f",'-EncodedCommand','{enc}')"
    win_style = " -WindowStyle Hidden" if hidden else ""
    subprocess.Popen(
        [
            str(PWSH),
            "-NoProfile",
            "-ExecutionPolicy",
            "Bypass",
            "-WindowStyle",
            "Hidden",
            "-Command",
            f"Start-Process -FilePath '{PWSH}' -Verb RunAs{win_style} -ArgumentList {args}",
        ],
        creationflags=CREATE_NO_WINDOW,
    )


def sync_runtime_script(name: str) -> Path:
    """Copy product/runtime script into ~/.claude-proxy-control so Start always uses fresh code.

    The BOM is guaranteed HERE rather than by each caller. Windows PowerShell 5.1 reads a
    BOM-less UTF-8 script as the ANSI codepage, so a single non-ASCII character inside a
    string literal becomes a parse error and the whole elevated apply dies. This used to be
    patched up in one call site only, which left every other path shipping a broken script.
    """
    PROXY.mkdir(parents=True, exist_ok=True)
    dst = PROXY / name
    # Раскладок две, и они разные. В дереве исходников скрипты лежат в
    # product\runtime\, а установленная копия кладёт их ПЛОСКО в папку продукта,
    # рядом с desktop\. Смотреть только в одну — значит на установленной машине
    # не найти ничего и молча отдать несуществующий путь.
    candidates = [
        APP_DIR.parent / "runtime" / name,   # дерево исходников
        APP_DIR.parent / name,               # установленная копия
        PROXY / "runtime" / name,
    ]
    for src in candidates:
        try:
            if not src.is_file() or src.resolve() == dst.resolve():
                continue
        except Exception:
            continue
        try:
            raw = src.read_bytes()
            if name.lower().endswith(".ps1") and not raw.startswith(b"\xef\xbb\xbf"):
                raw = b"\xef\xbb\xbf" + raw
            dst.write_bytes(raw)
            break
        except Exception:
            continue
    if dst.is_file():
        return dst
    clog(
        "runtime.missing",
        "script not found in any known layout",
        level="ERROR",
        name=name,
        looked=[str(c) for c in candidates],
    )
    return product_script(name)


def _write_ps1_utf8_bom(path: Path, text: str) -> None:
    """Windows PowerShell 5.1 needs BOM to parse UTF-8 scripts reliably.

    Idempotent on purpose. A caller that read its source with encoding="utf-8" (not
    "utf-8-sig") keeps a leading U+FEFF in the string; prepending the BOM bytes on top of
    that produced a DOUBLE BOM, and PowerShell then read the second one as part of the
    first token - "The term '<BOM>#Requires' is not recognized".
    """
    path.write_bytes(b"\xef\xbb\xbf" + text.lstrip("﻿").encode("utf-8"))


def _elevate_ps1_wait(script: Path, *, timeout: int = 90, kind: str = "script") -> bool:
    """Elevate a concrete .ps1 via on-disk bootstrap + Start-Process -Verb RunAs -Wait.

    Success = process ExitCode 0. Does NOT look at reload-result.json
    (that is only for run_elevated_reload_wait).
    """
    import time as _time

    PROXY.mkdir(parents=True, exist_ok=True)
    sync_runtime_script("_env.ps1")
    # Prefer a copy under PROXY so elevated session finds _env.ps1 next to it
    target = PROXY / script.name
    try:
        if script.is_file():
            raw = script.read_bytes()
            if script.suffix.lower() == ".ps1" and not raw.startswith(b"\xef\xbb\xbf"):
                try:
                    _write_ps1_utf8_bom(target, raw.decode("utf-8"))
                except Exception:
                    target.write_bytes(raw)
            else:
                target.write_bytes(raw)
        elif not target.is_file():
            clog("elevate.missing", "script not found", level="ERROR", script=str(script))
            return False
    except Exception as e:
        clog_exc("elevate.sync", "could not sync script to PROXY", e)
        target = script if script.is_file() else target

    bootstrap = PROXY / f"_elevate_{kind}_boot.ps1"
    target_s = str(target).replace("'", "''")
    home_s = str(PROXY).replace("'", "''")
    boot_body = "\n".join(
        [
            "$ErrorActionPreference = 'Stop'",
            f"$env:CLAUDE_PROXY_HOME = '{home_s}'",
            "$env:CLAUDE_PROXY_NOPAUSE = '1'",
            f"$target = '{target_s}'",
            "$logDir = Join-Path $env:CLAUDE_PROXY_HOME 'logs'",
            "New-Item -ItemType Directory -Force -Path $logDir | Out-Null",
            "$trace = Join-Path $logDir 'cpc-trace.log'",
            "function BootLog([string]$m) {",
            f"  $line = (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fff') + ' [BOOT-{kind.upper()}] INFO  boot  ' + $m",
            "  Add-Content -Path $trace -Value $line -Encoding UTF8",
            "}",
            "BootLog ('start target=' + $target)",
            "if (-not (Test-Path -LiteralPath $target)) { BootLog 'target missing'; exit 3 }",
            "try {",
            "  & $target",
            "  $code = 0",
            "  if ($null -ne $LASTEXITCODE) { $code = [int]$LASTEXITCODE }",
            "  BootLog ('exit=' + $code)",
            "  exit $code",
            "} catch {",
            "  BootLog ('EXCEPTION: ' + $_.Exception.Message)",
            "  exit 1",
            "}",
            "",
        ]
    )
    _write_ps1_utf8_bom(bootstrap, boot_body)

    clog(
        "elevate.wait.begin",
        f"elevate {kind}",
        bootstrap=str(bootstrap),
        target=str(target),
        timeout=timeout,
        home=str(PROXY),
    )
    boot_s = str(bootstrap).replace("'", "''")
    cmd = (
        "try { "
        f"$p = Start-Process -FilePath '{PWSH}' -Verb RunAs -Wait -WindowStyle Hidden -PassThru "
        f"-ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{boot_s}'); "
        "if (-not $p) { Write-Output 'NO_PROCESS'; exit 1 }; "
        "Write-Output ('EXITCODE=' + $p.ExitCode); "
        "if ($null -eq $p.ExitCode) { exit 1 }; "
        "exit ([int]$p.ExitCode) "
        "} catch { Write-Output ('CATCH=' + $_.Exception.Message); exit 1 }"
    )
    t0 = _time.time()
    try:
        p = subprocess.run(
            [
                str(PWSH),
                "-NoProfile",
                "-ExecutionPolicy",
                "Bypass",
                "-WindowStyle",
                "Hidden",
                "-Command",
                cmd,
            ],
            timeout=timeout,
            check=False,
            capture_output=True,
            text=True,
            creationflags=CREATE_NO_WINDOW,
        )
        elapsed = round(_time.time() - t0, 2)
        ok = p.returncode == 0
        # UAC cancel often yields non-zero quickly; treat as fail
        clog(
            "elevate.wait.end",
            f"elevated {kind} finished",
            returncode=p.returncode,
            ok=ok,
            elapsed_sec=elapsed,
            stdout=(p.stdout or "")[-400:],
            stderr=(p.stderr or "")[-200:],
        )
        return ok
    except subprocess.TimeoutExpired:
        clog(
            "elevate.wait.timeout",
            f"UAC/{kind} exceeded timeout",
            timeout=timeout,
            level="ERROR",
        )
        return False
    except Exception as e:
        clog_exc("elevate.wait.exc", f"elevate {kind} failed", e)
        return False


def run_ps_elevated_wait(script: Path, timeout: int = 90) -> bool:
    """Elevate an arbitrary product script (Quit-Product, Stop, Diagnose…)."""
    kind = "quit" if "quit" in script.name.lower() else "script"
    return _elevate_ps1_wait(script, timeout=timeout, kind=kind)


def sync_quit_script() -> Path:
    """Copy Quit-Product.ps1 into product home.

    Delegates to the one copier that owns the BOM rule instead of re-implementing it.
    """
    dst = sync_runtime_script("Quit-Product.ps1")
    if dst.is_file():
        return dst
    return product_script("Quit-Product.ps1")


KILLSWITCH_MARKER = MERGED / "killswitch.active"


def killswitch_leftover() -> str:
    """Осталось ли защитное правило брандмауэра от оборванной пересборки.

    Правило переживает процесс, который его поставил, поэтому после аварийного
    завершения компьютер может остаться без интернета. Маркер пишется до самого
    правила, так что ложная тревога безобиднее пропущенной.
    Возвращает содержимое маркера (кто и когда ставил) или пустую строку.
    """
    try:
        if KILLSWITCH_MARKER.is_file():
            return KILLSWITCH_MARKER.read_text(encoding="utf-8-sig").strip() or "неизвестно когда"
    except Exception:
        pass
    return ""


def clear_killswitch_elevated() -> bool:
    """Снять оставшееся правило. Требует прав администратора — будет запрос UAC."""
    script = sync_runtime_script("Clear-KillSwitch.ps1")
    if not script.is_file():
        script = product_script("Clear-KillSwitch.ps1")
    ok = _elevate_ps1_wait(script, timeout=60, kind="killswitch")
    clog("killswitch.clear", "elevated cleanup finished", ok=ok, left=bool(killswitch_leftover()))
    return ok and not killswitch_leftover()


def mark_status_stopped() -> None:
    """Clear live TUN flags so next UI start does not think tunnel is up."""
    from datetime import datetime

    MERGED.mkdir(parents=True, exist_ok=True)
    payload = {
        "ok": False,
        "stopped": True,
        "quit": True,
        "tun": "",
        "singBoxPid": 0,
        "vpnModeActive": False,
        "time": datetime.now().isoformat(timespec="seconds"),
    }
    try:
        (MERGED / "status.json").write_text(
            json.dumps(payload, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )
    except Exception:
        pass


def elevated_stop_product(*, timeout: int = 60, attempts: int = 2) -> bool:
    """Stop TUN + Claude via elevated Quit-Product; True only if our tunnel is gone."""
    script = sync_quit_script()
    clog("quit.stop.begin", "elevated Quit-Product", script=str(script), attempts=attempts)
    for i in range(max(1, attempts)):
        try:
            kill_claude_processes()
        except Exception:
            pass
        elev_ok = False
        try:
            elev_ok = bool(run_ps_elevated_wait(script, timeout=timeout))
        except Exception as e:
            clog_exc("quit.stop.elevate", "Quit elevate failed", e)
        try:
            kill_claude_processes()
        except Exception:
            pass
        # Prefer deep check off UI thread (caller should be worker)
        still = False
        try:
            still = bool(singbox_running() or singbox_running_deep())
        except Exception:
            still = bool(singbox_running())
        clog(
            "quit.stop.attempt",
            "after Quit-Product",
            n=i + 1,
            elev_ok=elev_ok,
            tun_still_up=still,
        )
        if not still:
            mark_status_stopped()
            clog("quit.stop.ok", "TUN/sing-box stopped")
            return True
    clog("quit.stop.fail", "TUN still up after Quit attempts", level="ERROR")
    return False


def read_reload_result() -> dict:
    """Current reload-result.json, or {} if absent/unreadable."""
    path = MERGED / "reload-result.json"
    try:
        if path.is_file() and path.stat().st_size:
            data = json.loads(path.read_text(encoding="utf-8-sig"))
            if isinstance(data, dict):
                return data
    except Exception:
        pass
    return {}


def _reload_result_settled(token: str) -> dict | None:
    """Return the result only when it is final AND belongs to `token`.

    Anything else - a leftover from a previous apply, a concurrent run's result, or our
    own not-yet-finished placeholder - is treated as "still waiting".
    """
    data = read_reload_result()
    if not data:
        return None
    if token and str(data.get("token") or "") != token:
        return None
    detail = str(data.get("detail") or "")
    if detail in {"waiting-elevated-reload", "elevated-reload-started", "launcher-started", ""}:
        return None
    return data


def run_elevated_reload_wait(token: str, timeout: int = 180) -> dict:
    """Elevate Reload-VpnRoutes; success is decided by a token-matching reload-result.

    Returns the settled result dict, or {} if it never settled. `timeout` is the overall
    budget, generous because it also covers the user reacting to the UAC prompt; the
    normal path returns as soon as the result file settles (~10s).
    """
    import time as _time

    sync_runtime_script("Run-ElevatedReload.ps1")
    sync_runtime_script("Reload-VpnRoutes.ps1")
    sync_runtime_script("_env.ps1")

    target = PROXY / "Run-ElevatedReload.ps1"
    if not target.is_file():
        # Возвращать False из функции, объявленной как -> dict, нельзя: вызывающий
        # делает rj.get("ok"), и пользователь вместо «нет скрипта» получал
        # «'bool' object has no attribute 'get'». Причину при этом не узнать.
        clog("elevate.reload.missing", "Run-ElevatedReload.ps1 missing", level="ERROR",
             expected=str(target))
        return {
            "ok": False,
            "detail": (
                "в папке продукта нет Run-ElevatedReload.ps1 — переустановите программу "
                "(Uninstall.bat, затем Install.bat)"
            ),
            "tun": "",
            "token": token,
        }

    bootstrap = PROXY / "_elevate_reload_boot.ps1"
    home_s = str(PROXY).replace("'", "''")
    target_s = str(target).replace("'", "''")
    token_s = str(token).replace("'", "''")
    boot_body = "\n".join(
        [
            "$ErrorActionPreference = 'Stop'",
            f"$env:CLAUDE_PROXY_HOME = '{home_s}'",
            "$env:CLAUDE_PROXY_NOPAUSE = '1'",
            f"$launcher = '{target_s}'",
            # Every failure result must carry the token, otherwise the waiter cannot tell
            # "my run failed" from "somebody else's leftover" and stalls until grace ends.
            f"$token = '{token_s}'",
            "$env:CPC_RELOAD_TOKEN = $token",
            "$logDir = Join-Path $env:CLAUDE_PROXY_HOME 'logs'",
            "New-Item -ItemType Directory -Force -Path $logDir | Out-Null",
            "$trace = Join-Path $logDir 'cpc-trace.log'",
            "$merged = Join-Path $env:CLAUDE_PROXY_HOME 'merged'",
            "New-Item -ItemType Directory -Force -Path $merged | Out-Null",
            "$result = Join-Path $merged 'reload-result.json'",
            "function BootLog([string]$m) {",
            "  $line = (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fff') + ' [BOOT] INFO  boot  ' + $m",
            "  Add-Content -Path $trace -Value $line -Encoding UTF8",
            "}",
            "BootLog ('start launcher=' + $launcher)",
            "if (-not (Test-Path -LiteralPath $launcher)) {",
            "  BootLog 'launcher missing'",
            "  @{ ok=$false; detail='boot-launcher-missing'; tun=''; token=$token; time=(Get-Date).ToString('o') } | ConvertTo-Json | Set-Content $result -Encoding UTF8",
            "  exit 3",
            "}",
            "try {",
            "  BootLog 'calling launcher'",
            "  & $launcher",
            "  $code = 0",
            "  if ($null -ne $LASTEXITCODE) { $code = [int]$LASTEXITCODE }",
            "  BootLog ('launcher exit=' + $code)",
            "  exit $code",
            "} catch {",
            "  BootLog ('launcher EXCEPTION: ' + $_.Exception.Message)",
            "  # Prefer already-written ok=true (TUN up) over late exception",
            "  if (Test-Path -LiteralPath $result) {",
            "    try {",
            "      $rj = Get-Content $result -Raw -Encoding UTF8 | ConvertFrom-Json",
            "      if ($rj.ok -eq $true) { BootLog 'exception after ok=true — exit 0'; exit 0 }",
            "    } catch {}",
            "  }",
            "  @{ ok=$false; detail=('boot-exception: ' + $_.Exception.Message); tun=''; token=$token; time=(Get-Date).ToString('o') } | ConvertTo-Json | Set-Content $result -Encoding UTF8",
            "  exit 1",
            "}",
            "",
        ]
    )
    _write_ps1_utf8_bom(bootstrap, boot_body)

    result_path = MERGED / "reload-result.json"
    before_detail = str(read_reload_result().get("detail") or "")

    clog(
        "elevate.reload.begin",
        "Start-Process RunAs bootstrap → Run-ElevatedReload",
        bootstrap=str(bootstrap),
        launcher=str(target),
        timeout=timeout,
        token=token,
        result_detail_before=before_detail,
    )
    boot_s = str(bootstrap).replace("'", "''")
    cmd = (
        "try { "
        f"$p = Start-Process -FilePath '{PWSH}' -Verb RunAs -Wait -WindowStyle Normal -PassThru "
        f"-ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{boot_s}'); "
        "if (-not $p) { Write-Output 'NO_PROCESS'; exit 1 }; "
        "Write-Output ('EXITCODE=' + $p.ExitCode); "
        "if ($null -eq $p.ExitCode) { exit 1 }; "
        "exit ([int]$p.ExitCode) "
        "} catch { Write-Output ('CATCH=' + $_.Exception.Message); exit 1 }"
    )
    t0 = _time.time()
    try:
        # Do NOT block on the wrapper. `Start-Process -Verb RunAs -Wait` regularly fails
        # to return long after the elevated child has exited: measured once at 10s of real
        # work followed by 170s of hanging, during which the UI sat on a busy button and
        # Claude stayed closed. (Stale wrappers from previous days were still alive.)
        #
        # The token-matching result file is already the authority on completion, so wait
        # on THAT and treat the wrapper purely as a cancellation hint.
        proc = subprocess.Popen(
            [
                str(PWSH),
                "-NoProfile",
                "-ExecutionPolicy",
                "Bypass",
                "-WindowStyle",
                "Hidden",
                "-Command",
                cmd,
            ],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            creationflags=CREATE_NO_WINDOW,
        )
        settled: dict | None = None
        exited_at: float | None = None
        deadline = t0 + max(1, timeout)
        # Once the wrapper is gone, a result should appear almost immediately. If it does
        # not, the user most likely dismissed the UAC prompt.
        AFTER_EXIT_GRACE = 15.0

        while _time.time() < deadline:
            settled = _reload_result_settled(token)
            if settled is not None:
                break
            if proc.poll() is not None:
                if exited_at is None:
                    exited_at = _time.time()
                    clog(
                        "elevate.reload.wrapper_exit",
                        "wrapper process exited; waiting briefly for the result",
                        returncode=proc.returncode,
                        elapsed_sec=round(exited_at - t0, 2),
                    )
                elif _time.time() - exited_at > AFTER_EXIT_GRACE:
                    clog(
                        "elevate.reload.no_result",
                        "wrapper gone and no token-matching result — UAC likely declined",
                        returncode=proc.returncode,
                        level="ERROR",
                        last=read_reload_result(),
                    )
                    break
            _time.sleep(0.5)

        elapsed = round(_time.time() - t0, 2)
        if settled is None:
            settled = _reload_result_settled(token)
        ok = bool((settled or {}).get("ok"))

        # Reap the wrapper so a hung one cannot accumulate across applies. The elevated
        # child is untouched by this - it has already done its work.
        still_running = proc.poll() is None
        if still_running:
            try:
                proc.kill()
            except Exception:
                pass
        clog(
            "elevate.reload.end",
            "elevated reload finished",
            ok=ok,
            elapsed_sec=elapsed,
            settled=bool(settled),
            wrapper_still_hung=still_running,
            returncode=proc.returncode,
            result=settled or {},
        )
        return settled or {}
    except Exception as e:
        clog_exc("elevate.reload.exc", "elevate reload failed", e)
        return {}


def rebuild_vpn_config_local() -> tuple[bool, str, str]:
    """Build vpn-claude-config.json in the UI Python (correct app_rules import).

    Writes merged/reload-request.json with the new TUN name and a one-shot token for
    Reload-VpnRoutes.ps1. Returns (ok, tun_name_or_error, token).
    """
    import secrets
    from datetime import datetime

    clog("rebuild.begin", "local builder start", python=sys.executable, home=str(PROXY))
    try:
        src = APP_DIR.parent / "runtime" / "merged" / "build_sota_merged_config.py"
        MERGED.mkdir(parents=True, exist_ok=True)
        dst = MERGED / "build_sota_merged_config.py"
        if src.is_file():
            dst.write_bytes(src.read_bytes())
            clog("rebuild.sync_builder", "copied builder", src=str(src), dst=str(dst))
        if not dst.is_file():
            clog("rebuild.fail", "builder missing", level="ERROR", dst=str(dst))
            return False, "нет build_sota_merged_config.py", ""
    except Exception as e:
        clog_exc("rebuild.sync_builder", "copy failed", e)
        return False, f"не скопировал builder: {e}", ""

    tun = f"cpc{datetime.now().strftime('%H%M%S')}{secrets.randbelow(90) + 10}"
    env = os.environ.copy()
    env["CLAUDE_PROXY_HOME"] = str(PROXY)
    env["VPN_CLAUDE_TUN_NAME"] = tun
    env["PYTHONPATH"] = str(APP_DIR) + os.pathsep + env.get("PYTHONPATH", "")
    # Use saved channels (VPN/Дом/…), not temporary apply_pending blocks
    env["CPC_APPLY_FINAL"] = "1"
    # Билдер пишет в pipe, а не в консоль: без этого Python возьмёт кодировку
    # системы (cp1251), и одна не-ASCII буква в его диагностике обрушит сборку.
    env["PYTHONIOENCODING"] = "utf-8"
    clog(
        "rebuild.run",
        "invoke build_sota_merged_config.py",
        tun=tun,
        PYTHONPATH=env.get("PYTHONPATH", "")[:200],
        CPC_APPLY_FINAL=env.get("CPC_APPLY_FINAL"),
    )
    try:
        p = subprocess.run(
            [sys.executable, str(dst)],
            env=env,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=90,
            creationflags=CREATE_NO_WINDOW,
        )
    except Exception as e:
        clog_exc("rebuild.run", "builder process failed", e)
        return False, f"builder не запустился: {e}", ""
    clog(
        "rebuild.run.done",
        "builder exit",
        returncode=p.returncode,
        stdout=(p.stdout or "")[-800:],
        stderr=(p.stderr or "")[-400:],
    )
    if p.returncode != 0:
        err = (p.stderr or p.stdout or "").strip()[:300]
        return False, err or f"builder exit {p.returncode}", ""

    # Sanity: config must mention the new TUN name
    cfg_path = MERGED / "vpn-claude-config.json"
    try:
        raw = cfg_path.read_text(encoding="utf-8-sig")
        if tun not in raw:
            clog("rebuild.fail", "tun name missing in config", level="ERROR", tun=tun)
            return False, f"конфиг собран, но TUN {tun} в нём не найден", ""
        # Log where firefox/chrome landed
        try:
            cfg = json.loads(raw)
            chmap = _channels_from_vpn_config(cfg)
            clog(
                "rebuild.channels",
                "process rules from new config",
                firefox=chmap.get("firefox.exe"),
                chrome=chmap.get("chrome.exe"),
                telegram=chmap.get("telegram.exe"),
                cfg_tun=str((cfg.get("inbounds") or [{}])[0].get("interface_name") or ""),
                n_channels=len(chmap),
            )
        except Exception as e:
            clog_exc("rebuild.channels", "parse channels failed", e)
    except Exception as e:
        clog_exc("rebuild.read", "config unreadable", e)
        return False, f"конфиг не читается: {e}", ""

    # The token, not the interface name, identifies this request. A retry inside the
    # elevated script may legitimately change the interface name, and the request file is
    # consumed once read, so a stale build can no longer be replayed.
    req = {
        "built": True,
        "tun": tun,
        "token": secrets.token_hex(8),
        "time": datetime.now().isoformat(timespec="seconds"),
    }
    (MERGED / "reload-request.json").write_text(
        json.dumps(req, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    clog("rebuild.request", "wrote reload-request.json", **req)
    clog("rebuild.ok", "local rebuild complete", tun=tun, token=req["token"])
    return True, tun, req["token"]


def claude_process_running() -> bool:
    return _image_running("Claude.exe") or _image_running("claude.exe")


def launch_claude_desktop() -> bool:
    """Reopen Claude Desktop (Store app id used by Start-VpnClaude)."""
    try:
        from claude_detect import claude_desktop_installed

        ok, detail = claude_desktop_installed()
        if ok and detail and Path(detail).is_file():
            subprocess.Popen(
                [detail],
                creationflags=CREATE_NO_WINDOW,
            )
            return True
    except Exception:
        pass
    try:
        subprocess.Popen(
            ["explorer.exe", "shell:AppsFolder\\Claude_pzs8sxrjxfjjc!Claude"],
            creationflags=CREATE_NO_WINDOW,
        )
        return True
    except Exception:
        return False


def kill_claude_processes() -> None:
    """Force-close Claude Desktop / cowork so it cannot leak past Squid when TUN is down."""
    for image in ("Claude.exe", "claude.exe", "cowork-svc.exe"):
        subprocess.run(
            ["taskkill", "/IM", image, "/F", "/T"],
            capture_output=True,
            timeout=15,
            creationflags=CREATE_NO_WINDOW,
        )
    # Store / helper processes sometimes need a second pass by name
    _run_hidden(
        [
            str(PWSH),
            "-NoProfile",
            "-ExecutionPolicy",
            "Bypass",
            "-Command",
            "Get-Process -ErrorAction SilentlyContinue | "
            "Where-Object { $_.ProcessName -match '^(?i)(Claude|cowork-svc)$' -or "
            "($_.Path -and $_.Path -match '(?i)AnthropicClaude|\\\\Claude\\\\') } | "
            "ForEach-Object { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue }",
        ],
        timeout=12,
    )


def runtime_installed() -> bool:
    return (PROXY / "Start-VpnClaude.ps1").is_file() and (PROXY / "_env.ps1").is_file()


def product_script(name: str) -> Path:
    """Prefer installed copy under ~/.claude-proxy-control, else product/runtime."""
    installed = PROXY / name
    if installed.is_file():
        return installed
    return APP_DIR.parent / "runtime" / name


def watcher_status() -> dict:
    """Return {ok, detail}. Prefer status pid / tasklist — no visible PowerShell."""
    task = _task_state(TASK_WATCHER)
    nproc = 0
    status_path = MERGED / "sota-captured" / "watcher-status.json"
    if status_path.is_file():
        try:
            data = json.loads(status_path.read_text(encoding="utf-8"))
            pid = int(data.get("pid") or 0)
            state = str(data.get("state") or "")
            if pid and _pid_alive(pid):
                nproc = 1
            elif state == "running" and _image_running("powershell.exe"):
                # fallback: any powershell + recent status
                nproc = 1
        except Exception:
            pass
    # Задание в состоянии Running — тоже признак работы. Состояние приходит уже
    # приведённым к одному слову, поэтому сравнивать переводы здесь не нужно.
    ok = nproc > 0 or task == "Running"
    if ok:
        detail = f"работает (процесс: {'найден' if nproc else 'нет'}, задание: {task or '?'})"
    elif task == "Ready":
        detail = "задание есть, но процесс не запущен"
    elif task:
        detail = f"задание есть, состояние: {task}"
    else:
        detail = "не установлен"
    return {"ok": ok, "detail": detail, "task": task, "procs": nproc}


def _adapter_up(name: str) -> bool:
    if not name:
        return False
    safe = "".join(ch for ch in name if ch.isalnum())
    if not safe:
        return False
    out = _run_hidden(
        [
            str(PWSH),
            "-NoProfile",
            "-ExecutionPolicy",
            "Bypass",
            "-Command",
            f"(Get-NetAdapter -Name '{safe}' -ErrorAction SilentlyContinue | "
            "Select-Object -ExpandProperty Status)",
        ],
        timeout=4,
    ).strip()
    return out.splitlines()[-1].strip().lower() == "up" if out else False


def _any_cpc_tun_up() -> bool:
    # Avoid $_ inside -Command (can be eaten by outer shells); use Name filter cpc*
    out = _run_hidden(
        [
            str(PWSH),
            "-NoProfile",
            "-ExecutionPolicy",
            "Bypass",
            "-Command",
            "$n=@(Get-NetAdapter -Name 'cpc*' -ErrorAction SilentlyContinue | "
            "Where-Object Status -EQ 'Up'); $n.Count",
        ],
        timeout=4,
    ).strip()
    try:
        return int(out.splitlines()[-1]) > 0
    except Exception:
        return False


def singbox_running() -> bool:
    """Fast TUN detect — pid from status.json. Safe for UI thread (no PowerShell).

    Never treat a random sing-box (Sota) as ours without our status pid.
    If status.json is empty, callers that need truth must use singbox_running_deep()
    or _status_cache['tun'] (filled by background probe).
    """
    st = read_status_json()
    try:
        pid = int(st.get("singBoxPid") or 0)
    except Exception:
        pid = 0
    if pid > 0 and _pid_alive(pid):
        return True
    return False


def singbox_running_deep() -> bool:
    """Background-only: also check adapters. May take a few seconds (PowerShell)."""
    if singbox_running():
        return True
    st = read_status_json()
    if not _image_running("sing-box.exe"):
        return False
    tun = str(st.get("tun") or "").strip()
    if tun and _adapter_up(tun):
        return True
    if _any_cpc_tun_up():
        return True
    return False


def read_status_json() -> dict:
    p = MERGED / "status.json"
    if not p.is_file() or p.stat().st_size == 0:
        return {}
    try:
        # PowerShell Set-Content -Encoding UTF8 writes BOM
        return json.loads(p.read_text(encoding="utf-8-sig"))
    except Exception:
        try:
            return json.loads(p.read_text(encoding="utf-8"))
        except Exception:
            return {}


def live_config_outbound_for_exe(exe: str) -> str | None:
    """Which outbound the live vpn-claude-config assigns to this exe (or None)."""
    p = MERGED / "vpn-claude-config.json"
    if not p.is_file():
        return None
    try:
        cfg = json.loads(p.read_text(encoding="utf-8-sig"))
    except Exception:
        return None
    key = Path(exe).name.lower()
    if not key:
        return None
    for rule in cfg.get("route", {}).get("rules") or []:
        if not isinstance(rule, dict):
            continue
        names = rule.get("process_name")
        if not names:
            continue
        if isinstance(names, str):
            names = [names]
        if key in {str(n).lower() for n in names}:
            return str(rule.get("outbound") or "") or None
    return None


def _outbound_to_channel(outbound: str | None) -> str:
    """Map sing-box outbound tag → app channel id."""
    if not outbound:
        return "block"
    o = str(outbound).strip().lower()
    if o == "direct":
        return "direct"
    if o == "block":
        return "block"
    if "squid" in o:
        return "proxy"
    if "sota" in o or "vless" in o or o == "vpn":
        return "vpn"
    # Older Sota configs tag the VLESS outbound as "proxy"
    if o == "proxy":
        return "vpn"
    return "block"


# Keys that narrow a rule to a slice of an app's traffic. A rule carrying any of these
# is a helper (block QUIC, block IPv6, ...), not the app's channel.
_RULE_QUALIFIERS = (
    "network",
    "ip_version",
    "protocol",
    "port",
    "port_range",
    "domain",
    "domain_suffix",
    "domain_keyword",
    "domain_regex",
    "ip_cidr",
)


def _channels_from_vpn_config(cfg: dict) -> dict[str, str]:
    """exe.lower() → channel from process_name rules (first match wins).

    Only unqualified rules define a channel. Previously just `network == "udp"` was
    skipped, so when an `ip_version: 6 -> block` guard was added for Claude, first-match
    won and the UI reported Claude as BLOCKED while it was really going through Squid.
    """
    out: dict[str, str] = {}
    for rule in cfg.get("route", {}).get("rules") or []:
        if not isinstance(rule, dict):
            continue
        names = rule.get("process_name")
        if not names:
            continue
        if any(k in rule for k in _RULE_QUALIFIERS):
            continue
        if isinstance(names, str):
            names = [names]
        ch = _outbound_to_channel(str(rule.get("outbound") or ""))
        for n in names:
            key = Path(str(n)).name.lower()
            if key and key not in out:
                out[key] = ch
    return out


def _first_vless_endpoint(cfg: dict) -> str:
    """'host:port' of the first VLESS outbound, or '' if there is none."""
    for o in cfg.get("outbounds") or []:
        if isinstance(o, dict) and o.get("type") == "vless" and o.get("server"):
            return f"{o.get('server')}:{o.get('server_port')}"
    return ""


def proxy_creds_need_apply() -> tuple[bool, str, str]:
    """Do the stored Squid credentials differ from the ones the running tunnel uses?

    Returns (needs_apply, user_in_running_config, user_stored_now).

    Re-activating a licence rewrites proxy.env, but the username and password are baked
    into the claude-squid outbound of the config the tunnel was started with. Activating
    a key right after an Apply therefore leaves the tunnel authenticating as the previous
    account, with nothing on screen saying so.
    """
    try:
        cfg = json.loads((MERGED / "vpn-claude-config.json").read_text(encoding="utf-8-sig"))
        live = ""
        for o in cfg.get("outbounds") or []:
            if isinstance(o, dict) and o.get("tag") == "claude-squid":
                live = str(o.get("username") or "")
                break
    except Exception:
        return False, "", ""
    if not live:
        return False, "", ""

    want = ""
    try:
        for raw in (PROXY / "proxy.env").read_text(encoding="utf-8").splitlines():
            line = raw.strip()
            if line.startswith("PROXY_USER="):
                want = line.split("=", 1)[1].strip()
                break
    except Exception:
        return False, live, ""
    if not want:
        return False, live, ""

    # Only meaningful while a tunnel is actually running from this config.
    running_tun = str(read_status_json().get("tun") or "").strip()
    try:
        cfg_tun = str((cfg.get("inbounds") or [{}])[0].get("interface_name") or "")
    except Exception:
        cfg_tun = ""
    if not running_tun or running_tun != cfg_tun:
        return False, live, want
    return (live != want), live, want


def vpn_node_needs_apply() -> tuple[bool, str, str]:
    """Does the selected VPN node differ from the one the running tunnel is using?

    Returns (needs_apply, node_in_running_config, node_selected_in_ui).

    Picking a node only rewrites sota-captured/latest.json. The node is baked into the
    running config in three places - the VLESS outbound, its Reality keys, and the
    `ip_cidr <node>/32 -> direct` rule that keeps the tunnel from dialling itself - so it
    cannot be swapped live. Without this check the UI happily reported the new node as
    active while traffic still went through the old one.
    """
    try:
        cfg = json.loads((MERGED / "vpn-claude-config.json").read_text(encoding="utf-8-sig"))
        live = _first_vless_endpoint(cfg)
        cap = json.loads(
            (MERGED / "sota-captured" / "latest.json").read_text(encoding="utf-8-sig")
        )
        want = _first_vless_endpoint(cap)
    except Exception:
        return False, "", ""
    if not live or not want:
        return False, live, want
    # Only meaningful while a tunnel is actually carrying traffic; otherwise the next
    # Start rebuilds from the capture anyway.
    running_tun = str(read_status_json().get("tun") or "").strip()
    cfg_tun = ""
    try:
        cfg_tun = str((cfg.get("inbounds") or [{}])[0].get("interface_name") or "")
    except Exception:
        pass
    if not running_tun or running_tun != cfg_tun:
        return False, live, want
    return (live != want), live, want


def save_applied_channels_snapshot(cfg: dict | None = None) -> None:
    """Remember which channels the RUNNING TUN actually has (after Start/Apply)."""
    from datetime import datetime as _dt

    MERGED.mkdir(parents=True, exist_ok=True)
    if cfg is None:
        p = MERGED / "vpn-claude-config.json"
        if not p.is_file():
            return
        try:
            cfg = json.loads(p.read_text(encoding="utf-8-sig"))
        except Exception:
            return
    tun = ""
    try:
        tun = str((cfg.get("inbounds") or [{}])[0].get("interface_name") or "")
    except Exception:
        tun = ""
    payload = {
        "tun": tun,
        "time": _dt.now().isoformat(timespec="seconds"),
        "channels": _channels_from_vpn_config(cfg),
    }
    (MERGED / "applied-channels.json").write_text(
        json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
    )


def mark_exe_blocked_until_apply(exe: str) -> None:
    """Until TUN reload succeeds, treat this exe as blocked in the live snapshot (UI + intent)."""
    from datetime import datetime as _dt

    key = Path(exe).name.lower()
    if not key or key in CLAUDE_EXES:
        clog("snapshot.block.skip", "not marking", exe=exe, key=key)
        return
    MERGED.mkdir(parents=True, exist_ok=True)
    path = MERGED / "applied-channels.json"
    data: dict = {"tun": "", "time": "", "channels": {}}
    if path.is_file() and path.stat().st_size:
        try:
            data = json.loads(path.read_text(encoding="utf-8-sig"))
        except Exception:
            pass
    ch = data.get("channels")
    if not isinstance(ch, dict):
        ch = {}
    prev = ch.get(key)
    ch[key] = "block"
    data["channels"] = ch
    data["time"] = _dt.now().isoformat(timespec="seconds")
    st = read_status_json()
    if st.get("tun"):
        data["tun"] = str(st.get("tun") or data.get("tun") or "")
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    clog(
        "snapshot.block",
        "marked exe blocked in applied-channels until apply",
        exe=key,
        prev_channel=prev,
        snap_tun=data.get("tun"),
    )


def load_applied_channels_snapshot() -> dict[str, str]:
    """Channels actually active in the last successfully started TUN."""
    p = MERGED / "applied-channels.json"
    if not p.is_file() or p.stat().st_size == 0:
        return {}
    try:
        data = json.loads(p.read_text(encoding="utf-8-sig"))
    except Exception:
        return {}
    ch = data.get("channels") or {}
    if not isinstance(ch, dict):
        return {}
    return {str(k).lower(): str(v) for k, v in ch.items()}


def sync_applied_channels_if_config_matches_running_tun() -> dict[str, str]:
    """Refresh snapshot from the config that matches the RUNNING TUN name.

    After «собрали конфиг, но Reload не прошёл» the on-disk vpn-claude-config.json
    may already be the *next* TUN — then we look in backups for the live adapter.
    """
    st = read_status_json()
    running = str(st.get("tun") or "").strip()
    if not running:
        return load_applied_channels_snapshot()

    def _tun_of(cfg: dict) -> str:
        try:
            return str((cfg.get("inbounds") or [{}])[0].get("interface_name") or "")
        except Exception:
            return ""

    cfg_path = MERGED / "vpn-claude-config.json"
    if cfg_path.is_file():
        try:
            cfg = json.loads(cfg_path.read_text(encoding="utf-8-sig"))
            if _tun_of(cfg) == running:
                save_applied_channels_snapshot(cfg)
                return load_applied_channels_snapshot()
        except Exception:
            pass

    # Snapshot already for this tun?
    snap_path = MERGED / "applied-channels.json"
    if snap_path.is_file():
        try:
            snap = json.loads(snap_path.read_text(encoding="utf-8-sig"))
            if str(snap.get("tun") or "") == running and snap.get("channels"):
                return load_applied_channels_snapshot()
        except Exception:
            pass

    bak_dir = MERGED / "backups"
    if bak_dir.is_dir():
        for p in sorted(bak_dir.glob("vpn-claude-config.*.json"), reverse=True):
            try:
                cfg = json.loads(p.read_text(encoding="utf-8-sig"))
                if _tun_of(cfg) == running:
                    save_applied_channels_snapshot(cfg)
                    break
            except Exception:
                continue
    return load_applied_channels_snapshot()


# Подложки и текст для состояний: тон окраски карточки плюс цвет надписи.
#
# Здесь была тёмная палитра (#143029, #30363d…), оставшаяся от первой версии
# окна. Живого кода на ней уже нет, но две кнопки красились отсюда и выглядели
# чёрными пятнами посреди кремового окна, пока это не всплыло. Значения взяты
# из общей палитры, чтобы промах не повторился, если сюда снова кто-то придёт.
UI = {
    "ok": {"bg": THEME["ok_bg"], "fg": THEME["ok_fg"], "accent": THEME["ok_fg"],
           "btn": THEME["ok_fg"], "btn_fg": "#ffffff"},
    "warn": {"bg": THEME["warn_bg"], "fg": THEME["warn_fg"], "accent": THEME["warn_fg"],
             "btn": THEME["warn_fg"], "btn_fg": "#ffffff"},
    "bad": {"bg": THEME["bad_bg"], "fg": THEME["bad_fg"], "accent": THEME["bad_fg"],
            "btn": THEME["bad_fg"], "btn_fg": "#ffffff"},
    "info": {"bg": THEME["info_bg"], "fg": THEME["info_fg"], "accent": THEME["info_fg"],
             "btn": BUTTONS["info"]["fg"], "btn_fg": "#ffffff"},
    "neutral": {"bg": THEME["surface2"], "fg": THEME["muted"], "accent": THEME["border"],
                "btn": BUTTONS["quiet"]["fg"], "btn_fg": BUTTONS["quiet"]["text"]},
}


def compute_step(
    w_ok: bool,
    nodes: int,
    tun: bool,
    *,
    tun_blockers: list[str] | None = None,
    tun_check_pending: bool = False,
    claude_running: bool = False,
    squid: tuple[str, str] = (SQUID_OK, ""),
) -> dict:
    """Что сейчас делать пользователю.

    Вердикты сюда приходят готовыми из status.py — считать их здесь заново
    означало бы завести четвёртое мнение о том же самом.
    """
    if tun and claude_running:
        return {
            "code": "running",
            "theme": "ok",
            "icon": "✅",
            "title": "Всё работает",
            "color": UI["ok"]["bg"],
            "fg": UI["ok"]["fg"],
            "next": "Можно работать. Если пользуетесь Sota — не включайте её, пока защита работает.",
            "primary": "stop",
            "primary_text": "Выключить защиту",
            "primary_hint": "выключить защиту и закрыть Claude",
            "primary_theme": "bad",
        }
    if tun and not claude_running:
        return {
            "code": "tun_only",
            "theme": "warn",
            "icon": "🟡",
            "title": "Защита включена",
            "color": UI["warn"]["bg"],
            "fg": UI["warn"]["fg"],
            "next": "Осталось открыть Claude.",
            "primary": "open_claude",
            "primary_text": "Открыть Claude",
            "primary_hint": "защита уже работает",
            "primary_theme": "ok",
        }
    # Ловца и узлов Sota здесь больше нет как шагов. Раньше они стояли именно тут
    # и запирали программу: на компьютере без Sota пользователь навсегда оставался
    # на «Ставлю ловец узлов», хотя от VPN зависит ровно один канал из четырёх.
    # Теперь это строка о недоступном канале (vpn_note), а не препятствие.
    if not settings_ready():
        return {
            "code": "need_proxy",
            "theme": "warn",
            "icon": "🔑",
            "title": "Нужен ключ подписки",
            "color": UI["warn"]["bg"],
            "fg": UI["warn"]["fg"],
            "next": "Настройки доступа приходят вместе с ключом. "
            "Активируйте ключ или продлите подписку.",
            "primary": "start",
            "primary_text": "Открыть Claude",
            "primary_hint": "сначала ключ",
            "primary_theme": "warn",
        }
    # Until first TUN readiness scan finishes — never claim "можно запускать"
    if tun_check_pending:
        return {
            "code": "checking",
            "theme": "warn",
            "icon": "🟡",
            "title": "Проверяю…",
            "color": UI["warn"]["bg"],
            "fg": UI["warn"]["fg"],
            "next": "Пара секунд.",
            "primary": "start",
            "primary_text": "Открыть Claude",
            "primary_hint": "идёт проверка",
            "primary_theme": "warn",
        }
    blockers = [str(x) for x in (tun_blockers or []) if x]
    if blockers:
        # Человеческие формулировки: пользователь должен понять, что ему сделать,
        # а не расшифровать код. «foreign_tun» ему ничего не говорит.
        human = {
            "sota_connect": "выключите Sota (Disconnect)",
            "foreign_tun": "выключите другую VPN-программу",
            "win_vpn": "отключите VPN-подключение Windows",
            "conflict_soft": "закройте другую VPN-программу",
            "license": "нужен ключ подписки",
            "proxy": "нужен ключ подписки",
            "watcher": "",
            "nodes": "",
            "runtime": "переустановите программу",
        }
        # Пустые строки — это помехи, о которых пользователю знать незачем
        # (ловец, узлы): они не мешают включению, а только сужают каналы.
        bits = [t for t in (human.get(b, b) for b in blockers[:3]) if t]
        return {
            "code": "blocked",
            "theme": "warn",
            "icon": "⛔",
            "title": "Сейчас включить нельзя",
            "color": UI["warn"]["bg"],
            "fg": UI["warn"]["fg"],
            "next": (" · ".join(bits) + ". Потом нажмите «Включить защиту»."
                     if bits else "Идёт проверка, подождите пару секунд."),
            "primary": "start",
            "primary_text": "Открыть Claude",
            "primary_hint": "сначала уберите помеху",
            "primary_theme": "warn",
            "blockers": blockers,
        }
    # Прокси проверяем последним: он решает судьбу одного канала — Claude, — а не
    # всей маршрутизации. Туннель поднимается и без него, остальные каналы живут.
    if squid[0] == SQUID_DOWN:
        return {
            "code": "squid_down",
            "theme": "warn",
            "icon": "🟡",
            "title": "Нет связи с сервером",
            "color": UI["warn"]["bg"],
            "fg": UI["warn"]["fg"],
            "next": "Claude сейчас не откроется. Остальные программы работают. "
            "Если это надолго — напишите в поддержку.",
            "primary": "start",
            "primary_text": "Открыть Claude",
            "primary_hint": "нет связи с сервером",
            "primary_theme": "warn",
        }
    return {
        "code": "ready",
        "theme": "info",
        "icon": "🚀",
        "title": "Готово к включению",
        "color": UI["info"]["bg"],
        "fg": UI["info"]["fg"],
        "next": "Нажмите «Включить защиту».",
        "primary": "start",
        "primary_text": "Открыть Claude",
        "primary_hint": "Windows спросит разрешение",
        "primary_theme": "ok",
    }


class App(ctk.CTk):
    def __init__(self) -> None:
        apply_ctk_globals()
        super().__init__()
        self.title("Claude Proxy Control")
        self.geometry("460x620")
        self.minsize(420, 560)
        self.configure(fg_color=THEME["bg"])
        self._set_app_icon()
        # Крестик = полное закрытие (туннель + Claude + выход)
        self.protocol("WM_DELETE_WINDOW", self.on_quit_program)

        # Paint dark shell immediately — otherwise Windows shows white until __init__ ends
        self._boot_label = ctk.CTkLabel(
            self,
            text="Claude Proxy Control\nзагрузка…",
            font=("Segoe UI Semibold", 16),
            text_color=THEME["muted"],
        )
        self._boot_label.pack(expand=True, fill="both")
        try:
            self.update_idletasks()
            self.update()
        except Exception:
            pass

        self._style()
        init_db()  # cheap; capture import deferred to background
        self._monitor_busy = False
        self._last_monitor_ts = 0.0
        self._capture_wait = False
        self._capture_baseline = None
        self._action_cards: dict[str, dict] = {}
        self._quitting = False
        self._watcher_auto_busy = False
        self._watcher_auto_attempted = False
        self._claude_install_cache: tuple[float, bool, str] | None = None
        self._tun_ready_report: dict | None = None
        self._tun_ready_busy = False
        self._ask_dialog_open = False
        self._ask_snooze: dict[str, float] = {}
        self._apply_routes_busy = False
        self._apply_routes_pending = False
        self._apply_routes_expect: tuple[str, str] = ("", "")
        self._last_vpn_ok: bool | None = None
        self._last_vpn_fail_apply_ts = 0.0
        self._last_squid_ok: bool | None = None
        self._last_squid_fail_apply_ts = 0.0
        self._squid_fail_streak = 0
        self._vpn_fail_streak = 0
        self._last_mon_result_ts = ""
        self._start_ok_ts = 0.0
        self._claude_restart_busy = False
        self._status_cache: dict = {
            "w": {"ok": False, "detail": "…", "task": "", "procs": 0},
            "tun": False,
            "claude": False,
            "nodes": 0,
            "active": None,
            "st": {},
        }
        self._status_probe_busy = False
        self._apps_refresh_busy = False
        self._last_apps_refresh_ts = 0.0
        self._last_import_ts = 0.0
        self._last_heartbeat_ts = 0.0
        self._last_tun_ready_ts = 0.0

        try:
            self._boot_label.destroy()
        except Exception:
            pass
        self._boot_label = None
        self._build()
        # First paint: skip Claude Store detect / heavy probes (fill from cache placeholders)
        self.refresh_all(light=True)
        try:
            self.update_idletasks()
        except Exception:
            pass
        # Один опрос состояния, а не три подряд. Раньше здесь стояли ещё
        # _warm_claude_detect_async и собственный опрос внутри bootstrap: все
        # трое звали одни и те же проверки, и при запуске это было видно глазом.
        self.after(50, self._probe_status_async)
        # TUN readiness ASAP — otherwise first paint falsely says «можно запускать»
        self.after(120, self._run_tun_ready_async)
        self.after(400, self._bootstrap_flow_async)
        # Раньше остального: если правило осталось, у пользователя прямо сейчас
        # может не быть интернета, и об этом надо сказать до всех прочих проверок.
        self.after(600, self._check_killswitch_leftover)
        self.after(2000, self._run_monitor_async)
        self.after(3000, self._tick)

    def _check_killswitch_leftover(self) -> None:
        """Заметить защитное правило, пережившее аварийное завершение программы."""
        left = killswitch_leftover()
        if not left:
            return
        clog("killswitch.leftover", "block rule may still be active", level="ERROR", marker=left)
        from tkinter import messagebox

        ok = messagebox.askyesno(
            "Возможно, интернет заблокирован",
            "Прошлое применение маршрутов оборвалось на середине.\n\n"
            "На время пересборки туннеля программа ставит правило брандмауэра, "
            "которое блокирует весь исходящий трафик, и снимает его сразу после. "
            f"Судя по метке ({left}), снять его не успели — интернета может не быть.\n\n"
            "Снять правило сейчас? Потребуется подтверждение администратора.",
            icon="warning",
            parent=self,
        )
        if not ok:
            return

        def work() -> None:
            done = clear_killswitch_elevated()

            def finish() -> None:
                from tkinter import messagebox as mb

                if done:
                    mb.showinfo("Готово", "Правило снято, интернет должен вернуться.", parent=self)
                else:
                    mb.showerror(
                        "Не получилось",
                        "Правило снять не удалось. Снимите вручную в PowerShell "
                        "от администратора:\n\n"
                        'Remove-NetFirewallRule -DisplayName "CPC-KillSwitch-TunRebuild"',
                        parent=self,
                    )

            self.after(0, finish)

        threading.Thread(target=work, daemon=True).start()

    def _set_app_icon(self) -> None:
        ico = APP_DIR / "claude-proxy-control.ico"
        png = APP_DIR / "claude-proxy-control.png"
        try:
            if ico.is_file():
                self.iconbitmap(default=str(ico))
        except Exception:
            pass
        try:
            if png.is_file():
                self._icon_img = tk.PhotoImage(file=str(png))
                # PhotoImage works best at smaller sizes for title bar
                try:
                    self._icon_img = self._icon_img.subsample(4, 4)
                except Exception:
                    pass
                self.iconphoto(True, self._icon_img)
        except Exception:
            pass

    def _style(self) -> None:
        s = ttk.Style(self)
        try:
            s.theme_use("clam")
        except Exception:
            pass
        # Стили ttk остались от тёмной темы: любой список или ярлык, созданный
        # мимо CustomTkinter, приезжал сюда за чёрным фоном и светлым текстом —
        # посреди кремового окна. Те же имена стилей, цвета из общей палитры.
        bg = THEME["bg"]
        card = THEME["surface"]
        s.configure("TFrame", background=bg)
        s.configure("Card.TFrame", background=card)
        s.configure("TLabel", background=bg, foreground=THEME["text"], font=("Segoe UI", 10))
        s.configure("Card.TLabel", background=card, foreground=THEME["text"])
        s.configure(
            "Title.TLabel",
            font=("Segoe UI Semibold", 17, "bold"),
            foreground=THEME["text"],
            background=bg,
        )
        s.configure("Muted.TLabel", foreground=THEME["muted"], background=bg)
        s.configure("CardMuted.TLabel", foreground=THEME["muted"], background=card)
        s.configure("OK.TLabel", foreground=THEME["ok_fg"], background=card, font=("Segoe UI", 10, "bold"))
        s.configure("Bad.TLabel", foreground=THEME["bad_fg"], background=card, font=("Segoe UI", 10, "bold"))
        s.configure("Warn.TLabel", foreground=THEME["warn_fg"], background=card, font=("Segoe UI", 10, "bold"))
        s.configure("Info.TLabel", foreground=THEME["info_fg"], background=card, font=("Segoe UI", 10, "bold"))
        s.configure("Big.TButton", font=("Segoe UI Semibold", 12, "bold"), padding=14)
        s.configure("TButton", padding=9, font=("Segoe UI", 10))
        s.configure(
            "Treeview",
            rowheight=26,
            font=("Segoe UI", 10),
            fieldbackground=THEME["surface2"],
            background=THEME["surface2"],
            foreground=THEME["text"],
        )
        s.configure("Treeview.Heading", font=("Segoe UI", 10, "bold"))
        s.configure("TNotebook", background=bg, borderwidth=0)
        s.configure("TNotebook.Tab", padding=(12, 6), font=("Segoe UI", 10))


    def _lbl(self, widget, *, text=None, color=None, **kwargs):
        """Configure ttk or CTk label safely."""
        if widget is None:
            return
        opts = dict(kwargs)
        if text is not None:
            opts["text"] = text
        try:
            import customtkinter as ctk

            if isinstance(widget, ctk.CTkBaseClass):
                if color is not None:
                    opts["text_color"] = color
                opts.pop("style", None)
                opts.pop("fg", None)
                opts.pop("bg", None)
                widget.configure(**opts)
                return
        except Exception:
            pass
        if color is not None:
            opts.setdefault("fg", color)
        try:
            widget.configure(**opts)
        except Exception:
            opts.pop("style", None)
            try:
                widget.configure(**{k: v for k, v in opts.items() if k in ("text", "fg", "bg")})
            except Exception:
                pass

    def _btn_theme(
        self, btn, *, style=None, icon=None, fg_color=None, hover=None, text_color=None,
        state=None, text=None, command=None,
    ):
        """Перекрасить кнопку.

        `style` — имя из ui_theme.BUTTONS: цвет заливки, наведения, текста и
        рамки берутся оттуда, а не подбираются в каждом месте заново. `icon` —
        значок из ui_theme.ICONS: по нему состояние кнопки видно раньше, чем
        прочитана подпись. `command` нужен там, где меняется само действие:
        «Скачать Claude» и «Открыть Claude» — одна кнопка в разных состояниях.
        """
        if btn is None:
            return
        if style is not None:
            s = BUTTONS.get(style) or BUTTONS["soft"]
            fg_color = s["fg"] if fg_color is None else fg_color
            hover = s["hover"] if hover is None else hover
            text_color = s["text"] if text_color is None else text_color
        opts = {}
        if text is not None:
            opts["text"] = btn_label(icon, text)
        if state is not None:
            opts["state"] = state
        if command is not None:
            opts["command"] = command
        try:
            import customtkinter as ctk

            if isinstance(btn, ctk.CTkBaseClass):
                if fg_color is not None:
                    opts["fg_color"] = fg_color
                if hover is not None:
                    opts["hover_color"] = hover
                if text_color is not None:
                    opts["text_color"] = text_color
                if style is not None:
                    s = BUTTONS.get(style) or BUTTONS["soft"]
                    opts["border_color"] = s["border"]
                    opts["border_width"] = 1
                    # Свой серый CustomTkinter для выключенной кнопки перебивает
                    # наш текст: «Включаю…» выцветало до неразличимого.
                    opts["text_color_disabled"] = s["text"]
                btn.configure(**opts)
                return
        except Exception:
            pass
        if fg_color is not None:
            opts["bg"] = fg_color
        if text_color is not None:
            opts["fg"] = text_color
        try:
            btn.configure(**opts)
        except Exception:
            pass


    def open_settings(self) -> None:
        win = getattr(self, "_settings_win", None)
        if win is not None:
            try:
                if win.winfo_exists():
                    win.focus_force()
                    win.lift()
                    return
            except Exception:
                pass
        self._settings_win = SettingsWindow(self)

    def _build(self) -> None:
        # Compact CustomTkinter home; apps/nodes/tools live in Settings.
        build_main(self)
        self._step = {"primary": "refresh"}

    def _make_scroll_tree(
        self,
        parent: ttk.Frame,
        columns: tuple[str, ...],
        headings: tuple[tuple[str, str, int], ...],
        height: int = 12,
    ) -> ttk.Treeview:
        """Treeview + vertical scrollbar; mouse wheel scrolls the list (not the page)."""
        wrap = ttk.Frame(parent)
        wrap.pack(fill=tk.BOTH, expand=False, pady=(0, 2))
        vsb = ttk.Scrollbar(wrap, orient=tk.VERTICAL)
        tree = ttk.Treeview(
            wrap,
            columns=columns,
            show="headings",
            height=height,
            yscrollcommand=vsb.set,
        )
        vsb.configure(command=tree.yview)
        vsb.pack(side=tk.RIGHT, fill=tk.Y)
        tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        for col, title, width in headings:
            tree.heading(col, text=title)
            tree.column(col, width=width, minwidth=40)

        def _wheel(event: tk.Event) -> str:
            if getattr(event, "delta", 0):
                tree.yview_scroll(int(-event.delta / 120), "units")
            elif getattr(event, "num", None) == 4:
                tree.yview_scroll(-1, "units")
            elif getattr(event, "num", None) == 5:
                tree.yview_scroll(1, "units")
            return "break"  # don't also scroll the outer page

        tree.bind("<Enter>", lambda _e: tree.focus_set())
        tree.bind("<MouseWheel>", _wheel)
        tree.bind("<Button-4>", _wheel)
        tree.bind("<Button-5>", _wheel)
        return tree

    def _theme(self, name: str) -> dict:
        return UI.get(name) or UI["neutral"]

    def _apply_theme_to_button(self, btn, theme: str) -> None:
        t = self._theme(theme)
        self._btn_theme(
            btn,
            fg_color=t["btn"],
            hover=t["accent"],
            text_color=t["btn_fg"],
        )

    def _pack_action_cards(
        self,
        parent: ttk.Frame,
        items: list[tuple],
        columns: int = 2,
        store: bool = True,
        uniform: str = "actions",
    ) -> None:
        """Colored action cards in a grid; never clipped off-screen."""
        for c in range(columns):
            parent.columnconfigure(c, weight=1, uniform=uniform)
        for i, item in enumerate(items):
            key, icon, title, hint, cmd, theme = item
            t = self._theme(theme)
            cell = ttk.Frame(parent)
            cell.grid(row=i // columns, column=i % columns, sticky="nsew", padx=4, pady=4)

            card = tk.Frame(cell, bg=t["bg"], highlightthickness=1, highlightbackground=t["accent"], cursor="hand2")
            card.pack(fill=tk.BOTH, expand=True)

            strip = tk.Frame(card, bg=t["accent"], width=5)
            strip.pack(side=tk.LEFT, fill=tk.Y)

            body = tk.Frame(card, bg=t["bg"], padx=12, pady=10)
            body.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

            top = tk.Frame(body, bg=t["bg"])
            top.pack(fill=tk.X)
            ic = tk.Label(top, text=icon, font=("Segoe UI Emoji", 16), bg=t["bg"], fg=t["fg"])
            ic.pack(side=tk.LEFT, padx=(0, 8))
            ttl = tk.Label(
                top,
                text=title,
                font=("Segoe UI Semibold", 11, "bold"),
                bg=t["bg"],
                fg=t["fg"],
                anchor="w",
            )
            ttl.pack(side=tk.LEFT, fill=tk.X, expand=True)
            hn = tk.Label(
                body,
                text=hint,
                font=("Segoe UI", 9),
                bg=t["bg"],
                fg="#9aa7b5",
                anchor="w",
                justify="left",
                wraplength=320,
            )
            hn.pack(fill=tk.X, pady=(6, 0))

            def _bind(widget: tk.Misc, command=cmd) -> None:
                widget.bind("<Button-1>", lambda _e, c=command: c())

            for w in (card, strip, body, top, ic, ttl, hn):
                _bind(w)

            if store:
                self._action_cards[key] = {
                    "card": card,
                    "strip": strip,
                    "body": body,
                    "top": top,
                    "ic": ic,
                    "ttl": ttl,
                    "hn": hn,
                    "base_theme": theme,
                    "icon": icon,
                    "title": title,
                }

    def _paint_action_card(self, key: str, theme: str, icon: str | None = None, title: str | None = None) -> None:
        ref = self._action_cards.get(key)
        if not ref:
            return
        t = self._theme(theme)
        for name in ("card", "body", "top", "ic", "ttl", "hn"):
            ref[name].configure(bg=t["bg"])
        ref["strip"].configure(bg=t["accent"])
        ref["card"].configure(highlightbackground=t["accent"])
        ref["ic"].configure(fg=t["fg"], text=icon or ref["icon"])
        ref["ttl"].configure(fg=t["fg"], text=title or ref["title"])
        ref["hn"].configure(fg="#9aa7b5")

    def _tick(self) -> None:
        import time

        # Schedule background probes only. Painting happens in probe/apps callbacks.
        # refresh_all here + deep PS used to freeze window drag every few seconds.
        self._probe_status_async()
        now = time.time()
        if now - self._last_apps_refresh_ts >= 15:
            self._refresh_apps_async()
        # Счётчик ждущих маршрутов держим свежим, но не перебиваем ход применения.
        if not self._apply_routes_busy:
            try:
                self._set_routes_ui(phase="idle")
            except Exception:
                pass
        # While TUN is up — check often (react to Squid/VPN drop fast). Idle — rarely.
        mon_every = 5.0 if self._status_cache.get("tun") else 60.0
        if now - self._last_monitor_ts >= mon_every:
            self._run_monitor_async()
        # While our TUN is off — re-check Sota Connect / foreign VPN often
        if not self._status_cache.get("tun"):
            if now - float(getattr(self, "_last_tun_ready_ts", 0) or 0) >= 20:
                self._run_tun_ready_async(show_dialog=False)
            # Повторяем автоподъём, пока он ещё ни разу не состоялся. Причина
            # отказа обычно временная — проверка ПК не закончилась, пользователь
            # ещё не выключил чужой VPN. Один раз попробовать и замолчать значит
            # оставить компьютер без маршрутизации до перезапуска программы.
            if getattr(self, "_bootstrap_done", False) and not self._capture_wait:
                self._autostart_tunnel()
        # Heartbeat to License API ~ every 6 hours
        if now - float(getattr(self, "_last_heartbeat_ts", 0) or 0) >= 6 * 3600:
            self._last_heartbeat_ts = now
            tun = bool(self._status_cache.get("tun"))

            def _hb() -> None:
                try:
                    send_heartbeat(tun_up=tun)
                except Exception:
                    pass

            threading.Thread(target=_hb, daemon=True).start()
        self.after(2000 if self._status_cache.get("tun") else 5000, self._tick)

    def _probe_status_async(self) -> None:
        """watcher / TUN / nodes — never on UI thread (PowerShell freezes Tk)."""
        if self._status_probe_busy:
            return
        self._status_probe_busy = True

        def work() -> None:
            try:
                # Import captures at most once per minute
                import time

                now = time.time()
                if now - self._last_import_ts >= 60:
                    try:
                        init_db()
                        import_existing_captures()
                    except Exception:
                        pass
                    self._last_import_ts = now
                w = watcher_status()
                # deep adapter check only in this worker thread
                tun = singbox_running_deep()
                claude = claude_process_running()
                nodes = count_nodes()
                active = get_active()
                st = read_status_json()
                # Свой замер прокси — здесь же, в фоне. Раньше единственным
                # источником был отчёт планировщика раз в пять минут, и на чистой
                # машине его первый круг успевал пройти ДО активации ключа: в
                # отчёте оставалось «proxy.env неполный», а кнопка «Запустить
                # Claude» стояла запертой до следующего круга.
                try:
                    probe_and_save(PROXY)
                except Exception:
                    pass
                # Наличие Claude — один кэш на всю программу. Их было два, и оба
                # заполнялись при запуске отдельными потоками: проверка умеет
                # уходить в PowerShell, так что она честно выполнялась дважды.
                try:
                    claude_ok, claude_detail = claude_desktop_installed()
                except Exception:
                    claude_ok, claude_detail = True, ""
                self._claude_install_cache = (time.time(), bool(claude_ok), claude_detail)
                cache = {
                    "w": w,
                    "tun": tun,
                    "claude": claude,
                    "claude_installed": bool(claude_ok),
                    "nodes": nodes,
                    "active": active,
                    "st": st,
                    # Отметка нужна, чтобы стартовый bootstrap не повторял тот же
                    # опрос через полсекунды после этого.
                    "ts": time.time(),
                }
            except Exception:
                cache = None

            def done() -> None:
                self._status_probe_busy = False
                if cache:
                    self._status_cache = cache
                    self.refresh_all(light=True)

            self.after(0, done)

        threading.Thread(target=work, daemon=True).start()

    def _run_monitor_async(self) -> None:
        if self._monitor_busy:
            return
        self._monitor_busy = True
        # Don't flash «проверяю» every 5s — only on first/idle paint or when already warning
        try:
            cur = (self.lbl_mon_overall.cget("text") or "")
            if ("…" in cur) or ("проблема" in cur) or ("нужен" in cur):
                self._lbl(self.lbl_mon_overall, text="🟡  Сеть: проверяю…")
        except Exception:
            pass

        def work() -> None:
            try:
                run_checks()
            except Exception:
                pass
            import time

            self._last_monitor_ts = time.time()
            self._monitor_busy = False

            def done() -> None:
                self._paint_monitor(from_probe=True)
                self._update_start_buttons()

            self.after(0, done)

        threading.Thread(target=work, daemon=True).start()

    def _paint_monitor(self, from_probe: bool = False) -> None:
        """Следит за каналами и закрывает их при падении. Ничего не рисует.

        Раньше отсюда на главный экран шла целая сводка: «Сеть: всё OK · Squid ок
        · VPN ок · TUN вкл · Claude→Squid 91.124.19.37 · VPN-выход … · проверка
        12:31:05 · след. ~5с». Для человека, который видит компьютер впервые, это
        не информация, а шум — состояние теперь показывает одна строка, которую
        рисует _paint_guide_block. Здесь осталось то, ради чего проверки и нужны:
        заметить падение канала и закрыть его, пока трафик не ушёл мимо.
        """
        m = load_last()
        if not m:
            return
        sq = m.get("squid") or {}
        vn = m.get("vpn_node") or {}
        tun_up = bool(m.get("tun_up")) or bool(self._status_cache.get("tun"))
        squid_ok = bool(sq.get("ok"))
        vpn_ok = bool(vn.get("ok"))

        # Пока туннель выключен, каналы никто не проверяет по-настоящему:
        # считать их упавшими и что-то закрывать не за что.
        if not tun_up:
            self._last_vpn_ok = None
            self._last_squid_ok = None
            self._squid_fail_streak = 0
            self._vpn_fail_streak = 0
            return

        now = time.time()
        mon_ts = str(m.get("time") or "")
        if from_probe and mon_ts and mon_ts != getattr(self, "_last_mon_result_ts", ""):
            self._last_mon_result_ts = mon_ts
            self._squid_fail_streak = 0 if squid_ok else int(getattr(self, "_squid_fail_streak", 0)) + 1
            self._vpn_fail_streak = 0 if vpn_ok else int(getattr(self, "_vpn_fail_streak", 0)) + 1

        # Одна осечка ничего не значит: сразу после запуска проверки ещё
        # догоняют реальность. Закрываем канал только после двух подряд.
        grace = (now - float(getattr(self, "_start_ok_ts", 0) or 0)) < 25
        squid_bad = self._squid_fail_streak >= 2 and not grace
        vpn_bad = self._vpn_fail_streak >= 2 and not grace

        prev_v = self._last_vpn_ok
        self._last_vpn_ok = vpn_ok
        if from_probe and vpn_bad and prev_v is not False:
            if now - self._last_vpn_fail_apply_ts >= 30:
                self._last_vpn_fail_apply_ts = now
                self._flash_action(
                    "Пропал VPN — программы этого канала временно без интернета, "
                    "чтобы они не вышли напрямую"
                )
                self._apply_routes_auto()

        prev_s = self._last_squid_ok
        self._last_squid_ok = squid_ok
        if from_probe and squid_bad and prev_s is not False:
            if now - self._last_squid_fail_apply_ts >= 30:
                self._last_squid_fail_apply_ts = now
                self._flash_action(
                    "Нет связи с сервером — Claude временно без интернета, "
                    "чтобы запросы не ушли в обход"
                )
                self._apply_routes_auto()

    def _paint_guide_block(self, step: dict) -> None:
        """Idle guide in the same visual block as start progress (no huge STEP banner)."""
        if getattr(self, "_start_progress_polling", False):
            return
        if not hasattr(self, "lbl_prog_head"):
            return
        code = str(step.get("code") or "")
        nxt = str(step.get("next") or "")

        # Состояние — одной строкой, человеческим языком. Раньше здесь было
        # «VPN/TUN работает», «Прокси молчит», «Сейчас запускать нельзя» — слова,
        # которые пользователю ничего не говорят, а некоторых пугают.
        STATES = {
            "running": ("Всё работает", "ok"),
            "tun_only": ("Защита включена", "ok"),
            "checking": ("Проверяю…", "wait"),
            "ready": ("Готово к включению", "info"),
            "blocked": ("Сейчас включить нельзя", "warn"),
            "need_proxy": ("Нужен ключ подписки", "warn"),
            "squid_down": ("Нет связи с сервером", "warn"),
        }
        headline, tone = STATES.get(code, ("Проверяю…", "wait"))
        colors = {
            "ok": THEME["ok_fg"],
            "warn": THEME["warn_fg"],
            "bad": THEME["bad_fg"],
            "info": THEME["info_fg"],
            "wait": THEME["muted"],
        }
        self._lbl(self.lbl_mon_overall, text=headline, color=colors.get(tone, THEME["text"]))
        # Первая строка пояснения — что это значит, вторая — что делать.
        MEANING = {
            "running": "Claude открыт и работает через ваш доступ.",
            "tun_only": "Программы ходят по вашим правилам.",
            "ready": "Осталось нажать «Включить защиту».",
        }
        self._line(self.lbl_prog_title, MEANING.get(code, ""), pady=(4, 0))
        self._line(self.lbl_prog_step, nxt, pady=(2, 0))

        # Предупреждение после запуска — только если оно есть. Пустая жёлтая
        # строка на светлом фоне читается как «что-то не так».
        prog = self._load_start_progress()
        warns = prog.get("warns") or []
        if code == "running" and prog.get("phase") == "ok" and warns:
            self._show_warning(
                " · ".join(str(x) for x in warns[:3]),
                copy_text=str(prog.get("copy_text") or "\n".join(str(x) for x in warns)),
            )
        elif not getattr(self, "_start_progress_polling", False):
            self._show_warning("")

    def _line(self, widget, text: str, *, color: str | None = None, pady=(0, 0)) -> None:
        """Показать строку или убрать её совсем.

        Пустой ярлык всё равно занимает место, и карточка состояния зияла
        дырами там, где пояснения нет. Пустая строка должна исчезать, а не
        оставлять пробел.
        """
        if widget is None:
            return
        self._lbl(widget, text=text, color=color or THEME["muted"])
        try:
            if text:
                widget.pack(fill="x", pady=pady)
            else:
                widget.pack_forget()
        except Exception:
            pass

    def _show_warning(self, text: str, *, copy_text: str = "") -> None:
        """Показать или спрятать строку предупреждения вместе с кнопкой копирования."""
        warn = getattr(self, "lbl_prog_warn", None)
        btn = getattr(self, "btn_copy_prog", None)
        if warn is None:
            return
        if text:
            self._lbl(warn, text="⚠  " + text, color=THEME["warn_fg"])
            try:
                warn.pack(fill="x", pady=(8, 0))
            except Exception:
                pass
            if btn is not None:
                self._start_progress_copy = copy_text or text
                self._btn_theme(btn, state="normal")
                try:
                    btn.pack(anchor="w", pady=(6, 0))
                except Exception:
                    pass
            return
        try:
            warn.pack_forget()
        except Exception:
            pass
        if btn is not None:
            self._btn_theme(btn, state="disabled")
            try:
                btn.pack_forget()
            except Exception:
                pass

    def refresh_all(self, light: bool = False) -> None:
        """Paint UI. Heavy PS/log work must NOT run here — use cache / background probes."""
        c = self._status_cache
        w = c.get("w") or {"ok": False, "detail": "…"}
        nodes = int(c.get("nodes") or 0)
        tun = bool(c.get("tun"))
        claude = bool(c.get("claude"))
        rep = getattr(self, "_tun_ready_report", None)
        tun_check_pending = (not tun) and (rep is None or bool(getattr(self, "_tun_ready_busy", False)))
        tun_blockers = list((rep or {}).get("blockers") or []) if not tun and not tun_check_pending else []
        squid = self._squid_state()
        vpn = self._vpn_state()
        step = compute_step(
            bool(w.get("ok")),
            nodes,
            tun,
            tun_blockers=tun_blockers,
            tun_check_pending=tun_check_pending,
            claude_running=claude,
            squid=squid,
        )
        # Приписки, а не шаги: недоступный канал и факт того, что перехват уже
        # работает. Раньше отсутствие узлов Sota было отдельным экраном, и
        # программа на нём останавливалась насовсем.
        extra = []
        if tun:
            extra.append(
                "Весь трафик идёт через программу. Незнакомые приложения "
                "блокируются — назначьте им каналы и нажмите «Применить маршруты»."
            )
        note = channel_note(squid, vpn)
        if note:
            extra.append(note)
        if extra and step.get("code") not in ("squid_down", "need_proxy"):
            step = dict(step, next="\n".join([step["next"], *extra]))
        self._step = step

        self._paint_guide_block(step)
        self._refresh_license_bar()
        self._refresh_runtime_summary(w, nodes)

        self._paint_monitor()
        self._update_start_buttons()
        self._paint_watcher_block()
        self._refresh_node_apply_hint()

        if not light:
            self._refresh_apps_async()
            self._refresh_nodes_tree()

    def _refresh_node_apply_hint(self) -> None:
        """Keep 'something changed — press Apply' visible until it is actually applied."""
        if getattr(self, "_apply_routes_busy", False) or getattr(self, "_quitting", False):
            return
        if getattr(self, "_start_progress_polling", False):
            return
        try:
            needs, live, want = vpn_node_needs_apply()
            if needs:
                self._set_routes_ui(
                    phase="idle",
                    text=f"Узел сменён на {want} · в туннеле пока {live} — нажми «Применить маршруты»",
                )
                return
            needs, live_u, want_u = proxy_creds_need_apply()
            if needs:
                self._set_routes_ui(
                    phase="idle",
                    text=(
                        f"Прокси-доступ обновлён ({want_u}) · туннель ещё под {live_u} — "
                        "нажми «Применить маршруты»"
                    ),
                )
        except Exception:
            return

    def _refresh_nodes_tree(self) -> None:
        if not getattr(self, "tree", None):
            return
        prev = ""
        try:
            sel = self.tree.selection()
            if sel:
                prev = str(sel[0])
        except Exception:
            pass
        for i in self.tree.get_children():
            self.tree.delete(i)
        need_geo = False
        for row in list_nodes():
            active = bool(row.get("is_active"))
            vpn = str(row.get("provider") or "Sota").strip() or "Sota"
            country = country_label(row)
            if country == "—":
                need_geo = True
            host = f"{row['server']}:{row['port']}"
            server = f"★ {host}" if active else host
            when = str(row.get("captured_at") or "")
            self.tree.insert(
                "",
                tk.END,
                iid=str(row["id"]),
                values=(vpn, country, server, when),
                tags=("active" if active else "idle",),
            )
        if prev:
            try:
                if self.tree.exists(prev):
                    self.tree.selection_set(prev)
            except Exception:
                pass
        if need_geo:
            self._enrich_nodes_geo_async()

    def _enrich_nodes_geo_async(self) -> None:
        if getattr(self, "_nodes_geo_busy", False):
            return
        self._nodes_geo_busy = True

        def work() -> None:
            try:
                n = enrich_missing_geo(limit=8)
            except Exception:
                n = 0

            def done() -> None:
                self._nodes_geo_busy = False
                if n:
                    self._refresh_nodes_tree()

            self.after(0, done)

        threading.Thread(target=work, daemon=True).start()

    def _bind_nodes_context_menu(self, tree: ttk.Treeview) -> None:
        menu = tk.Menu(tree, tearoff=0)
        menu.add_command(label="⭐ Сделать активным", command=self.on_activate)
        menu.add_separator()
        menu.add_command(label="📋 Скопировать сервер", command=self.on_copy_node_server)

        def popup(event: tk.Event) -> None:
            row = tree.identify_row(event.y)
            if not row:
                return
            tree.selection_set(row)
            tree.focus(row)
            try:
                menu.tk_popup(event.x_root, event.y_root)
            finally:
                menu.grab_release()

        tree.bind("<Button-3>", popup)
        tree.bind("<App>", popup)
        tree.bind("<Double-1>", lambda _e: self.on_activate())

    def on_copy_node_server(self) -> None:
        sel = self.tree.selection() if hasattr(self, "tree") else ()
        if not sel:
            messagebox.showwarning("Узел", "Сначала кликни строку в таблице узлов.")
            return
        try:
            vals = self.tree.item(sel[0], "values") or ()
            text = str(vals[2] if len(vals) > 2 else "").replace("★ ", "").strip()
        except Exception:
            text = ""
        if not text:
            messagebox.showwarning("Узел", "Не удалось прочитать сервер.")
            return
        try:
            self.clipboard_clear()
            self.clipboard_append(text)
            self.update_idletasks()
            self._flash_action(f"Скопировано: {text}")
        except Exception as e:
            messagebox.showerror("Буфер", str(e))

    def _squid_state(self) -> tuple[str, str]:
        """Единственный ответ на вопрос «отвечает ли прокси» — см. status.py."""
        try:
            return squid_state(MERGED, load_last() or {})
        except Exception:
            return SQUID_OK, ""

    def _squid_usable(self) -> bool:
        """Можно ли пускать Claude через прокси.

        «Неизвестно» — не «сломано». Запрещать запуск из-за отсутствия данных
        значит запирать программу на ровном месте, что и случилось на чужом
        компьютере: один протухший отчёт монитора держал кнопку закрытой.
        """
        return self._squid_state()[0] != SQUID_DOWN

    def _vpn_state(self) -> tuple[str, str]:
        w = self._status_cache.get("w") or {}
        return vpn_state(int(self._status_cache.get("nodes") or 0), bool(w.get("ok")))

    def _sota_available(self) -> bool:
        """Есть ли вообще канал VPN — по наличию пойманных узлов Sota."""
        return self._vpn_state()[0] == VPN_OK

    def _start_gate(self) -> tuple[bool, str]:
        """Can we raise Claude+TUN now? (False, reason) if not.

        Special reason \"open_claude\": TUN already up, only reopen Claude Desktop.
        """
        if getattr(self, "_quitting", False):
            return False, "программа закрывается"
        # Cache only — never block Start-button paint with process scans
        tun = bool(self._status_cache.get("tun"))
        claude = bool(self._status_cache.get("claude"))
        if tun and claude:
            # Everything is up: the button becomes a restart, so the user never has to
            # reach for "Остановить Claude" just to bounce the app.
            return True, "restart_claude"
        if tun and not claude:
            return True, "open_claude"
        ok, reason = self._tunnel_gate()
        if not ok:
            return False, reason
        # Claude ходит через Squid, поэтому именно Squid решает, можно ли его
        # запускать. На сам туннель это не влияет: маршрутизация остальных
        # каналов от прокси не зависит и должна работать даже когда он молчит.
        state, detail = self._squid_state()
        if state == SQUID_DOWN:
            return False, f"Claude не запустить: {detail or 'прокси-сервер не отвечает'}"
        return True, ""

    def _tunnel_gate(self) -> tuple[bool, str]:
        """Можно ли вообще поднимать туннель. Без требований к Squid и Sota."""
        info = license_info()
        if not info.get("active"):
            return False, "Нет активной подписки — активируйте ключ."
        if not info.get("proxy_ready"):
            return False, "Настройки доступа не получены — активируйте ключ заново."
        # «Идёт проверка» — только пока отчёта нет вовсе. Раньше сюда входил и
        # флаг занятости, а проверка перезапускается каждые 20 секунд: причина на
        # экране моргала между настоящей и «идёт проверка…», хотя состояние всё
        # это время было известно.
        if self._tun_ready_report is None:
            return False, "Проверяю компьютер, пара секунд…"
        if not self._tun_ready_report.get("ready"):
            # Коды помех наружу не показываем — только то, что человек может сделать.
            human = {
                "sota_connect": "выключите Sota (Disconnect)",
                "foreign_tun": "выключите другую VPN-программу",
                "win_vpn": "отключите VPN-подключение Windows",
                "conflict_soft": "закройте другую VPN-программу",
                "runtime": "переустановите программу",
            }
            bits = [t for t in (human.get(b, "") for b in
                                (self._tun_ready_report.get("blockers") or [])) if t]
            if not bits:
                return False, "Что-то мешает включению. Нажмите «Подробности» в настройках."
            return False, "Мешает: " + ", ".join(bits[:2])
        return True, ""

    def _update_start_buttons(self) -> None:
        """Enable Start Claude only when proxy + TUN readiness are OK."""
        if not hasattr(self, "btn_primary") or getattr(self, "_quitting", False):
            return
        # While we are driving Claude ourselves the button must stay dead. The periodic
        # refresh used to re-enable it mid-start: once the tunnel came up at step 9 the
        # gate saw "TUN up, Claude not running" and painted "Открыть Claude", so a click
        # at step 11 launched Claude, and step 12 launched it a second time.
        # Никаких «шаг 7 из 15»: пятнадцать технических шагов — это наша кухня.
        # Человеку достаточно знать, что идёт работа и что ждать надо недолго.
        busy_text = ""
        if getattr(self, "_start_progress_polling", False):
            busy_text = "Включаю…"
        elif getattr(self, "_claude_restart_busy", False):
            busy_text = "Перезапускаю Claude…"
        elif getattr(self, "_tun_ready_busy", False):
            busy_text = "Проверяю…"
        if busy_text:
            for btn in (getattr(self, "btn_tun", None), self.btn_primary):
                if btn is not None:
                    self._btn_theme(
                        btn, state="disabled", text=busy_text, style="busy", icon="busy"
                    )
            if hasattr(self, "lbl_primary_hint"):
                self._lbl(
                    self.lbl_primary_hint,
                    text="Идёт запуск — подождите, ничего нажимать не нужно",
                    color=THEME["warn_fg"],
                )
            return

        tun = bool(self._status_cache.get("tun"))
        claude_running = bool(self._status_cache.get("claude"))
        # По умолчанию считаем, что Claude есть: до первой фоновой проверки
        # гасить кнопку не за что.
        claude_installed = bool(self._status_cache.get("claude_installed", True))
        hint = ""

        # ── кнопка защиты ───────────────────────────────────────────────────
        # Слова «TUN» здесь больше нет. Для человека это «включить защиту»:
        # программа начинает решать, кто и как выходит в интернет.
        if hasattr(self, "btn_tun"):
            if tun:
                self._btn_theme(
                    self.btn_tun,
                    state="normal",
                    text="Применить изменения",
                    style="info",
                    icon="apply",
                )
                hint = "Защита включена. Применяйте после того, как раздали программам каналы."
            else:
                can_tun, why_tun = self._tunnel_gate()
                if can_tun:
                    self._btn_theme(
                        self.btn_tun,
                        state="normal",
                        text="Включить защиту",
                        style="primary",
                        icon="start",
                    )
                    hint = "Windows спросит разрешение — нажмите «Да»."
                else:
                    # Значок «нельзя» вместо «пуск»: видно раньше, чем прочитана
                    # подпись, и не приходится гадать, почему кнопка не жмётся.
                    self._btn_theme(
                        self.btn_tun,
                        state="disabled",
                        text="Включить защиту",
                        style="off",
                        icon="blocked",
                    )
                    hint = why_tun

        # ── кнопка Claude ───────────────────────────────────────────────────
        # Если Claude не установлен, кнопка не «мертва с упрёком», а ведёт
        # скачивать: человеку нужен следующий шаг, а не сообщение о проблеме.
        if not claude_installed:
            self._btn_theme(
                self.btn_primary,
                state="normal",
                text="Скачать Claude",
                style="info",
                icon="download",
                command=self.on_download_claude,
            )
            hint = (hint + "\n" if hint else "") + "Claude на компьютере не найден — откроем страницу загрузки."
        elif not tun:
            self._btn_theme(
                self.btn_primary,
                state="disabled",
                text="Открыть Claude",
                style="off",
                icon="blocked",
                command=self.on_start_claude,
            )
            hint = (hint + "\n" if hint else "") + "Claude работает через защиту — сначала включите её."
        else:
            can, reason = self._start_gate()
            if not can:
                self._btn_theme(
                    self.btn_primary,
                    state="disabled",
                    text="Открыть Claude",
                    style="off",
                    icon="blocked",
                    command=self.on_start_claude,
                )
                hint = (hint + "\n" if hint else "") + reason
            else:
                self._btn_theme(
                    self.btn_primary,
                    state="normal",
                    text="Перезапустить Claude" if claude_running else "Открыть Claude",
                    style="primary",
                    icon="restart" if claude_running else "start",
                    command=self.on_start_claude,
                )

        if hasattr(self, "lbl_primary_hint"):
            trouble = any(w in hint for w in ("нельзя", "не найден", "Нет ", "не отвеч"))
            self._lbl(
                self.lbl_primary_hint,
                text=hint,
                color=THEME["warn_fg"] if trouble else THEME["muted"],
            )

        # Выключить можно только то, что включено.
        if hasattr(self, "btn_stop_claude"):
            try:
                current = str(self.btn_stop_claude.cget("text") or "")
            except Exception:
                current = ""
            if "Выключаю" in current:
                return
            if tun:
                self._btn_theme(
                    self.btn_stop_claude,
                    state="normal",
                    text="Выключить защиту",
                    style="danger",
                    icon="stop",
                )
            else:
                self._btn_theme(
                    self.btn_stop_claude,
                    state="disabled",
                    text="Выключить защиту",
                    style="off",
                    icon="stop",
                )

    def _flash_action(self, text: str) -> None:
        if hasattr(self, "lbl_action_flash"):
            self._lbl(self.lbl_action_flash, text=text, color=THEME["ok_fg"])
            self.after(4000, lambda: self._lbl(self.lbl_action_flash, text="", color=THEME["muted"]))
        # Mirror onto Apps-tab routes status when settings are open
        if hasattr(self, "lbl_routes_status") and self.lbl_routes_status.winfo_exists():
            try:
                self.lbl_routes_status.configure(text=text, text_color=THEME["ok_fg"])
            except Exception:
                pass

    def _set_routes_ui(self, *, phase: str, text: str = "") -> None:
        """Update Apply-routes button + status on Apps tab (and keep Service in sync)."""
        from datetime import datetime

        btn = getattr(self, "btn_apply_routes", None)
        lbl = getattr(self, "lbl_routes_status", None)
        now = datetime.now().strftime("%H:%M:%S")

        if phase == "busy":
            label = text or "Применяю маршруты…"
            self._btn_theme(btn, state="disabled", text="Применяю…", style="busy", icon="busy")
            if lbl is not None:
                try:
                    lbl.configure(text=label, text_color=THEME["warn_fg"])
                except Exception:
                    pass
            self._flash_action(label)
            return

        if phase == "ok":
            label = text or f"Маршруты применены · {now}"
            self._btn_theme(
                btn, state="normal", text="Применить маршруты", style="primary", icon="apply"
            )
            if lbl is not None:
                try:
                    lbl.configure(text=label, text_color=THEME["ok_fg"])
                except Exception:
                    pass
            self._flash_action(label)
            return

        if phase == "fail":
            label = text or f"Не удалось применить · {now}"
            self._btn_theme(
                btn, state="normal", text="Применить маршруты", style="primary", icon="apply"
            )
            if lbl is not None:
                try:
                    lbl.configure(text=label, text_color=THEME["bad_fg"])
                except Exception:
                    pass
            self._flash_action(label)
            return

        # idle / hint — единая строка про состояние трафика (см. _routing_hint)
        label = text or self._routing_hint()
        self._btn_theme(
            btn, state="normal", text="Применить маршруты", style="primary", icon="apply"
        )
        if lbl is not None:
            try:
                lbl.configure(text=label, text_color=THEME["muted"])
            except Exception:
                pass

    def _paint_watcher_block(self) -> None:
        """Состояние ловца узлов и две кнопки под ним (вкладка «Сервис»).

        Обе кнопки были активны всегда, и по ним нельзя было понять, установлен
        ловец или нет: «Установить» на установленном и «Остановить» на
        неустановленном — это нажатия, которые ничего не меняют. Кнопка должна
        быть доступна тогда, когда от неё есть толк.
        """
        lbl = getattr(self, "lbl_watcher", None)
        b_install = getattr(self, "btn_watcher_install", None)
        b_stop = getattr(self, "btn_watcher_stop", None)
        if lbl is None or b_install is None or b_stop is None:
            return
        try:
            if not lbl.winfo_exists():
                return
        except Exception:
            return

        w = self._status_cache.get("w") or {}
        task = str(w.get("task") or "")
        running = bool(w.get("ok"))

        if running:
            self._lbl(lbl, text="Ловец узлов установлен, запущен и работает.", color=THEME["ok_fg"])
            self._btn_theme(
                b_install, state="disabled", text="Ловец уже установлен", style="off", icon="ok"
            )
            self._btn_theme(
                b_stop, state="normal", text="Остановить ловец", style="soft", icon="stop"
            )
            return

        if task:
            # Задание в планировщике есть, но процесс не поднят: чинится тем же
            # «Установить» — оно перерегистрирует задание и сразу запускает.
            self._lbl(
                lbl,
                text="Ловец узлов установлен, но сейчас не работает.",
                color=THEME["warn_fg"],
            )
            self._btn_theme(
                b_install, state="normal", text="Запустить ловец", style="soft", icon="start"
            )
            # «Остановить» здесь оставляем живой: задание в планировщике есть, и
            # если оно почему-то не работает, человек должен иметь возможность
            # его убрать, а не остаться с ним навсегда.
            self._btn_theme(
                b_stop, state="normal", text="Убрать ловец", style="soft", icon="stop"
            )
            return

        self._lbl(lbl, text="Ловец узлов не установлен.", color=THEME["muted"])
        self._btn_theme(
            b_install, state="normal", text="Установить ловец узлов", style="soft", icon="download"
        )
        self._btn_theme(
            b_stop, state="disabled", text="Остановить ловец", style="off", icon="stop"
        )

    def autostart_enabled(self) -> bool:
        """Включать ли защиту при запуске программы. По умолчанию — нет."""
        return bool(load_settings().get("autostart_tunnel", False))

    def on_toggle_autostart(self, value: bool) -> None:
        s = load_settings()
        s["autostart_tunnel"] = bool(value)
        save_settings(s)
        clog("settings.autostart", "autostart changed", value=bool(value))
        self._flash_action(
            "Защита будет включаться сама при запуске программы"
            if value
            else "Защиту включаете вы — кнопкой «Включить защиту»"
        )

    def on_refresh_status(self) -> None:
        """Re-read watcher/nodes/TUN into the UI. Does not restart anything."""
        from datetime import datetime

        self._probe_status_async()
        self._refresh_apps_async()
        self._refresh_nodes_tree()
        w = self._status_cache.get("w") or {}
        nodes = int(self._status_cache.get("nodes") or 0)
        tun = "вкл" if self._status_cache.get("tun") else "выкл"
        self._flash_action(
            f"Обновляю… {datetime.now().strftime('%H:%M:%S')} — "
            f"ловец={'ОК' if w.get('ok') else '…'}, узлов={nodes}, TUN={tun}"
        )

    def on_check_network(self) -> None:
        from datetime import datetime

        self._flash_action(f"Проверяю сеть… {datetime.now().strftime('%H:%M:%S')}")
        self._run_monitor_async()

    def _refresh_license_bar(self) -> None:
        info = license_info()
        if info.get("active"):
            days = int(info.get("days_left") or 0)
            # Склонение: «1 день», «3 дня», «10 дней».
            tail, hundred = days % 10, days % 100
            if tail == 1 and hundred != 11:
                word = "день"
            elif tail in (2, 3, 4) and hundred not in (12, 13, 14):
                word = "дня"
            else:
                word = "дней"
            text = f"Подписка до {info.get('expires_human') or '?'}"
            # Про скорое окончание говорим заранее и заметно, а не в день Х.
            fg = THEME["warn_fg"] if days <= 7 else THEME["text"]
            sub = f"Осталось {days} {word}"
            if days <= 7:
                sub += " — продлите, чтобы доступ не прервался"
        elif info.get("expired"):
            text = "Подписка закончилась"
            sub = "Нажмите «Продлить подписку» — доступ вернётся сразу после оплаты"
            fg = THEME["bad_fg"]
        else:
            text = "Подписка не активирована"
            sub = "Введите ключ из письма или кабинета"
            fg = THEME["warn_fg"]
        if hasattr(self, "lbl_license"):
            self._lbl(self.lbl_license, text=text, color=fg)
        # Вторая строка карточки — про подписку, а не про службы. Раньше здесь
        # висело «запущена · установлен · проверка каждые 2 сек · новые узлы
        # появляются в списке…» — набор слов, который человеку ничего не даёт.
        if hasattr(self, "lbl_runtime_summary"):
            self._lbl(self.lbl_runtime_summary, text=sub, color=THEME["muted"])

    def _claude_install_cached(self) -> tuple[bool, str]:
        """Never block UI on Get-AppxPackage — use cache or optimistic placeholder."""
        import time

        now = time.time()
        cache = self._claude_install_cache
        if cache and (now - cache[0]) < 300:
            return cache[1], cache[2]
        # Placeholder until _warm_claude_detect_async fills cache
        return True, "проверяю…"

    def _warm_claude_detect_async(self) -> None:
        def work() -> None:
            import time

            try:
                ok, detail = claude_desktop_installed()
            except Exception:
                ok, detail = False, "ошибка проверки"
            self._claude_install_cache = (time.time(), ok, detail)

            def done() -> None:
                try:
                    self._refresh_runtime_summary()
                except Exception:
                    pass

            self.after(0, done)

        threading.Thread(target=work, daemon=True).start()

    def _refresh_claude_install_label(self) -> None:
        """Back-compat: refresh the runtime summary block."""
        w = self._status_cache.get("w") or {"ok": False, "detail": "…"}
        nodes = int(self._status_cache.get("nodes") or 0)
        self._refresh_runtime_summary(w, nodes)

    def _refresh_runtime_summary(self, w: dict | None = None, nodes: int | None = None) -> None:
        """Раньше собирала пять пронумерованных строк вида «3. Ловец конфигов
        VPN: установлен и работает · проверка каждые 2 сек · новые узлы
        появляются в списке без перезапуска…» и вжимала их в одну строчку под
        лицензией. Получался набор слов, из которого пользователь не мог
        извлечь ни одного полезного действия.

        Теперь эта строка занята сроком подписки (её пишет _refresh_license_bar),
        а вся техническая сводка живёт в отчёте «Собрать отчёт» — там она нужна
        и там её читает тот, кто умеет.
        """
        return

    def on_open_support(self) -> None:
        """Поддержка — в один клик с главного экрана, а не поиском по письмам."""
        try:
            webbrowser.open(SUPPORT_URL)
            self._flash_action("Открываю поддержку в браузере")
        except Exception as e:
            messagebox.showinfo("Поддержка", f"{SUPPORT_URL}\n\n{e}")

    def on_open_site(self) -> None:
        try:
            webbrowser.open(SITE_URL)
            self._flash_action("Открываю сайт")
        except Exception as e:
            messagebox.showinfo("Сайт", f"{SITE_URL}\n\n{e}")

    def on_download_claude(self) -> None:
        """Claude не установлен — отправляем скачивать, а не сообщаем об ошибке."""
        try:
            webbrowser.open(CLAUDE_DOWNLOAD_URL)
            self._flash_action("Открываю страницу загрузки Claude")
        except Exception as e:
            messagebox.showinfo("Скачать Claude", f"{CLAUDE_DOWNLOAD_URL}\n\n{e}")

    def on_extend_license(self) -> None:
        """Open cabinet via one-time magic link (no login)."""
        if not (license_info().get("has_key") or license_info().get("active") or license_info().get("expired")):
            messagebox.showerror(
                "Нет ключа",
                "Сначала активируйте ключ — потом можно продлить в кабинете.",
            )
            return
        self.btn_extend.configure(state=tk.DISABLED, text="Открываю…")
        self.update_idletasks()

        def work() -> None:
            err = None
            url = ""
            try:
                url = open_magic_cabinet()
            except Exception as e:
                err = str(e)

            def done() -> None:
                try:
                    self.btn_extend.configure(state=tk.NORMAL, text="Продлить лицензию")
                except Exception:
                    pass
                if err:
                    messagebox.showerror("Кабинет", err)
                    return
                webbrowser.open(url)
                messagebox.showinfo(
                    "Кабинет",
                    "Открыл личный кабинет в браузере (без пароля).\n"
                    "Там выберите срок (1–12 мес.) и нажмите «Продлить» у нужного ключа.",
                )

            self.after(0, done)

        threading.Thread(target=work, daemon=True).start()

    def on_change_key(self) -> None:
        dlg = tk.Toplevel(self)
        dlg.title("Сменить ключ")
        dlg.geometry("480x220")
        dlg.resizable(False, False)
        dlg.transient(self)
        dlg.grab_set()
        frm = ttk.Frame(dlg, padding=16)
        frm.pack(fill="both", expand=True)
        ttk.Label(
            frm,
            text="Новый ключ привяжется к этому ПК.\n"
            "Старый ключ с этого же железа снова активируется, если подписка активна.",
            justify="left",
        ).pack(anchor="w", pady=(0, 10))
        key_var = tk.StringVar()
        ent = tk.Entry(frm, textvariable=key_var, width=48)
        ent.pack(fill="x", pady=(0, 6))
        ent.focus_set()
        status = ttk.Label(frm, text="")
        status.pack(anchor="w", pady=(0, 8))

        def do_paste(_event=None):
            try:
                text = dlg.clipboard_get()
            except tk.TclError:
                return "break"
            try:
                if ent.selection_present():
                    ent.delete("sel.first", "sel.last")
            except tk.TclError:
                pass
            ent.insert("insert", text)
            return "break"

        for seq in ("<Control-v>", "<Control-V>", "<<Paste>>", "<Shift-Insert>", "<Control-m>", "<Control-M>"):
            ent.bind(seq, do_paste)

        def do_ok() -> None:
            key = key_var.get().strip()
            if not key:
                status.configure(text="Введите ключ")
                return
            status.configure(text="Активация…")
            dlg.update_idletasks()
            try:
                activate_license(key)
                from license_client import load_license

                lic = load_license() or {}
                proxy = lic.get("proxy") or {}
                if proxy.get("host"):
                    s = load_settings()
                    s["proxy_host"] = proxy["host"]
                    s["proxy_port"] = int(proxy.get("port") or 13128)
                    s["proxy_user"] = proxy.get("user") or "proxy"
                    s["proxy_pass"] = proxy.get("password") or ""
                    save_settings(s)
                    sync_proxy_env(s)
                info = license_info()
                self._refresh_license_bar()
                messagebox.showinfo(
                    "Ключ",
                    f"Активировано до {info.get('expires_human') or '?'} "
                    f"({info.get('days_left')} дн.).",
                    parent=self,
                )
                dlg.destroy()
                self.refresh_all()
            except Exception as e:
                status.configure(text=str(e))

        btns = ttk.Frame(frm)
        btns.pack(fill="x")
        ttk.Button(btns, text="Вставить", command=do_paste).pack(side="left")
        ttk.Button(btns, text="Активировать", command=do_ok).pack(side="left", padx=(8, 0))
        ttk.Button(btns, text="Отмена", command=dlg.destroy).pack(side="right")
        dlg.bind("<Return>", lambda _e: do_ok())

    def on_start_claude(self) -> None:
        """Fresh TUN readiness check, then raise tunnel + launch Claude.

        If TUN is already up and Claude is closed — just reopen Claude Desktop.
        """
        if getattr(self, "_quitting", False):
            return
        if getattr(self, "_claude_restart_busy", False):
            return
        tun_up = bool(self._status_cache.get("tun")) or singbox_running()
        if tun_up:
            if claude_process_running():
                self.on_restart_claude()
                return
            ok, detail = claude_desktop_installed()
            if not ok:
                messagebox.showerror(
                    "Claude не установлен",
                    "Скачайте Claude с claude.ai/download и установите — потом откроем его отсюда.",
                )
                return
            self._flash_action("Открываю Claude Desktop (TUN уже работает)…")
            launched = launch_claude_desktop()
            if launched:
                self._status_cache["claude"] = True
                self._flash_action("Claude Desktop открыт")
                self.refresh_all(light=True)
            else:
                messagebox.showerror(
                    "Не удалось открыть Claude",
                    "TUN работает, но Claude Desktop не запустился.\n"
                    "Открой его вручную из меню Пуск.",
                )
            return
        info = license_info()
        if not info.get("active"):
            messagebox.showerror("Запуск недоступен", "нет активной лицензии")
            return
        if not info.get("proxy_ready"):
            messagebox.showerror(
                "Запуск недоступен",
                "прокси с сервера не получен / не готов — активируйте ключ",
            )
            return
        ok, detail = claude_desktop_installed()
        if not ok:
            messagebox.showerror(
                "Claude не установлен",
                "Скачайте Claude с claude.ai/download и установите — потом откроем его отсюда.",
            )
            return
        # Перед поднятием TUN — ещё раз полная диагностика (Sota/чужие VPN и т.д.)
        self._start_after_fresh_tun_check()

    def on_restart_claude(self) -> None:
        """Bounce Claude Desktop without touching the tunnel.

        Idempotent by design: closes Claude only if it is actually running, then starts
        it. So the same button works whether the user already closed Claude or not.
        """
        if getattr(self, "_quitting", False) or getattr(self, "_claude_restart_busy", False):
            return
        ok, detail = claude_desktop_installed()
        if not ok:
            messagebox.showerror(
                "Claude не установлен",
                "Скачайте Claude с claude.ai/download и установите — потом откроем его отсюда.",
            )
            return

        self._claude_restart_busy = True
        self._update_start_buttons()
        self._flash_action("Перезапускаю Claude…")
        clog_banner("RESTART_CLAUDE", tun=str(self._status_cache.get("tun") or ""))

        def work() -> None:
            import time as _time

            launched = False
            msg = ""
            try:
                if claude_process_running():
                    clog("restart.kill", "closing Claude before relaunch")
                    kill_claude_processes()
                    for _ in range(24):  # up to ~6s for the processes to disappear
                        if not claude_process_running():
                            break
                        _time.sleep(0.25)
                    clog("restart.killed", "close done", still=claude_process_running())
                else:
                    clog("restart.kill", "Claude was not running — just start it")
                launched = launch_claude_desktop()
                clog("restart.launch", "launch_claude_desktop", ok=launched)
                msg = "Claude перезапущен" if launched else "Не удалось запустить Claude"
            except Exception as e:
                clog_exc("restart.exc", "restart failed", e)
                msg = f"Ошибка перезапуска: {e}"

            def done() -> None:
                self._claude_restart_busy = False
                if launched:
                    self._status_cache["claude"] = True
                self._flash_action(msg)
                if not launched:
                    try:
                        messagebox.showerror("Перезапуск Claude", msg)
                    except Exception:
                        pass
                self.refresh_all(light=True)
                self._update_start_buttons()

            self.after(0, done)

        threading.Thread(target=work, daemon=True).start()

    def _start_after_fresh_tun_check(self) -> None:
        if self._tun_ready_busy:
            messagebox.showinfo("Подождите", "Уже идёт проверка готовности ПК…")
            return
        self._tun_ready_busy = True
        try:
            self._btn_theme(
                self.btn_primary,
                state="disabled",
                text="Проверяю…",
                style="busy",
                icon="busy",
            )
            if hasattr(self, "lbl_primary_hint"):
                self._lbl(
                    self.lbl_primary_hint,
                    text="Проверяю компьютер перед включением…",
                    color=THEME["muted"],
                )
        except Exception:
            pass
        self.update_idletasks()

        info = license_info()
        w = watcher_status()
        nodes = count_nodes()

        def work() -> None:
            try:
                report = run_tun_ready_check(
                    license_active=bool(info.get("active")),
                    proxy_ready=bool(info.get("proxy_ready")),
                    proxy_host=str(info.get("proxy_host") or ""),
                    watcher_ok=bool(w.get("ok")),
                    nodes=int(nodes),
                    our_tunnel_up=False,
                )
            except Exception as e:
                report = {
                    "ready": False,
                    "summary": f"Ошибка диагностики: {e}",
                    "lines": [{"level": "bad", "text": str(e)}],
                    "blockers": ["error"],
                    "warnings": [],
                }

            def done() -> None:
                self._tun_ready_busy = False
                self._tun_ready_report = report
                try:
                    self._refresh_runtime_summary()
                    self._paint_action_card(
                        "diagnose",
                        "ok" if report.get("ready") else "bad",
                        title="Готовность TUN · ОК" if report.get("ready") else "Готовность TUN · блокеры",
                    )
                except Exception:
                    pass
                self._update_start_buttons()
                if not report.get("ready"):
                    blockers = ", ".join(report.get("blockers") or []) or "блокеры"
                    messagebox.showerror(
                        "Пока нельзя включить",
                        f"Мешает: {blockers}\n\n"
                        "Уберите помеху и нажмите снова.",
                    )
                    self._show_tun_ready_dialog(report)
                    return
                can, reason = self._start_gate()
                if not can:
                    messagebox.showerror("Запуск недоступен", reason)
                    return
                self.on_start()

            self.after(0, done)

        threading.Thread(target=work, daemon=True).start()

    def on_stop_claude(self) -> None:
        """Stop tunnel + close Claude, keep this program open."""
        if not messagebox.askyesno(
            "Выключить защиту?",
            "Claude закроется, программы перестанут ходить по вашим правилам.\nОкно останется открытым.\n\nWindows спросит разрешение — нажмите «Да».",
        ):
            return
        self.btn_stop_claude.configure(state=tk.DISABLED, text="⏹  Останавливаю…")
        self.update_idletasks()

        def work() -> None:
            ok = elevated_stop_product(timeout=60, attempts=2)

            def done() -> None:
                self._after_stop_claude()
                if not ok:
                    messagebox.showwarning(
                        "Не удалось выключить",
                        "Похоже, разрешение Windows не подтвердили.\n\n"
                        "Нажмите «Выключить защиту» ещё раз и ответьте «Да».",
                    )

            self.after(0, done)

        threading.Thread(target=work, daemon=True).start()

    def _after_stop_claude(self) -> None:
        try:
            self.btn_stop_claude.configure(state=tk.NORMAL, text="⏹  Остановить TUN")
        except Exception:
            pass
        # Force fresh probe so buttons match real TUN state
        try:
            self._status_cache["tun"] = singbox_running()
            self._status_cache["claude"] = claude_process_running()
        except Exception:
            pass
        self.refresh_all()
        try:
            self._probe_status_async()
        except Exception:
            pass

    def _show_exit_overlay(self, mode: str) -> None:
        """Replace whole window with a calm full-screen status (quit / restart)."""
        # Close settings if open
        win = getattr(self, "_settings_win", None)
        if win is not None:
            try:
                win.destroy()
            except Exception:
                pass
            self._settings_win = None

        # Hide current UI
        for child in list(self.winfo_children()):
            try:
                child.pack_forget()
            except Exception:
                pass
            try:
                child.grid_forget()
            except Exception:
                pass
            try:
                child.place_forget()
            except Exception:
                pass

        # Спокойный экран без технических слов. Раньше здесь были «туннель» и
        # «UAC»; человек читал это как сообщение об ошибке и звонил в поддержку.
        if mode == "restart":
            emoji = "🔄"
            title = "Перезапускаю"
            detail = (
                "Закрываю Claude и выключаю защиту.\n"
                "Если Windows спросит разрешение — нажмите «Да».\n"
                "Окно откроется снова само."
            )
            accent = THEME["info_fg"]
        else:
            emoji = "👋"
            title = "Закрываю"
            detail = (
                "Закрываю Claude и выключаю защиту.\n"
                "Если Windows спросит разрешение — нажмите «Да»."
            )
            accent = THEME["accent_fg"]

        overlay = ctk.CTkFrame(self, fg_color=THEME["bg"])
        overlay.pack(fill="both", expand=True)
        self._exit_overlay = overlay
        box = ctk.CTkFrame(
            overlay,
            fg_color=THEME["surface"],
            corner_radius=18,
            border_width=1,
            border_color=THEME["border"],
        )
        box.place(relx=0.5, rely=0.45, anchor="center", relwidth=0.88)
        ctk.CTkLabel(box, text=emoji, font=("Segoe UI", 38)).pack(padx=24, pady=(30, 4))
        ctk.CTkLabel(
            box,
            text=title,
            font=("Segoe UI Semibold", 20),
            text_color=accent,
            wraplength=360,
        ).pack(padx=24, pady=(0, 10))
        ctk.CTkLabel(
            box,
            text=detail,
            font=("Segoe UI", 12),
            text_color=THEME["muted"],
            justify="center",
            wraplength=340,
        ).pack(padx=24, pady=(0, 30))
        try:
            self.update_idletasks()
            self.update()
        except Exception:
            pass

    def on_restart_program(self) -> None:
        """Stop TUN + close Claude, then reopen CPC UI — Claude must not leak without proxy."""
        if self._quitting:
            return
        if not messagebox.askyesno(
            "Перезапустить программу?",
            "Claude закроется, защита на время выключится, "
            "и окно откроется заново.\n\n"
            "Windows спросит разрешение — нажмите «Да».",
        ):
            return
        self._quitting = True
        self._show_exit_overlay("restart")
        # Close Claude immediately so it cannot browse while TUN dies
        try:
            kill_claude_processes()
        except Exception:
            pass

        launcher = APP_DIR / "Run-Product.bat"
        if not launcher.is_file():
            launcher_cmd = (
                f"Start-Process -FilePath '{sys.executable}' "
                f"-ArgumentList @('{Path(__file__).resolve()}') "
                f"-WorkingDirectory '{APP_DIR}'"
            )
        else:
            launcher_cmd = (
                f"Start-Process -FilePath '{launcher}' -WorkingDirectory '{APP_DIR}'"
            )

        pid = os.getpid()
        # Wait until this process exits (mutex released), then start again.
        ps = (
            f"$target = {pid}; "
            "Start-Sleep -Seconds 1; "
            "Wait-Process -Id $target -Timeout 90 -ErrorAction SilentlyContinue; "
            "Start-Sleep -Milliseconds 600; "
            f"{launcher_cmd}"
        )
        try:
            subprocess.Popen(
                [
                    str(PWSH),
                    "-NoProfile",
                    "-ExecutionPolicy",
                    "Bypass",
                    "-WindowStyle",
                    "Hidden",
                    "-Command",
                    ps,
                ],
                creationflags=CREATE_NO_WINDOW,
                cwd=str(APP_DIR),
            )
        except Exception as e:
            self._quitting = False
            messagebox.showerror("Перезапуск", f"Не удалось запланировать перезапуск:\n{e}")
            # Hard recovery: relaunch UI cleanly
            try:
                self.destroy()
            except Exception:
                pass
            return

        def work() -> None:
            # Restart scheduler already armed — still must stop TUN before UI dies
            elevated_stop_product(timeout=60, attempts=2)
            self.after(0, self._finish_quit)

        threading.Thread(target=work, daemon=True).start()

    def on_primary(self) -> None:
        self.on_start_claude()

    def _capture_fingerprint(self) -> str:
        """Fingerprint of current capture state for change detection."""
        latest = MERGED / "sota-captured" / "latest.json"
        parts = [f"nodes={count_nodes()}"]
        if latest.is_file():
            st = latest.stat()
            parts.append(f"mtime={int(st.st_mtime)}")
            parts.append(f"size={st.st_size}")
        else:
            parts.append("latest=missing")
        status = MERGED / "sota-captured" / "capture-status.txt"
        if status.is_file():
            parts.append(f"status_m={int(status.stat().st_mtime)}")
        return "|".join(parts)

    def start_capture_wait(self, force_prompt: bool = False) -> None:
        """Poll every 2s until a new Sota config appears. Only while TUN is OFF."""
        if singbox_running():
            self._capture_wait = False
            return
        if not watcher_status()["ok"]:
            return
        if count_nodes() > 0 and not force_prompt:
            return
        if self._capture_wait and not force_prompt:
            return

        self._capture_baseline = self._capture_fingerprint()
        self._capture_wait = True
        # Ждать снимок бесконечно нельзя: на компьютере, где Sota вообще не
        # установлена, он не появится никогда, а программа так и стоит без
        # маршрутизации. Ждём ограниченное время, потом поднимаем туннель без
        # канала VPN — ровно то поведение, которое обещано в руководстве.
        self._capture_deadline = time.time() + CAPTURE_WAIT_SEC
        if force_prompt or count_nodes() <= 0:
            messagebox.showinfo(
                "Поймай узел Sota",
                "1) Открой Sota VPN (ты ставишь его сам)\n"
                "2) Нажми Connect\n"
                "3) Подожди 2–3 секунды\n"
                "4) Нажми Disconnect\n\n"
                "Это окно само проверяет каждые 2 секунды и сразу увидит конфиг.\n"
                "Пока наш Claude+VPN не запущен — Connect в Sota можно жать.",
            )
        self.lbl_next.configure(
            text="Жду снимок… Открой Sota → Connect → Disconnect. Проверка каждые 2 сек.",
        )
        self.after(2000, self._poll_capture_wait)

    def _poll_capture_wait(self) -> None:
        if not self._capture_wait:
            return
        # While our tunnel works — stop waiting (Connect in Sota forbidden)
        if singbox_running():
            self._capture_wait = False
            self.refresh_all()
            return
        import_existing_captures()
        fp = self._capture_fingerprint()
        nodes = count_nodes()
        baseline = self._capture_baseline or ""
        started_empty = "nodes=0" in baseline
        latest = MERGED / "sota-captured" / "latest.json"
        got_new = fp != baseline
        first_ok = started_empty and (nodes > 0 or latest.is_file())
        refresh_ok = (not started_empty) and got_new and nodes > 0
        if first_ok or refresh_ok:
            self._capture_wait = False
            # ensure DB imported latest
            import_existing_captures()
            active = get_active()
            detail = ""
            if active:
                detail = f"\n\nУзел: {active.get('server')}:{active.get('port')}"
            messagebox.showinfo(
                "Узел пойман!",
                "Конфиг из Sota получен." + detail
                + "\n\nДальше: настройки Squid (если ещё нет) → Запустить Claude + VPN.\n"
                "Когда туннель уже работает — Connect в Sota больше не жми "
                "(и новые снижки программа в этом режиме не ждёт).",
            )
            self.refresh_all()
            return
        from datetime import datetime

        left = float(getattr(self, "_capture_deadline", 0) or 0) - time.time()
        if left <= 0:
            self._capture_wait = False
            clog("capture.timeout", "no Sota config captured, going on without VPN")
            self._set_routes_ui(
                phase="idle",
                text=(
                    "Снимок Sota так и не появился — включаю маршрутизацию без канала VPN. "
                    "Приложения на канале «VPN» будут заблокированы; остальное работает. "
                    "Поймать узел можно позже: Sota → Connect → Disconnect."
                ),
            )
            self.refresh_all()
            self._autostart_tunnel()
            return

        self.lbl_next.configure(
            text=f"Жду снимок… ещё {int(left)} с  ·  Sota: Connect → Disconnect "
            f"(проверка {datetime.now().strftime('%H:%M:%S')})",
        )
        self.after(2000, self._poll_capture_wait)

    def _sync_watcher_scripts(self) -> None:
        """Keep capture scripts in product home in sync with the app package."""
        src_dir = APP_DIR.parent / "runtime"
        if not src_dir.is_dir():
            return
        PROXY.mkdir(parents=True, exist_ok=True)
        for name in (
            "_env.ps1",
            "Watch-SotaCapture.ps1",
            "Install-SotaCaptureWatcher.ps1",
            "Stop-SotaCaptureWatcher.ps1",
        ):
            src = src_dir / name
            if src.is_file():
                try:
                    (PROXY / name).write_bytes(src.read_bytes())
                except Exception:
                    pass

    def _ensure_watcher_auto(self, *, force: bool = False) -> None:
        """Install/start capture watcher without asking the user (UAC only)."""
        if self._watcher_auto_busy:
            return
        if self._watcher_auto_attempted and not force:
            return
        if not load_settings().get("auto_install_watcher", True):
            return
        self._watcher_auto_busy = True
        self._watcher_auto_attempted = True
        try:
            if hasattr(self, "lbl_next"):
                self.lbl_next.configure(
                    text="Ставлю ловец узлов автоматически… Подтверди UAC (Да).",
                )
            if hasattr(self, "lbl_action_flash"):
                self.lbl_action_flash.configure(
                    text="📡 Ловец ставится сам — нужен только UAC (Да).",
                    style="Warn.TLabel",
                )
        except Exception:
            pass
        self.on_install_watcher(quiet=True)

    def _bootstrap_flow(self) -> None:
        """First-run sequence: watcher → wait first capture (TUN must be off)."""
        # Cache only — never watcher_status()/singbox_running() on UI thread.
        if self._status_cache.get("tun"):
            self._capture_wait = False
            return
        # Пока человек не попросил включать защиту автоматически, программа при
        # запуске не делает ничего сама: не ставит ловец узлов (а это запрос прав
        # у Windows), не показывает окно про Sota и не поднимает туннель. Окно
        # открывают и просто чтобы посмотреть каналы или подписку.
        #
        # Ничего при этом не теряется: ловец ставится при нажатии «Включить
        # защиту» (см. on_start), а узел Sota ловится кнопкой «Обновить узел» в
        # настройках. Оба действия — по воле человека, а не вместо него.
        if not bool(load_settings().get("autostart_tunnel", False)):
            self._autostart_tunnel()
            return
        w = self._status_cache.get("w") or {}
        if not w.get("ok"):
            # Ловец нужен только каналу VPN — ставим его, но старт не откладываем.
            self._ensure_watcher_auto(force=True)
        # Конфиг Sota ловится, только пока НАШ туннель выключен: Sota должна
        # подняться сама. Поэтому на самом первом запуске сначала даём поймать
        # узлы, и лишь потом переходим к «программа работает = трафик под
        # контролем». Иначе автоподъём навсегда закрыл бы дорогу каналу VPN.
        if w.get("ok") and int(self._status_cache.get("nodes") or 0) <= 0:
            self._set_routes_ui(
                phase="idle",
                text=(
                    "Маршрутизация ещё выключена: ловим настройки Sota. "
                    "Сделайте в Sota Connect → Disconnect, либо пропустите — "
                    "тогда канал VPN будет недоступен, а остальное заработает."
                ),
            )
            self.start_capture_wait(force_prompt=True)
            return
        self._autostart_tunnel()

    def _autostart_tunnel(self) -> None:
        """Включить защиту сразу при запуске программы — если так попросили.

        Задумывалось как «программа работает — трафик под контролем»: перехват
        и блокировка неизвестных программ делаются самим туннелем, и пока
        sing-box не запущен, программа ничего не видит и ничего не закрывает.

        Но окно открывают и просто чтобы посмотреть: раздать программам каналы,
        свериться с подпиской, полистать список. Перестраивать ради этого весь
        сетевой стек и спрашивать разрешение Windows — навязчиво, и человек
        теряет контроль над тем, что должно происходить по его кнопке. Поэтому
        по умолчанию защиту включает он сам; автоматически — только по галочке
        в настройках.
        """
        if getattr(self, "_autostart_done", False):
            return
        if not bool(load_settings().get("autostart_tunnel", False)):
            self._autostart_done = True
            clog("autostart.off", "manual start: waiting for the button")
            return
        if self._status_cache.get("tun") or singbox_running():
            self._autostart_done = True
            return

        # Именно _tunnel_gate: молчащий Squid не повод оставлять компьютер
        # вообще без маршрутизации — Claude просто нельзя будет запустить.
        can, reason = self._tunnel_gate()
        if not can:
            # Попытку НЕ засчитываем. Раньше флаг ставился до проверки, и первый
            # же отказ выключал автоподъём навсегда: на чужом компьютере отчёт о
            # готовности ПК просто не успевал дойти («идёт проверка…»), туннель
            # не поднимался, а программа так и стояла — при том что через пару
            # секунд подниматься было уже можно. То же самое было после того, как
            # пользователь убирал мешавший VPN: причина исчезала, а повторять
            # было некому.
            if reason != getattr(self, "_autostart_last_reason", None):
                self._autostart_last_reason = reason
                clog("autostart.wait", "cannot raise tunnel yet", reason=reason)
            self._set_routes_ui(
                phase="idle",
                text=(
                    f"Маршрутизация выключена — программа сейчас ничего не перехватывает. "
                    f"Причина: {reason}"
                ),
            )
            return

        self._autostart_done = True
        clog("autostart.begin", "raising tunnel at startup")
        self._set_routes_ui(
            phase="busy",
            text="Поднимаю туннель — подтвердите запрос администратора…",
        )
        self.on_start()

    def _bootstrap_flow_async(self) -> None:
        """Run bootstrap after a quick background status fill (keeps startup snappy)."""

        def work() -> None:
            try:
                init_db()
                import_existing_captures()
            except Exception:
                pass
            # Тот же опрос уже сделал _probe_status_async за 350 мс до нас. Второй
            # заход означал, что при запуске всё проверяется дважды — это видно
            # глазом и выглядит как «программа проверяет два раза».
            fresh = time.time() - float(self._status_cache.get("ts") or 0) < 10
            if fresh:
                cache = None
            else:
                try:
                    w = watcher_status()
                    tun = singbox_running_deep()
                    claude = claude_process_running()
                    nodes = count_nodes()
                    active = get_active()
                    st = read_status_json()
                    # Наличие Claude кешируем: проверка умеет уходить в PowerShell,
                    # а _update_start_buttons вызывается на каждой отрисовке.
                    try:
                        claude_installed = bool(claude_desktop_installed()[0])
                    except Exception:
                        claude_installed = True
                    cache = {
                        "w": w,
                        "tun": tun,
                        "claude": claude,
                        "claude_installed": claude_installed,
                        "nodes": nodes,
                        "active": active,
                        "st": st,
                        "ts": time.time(),
                    }
                except Exception:
                    cache = None

            def done() -> None:
                if cache:
                    self._status_cache = cache
                    self.refresh_all(light=True)
                try:
                    self._bootstrap_flow()
                finally:
                    # С этого момента за автоподъём отвечает периодический тик:
                    # первая попытка почти всегда упирается в незаконченную
                    # проверку готовности ПК.
                    self._bootstrap_done = True

            self.after(0, done)

        threading.Thread(target=work, daemon=True).start()

    def on_bootstrap_services(self) -> None:
        install = APP_DIR.parent / "runtime" / "Install-Product.ps1"
        script = product_script("Bootstrap-NewPC.ps1")
        if not runtime_installed() and install.is_file():
            if not messagebox.askyesno(
                "Установка runtime",
                "Скрипты ещё не установлены в\n"
                f"{PROXY}\n\n"
                "Установить сейчас? (ловец, sing-box, ярлык)\nНужен UAC (Да).",
            ):
                return
            target = install
        elif script.is_file():
            if not messagebox.askyesno(
                "Авто-установка",
                "Установить ловец + монитор, проверить sing-box/python?\nНужен UAC (Да).",
            ):
                return
            target = script
        else:
            self.on_install_watcher()
            return

        def work() -> None:
            env = os.environ.copy()
            env["CLAUDE_PROXY_NOPAUSE"] = "1"
            env["CLAUDE_PROXY_HOME"] = str(PROXY)
            subprocess.run(
                [
                    str(PWSH),
                    "-NoProfile",
                    "-ExecutionPolicy",
                    "Bypass",
                    "-WindowStyle",
                    "Hidden",
                    "-Command",
                    f"Start-Process -FilePath '{PWSH}' -Verb RunAs -Wait -WindowStyle Hidden -ArgumentList "
                    f"@('-NoProfile','-ExecutionPolicy','Bypass','-File','{target}')",
                ],
                env=env,
                check=False,
                creationflags=CREATE_NO_WINDOW,
            )
            self.after(800, self.refresh_all)

        threading.Thread(target=work, daemon=True).start()

    def on_tun_button(self) -> None:
        """Кнопка маршрутизации: поднять туннель или пересобрать его."""
        if self._status_cache.get("tun") or singbox_running():
            # Туннель уже работает — единственное осмысленное действие здесь
            # пересборка с текущими каналами. Это же и «применить маршруты».
            self.on_apply_routes()
            return
        self._start_tunnel_only = True
        self.on_start()

    def on_start(self) -> None:
        if not runtime_installed():
            messagebox.showerror(
                "Программа установлена не полностью",
                "Переустановите её: Uninstall.bat, затем Install.bat из архива.",
            )
            return
        if not (PROXY / "proxy.env").is_file():
            messagebox.showerror(
                "Нужен ключ",
                "Настройки доступа не получены. Активируйте ключ заново или продлите подписку.",
            )
            return
        # Ловец и узлы Sota нужны только каналу VPN. Их отсутствие не должно
        # мешать поднять туннель: Claude пойдёт через Squid, остальное — по своим
        # каналам, а приложения на VPN честно окажутся в блоке.
        w = watcher_status()
        if not w["ok"]:
            self._ensure_watcher_auto(force=True)
        active = get_active()
        if active:
            try:
                set_active(int(active["id"]))
            except Exception as e:
                clog_exc("start.set_active", "could not set active node", e)
        elif count_nodes() > 0:
            clog("start.no_active", "nodes exist but none is active - VPN channel will be down")
        # Туннель уже поднят — значит это не старт, а работа с самим Claude.
        if bool(self._status_cache.get("tun")) or singbox_running():
            self.on_start_claude()
            return
        # Поднятие туннеля гейтится без требований к Squid: маршрутизация нужна
        # и тогда, когда прокси временно молчит.
        can, reason = self._tunnel_gate()
        if not can:
            messagebox.showerror("Запуск недоступен", reason)
            return
        self._capture_wait = False
        # Always push newest Start script + builder into product home before elevate
        try:
            src_b = APP_DIR.parent / "runtime" / "merged" / "build_sota_merged_config.py"
            if src_b.is_file():
                (PROXY / "merged").mkdir(parents=True, exist_ok=True)
                (PROXY / "merged" / "build_sota_merged_config.py").write_bytes(src_b.read_bytes())
        except Exception:
            pass
        start_script = sync_runtime_script("Start-VpnClaude.ps1")
        # Also sync _env.ps1 used by Start
        try:
            sync_runtime_script("_env.ps1")
        except Exception:
            pass
        # Clear old progress so UI doesn't show stale success
        try:
            prog = MERGED / "start-progress.json"
            prog.write_text(
                json.dumps(
                    {
                        "phase": "running",
                        "step": "0/15",
                        "title": "Жду подтверждения UAC…",
                        "detail": "В окне Windows нажми «Да»",
                        "pct": 0,
                        "warns": [],
                        "fails": [],
                        "copy_text": "",
                        "summary": "",
                    },
                    ensure_ascii=False,
                    indent=2,
                ),
                encoding="utf-8",
            )
        except Exception:
            pass
        self._paint_start_progress(
            {
                "phase": "running",
                "title": "Жду подтверждения UAC…",
                "step": "0/15",
                "detail": "В окне Windows нажми «Да» — дальше шаги здесь, без чёрного окна",
                "pct": 0,
            }
        )
        # Hidden elevated start — progress via start-progress.json in this window
        import time

        self._start_launch_ts = time.time()
        # Только туннель — когда об этом попросили кнопкой или когда Claude на
        # машине просто нет: раньше его отсутствие валило весь запуск.
        skip_claude = bool(getattr(self, "_start_tunnel_only", False))
        if not skip_claude and not claude_desktop_installed()[0]:
            skip_claude = True
            clog("start.no_claude", "Claude not installed - raising tunnel only")
        self._start_tunnel_only = False
        run_ps_elevated(
            start_script,
            no_exit=False,
            nopause=True,
            hidden=True,
            extra_env={"CPC_SKIP_CLAUDE": "1"} if skip_claude else None,
        )
        self._start_poll_left = 90
        self._start_progress_polling = True
        self._flash_action("Жду UAC — нажми «Да» в окне Windows (может быть под другими окнами)")
        self.after(800, self._poll_after_start)

    def _load_start_progress(self) -> dict:
        p = MERGED / "start-progress.json"
        if not p.is_file():
            return {}
        try:
            return json.loads(p.read_text(encoding="utf-8-sig"))
        except Exception:
            try:
                return json.loads(p.read_text(encoding="utf-8"))
            except Exception:
                return {}

    def _paint_start_progress(self, data: dict | None = None) -> None:
        if not hasattr(self, "lbl_prog_title"):
            return
        d = data if data is not None else self._load_start_progress()
        if not d:
            return
        phase = str(d.get("phase") or "running")
        title = str(d.get("title") or "…")
        step = str(d.get("step") or "")
        detail = str(d.get("detail") or "")
        pct = d.get("pct")
        try:
            pct_i = int(pct) if pct is not None else 0
        except Exception:
            pct_i = 0
        warns = d.get("warns") or []
        fails = d.get("fails") or []
        summary = str(d.get("summary") or "")
        copy_text = str(d.get("copy_text") or "")
        if not copy_text:
            bits = []
            if fails:
                bits.append("Ошибки:\n" + "\n".join(str(x) for x in fails))
            if warns:
                bits.append("Предупреждения:\n" + "\n".join(str(x) for x in warns))
            if summary:
                bits.append(summary)
            copy_text = "\n\n".join(bits)
        self._start_progress_copy = copy_text

        # Пятнадцать шагов, проценты и технические подробности наружу не идут.
        # Идёт работа — «Включаю…», закончилось — «Готово» либо «Не получилось».
        if phase == "ok":
            state_txt, state_fg = "Всё работает", THEME["ok_fg"]
            meaning = "Программы ходят по вашим правилам."
        elif phase == "fail":
            state_txt, state_fg = "Не получилось включить", THEME["bad_fg"]
            meaning = "Ниже — что именно помешало."
        else:
            state_txt, state_fg = "Включаю…", THEME["muted"]
            meaning = "Обычно занимает несколько секунд."
        self._lbl(self.lbl_mon_overall, text=state_txt, color=state_fg)
        self._lbl(self.lbl_prog_title, text=meaning, color=THEME["muted"])
        # Полоса прогресса словами: «проверка» → «включаю» → «проверяю связь».
        human = ""
        low = (title + " " + detail).lower()
        if phase == "running":
            if any(w in low for w in ("проверя", "конфликт", "готовност")):
                human = "Проверяю компьютер…"
            elif any(w in low for w in ("uac", "администратор", "подтверд")):
                human = "Жду разрешения Windows — нажмите «Да»."
            elif any(w in low for w in ("squid", "прокси", "hello", "сеть")):
                human = "Проверяю связь с сервером…"
            elif "claude" in low:
                human = "Открываю Claude…"
            else:
                human = "Настраиваю…"
        self._lbl(self.lbl_prog_step, text=human, color=THEME["muted"])

        # Ошибку показываем всегда, предупреждение — только если оно осмысленное.
        if fails:
            self._show_warning(
                "Не получилось: " + " · ".join(str(x) for x in fails[:3]),
                copy_text=copy_text,
            )
        elif phase == "ok" and warns:
            self._show_warning(" · ".join(str(x) for x in warns[:3]), copy_text=copy_text)
        else:
            self._show_warning("")

    def on_copy_start_progress(self) -> None:
        text = (self._start_progress_copy or "").strip()
        if not text:
            d = self._load_start_progress()
            text = str(d.get("copy_text") or d.get("summary") or d.get("detail") or "").strip()
        if not text:
            messagebox.showinfo("Буфер", "Пока нечего копировать — дождись предупреждения или итога.")
            return
        try:
            self.clipboard_clear()
            self.clipboard_append(text)
            self.update_idletasks()
            self._flash_action("Скопировано в буфер")
        except Exception as e:
            messagebox.showerror("Буфер", str(e))

    def _poll_after_start(self) -> None:
        try:
            self._poll_after_start_inner()
        except Exception as e:
            # Never let a paint bug kill the start watcher (CTk vs tk kwargs etc.)
            try:
                self._flash_action(f"Ошибка обновления прогресса: {e}")
            except Exception:
                pass
            left = int(getattr(self, "_start_poll_left", 0))
            if left > 0:
                self._start_poll_left = left - 1
                self.after(1000, self._poll_after_start)

    def _poll_after_start_inner(self) -> None:
        import time

        left = int(getattr(self, "_start_poll_left", 0))
        prog = self._load_start_progress()
        self._paint_start_progress(prog)
        phase = str(prog.get("phase") or "")
        title = str(prog.get("title") or "")
        if phase == "ok":
            self._start_poll_left = 0
            self._start_progress_polling = False
            self._start_ok_ts = time.time()
            self._squid_fail_streak = 0
            self._vpn_fail_streak = 0
            try:
                save_applied_channels_snapshot()
            except Exception:
                pass
            self.refresh_all()
            self._flash_action("Claude + VPN запущены")
            # Сразу после подъёма говорим главное: перехват уже работает. Без этой
            # строки человек видит только «запущено» и не понимает, почему у
            # незнакомой программы пропал интернет.
            self._set_routes_ui(phase="idle", text=self._routing_hint())
            # Fresh network check soon — but after grace, so no false Squid FAIL
            self.after(8000, self._run_monitor_async)
            return
        if phase == "fail":
            self._start_poll_left = 0
            self._start_progress_polling = False
            self.refresh_all()
            return

        # Stuck on UAC prompt: last-start.log not advancing → user cancelled or UAC not shown
        started_at = float(getattr(self, "_start_launch_ts", 0) or 0)
        if (
            phase == "running"
            and "UAC" in title
            and started_at
            and (time.time() - started_at) >= 45
        ):
            log = MERGED / "last-start.log"
            log_mtime = log.stat().st_mtime if log.is_file() else 0.0
            if log_mtime < started_at - 1:
                self._start_poll_left = 0
                self._start_progress_polling = False
                self._paint_start_progress(
                    {
                        "phase": "fail",
                        "title": "UAC не подтверждён",
                        "detail": "Запуск не начался — в окне Windows нажми «Да», "
                        "или запусти снова (UAC иногда прячется за другими окнами).",
                        "step": "0/15",
                        "fails": ["UAC cancelled or not confirmed"],
                        "copy_text": "Start stuck on UAC. Confirm elevation or retry.",
                    }
                )
                self.refresh_all()
                messagebox.showwarning(
                    "Нужен UAC",
                    "Запуск Claude требует прав администратора.\n\n"
                    "Найди окно «Контроль учётных записей» и нажми «Да».\n"
                    "Если окна не было — нажми «Запустить Claude» ещё раз.",
                )
                return

        if singbox_running() and phase == "running" and left < 60:
            # TUN already up; keep polling until script writes phase=ok
            pass
        if left <= 0:
            self._start_progress_polling = False
            if phase != "ok":
                self._paint_start_progress(
                    {
                        "phase": "fail",
                        "title": "Таймаут ожидания запуска",
                        "detail": "Скрипт не ответил вовремя — проверь UAC и last-start.log",
                        "step": "",
                        "fails": ["timeout waiting for start-progress.json"],
                        "copy_text": "Таймаут запуска CPC. Проверь UAC и last-start.log",
                    }
                )
            self.refresh_all()
            return
        self._start_poll_left = left - 1
        self.after(1000, self._poll_after_start)

    def on_stop(self) -> None:
        run_ps_elevated(product_script("Stop-VpnClaude.ps1"), no_exit=True)

    def on_quit_program(self) -> None:
        if self._quitting:
            return
        # Формулировка намеренно отличается от подтверждения при применении
        # маршрутов: там Claude лишь теряет соединение на пару секунд, а здесь
        # закрывается вместе с программой и сам не вернётся.
        if not messagebox.askyesno(
            "Закрыть программу?",
            "Туннель будет выключен, Claude закроется вместе с программой "
            "и сам не запустится — откройте его снова после следующего запуска.\n\n"
            "После выключения туннеля маршрутизации не будет: весь трафик пойдёт "
            "обычным путём, мимо программы.\n\n"
            "Потребуется подтверждение администратора.\n"
            "(То же действие по крестику окна.)",
        ):
            return
        self._quitting = True
        self._show_exit_overlay("quit")
        # Claude first — even if UAC delayed, no leak without Squid/TUN
        try:
            kill_claude_processes()
        except Exception:
            pass

        def work() -> None:
            ok = elevated_stop_product(timeout=60, attempts=2)

            def done() -> None:
                if ok:
                    self._finish_quit()
                    return
                # Do not silently leave TUN running (that was the bug after «Закрыть»)
                force = messagebox.askyesno(
                    "Туннель всё ещё работает",
                    "Не удалось выключить TUN (часто: UAC отклонён или окно не заметили).\n\n"
                    "Да — закрыть только окно программы (TUN останется).\n"
                    "Нет — оставить программу открытой и остановить TUN кнопкой «Остановить TUN».",
                )
                if force:
                    clog("quit.force_ui_close", "user closed UI while TUN still up", level="WARN")
                    self._finish_quit()
                    return
                clog("quit.abort", "user kept UI open because TUN still up")
                self._relaunch_ui_after_failed_quit()

            self.after(0, done)

        threading.Thread(target=work, daemon=True).start()

    def _relaunch_ui_after_failed_quit(self) -> None:
        """Overlay already hid widgets — reopen a clean CPC window, then exit this process."""
        self._quitting = False
        launcher = APP_DIR / "Run-Product.bat"
        try:
            if launcher.is_file():
                subprocess.Popen(
                    ["cmd.exe", "/c", "start", "", str(launcher)],
                    cwd=str(APP_DIR),
                    creationflags=CREATE_NO_WINDOW,
                )
            else:
                subprocess.Popen(
                    [sys.executable, str(Path(__file__).resolve())],
                    cwd=str(APP_DIR),
                    creationflags=CREATE_NO_WINDOW,
                )
        except Exception as e:
            clog_exc("quit.relaunch", "failed to relaunch UI", e)
            messagebox.showwarning(
                "TUN ещё работает",
                "Окно интерфейса сброшено. Запусти CPC снова вручную\n"
                "и нажми «Остановить TUN» + UAC.",
            )
        self._finish_quit()

    def _finish_quit(self) -> None:
        try:
            self.destroy()
        except Exception:
            os._exit(0)

    def on_diagnose(self) -> None:
        run_ps_elevated(product_script("Diagnose-VpnClaude.ps1"), no_exit=True)

    def _run_tun_ready_async(self, *, show_dialog: bool = False) -> None:
        if self._tun_ready_busy:
            return
        self._tun_ready_busy = True
        # Отметку времени ставит done() этой же функции — один владелец. Раньше её
        # выставлял ещё и периодический тик, и стартовая проверка оставалась для
        # него «не бывшей».
        self._last_tun_ready_ts = time.time()
        try:
            if hasattr(self, "lbl_runtime_summary"):
                self._refresh_runtime_summary()
        except Exception:
            pass

        info = license_info()

        def work() -> None:
            try:
                w = watcher_status()
                nodes = count_nodes()
                tun = singbox_running_deep()
                report = run_tun_ready_check(
                    license_active=bool(info.get("active")),
                    proxy_ready=bool(info.get("proxy_ready")),
                    proxy_host=str(info.get("proxy_host") or ""),
                    watcher_ok=bool(w.get("ok")),
                    nodes=int(nodes),
                    our_tunnel_up=bool(tun),
                )
            except Exception as e:
                report = {
                    "ready": False,
                    "summary": f"Ошибка диагностики: {e}",
                    "lines": [{"level": "bad", "text": str(e)}],
                    "blockers": ["error"],
                    "warnings": [],
                }

            def done() -> None:
                import time as _time

                self._tun_ready_busy = False
                self._tun_ready_report = report
                self._last_tun_ready_ts = _time.time()
                try:
                    self._refresh_runtime_summary()
                    self._paint_action_card(
                        "diagnose",
                        "ok" if report.get("ready") else "bad",
                        title="Готовность TUN · ОК" if report.get("ready") else "Готовность TUN · блокеры",
                    )
                    self._update_start_buttons()
                    # Repaint guide/network so "можно запускать" never contradicts blockers
                    self.refresh_all(light=True)
                except Exception:
                    pass
                if show_dialog:
                    self._show_tun_ready_dialog(report)

            self.after(0, done)

        threading.Thread(target=work, daemon=True).start()

    def on_tun_ready(self) -> None:
        """Full PC readiness check before raising TUN."""
        self._flash_action("Проверяю готовность ПК к TUN…")
        self._run_tun_ready_async(show_dialog=True)

    def _show_tun_ready_dialog(self, report: dict) -> None:
        win = tk.Toplevel(self)
        win.title("Готовность ПК к TUN")
        win.geometry("720x560")
        win.configure(bg=THEME["bg"])
        win.transient(self)

        ready = bool(report.get("ready"))
        head_bg = THEME["ok_bg"] if ready else THEME["bad_bg"]
        head_fg = THEME["ok_fg"] if ready else THEME["bad_fg"]
        head = tk.Frame(win, bg=head_bg, padx=14, pady=12)
        head.pack(fill=tk.X)
        tk.Label(
            head,
            text=("✅  " if report.get("ready") else "❌  ") + (report.get("summary") or ""),
            font=("Segoe UI Semibold", 12, "bold"),
            bg=head_bg,
            fg=head_fg,
            wraplength=680,
            justify="left",
            anchor="w",
        ).pack(fill=tk.X)

        body = tk.Frame(win, bg=THEME["bg"], padx=12, pady=10)
        body.pack(fill=tk.BOTH, expand=True)
        txt = tk.Text(
            body,
            wrap="word",
            font=("Consolas", 10),
            bg=THEME["surface2"],
            fg=THEME["text"],
            insertbackground=THEME["text"],
            relief=tk.FLAT,
            padx=10,
            pady=10,
        )
        scroll = ttk.Scrollbar(body, command=txt.yview)
        txt.configure(yscrollcommand=scroll.set)
        txt.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scroll.pack(side=tk.RIGHT, fill=tk.Y)
        txt.insert("1.0", format_report_text(report))
        txt.configure(state=tk.DISABLED)

        btns = tk.Frame(win, bg=THEME["bg"], padx=12, pady=10)
        btns.pack(fill=tk.X)
        tk.Button(
            btns,
            text="Повторить проверку",
            font=("Segoe UI", 10, "bold"),
            relief=tk.FLAT,
            bd=0,
            padx=12,
            pady=8,
            cursor="hand2",
            bg=BUTTONS["info"]["fg"],
            fg="#ffffff",
            command=lambda: (win.destroy(), self.on_tun_ready()),
        ).pack(side=tk.LEFT)
        tk.Button(
            btns,
            text="Старая диагностика (PowerShell)",
            font=("Segoe UI", 10),
            relief=tk.FLAT,
            bd=0,
            padx=12,
            pady=8,
            cursor="hand2",
            bg=BUTTONS["quiet"]["fg"],
            fg=BUTTONS["quiet"]["text"],
            command=self.on_diagnose,
        ).pack(side=tk.LEFT, padx=(8, 0))
        tk.Button(
            btns,
            text="Закрыть",
            font=("Segoe UI", 10, "bold"),
            relief=tk.FLAT,
            bd=0,
            padx=12,
            pady=8,
            cursor="hand2",
            # Закрыть окно — не разрушительное действие: красная кнопка здесь
            # пугала на ровном месте.
            bg=BUTTONS["quiet"]["fg"],
            fg=BUTTONS["quiet"]["text"],
            command=win.destroy,
        ).pack(side=tk.RIGHT)

    def on_help_capture(self) -> None:
        if singbox_running():
            messagebox.showinfo(
                "Обновить узел",
                "Сначала останови Claude+VPN.\n"
                "Пока туннель работает — Connect в Sota нельзя (конфликт), "
                "и программа новые конфиги не ждёт.",
            )
            return
        if not watcher_status()["ok"]:
            self._ensure_watcher_auto(force=True)
            messagebox.showinfo(
                "Обновить узел",
                "Ловец ещё не готов — ставлю его сам.\n"
                "Подтверди UAC (Да), потом снова: Sota Connect → Disconnect.",
            )
            return
        messagebox.showinfo(
            "Как обновить узел",
            "1) Ловец включён\n"
            "2) Туннель Claude+VPN ВЫКЛЮЧЕН\n"
            "3) В Sota: Connect → 2–3 сек → Disconnect\n"
            "4) Это окно само увидит новый конфиг (проверка каждые 2 сек)\n\n"
            "Пока туннель работает — Connect не жми.",
        )
        # Wait for a NEW capture (even if nodes already exist)
        self._capture_baseline = self._capture_fingerprint()
        self._capture_wait = True
        # Срок обязателен: без него _poll_capture_wait считает дедлайн нулём,
        # то есть давно истёкшим, и первая же проверка объявляет «снимок не
        # появился» — через две секунды после того, как вы нажали «жду».
        self._capture_deadline = time.time() + CAPTURE_WAIT_SEC
        self.lbl_next.configure(text="Жду новый снимок… Sota: Connect → Disconnect")
        self.after(2000, self._poll_capture_wait)

    def on_install_watcher(self, quiet: bool = False) -> None:
        if not runtime_installed():
            install = APP_DIR.parent / "runtime" / "Install-Product.ps1"
            if install.is_file():
                self.on_bootstrap_services()
                return
        self._sync_watcher_scripts()
        script = product_script("Install-SotaCaptureWatcher.ps1")
        if not script.is_file():
            self._watcher_auto_busy = False
            messagebox.showerror(
                "Ловец узлов",
                "В установленной копии нет файла Install-SotaCaptureWatcher.ps1.\n\n"
                "Переустановите программу: Install.bat из архива.",
            )
            return
        clog("watcher.install", "starting elevated install", script=str(script))
        self._flash_action("Ставлю ловец узлов — подтвердите запрос Windows…")

        def work() -> None:
            code, tail = run_ps_elevated_wait(script, log_name="watcher-install.log")
            self.after(0, lambda: self._after_watcher_install(code, tail, quiet))

        threading.Thread(target=work, daemon=True).start()

    def _after_watcher_install(self, code: int = 0, tail: str = "", quiet: bool = False) -> None:
        self._watcher_auto_busy = False
        # Свежий ответ кладём в кэш ДО перерисовки: refresh_all рисует из кэша,
        # и без этого кнопки показывали состояние до установки.
        w = watcher_status()
        self._status_cache["w"] = w
        self.refresh_all()

        if w["ok"]:
            self._flash_action("Ловец узлов установлен и работает")
            if not singbox_running() and count_nodes() <= 0:
                self.start_capture_wait(force_prompt=True)
            return

        # Задание создано, но процесс ещё не поднялся — это не провал: он
        # запустится при следующем входе в систему. Ошибку показывать не за что.
        if w.get("task") and code == 0:
            self._flash_action("Ловец узлов установлен — запустится при следующем входе")
            return

        # Дальше — не получилось, и человек должен узнать почему, а не смотреть
        # на мигнувшее окно. Причина берётся из транскрипта elevated-процесса.
        if code == UAC_DECLINED:
            reason = "Windows не дал прав: в окне с запросом нужно нажать «Да»."
        elif code == -1:
            reason = "Установка не уложилась в отведённое время."
        elif tail:
            reason = tail.strip().splitlines()[-1]
        else:
            reason = f"Скрипт установки завершился с кодом {code}."
        clog("watcher.install.fail", "watcher did not come up", code=code, reason=reason)
        self._flash_action("Ловец узлов не установился")
        if quiet:
            return
        messagebox.showerror(
            "Ловец узлов не установился",
            reason
            + "\n\nПодробности: "
            + str(PROXY / "logs" / "watcher-install.log"),
        )

    def on_stop_watcher(self) -> None:
        # Раньше здесь стояло «Запуск Claude+VPN потом будет заблокирован» —
        # неправда с тех пор, как туннель поднимается и без Sota. Ловец нужен
        # одному каналу из четырёх, об этом и говорим.
        if not messagebox.askyesno(
            "Остановить ловец узлов?",
            "Канал «VPN» перестанет обновляться: новые узлы Sota программа "
            "больше не поймает.\n\nОстальные каналы и Claude работают как обычно.",
        ):
            return
        script = product_script("Stop-SotaCaptureWatcher.ps1")
        self._flash_action("Убираю ловец узлов — подтвердите запрос Windows…")

        def work() -> None:
            code, tail = run_ps_elevated_wait(script, log_name="watcher-stop.log")
            self.after(0, lambda: self._after_watcher_stop(code, tail))

        threading.Thread(target=work, daemon=True).start()

    def _after_watcher_stop(self, code: int = 0, tail: str = "") -> None:
        # Как и после установки: сначала свежий ответ в кэш, потом отрисовка.
        w = watcher_status()
        self._status_cache["w"] = w
        self.refresh_all()
        if not w["ok"] and not w.get("task"):
            self._flash_action("Ловец узлов остановлен")
            return
        reason = (
            "Windows не дал прав: в окне с запросом нужно нажать «Да»."
            if code == UAC_DECLINED
            else (tail.strip().splitlines()[-1] if tail.strip() else f"код {code}")
        )
        self._flash_action("Ловец остановить не удалось")
        messagebox.showerror("Ловец узлов", "Не удалось остановить.\n\n" + reason)

    def on_activate(self) -> None:
        sel = self.tree.selection() if hasattr(self, "tree") else ()
        if not sel:
            messagebox.showwarning("Узел", "Сначала кликни строку в таблице узлов.")
            return
        try:
            row = set_active(int(sel[0]))
        except Exception as e:
            messagebox.showerror("Ошибка", str(e))
            return
        if not row:
            messagebox.showerror("Узел", "Узел не найден.")
            return
        self._flash_action(f"Активен #{row['id']}: {row['server']}:{row['port']} (Sota)")
        self._refresh_nodes_tree()
        self.refresh_all(light=True)

        # Selecting a node only rewrites the capture file. The running tunnel keeps the
        # old node until the config is rebuilt and the TUN restarted, so say so plainly
        # instead of letting the UI imply the switch already happened.
        needs, live, want = vpn_node_needs_apply()
        clog(
            "node.activate",
            "active node changed",
            node=f"{row['server']}:{row['port']}",
            live_in_tunnel=live,
            selected=want,
            needs_apply=needs,
        )
        if not needs:
            return
        self._set_routes_ui(
            phase="idle",
            text=f"Узел сменён на {want} · в туннеле пока {live} — нажми «Применить маршруты»",
        )
        if messagebox.askyesno(
            "Узел сменён — нужно применить",
            f"Выбран узел: {want}\n"
            f"В работающем туннеле сейчас: {live}\n\n"
            "Новый узел заработает только после пересборки конфига и перезапуска "
            "туннеля — на лету адрес узла подменить нельзя.\n\n"
            "Применить сейчас?\n"
            "(потребуется подтверждение UAC, Claude закроется и откроется снова)",
        ):
            self._apply_routes_auto()

    def _vpn_channel_alive(self) -> bool:
        """True if VPN channel can carry app traffic (not fail-closed).

        UI-safe: only status cache + monitor JSON (no tasklist / PowerShell).
        """
        if not bool(self._status_cache.get("tun")):
            return False
        try:
            m = load_last() or {}
            vpn = m.get("vpn_node") or {}
            # Only treat as dead if monitor saw TUN up AND node failed
            if m.get("tun_up") is True and vpn.get("ok") is False:
                return False
        except Exception:
            pass
        return True

    def _channel_label(self, channel: str, exe: str) -> str:
        key = Path(exe).name.lower()
        if key in CLAUDE_EXES:
            return "Squid"
        if channel == "vpn" and not self._vpn_channel_alive():
            # Fail-closed: assigned VPN but channel dead → no internet
            if not bool(self._status_cache.get("tun")):
                return "VPN"  # tunnel off — rules idle; don't scare with ⚠блок
            return "VPN⚠блок"
        return {
            "direct": "Дом",
            "vpn": "VPN",
            "proxy": "Прокси",
            "block": "Блок",
            "pending": "Спросить",
        }.get(channel, channel)

    def _channel_cell_text(
        self,
        saved_channel: str,
        exe: str,
        live_map: dict[str, str],
    ) -> tuple[str, str]:
        """Return (label for Канал column, tree tag). Shows pending TUN apply."""
        key = Path(exe).name.lower()
        if key in CLAUDE_EXES:
            return "Squid", "claude"

        want = str(saved_channel or "pending")
        want_label = self._channel_label(want, exe)
        tun_up = bool(self._status_cache.get("tun"))

        if not tun_up:
            if want in {"pending", "block"}:
                return f"{want_label} (туннель выкл)", want
            return f"{want_label} (туннель выкл · ещё не в TUN)", "stale"

        # No snapshot yet (first run after update) — don't mark everything blocked
        if not live_map:
            return want_label, want

        # What the running TUN actually does (missing → final block)
        live = live_map.get(key)
        if live is None:
            live = "block"
        # pending in rules == treat as want-block for compare
        want_eff = "block" if want == "pending" else want
        live_label = self._channel_label(live, exe)

        if want_eff == live:
            return want_label, want

        if live == "block":
            return (
                f"{want_label} (сейчас нет выхода · ждёт перезапуска TUN)",
                "stale",
            )
        return (
            f"{want_label} (сейчас {live_label} · применится после перезапуска TUN)",
            "stale",
        )

    def _refresh_live_and_direct(self) -> None:
        """Back-compat: schedule async apps refresh."""
        self._refresh_apps_async()

    def _refresh_apps_async(self) -> None:
        """Parse TUN log + ingest catalog off UI thread, then paint list.

        Разбор идёт всегда, даже когда окно настроек закрыто: именно здесь
        обнаруживаются новые программы и всплывает вопрос о канале. Раньше
        функция выходила, если нет виджета списка, — а он существует только
        внутри окна настроек, поэтому предложение появлялось лишь после того,
        как пользователь сам туда заглянет.
        """
        if self._apps_refresh_busy:
            return
        self._apps_refresh_busy = True

        def work() -> None:
            import time

            apps: list = []
            try:
                try:
                    # Окно шире интервала опроса (15 с), иначе короткая вспышка
                    # трафика между обновлениями осталась бы незамеченной.
                    live_rows = parse_live_traffic(max_lines=4000, recent_sec=180)
                    ingest_live_rows(live_rows)
                    apps = list_apps()
                except Exception as e:
                    # Раньше ошибка глохла молча, и было не понять, почему список
                    # программ перестал пополняться.
                    clog_exc("apps.refresh", "live traffic ingest failed", e)
                    try:
                        apps = list_apps()
                    except Exception:
                        apps = []
                try:
                    sync_applied_channels_if_config_matches_running_tun()
                except Exception:
                    pass

                def done() -> None:
                    # Рисуем список, только если окно настроек открыто, а вот
                    # спрашивать про новую программу нужно в любом случае.
                    try:
                        if getattr(self, "live", None):
                            self._paint_apps_tree(apps)
                    except Exception as e:
                        clog_exc("apps.paint", "painting apps failed", e)
                    self.after(300, self._maybe_ask_pending)

                self.after(0, done)
            finally:
                # Снимаем флаг здесь, а не в done(): раньше он сбрасывался только
                # в UI-колбэке, и одна осечка там навсегда останавливала
                # обнаружение новых программ — вместе с приглашением выбрать канал.
                self._apps_refresh_busy = False
                self._last_apps_refresh_ts = time.time()

        threading.Thread(target=work, daemon=True).start()

    def _clear_apps_filter(self) -> None:
        if hasattr(self, "var_apps_filter"):
            self.var_apps_filter.set("")

    def _on_apps_filter_changed(self) -> None:
        # Re-paint from cache only (no disk / PS)
        if hasattr(self, "_apps_list_cache"):
            self._paint_apps_tree(self._apps_list_cache, keep_cache=True)

    def _filtered_apps(self, apps: list) -> list:
        q = ""
        try:
            q = (self.var_apps_filter.get() or "").strip().lower()
        except Exception:
            q = ""
        if not q:
            return list(apps)
        out = []
        for a in apps:
            exe = str(a.get("exe") or "").lower()
            path = str(a.get("path") or "").lower()
            if q in exe or q in path:
                out.append(a)
        return out

    def _paint_apps_tree(self, apps: list, *, keep_cache: bool = False) -> None:
        # Атрибут есть всегда, но равен None, пока окно настроек закрыто:
        # проверять надо значение, а не наличие.
        if not getattr(self, "live", None):
            if not keep_cache:
                self._apps_list_cache = list(apps)
            return
        if not keep_cache:
            self._apps_list_cache = list(apps)
        prev_sel = ""
        try:
            sel = self.live.selection()
            if sel:
                prev_sel = str(sel[0])
        except Exception:
            prev_sel = ""
        yview = None
        try:
            yview = self.live.yview()
        except Exception:
            pass

        for i in self.live.get_children():
            self.live.delete(i)

        total = len(apps)
        shown = self._filtered_apps(apps)

        if hasattr(self, "lbl_apps_filter_count"):
            q = ""
            try:
                q = (self.var_apps_filter.get() or "").strip()
            except Exception:
                q = ""
            if q:
                self.lbl_apps_filter_count.configure(text=f"{len(shown)} / {total}")
            else:
                self.lbl_apps_filter_count.configure(text=f"{total}" if total else "")

        if not apps:
            self.live.insert(
                "",
                tk.END,
                values=("⚪", "—", "пока пусто", "Запусти туннель — появятся при попытке в интернет", ""),
            )
        elif not shown:
            self.live.insert(
                "",
                tk.END,
                values=("⚪", "—", "нет совпадений", "Смени фильтр или нажми ✕", ""),
            )
        else:
            live_map = load_applied_channels_snapshot()
            try:
                live_map = sync_applied_channels_if_config_matches_running_tun() or live_map
            except Exception:
                pass
            for a in shown[:400]:
                exe = str(a.get("exe") or "")
                ch = str(a.get("channel") or "pending")
                key = exe.lower()
                cell, tag = self._channel_cell_text(ch, exe, live_map)
                act = "🟢" if is_active(a) else "⚪"
                # iid must be unique; filter may still have unique exes
                try:
                    self.live.insert(
                        "",
                        tk.END,
                        iid=key,
                        values=(
                            act,
                            cell,
                            exe,
                            a.get("path") or "",
                            a.get("last_seen") or "",
                        ),
                        tags=(tag,),
                    )
                except tk.TclError:
                    # duplicate iid edge-case — insert without fixed iid
                    self.live.insert(
                        "",
                        tk.END,
                        values=(
                            act,
                            cell,
                            exe,
                            a.get("path") or "",
                            a.get("last_seen") or "",
                        ),
                        tags=(tag,),
                    )

        if prev_sel:
            try:
                if self.live.exists(prev_sel):
                    self.live.selection_set(prev_sel)
                    self.live.focus(prev_sel)
                    self.live.see(prev_sel)
            except Exception:
                pass
        if yview:
            try:
                self.live.yview_moveto(yview[0])
            except Exception:
                pass

    def _selected_live_exe_path(self) -> tuple[str, str] | None:
        sel = self.live.selection()
        if not sel:
            return None
        vals = self.live.item(sel[0], "values")
        if not vals or len(vals) < 4:
            return None
        # columns: act, channel, exe, path, seen
        exe = str(vals[2] or "").strip()
        path = str(vals[3] or "").strip()
        if not exe or exe in {"пока пусто", "нет совпадений"}:
            return None
        return exe, path

    def on_copy_app_path(self) -> None:
        picked = self._selected_live_exe_path()
        if not picked:
            messagebox.showwarning("Путь", "Выбери программу в списке.")
            return
        exe, path = picked
        text = path or exe
        try:
            self.clipboard_clear()
            self.clipboard_append(text)
            self.update_idletasks()
        except Exception as e:
            messagebox.showerror("Буфер", str(e))
            return
        self._flash_action(f"Скопирован путь: {text}")

    def _bind_apps_context_menu(self, tree: ttk.Treeview) -> None:
        """Right-click a row → assign channel / copy path and apply routes immediately."""
        menu = tk.Menu(tree, tearoff=0)
        menu.add_command(label="🏠 Домашний IP", command=lambda: self.on_set_channel("direct"))
        menu.add_command(label="🛡 Через VPN", command=lambda: self.on_set_channel("vpn"))
        menu.add_command(label="🔌 Через прокси", command=lambda: self.on_set_channel("proxy"))
        menu.add_command(label="⛔ Блокировать", command=lambda: self.on_set_channel("block"))
        menu.add_separator()
        menu.add_command(label="📋 Скопировать путь", command=self.on_copy_app_path)
        menu.add_command(label="Спросить снова (pending)", command=lambda: self.on_set_channel("pending"))

        def popup(event: tk.Event) -> None:
            row = tree.identify_row(event.y)
            if not row:
                return
            tree.selection_set(row)
            tree.focus(row)
            try:
                menu.tk_popup(event.x_root, event.y_root)
            finally:
                menu.grab_release()

        tree.bind("<Button-3>", popup)
        # some Windows setups also deliver App key / Shift+F10 via this
        tree.bind("<App>", popup)

    def on_set_channel(self, channel: str) -> None:
        picked = self._selected_live_exe_path()
        if not picked:
            messagebox.showwarning("Программа", "Выбери строку в списке приложений.")
            return
        exe, path = picked
        clog_banner(
            "SET_CHANNEL",
            exe=exe,
            path=path,
            channel=channel,
            snap_before=load_applied_channels_snapshot().get(exe.lower()),
            status_tun=str(read_status_json().get("tun") or ""),
            log=str(log_path()),
        )
        try:
            set_channel(exe, channel, path=path)  # type: ignore[arg-type]
            clog("set_channel.saved", "app-rules updated", exe=exe, channel=channel)
        except Exception as e:
            clog_exc("set_channel.save", "set_channel failed", e)
            messagebox.showerror("Нельзя", str(e))
            return
        # Until TUN reload finishes — no old channel leak (UI + then full Apply)
        try:
            mark_exe_blocked_until_apply(exe)
        except Exception as e:
            clog_exc("set_channel.mark_block", "snapshot block failed", e)
        self._refresh_live_and_direct()
        # Выбор канала — это запись, а не действие над туннелем. Раньше каждый
        # выбор тут же перезапускал TUN: назначил три программы — три перезапуска
        # и три запроса администратора подряд, каждый с паузой без сети. Кнопка
        # «Применить маршруты» для того и есть, чтобы собрать всё разом.
        clog("set_channel.pending", "channel saved, waiting for Apply", exe=exe, channel=channel)
        self._set_routes_ui(phase="idle")
        self._flash_action(
            f"{exe} → {self._channel_label(channel, exe)}. "
            "Нажмите «Применить маршруты», чтобы это заработало."
        )

    def on_bypass_browse(self) -> None:
        path = filedialog.askopenfilename(
            title="Выбери программу (.exe)",
            filetypes=[("Programs", "*.exe"), ("All", "*.*")],
        )
        if not path:
            return
        exe = Path(path).name
        try:
            set_channel(exe, "direct", path=path)
        except Exception as e:
            messagebox.showerror("Нельзя", str(e))
            return
        self._refresh_live_and_direct()
        self._set_routes_ui(
            phase="idle",
            text=f"Добавлено {exe} → Дом. Нажми «Применить маршруты».",
        )

    def _maybe_ask_pending(self) -> None:
        if self._ask_dialog_open or getattr(self, "_quitting", False):
            return
        if not self._status_cache.get("tun"):
            return
        import time

        now = time.time()
        for app in pending_apps():
            exe = str(app.get("exe") or "")
            key = exe.lower()
            if not key or key in CLAUDE_EXES:
                continue
            snooze_until = float(self._ask_snooze.get(key) or 0)
            if now < snooze_until:
                continue
            self._show_channel_ask_dialog(app)
            return

    def _show_channel_ask_dialog(self, app: dict) -> None:
        if self._ask_dialog_open:
            return
        self._ask_dialog_open = True
        exe = str(app.get("exe") or "?")
        path = str(app.get("path") or "")
        win = tk.Toplevel(self)
        win.title("Куда пускать программу?")
        win.geometry("660x340")
        win.configure(bg=THEME["bg"])
        win.transient(self)
        win.grab_set()

        body = tk.Frame(win, bg=THEME["bg"], padx=16, pady=14)
        body.pack(fill=tk.BOTH, expand=True)
        tk.Label(
            body,
            text=f"«{exe}» пытается в интернет.\nПравила ещё нет — пока заблокировано.",
            font=("Segoe UI Semibold", 12),
            bg=THEME["bg"],
            fg=THEME["text"],
            justify="left",
            anchor="w",
        ).pack(fill=tk.X)
        path_row = tk.Frame(body, bg=THEME["bg"])
        path_row.pack(fill=tk.X, pady=(6, 0))
        tk.Label(
            path_row,
            text=path or "(путь неизвестен — скопируется имя exe)",
            font=("Segoe UI", 9),
            bg=THEME["bg"],
            fg=THEME["muted"],
            wraplength=520,
            justify="left",
            anchor="w",
        ).pack(side=tk.LEFT, fill=tk.X, expand=True)

        def copy_path() -> None:
            text = path or exe
            try:
                win.clipboard_clear()
                win.clipboard_append(text)
                win.update_idletasks()
                self._flash_action(f"Скопирован путь: {text}")
            except Exception as e:
                messagebox.showerror("Буфер", str(e), parent=win)

        tk.Button(
            path_row,
            text="📋 Скопировать путь",
            font=("Segoe UI", 9, "bold"),
            relief=tk.FLAT,
            bd=0,
            padx=8,
            pady=4,
            cursor="hand2",
            bg=BUTTONS["quiet"]["fg"],
            fg=BUTTONS["quiet"]["text"],
            command=copy_path,
        ).pack(side=tk.RIGHT, padx=(8, 0))
        tk.Label(
            body,
            text="Через что пускать? (применится сразу; «Прокси» = тот же Squid, что у Claude)",
            font=("Segoe UI", 10),
            bg=THEME["bg"],
            fg=THEME["muted"],
            anchor="w",
        ).pack(fill=tk.X, pady=(12, 8))

        def finish(channel: str | None) -> None:
            import time

            try:
                win.grab_release()
            except Exception:
                pass
            win.destroy()
            self._ask_dialog_open = False
            if channel is None:
                self._ask_snooze[exe.lower()] = time.time() + 120
                return
            try:
                set_channel(exe, channel, path=path)  # type: ignore[arg-type]
            except Exception as e:
                messagebox.showerror("Нельзя", str(e))
                return
            try:
                mark_exe_blocked_until_apply(exe)
            except Exception:
                pass
            self._refresh_live_and_direct()
            # Тоже без немедленного применения: человек назначает каналы подряд,
            # и перезапускать туннель после каждого ответа значит рвать ему сеть
            # столько раз, сколько программ он разобрал.
            self._set_routes_ui(phase="idle")
            self._flash_action(
                f"{exe} → {self._channel_label(channel, exe)}. "
                "Нажмите «Применить маршруты», когда разберёте остальные."
            )
            # next pending
            self.after(600, self._maybe_ask_pending)

        btns = tk.Frame(body, bg=THEME["bg"])
        btns.pack(fill=tk.X, pady=(8, 0))
        # Предлагаем только то, что реально есть. Кнопка «Через VPN» без узлов
        # Sota означала бы, что приложение молча уедет в block: канала нет.
        # Цвета — тёмные заливки под белый текст: это единственные кнопки в
        # программе, нарисованные голым tk, и пастель на них не читается.
        options = [("🏠 Домашний IP", "direct", THEME["warn_fg"])]
        if self._sota_available():
            options.append(("🛡 Через VPN", "vpn", BUTTONS["info"]["fg"]))
        options.append(("🔌 Через прокси", "proxy", THEME["ok_fg"]))
        options.append(("⛔ Блок", "block", THEME["bad_fg"]))
        for text, ch, bg in options:
            tk.Button(
                btns,
                text=text,
                font=("Segoe UI", 10, "bold"),
                relief=tk.FLAT,
                bd=0,
                padx=8,
                pady=8,
                cursor="hand2",
                bg=bg,
                fg="#ffffff",
                activebackground=bg,
                activeforeground="#ffffff",
                command=lambda c=ch: finish(c),
            ).pack(side=tk.LEFT, padx=(0, 6))
        tk.Button(
            btns,
            text="Позже",
            font=("Segoe UI", 10),
            relief=tk.FLAT,
            bd=0,
            padx=10,
            pady=8,
            cursor="hand2",
            bg=BUTTONS["quiet"]["fg"],
            fg=BUTTONS["quiet"]["text"],
            activebackground=BUTTONS["quiet"]["hover"],
            activeforeground=BUTTONS["quiet"]["text"],
            command=lambda: finish(None),
        ).pack(side=tk.RIGHT)
        win.protocol("WM_DELETE_WINDOW", lambda: finish(None))

    def _apply_routes_auto(self, *, expect_exe: str = "", expect_channel: str = "") -> None:
        """Close Claude → rebuild → elevated TUN restart → reopen Claude."""
        if self._apply_routes_busy:
            self._apply_routes_pending = True
            if expect_exe:
                self._apply_routes_expect = (expect_exe, expect_channel)
            clog(
                "apply.busy",
                "queued — already applying",
                expect_exe=expect_exe,
                expect_channel=expect_channel,
            )
            self._set_routes_ui(phase="busy", text="Уже применяю… подожди")
            return
        self._apply_routes_busy = True
        self._apply_routes_pending = False
        expect_exe = expect_exe or getattr(self, "_apply_routes_expect", ("", ""))[0]
        expect_channel = expect_channel or getattr(self, "_apply_routes_expect", ("", ""))[1]
        self._apply_routes_expect = ("", "")
        clog_banner(
            "APPLY_ROUTES",
            expect_exe=expect_exe,
            expect_channel=expect_channel,
            cache_tun=bool(self._status_cache.get("tun")),
            status=read_status_json(),
            log=str(log_path()),
        )
        self._set_routes_ui(
            phase="busy",
            text="Применяю: закрою Claude → новый TUN → открою Claude…",
        )

        def work() -> None:
            msg = ""
            ok = False
            soft = False  # informational outcome, not a failure worth a warning popup
            detail = ""
            reopen_claude = False
            try:
                clog("apply.step", "1 check TUN up")
                tun_up = bool(self._status_cache.get("tun")) or singbox_running_deep()
                clog(
                    "apply.tun_check",
                    "tun detection",
                    cache_tun=bool(self._status_cache.get("tun")),
                    deep=tun_up,
                    singbox_pid=read_status_json().get("singBoxPid"),
                    status_tun=read_status_json().get("tun"),
                )
                if not tun_up:
                    # Informational, not a failure: nothing was killed, nothing broke.
                    msg = "Туннель выключен — правило сохранится и применится при старте Claude"
                    soft = True
                    clog("apply.abort", msg, level="WARN")
                    return

                reopen_claude = claude_process_running()
                clog("apply.step", "2 kill Claude", was_running=reopen_claude)
                self.after(
                    0,
                    lambda: self._set_routes_ui(
                        phase="busy", text="Закрываю Claude…"
                    ),
                )
                kill_claude_processes()
                clog("apply.claude_killed", "kill_claude_processes done", still=claude_process_running())

                clog("apply.step", "3 rebuild config local")
                self.after(
                    0,
                    lambda: self._set_routes_ui(
                        phase="busy", text="Собираю конфиг маршрутов…"
                    ),
                )
                built, tun_or_err, want_token = rebuild_vpn_config_local()
                if not built:
                    msg = f"Конфиг не собрался: {tun_or_err}"
                    clog("apply.abort", msg, level="ERROR")
                    return

                clog("apply.step", "4 verify expect channel in new config")
                if expect_exe and expect_channel:
                    out = live_config_outbound_for_exe(expect_exe)
                    if expect_channel == "vpn":
                        verified = bool(out) and out not in {
                            "direct",
                            "block",
                            "claude-squid",
                        }
                    elif expect_channel in {"direct", "proxy", "block", "pending"}:
                        want = {
                            "direct": "direct",
                            "proxy": "claude-squid",
                            "block": "block",
                            "pending": "block",
                        }[expect_channel]
                        verified = out == want
                    else:
                        verified = True
                    detail = (
                        f"{Path(expect_exe).name} → {out or 'нет'} "
                        f"(ждали {expect_channel})"
                    )
                    clog(
                        "apply.verify_cfg",
                        "outbound for expect exe",
                        exe=expect_exe,
                        expect=expect_channel,
                        outbound=out,
                        verified=verified,
                    )
                    if not verified:
                        msg = "В новом конфиге канал не тот · " + detail
                        clog("apply.abort", msg, level="ERROR")
                        return

                want_tun = str(tun_or_err or "").strip()
                # The token identifies THIS request. The interface name cannot: a retry
                # inside the elevated script renames the interface, which used to make
                # every retry report failure even though the tunnel came up fine.
                pending_result = {
                    "ok": False,
                    "detail": "waiting-elevated-reload",
                    "tun": "",
                    "token": want_token,
                    "time": __import__("datetime").datetime.now().isoformat(
                        timespec="seconds"
                    ),
                }
                (MERGED / "reload-result.json").write_text(
                    json.dumps(pending_result, ensure_ascii=False, indent=2),
                    encoding="utf-8",
                )
                clog("apply.step", "5 wrote waiting reload-result", token=want_token, tun=want_tun)

                self.after(
                    0,
                    lambda: self._set_routes_ui(
                        phase="busy",
                        text=f"Конфиг {want_tun}: подтверди UAC (Да) — перезапуск TUN",
                    ),
                )
                script = sync_runtime_script("Reload-VpnRoutes.ps1")
                sync_runtime_script("_env.ps1")
                clog(
                    "apply.step",
                    "6 sync Reload script",
                    script=str(script),
                    exists=script.is_file(),
                    mtime=script.stat().st_mtime if script.is_file() else 0,
                )
                if not script.is_file():
                    msg = "Нет Reload-VpnRoutes.ps1"
                    clog("apply.abort", msg, level="ERROR")
                    return

                clog("apply.step", "7 elevate Reload-VpnRoutes (UAC)")
                rj = run_elevated_reload_wait(want_token, timeout=180)
                result_ok = bool(rj.get("ok"))
                clog("apply.elevate_done", "back from UAC/script", result_ok=result_ok, result=rj)

                clog("apply.step", "8 verify applied TUN")
                # The elevated script reports the interface it actually brought up, which
                # may differ from want_tun after an internal retry. That is legitimate:
                # the config is unchanged apart from the interface name.
                applied_tun = str(rj.get("tun") or "").strip()
                st = read_status_json()
                status_tun = str(st.get("tun") or "").strip()
                effective_tun = applied_tun or status_tun
                adapter_ok = bool(effective_tun and _adapter_up(effective_tun))
                renamed = bool(effective_tun and want_tun and effective_tun != want_tun)
                if rj.get("detail"):
                    detail = (detail + " · " if detail else "") + str(rj.get("detail"))
                clog(
                    "apply.verify_tun",
                    "post-reload checks",
                    want_tun=want_tun,
                    want_token=want_token,
                    applied_tun=applied_tun,
                    status_tun=status_tun,
                    effective_tun=effective_tun,
                    renamed_by_retry=renamed,
                    result_ok=result_ok,
                    adapter_ok=adapter_ok,
                    status_pid=st.get("singBoxPid"),
                    route_final=st.get("routeFinal"),
                )

                if not rj:
                    msg = (
                        "Не применилось: не дождался ответа от elevated-скрипта. "
                        "Возможно, окно UAC не подтвердили — оно может быть под другими окнами. "
                        "Подробности: Сервис → подробный лог (строки [BOOT] и [LAUNCHER])."
                    )
                    ok = False
                    clog("apply.fail", msg, level="ERROR")
                    return
                if not result_ok:
                    msg = f"Не применилось: {rj.get('detail') or 'Reload завершился с ошибкой'}"
                    ok = False
                    clog("apply.fail", msg, level="ERROR", result=rj)
                    return
                if not adapter_ok:
                    msg = (
                        f"Reload отчитался об успехе, но адаптер {effective_tun or '?'} не поднялся. "
                        "Нажми «Применить маршруты» ещё раз."
                    )
                    ok = False
                    clog("apply.fail", msg, level="ERROR")
                    return

                clog("apply.step", "9 snapshot + clear apply_pending")
                try:
                    save_applied_channels_snapshot()
                    clear_apply_pending()
                    snap = load_applied_channels_snapshot()
                    clog(
                        "apply.snapshot",
                        "saved applied channels",
                        firefox=snap.get("firefox.exe"),
                        n=len(snap),
                    )
                except Exception as e:
                    clog_exc("apply.snapshot", "snapshot failed", e)

                # Cross-check the RUNNING tunnel against the config we just built. The
                # snapshot above is derived from that same config, so on its own it can
                # never disagree with it; this is the only step that can.
                try:
                    import clash_api

                    cfg_live = json.loads(
                        (MERGED / "vpn-claude-config.json").read_text(encoding="utf-8-sig")
                    )
                    verdict = clash_api.compare_with_config(
                        _channels_from_vpn_config(cfg_live),
                        normalize=_outbound_to_channel,
                    )
                    clog(
                        "apply.live_verify",
                        "live outbounds vs config",
                        available=verdict.get("available"),
                        checked=verdict.get("checked"),
                        mismatch=verdict.get("mismatch"),
                        blocked_now=clash_api.blocked_now(),
                        level="ERROR" if verdict.get("mismatch") else "INFO",
                    )
                except Exception as e:
                    clog_exc("apply.live_verify", "live verification unavailable", e)

                ok = True
                msg = "Маршруты применены" + (f" · {detail}" if detail else "")
                clog("apply.ok", msg)
            except Exception as e:
                ok = False
                msg = f"Ошибка применения маршрутов: {e}"
                clog_exc("apply.exc", msg, e)
            finally:

                # Reload kills Claude before touching the tunnel. Relaunching it only on
                # the success path meant every failure left the user with no Claude at
                # all, on top of the failure itself.
                if reopen_claude:
                    try:
                        if claude_process_running():
                            clog("apply.claude_relaunch", "Claude already running — skip")
                        else:
                            clog("apply.claude_relaunch", "relaunching Claude", after_ok=ok)
                            launched = launch_claude_desktop()
                            clog("apply.claude_launch", "launch_claude_desktop", ok=launched)
                    except Exception as e:
                        clog_exc("apply.claude_relaunch", "relaunch failed", e)

                def done() -> None:
                    self._apply_routes_busy = False
                    clog("apply.ui_done", "paint result", ok=ok, soft=soft, msg=msg)
                    if ok:
                        self._set_routes_ui(phase="ok", text=msg)
                    elif soft:
                        # Nothing failed and nothing was disturbed — do not raise an alarm.
                        self._set_routes_ui(phase="idle", text=msg)
                    else:
                        self._set_routes_ui(phase="fail", text=msg)
                        try:
                            messagebox.showwarning("Маршруты не применились", msg)
                        except Exception:
                            pass
                    self.refresh_all()
                    if self._apply_routes_pending:
                        self._apply_routes_pending = False
                        ex = getattr(self, "_apply_routes_expect", ("", ""))
                        clog("apply.drain_pending", "run queued apply", expect=ex)
                        self._apply_routes_auto(
                            expect_exe=ex[0], expect_channel=ex[1]
                        )

                self.after(0, done)

        threading.Thread(target=work, daemon=True).start()


    def on_support_report(self) -> None:
        """Collect diagnostics text for Telegram support."""
        from datetime import datetime
        import platform

        info = license_info()
        m = load_last() or {}
        c = self._status_cache
        # Nested same-type quotes inside f-strings are PEP 701 (Python 3.12+). Using them
        # made this whole module fail to import on 3.11 with a bare SyntaxError.
        lines = [
            "Claude Proxy Control — отчёт в поддержку",
            f"time={datetime.now().isoformat(timespec='seconds')}",
            f"app=0.3.0-ctk platform={platform.platform()}",
            f"license_active={info.get('active')} expires={info.get('expires_human')}",
            f"tun={c.get('tun')} nodes={c.get('nodes')} watcher={(c.get('w') or {}).get('ok')}",
            f"monitor_squid={(m.get('squid') or {}).get('ok')} "
            f"vpn={(m.get('vpn_node') or {}).get('ok')}",
            f"device={info.get('device_id') or '?'}",
        ]
        text_out = "\n".join(lines)
        try:
            self.clipboard_clear()
            self.clipboard_append(text_out)
            self.update_idletasks()
            messagebox.showinfo(
                "Отчёт",
                "Диагностика скопирована в буфер.\nВставьте в Telegram-поддержку.",
            )
        except Exception as e:
            messagebox.showerror("Отчёт", str(e))

    def on_check_updates(self) -> None:
        """Сравнить свою версию с той, что на сервере.

        Раньше эта кнопка сообщала «Доступна версия X» на любой ответ сервера —
        даже когда X совпадала с установленной. Теперь версии сравниваются
        по числам: «1.10» больше «1.9», хотя строкой — меньше.
        """
        from urllib import request
        from license_client import API_BASE

        mine = app_version()

        def work() -> None:
            latest, notes, url = "", "", ""
            failed = True
            # Сначала — файл рядом с самим архивом: его пишет тот же шаг, что
            # выкладывает архив, поэтому разойтись они не могут. API — запасной
            # путь на случай, если статику отдаёт что-то другое.
            for endpoint in (
                f"{SITE_URL.rstrip('/')}/downloads/version.json",
                f"{API_BASE}/v1/app/version",
            ):
                try:
                    with request.urlopen(endpoint, timeout=8) as resp:
                        # utf-8-sig: файл может быть сохранён с BOM, и тогда
                        # json.loads спотыкается о первый же символ.
                        data = json.loads(resp.read().decode("utf-8-sig"))
                except Exception:
                    continue
                latest = str(data.get("version") or "")
                notes = str(data.get("notes") or "")
                url = str(data.get("url") or "")
                if latest:
                    failed = False
                    break

            if failed or not latest:
                msg = (
                    f"У вас версия {mine}.\n"
                    "Проверить обновления не получилось — нет связи с сервером."
                )
            elif is_newer(latest, mine):
                msg = f"Есть версия {latest}, у вас {mine}."
                if notes:
                    msg += "\n\n" + notes
                msg += "\n\nСкачать: " + (url or SITE_URL)
            else:
                msg = f"У вас последняя версия — {mine}."

            def done() -> None:
                messagebox.showinfo("Обновления", msg)

            self.after(0, done)

        threading.Thread(target=work, daemon=True).start()

    def _pending_routes_note(self) -> str:
        """Строка для главного экрана: сколько программ ждёт применения маршрутов."""
        try:
            waiting = apps_awaiting_apply()
        except Exception:
            return ""
        n = len(waiting)
        if not n:
            return ""
        # Склонение по остаткам, иначе «22 приложений» и «2 приложения ждёт».
        tail, hundred = n % 10, n % 100
        if tail == 1 and hundred != 11:
            phrase = "приложение ждёт"
        elif tail in (2, 3, 4) and hundred not in (12, 13, 14):
            phrase = "приложения ждут"
        else:
            phrase = "приложений ждут"
        names = ", ".join(str(a.get("exe") or "?") for a in waiting[:3])
        if n > 3:
            names += f" и ещё {n - 3}"
        return f"{n} {phrase} применения маршрутов — трафик заблокирован ({names})"

    def _routing_hint(self) -> str:
        """Что сейчас происходит с трафиком — одной строкой.

        Раньше по умолчанию здесь висело «Сменил канал → нажми Применить»,
        то есть подсказка предполагала, что пользователь уже что-то менял.
        Человек, который только что поднял туннель, из неё не узнавал главного:
        трафик уже идёт через программу, а незнакомые программы закрыты.
        """
        if not self._status_cache.get("tun"):
            return "Маршрутизация выключена — программа сейчас ничего не перехватывает."
        note = self._pending_routes_note()
        if note:
            return note + ". Назначьте им каналы и нажмите «Применить маршруты»."
        return (
            "Туннель поднят — весь трафик идёт через программу. Незнакомые "
            "приложения блокируются: назначьте им каналы и нажмите «Применить маршруты»."
        )

    def _confirm_apply_routes(self) -> bool:
        """Подтверждение с отсчётом. Возвращает True, если применяем.

        По истечении отсчёта применяем сами: пользователь уже нажал «Применить»,
        окно лишь даёт последний шанс отменить и объясняет, что именно случится.
        """
        note = self._pending_routes_note()
        win = tk.Toplevel(self)
        win.title("Применить маршруты?")
        win.configure(bg=THEME["bg"])
        win.transient(self)
        win.grab_set()
        win.resizable(False, False)

        body = tk.Frame(win, bg=THEME["bg"], padx=18, pady=16)
        body.pack(fill=tk.BOTH, expand=True)

        head = "Сейчас защита перенастроится"
        tk.Label(body, text=head, font=("Segoe UI Semibold", 13), bg=THEME["bg"],
                 fg=THEME["text"], anchor="w", justify="left").pack(fill=tk.X)

        details = [
            "• Около 3 секунд без интернета. На это время программа его закрывает,",
            "  чтобы ничего не ушло мимо ваших правил.",
            "• Соединения Claude оборвутся и восстановятся сами.",
            "  Закрывать его не нужно.",
        ]
        if note:
            details.insert(0, f"• {note}")
        tk.Label(body, text="\n".join(details), font=("Segoe UI", 10), bg=THEME["bg"],
                 fg=THEME["muted"], anchor="w", justify="left").pack(fill=tk.X, pady=(10, 12))

        result = {"go": False}
        state = {"left": 10, "timer": None}

        def close(go: bool) -> None:
            result["go"] = go
            if state["timer"]:
                try:
                    self.after_cancel(state["timer"])
                except Exception:
                    pass
                state["timer"] = None
            try:
                win.grab_release()
            except Exception:
                pass
            win.destroy()

        btns = tk.Frame(body, bg=THEME["bg"])
        btns.pack(fill=tk.X)
        go_btn = tk.Button(btns, text="Применить сейчас", font=("Segoe UI", 10, "bold"),
                           relief=tk.FLAT, bd=0, padx=14, pady=8, cursor="hand2",
                           bg=THEME["ok_fg"], fg="#ffffff", command=lambda: close(True))
        go_btn.pack(side=tk.LEFT)
        tk.Button(btns, text="Отмена", font=("Segoe UI", 10), relief=tk.FLAT, bd=0,
                  padx=14, pady=8, cursor="hand2", bg=BUTTONS["quiet"]["fg"], fg=BUTTONS["quiet"]["text"],
                  command=lambda: close(False)).pack(side=tk.RIGHT)

        def tick() -> None:
            state["left"] -= 1
            if state["left"] <= 0:
                close(True)
                return
            try:
                go_btn.configure(text=f"Применить сейчас ({state['left']})")
            except Exception:
                pass
            state["timer"] = self.after(1000, tick)

        go_btn.configure(text=f"Применить сейчас ({state['left']})")
        state["timer"] = self.after(1000, tick)
        win.protocol("WM_DELETE_WINDOW", lambda: close(False))
        win.wait_window()
        return bool(result["go"])

    def on_apply_routes(self) -> None:
        """Manual Apply from Apps / Service tab — always show button reaction."""
        clog_banner("APPLY_BUTTON", busy=self._apply_routes_busy, log=str(log_path()))
        if self._apply_routes_busy:
            self._set_routes_ui(phase="busy", text="Уже применяю… подожди и подтверди UAC")
            return
        if not self._confirm_apply_routes():
            self._set_routes_ui(phase="idle", text="Применение отменено")
            return
        self._set_routes_ui(phase="busy", text="Кнопка нажата — начинаю применять…")
        self._apply_routes_auto()

    def on_export_channels(self) -> None:
        """Сохранить назначенные каналы в файл — чтобы не разбирать всё заново."""
        from datetime import datetime

        try:
            data = export_channels()
        except Exception as e:
            messagebox.showerror("Экспорт", f"Не удалось собрать список: {e}")
            return
        if not data.get("apps"):
            messagebox.showinfo(
                "Экспортировать нечего",
                "Ни одной программе ещё не назначен канал.",
            )
            return
        path = filedialog.asksaveasfilename(
            title="Сохранить каналы приложений",
            defaultextension=".json",
            initialfile=f"cpc-channels-{datetime.now().strftime('%Y%m%d')}.json",
            filetypes=[("Каналы CPC", "*.json"), ("Все файлы", "*.*")],
        )
        if not path:
            return
        try:
            Path(path).write_text(
                json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
            )
        except Exception as e:
            messagebox.showerror("Экспорт", f"Не удалось записать файл:\n{e}")
            return
        clog("channels.export", "saved", path=path, apps=len(data["apps"]))
        messagebox.showinfo(
            "Каналы сохранены",
            f"Программ в файле: {len(data['apps'])}\n\n{path}\n\n"
            "Этот файл можно перенести на другой компьютер и загрузить там.",
        )

    def on_import_channels(self) -> None:
        """Загрузить каналы из файла. Маршруты пользователь применит сам."""
        path = filedialog.askopenfilename(
            title="Загрузить каналы приложений",
            filetypes=[("Каналы CPC", "*.json"), ("Все файлы", "*.*")],
        )
        if not path:
            return
        try:
            data = json.loads(Path(path).read_text(encoding="utf-8-sig"))
        except Exception as e:
            messagebox.showerror("Импорт", f"Файл не читается:\n{e}")
            return

        replace = messagebox.askyesno(
            "Как загрузить?",
            "Да — заменить настройку целиком: программы, которых нет в файле, "
            "снова станут «спросить».\n\n"
            "Нет — только добавить и переназначить то, что есть в файле.\n\n"
            "В обоих случаях значение из файла главнее того, что назначено сейчас.",
        )
        try:
            report = import_channels(data, replace=replace)
        except Exception as e:
            messagebox.showerror("Импорт", str(e))
            return

        clog(
            "channels.import",
            "applied",
            path=path,
            replace=replace,
            applied=len(report["applied"]),
            skipped=len(report["skipped"]),
            cleared=len(report["cleared"]),
        )
        self._refresh_live_and_direct()
        self._set_routes_ui(phase="idle")

        lines = [f"Назначено программ: {len(report['applied'])}"]
        if report["cleared"]:
            lines.append(f"Сброшено в «спросить»: {len(report['cleared'])}")
        if report["skipped"]:
            lines.append(f"Пропущено: {len(report['skipped'])}")
            lines += ["  · " + s for s in report["skipped"][:6]]
            if len(report["skipped"]) > 6:
                lines.append(f"  · и ещё {len(report['skipped']) - 6}")
        lines.append("")
        lines.append(
            "Каналы записаны, но ещё не работают. Нажмите «Применить маршруты» — "
            "если туннель поднят, он перезапустится; если выключен, настройка "
            "вступит в силу при его запуске."
        )
        messagebox.showinfo("Каналы загружены", "\n".join(lines))

    def on_open_trace_log(self) -> None:
        """Open detailed apply/TUN trace log in Notepad."""
        path = log_path()
        try:
            path.parent.mkdir(parents=True, exist_ok=True)
            if not path.is_file():
                path.write_text(
                    "# CPC trace log — появится после смены канала / Применить маршруты\n",
                    encoding="utf-8",
                )
            subprocess.Popen(["notepad.exe", str(path)])
            self._flash_action(f"Лог: {path}")
        except Exception as e:
            messagebox.showerror("Лог", f"{path}\n{e}")


def _activation_dialog() -> bool:
    """Require license key from site cabinet before opening the app."""
    from license_client import activate, is_licensed

    if is_licensed():
        return True

    apply_ctk_globals()
    root = ctk.CTk()
    root.title("Claude Proxy Control — активация")
    root.geometry("520x300")
    root.resizable(False, False)
    root.configure(fg_color=THEME["bg"])
    frm = ctk.CTkFrame(root, fg_color=THEME["surface"], corner_radius=14)
    frm.pack(fill="both", expand=True, padx=18, pady=18)
    ctk.CTkLabel(
        frm,
        text="Активация",
        font=("Segoe UI Semibold", 18),
        text_color=THEME["text"],
        anchor="w",
    ).pack(fill="x", padx=18, pady=(16, 4))
    ctk.CTkLabel(
        frm,
        text="Ключ из кабинета https://proxy.mindq.ru\n"
        "В тариф не входит VPN — Sota ставите сами при необходимости.",
        font=("Segoe UI", 12),
        text_color=THEME["muted"],
        justify="left",
        anchor="w",
    ).pack(fill="x", padx=18, pady=(0, 12))
    key_var = tk.StringVar()
    ent = ctk.CTkEntry(frm, textvariable=key_var, height=40, font=("Segoe UI", 13))
    ent.pack(fill="x", padx=18, pady=(0, 8))
    ent.focus_set()
    status = ctk.CTkLabel(frm, text="", text_color=THEME["warn_fg"], anchor="w")
    status.pack(fill="x", padx=18, pady=(0, 10))
    ok = {"v": False}

    def do_paste_btn() -> None:
        try:
            text = root.clipboard_get()
        except tk.TclError:
            return
        key_var.set(text.strip())

    def do_activate() -> None:
        key = key_var.get().strip()
        if not key:
            status.configure(text="Введите ключ")
            return
        status.configure(text="Активация…")
        root.update_idletasks()
        try:
            activate(key)
            ok["v"] = True
            root.destroy()
        except Exception as e:
            status.configure(text=str(e))

    # Это первое окно программы, и кнопки в нём красились вручную: «Вставить»
    # получалась кремовой на кремовом. Берём те же стили, что и везде.
    btns = ctk.CTkFrame(frm, fg_color="transparent")
    btns.pack(fill="x", padx=18, pady=(0, 16))
    ui_button(btns, "Вставить", do_paste_btn, style="soft", icon="copy", width=110, height=38).pack(
        side="left"
    )
    ui_button(
        btns, "Активировать", do_activate, style="primary", icon="ok", width=150, height=38,
        font=("Segoe UI Semibold", 13),
    ).pack(side="left", padx=(8, 0))
    ui_button(btns, "Выход", root.destroy, style="quiet", icon="close", width=90, height=38).pack(
        side="right"
    )
    root.bind("<Return>", lambda _e: do_activate())
    root.mainloop()
    return ok["v"] and is_licensed()


def main() -> int:
    # Second click on shortcut → restore existing window, do not open another
    mutex = acquire_or_focus_existing()
    if mutex is None:
        return 0
    # keep mutex handle alive
    main._mutex = mutex  # type: ignore[attr-defined]

    publish_runtime_paths()
    rotate_logs()

    if not _activation_dialog():
        return 1
    clog_banner(
        "APP_START",
        # Версия первой строкой: по журналу должно быть сразу видно, какая
        # программа запущена, без разбора снимков экрана.
        version=app_version(),
        build=build_info().get("built", "?"),
        home=str(PROXY),
        merged=str(MERGED),
        log=str(log_path()),
        app_dir=str(APP_DIR),
        python=sys.executable,
        py_version=".".join(str(n) for n in sys.version_info[:3]),
    )
    # After activation dialog, another instance might have appeared — re-check title
    app = App()
    app.mainloop()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
