Ctrl K
ComunidadGuías…

src-tauri/src/connection.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! Менеджер подключения: запуск/останов sidecar sing-box, состояние, события.
//!
//! Состояние живёт в managed-state Tauri (`Connection`). При каждом изменении
//! шлём фронту событие `connection-state`. Если sing-box падает сам —
//! переводим в `Error` и снимаем системный прокси.

use crate::models::{ConnectionState, Mode, Outbound};
use crate::{config, elevation, profiles, settings, stats, storage, sysproxy};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;

/// Порт локального mixed-inbound (proxy mode). Phase 3 — фиксированный.
pub const LOCAL_PORT: u16 = 2080;
/// Порт локального SOCKS5 у awg-shim (userspace AmneziaWG). Фиксированный.
pub const SHIM_PORT: u16 = 2081;
/// Конфиг для awg-shim (тот же .conf, что у legacy-движка, но своё имя файла).
const SHIM_CONF: &str = "awg-shim.conf";
const RUNNING_CONFIG: &str = "running-config.json";
const EVENT: &str = "connection-state";
const GEOIP_RU_FILE: &str = "geoip-ru.srs";
const GEOIP_RU_URL: &str =
    "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-ru.srs";
const GEOIP_REFRESH_AFTER: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
static GEOIP_UPDATE_ATTEMPTED: std::sync::atomic::AtomicBool =
    std::sync::atomic::AtomicBool::new(false);
/// Имя AmneziaWG-тоннеля (= имя .conf без расширения) для /installtunnelservice.
/// Только Windows: на macOS движок (awg-quick) адресуется путём к `.conf`.
#[cfg(target_os = "windows")]
const AWG_TUNNEL: &str = "UniGate";
const AWG_CONF: &str = "UniGate.conf";
/// macOS TUN: единого «перезапуска с UAC» нет, поэтому sing-box поднимаем от root
/// через osascript (нативный запрос пароля). Управляем им «сигнальным» файлом:
/// GUI создаёт его перед запуском, а при отключении удаляет — root-watcher внутри
/// той же привилегированной сессии убивает sing-box (disconnect без пароля).
#[cfg(target_os = "macos")]
const TUN_SENTINEL: &str = "tun-run.lock";
/// macOS AmneziaWG без root-приложения: тот же сигнальный механизм, что и у TUN
/// (awg-quick up от root через osascript + root-watcher, снимающий тоннель при
/// удалении файла — disconnect без повторного пароля).
#[cfg(target_os = "macos")]
const AWG_SENTINEL: &str = "awg-run.lock";

#[derive(Default)]
pub struct ConnState {
    /// sing-box (proxy/tun).
    child: Option<CommandChild>,
    /// активный AmneziaWG-тоннель (имя сервиса), если поднят legacy-движок.
    awg_tunnel: Option<String>,
    /// awg-shim (userspace AmneziaWG → локальный SOCKS5), когда AWG-профиль
    /// идёт через sing-box. Держим stdin-пайп: выход UniGate = EOF = шим
    /// завершается сам, даже если мы не успели его убить.
    shim: Option<std::process::Child>,
    /// PID sing-box, запущенного от root на macOS в TUN-режиме (управляется
    /// сигнальным файлом, не через CommandChild).
    #[cfg(target_os = "macos")]
    tun_root_pid: Option<u32>,
    state: ConnectionState,
}

pub struct Connection(pub Mutex<ConnState>);

#[cfg(target_os = "windows")]
fn terminate_windows_process(pid: u32, name: &str) -> Result<(), String> {
    use windows_sys::Win32::{
        Foundation::{CloseHandle, GetLastError, ERROR_INVALID_PARAMETER, WAIT_OBJECT_0},
        System::Threading::{
            OpenProcess, TerminateProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE,
            PROCESS_TERMINATE,
        },
    };

    const WAIT_TIMEOUT_MS: u32 = 5_000;
    let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, 0, pid) };
    if handle.is_null() {
        let code = unsafe { GetLastError() };
        // Процесс мог успеть завершиться между чтением PID и OpenProcess.
        return if code == ERROR_INVALID_PARAMETER {
            Ok(())
        } else {
            Err(format!("не удалось открыть процесс {name} (код {code})"))
        };
    }
    if unsafe { WaitForSingleObject(handle, 0) } != WAIT_OBJECT_0
        && unsafe { TerminateProcess(handle, 1) } == 0
    {
        let code = unsafe { GetLastError() };
        unsafe { CloseHandle(handle) };
        return Err(format!("не удалось завершить {name} (код {code})"));
    }
    let result = unsafe { WaitForSingleObject(handle, WAIT_TIMEOUT_MS) };
    unsafe { CloseHandle(handle) };
    if result == WAIT_OBJECT_0 {
        Ok(())
    } else {
        Err(format!("{name} не завершился за 5 секунд"))
    }
}

impl Connection {
    pub fn new() -> Self {
        Self(Mutex::new(ConnState::default()))
    }
}

/// На старте: если мы (ещё) не подключены, а системный прокси указывает на наш
/// локальный порт — значит остался после нештатного выхода/краха. Снимаем его,
/// чтобы не сломать пользователю интернет мёртвым прокси.
pub fn reconcile_startup(_app: &AppHandle) {
    if sysproxy::is_enabled_for(LOCAL_PORT) {
        let _ = sysproxy::disable();
    }
    // macOS: убираем «застрявшие» сигнальные файлы после жёсткого краша GUI —
    // тогда оставшийся root-watcher (если жив) сам завершит sing-box/awg-тоннель.
    #[cfg(target_os = "macos")]
    for sentinel in [TUN_SENTINEL, AWG_SENTINEL] {
        if let Ok(sent) = storage::path(_app, sentinel) {
            let _ = std::fs::remove_file(sent);
        }
    }
    // macOS: если каталог настроек создавался под sudo — чиним владельца,
    // иначе обычный (не-root) запуск не может ничего сохранять.
    #[cfg(target_os = "macos")]
    heal_config_dir_ownership(_app);
}

/// macOS: после запуска приложения через `sudo` файлы в App Support остаются
/// root-owned — обычный запуск не может сохранять настройки/профили и создавать
/// сигнальные файлы (и пользователь вынужден снова запускать от админа).
/// Обнаруживаем это и чиним владельца одним запросом пароля.
#[cfg(target_os = "macos")]
fn heal_config_dir_ownership(app: &AppHandle) {
    use std::os::unix::fs::MetadataExt;
    if elevation::is_elevated() {
        return;
    }
    let Ok(probe) = storage::path(app, ".rw-probe") else { return };
    let Some(dir) = probe.parent().map(|d| d.to_path_buf()) else { return };

    let uid = unsafe { libc::getuid() };
    let dir_broken = match std::fs::write(&probe, b"") {
        Ok(()) => {
            let _ = std::fs::remove_file(&probe);
            false
        }
        Err(e) => e.kind() == std::io::ErrorKind::PermissionDenied,
    };
    // отдельные root-owned файлы при user-owned каталоге (запускали sudo ПОСЛЕ
    // обычных запусков): write_json переживёт (rename), а копирование geoip и
    // лог ядра — нет, поэтому тоже чиним
    let files_broken = std::fs::read_dir(&dir)
        .map(|rd| {
            rd.flatten()
                .any(|e| e.metadata().map(|m| m.uid() != uid).unwrap_or(false))
        })
        .unwrap_or(false);
    if !dir_broken && !files_broken {
        return;
    }

    let gid = unsafe { libc::getgid() };
    let q = dir.to_string_lossy().replace('\'', "'\\''");
    if let Err(e) = elevation::osascript_admin(&format!("/usr/sbin/chown -R {uid}:{gid} '{q}'")) {
        eprintln!("не удалось починить владельца каталога настроек: {e}");
    }
}

/// Снимает прокси, убивает sing-box и снимает AmneziaWG-тоннель.
/// Вызывается при выходе из приложения (best-effort).
pub fn cleanup(app: &AppHandle) {
    let (child, awg, shim) = if let Some(conn) = app.try_state::<Connection>() {
        if let Ok(mut guard) = conn.0.lock() {
            let t = (guard.child.take(), guard.awg_tunnel.take(), guard.shim.take());
            guard.state = ConnectionState::Disconnected;
            t
        } else {
            (None, None, None)
        }
    } else {
        (None, None, None)
    };

    if let Some(child) = child {
        #[cfg(target_os = "windows")]
        let _ = terminate_windows_process(child.pid(), "sing-box");
        #[cfg(not(target_os = "windows"))]
        let _ = child.kill();
    }
    if let Some(mut shim) = shim {
        let _ = shim.kill();
        let _ = shim.wait();
    }
    if let Some(handle) = awg {
        if let Err(e) = awg_tunnel_down(app, &handle) {
            eprintln!("не удалось снять AmneziaWG-тоннель: {e}");
        }
    }
    // macOS TUN: удаляем сигнальный файл — root-watcher (переживёт выход GUI) убьёт sing-box
    #[cfg(target_os = "macos")]
    if let Ok(sent) = storage::path(app, TUN_SENTINEL) {
        let _ = std::fs::remove_file(sent);
    }
    let _ = sysproxy::disable();
}

