#!/usr/bin/env python3
# SPEC: PJ2026-010104 AI网关 draft-2026-07-19-windows-python-provider
"""纯 Windows UniDesk Provider：面板、托盘、自启动和 trans 数据通道。"""

from __future__ import annotations

import base64
import ctypes
import ctypes.wintypes as wt
import hashlib
import json
import logging
from logging.handlers import TimedRotatingFileHandler
import math
import os
from pathlib import Path
import platform
import queue
import re
import select
import signal
import shlex
import shutil
import socket
import ssl
import struct
import subprocess
import sys
import threading
import time
import urllib.parse
import urllib.request

try:
    import yaml
except ImportError:
    yaml = None

try:
    import tkinter as tk
    from tkinter import messagebox, ttk
except ImportError:
    tk = None
    messagebox = None
    ttk = None

if sys.platform == "win32":
    import winreg
else:
    winreg = None

if sys.platform.startswith("linux"):
    import fcntl
else:
    fcntl = None


APP_NAME = "UniDesk Windows Provider"
DISPLAY_NAME = "UniDesk Windows Provider" if sys.platform == "win32" else "UniDesk Linux Provider"
APP_VERSION = "0.4.3"
CONFIG_DIR = Path.home() / ".unidesk"
CONFIG_PATH = CONFIG_DIR / "provider.yaml"
LOG_DIR = CONFIG_DIR / "logs"
LOG_PATH = LOG_DIR / "unideskcprovider.log"
UPDATE_DIR = CONFIG_DIR / "update"
UPDATE_STATE_PATH = UPDATE_DIR / "update-state.json"
AUTOSTART_NAME = "UniDeskWindowsProvider"
SSH_DATA_PROTOCOL = "unidesk-host-ssh-tcp-pool-v1"
POOL_SIZE = 32
MAX_HEADER_BYTES = 64 * 1024
MAX_PAYLOAD_BYTES = 16 * 1024 * 1024
HEARTBEAT_SECONDS = 15
REGISTER_TIMEOUT_SECONDS = 15
TCP_KEEPALIVE_IDLE_SECONDS = 30
TCP_KEEPALIVE_INTERVAL_SECONDS = 10
TCP_KEEPALIVE_PROBES = 3
DEFAULT_UPDATE_METADATA_URL = (
    "https://apps.hwpod.com/v1/unideskcprovider/update.json"
)
DEFAULT_UPDATE_CONFIG = {
    "enabled": True,
    "autoApplyWhenIdle": False,
    "checkIntervalSeconds": 300,
    "channel": "stable",
    "metadataUrl": DEFAULT_UPDATE_METADATA_URL,
    "downloadHostAllowlist": ["apps.hwpod.com"],
    "proxyMode": "direct",
}
ALLOWED_CONFIG_KEYS = {
    "id",
    "name",
    "server",
    "credentialFile",
    "pythonRuntime",
    "data",
    "autoStart",
    "update",
}
ALLOWED_UPDATE_KEYS = set(DEFAULT_UPDATE_CONFIG)
PROVIDER_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
VERSION_PATTERN = re.compile(r"^[0-9]+(?:\.[0-9]+){1,3}$")
COMMAND_LOG_LIMIT = 4096
STDIN_LOG_LIMIT = 4096
TRAY_ICON_SIZE = 32
TRAY_STATE_COLORS = {
    "disconnected": (135, 148, 160),
    "connecting": (238, 174, 51),
    "connected": (42, 190, 123),
    "reconnecting": (232, 102, 66),
}
BRIDGE_EXECUTABLE_PATTERN = re.compile(
    r"""(?:'|")?(/mnt/[A-Za-z]/Windows/System32/WindowsPowerShell/v1\.0/powershell\.exe)(?:'|")?""",
    re.IGNORECASE,
)


def utc_now() -> str:
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())


def windows_path_from_bridge(raw: str) -> str:
    if not raw:
        return raw
    normalized = raw.replace("/", "\\")
    match = re.match(r"^\\mnt\\([A-Za-z])(?:\\(.*))?$", normalized)
    if not match:
        return raw
    return f"{match.group(1).upper()}:\\{match.group(2) or ''}"


def parse_bridge_powershell_command(command: str) -> tuple[str, list[str]]:
    if not command:
        raise ValueError("interactive trans sessions are not supported")
    match = BRIDGE_EXECUTABLE_PATTERN.search(command)
    if not match:
        raise ValueError("expected the controlled Windows PowerShell bridge")
    try:
        words = shlex.split(command[match.start() :], posix=True)
    except ValueError as exc:
        raise ValueError(f"invalid controlled trans command: {exc}") from exc
    if len(words) < 2:
        raise ValueError("controlled Windows PowerShell bridge has no arguments")
    if not any(value.lower() == "-encodedcommand" for value in words[1:]):
        raise ValueError("controlled PowerShell -EncodedCommand is required")
    return windows_path_from_bridge(words[0]), words[1:]


def native_process_command(command: str) -> tuple[str, list[str], str, str, bool]:
    if sys.platform == "win32":
        executable, arguments = parse_bridge_powershell_command(command)
        shell_name, summary, capture_stdin = powershell_command_details_for_log(arguments)
        return executable, arguments, shell_name, summary, capture_stdin
    if sys.platform.startswith("linux"):
        if not command.strip():
            raise ValueError("interactive trans sessions are not supported")
        return "/bin/bash", ["-lc", command], "bash", redact_command_for_log(command), False
    raise RuntimeError(f"unsupported native Provider platform: {sys.platform}")


def redact_command_for_log(command: str) -> str:
    value = command.replace("\r\n", "\n").replace("\r", "\n")
    patterns = (
        (
            re.compile(r"(?i)(authorization\s*[:=]\s*bearer\s+)([^\s;]+)"),
            r"\1<redacted>",
        ),
        (
            re.compile(
                r"""(?ix)
                (
                    (?:\$env:)?(?:provider_token|unidesk_provider_token|unidesk_ssh_client_token|
                    api[_-]?key|access[_-]?token|refresh[_-]?token|password)
                    \s*=\s*
                )
                ("(?:[^"]|"")*"|'(?:[^']|'')*'|[^\s;]+)
                """
            ),
            r"\1<redacted>",
        ),
        (
            re.compile(
                r"""(?ix)
                (
                    --(?:provider-token|token|password|api-key|access-token|refresh-token)
                    (?:=|\s+)
                )
                ("[^"]*"|'[^']*'|[^\s;]+)
                """
            ),
            r"\1<redacted>",
        ),
        (
            re.compile(r"(?i)(https?://)([^/\s:@]+):([^@/\s]+)@"),
            r"\1<redacted>:<redacted>@",
        ),
    )
    for pattern, replacement in patterns:
        value = pattern.sub(replacement, value)
    value = value.replace("\n", r"\n").replace("\t", r"\t")
    if len(value) <= COMMAND_LOG_LIMIT:
        return value
    omitted = len(value) - COMMAND_LOG_LIMIT
    return f"{value[:COMMAND_LOG_LIMIT]}...<truncated chars={omitted}>"


def decoded_powershell_command(arguments: list[str]) -> str:
    for index, value in enumerate(arguments[:-1]):
        if value.lower() == "-encodedcommand":
            try:
                return base64.b64decode(arguments[index + 1], validate=True).decode("utf-16le")
            except (ValueError, UnicodeError):
                return ""
    return ""


def powershell_command_details_for_log(arguments: list[str]) -> tuple[str, str, bool]:
    decoded = decoded_powershell_command(arguments)
    if not decoded:
        return "PowerShell", "<encoded-command-decode-failed>", False
    cmd_match = re.search(
        r"""(?is)&\s+'[^']*\\cmd\.exe'\s+(?:/d\s+)?(?:/s\s+)?/c\s+'((?:''|[^'])*)'""",
        decoded,
    )
    if cmd_match:
        command = cmd_match.group(1).replace("''", "'")
        command = re.sub(
            r"""(?isx)^chcp\s+65001>nul\s*&&\s*
                set\s+"PYTHONUTF8=1"\s*&&\s*
                set\s+"PYTHONIOENCODING=utf-8"\s*&&\s*""",
            "",
            command,
        )
        return "cmd", redact_command_for_log(command), False
    script_match = re.search(r"""(?is)\$script\s*=\s*'((?:''|[^'])*)'\s*;""", decoded)
    if script_match:
        return "PowerShell", redact_command_for_log(script_match.group(1).replace("''", "'")), False
    if "$script = [Console]::In.ReadToEnd()" in decoded:
        return "PowerShell", "<stdin-script awaiting payload>", True
    return "PowerShell", redact_command_for_log(decoded), False


def powershell_command_for_log(arguments: list[str]) -> str:
    _, summary, _ = powershell_command_details_for_log(arguments)
    return summary


def tray_icon_pixels(state: str, active: bool, frame: int = 0) -> bytes:
    size = TRAY_ICON_SIZE
    pixels = bytearray(size * size * 4)

    def paint(x: int, y: int, color: tuple[int, int, int, int]) -> None:
        if not (0 <= x < size and 0 <= y < size):
            return
        offset = (y * size + x) * 4
        red, green, blue, alpha = color
        pixels[offset : offset + 4] = bytes((blue, green, red, alpha))

    def circle(cx: float, cy: float, radius: float, color: tuple[int, int, int, int]) -> None:
        left = max(0, int(cx - radius - 1))
        right = min(size - 1, int(cx + radius + 1))
        top = max(0, int(cy - radius - 1))
        bottom = min(size - 1, int(cy + radius + 1))
        radius_squared = radius * radius
        for y in range(top, bottom + 1):
            for x in range(left, right + 1):
                if (x - cx) ** 2 + (y - cy) ** 2 <= radius_squared:
                    paint(x, y, color)

    def line(
        x1: float,
        y1: float,
        x2: float,
        y2: float,
        width: float,
        color: tuple[int, int, int, int],
    ) -> None:
        steps = max(1, int(max(abs(x2 - x1), abs(y2 - y1)) * 2))
        for index in range(steps + 1):
            ratio = index / steps
            circle(
                x1 + (x2 - x1) * ratio,
                y1 + (y2 - y1) * ratio,
                width / 2,
                color,
            )

    circle(15.5, 15.5, 14.5, (26, 38, 49, 255))
    circle(15.5, 15.5, 12.5, (20, 100, 163, 255))
    white = (245, 249, 252, 255)
    line(9.5, 8.5, 9.5, 18.0, 3.2, white)
    line(21.5, 8.5, 21.5, 18.0, 3.2, white)
    line(9.5, 18.0, 15.5, 23.0, 3.2, white)
    line(21.5, 18.0, 15.5, 23.0, 3.2, white)

    state_color = TRAY_STATE_COLORS.get(state, TRAY_STATE_COLORS["disconnected"])
    circle(25.0, 25.0, 5.5, (17, 25, 31, 255))
    circle(25.0, 25.0, 4.0, (*state_color, 255))

    if active:
        angle = (frame % 8) * math.pi / 4
        center_x, center_y = 21.0, 9.0
        dx, dy = math.cos(angle), math.sin(angle)
        normal_x, normal_y = -dy, dx
        tail_x, tail_y = center_x - dx * 5.0, center_y - dy * 5.0
        head_x, head_y = center_x + dx * 4.0, center_y + dy * 4.0
        wrench = (255, 211, 77, 255)
        line(tail_x, tail_y, head_x, head_y, 2.2, wrench)
        circle(tail_x, tail_y, 2.3, wrench)
        circle(tail_x, tail_y, 0.9, (26, 38, 49, 255))
        line(
            head_x,
            head_y,
            head_x + dx * 2.5 + normal_x * 1.8,
            head_y + dy * 2.5 + normal_y * 1.8,
            1.8,
            wrench,
        )
        line(
            head_x,
            head_y,
            head_x + dx * 2.5 - normal_x * 1.8,
            head_y + dy * 2.5 - normal_y * 1.8,
            1.8,
            wrench,
        )
    return bytes(pixels)