/// Путь к amneziawg.exe. ВАЖНО: запускаем именно ту копию, рядом с которой лежит
/// `wintun.dll` — иначе сервис тоннеля не создаст Wintun-адаптер (exit 3).
/// В dev это `src-tauri/binaries`, а НЕ `target/debug` (куда Tauri кладёт копию
/// sidecar без wintun.dll).
#[cfg(target_os = "windows")]
fn awg_exe() -> Option<std::path::PathBuf> {
    const TRIPLE: &str = "amneziawg-x86_64-pc-windows-msvc.exe";
    const PLAIN: &str = "amneziawg.exe";
    let mut candidates: Vec<std::path::PathBuf> = Vec::new();
    // dev: рядом с wintun.dll
    if let Ok(cwd) = std::env::current_dir() {
        candidates.push(cwd.join("binaries").join(TRIPLE));
        candidates.push(cwd.join("src-tauri").join("binaries").join(TRIPLE));
    }
    // prod: рядом с основным exe (туда бандлится и wintun.dll)
    if let Ok(exe) = std::env::current_exe() {
        if let Some(dir) = exe.parent() {
            candidates.push(dir.join(TRIPLE));
            candidates.push(dir.join(PLAIN));
        }
    }
    candidates.into_iter().find(|p| p.exists())
}

/// Находит файл в каталоге binaries (dev: src-tauri/binaries; prod: рядом с exe
/// или в resources/).
fn binaries_file(name: &str) -> Option<std::path::PathBuf> {
    let mut candidates: Vec<std::path::PathBuf> = Vec::new();
    if let Ok(cwd) = std::env::current_dir() {
        candidates.push(cwd.join("binaries").join(name));
        candidates.push(cwd.join("src-tauri").join("binaries").join(name));
    }
    if let Ok(exe) = std::env::current_exe() {
        if let Some(dir) = exe.parent() {
            candidates.push(dir.join(name));
            candidates.push(dir.join("resources").join(name));
            // macOS .app: exe лежит в Contents/MacOS, а bundle.resources Tauri
            // кладёт в Contents/Resources — без этого кандидата установленное
            // приложение не находило geoip-ru.srs и RU-обход молча отключался
            #[cfg(target_os = "macos")]
            if let Some(contents) = dir.parent() {
                candidates.push(contents.join("Resources").join(name));
            }
        }
    }
    candidates.into_iter().find(|p| p.exists())
}

/// Путь к awg-shim — userspace AmneziaWG, торчащий локальным SOCKS5
/// (Windows: scripts/build-awg-shim.ps1; macOS: scripts/fetch-awg-macos.sh).
/// В prod externalBin кладёт его в каталог исполняемых файлов без triple-суффикса.
fn awg_shim_exe() -> Option<std::path::PathBuf> {
    #[cfg(target_os = "windows")]
    {
        binaries_file("awg-shim-x86_64-pc-windows-msvc.exe")
            .or_else(|| binaries_file("awg-shim.exe"))
    }
    #[cfg(target_os = "macos")]
    {
        const NAMES: [&str; 3] = [
            "awg-shim-aarch64-apple-darwin",
            "awg-shim-x86_64-apple-darwin",
            "awg-shim",
        ];
        NAMES.into_iter().find_map(binaries_file)
    }
    #[cfg(not(any(target_os = "windows", target_os = "macos")))]
    {
        None
    }
}

/// Запускает awg-shim: пишет .conf, стартует процесс со stdin-пайпом (умер
/// UniGate → закрылся пайп → шим выходит сам) и ждёт строку `READY` — к этому
/// моменту endpoint разрезолвлен и SOCKS5 слушает. Ошибка старта — из stderr.
async fn start_awg_shim(app: &AppHandle, conf: &str) -> Result<std::process::Child, String> {
    let exe = awg_shim_exe().ok_or("awg-shim не найден рядом с приложением")?;
    let conf_path = storage::path(app, SHIM_CONF)?;
    std::fs::write(&conf_path, conf)
        .map_err(|e| format!("не удалось записать {SHIM_CONF}: {e}"))?;

    let mut cmd = std::process::Command::new(&exe);
    cmd.arg("--conf")
        .arg(&conf_path)
        .arg("--listen")
        .arg(format!("127.0.0.1:{SHIM_PORT}"))
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped());
    #[cfg(target_os = "windows")]
    {
        use std::os::windows::process::CommandExt;
        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
        cmd.creation_flags(CREATE_NO_WINDOW);
    }
    let mut child = cmd
        .spawn()
        .map_err(|e| format!("не удалось запустить awg-shim: {e}"))?;

    let stdout = child.stdout.take();
    let stderr = child.stderr.take();
    let ready = tauri::async_runtime::spawn_blocking(move || {
        use std::io::{BufRead, BufReader, Read};
        let mut line = String::new();
        if let Some(out) = stdout {
            let _ = BufReader::new(out).read_line(&mut line);
        }
        if line.starts_with("READY") {
            return Ok(());
        }
        // stdout закрылся без READY — шим упал на старте, причина в stderr
        let mut err = String::new();
        if let Some(mut se) = stderr {
            let _ = se.read_to_string(&mut err);
        }
        let lines: Vec<&str> = err.trim().lines().collect();
        let tail = lines[lines.len().saturating_sub(3)..].join("\n");
        Err(if tail.is_empty() { line.trim().to_string() } else { tail })
    });
    match tokio::time::timeout(std::time::Duration::from_secs(10), ready).await {
        Ok(Ok(Ok(()))) => Ok(child),
        Ok(Ok(Err(msg))) => {
            let _ = child.kill();
            Err(format!("awg-shim не запустился: {msg}"))
        }
        _ => {
            let _ = child.kill();
            Err("awg-shim не ответил за 10 секунд".into())
        }
    }
}

/// Доступен ли awg-shim: AmneziaWG тогда идёт через sing-box (split-tunneling
/// и статистика работают). Иначе — legacy-движок, полный туннель.
#[tauri::command]
pub fn awg_shim_available() -> bool {
    awg_shim_exe().is_some()
}

/// Убивает awg-shim, если он ещё запущен (best-effort).
fn kill_shim(app: &AppHandle) {
    if let Some(conn) = app.try_state::<Connection>() {
        if let Ok(mut guard) = conn.0.lock() {
            if let Some(mut shim) = guard.shim.take() {
                let _ = shim.kill();
            }
        }
    }
}

fn valid_geoip(bytes: &[u8]) -> bool {
    // sing-box rule-set binary: magic `SRS` + version. Ограничения
    // отсекают HTML-ошибку GitHub и случайно обрезанную загрузку.
    (10_000..=1_000_000).contains(&bytes.len()) && bytes.starts_with(b"SRS\x01")
}

fn valid_geoip_file(path: &std::path::Path) -> bool {
    std::fs::read(path)
        .map(|bytes| valid_geoip(&bytes))
        .unwrap_or(false)
}

fn geoip_is_fresh(path: &std::path::Path) -> bool {
    std::fs::metadata(path)
        .and_then(|meta| meta.modified())
        .ok()
        .and_then(|modified| modified.elapsed().ok())
        .map(|age| age < GEOIP_REFRESH_AFTER)
        .unwrap_or(false)
}

/// Заменяет кэш через готовый temp-файл. На Windows `rename` не
/// перезаписывает цель, поэтому держим backup до успешной замены.
fn replace_geoip(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
    let tmp = path.with_extension("srs.tmp");
    std::fs::write(&tmp, bytes)
        .map_err(|e| format!("не удалось записать {}: {e}", tmp.display()))?;
    if !valid_geoip_file(&tmp) {
        let _ = std::fs::remove_file(&tmp);
        return Err("загруженный geoip-ru.srs не прошёл проверку".into());
    }

    #[cfg(target_os = "windows")]
    if path.exists() {
        let backup = path.with_extension("srs.bak");
        let _ = std::fs::remove_file(&backup);
        std::fs::rename(path, &backup)
            .map_err(|e| format!("не удалось подготовить замену geoip: {e}"))?;
        if let Err(e) = std::fs::rename(&tmp, path) {
            let _ = std::fs::rename(&backup, path);
            return Err(format!("не удалось заменить geoip: {e}"));
        }
        let _ = std::fs::remove_file(backup);
        return Ok(());
    }

    std::fs::rename(&tmp, path)
        .map_err(|e| format!("не удалось установить geoip: {e}"))
}

/// Возвращает читаемую ядром копию GeoIP из app config dir.
/// Раз в сутки пытаемся обновить её до актуальной SagerNet; при любой
/// ошибке остаётся прежний кэш или встроенная в приложение база.
/// Заодно это снимает macOS TCC-проблему с проектом на Desktop.
async fn prepare_geoip(app: &AppHandle) -> Option<String> {
    let cache = storage::path(app, GEOIP_RU_FILE).ok();
    let cached_valid = cache.as_deref().map(valid_geoip_file).unwrap_or(false);

    let stale = !cached_valid || !cache.as_deref().map(geoip_is_fresh).unwrap_or(false);
    // Если GitHub недоступен, не задерживаем каждый reconnect
    // в том же запуске приложения; новая попытка будет после перезапуска.
    let should_refresh = stale
        && !GEOIP_UPDATE_ATTEMPTED.swap(true, std::sync::atomic::Ordering::Relaxed);
    if should_refresh {
        let download = async {
            let client = reqwest::Client::builder()
                .user_agent("UniGate")
                .timeout(std::time::Duration::from_secs(6))
                .build()
                .map_err(|e| format!("http-клиент geoip: {e}"))?;
            let response = client
                .get(GEOIP_RU_URL)
                .send()
                .await
                .map_err(|e| format!("загрузка geoip: {e}"))?;
            if !response.status().is_success() {
                return Err(format!("geoip вернул статус {}", response.status()));
            }
            let bytes = response
                .bytes()
                .await
                .map_err(|e| format!("чтение geoip: {e}"))?;
            if !valid_geoip(&bytes) {
                return Err("ответ geoip не похож на sing-box rule-set".to_string());
            }
            Ok::<_, String>(bytes)
        }
        .await;

        match (download, cache.as_deref()) {
            (Ok(bytes), Some(path)) => {
                if let Err(e) = replace_geoip(path, &bytes) {
                    eprintln!("[geoip] {e}");
                }
            }
            (Err(e), _) => eprintln!("[geoip] {e}; используем локальную базу"),
            _ => {}
        }
    }

    if let Some(path) = cache.filter(|path| valid_geoip_file(path)) {
        return Some(path.to_string_lossy().into_owned());
    }
    let bundled = binaries_file(GEOIP_RU_FILE).filter(|path| valid_geoip_file(path));
    if bundled.is_none() {
        eprintln!("geoip-ru.srs не найден — RU-обход не будет применён");
    }
    bundled.map(|path| path.to_string_lossy().into_owned())
}

fn parse_vpn_route_prefixes(output: &str) -> Vec<String> {
    let mut routes = Vec::new();
    for prefix in output.lines().map(str::trim).filter(|line| !line.is_empty()) {
        let Some((ip, bits)) = prefix.split_once('/') else {
            continue;
        };
        let Ok(ip) = ip.parse::<std::net::Ipv4Addr>() else {
            continue;
        };
        let Ok(bits) = bits.parse::<u8>() else {
            continue;
        };
        // Не отдаём второму VPN default/half-default: обычный
        // интернет должен остаться за UniGate. Не исключаем также
        // loopback/multicast и служебную benchmark-сеть нашего TUN.
        let octets = ip.octets();
        if !(8..=32).contains(&bits)
            || ip.is_loopback()
            || ip.is_multicast()
            || (octets[0] == 198 && matches!(octets[1], 18 | 19))
        {
            continue;
        }
        let value = format!("{ip}/{bits}");
        if !routes.contains(&value) {
            routes.push(value);
        }
    }
    routes
}

/// Конкретные IPv4-сети активных сторонних VPN-адаптеров.
/// Имя самого подключения может быть любым; ищем также типичные
/// описания драйверов OpenVPN/WireGuard/корпоративных VPN.
#[cfg(target_os = "windows")]
fn detect_other_vpn_routes() -> Vec<String> {
    use std::os::windows::process::CommandExt;
    const CREATE_NO_WINDOW: u32 = 0x0800_0000;
    let script = r#"
$pattern = '(?i)(openvpn|wireguard|wintun|tap-windows|\bdco\b|tailscale|zerotier|amnezia|anyconnect|fortinet|forticlient|globalprotect|pulse secure|juniper|sonicwall|check point|zscaler|netmotion|vpn|wan miniport.*(ikev2|l2tp|pptp|sstp))'
$indexes = @(Get-NetAdapter -IncludeHidden -ErrorAction SilentlyContinue |
  Where-Object { $_.Status -eq 'Up' -and $_.Name -ne 'tun0' -and ($_.Name -match $pattern -or $_.InterfaceDescription -match $pattern) } |
  Select-Object -ExpandProperty ifIndex)
if ($indexes.Count -gt 0) {
  Get-NetRoute -AddressFamily IPv4 -ErrorAction SilentlyContinue |
    Where-Object { $_.State -eq 'Alive' -and $indexes -contains $_.InterfaceIndex } |
    Select-Object -ExpandProperty DestinationPrefix -Unique
}
"#;
    let output = std::process::Command::new("powershell.exe")
        .args([
            "-NoLogo",
            "-NoProfile",
            "-NonInteractive",
            "-ExecutionPolicy",
            "Bypass",
            "-Command",
            script,
        ])
        .creation_flags(CREATE_NO_WINDOW)
        .output();
    match output {
        Ok(output) if output.status.success() => {
            parse_vpn_route_prefixes(&String::from_utf8_lossy(&output.stdout))
        }
        Ok(output) => {
            eprintln!(
                "[vpn-compat] не удалось прочитать маршруты: {}",
                String::from_utf8_lossy(&output.stderr).trim()
            );
            Vec::new()
        }
        Err(e) => {
            eprintln!("[vpn-compat] не удалось запустить аудит маршрутов: {e}");
            Vec::new()
        }
    }
}

/// Путь к бинарнику sing-box для ПРЯМОГО запуска (не через Tauri-sidecar).
/// Нужно на macOS в TUN-режиме, где sing-box поднимается от root через osascript.
/// dev: `src-tauri/binaries/sing-box-<triple>`; prod: рядом с exe (`sing-box`).
#[cfg(target_os = "macos")]
fn singbox_path() -> Option<std::path::PathBuf> {
    const NAMES: [&str; 3] = [
        "sing-box-aarch64-apple-darwin",
        "sing-box-x86_64-apple-darwin",
        "sing-box",
    ];
    let mut dirs: Vec<std::path::PathBuf> = Vec::new();
    if let Ok(cwd) = std::env::current_dir() {
        dirs.push(cwd.join("binaries"));
        dirs.push(cwd.join("src-tauri").join("binaries"));
    }
    if let Ok(exe) = std::env::current_exe() {
        if let Some(dir) = exe.parent() {
            dirs.push(dir.to_path_buf());
            dirs.push(dir.join("resources"));
            // установленный macOS bundle: executable = Contents/MacOS/unigate,
            // ресурсы Tauri = Contents/Resources
            if let Some(contents) = dir.parent() {
                dirs.push(contents.join("Resources"));
            }
        }
    }
    for d in dirs {
        for n in NAMES {
            let p = d.join(n);
            if p.exists() {
                return Some(p);
            }
        }
    }
    None
}

/// Запускает sing-box от root через osascript (нативный запрос пароля админа).
/// Возвращает PID. В той же привилегированной сессии стартует root-watcher: он
/// убьёт sing-box, как только исчезнет сигнальный файл `sentinel` — это даёт
/// отключение без повторного запроса пароля (GUI просто удаляет файл).
#[cfg(target_os = "macos")]
fn spawn_singbox_root(
    singbox: &std::path::Path,
    cfg: &std::path::Path,
    log: &std::path::Path,
    sentinel: &std::path::Path,
) -> Result<u32, String> {
    // экранирование одинарных кавычек для sh: ' -> '\''
    let q = |p: &std::path::Path| p.to_string_lossy().replace('\'', "'\\''");
    let shell = format!(
        "'{sb}' run -c '{cfg}' > '{log}' 2>&1 & SB=$!; echo $SB; \
         ( while kill -0 $SB 2>/dev/null && [ -e '{sent}' ]; do sleep 0.3; done; \
           kill $SB 2>/dev/null ) >/dev/null 2>&1 &",
        sb = q(singbox),
        cfg = q(cfg),
        log = q(log),
        sent = q(sentinel),
    );
    let out = elevation::osascript_admin(&shell)?;
    String::from_utf8_lossy(&out.stdout)
        .trim()
        .parse::<u32>()
        .map_err(|_| "не удалось получить PID sing-box (root)".to_string())
}

/// Ждёт освобождения локального порта (проверка bind'ом), poll 150 мс.
/// true — порт свободен; false — так и не освободился за timeout.
async fn wait_port_free(port: u16, timeout: std::time::Duration) -> bool {
    let deadline = std::time::Instant::now() + timeout;
    loop {
        // bind тут же закрываем — нам нужен только факт доступности порта
        if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
            return true;
        }
        if std::time::Instant::now() >= deadline {
            return false;
        }
        tokio::time::sleep(std::time::Duration::from_millis(150)).await;
    }
}

/// Последние строки лога ядра (`sing-box.log`) — чтобы показать причину падения
/// в сообщении об ошибке, а не только «завершился с кодом N».
fn log_tail(app: &AppHandle, max_lines: usize) -> Option<String> {
    let path = storage::path(app, "sing-box.log").ok()?;
    let text = std::fs::read_to_string(path).ok()?;
    let mut lines: Vec<&str> = text.lines().rev().take(max_lines).collect();
    lines.reverse();
    if lines.is_empty() {
        return None;
    }
    Some(lines.join("\n"))
}

/// true, если процесс с данным PID жив. `kill -0` от пользователя к root-процессу
/// возвращает EPERM — это тоже «жив»; окончательно мёртв только ESRCH.
#[cfg(target_os = "macos")]
fn pid_alive(pid: u32) -> bool {
    if unsafe { libc::kill(pid as i32, 0) } == 0 {
        return true;
    }
    std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}