def create_tray_hicon(state: str, active: bool, frame: int = 0):
    if sys.platform != "win32":
        return None
    width = height = TRAY_ICON_SIZE
    pixels = tray_icon_pixels(state, active, frame)
    user32 = ctypes.windll.user32
    gdi32 = ctypes.windll.gdi32

    class BITMAPINFOHEADER(ctypes.Structure):
        _fields_ = [
            ("biSize", wt.DWORD),
            ("biWidth", ctypes.c_long),
            ("biHeight", ctypes.c_long),
            ("biPlanes", wt.WORD),
            ("biBitCount", wt.WORD),
            ("biCompression", wt.DWORD),
            ("biSizeImage", wt.DWORD),
            ("biXPelsPerMeter", ctypes.c_long),
            ("biYPelsPerMeter", ctypes.c_long),
            ("biClrUsed", wt.DWORD),
            ("biClrImportant", wt.DWORD),
        ]

    class BITMAPINFO(ctypes.Structure):
        _fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", wt.DWORD * 3)]

    class ICONINFO(ctypes.Structure):
        _fields_ = [
            ("fIcon", wt.BOOL),
            ("xHotspot", wt.DWORD),
            ("yHotspot", wt.DWORD),
            ("hbmMask", wt.HBITMAP),
            ("hbmColor", wt.HBITMAP),
        ]

    user32.GetDC.argtypes = [wt.HWND]
    user32.GetDC.restype = wt.HDC
    user32.ReleaseDC.argtypes = [wt.HWND, wt.HDC]
    user32.ReleaseDC.restype = ctypes.c_int
    gdi32.CreateDIBSection.argtypes = [
        wt.HDC,
        ctypes.POINTER(BITMAPINFO),
        wt.UINT,
        ctypes.POINTER(ctypes.c_void_p),
        wt.HANDLE,
        wt.DWORD,
    ]
    gdi32.CreateDIBSection.restype = wt.HBITMAP
    gdi32.CreateBitmap.argtypes = [
        ctypes.c_int,
        ctypes.c_int,
        wt.UINT,
        wt.UINT,
        ctypes.c_void_p,
    ]
    gdi32.CreateBitmap.restype = wt.HBITMAP
    gdi32.DeleteObject.argtypes = [wt.HANDLE]
    gdi32.DeleteObject.restype = wt.BOOL
    user32.CreateIconIndirect.argtypes = [ctypes.POINTER(ICONINFO)]
    user32.CreateIconIndirect.restype = wt.HICON
    info = BITMAPINFO()
    info.bmiHeader = BITMAPINFOHEADER(
        ctypes.sizeof(BITMAPINFOHEADER),
        width,
        -height,
        1,
        32,
        0,
        len(pixels),
        0,
        0,
        0,
        0,
    )
    bits = ctypes.c_void_p()
    screen = user32.GetDC(None)
    color_bitmap = gdi32.CreateDIBSection(
        screen,
        ctypes.byref(info),
        0,
        ctypes.byref(bits),
        None,
        0,
    )
    user32.ReleaseDC(None, screen)
    if not color_bitmap or not bits:
        raise ctypes.WinError()
    ctypes.memmove(bits, pixels, len(pixels))
    mask_bits = ctypes.create_string_buffer(bytes((width // 8) * height))
    mask_bitmap = gdi32.CreateBitmap(width, height, 1, 1, mask_bits)
    if not mask_bitmap:
        gdi32.DeleteObject(color_bitmap)
        raise ctypes.WinError()
    icon_info = ICONINFO(True, 0, 0, mask_bitmap, color_bitmap)
    icon = user32.CreateIconIndirect(ctypes.byref(icon_info))
    gdi32.DeleteObject(mask_bitmap)
    gdi32.DeleteObject(color_bitmap)
    if not icon:
        raise ctypes.WinError()
    return icon


def default_data_endpoint(server: urllib.parse.ParseResult) -> tuple[str, int]:
    control_port = server.port or (443 if server.scheme == "wss" else 80)
    port = 8082 if control_port == 8081 else 18084 if control_port == 18082 else control_port + 2
    return server.hostname or "", port


def configure_tcp_keepalive(value: socket.socket) -> None:
    value.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
    if os.name == "nt" and hasattr(socket, "SIO_KEEPALIVE_VALS"):
        value.ioctl(
            socket.SIO_KEEPALIVE_VALS,
            (1, TCP_KEEPALIVE_IDLE_SECONDS * 1000, TCP_KEEPALIVE_INTERVAL_SECONDS * 1000),
        )
        return
    for option_name, option_value in (
        ("TCP_KEEPIDLE", TCP_KEEPALIVE_IDLE_SECONDS),
        ("TCP_KEEPINTVL", TCP_KEEPALIVE_INTERVAL_SECONDS),
        ("TCP_KEEPCNT", TCP_KEEPALIVE_PROBES),
    ):
        option = getattr(socket, option_name, None)
        if option is not None:
            value.setsockopt(socket.IPPROTO_TCP, option, option_value)


def version_key(value: str) -> tuple[int, ...]:
    if not isinstance(value, str) or not VERSION_PATTERN.fullmatch(value):
        raise ValueError(f"invalid update version: {value!r}")
    parts = tuple(int(part) for part in value.split("."))
    return parts + (0,) * (4 - len(parts))


def update_is_newer(candidate: str, current: str = APP_VERSION) -> bool:
    return version_key(candidate) > version_key(current)


class ProviderConfig:
    def __init__(self, path: Path = CONFIG_PATH):
        if yaml is None:
            raise RuntimeError("PyYAML is required; install it with the selected Python runtime")
        self.path = path
        if not path.is_file():
            raise ValueError(f"provider user YAML not found: {path}")
        try:
            parsed = yaml.safe_load(path.read_text(encoding="utf-8"))
        except Exception as exc:
            raise ValueError(f"invalid provider user YAML: {exc}") from exc
        if not isinstance(parsed, dict):
            raise ValueError("provider user YAML root must be a mapping")
        unknown = set(parsed) - ALLOWED_CONFIG_KEYS
        if unknown:
            raise ValueError(f"unknown provider.yaml field: {sorted(unknown)[0]}")
        self.raw = parsed
        self.id = self._required_string("id")
        if not PROVIDER_ID_PATTERN.fullmatch(self.id):
            raise ValueError('provider.yaml field "id" must match [A-Za-z0-9_.-] and be at most 64 characters')
        self.name = self._optional_string("name") or self.id
        self.server = self._parse_server(self._required_string("server"))
        self.credential_path = self._resolve_path(self._required_string("credentialFile"))
        if not self.credential_path.is_file():
            raise ValueError(f"provider credential file not found: {self.credential_path}")
        self.token = self.credential_path.read_text(encoding="utf-8").strip()
        if not self.token:
            raise ValueError(f"provider credential file is empty: {self.credential_path}")
        self.credential_fingerprint = hashlib.sha256(self.token.encode()).hexdigest()[:12]
        self.data_host, self.data_port = self._parse_data(self._optional_string("data"))
        python_runtime = parsed.get("pythonRuntime", "auto")
        if python_runtime not in {"auto", "system", "private"}:
            raise ValueError('provider.yaml field "pythonRuntime" must be auto, system, or private')
        self.python_runtime = python_runtime
        auto_start = parsed.get("autoStart", False)
        if not isinstance(auto_start, bool):
            raise ValueError('provider.yaml field "autoStart" must be true or false')
        self.auto_start = auto_start
        self._parse_update(parsed.get("update"))

    def _required_string(self, key: str) -> str:
        value = self.raw.get(key)
        if not isinstance(value, str) or not value.strip():
            raise ValueError(f'provider.yaml field "{key}" must be a non-empty string')
        return value.strip()

    def _optional_string(self, key: str) -> str | None:
        value = self.raw.get(key)
        if value is None:
            return None
        if not isinstance(value, str) or not value.strip():
            raise ValueError(f'provider.yaml field "{key}" must be a non-empty string when present')
        return value.strip()

    def _parse_server(self, raw: str) -> urllib.parse.ParseResult:
        value = urllib.parse.urlparse(raw)
        if value.scheme not in {"ws", "wss"} or not value.hostname:
            raise ValueError('provider.yaml field "server" must use ws:// or wss://')
        if value.path in {"", "/"}:
            value = value._replace(path="/ws/provider")
        return value

    def _parse_data(self, raw: str | None) -> tuple[str, int]:
        if raw is None:
            return default_data_endpoint(self.server)
        value = urllib.parse.urlparse(raw)
        try:
            port = value.port
        except ValueError as exc:
            raise ValueError('provider.yaml field "data" must use tcp://host:port') from exc
        if value.scheme != "tcp" or not value.hostname or not port or value.path not in {"", "/"}:
            raise ValueError('provider.yaml field "data" must use tcp://host:port')
        return value.hostname, port

    def _resolve_path(self, raw: str) -> Path:
        value = Path(raw).expanduser()
        return value if value.is_absolute() else (self.path.parent / value).resolve()

    def _parse_update(self, raw: object) -> None:
        if raw is None:
            raw = {}
        if not isinstance(raw, dict):
            raise ValueError('provider.yaml field "update" must be a mapping')
        unknown = set(raw) - ALLOWED_UPDATE_KEYS
        if unknown:
            raise ValueError(f"unknown provider.yaml update field: {sorted(unknown)[0]}")
        values = dict(DEFAULT_UPDATE_CONFIG)
        values.update(raw)
        for key in ("enabled", "autoApplyWhenIdle"):
            if not isinstance(values[key], bool):
                raise ValueError(f'provider.yaml field "update.{key}" must be true or false')
        interval = values["checkIntervalSeconds"]
        if not isinstance(interval, int) or isinstance(interval, bool) or not 60 <= interval <= 86400:
            raise ValueError('provider.yaml field "update.checkIntervalSeconds" must be an integer from 60 to 86400')
        for key in ("channel", "metadataUrl"):
            if not isinstance(values[key], str) or not values[key].strip():
                raise ValueError(f'provider.yaml field "update.{key}" must be a non-empty string')
            values[key] = values[key].strip()
        metadata = urllib.parse.urlparse(values["metadataUrl"])
        if metadata.scheme != "https" or not metadata.hostname:
            raise ValueError('provider.yaml field "update.metadataUrl" must use https://')
        allowlist = values["downloadHostAllowlist"]
        if (
            not isinstance(allowlist, list)
            or not allowlist
            or any(not isinstance(host, str) or not host.strip() for host in allowlist)
        ):
            raise ValueError('provider.yaml field "update.downloadHostAllowlist" must be a non-empty string list')
        self.update_enabled = values["enabled"]
        self.update_auto_apply = values["autoApplyWhenIdle"]
        self.update_interval = interval
        self.update_channel = values["channel"]
        self.update_metadata_url = values["metadataUrl"]
        self.update_download_hosts = tuple(host.strip().lower().rstrip(".") for host in allowlist)
        proxy_mode = values["proxyMode"]
        if not isinstance(proxy_mode, str) or proxy_mode not in {"direct", "system"}:
            raise ValueError('provider.yaml field "update.proxyMode" must be direct or system')
        self.update_proxy_mode = proxy_mode

    @property
    def server_url(self) -> str:
        return urllib.parse.urlunparse(self.server)

    def save_auto_start(self, enabled: bool) -> None:
        self.raw["autoStart"] = bool(enabled)
        text = yaml.safe_dump(self.raw, allow_unicode=True, sort_keys=False)
        temporary = self.path.with_suffix(".yaml.tmp")
        temporary.write_text(text, encoding="utf-8")
        os.replace(temporary, self.path)
        self.auto_start = bool(enabled)

    def save_update_settings(self, enabled: bool, auto_apply: bool, interval: int, channel: str) -> None:
        update = dict(self.raw.get("update") or {})
        update["enabled"] = bool(enabled)
        update["autoApplyWhenIdle"] = bool(auto_apply)
        update["checkIntervalSeconds"] = int(interval)
        update["channel"] = channel.strip()
        self.raw["update"] = update
        text = yaml.safe_dump(self.raw, allow_unicode=True, sort_keys=False)
        temporary = self.path.with_suffix(".yaml.tmp")
        temporary.write_text(text, encoding="utf-8")
        os.replace(temporary, self.path)
        self._parse_update(update)


class UpdateManager:
    def __init__(
        self,
        config: ProviderConfig,
        logger: logging.Logger,
        bus: "UiBus",
        is_idle,
        script_path: Path | None = None,
        update_dir: Path = UPDATE_DIR,
    ):
        self.config = config
        self.logger = logger
        self.bus = bus
        self.is_idle = is_idle
        self.script_path = (script_path or Path(__file__)).resolve()
        self.update_dir = update_dir
        self.state_path = update_dir / "update-state.json"
        self.available: dict | None = None
        self.lock = threading.Lock()
        self.busy = False

    def status(self, state: str, message: str, **extra) -> None:
        value = {"state": state, "message": message, "at": utc_now(), **extra}
        self.bus.emit("update", value)

    def _write_state(self, state: str, **extra) -> None:
        self.update_dir.mkdir(parents=True, exist_ok=True)
        value = {"state": state, "currentVersion": APP_VERSION, "at": utc_now(), **extra}
        temporary = self.state_path.with_suffix(".json.tmp")
        temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        os.replace(temporary, self.state_path)

    def confirm_startup(self) -> None:
        try:
            if not self.state_path.is_file():
                return
            previous = json.loads(self.state_path.read_text(encoding="utf-8"))
            if previous.get("state") == "restarting" and previous.get("targetVersion") == APP_VERSION:
                self._write_state("running", installedVersion=APP_VERSION)
                self.logger.info("升级后启动确认成功: version=%s", APP_VERSION)
        except Exception as exc:
            self.logger.warning("读取升级状态失败: %s", exc)

    @staticmethod
    def validate_manifest(raw: object, metadata_url: str, channel: str) -> dict:
        if not isinstance(raw, dict):
            raise ValueError("update metadata root must be an object")
        if raw.get("schemaVersion") != 1:
            raise ValueError("unsupported update metadata schemaVersion")
        if raw.get("channel") != channel:
            raise ValueError(f"update metadata channel mismatch: {raw.get('channel')!r}")
        version = raw.get("latestVersion")
        version_key(version)
        download = raw.get("downloadUrl")
        if not isinstance(download, str) or not download.strip():
            raise ValueError("update metadata downloadUrl must be a non-empty string")
        size = raw.get("bytes")
        if not isinstance(size, int) or isinstance(size, bool) or size <= 0:
            raise ValueError("update metadata bytes must be a positive integer")
        digest = raw.get("sha256")
        if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", digest):
            raise ValueError("update metadata sha256 must contain 64 hexadecimal characters")
        result = dict(raw)
        result["latestVersion"] = version
        result["downloadUrl"] = urllib.parse.urljoin(metadata_url, download.strip())
        result["sha256"] = digest.lower()
        return result

    @staticmethod
    def validate_payload(payload: bytes, manifest: dict) -> None:
        if len(payload) != manifest["bytes"]:
            raise ValueError(f"download byte count mismatch: expected {manifest['bytes']}, got {len(payload)}")
        digest = hashlib.sha256(payload).hexdigest()
        if digest != manifest["sha256"]:
            raise ValueError("download SHA-256 mismatch")
        try:
            source = payload.decode("utf-8")
            compile(source, "unideskcprovider.py", "exec")
        except Exception as exc:
            raise ValueError(f"downloaded Python validation failed: {exc}") from exc
        if 'APP_NAME = "UniDesk Windows Provider"' not in source or "class ProviderApp:" not in source:
            raise ValueError("downloaded file is not a UniDesk Windows Provider")
        version_match = re.search(r'^APP_VERSION\s*=\s*"([^"]+)"', source, re.MULTILINE)
        if not version_match or version_match.group(1) != manifest["latestVersion"]:
            raise ValueError("downloaded Provider version does not match update metadata")

    def _download(self, url: str) -> bytes:
        request = urllib.request.Request(url, headers={"User-Agent": f"unideskcprovider/{APP_VERSION}"})
        open_url = (
            urllib.request.build_opener(urllib.request.ProxyHandler({})).open
            if self.config.update_proxy_mode == "direct"
            else urllib.request.urlopen
        )
        with open_url(request, timeout=20) as response:
            return response.read(MAX_PAYLOAD_BYTES + 1)

    def check(self) -> dict | None:
        metadata = json.loads(self._download(self.config.update_metadata_url).decode("utf-8"))
        manifest = self.validate_manifest(
            metadata,
            self.config.update_metadata_url,
            self.config.update_channel,
        )
        if update_is_newer(manifest["latestVersion"]):
            self.available = manifest
            return manifest
        self.available = None
        return None

    def apply(self, automatic: bool = False) -> str | None:
        if not self.is_idle():
            raise RuntimeError("active trans sessions prevent update")
        manifest = self.available or self.check()
        if not manifest:
            return None
        download_url = manifest["downloadUrl"]
        parsed = urllib.parse.urlparse(download_url)
        host = (parsed.hostname or "").lower().rstrip(".")
        if (
            parsed.scheme != "https"
            or host not in self.config.update_download_hosts
            or not parsed.path.lower().endswith(".py")
        ):
            raise ValueError(f"update download host/path is not allowed: {host}{parsed.path}")
        payload = self._download(download_url)
        self.validate_payload(payload, manifest)
        self.update_dir.mkdir(parents=True, exist_ok=True)
        backup = self.update_dir / f"{self.script_path.name}.bak"
        shutil.copy2(self.script_path, backup)
        temporary = self.script_path.with_name(f".{self.script_path.name}.update.tmp")
        try:
            temporary.write_bytes(payload)
            os.replace(temporary, self.script_path)
        finally:
            temporary.unlink(missing_ok=True)
        target = manifest["latestVersion"]
        self._write_state(
            "restarting",
            previousVersion=APP_VERSION,
            targetVersion=target,
            backupPath=str(backup),
            automatic=automatic,
        )
        return target

    def check_async(self, manual: bool = False) -> None:
        if not self.config.update_enabled and not manual:
            self.status("disabled", "升级监测已关闭")
            return
        with self.lock:
            if self.busy:
                return
            self.busy = True
        self.status("checking", "正在检查更新")

        def worker() -> None:
            try:
                manifest = self.check()
                if manifest:
                    version = manifest["latestVersion"]
                    self.status("available", f"发现新版本 {version}", version=version)
                    self.logger.info("发现 Provider 新版本: %s", version)
                    if self.config.update_auto_apply and self.is_idle():
                        self.apply_async(automatic=True, already_locked=True)
                        return
                else:
                    self.status("current", f"当前已是最新版 v{APP_VERSION}")
                    if manual:
                        self.logger.info("Provider 当前已是最新版: %s", APP_VERSION)
            except Exception as exc:
                self.status("error", f"检查更新失败：{exc}")
                self.logger.warning("检查更新失败: %s", exc)
            finally:
                with self.lock:
                    self.busy = False

        threading.Thread(target=worker, name="provider-update-check", daemon=True).start()

    def apply_async(self, automatic: bool = False, already_locked: bool = False) -> None:
        if not self.is_idle():
            self.status("waiting", "存在活跃 trans 会话，暂不升级")
            return
        if not already_locked:
            with self.lock:
                if self.busy:
                    return
                self.busy = True

        def worker() -> None:
            try:
                target = self.available["latestVersion"] if self.available else None
                if target:
                    self.status("downloading", f"正在下载 v{target}")
                target = self.apply(automatic)
                if not target:
                    self.status("current", f"当前已是最新版 v{APP_VERSION}")
                    return
                self.status("restarting", f"已安装 v{target}，正在重启", version=target)
                self.logger.info("Provider 升级已安装，准备重启: %s -> %s", APP_VERSION, target)
                self.bus.emit("restart", target)
            except Exception as exc:
                self._write_state("failed", error=str(exc))
                self.status("error", f"升级失败：{exc}")
                self.logger.error("Provider 升级失败: %s", exc)
            finally:
                with self.lock:
                    self.busy = False

        threading.Thread(target=worker, name="provider-update-apply", daemon=True).start()


class UiBus:
    def __init__(self):
        self.events: queue.Queue[tuple[str, object]] = queue.Queue()

    def emit(self, kind: str, value: object) -> None:
        self.events.put((kind, value))


class UiLogHandler(logging.Handler):
    def __init__(self, bus: UiBus):
        super().__init__()
        self.bus = bus

    def emit(self, record: logging.LogRecord) -> None:
        self.bus.emit("log", self.format(record))


def setup_logger(bus: UiBus, ui_enabled: bool = True) -> logging.Logger:
    LOG_DIR.mkdir(parents=True, exist_ok=True)
    logger = logging.getLogger("unideskcprovider")
    logger.setLevel(logging.INFO)
    logger.handlers.clear()
    formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s", "%Y-%m-%d %H:%M:%S")
    file_handler = TimedRotatingFileHandler(
        LOG_PATH,
        when="midnight",
        interval=1,
        backupCount=14,
        encoding="utf-8",
        delay=True,
    )
    file_handler.setFormatter(formatter)
    logger.addHandler(file_handler)
    if ui_enabled:
        ui_handler = UiLogHandler(bus)
        ui_handler.setFormatter(formatter)
        logger.addHandler(ui_handler)
    else:
        stream_handler = logging.StreamHandler(sys.stderr)
        stream_handler.setFormatter(formatter)
        logger.addHandler(stream_handler)
    return logger


def pythonw_command() -> str:
    executable = Path(sys.executable)
    pythonw = executable.with_name("pythonw.exe")
    if pythonw.is_file():
        executable = pythonw
    return f'"{executable}" "{Path(__file__).resolve()}"'


def set_autostart(enabled: bool) -> None:
    if sys.platform != "win32" or winreg is None:
        raise RuntimeError("Windows autostart is only available on Windows")
    key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
    with winreg.CreateKey(winreg.HKEY_CURRENT_USER, key_path) as key:
        if enabled:
            winreg.SetValueEx(key, AUTOSTART_NAME, 0, winreg.REG_SZ, pythonw_command())
        else:
            try:
                winreg.DeleteValue(key, AUTOSTART_NAME)
            except FileNotFoundError:
                pass


def autostart_enabled() -> bool:
    if sys.platform != "win32" or winreg is None:
        return False
    try:
        with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run") as key:
            value, _ = winreg.QueryValueEx(key, AUTOSTART_NAME)
            return value == pythonw_command()
    except FileNotFoundError:
        return False


def encode_data_frame(header: dict, payload: bytes = b"") -> bytes:
    value = dict(header)
    value["length"] = len(payload)
    header_bytes = json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
    if not 0 < len(header_bytes) <= MAX_HEADER_BYTES:
        raise ValueError("data frame header exceeds protocol limit")
    if len(payload) > MAX_PAYLOAD_BYTES:
        raise ValueError("data frame payload exceeds protocol limit")
    return struct.pack("!I", len(header_bytes)) + header_bytes + payload


class WebSocketConnection:
    def __init__(self, url: str, token: str):
        self.url = self._with_token(url, token)
        self.socket: socket.socket | None = None
        self.send_lock = threading.Lock()

    @staticmethod
    def _with_token(raw: str, token: str) -> str:
        parsed = urllib.parse.urlparse(raw)
        query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
        query = [(key, value) for key, value in query if key != "token"]
        query.append(("token", token))
        return urllib.parse.urlunparse(parsed._replace(query=urllib.parse.urlencode(query)))

    def connect(self) -> None:
        parsed = urllib.parse.urlparse(self.url)
        host = parsed.hostname or ""
        port = parsed.port or (443 if parsed.scheme == "wss" else 80)
        raw = socket.create_connection((host, port), timeout=10)
        raw.settimeout(1)
        self.socket = ssl.create_default_context().wrap_socket(raw, server_hostname=host) if parsed.scheme == "wss" else raw
        key = base64.b64encode(os.urandom(16)).decode("ascii")
        path = parsed.path or "/"
        if parsed.query:
            path += "?" + parsed.query
        request = (
            f"GET {path} HTTP/1.1\r\n"
            f"Host: {host}:{port}\r\n"
            "Upgrade: websocket\r\n"
            "Connection: Upgrade\r\n"
            f"Sec-WebSocket-Key: {key}\r\n"
            "Sec-WebSocket-Version: 13\r\n\r\n"
        )
        self.socket.sendall(request.encode("ascii"))
        response = b""
        while b"\r\n\r\n" not in response and len(response) <= 16384:
            chunk = self.socket.recv(4096)
            if not chunk:
                break
            response += chunk
        status = response.split(b"\r\n", 1)[0]
        if b" 101 " not in status:
            raise RuntimeError(f"WebSocket upgrade failed: {status.decode(errors='replace')}")

    def send_json(self, value: dict) -> None:
        self.send_frame(json.dumps(value, ensure_ascii=False).encode("utf-8"), 0x1)

    def send_frame(self, payload: bytes, opcode: int) -> None:
        mask = os.urandom(4)
        length = len(payload)
        if length < 126:
            header = struct.pack("!BB", 0x80 | opcode, 0x80 | length)
        elif length < 65536:
            header = struct.pack("!BBH", 0x80 | opcode, 0x80 | 126, length)
        else:
            header = struct.pack("!BBQ", 0x80 | opcode, 0x80 | 127, length)
        masked = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
        with self.send_lock:
            if self.socket is None:
                raise RuntimeError("WebSocket is closed")
            self.socket.sendall(header + mask + masked)

    def receive(self) -> tuple[int, bytes]:
        head = self._read_exact(2)
        first, second = head
        opcode = first & 0x0F
        length = second & 0x7F
        if length == 126:
            length = struct.unpack("!H", self._read_exact(2))[0]
        elif length == 127:
            length = struct.unpack("!Q", self._read_exact(8))[0]
        mask = self._read_exact(4) if second & 0x80 else b""
        payload = self._read_exact(length) if length else b""
        if mask:
            payload = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
        return opcode, payload

    def _read_exact(self, length: int) -> bytes:
        chunks = []
        remaining = length
        while remaining:
            if self.socket is None:
                raise RuntimeError("WebSocket is closed")
            chunk = self.socket.recv(remaining)
            if not chunk:
                raise RuntimeError("WebSocket closed")
            chunks.append(chunk)
            remaining -= len(chunk)
        return b"".join(chunks)

    def close(self) -> None:
        value, self.socket = self.socket, None
        if value is not None:
            try:
                value.close()
            except OSError:
                pass


class DataChannel:
    def __init__(self, manager: "ProviderManager", channel_id: str):
        self.manager = manager
        self.id = channel_id
        self.socket: socket.socket | None = None
        self.status = "connecting"
        self.session_id: str | None = None
        self.last_error = ""
        self.buffer = bytearray()
        self.pending: list[tuple[dict, bytes]] = []
        self.pending_bytes = 0
        self.idle_since = time.monotonic()
        self.write_lock = threading.Lock()
        self.closed = threading.Event()

    def start(self) -> None:
        threading.Thread(target=self._run, name=f"provider-data-{self.id[-8:]}", daemon=True).start()

    def _run(self) -> None:
        reason = "data channel closed"
        try:
            self.socket = socket.create_connection((self.manager.config.data_host, self.manager.config.data_port), timeout=5)
            configure_tcp_keepalive(self.socket)
            self.socket.settimeout(None)
            self.write(
                {
                    "type": "hello",
                    "protocol": SSH_DATA_PROTOCOL,
                    "providerId": self.manager.config.id,
                    "channelId": self.id,
                    "token": self.manager.config.token,
                }
            )
            self.status = "ready"
            self.idle_since = time.monotonic()
            self.manager.logger.info("TCP 通道已就绪: %s", self.id)
            self.manager.pool_changed()
            while not self.closed.is_set():
                readable, _, _ = select.select([self.socket], [], [], 1)
                if not readable:
                    continue
                chunk = self.socket.recv(65536)
                if not chunk:
                    break
                self.buffer.extend(chunk)
                self._consume_frames()
        except Exception as exc:
            reason = str(exc)
            self.last_error = reason
            if not self.closed.is_set():
                self.manager.logger.warning("TCP 通道异常 %s: %s", self.id, reason)
        finally:
            self.close(reason)

    def _consume_frames(self) -> None:
        while len(self.buffer) >= 4:
            header_length = struct.unpack("!I", self.buffer[:4])[0]
            if not 0 < header_length <= MAX_HEADER_BYTES:
                raise ValueError(f"invalid inbound header length: {header_length}")
            if len(self.buffer) < 4 + header_length:
                return
            header = json.loads(bytes(self.buffer[4 : 4 + header_length]).decode("utf-8"))
            payload_length = int(header.get("length", 0))
            if not 0 <= payload_length <= MAX_PAYLOAD_BYTES:
                raise ValueError(f"invalid inbound payload length: {payload_length}")
            frame_length = 4 + header_length + payload_length
            if len(self.buffer) < frame_length:
                return
            payload = bytes(self.buffer[4 + header_length : frame_length])
            del self.buffer[:frame_length]
            self.manager.handle_data_frame(self, header, payload)

    def write(self, header: dict, payload: bytes = b"") -> None:
        frame = encode_data_frame(header, payload)
        with self.write_lock:
            if self.socket is None or self.closed.is_set():
                raise RuntimeError("data channel is closed")
            self.socket.sendall(frame)

    def close(self, reason: str) -> None:
        if self.closed.is_set():
            return
        self.closed.set()
        self.status = "closed"
        value, self.socket = self.socket, None
        if value is not None:
            try:
                value.close()
            except OSError:
                pass
        self.manager.channel_closed(self, reason)


class ProviderSession:
    def __init__(
        self,
        manager: "ProviderManager",
        session_id: str,
        channel: DataChannel,
        process: subprocess.Popen,
        capture_stdin_log: bool = False,
    ):
        self.manager = manager
        self.id = session_id
        self.channel = channel
        self.process = process
        self.closed = threading.Event()
        self.stdin_lock = threading.Lock()
        self.capture_stdin_log = capture_stdin_log
        self.stdin_log = bytearray()
        self.stdin_bytes = 0
        self.stdin_logged = False

    def start(self) -> None:
        stdout_thread = threading.Thread(target=self._pump, args=("stdout", self.process.stdout), daemon=True)
        stderr_thread = threading.Thread(target=self._pump, args=("stderr", self.process.stderr), daemon=True)
        stdout_thread.start()
        stderr_thread.start()
        threading.Thread(target=self._wait, args=(stdout_thread, stderr_thread), daemon=True).start()

    def _pump(self, stream_name: str, stream) -> None:
        try:
            while True:
                chunk = stream.read(65536)
                if not chunk:
                    break
                self.manager.send_session_frame(self.id, {"type": "data", "stream": stream_name, "at": utc_now()}, chunk)
        except Exception as exc:
            if not self.closed.is_set():
                self.manager.logger.warning("会话输出异常 %s: %s", self.id, exc)

    def _wait(self, stdout_thread: threading.Thread, stderr_thread: threading.Thread) -> None:
        exit_code = self.process.wait()
        stdout_thread.join()
        stderr_thread.join()
        self.manager.finish_session(self.id, exit_code)

    def input(self, payload: bytes) -> None:
        if payload == b"\x04":
            return
        with self.stdin_lock:
            if self.capture_stdin_log:
                self.stdin_bytes += len(payload)
                remaining = STDIN_LOG_LIMIT - len(self.stdin_log)
                if remaining > 0:
                    self.stdin_log.extend(payload[:remaining])
            if self.process.stdin:
                self.process.stdin.write(payload)
                self.process.stdin.flush()

    def eof(self) -> None:
        with self.stdin_lock:
            if self.capture_stdin_log and not self.stdin_logged:
                self.stdin_logged = True
                script = self.stdin_log.decode("utf-8", errors="replace")
                if self.stdin_bytes > len(self.stdin_log):
                    script += f"...<truncated bytes={self.stdin_bytes - len(self.stdin_log)}>"
                self.manager.logger.info(
                    "会话 stdin 脚本: session=%s bytes=%s command=%s",
                    self.id,
                    self.stdin_bytes,
                    redact_command_for_log(script),
                )
            if self.process.stdin:
                self.process.stdin.close()

    def terminate(self) -> None:
        if self.process.poll() is None:
            if sys.platform.startswith("linux"):
                try:
                    os.killpg(self.process.pid, signal.SIGTERM)
                    return
                except OSError:
                    pass
            self.process.terminate()


class ProviderManager:
    def __init__(self, config: ProviderConfig, logger: logging.Logger, bus: UiBus):
        self.config = config
        self.logger = logger
        self.bus = bus
        self.started_at = utc_now()
        self.stop_event = threading.Event()
        self.manual_stop = True
        self.control: WebSocketConnection | None = None
        self.control_lock = threading.Lock()
        self.channels: dict[str, DataChannel] = {}
        self.sessions: dict[str, ProviderSession] = {}
        self.state_lock = threading.RLock()
        self.sequence = 0
        self.thread: threading.Thread | None = None

    def connect(self) -> None:
        if self.thread and self.thread.is_alive():
            return
        self.manual_stop = False
        self.stop_event.clear()
        self.thread = threading.Thread(target=self._connection_loop, name="provider-control", daemon=True)
        self.thread.start()

    def disconnect(self) -> None:
        self.manual_stop = True
        self.stop_event.set()
        self._close_control()
        self._close_pool("手动断开")
        self.bus.emit("connection", "disconnected")

    def shutdown(self) -> None:
        self.manual_stop = True
        self.stop_event.set()
        self._close_control()
        self._close_pool("Provider 退出")

    def session_count(self) -> int:
        with self.state_lock:
            return len(self.sessions)

    def is_idle(self) -> bool:
        return self.session_count() == 0

    def _connection_loop(self) -> None:
        attempt = 0
        while not self.stop_event.is_set():
            self.bus.emit("connection", "connecting" if attempt == 0 else "reconnecting")
            try:
                control = WebSocketConnection(self.config.server_url, self.config.token)
                control.connect()
                with self.control_lock:
                    self.control = control
                self.logger.info(
                    "云端连接已打开: provider=%s credential=%s",
                    self.config.id,
                    self.config.credential_fingerprint,
                )
                self._send_register()
                self._control_messages(control)
                attempt = 0
            except Exception as exc:
                if not self.stop_event.is_set():
                    self.logger.warning("云端连接失败: %s", exc)
            finally:
                self._close_control()
                self._close_pool("控制连接已关闭")
            if self.stop_event.is_set() or self.manual_stop:
                break
            attempt += 1
            self.bus.emit("connection", "reconnecting")
            self.stop_event.wait(min(30, 2 ** min(attempt, 5)))
        self.bus.emit("connection", "disconnected")

    def _control_messages(self, control: WebSocketConnection) -> None:
        last_heartbeat = 0.0
        registered = False
        register_deadline = time.monotonic() + REGISTER_TIMEOUT_SECONDS
        while not self.stop_event.is_set():
            if time.time() - last_heartbeat >= HEARTBEAT_SECONDS:
                self._send_heartbeat()
                last_heartbeat = time.time()
            try:
                opcode, payload = control.receive()
            except socket.timeout:
                if not registered and time.monotonic() >= register_deadline:
                    raise TimeoutError(f"provider register ack timeout after {REGISTER_TIMEOUT_SECONDS}s")
                continue
            if opcode == 0x8:
                break
            if opcode == 0x9:
                control.send_frame(payload, 0xA)
                continue
            if opcode != 0x1:
                continue
            message = json.loads(payload.decode("utf-8"))
            if message.get("type") == "ack" and message.get("requestId") == "register":
                if message.get("ok") is not True:
                    raise RuntimeError(f"provider registration failed: {message.get('message')}")
                registered = True
                self.bus.emit("connection", "connected")
                self.logger.info("Provider 注册成功")
                self.ensure_pool()
            elif message.get("type") == "host_ssh_open":
                self.start_session(message)
            elif message.get("type") == "dispatch":
                self.handle_dispatch(message)
            elif message.get("type") == "ack" and message.get("ok") is not True:
                self.logger.warning("云端拒绝消息: %s", message.get("message"))
        if registered:
            self.logger.warning("云端连接已断开")

    def _send_control(self, value: dict) -> bool:
        with self.control_lock:
            control = self.control
        if control is None:
            return False
        try:
            control.send_json(value)
            return True
        except Exception:
            return False

    def _send_register(self) -> None:
        self._send_control(
            {
                "type": "register",
                "providerId": self.config.id,
                "name": self.config.name,
                "labels": self.labels(),
                "startedAt": self.started_at,
                "capabilities": ["heartbeat", "host.ssh", "host.ssh.tcp-pool"],
            }
        )

    def _send_heartbeat(self) -> None:
        self._send_control(
            {
                "type": "heartbeat",
                "providerId": self.config.id,
                "labels": self.labels(),
                "at": utc_now(),
            }
        )

    def labels(self) -> dict:
        status = self.pool_status()
        windows = sys.platform == "win32"
        return {
            "host": self.config.id,
            "role": "windows-provider" if windows else "linux-provider",
            "windows": windows,
            "linux": not windows,
            "docker": False,
            "attachMode": "native-windows" if windows else "native-linux",
            "runtime": "python-tkinter" if windows else "python-headless",
            "osHostname": platform.node(),
            "osRelease": platform.release(),
            "pythonVersion": platform.python_version(),
            "hostSshConfigured": True,
            "hostSshTarget": "native-windows" if windows else "native-linux",
            "providerGatewayName": "unideskcprovider",
            "providerGatewayVersion": APP_VERSION,
            "providerGatewayStartedAt": self.started_at,
            "providerGatewayUpgradePolicy": (
                "idle-auto-python-file" if self.config.update_auto_apply else "managed-python-file"
            ),
            "providerGatewayUpdateEnabled": self.config.update_enabled,
            "providerGatewayUpdateChannel": self.config.update_channel,
            "providerGatewayRuntimeGuardOk": True,
            "providerGatewaySshDataTransport": "tcp-pool",
            "providerGatewaySshDataHost": self.config.data_host,
            "providerGatewaySshDataPort": self.config.data_port,
            "providerGatewaySshDataPoolDesired": POOL_SIZE,
            "providerGatewaySshDataPoolTotal": status["total"],
            "providerGatewaySshDataPoolReady": status["ready"],
            "providerGatewaySshDataPoolClaimed": status["claimed"],
            "providerGatewaySshDataPoolConnecting": status["connecting"],
            "providerGatewaySshDataPoolLastError": status["lastError"],
            "providerConfigPath": str(self.config.path),
            "providerCredentialPresent": True,
            "providerCredentialFingerprint": self.config.credential_fingerprint,
        }

    def pool_status(self) -> dict:
        with self.state_lock:
            values = list(self.channels.values())
        errors = [value.last_error for value in values if value.last_error]
        return {
            "total": len(values),
            "ready": sum(value.status == "ready" for value in values),
            "claimed": sum(value.status == "claimed" for value in values),
            "connecting": sum(value.status == "connecting" for value in values),
            "lastError": errors[-1] if errors else "",
        }

    def pool_changed(self) -> None:
        self.bus.emit("pool", self.pool_status())

    def ensure_pool(self) -> None:
        if self.stop_event.is_set():
            return
        with self.control_lock:
            if self.control is None:
                return
        with self.state_lock:
            missing = POOL_SIZE - len(self.channels)
            for _ in range(max(0, missing)):
                self.sequence += 1
                channel_id = f"sshdata_{self.config.id}_{int(time.time() * 1000)}_{self.sequence}"
                channel = DataChannel(self, channel_id)
                self.channels[channel_id] = channel
                channel.start()
        self.pool_changed()

    def channel_closed(self, channel: DataChannel, reason: str) -> None:
        session_id = channel.session_id
        with self.state_lock:
            if self.channels.get(channel.id) is channel:
                del self.channels[channel.id]
        if session_id:
            self.close_session(session_id, "data-channel-close")
        self.logger.info("TCP 通道已关闭 %s: %s", channel.id, reason)
        self.pool_changed()
        if not self.stop_event.is_set():
            threading.Timer(0.5, self.ensure_pool).start()

    def _close_pool(self, reason: str) -> None:
        with self.state_lock:
            sessions = list(self.sessions.values())
            channels = list(self.channels.values())
        for session in sessions:
            self.close_session(session.id, reason)
        for channel in channels:
            channel.close(reason)

    def _close_control(self) -> None:
        with self.control_lock:
            control, self.control = self.control, None
        if control:
            control.close()

    def acquire_channel(self, session_id: str, channel_id: str) -> DataChannel | None:
        with self.state_lock:
            channel = self.channels.get(channel_id)
            if not channel or channel.status != "ready" or channel.session_id is not None:
                return None
            channel.status = "claimed"
            channel.session_id = session_id
        self.pool_changed()
        return channel

    def release_channel(self, channel: DataChannel, session_id: str) -> None:
        with self.state_lock:
            if channel.session_id != session_id:
                return
            channel.session_id = None
            channel.pending.clear()
            channel.pending_bytes = 0
            if not channel.closed.is_set():
                channel.status = "ready"
                channel.idle_since = time.monotonic()
        self.pool_changed()

    def handle_data_frame(self, channel: DataChannel, header: dict, payload: bytes) -> None:
        session_id = str(header.get("sessionId") or "")
        with self.state_lock:
            session = self.sessions.get(session_id)
            if session is None and channel.session_id == session_id:
                if channel.pending_bytes + len(payload) <= 1024 * 1024 and len(channel.pending) < 128:
                    channel.pending.append((header, payload))
                    channel.pending_bytes += len(payload)
                return
        if session is None or session.channel is not channel:
            return
        try:
            frame_type = header.get("type")
            if frame_type == "input":
                session.input(payload)
            elif frame_type == "eof":
                session.eof()
            elif frame_type == "close":
                self.close_session(session_id, "client-close")
            elif frame_type != "resize":
                raise ValueError(f"unsupported data frame: {frame_type}")
        except Exception as exc:
            self.send_session_frame(session_id, {"type": "error", "message": str(exc), "at": utc_now()})

    def send_session_frame(self, session_id: str, header: dict, payload: bytes = b"") -> bool:
        with self.state_lock:
            session = self.sessions.get(session_id)
        if not session:
            return False
        try:
            session.channel.write({**header, "sessionId": session_id}, payload)
            return True
        except Exception as exc:
            self.logger.warning("会话数据发送失败 %s: %s", session_id, exc)
            return False

    def start_session(self, message: dict) -> None:
        session_id = str(message.get("sessionId") or "")
        channel_id = str(message.get("dataChannelId") or "")
        if not session_id or not channel_id or message.get("transport") != "tcp-pool":
            return
        channel = self.acquire_channel(session_id, channel_id)
        if channel is None:
            self._send_control(
                {
                    "type": "host_ssh_error",
                    "providerId": self.config.id,
                    "sessionId": session_id,
                    "message": f"requested ssh tcp data channel is not ready: {channel_id}",
                    "at": utc_now(),
                }
            )
            return
        try:
            executable, arguments, shell_name, command_summary, capture_stdin_log = native_process_command(
                str(message.get("command") or "")
            )
            if sys.platform == "win32":
                cwd = windows_path_from_bridge(str(message.get("cwd") or r"C:\Windows"))
            else:
                cwd = str(message.get("cwd") or Path.home())
            environment = os.environ.copy()
            environment.update(
                {
                    "UNIDESK_BRIDGE": "host.ssh",
                    "UNIDESK_PROVIDER_ID": self.config.id,
                    "PYTHONUTF8": "1",
                    "PYTHONIOENCODING": "utf-8",
                }
            )
            process_options = {
                "cwd": cwd,
                "env": environment,
                "stdin": subprocess.PIPE,
                "stdout": subprocess.PIPE,
                "stderr": subprocess.PIPE,
            }
            if sys.platform == "win32":
                process_options["creationflags"] = 0x08000000
            else:
                process_options["start_new_session"] = True
            process = subprocess.Popen([executable, *arguments], **process_options)
            session = ProviderSession(self, session_id, channel, process, capture_stdin_log)
            with self.state_lock:
                self.sessions[session_id] = session
                pending = list(channel.pending)
                channel.pending.clear()
                channel.pending_bytes = 0
            channel.write({"type": "opened", "sessionId": session_id, "providerId": self.config.id, "at": utc_now()})
            session.start()
            for header, payload in pending:
                self.handle_data_frame(channel, header, payload)
            self.logger.info(
                "会话已启动: session=%s shell=%s cwd=%s command=%s",
                session_id,
                shell_name,
                cwd,
                command_summary,
            )
        except Exception as exc:
            try:
                channel.write({"type": "error", "sessionId": session_id, "message": str(exc), "at": utc_now()})
            except Exception:
                pass
            self.release_channel(channel, session_id)
            self.logger.warning("会话启动失败 %s: %s", session_id, exc)

    def finish_session(self, session_id: str, exit_code: int) -> None:
        with self.state_lock:
            session = self.sessions.pop(session_id, None)
        if not session or session.closed.is_set():
            return
        session.closed.set()
        try:
            session.channel.write(
                {"type": "exit", "sessionId": session_id, "exitCode": exit_code, "signal": None, "at": utc_now()}
            )
        except Exception:
            pass
        self.release_channel(session.channel, session_id)
        self.logger.info("会话已完成: session=%s exitCode=%s", session_id, exit_code)

    def close_session(self, session_id: str, reason: str) -> None:
        with self.state_lock:
            session = self.sessions.pop(session_id, None)
        if not session or session.closed.is_set():
            return
        session.closed.set()
        self.release_channel(session.channel, session_id)
        session.terminate()
        self.logger.info("会话已关闭: session=%s reason=%s", session_id, reason)

    def handle_dispatch(self, message: dict) -> None:
        task_id = str(message.get("taskId") or "")
        if not task_id:
            return
        platform_name = "Windows" if sys.platform == "win32" else "Linux"
        self._task_status(task_id, "accepted", f"{platform_name} Provider 已接受任务")
        self._task_status(task_id, "running", f"{platform_name} Provider 正在执行任务")
        if message.get("command") == "host.ssh" and (message.get("payload") or {}).get("mode") != "exec":
            self._task_status(
                task_id,
                "succeeded",
                f"{platform_name} Provider 探测完成",
                {
                    "ok": True,
                    "providerId": self.config.id,
                    "runtime": "python-tkinter" if sys.platform == "win32" else "python-headless",
                    "platform": sys.platform,
                    "pythonVersion": platform.python_version(),
                    "host": platform.node(),
                },
            )
        else:
            self._task_status(
                task_id,
                "failed",
                "不支持的 Provider 命令",
                {
                    "ok": False,
                    "errorCode": "unsupported_provider_command",
                    "command": str(message.get("command") or ""),
                    "supported": ["host.ssh probe", "host.ssh.tcp-pool"],
                },
            )

    def _task_status(self, task_id: str, status: str, message: str, result: dict | None = None) -> None:
        value = {
            "type": "task_status",
            "providerId": self.config.id,
            "taskId": task_id,
            "status": status,
            "message": message,
            "at": utc_now(),
        }
        if result is not None:
            value["result"] = result
        self._send_control(value)


WIN_LRESULT = ctypes.c_ssize_t
WIN_WPARAM = ctypes.c_size_t
WIN_LPARAM = ctypes.c_ssize_t


class TrayIcon:
    CALLBACK_MESSAGE = 0x0400 + 20
    MENU_SHOW = 1001
    MENU_TOGGLE = 1002
    MENU_CHECK_UPDATE = 1003
    MENU_APPLY_UPDATE = 1004
    MENU_EXIT = 1005

    @classmethod
    def action_for_command(cls, command: int) -> str | None:
        return {
            cls.MENU_SHOW: "show",
            cls.MENU_TOGGLE: "toggle",
            cls.MENU_CHECK_UPDATE: "check-update",
            cls.MENU_APPLY_UPDATE: "apply-update",
            cls.MENU_EXIT: "exit",
        }.get(command)

    def __init__(self, bus: UiBus, logger: logging.Logger):
        self.bus = bus
        self.logger = logger
        self.state = "disconnected"
        self.update_state = "current"
        self.active = False
        self.animation_frame = 0
        self.hwnd = None
        self.notify_data = None
        self.icon_handle = None
        self.wndproc_ref = None
        self.ready = threading.Event()
        self.thread: threading.Thread | None = None

    def start(self) -> None:
        if sys.platform != "win32":
            return
        self.thread = threading.Thread(target=self._run, name="provider-tray", daemon=True)
        self.thread.start()
        self.ready.wait(2)

    def _run(self) -> None:
        try:
            self._create_window()
            self._add_icon()
            self.ready.set()
            message = wt.MSG()
            user32 = ctypes.windll.user32
            while user32.GetMessageW(ctypes.byref(message), None, 0, 0) != 0:
                user32.TranslateMessage(ctypes.byref(message))
                user32.DispatchMessageW(ctypes.byref(message))
        except Exception as exc:
            self.logger.warning("托盘初始化失败: %s", exc)
            self.ready.set()

    def _create_window(self) -> None:
        user32 = ctypes.windll.user32
        kernel32 = ctypes.windll.kernel32
        kernel32.GetModuleHandleW.argtypes = [wt.LPCWSTR]
        kernel32.GetModuleHandleW.restype = wt.HANDLE
        user32.DefWindowProcW.argtypes = [wt.HWND, wt.UINT, WIN_WPARAM, WIN_LPARAM]
        user32.DefWindowProcW.restype = WIN_LRESULT
        user32.RegisterClassW.argtypes = [ctypes.c_void_p]
        user32.RegisterClassW.restype = wt.ATOM
        user32.CreateWindowExW.argtypes = [
            wt.DWORD,
            wt.LPCWSTR,
            wt.LPCWSTR,
            wt.DWORD,
            ctypes.c_int,
            ctypes.c_int,
            ctypes.c_int,
            ctypes.c_int,
            wt.HWND,
            wt.HANDLE,
            wt.HANDLE,
            ctypes.c_void_p,
        ]
        user32.CreateWindowExW.restype = wt.HWND
        hinstance = kernel32.GetModuleHandleW(None)
        wndproc_type = ctypes.WINFUNCTYPE(WIN_LRESULT, wt.HWND, wt.UINT, WIN_WPARAM, WIN_LPARAM)

        def wndproc(hwnd, message, wparam, lparam):
            if message == self.CALLBACK_MESSAGE:
                if int(lparam) == 0x0203:
                    self.bus.emit("tray", "show")
                elif int(lparam) == 0x0205:
                    self._show_menu()
                return 0
            if message == 0x0111:
                command = int(wparam) & 0xFFFF
                action = self.action_for_command(command)
                if action:
                    self.bus.emit("tray", action)
                return 0
            if message == 0x0010:
                self.remove()
                user32.PostQuitMessage(0)
                return 0
            return user32.DefWindowProcW(hwnd, message, wparam, lparam)

        self.wndproc_ref = wndproc_type(wndproc)

        class WNDCLASSW(ctypes.Structure):
            _fields_ = [
                ("style", wt.UINT),
                ("lpfnWndProc", wndproc_type),
                ("cbClsExtra", ctypes.c_int),
                ("cbWndExtra", ctypes.c_int),
                ("hInstance", wt.HANDLE),
                ("hIcon", wt.HANDLE),
                ("hCursor", wt.HANDLE),
                ("hbrBackground", wt.HANDLE),
                ("lpszMenuName", wt.LPCWSTR),
                ("lpszClassName", wt.LPCWSTR),
            ]

        class_name = "UniDeskWindowsProviderTray"
        window_class = WNDCLASSW(0, self.wndproc_ref, 0, 0, hinstance, None, None, None, None, class_name)
        user32.RegisterClassW(ctypes.byref(window_class))
        self.hwnd = user32.CreateWindowExW(0, class_name, APP_NAME, 0, 0, 0, 0, 0, None, None, hinstance, None)
        if not self.hwnd:
            raise ctypes.WinError()

    def _notify_struct(self):
        class GUID(ctypes.Structure):
            _fields_ = [("Data1", wt.DWORD), ("Data2", wt.WORD), ("Data3", wt.WORD), ("Data4", ctypes.c_ubyte * 8)]

        class NOTIFYICONDATAW(ctypes.Structure):
            _fields_ = [
                ("cbSize", wt.DWORD),
                ("hWnd", wt.HWND),
                ("uID", wt.UINT),
                ("uFlags", wt.UINT),
                ("uCallbackMessage", wt.UINT),
                ("hIcon", wt.HANDLE),
                ("szTip", wt.WCHAR * 128),
                ("dwState", wt.DWORD),
                ("dwStateMask", wt.DWORD),
                ("szInfo", wt.WCHAR * 256),
                ("uTimeoutOrVersion", wt.UINT),
                ("szInfoTitle", wt.WCHAR * 64),
                ("dwInfoFlags", wt.DWORD),
                ("guidItem", GUID),
                ("hBalloonIcon", wt.HANDLE),
            ]

        return NOTIFYICONDATAW

    def _add_icon(self) -> None:
        structure = self._notify_struct()
        data = structure()
        data.cbSize = ctypes.sizeof(structure)
        data.hWnd = self.hwnd
        data.uID = 1
        data.uFlags = 0x1 | 0x2 | 0x4
        data.uCallbackMessage = self.CALLBACK_MESSAGE
        self.icon_handle = create_tray_hicon(self.state, self.active, self.animation_frame)
        data.hIcon = self.icon_handle
        data.szTip = f"{APP_NAME} - 未连接"
        self.notify_data = data
        if not ctypes.windll.shell32.Shell_NotifyIconW(0, ctypes.byref(data)):
            if self.icon_handle:
                ctypes.windll.user32.DestroyIcon(self.icon_handle)
                self.icon_handle = None
            raise ctypes.WinError()

    def _refresh_icon(self) -> None:
        if not self.notify_data:
            return
        previous = self.icon_handle
        self.icon_handle = create_tray_hicon(self.state, self.active, self.animation_frame)
        self.notify_data.hIcon = self.icon_handle
        ctypes.windll.shell32.Shell_NotifyIconW(1, ctypes.byref(self.notify_data))
        if previous:
            ctypes.windll.user32.DestroyIcon(previous)

    def update(
        self,
        state: str | None = None,
        update_state: str | None = None,
        active: bool | None = None,
    ) -> None:
        icon_changed = False
        if state is not None:
            icon_changed = icon_changed or state != self.state
            self.state = state
        if update_state is not None:
            self.update_state = update_state
        if active is not None:
            icon_changed = icon_changed or active != self.active
            self.active = active
            if not active:
                self.animation_frame = 0
        if not self.notify_data:
            return
        names = {"connected": "已连接", "connecting": "连接中", "reconnecting": "重连中", "disconnected": "未连接"}
        suffix = " - 有新版本" if self.update_state == "available" else ""
        active_suffix = " - 正在执行命令" if self.active else ""
        self.notify_data.szTip = (
            f"{APP_NAME} - {names.get(self.state, self.state)}{active_suffix}{suffix}"
        )[:127]
        if icon_changed:
            self._refresh_icon()
        else:
            ctypes.windll.shell32.Shell_NotifyIconW(1, ctypes.byref(self.notify_data))

    def advance_animation(self) -> None:
        if not self.active or not self.notify_data:
            return
        self.animation_frame = (self.animation_frame + 1) % 8
        self._refresh_icon()

    def _show_menu(self) -> None:
        user32 = ctypes.windll.user32
        menu = user32.CreatePopupMenu()
        user32.AppendMenuW(menu, 0, self.MENU_SHOW, "显示窗口")
        toggle_text = "断开" if self.state == "connected" else "连接"
        user32.AppendMenuW(menu, 0, self.MENU_TOGGLE, toggle_text)
        user32.AppendMenuW(menu, 0x0800, 0, None)
        user32.AppendMenuW(menu, 0, self.MENU_CHECK_UPDATE, "检查更新")
        user32.AppendMenuW(menu, 0, self.MENU_APPLY_UPDATE, "立即升级")
        user32.AppendMenuW(menu, 0x0800, 0, None)
        user32.AppendMenuW(menu, 0, self.MENU_EXIT, "退出")
        point = wt.POINT()
        user32.GetCursorPos(ctypes.byref(point))
        user32.SetForegroundWindow(self.hwnd)
        command = int(user32.TrackPopupMenu(menu, 0x0100, point.x, point.y, 0, self.hwnd, None))
        action = self.action_for_command(command)
        if action:
            self.bus.emit("tray", action)
        user32.PostMessageW(self.hwnd, 0, 0, 0)
        user32.DestroyMenu(menu)

    def remove(self) -> None:
        if self.notify_data:
            ctypes.windll.shell32.Shell_NotifyIconW(2, ctypes.byref(self.notify_data))
            self.notify_data = None
        if self.icon_handle:
            ctypes.windll.user32.DestroyIcon(self.icon_handle)
            self.icon_handle = None

    def stop(self) -> None:
        if self.hwnd:
            ctypes.windll.user32.PostMessageW(self.hwnd, 0x0010, 0, 0)


class ProviderApp:
    def __init__(self, config: ProviderConfig):
        self.config = config
        self.bus = UiBus()
        self.logger = setup_logger(self.bus)
        self.manager = ProviderManager(config, self.logger, self.bus)
        self.updater = UpdateManager(config, self.logger, self.bus, self.manager.is_idle)
        self.connection_state = "disconnected"
        self.restart_requested = False
        self.root = tk.Tk()
        self.root.title(APP_NAME)
        self.root.geometry("800x560")
        self.root.minsize(680, 460)
        self.status_var = tk.StringVar(value="未连接")
        self.pool_var = tk.StringVar(value=f"0/{POOL_SIZE} 就绪")
        self.update_var = tk.StringVar(value="尚未检查")
        self.auto_start_var = tk.BooleanVar(value=config.auto_start)
        self._build_ui()
        self.tray = TrayIcon(self.bus, self.logger)
        self.tray.start()
        self.root.protocol("WM_DELETE_WINDOW", self._hide_to_tray)
        self.root.after(120, self._poll_events)
        self.root.after(180, self._animate_tray)
        self.root.after(400, self._startup)

    def _build_ui(self) -> None:
        outer = ttk.Frame(self.root, padding=14)
        outer.pack(fill="both", expand=True)
        brand = ttk.Frame(outer)
        brand.pack(fill="x")
        ttk.Label(brand, text=APP_NAME, font=("Segoe UI", 15, "bold")).pack(side="left")
        ttk.Label(brand, text=f"v{APP_VERSION}").pack(side="left", padx=(12, 0))

        details = ttk.Frame(outer)
        details.pack(fill="x", pady=(10, 0))
        details.columnconfigure(1, weight=1)
        ttk.Label(details, text="Provider").grid(row=0, column=0, sticky="w")
        ttk.Label(details, text=self.config.id).grid(row=0, column=1, sticky="w", padx=(12, 0))
        ttk.Label(details, text="服务器").grid(row=1, column=0, sticky="nw")
        ttk.Label(details, text=self.config.server_url, wraplength=620).grid(row=1, column=1, sticky="ew", padx=(12, 0))
        ttk.Label(details, text="状态").grid(row=2, column=0, sticky="w")
        ttk.Label(details, textvariable=self.status_var).grid(row=2, column=1, sticky="w", padx=(12, 0))
        ttk.Label(details, text="TCP 通道").grid(row=3, column=0, sticky="w")
        ttk.Label(details, textvariable=self.pool_var).grid(row=3, column=1, sticky="w", padx=(12, 0))
        ttk.Label(details, text="升级").grid(row=4, column=0, sticky="w")
        ttk.Label(details, textvariable=self.update_var).grid(row=4, column=1, sticky="w", padx=(12, 0))

        actions = ttk.Frame(outer)
        actions.pack(fill="x", pady=(12, 6))
        self.connect_button = ttk.Button(actions, text="连接", command=self.manager.connect)
        self.connect_button.pack(side="left", padx=(0, 6))
        self.disconnect_button = ttk.Button(actions, text="断开", command=self.manager.disconnect)
        self.disconnect_button.pack(side="left", padx=6)
        ttk.Button(actions, text="配置目录", command=lambda: os.startfile(CONFIG_DIR)).pack(side="left", padx=6)
        ttk.Button(actions, text="日志目录", command=lambda: os.startfile(LOG_DIR)).pack(side="left", padx=6)

        update_actions = ttk.Frame(outer)
        update_actions.pack(fill="x", pady=(0, 12))
        self.check_update_button = ttk.Button(update_actions, text="检查更新", command=self._check_update)
        self.check_update_button.pack(side="left", padx=(0, 6))
        self.apply_update_button = ttk.Button(update_actions, text="升级", command=self._apply_update)
        self.apply_update_button.pack(side="left", padx=6)
        ttk.Button(update_actions, text="升级设置", command=self._show_update_settings).pack(side="left", padx=6)
        ttk.Checkbutton(
            update_actions,
            text="登录自动启动",
            variable=self.auto_start_var,
            command=self._toggle_autostart,
        ).pack(side="right")

        self.log_text = tk.Text(outer, height=18, wrap="word", state="disabled", font=("Consolas", 9))
        scrollbar = ttk.Scrollbar(outer, orient="vertical", command=self.log_text.yview)
        self.log_text.configure(yscrollcommand=scrollbar.set)
        scrollbar.pack(side="right", fill="y")
        self.log_text.pack(fill="both", expand=True)

    def _startup(self) -> None:
        try:
            set_autostart(self.config.auto_start)
            self.auto_start_var.set(autostart_enabled())
        except Exception as exc:
            self.logger.warning("同步登录启动项失败: %s", exc)
        self.logger.info(
            "Provider 已启动: id=%s version=%s config=%s credential=%s",
            self.config.id,
            APP_VERSION,
            self.config.path,
            self.config.credential_fingerprint,
        )
        self.updater.confirm_startup()
        self.manager.connect()
        self.root.after(1500, self._scheduled_update_check)

    def _scheduled_update_check(self) -> None:
        self.updater.check_async()
        self.root.after(self.config.update_interval * 1000, self._scheduled_update_check)

    def _check_update(self) -> None:
        self.updater.check_async(manual=True)

    def _apply_update(self, confirm: bool = True) -> None:
        if not self.manager.is_idle():
            messagebox.showwarning(APP_NAME, "存在活跃 trans 会话，请在会话结束后升级。", parent=self.root)
            return
        if confirm and not messagebox.askyesno(
            APP_NAME,
            "检查并安装可用更新？安装后 Provider 将自动重启。",
            parent=self.root,
        ):
            return
        self.updater.apply_async()

    def _show_update_settings(self) -> None:
        dialog = tk.Toplevel(self.root)
        dialog.title("升级设置")
        dialog.resizable(False, False)
        dialog.transient(self.root)
        dialog.grab_set()
        frame = ttk.Frame(dialog, padding=14)
        frame.pack(fill="both", expand=True)
        enabled = tk.BooleanVar(value=self.config.update_enabled)
        auto_apply = tk.BooleanVar(value=self.config.update_auto_apply)
        interval = tk.StringVar(value=str(self.config.update_interval))
        channel = tk.StringVar(value=self.config.update_channel)
        ttk.Checkbutton(frame, text="自动检查更新", variable=enabled).grid(row=0, column=0, columnspan=2, sticky="w")
        ttk.Checkbutton(frame, text="空闲时自动升级", variable=auto_apply).grid(
            row=1, column=0, columnspan=2, sticky="w", pady=(6, 0)
        )
        ttk.Label(frame, text="检查间隔（秒）").grid(row=2, column=0, sticky="w", pady=(10, 0))
        ttk.Entry(frame, textvariable=interval, width=12).grid(row=2, column=1, sticky="w", padx=(10, 0), pady=(10, 0))
        ttk.Label(frame, text="频道").grid(row=3, column=0, sticky="w", pady=(8, 0))
        ttk.Entry(frame, textvariable=channel, width=12).grid(
            row=3, column=1, sticky="w", padx=(10, 0), pady=(8, 0)
        )

        def save() -> None:
            try:
                seconds = int(interval.get())
                self.config.save_update_settings(enabled.get(), auto_apply.get(), seconds, channel.get())
                self.logger.info(
                    "升级设置已保存: enabled=%s autoApplyWhenIdle=%s interval=%s",
                    self.config.update_enabled,
                    self.config.update_auto_apply,
                    self.config.update_interval,
                )
                dialog.destroy()
            except Exception as exc:
                messagebox.showerror(APP_NAME, f"保存升级设置失败：{exc}", parent=dialog)

        buttons = ttk.Frame(frame)
        buttons.grid(row=4, column=0, columnspan=2, sticky="e", pady=(14, 0))
        ttk.Button(buttons, text="取消", command=dialog.destroy).pack(side="left", padx=(0, 6))
        ttk.Button(buttons, text="保存", command=save).pack(side="left")

    def _toggle_autostart(self) -> None:
        requested = self.auto_start_var.get()
        try:
            set_autostart(requested)
            self.config.save_auto_start(requested)
            self.auto_start_var.set(autostart_enabled())
            self.logger.info("登录自动启动已%s", "启用" if requested else "关闭")
        except Exception as exc:
            self.auto_start_var.set(self.config.auto_start)
            messagebox.showerror(APP_NAME, f"修改自动启动失败：{exc}", parent=self.root)

    def _poll_events(self) -> None:
        while True:
            try:
                kind, value = self.bus.events.get_nowait()
            except queue.Empty:
                break
            if kind == "log":
                self._append_log(str(value))
            elif kind == "connection":
                self._set_connection(str(value))
            elif kind == "pool" and isinstance(value, dict):
                self.pool_var.set(
                    f"{value.get('ready', 0)}/{POOL_SIZE} 就绪，{value.get('claimed', 0)} 使用中"
                )
                self.tray.update(active=int(value.get("claimed", 0)) > 0)
            elif kind == "tray":
                self._handle_tray(str(value))
            elif kind == "update" and isinstance(value, dict):
                self.update_var.set(str(value.get("message") or ""))
                self.tray.update(update_state=str(value.get("state") or ""))
            elif kind == "restart":
                self.restart_requested = True
                self.quit()
                return
        self.root.after(120, self._poll_events)

    def _animate_tray(self) -> None:
        self.tray.advance_animation()
        self.root.after(180, self._animate_tray)

    def _append_log(self, line: str) -> None:
        try:
            if not self.log_text.winfo_exists():
                return
            self.log_text.configure(state="normal")
            self.log_text.insert("end", line + "\n")
            lines = int(self.log_text.index("end-1c").split(".")[0])
            if lines > 1000:
                self.log_text.delete("1.0", f"{lines - 1000}.0")
            self.log_text.see("end")
            self.log_text.configure(state="disabled")
        except tk.TclError:
            return

    def _set_connection(self, state: str) -> None:
        self.connection_state = state
        names = {"connected": "已连接", "connecting": "连接中", "reconnecting": "重连中", "disconnected": "未连接"}
        self.status_var.set(names.get(state, state))
        self.tray.update(state)

    def _handle_tray(self, action: str) -> None:
        if action == "show":
            self.root.deiconify()
            self.root.lift()
            self.root.focus_force()
        elif action == "toggle":
            if self.connection_state == "connected":
                self.manager.disconnect()
            else:
                self.manager.connect()
        elif action == "check-update":
            self._check_update()
        elif action == "apply-update":
            self._apply_update(confirm=False)
        elif action == "exit":
            self.quit()

    def _hide_to_tray(self) -> None:
        self.root.withdraw()
        self.logger.info("控制面板已隐藏到托盘")

    def quit(self) -> None:
        self.manager.shutdown()
        self.tray.stop()
        self.root.destroy()

    def run(self) -> bool:
        self.root.mainloop()
        return self.restart_requested


class ProviderDaemon:
    def __init__(self, config: ProviderConfig):
        self.config = config
        self.bus = UiBus()
        self.logger = setup_logger(self.bus, ui_enabled=False)
        self.manager = ProviderManager(config, self.logger, self.bus)
        self.updater = UpdateManager(config, self.logger, self.bus, self.manager.is_idle)
        self.stop_event = threading.Event()
        self.restart_requested = False

    def _stop(self, _signum=None, _frame=None) -> None:
        self.stop_event.set()

    def run(self) -> bool:
        previous_handlers = {
            value: signal.getsignal(value)
            for value in (signal.SIGINT, signal.SIGTERM)
        }
        for value in previous_handlers:
            signal.signal(value, self._stop)
        try:
            self.logger.info(
                "Provider 已启动: id=%s version=%s platform=linux config=%s credential=%s",
                self.config.id,
                APP_VERSION,
                self.config.path,
                self.config.credential_fingerprint,
            )
            self.updater.confirm_startup()
            self.manager.connect()
            next_update_check = time.monotonic() + 1.5
            while not self.stop_event.wait(0.2):
                now = time.monotonic()
                if now >= next_update_check:
                    self.updater.check_async()
                    next_update_check = now + self.config.update_interval
                while True:
                    try:
                        kind, value = self.bus.events.get_nowait()
                    except queue.Empty:
                        break
                    if kind == "restart":
                        self.logger.info("Linux Provider 将重启到版本: %s", value)
                        self.restart_requested = True
                        self.stop_event.set()
                        break
            return self.restart_requested
        finally:
            self.manager.shutdown()
            for value, handler in previous_handlers.items():
                signal.signal(value, handler)


def acquire_single_instance():
    if sys.platform == "win32":
        kernel32 = ctypes.windll.kernel32
        kernel32.CreateMutexW.argtypes = [ctypes.c_void_p, wt.BOOL, wt.LPCWSTR]
        kernel32.CreateMutexW.restype = wt.HANDLE
        kernel32.CloseHandle.argtypes = [wt.HANDLE]
        kernel32.CloseHandle.restype = wt.BOOL
        handle = kernel32.CreateMutexW(None, False, "Local\\UniDeskWindowsProvider")
        if not handle:
            raise ctypes.WinError()
        if kernel32.GetLastError() == 183:
            kernel32.CloseHandle(handle)
            return None
        return handle
    if sys.platform.startswith("linux") and fcntl is not None:
        CONFIG_DIR.mkdir(parents=True, exist_ok=True)
        handle = (CONFIG_DIR / "provider.lock").open("a+")
        try:
            fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            handle.close()
            return None
        return handle
    raise RuntimeError(f"unsupported native Provider platform: {sys.platform}")


def main() -> int:
    if sys.platform != "win32" and not sys.platform.startswith("linux"):
        print("unideskcprovider requires native Windows or Linux", file=sys.stderr)
        return 1
    if sys.platform == "win32" and (tk is None or messagebox is None):
        print("unideskcprovider requires Python tkinter", file=sys.stderr)
        return 1
    if len(sys.argv) != 1:
        print("unideskcprovider does not accept startup arguments; edit provider.yaml", file=sys.stderr)
        return 1
    instance = acquire_single_instance()
    if instance is None:
        if sys.platform == "win32":
            messagebox.showinfo(APP_NAME, "UniDesk Windows Provider 已经在运行。")
        else:
            print("UniDesk Linux Provider is already running", file=sys.stderr)
        return 0
    restart_requested = False
    try:
        config = ProviderConfig()
        restart_requested = (
            ProviderApp(config).run()
            if sys.platform == "win32"
            else ProviderDaemon(config).run()
        )
    except Exception as exc:
        CONFIG_DIR.mkdir(parents=True, exist_ok=True)
        if sys.platform == "win32":
            messagebox.showerror(APP_NAME, f"启动失败：{exc}")
        else:
            print(f"UniDesk Linux Provider startup failed: {exc}", file=sys.stderr)
        return 1
    finally:
        if sys.platform == "win32":
            ctypes.windll.kernel32.CloseHandle(instance)
        else:
            instance.close()
    if restart_requested:
        if sys.platform == "win32":
            subprocess.Popen(
                [sys.executable, str(Path(__file__).resolve())],
                creationflags=0x00000008 | 0x00000200,
                close_fds=True,
            )
        else:
            os.execv(sys.executable, [sys.executable, str(Path(__file__).resolve())])
    return 0


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