/// Запускает amneziawg.exe напрямую (как в консоли — без перенаправления stdio
/// и CREATE_NO_WINDOW, иначе GUI-exe не стартует сервис тоннеля).
#[cfg(target_os = "windows")]
fn run_awg(args: &[&str]) -> Result<(), String> {
    let exe = awg_exe().ok_or("amneziawg.exe не найден рядом с приложением")?;
    let status = std::process::Command::new(&exe)
        .args(args)
        .status()
        .map_err(|e| format!("не удалось запустить amneziawg: {e}"))?;
    if !status.success() {
        return Err(format!("amneziawg завершился с кодом {:?}", status.code()));
    }
    Ok(())
}

/// Каталог с движком AmneziaWG для macOS: `amneziawg-go` (userspace-датапас) +
/// `awg` (UAPI-конфигуратор) + `awg-quick` (bash-обёртка, поднимает utun+адреса+
/// маршруты+DNS из `.conf`). Prebuilt-бинарников нет — собираются из исходников,
/// см. scripts/fetch-awg-macos.sh; в dev кладутся в `src-tauri/binaries`.
#[cfg(target_os = "macos")]
fn awg_engine_dir() -> Option<std::path::PathBuf> {
    let mut dirs: Vec<std::path::PathBuf> = Vec::new();
    if let Ok(cwd) = std::env::current_dir() {
        dirs.push(cwd.join("binaries"));
        dirs.push(cwd.join("src-tauri").join("binaries"));
    }
    if let Ok(exe) = std::env::current_exe() {
        if let Some(dir) = exe.parent() {
            dirs.push(dir.to_path_buf());
            dirs.push(dir.join("resources"));
            if let Some(contents) = dir.parent() {
                dirs.push(contents.join("Resources"));
            }
        }
    }
    dirs.into_iter().find(|d| {
        d.join("amneziawg-go").exists() && d.join("awg").exists() && d.join("awg-quick").exists()
    })
}

/// macOS: `awg-quick up|down <conf>` от root. PATH дополняем каталогом движка,
/// чтобы awg-quick нашёл `awg` и userspace-реализацию (`amneziawg-go`).
#[cfg(target_os = "macos")]
fn run_awg_quick(action: &str, conf: &str) -> Result<(), String> {
    let dir = awg_engine_dir()
        .ok_or("движок AmneziaWG (amneziawg-go/awg/awg-quick) не найден рядом с приложением")?;
    let path_env = match std::env::var("PATH") {
        Ok(p) => format!("{}:{}", dir.display(), p),
        Err(_) => format!("{}:/usr/bin:/bin:/usr/sbin:/sbin", dir.display()),
    };
    let status = std::process::Command::new("bash")
        .arg(dir.join("awg-quick"))
        .arg(action)
        .arg(conf)
        .env("PATH", path_env)
        .env("WG_QUICK_USERSPACE_IMPLEMENTATION", "amneziawg-go")
        .status()
        .map_err(|e| format!("не удалось запустить awg-quick: {e}"))?;
    if !status.success() {
        return Err(format!("awg-quick {action} завершился с кодом {:?}", status.code()));
    }
    Ok(())
}

/// true, если у системы есть реальный IPv6-маршрут по умолчанию.
/// `route -n get -inet6 default` возвращает 0 даже при отсутствии маршрута
/// («not in table» уходит в stderr) — поэтому смотрим на stdout: у найденного
/// маршрута там есть строка `interface: enX`.
#[cfg(target_os = "macos")]
fn has_ipv6_route() -> bool {
    std::process::Command::new("route")
        .args(["-n", "get", "-inet6", "default"])
        .output()
        .map(|o| String::from_utf8_lossy(&o.stdout).contains("interface:"))
        .unwrap_or(false)
}

/// Резолвит домен сервера профиля системным резолвером (best-effort, таймаут 4 с).
/// IPv4 — в начале списка (DNS-стратегия ядра ipv4_only). Пусто, если сервер
/// задан IP-литералом или резолв не удался.
async fn resolve_server_ips(outbound: &Outbound) -> Vec<std::net::IpAddr> {
    use std::net::ToSocketAddrs;
    let Some(host) = config::server_domain(outbound) else {
        return Vec::new();
    };
    let host = host.to_string();
    // getaddrinfo блокирующий и может висеть — уводим в blocking-пул с таймаутом
    let resolve = tauri::async_runtime::spawn_blocking(move || {
        (host.as_str(), 443)
            .to_socket_addrs()
            .map(|addrs| {
                let (mut v4, mut v6): (Vec<_>, Vec<_>) =
                    addrs.map(|a| a.ip()).partition(|ip| ip.is_ipv4());
                v4.append(&mut v6);
                v4.dedup();
                v4
            })
            .unwrap_or_default()
    });
    match tokio::time::timeout(std::time::Duration::from_secs(4), resolve).await {
        Ok(Ok(ips)) => ips,
        _ => Vec::new(),
    }
}

/// На время сеанса закрепляем тот же IP в конфиге шима, который исключили из
/// TUN. Иначе повторный DNS-запрос шима может выбрать другой A-запись.
fn pin_awg_endpoint(
    conf: &str,
    server: &str,
    port: u16,
    ip: std::net::IpAddr,
) -> Result<String, String> {
    let expected = format!("{server}:{port}");
    let replacement = std::net::SocketAddr::new(ip, port).to_string();
    let mut result = String::with_capacity(conf.len());
    let mut replaced = false;
    for line in conf.split_inclusive('\n') {
        let (body, newline) = if let Some(body) = line.strip_suffix("\r\n") {
            (body, "\r\n")
        } else if let Some(body) = line.strip_suffix('\n') {
            (body, "\n")
        } else {
            (line, "")
        };
        if let Some((key, value)) = body.split_once('=') {
            if key.trim().eq_ignore_ascii_case("Endpoint") && value.trim() == expected {
                result.push_str(key);
                result.push_str("= ");
                result.push_str(&replacement);
                result.push_str(newline);
                replaced = true;
                continue;
            }
        }
        result.push_str(line);
    }
    if replaced {
        Ok(result)
    } else {
        Err("не удалось закрепить IP: Endpoint в AWG-конфиге не совпадает с профилем".into())
    }
}

/// Исключение на уровне маршрутов ОС надёжнее одного process_name-правила:
/// Windows TUN не всегда определяет процесс для UDP-пакетов awg-shim.
#[cfg(target_os = "windows")]
fn exclude_awg_endpoint_from_tun(cfg: &mut serde_json::Value, ips: &[std::net::IpAddr]) {
    let Some(tun) = cfg
        .get_mut("inbounds")
        .and_then(|v| v.as_array_mut())
        .and_then(|inbounds| inbounds.iter_mut().find(|v| v["type"] == "tun"))
    else {
        return;
    };
    let mut excludes = tun["route_exclude_address"]
        .as_array()
        .cloned()
        .unwrap_or_default();
    for ip in ips {
        let prefix = if ip.is_ipv4() { 32 } else { 128 };
        let cidr = serde_json::json!(format!("{ip}/{prefix}"));
        if !excludes.contains(&cidr) {
            excludes.push(cidr);
        }
    }
    tun["route_exclude_address"] = serde_json::Value::Array(excludes);
}

fn set_state(app: &AppHandle, state: ConnectionState) {
    if let Some(conn) = app.try_state::<Connection>() {
        if let Ok(mut guard) = conn.0.lock() {
            guard.state = state.clone();
        }
    }
    let _ = app.emit(EVENT, state);
}

#[tauri::command]
pub fn get_connection_state(app: AppHandle) -> ConnectionState {
    app.state::<Connection>()
        .0
        .lock()
        .map(|g| g.state.clone())
        .unwrap_or_default()
}

/// true, если текущее состояние — Connected (используется поллером статистики).
pub fn is_connected(app: &AppHandle) -> bool {
    matches!(get_connection_state(app.clone()), ConnectionState::Connected)
}

/// Возвращает адрес локального прокси (для ручной проверки/отображения).
#[tauri::command]
pub fn local_proxy_addr() -> String {
    format!("127.0.0.1:{LOCAL_PORT}")
}

/// Windows + совместная работа с другим VPN: sing-box назначает TUN-адаптеру
/// служебный DNS (следующий адрес после 198.18.0.1), и Windows ставит этот
/// интерфейс первым. OpenVPN Connect с защитой от DNS-утечек блокирует запросы к
/// чужому TUN-DNS, хотя его собственные DNS через TAP/DCO доступны.
///
/// После создания адаптера очищаем его DNS и поднимаем interface metric: тогда
/// Windows выбирает DNS активного OpenVPN (либо физической сети), а более
/// специфичные auto_route-маршруты UniGate продолжают перехватывать обычный
/// трафик. Настройки относятся к ActiveStore и исчезают вместе с адаптером.
#[cfg(target_os = "windows")]
async fn release_tun_dns_to_system() -> Result<(), String> {
    let script = r#"
$ErrorActionPreference = 'Stop'
$deadline = [DateTime]::UtcNow.AddSeconds(5)
do {
  $tun = Get-NetIPAddress -AddressFamily IPv4 -IPAddress '198.18.0.1' -ErrorAction SilentlyContinue |
    Select-Object -First 1
  if ($null -ne $tun) {
    Set-DnsClientServerAddress -InterfaceIndex $tun.InterfaceIndex -ResetServerAddresses
    Set-NetIPInterface -InterfaceIndex $tun.InterfaceIndex -AddressFamily IPv4 -InterfaceMetric 5000
    exit 0
  }
  Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $deadline)
Write-Error 'TUN interface 198.18.0.1 was not found'
exit 1
"#;

    tauri::async_runtime::spawn_blocking(move || {
        use std::os::windows::process::CommandExt;
        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
        let output = std::process::Command::new("powershell.exe")
            .args([
                "-NoLogo",
                "-NoProfile",
                "-NonInteractive",
                "-ExecutionPolicy",
                "Bypass",
                "-Command",
                script,
            ])
            .creation_flags(CREATE_NO_WINDOW)
            .output()
            .map_err(|e| format!("не удалось настроить DNS TUN: {e}"))?;
        if output.status.success() {
            Ok(())
        } else {
            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
            Err(if stderr.is_empty() {
                format!(
                    "настройка DNS TUN завершилась с кодом {:?}",
                    output.status.code()
                )
            } else {
                format!("не удалось освободить DNS TUN: {stderr}")
            })
        }
    })
    .await
    .map_err(|e| format!("задача настройки DNS TUN завершилась с ошибкой: {e}"))?
}

#[tauri::command]
pub async fn connect(app: AppHandle, profile_id: String) -> Result<ConnectionState, String> {
    {
        let conn = app.state::<Connection>();
        let guard = conn.0.lock().map_err(|e| e.to_string())?;
        if matches!(
            guard.state,
            ConnectionState::Connected | ConnectionState::Connecting
        ) {
            return Err("уже подключено".into());
        }
    }

    let profile = profiles::list(&app)?
        .into_iter()
        .find(|p| p.id == profile_id)
        .ok_or_else(|| format!("профиль {profile_id} не найден"))?;

    // AmneziaWG: предпочтительный путь — awg-shim (userspace AWG → локальный
    // SOCKS5): sing-box остаётся мозгом маршрутизации, split-tunneling и
    // статистика работают как у остальных протоколов. Если шима нет рядом —
    // legacy-движок (amneziawg.exe / awg-quick): полный туннель без split.
    let awg_shim =
        matches!(profile.outbound, Outbound::AmneziaWg { .. }) && awg_shim_exe().is_some();
    if let Outbound::AmneziaWg { config, .. } = &profile.outbound {
        if !awg_shim {
            return connect_amneziawg(&app, config).await;
        }
    }

    // настройки: режим + маршрутизация; TUN требует прав администратора.
    // На Windows/Linux нужен elevated-процесс приложения; на macOS так нельзя —
    // там сам sing-box поднимаем от root через osascript (см. connect_tun_macos),
    // поэтому здесь мак не блокируем.
    let settings = settings::load(&app)?;
    let mode = settings.mode;
    #[cfg(not(target_os = "macos"))]
    if mode == Mode::Tun && !elevation::is_elevated() {
        let msg =
            "TUN-режим требует прав администратора — перезапустите приложение от имени администратора"
                .to_string();
        set_state(&app, ConnectionState::Error { message: msg.clone() });
        return Err(msg);
    }

    set_state(&app, ConnectionState::Connecting);

    // Актуальная кэшированная geoip-ru.srs для RU-обхода.
    // При офлайне остаётся прежний кэш/встроенная база.
    let geoip = if mode == Mode::Tun && settings.routing.bypass_ru {
        prepare_geoip(&app).await
    } else {
        None
    };

    // До создания tun0 снимаем снимок конкретных сетей уже
    // активного стороннего VPN. Его default route фильтруется —
    // обычный интернет по-прежнему перекрывает UniGate.
    #[cfg(target_os = "windows")]
    let vpn_route_excludes = if mode == Mode::Tun
        && settings.routing.bypass_lan
        && settings.routing.vpn_compatibility
    {
        detect_other_vpn_routes()
    } else {
        Vec::new()
    };
    #[cfg(not(target_os = "windows"))]
    let vpn_route_excludes: Vec<String> = Vec::new();
    // macOS/прочие + TUN: предрезолв домена сервера для DNS-бутстрапа.
    // Windows + AWG shim + TUN: тот же IP нужен для исключения endpoint из
    // маршрута TUN, иначе UDP шима может зациклиться при доменном Endpoint.
    #[cfg(not(target_os = "windows"))]
    let bootstrap_ips = if mode == Mode::Tun {
        resolve_server_ips(&profile.outbound).await
    } else {
        Vec::new()
    };
    #[cfg(target_os = "windows")]
    let bootstrap_ips = if mode == Mode::Tun && awg_shim {
        resolve_server_ips(&profile.outbound).await
    } else {
        Vec::new()
    };

    let mut awg_endpoint_ips = Vec::new();
    let mut shim_config = None;
    if awg_shim {
        if let Outbound::AmneziaWg {
            config: conf,
            server,
            port,
        } = &profile.outbound
        {
            if let Ok(ip) = server.parse::<std::net::IpAddr>() {
                awg_endpoint_ips.push(ip);
            } else if mode == Mode::Tun {
                if let Some(&ip) = bootstrap_ips.first() {
                    // Шим и исключение маршрута должны использовать один IP.
                    let pinned = pin_awg_endpoint(conf, server, *port, ip).map_err(|e| {
                        set_state(&app, ConnectionState::Error { message: e.clone() });
                        e
                    })?;
                    shim_config = Some(pinned);
                    awg_endpoint_ips.push(ip);
                } else if cfg!(target_os = "windows") {
                    let msg =
                        format!("не удалось определить IP AWG-сервера {server} до запуска TUN");
                    set_state(&app, ConnectionState::Error { message: msg.clone() });
                    return Err(msg);
                }
            }
        }
    }

    // Перехватывать ли IPv6 в TUN: только если у системы реально есть
    // IPv6-маршрут. На IPv4-only сети фейковый IPv6 через tun ломает
    // direct-обход (bypass_ru): приложения выбирают IPv6, а доставить его
    // некуда → мгновенный разрыв. Windows не трогаем (флаг игнорируется).
    #[cfg(target_os = "macos")]
    let tun_ipv6 = mode == Mode::Tun && has_ipv6_route();
    #[cfg(not(target_os = "macos"))]
    let tun_ipv6 = false;

    // Профиль для генератора: AmneziaWG через шим для sing-box — обычный
    // SOCKS5-outbound на localhost
    let mut gen_profile = profile.clone();
    if awg_shim {
        gen_profile.outbound = Outbound::Socks {
            server: "127.0.0.1".into(),
            port: SHIM_PORT,
            username: None,
            password: None,
        };
    }

    // генерируем и пишем рабочий конфиг
    let mut cfg = config::generate_with_vpn_routes(
        &gen_profile,
        mode,
        LOCAL_PORT,
        &settings.routing,
        geoip.as_deref(),
        &bootstrap_ips,
        tun_ipv6,
        settings.tun_stack,
        &vpn_route_excludes,
    );

    #[cfg(target_os = "windows")]
    if awg_shim && mode == Mode::Tun {
        exclude_awg_endpoint_from_tun(&mut cfg, &awg_endpoint_ips);
    }

    // Трафик самого шима (UDP к AWG-серверу) обязан идти мимо TUN, иначе цикл:
    // правило по имени процесса + по IP endpoint. Доменный endpoint мы
    // предрезолвим до подъёма TUN; IP-литерал берём из профиля.
    // В proxy-режиме route-правил в конфиге нет — блок no-op.
    if awg_shim {
        if let Some(rules) = cfg
            .get_mut("route")
            .and_then(|r| r.get_mut("rules"))
            .and_then(|r| r.as_array_mut())
        {
            let mut extra: Vec<serde_json::Value> = Vec::new();
            if let Some(name) = awg_shim_exe()
                .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
            {
                extra.push(serde_json::json!({ "process_name": [name], "outbound": "direct" }));
            }
            for ip in &awg_endpoint_ips {
                let prefix = if ip.is_ipv4() { 32 } else { 128 };
                extra.push(serde_json::json!({
                    "ip_cidr": [format!("{ip}/{prefix}")],
                    "outbound": "direct"
                }));
            }
            // после sniff + hijack-dns (первые два правила), до обходов/per-app
            let at = 2.min(rules.len());
            rules.splice(at..at, extra);
        }
    }
    // логи ядра в файл — уровень warn (без адресов подключений, для траблшутинга).
    // Файл обнуляем перед стартом, чтобы хвост лога относился к текущему запуску
    // (его показываем в сообщении об ошибке при падении ядра).
    if let Ok(log_path) = storage::path(&app, "sing-box.log") {
        let _ = std::fs::write(&log_path, b"");
        cfg["log"] = serde_json::json!({
            "level": "warn",
            "timestamp": true,
            "output": log_path.to_string_lossy()
        });
    }
    if let Err(e) = storage::write_json(&app, RUNNING_CONFIG, &cfg) {
        set_state(&app, ConnectionState::Error { message: e.clone() });
        return Err(e);
    }
    let path = storage::path(&app, RUNNING_CONFIG)?;

    // Пре-флайт: ядро слушает Clash API (9090), а в proxy-режиме ещё и 2080.
    // Порт может быть занят предыдущим ядром, которое завершается асинхронно
    // (root-ядро macOS TUN убивает watcher с задержкой), или чужим процессом —
    // тогда sing-box падает с невнятным «код 1». Ждём до 3 с и говорим внятно.
    {
        let mut ports = vec![config::CLASH_API_PORT];
        if mode == Mode::Proxy {
            ports.push(LOCAL_PORT);
        }
        if awg_shim {
            ports.push(SHIM_PORT);
        }
        for port in ports {
            if !wait_port_free(port, std::time::Duration::from_secs(3)).await {
                let msg = format!(
                    "порт 127.0.0.1:{port} занят другим процессом (возможно, предыдущее ядро ещё завершается) — попробуйте ещё раз"
                );
                set_state(&app, ConnectionState::Error { message: msg.clone() });
                return Err(msg);
            }
        }
    }

    // AmneziaWG: шим поднимаем ДО ядра — он резолвит endpoint системным
    // резолвером (наш TUN ещё не активен) и начинает слушать SOCKS5
    let mut shim_child: Option<std::process::Child> = None;
    if awg_shim {
        if let Outbound::AmneziaWg { config: conf, .. } = &profile.outbound {
            match start_awg_shim(&app, shim_config.as_deref().unwrap_or(conf)).await {
                Ok(c) => shim_child = Some(c),
                Err(e) => {
                    set_state(&app, ConnectionState::Error { message: e.clone() });
                    return Err(e);
                }
            }
        }
    }

    // macOS + TUN: sing-box нужен root (создать utun + маршруты).
    // Важно попасть сюда уже ПОСЛЕ старта awg-shim: root sing-box получает
    // готовый SOCKS5 на 127.0.0.1:2081. Handle шима храним в state, потому
    // что root-процесс sing-box не является нашим CommandChild.
    #[cfg(target_os = "macos")]
    if mode == Mode::Tun && !elevation::is_elevated() {
        if let Some(shim) = shim_child.take() {
            let conn = app.state::<Connection>();
            let mut guard = conn.0.lock().map_err(|e| e.to_string())?;
            guard.shim = Some(shim);
        }
        let result = connect_tun_macos(&app, &path);
        if result.is_err() {
            kill_shim(&app);
        }
        return result;
    }

    // запускаем sing-box
    let spawn = app
        .shell()
        .sidecar("sing-box")
        .map_err(|e| format!("sidecar sing-box: {e}"))
        .and_then(|cmd| {
            cmd.args(["run", "-c", &path.to_string_lossy()])
                .spawn()
                .map_err(|e| format!("не удалось запустить sing-box: {e}"))
        });

    let (mut rx, child) = match spawn {
        Ok(pair) => pair,
        Err(e) => {
            if let Some(mut shim) = shim_child.take() {
                let _ = shim.kill();
            }
            set_state(&app, ConnectionState::Error { message: e.clone() });
            return Err(e);
        }
    };

    {
        let conn = app.state::<Connection>();
        let mut guard = conn.0.lock().map_err(|e| e.to_string())?;
        guard.child = Some(child);
        guard.shim = shim_child;
        guard.state = ConnectionState::Connected;
    }

    // При явно включённой совместимости корпоративные сети и DNS должны
    // оставаться за нативным OpenVPN-адаптером. Делаем после spawn: только
    // теперь существует tun0.
    #[cfg(target_os = "windows")]
    if mode == Mode::Tun
        && settings.routing.bypass_lan
        && settings.routing.vpn_compatibility
    {
        if let Err(e) = release_tun_dns_to_system().await {
            // Best-effort: без OpenVPN само подключение UniGate остаётся рабочим,
            // поэтому не роняем сессию из-за системной настройки DNS.
            eprintln!("[tun-dns] {e}");
        }
    }

    // системный прокси ставим только в proxy-режиме (в TUN роутит сам адаптер)
    if mode == Mode::Proxy {
        if let Err(e) = sysproxy::enable(LOCAL_PORT) {
            eprintln!("не удалось включить системный прокси: {e}");
        }
    }
    let _ = app.emit(EVENT, ConnectionState::Connected);

    // запускаем опрос статистики (живёт, пока Connected)
    stats::start_polling(app.clone());

    // следим за неожиданным завершением процесса
    let app_watch = app.clone();
    tauri::async_runtime::spawn(async move {
        // последние строки stderr/stdout ядра: FATAL при старте (например,
        // занятый порт) печатается в stderr ДО настройки файлового лога —
        // без этого буфера ошибка в UI была бы просто «код 1»
        let mut stderr_tail: std::collections::VecDeque<String> =
            std::collections::VecDeque::with_capacity(5);
        while let Some(event) = rx.recv().await {
            match event {
                CommandEvent::Stderr(bytes) | CommandEvent::Stdout(bytes) => {
                    let line = String::from_utf8_lossy(&bytes);
                    let line = line.trim();
                    if !line.is_empty() {
                        eprintln!("[sing-box] {line}");
                        if stderr_tail.len() == 5 {
                            stderr_tail.pop_front();
                        }
                        stderr_tail.push_back(line.to_string());
                    }
                }
                CommandEvent::Terminated(payload) => {
                    let conn = app_watch.state::<Connection>();
                    let unexpected = {
                        let mut guard = conn.0.lock().unwrap();
                        guard.child = None;
                        !matches!(
                            guard.state,
                            ConnectionState::Disconnecting | ConnectionState::Disconnected
                        )
                    };
                    // ядро умерло — шим больше не нужен (при штатном
                    // disconnect его уже забрали, тогда это no-op)
                    kill_shim(&app_watch);
                    if unexpected {
                        let _ = sysproxy::disable();
                        let mut msg = format!("sing-box завершился (код {:?})", payload.code);
                        if let Some(tail) = log_tail(&app_watch, 5) {
                            msg = format!("{msg}\n{tail}");
                        } else if !stderr_tail.is_empty() {
                            let tail: Vec<String> = stderr_tail.iter().cloned().collect();
                            msg = format!("{msg}\n{}", tail.join("\n"));
                        }
                        set_state(&app_watch, ConnectionState::Error { message: msg });
                    }
                    break;
                }
                _ => {}
            }
        }
    });

    Ok(ConnectionState::Connected)
}

/// macOS TUN: поднимаем sing-box от root через osascript. Системный прокси НЕ
/// ставим (роутит сам utun). Статистика идёт через Clash API как обычно.
#[cfg(target_os = "macos")]
fn connect_tun_macos(app: &AppHandle, cfg_path: &std::path::Path) -> Result<ConnectionState, String> {
    let fail = |app: &AppHandle, msg: String| -> Result<ConnectionState, String> {
        set_state(app, ConnectionState::Error { message: msg.clone() });
        Err(msg)
    };

    let singbox = match singbox_path() {
        Some(p) => p,
        None => return fail(app, "не найден бинарник sing-box".into()),
    };
    let log = storage::path(app, "sing-box.log")?;
    let sentinel = storage::path(app, TUN_SENTINEL)?;

    // создаём сигнальный файл ДО запуска — пока он есть, root-watcher держит sing-box
    if let Err(e) = std::fs::write(&sentinel, b"") {
        return fail(app, format!("не удалось создать сигнальный файл: {e}"));
    }

    let pid = match spawn_singbox_root(&singbox, cfg_path, &log, &sentinel) {
        Ok(pid) => pid,
        Err(e) => {
            let _ = std::fs::remove_file(&sentinel);
            return fail(app, e);
        }
    };

    // Даём ядру время на старт и проверяем, что оно не упало сразу (FATAL на
    // конфиге/DNS/TCC): иначе покажем ложный «Connected», а ошибка останется
    // только в логе. При падении — показываем хвост sing-box.log.
    std::thread::sleep(std::time::Duration::from_millis(1500));
    if !pid_alive(pid) {
        let _ = std::fs::remove_file(&sentinel);
        let mut msg = "sing-box (TUN) упал при старте".to_string();
        if let Some(tail) = log_tail(app, 5) {
            msg = format!("{msg}:\n{tail}");
        }
        return fail(app, msg);
    }

    {
        let conn = app.state::<Connection>();
        let mut guard = conn.0.lock().map_err(|e| e.to_string())?;
        guard.tun_root_pid = Some(pid);
        guard.state = ConnectionState::Connected;
    }
    let _ = app.emit(EVENT, ConnectionState::Connected);
    stats::start_polling(app.clone());

    // Следим за root-процессом: CommandChild-событий у него нет, поэтому поллим
    // PID. Если ядро умерло, а мы всё ещё «Connected» — показываем ошибку с логом.
    let app_watch = app.clone();
    tauri::async_runtime::spawn(async move {
        loop {
            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
            let ours = {
                let conn = app_watch.state::<Connection>();
                let guard = match conn.0.lock() {
                    Ok(g) => g,
                    Err(_) => break,
                };
                guard.tun_root_pid == Some(pid)
            };
            if !ours {
                break; // штатный disconnect уже забрал PID
            }
            if !pid_alive(pid) {
                {
                    let conn = app_watch.state::<Connection>();
                    if let Ok(mut guard) = conn.0.lock() {
                        guard.tun_root_pid = None;
                    };
                }
                if let Ok(sent) = storage::path(&app_watch, TUN_SENTINEL) {
                    let _ = std::fs::remove_file(sent);
                }
                // root sing-box больше не использует локальный AWG SOCKS.
                kill_shim(&app_watch);
                let mut msg = "sing-box (TUN) неожиданно завершился".to_string();
                if let Some(tail) = log_tail(&app_watch, 5) {
                    msg = format!("{msg}:\n{tail}");
                }
                set_state(&app_watch, ConnectionState::Error { message: msg });
                break;
            }
        }
    });
    Ok(ConnectionState::Connected)
}

/// Подключение через движок AmneziaWG. Windows — `amneziawg.exe
/// /installtunnelservice`; macOS — `awg-quick up` (amneziawg-go userspace).
/// Всегда требует прав администратора; статистики Clash API у него нет.
#[cfg(any(target_os = "macos", test))]
fn normalize_awg_config_for_macos(config: &str, keep_ipv6_routes: bool) -> String {
    let is_empty_concealment = |line: &str| {
        let Some((key, value)) = line.trim().split_once('=') else {
            return false;
        };
        let key = key.trim().as_bytes();
        let is_i1_i5 = key.len() == 2
            && key[0].eq_ignore_ascii_case(&b'i')
            && matches!(key[1], b'1'..=b'5');
        let value = value.trim();
        is_i1_i5 && (value.is_empty() || value == "''" || value == "\"\"")
    };

    let filter_allowed_ips = |line: &str| -> Option<String> {
        if keep_ipv6_routes {
            return None;
        }
        let (key, value) = line.split_once('=')?;
        if !key.trim().eq_ignore_ascii_case("AllowedIPs") {
            return None;
        }
        let ipv4_routes = value
            .split(',')
            .map(str::trim)
            .filter(|route| !route.is_empty() && !route.contains(':'))
            .collect::<Vec<_>>();
        Some(if ipv4_routes.is_empty() {
            String::new()
        } else {
            format!("{}= {}", key, ipv4_routes.join(", "))
        })
    };

    let mut normalized = String::with_capacity(config.len());
    for segment in config.split_inclusive('\n') {
        let newline = if segment.ends_with("\r\n") {
            "\r\n"
        } else if segment.ends_with('\n') {
            "\n"
        } else {
            ""
        };
        let line = segment
            .strip_suffix('\n')
            .unwrap_or(segment)
            .strip_suffix('\r')
            .unwrap_or_else(|| segment.strip_suffix('\n').unwrap_or(segment));
        if is_empty_concealment(line) {
            continue;
        }
        if let Some(filtered) = filter_allowed_ips(line) {
            if !filtered.is_empty() {
                normalized.push_str(&filtered);
                normalized.push_str(newline);
            }
        } else {
            normalized.push_str(segment);
        }
    }
    normalized
}

async fn connect_amneziawg(app: &AppHandle, config: &str) -> Result<ConnectionState, String> {
    // macOS не блокируем: там awg-quick поднимается от root через osascript
    // (нативный запрос пароля), приложение может оставаться обычным.
    #[cfg(not(target_os = "macos"))]
    if !elevation::is_elevated() {
        let msg = "AmneziaWG требует прав администратора — перезапустите приложение от имени администратора".to_string();
        set_state(app, ConnectionState::Error { message: msg.clone() });
        return Err(msg);
    }
    set_state(app, ConnectionState::Connecting);

    let conf_path = storage::path(app, AWG_CONF)?;
    // awg(8) on Unix rejects explicit empty AWG 2.0 concealment fields (`I2 =`),
    // while an omitted field has the same zero-value semantics. Amnezia containers
    // commonly emit empty I2-I5, so remove only empty I1-I5 on macOS. When the
    // current network has no IPv6 default route, also omit IPv6 AllowedIPs: the
    // macOS route tool otherwise reports "Network is unreachable" during setup.
    // Filled concealment packets and the stored/exported profile remain untouched.
    #[cfg(target_os = "macos")]
    let config_to_write = normalize_awg_config_for_macos(config, has_ipv6_route());
    #[cfg(not(target_os = "macos"))]
    let config_to_write = config;
    if let Err(e) = std::fs::write(&conf_path, config_to_write) {
        let m = format!("не удалось записать {AWG_CONF}: {e}");
        set_state(app, ConnectionState::Error { message: m.clone() });
        return Err(m);
    }
    #[cfg(target_os = "macos")]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&conf_path, std::fs::Permissions::from_mode(0o600))
            .map_err(|e| format!("не удалось защитить {AWG_CONF}: {e}"))?;
    }

    let cp = conf_path.to_string_lossy().into_owned();

    // Поднимаем тоннель движком под конкретную ОС; возвращённый «handle» кладём в
    // состояние для последующего снятия (Windows: имя сервиса; macOS: путь conf).
    let handle = match awg_tunnel_up(app, &cp) {
        Ok(h) => h,
        Err(e) => {
            set_state(app, ConnectionState::Error { message: e.clone() });
            return Err(e);
        }
    };

    {
        let conn = app.state::<Connection>();
        let mut guard = conn.0.lock().map_err(|e| e.to_string())?;
        guard.awg_tunnel = Some(handle);
        guard.state = ConnectionState::Connected;
    }
    let _ = app.emit(EVENT, ConnectionState::Connected);
    Ok(ConnectionState::Connected)
}

/// Поднимает AmneziaWG-тоннель из `.conf` движком под текущую ОС. Возвращает
/// «handle» для снятия: Windows — имя сервиса тоннеля, macOS — путь к `.conf`.
fn awg_tunnel_up(app: &AppHandle, conf: &str) -> Result<String, String> {
    #[cfg(target_os = "windows")]
    {
        let _ = app;
        // снимаем возможный «зависший» тоннель с тем же именем (best-effort)
        let _ = run_awg(&["/uninstalltunnelservice", AWG_TUNNEL]);
        run_awg(&["/installtunnelservice", conf])?;
        Ok(AWG_TUNNEL.to_string())
    }
    #[cfg(target_os = "macos")]
    {
        if elevation::is_elevated() {
            // приложение уже root — управляем движком напрямую
            let _ = run_awg_quick("down", conf);
            run_awg_quick("up", conf)?;
        } else {
            // обычный запуск: up от root через osascript + сигнальный файл
            spawn_awg_quick_root(app, conf)?;
        }
        Ok(conf.to_string())
    }
    #[cfg(not(any(target_os = "windows", target_os = "macos")))]
    {
        let _ = (app, conf);
        Err("AmneziaWG на этой платформе не поддерживается".to_string())
    }
}

/// macOS без root: `awg-quick up` через osascript (запрос пароля) + root-watcher,
/// который снимет тоннель (`awg-quick down`), как только GUI удалит сигнальный
/// файл — disconnect и выход из приложения не требуют повторного пароля.
#[cfg(target_os = "macos")]
fn spawn_awg_quick_root(app: &AppHandle, conf: &str) -> Result<(), String> {
    let dir = awg_engine_dir()
        .ok_or("движок AmneziaWG (amneziawg-go/awg/awg-quick) не найден рядом с приложением")?;
    let sentinel = storage::path(app, AWG_SENTINEL)?;
    std::fs::write(&sentinel, b"")
        .map_err(|e| format!("не удалось создать сигнальный файл: {e}"))?;

    // экранирование одинарных кавычек для sh: ' -> '\''
    let q = |s: &str| s.replace('\'', "'\\''");
    let dirq = q(&dir.to_string_lossy());
    let confq = q(conf);
    let sentq = q(&sentinel.to_string_lossy());
    let shell = format!(
        "export PATH='{dirq}':\"$PATH\"; export WG_QUICK_USERSPACE_IMPLEMENTATION=amneziawg-go; \
         bash '{dirq}/awg-quick' down '{confq}' >/dev/null 2>&1; \
         bash '{dirq}/awg-quick' up '{confq}' && \
         ( ( while [ -e '{sentq}' ]; do sleep 1; done; \
             bash '{dirq}/awg-quick' down '{confq}' ) >/dev/null 2>&1 & )",
    );
    if let Err(e) = elevation::osascript_admin(&shell) {
        let _ = std::fs::remove_file(&sentinel);
        return Err(e);
    }
    Ok(())
}

/// Снятие AmneziaWG-тоннеля по сохранённому «handle» (Windows: имя сервиса;
/// macOS: путь `.conf`). Best-effort — ошибку только логируем.
fn awg_tunnel_down(app: &AppHandle, handle: &str) -> Result<(), String> {
    #[cfg(target_os = "windows")]
    {
        let _ = app;
        run_awg(&["/uninstalltunnelservice", handle])?;
    }
    #[cfg(target_os = "macos")]
    {
        if elevation::is_elevated() {
            run_awg_quick("down", handle)?;
        } else {
            // тоннель поднимал root-watcher — снимаем удалением сигнального файла
            if let Ok(sent) = storage::path(app, AWG_SENTINEL) {
                let _ = std::fs::remove_file(sent);
            }
        }
    }
    #[cfg(not(any(target_os = "windows", target_os = "macos")))]
    {
        let _ = (app, handle);
    }
    Ok(())
}

#[tauri::command]
pub async fn disconnect(app: AppHandle) -> Result<ConnectionState, String> {
    {
        let conn = app.state::<Connection>();
        let mut guard = conn.0.lock().map_err(|e| e.to_string())?;
        guard.state = ConnectionState::Disconnecting;
    }
    let _ = app.emit(EVENT, ConnectionState::Disconnecting);

    let mut stop_errors = Vec::new();

    // Windows updater немедленно завершает GUI после запуска установщика.
    // Поэтому сначала синхронно останавливаем и проверяем все принадлежащие
    // UniGate процессы. При ошибке оставляем handle в состоянии: повторное
    // нажатие снова попробует остановить тот же процесс и не пропустит установку.
    #[cfg(target_os = "windows")]
    {
        let (child_pid, shim_pid, awg) = {
            let conn = app.state::<Connection>();
            let guard = conn.0.lock().map_err(|e| e.to_string())?;
            (
                guard.child.as_ref().map(CommandChild::pid),
                guard.shim.as_ref().map(std::process::Child::id),
                guard.awg_tunnel.clone(),
            )
        };

        if let Some(pid) = child_pid {
            match terminate_windows_process(pid, "sing-box") {
                Ok(()) => {
                    let conn = app.state::<Connection>();
                    if let Ok(mut guard) = conn.0.lock() {
                        guard.child.take();
                    };
                }
                Err(e) => stop_errors.push(e),
            }
        }
        if let Some(pid) = shim_pid {
            match terminate_windows_process(pid, "AWG-движок") {
                Ok(()) => {
                    let conn = app.state::<Connection>();
                    if let Ok(mut guard) = conn.0.lock() {
                        if let Some(mut shim) = guard.shim.take() {
                            let _ = shim.wait();
                        }
                    };
                }
                Err(e) => stop_errors.push(e),
            }
        }
        if let Some(handle) = awg {
            match awg_tunnel_down(&app, &handle) {
                Ok(()) => {
                    let conn = app.state::<Connection>();
                    if let Ok(mut guard) = conn.0.lock() {
                        if guard.awg_tunnel.as_deref() == Some(handle.as_str()) {
                            guard.awg_tunnel.take();
                        }
                    };
                }
                Err(e) => stop_errors.push(format!("не удалось остановить AmneziaWG: {e}")),
            }
        }
    }

    #[cfg(not(target_os = "windows"))]
    {
        let (child, awg, shim) = {
            let conn = app.state::<Connection>();
            let mut guard = conn.0.lock().map_err(|e| e.to_string())?;
            (guard.child.take(), guard.awg_tunnel.take(), guard.shim.take())
        };
        if let Some(child) = child {
            let _ = child.kill();
        }
        if let Some(mut shim) = shim {
            let _ = shim.kill();
            if let Err(e) = shim.wait() {
                stop_errors.push(format!("не удалось дождаться остановки AWG-движка: {e}"));
            }
        }
        if let Some(handle) = awg {
            if let Err(e) = awg_tunnel_down(&app, &handle) {
                stop_errors.push(format!("не удалось остановить AmneziaWG: {e}"));
            }
        }
    }
    // macOS TUN: удаляем сигнальный файл — root-watcher сам убьёт sing-box (без
    // пароля). ВАЖНО дождаться реальной смерти ядра: оно держит порт Clash API
    // (9090), и мгновенный reconnect (например, в proxy-режиме) иначе падает с
    // «address already in use».
    #[cfg(target_os = "macos")]
    {
        let pid = {
            let conn = app.state::<Connection>();
            let mut guard = conn.0.lock().map_err(|e| e.to_string())?;
            guard.tun_root_pid.take()
        };
        if let Some(pid) = pid {
            if let Ok(sent) = storage::path(&app, TUN_SENTINEL) {
                let _ = std::fs::remove_file(sent);
            }
            // watcher поллит сигнальный файл раз в 0.3 c; ждём до ~5 с
            for _ in 0..50 {
                if !pid_alive(pid) {
                    break;
                }
                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
            }
        }
    }
    if let Err(e) = sysproxy::disable() {
        eprintln!("не удалось снять системный прокси: {e}");
    }

    if stop_errors.is_empty() {
        set_state(&app, ConnectionState::Disconnected);
        Ok(ConnectionState::Disconnected)
    } else {
        let message = stop_errors.join("; ");
        set_state(
            &app,
            ConnectionState::Error {
                message: message.clone(),
            },
        );
        Err(message)
    }
}

#[cfg(test)]
mod tests {
    use super::{
        normalize_awg_config_for_macos, parse_vpn_route_prefixes, pin_awg_endpoint, valid_geoip,
    };

    #[test]
    fn awg_domain_endpoint_is_pinned_to_session_ip() {
        let conf = "[Interface]\r\nPrivateKey = TEST\r\n[Peer]\r\nEndpoint = vpn.example.com:45826\r\n";
        let pinned = pin_awg_endpoint(
            conf,
            "vpn.example.com",
            45826,
            "203.0.113.10".parse().unwrap(),
        )
        .unwrap();
        assert_eq!(
            pinned,
            "[Interface]\r\nPrivateKey = TEST\r\n[Peer]\r\nEndpoint = 203.0.113.10:45826\r\n"
        );
        assert!(
            pin_awg_endpoint(conf, "wrong.example", 45826, "203.0.113.10".parse().unwrap())
                .is_err()
        );
    }

    #[cfg(target_os = "windows")]
    #[test]
    fn awg_endpoint_exclusion_keeps_existing_tun_exclusions() {
        let mut cfg = serde_json::json!({
            "inbounds": [{
                "type": "tun",
                "route_exclude_address": ["192.168.0.0/16"]
            }]
        });
        let ip = "203.0.113.10".parse().unwrap();
        super::exclude_awg_endpoint_from_tun(&mut cfg, &[ip, ip]);
        assert_eq!(
            cfg["inbounds"][0]["route_exclude_address"],
            serde_json::json!(["192.168.0.0/16", "203.0.113.10/32"])
        );
    }

    #[test]
    fn geoip_validation_rejects_html_and_truncated_data() {
        let mut valid = b"SRS\x01".to_vec();
        valid.resize(10_000, 0);
        assert!(valid_geoip(&valid));
        assert!(!valid_geoip(b"<html>rate limited</html>"));
        assert!(!valid_geoip(b"SRS\x01short"));
    }

    #[test]
    fn vpn_routes_keep_specific_networks_and_drop_default_hijacks() {
        let routes = parse_vpn_route_prefixes(
            "0.0.0.0/0\n0.0.0.0/1\n128.0.0.0/1\n10.44.0.0/16\n\
             100.96.0.0/11\n203.0.113.0/24\n198.18.0.0/15\n10.44.0.0/16\nbad\n",
        );
        assert_eq!(
            routes,
            vec!["10.44.0.0/16", "100.96.0.0/11", "203.0.113.0/24"]
        );
    }

    #[test]
    fn macos_awg_drops_only_empty_concealment_packets() {
        let config = "[Interface]\r\nI1 = <b 0x01>\r\nI2 =\r\nI3 = ''\r\nI4 = \"\"\r\nI5 = <b 0x05>\r\nJc = 5\r\n[Peer]\r\nPublicKey = PUB\r\n";
        let normalized = normalize_awg_config_for_macos(config, true);

        assert!(normalized.contains("I1 = <b 0x01>\r\n"));
        assert!(normalized.contains("I5 = <b 0x05>\r\n"));
        assert!(normalized.contains("Jc = 5\r\n"));
        assert!(!normalized.contains("I2 ="));
        assert!(!normalized.contains("I3 ="));
        assert!(!normalized.contains("I4 ="));
    }

    #[test]
    fn macos_awg_preserves_config_without_empty_i_fields_exactly() {
        let config = "[Interface]\nI2 = <b 0x02>\n[Peer]\nEndpoint = 1.2.3.4:1234";
        assert_eq!(normalize_awg_config_for_macos(config, true), config);
    }

    #[test]
    fn macos_awg_drops_ipv6_routes_when_the_network_has_no_ipv6() {
        let config = "[Interface]\r\nAddress = 10.8.1.7/32\r\n[Peer]\r\nAllowedIPs = 0.0.0.0/0, ::/0, 10.0.0.0/8, 2001:db8::/32\r\nEndpoint = 192.0.2.1:51820\r\n";
        let normalized = normalize_awg_config_for_macos(config, false);

        assert!(normalized.contains("AllowedIPs = 0.0.0.0/0, 10.0.0.0/8\r\n"));
        assert!(!normalized.contains("::/0"));
        assert!(!normalized.contains("2001:db8::/32"));
        assert!(normalized.contains("Endpoint = 192.0.2.1:51820\r\n"));
    }

    #[test]
    fn macos_awg_keeps_ipv6_routes_when_the_network_supports_ipv6() {
        let config = "[Peer]\nAllowedIPs = 0.0.0.0/0, ::/0\n";
        assert_eq!(normalize_awg_config_for_macos(config, true), config);
    }
}