#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# SPEC: PJ2026-010104 AI网关; PJ2026-010405 云端控制台。
# Implementation reference: draft-2026-07-13-p0-cloud-console.
"""面向 Windows HWPOD 的单文件 HWLAB 图形节点。

用户入口不接受命令行参数，也不依赖 HWLAB_* 环境变量。
持久配置统一存放在 ~/.hwlab/，并由设置界面或受控部署管理。
"""

from __future__ import annotations

import base64
import calendar
import ctypes
import ctypes.wintypes as wt
import hashlib
import json
import logging
from logging.handlers import RotatingFileHandler
import os
from pathlib import Path
import queue
import random
import re
import shutil
import socket
import ssl
import struct
import subprocess
import sys
import threading
import time
import traceback
import urllib.parse
import urllib.request

WIN_WORD = getattr(wt, "WORD", ctypes.c_ushort)
WIN_HBITMAP = getattr(wt, "HBITMAP", wt.HANDLE)
WIN_HBRUSH = getattr(wt, "HBRUSH", wt.HANDLE)
WIN_HICON = getattr(wt, "HICON", wt.HANDLE)
WIN_HINSTANCE = getattr(wt, "HINSTANCE", wt.HANDLE)
WIN_WPARAM = ctypes.c_size_t
WIN_LPARAM = ctypes.c_ssize_t
WIN_LRESULT = ctypes.c_ssize_t

try:
    import tkinter as tk
    from tkinter import messagebox, ttk
except Exception:  # pragma: no cover - import failure is reported in main.
    tk = None
    ttk = None
    messagebox = None


APP_VERSION = "0.1.3"
NODE_VERSION = f"{APP_VERSION}-python-gui"
CONTRACT_VERSION = "hwpod-node-ops-v1"
APP_NAME = "HWLAB Node"
CONFIG_DIR = Path.home() / ".hwlab"
LOG_DIR = CONFIG_DIR / "logs"
UPDATE_DIR = CONFIG_DIR / "update"
CONFIG_PATH = CONFIG_DIR / "config.json"
STATE_PATH = CONFIG_DIR / "state.json"
OPERATION_LEDGER_PATH = CONFIG_DIR / "hwpod-operations.json"
LOG_PATH = LOG_DIR / "hwlab-node.log"

DEFAULT_CONFIG = {
    "schemaVersion": 1,
    "serverUrl": "https://hwlab.pikapython.com",
    "nodeId": "node-d601-f103-v2",
    "autoConnect": False,
    "requireCredential": False,
    "credentialFile": str(CONFIG_DIR / "hwpod-node.token"),
    "allowedWorkspaceRoots": [],
    "defaultWorkspaceRoot": "",
    "update": {
        "enabled": True,
        "autoApplyWhenIdle": False,
        "checkIntervalSeconds": 300,
        "channel": "stable",
        "metadataUrl": "https://hwlab.pikapython.com/v1/hwlab-node/update",
        "downloadHostAllowlist": ["hwlab.pikapython.com"],
    },
    "logging": {
        "maxUiLines": 1000,
        "maxFileBytes": 5 * 1024 * 1024,
        "redactSecrets": True,
    },
}

SECRET_PATTERNS = [
    re.compile(r"(authorization\s*:\s*bearer\s+)[^\s]+", re.I),
    re.compile(r"(api[_-]?key\s*[=:]\s*)[^\s,;]+", re.I),
    re.compile(r"(token\s*[=:]\s*)[^\s,;]+", re.I),
    re.compile(r"(password\s*[=:]\s*)[^\s,;]+", re.I),
    re.compile(r"hwl_live_[A-Za-z0-9_\-]+"),
]


def ensure_dirs() -> None:
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    LOG_DIR.mkdir(parents=True, exist_ok=True)
    UPDATE_DIR.mkdir(parents=True, exist_ok=True)


def deep_merge(default: dict, current: dict) -> dict:
    result = dict(default)
    for key, value in current.items():
        if isinstance(value, dict) and isinstance(result.get(key), dict):
            result[key] = deep_merge(result[key], value)
        else:
            result[key] = value
    return result


def atomic_write_json(path: Path, value: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    os.replace(tmp, path)


def redact(text: object) -> str:
    value = str(text)
    for pattern in SECRET_PATTERNS:
        value = pattern.sub(lambda match: (match.group(1) if match.groups() else "") + "<redacted>", value)
    return value


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


def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def safe_int(value: object, default: int) -> int:
    try:
        number = int(value)
        return number if number > 0 else default
    except Exception:
        return default


class ConfigStore:
    def __init__(self, path: Path = CONFIG_PATH):
        self.path = path
        self.config = self.load()

    def load(self) -> dict:
        ensure_dirs()
        if not self.path.exists():
            atomic_write_json(self.path, DEFAULT_CONFIG)
            return deep_merge(DEFAULT_CONFIG, {})
        try:
            loaded = json.loads(self.path.read_text(encoding="utf-8"))
            if not isinstance(loaded, dict):
                loaded = {}
        except Exception:
            backup = self.path.with_suffix(".invalid.json")
            try:
                shutil.copy2(self.path, backup)
            except Exception:
                pass
            loaded = {}
        merged = deep_merge(DEFAULT_CONFIG, loaded)
        if merged != loaded:
            atomic_write_json(self.path, merged)
        return merged

    def save(self, config: dict) -> None:
        self.config = deep_merge(DEFAULT_CONFIG, config)
        atomic_write_json(self.path, self.config)


def setup_logger() -> logging.Logger:
    ensure_dirs()
    logger = logging.getLogger("hwlab-node")
    logger.setLevel(logging.INFO)
    logger.handlers.clear()
    handler = RotatingFileHandler(LOG_PATH, maxBytes=DEFAULT_CONFIG["logging"]["maxFileBytes"], backupCount=3, encoding="utf-8")
    handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
    logger.addHandler(handler)
    return logger


class DiagnosticBuffer:
    def __init__(self, limit: int = 200):
        self.limit = limit
        self.lock = threading.Lock()
        self.events: list[dict] = []
        self.sequence = 0

    def add(self, source: str, message: str, level: str = "ERROR", details: dict | None = None) -> dict:
        with self.lock:
            self.sequence += 1
            event = {
                "seq": self.sequence,
                "level": str(level or "ERROR"),
                "source": str(source or "unknown")[:80],
                "message": redact(message)[:2000],
                "details": details or {},
                "observedAt": utc_now(),
            }
            self.events.append(event)
            if len(self.events) > self.limit:
                self.events = self.events[-self.limit:]
            return dict(event)

    def recent(self, limit: int = 50) -> list[dict]:
        with self.lock:
            return [dict(item) for item in self.events[-max(1, min(limit, self.limit)):]]

    def since(self, sequence: int, limit: int = 20) -> list[dict]:
        with self.lock:
            selected = [dict(item) for item in self.events if int(item.get("seq") or 0) > sequence]
            return selected[:max(1, limit)]

    def summary(self) -> dict:
        with self.lock:
            last = dict(self.events[-1]) if self.events else None
            return {"count": len(self.events), "lastSeq": self.sequence, "last": last}


DIAGNOSTICS = DiagnosticBuffer()
_EXCEPTION_HOOKS_INSTALLED = False
_EXCEPTION_HOOKS_LOCK = threading.Lock()
_DIAGNOSTIC_LOGGER: logging.Logger | None = None
_DIAGNOSTIC_BUS: "UiEventBus | None" = None
_ORIGINAL_STDERR = sys.stderr


def report_diagnostic(source: str, message: object, *, level: str = "ERROR", details: dict | None = None, logger: logging.Logger | None = None, bus: "UiEventBus | None" = None) -> dict:
    event = DIAGNOSTICS.add(source, str(message), level=level, details=details)
    target_logger = logger or _DIAGNOSTIC_LOGGER
    if target_logger is not None:
        log_level = logging.ERROR if str(level).upper() in ("ERROR", "CRITICAL") else logging.WARNING
        target_logger.log(log_level, "diagnostic[%s] %s", event["source"], event["message"])
    target_bus = bus or _DIAGNOSTIC_BUS
    if target_bus is not None:
        target_bus.emit("log", f"诊断[{event['source']}]: {event['message']}")
    return event


class DiagnosticStderr:
    def __init__(self, original, logger: logging.Logger):
        self.original = original
        self.logger = logger
        self._buffer = ""

    def write(self, text: str) -> int:
        if self.original is not None:
            self.original.write(text)
        self._buffer += str(text)
        while "\n" in self._buffer:
            line, self._buffer = self._buffer.split("\n", 1)
            if line.strip():
                report_diagnostic("stderr", line.rstrip(), logger=self.logger)
        return len(text)

    def flush(self) -> None:
        if self.original is not None:
            self.original.flush()

    def isatty(self) -> bool:
        return bool(getattr(self.original, "isatty", lambda: False)())


def install_exception_hooks(logger: logging.Logger, bus: "UiEventBus | None" = None) -> None:
    global _EXCEPTION_HOOKS_INSTALLED, _DIAGNOSTIC_LOGGER, _DIAGNOSTIC_BUS
    with _EXCEPTION_HOOKS_LOCK:
        _DIAGNOSTIC_LOGGER = logger
        _DIAGNOSTIC_BUS = bus
        if _EXCEPTION_HOOKS_INSTALLED:
            return

        def excepthook(exc_type, exc, tb):
            report_diagnostic("sys.excepthook", exc, logger=logger, bus=bus, details={"trace": "".join(traceback.format_exception(exc_type, exc, tb))})

        def threading_hook(args):
            report_diagnostic("threading.excepthook", args.exc_value, logger=logger, bus=bus, details={"thread": getattr(args.thread, "name", None), "trace": "".join(traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback))})

        def unraisable_hook(args):
            obj = getattr(args, "object", None)
            report_diagnostic("sys.unraisablehook", args.exc_value, logger=logger, bus=bus, details={"object": repr(obj)[:300], "errMsg": getattr(args, "err_msg", None), "trace": "".join(traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback))})

        sys.excepthook = excepthook
        threading.excepthook = threading_hook
        sys.unraisablehook = unraisable_hook
        sys.stderr = DiagnosticStderr(_ORIGINAL_STDERR, logger)
        _EXCEPTION_HOOKS_INSTALLED = True


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

    def emit(self, kind: str, payload: object = None) -> None:
        self.queue.put((kind, payload))


class NodeOpsExecutor:
    def __init__(self, config: dict, logger: logging.Logger):
        self.config = config
        self.logger = logger
        self.uart = None
        self.uart_lock = threading.Lock()

    @property
    def node_id(self) -> str:
        return str(self.config.get("nodeId") or socket.gethostname())

    def execute(self, plan: dict) -> dict:
        if not isinstance(plan, dict):
            raise ValueError("plan must be an object")
        if plan.get("contractVersion") != CONTRACT_VERSION:
            raise ValueError(f"contractVersion must be {CONTRACT_VERSION}")
        ops = plan.get("ops")
        if not isinstance(ops, list) or not ops:
            raise ValueError("ops must be a non-empty array")
        requested_node = str(plan.get("nodeId") or self.node_id)
        if requested_node and requested_node != self.node_id:
            return self._node_mismatch(plan, requested_node)
        results = [self._execute_op(op if isinstance(op, dict) else {}) for op in ops]
        failed = any(item.get("ok") is False for item in results)
        return {
            "ok": not failed,
            "status": "failed" if failed else "completed",
            "contractVersion": CONTRACT_VERSION,
            "serviceId": "hwlab-node",
            "nodeVersion": NODE_VERSION,
            "nodeRole": "single-host-hwpod-node",
            "acceptedInput": "hwpod-node-ops",
            "specAuthority": "none",
            "planId": plan.get("planId"),
            "hwpodId": plan.get("hwpodId"),
            "nodeId": requested_node,
            "localNodeId": self.node_id,
            "results": results,
            "observedAt": utc_now(),
        }

    def capabilities(self) -> list[str]:
        return [
            "hwpod-node-ops",
            "node.health",
            "node.version",
            "node.inventory",
            "node.diagnostics",
            "workspace.ls",
            "workspace.cat",
            "workspace.rg",
            "workspace.write",
            "workspace.replace",
            "workspace.insert-after",
            "cmd.run",
            "debug.build",
            "debug.download",
            "debug.reset",
            "io.uart.open",
            "io.uart.close",
            "io.uart.read",
            "io.uart.write",
        ]

    def allowed_workspace_roots(self) -> list[Path]:
        values = self.config.get("allowedWorkspaceRoots")
        if not isinstance(values, list):
            return []
        roots = []
        for value in values:
            if str(value).strip():
                roots.append(Path(str(value)).expanduser().resolve(strict=False))
        return roots

    def _require_allowed_path(self, path: Path) -> Path:
        resolved = path.expanduser().resolve(strict=False)
        roots = self.allowed_workspace_roots()
        if not roots:
            return resolved
        resolved_key = os.path.normcase(str(resolved))
        for root in roots:
            root_key = os.path.normcase(str(root))
            try:
                if os.path.commonpath([resolved_key, root_key]) == root_key:
                    return resolved
            except ValueError:
                continue
        raise PermissionError("workspace path is outside allowedWorkspaceRoots")

    def _node_mismatch(self, plan: dict, requested_node: str) -> dict:
        blocker = {
            "code": "hwpod_node_id_mismatch",
            "layer": "hwpod-node",
            "retryable": True,
            "summary": f"hwlab-node {self.node_id} cannot execute plan for {requested_node}",
            "details": {"localNodeId": self.node_id, "requestedNodeId": requested_node, "platform": sys.platform, "hostname": socket.gethostname()},
        }
        results = []
        for index, op in enumerate(plan.get("ops") if isinstance(plan.get("ops"), list) else []):
            results.append({"opId": str(op.get("opId") or f"op_{index + 1}"), "op": str(op.get("op") or "unknown"), "ok": False, "status": "blocked", "blocker": blocker})
        return {"ok": False, "status": "blocked", "contractVersion": CONTRACT_VERSION, "serviceId": "hwlab-node", "nodeVersion": NODE_VERSION, "nodeId": requested_node, "localNodeId": self.node_id, "results": results, "blocker": blocker, "observedAt": utc_now()}

    def _execute_op(self, op: dict) -> dict:
        op_id = str(op.get("opId") or "op_unknown")
        name = str(op.get("op") or "")
        args = op.get("args") if isinstance(op.get("args"), dict) else {}
        try:
            if name == "node.health":
                return self._ok(op_id, name, {"platform": sys.platform, "hostname": socket.gethostname(), "cwd": os.getcwd(), "diagnostics": DIAGNOSTICS.summary()})
            if name == "node.version":
                return self._ok(op_id, name, {"version": NODE_VERSION})
            if name == "node.inventory":
                return self._ok(op_id, name, self._node_inventory(args))
            if name == "node.diagnostics":
                return self._ok(op_id, name, self._node_diagnostics(args))
            if name == "workspace.ls":
                return self._ok(op_id, name, self._workspace_ls(args))
            if name == "workspace.cat":
                return self._ok(op_id, name, self._workspace_cat(args))
            if name == "workspace.rg":
                return self._ok(op_id, name, self._workspace_rg(args))
            if name == "workspace.write":
                return self._ok(op_id, name, self._workspace_write(args))
            if name == "workspace.replace":
                return self._ok(op_id, name, self._workspace_replace(args))
            if name == "workspace.insert-after":
                return self._ok(op_id, name, self._workspace_insert_after(args))
            if name == "cmd.run":
                return self._command_result(op_id, name, self._cmd_run(args, default_timeout_ms=30000))
            if name in ("debug.build", "debug.download", "debug.reset"):
                return self._command_result(op_id, name, self._debug_command(name, args))
            if name == "io.uart.open":
                return self._command_result(op_id, name, self._uart_control(args, "start"))
            if name == "io.uart.close":
                return self._command_result(op_id, name, self._uart_control(args, "stop"))
            if name == "io.uart.read":
                output = self._uart_read(args)
                if output.get("ok"):
                    return self._ok(op_id, name, output)
                return self._blocked(op_id, name, output.get("blockerCode", "hwpod_uart_read_failed"), output.get("summary", "io.uart.read failed"), output.get("details", output))
            if name == "io.uart.write":
                return self._command_result(op_id, name, self._uart_write(args))
            return self._blocked(op_id, name, "unsupported_hwpod_node_op", f"unsupported hwpod-node op: {name}")
        except Exception as exc:
            return self._blocked(op_id, name, getattr(exc, "errno", None) or exc.__class__.__name__, str(exc), {"trace": traceback.format_exc(limit=4)})

    def _ok(self, op_id: str, name: str, output: dict) -> dict:
        return {"opId": op_id, "op": name, "ok": True, "status": "completed", "output": output}

    def _failed(self, op_id: str, name: str, output: dict, code: str, summary: str) -> dict:
        return {"opId": op_id, "op": name, "ok": False, "status": "failed", "output": output, "blocker": {"code": code, "layer": "hwpod-node", "retryable": True, "summary": summary}}

    def _blocked(self, op_id: str, name: str, code: str, summary: str, details: dict | None = None) -> dict:
        blocker = {"code": str(code), "layer": "hwpod-node", "retryable": True, "summary": summary}
        if details is not None:
            blocker["details"] = details
        return {"opId": op_id, "op": name, "ok": False, "status": "blocked", "blocker": blocker}

    def _command_result(self, op_id: str, name: str, output: dict) -> dict:
        if output.get("blockerCode"):
            return self._blocked(op_id, name, output["blockerCode"], output.get("stderr") or "command failed before process start", {"output": output})
        if output.get("ok"):
            return self._ok(op_id, name, output)
        return self._failed(op_id, name, output, "hwpod_node_command_failed", f"{name} node-side command exited with {output.get('exitCode')}")

    def _workspace_root(self, args: dict) -> Path:
        value = (
            args.get("workspacePath")
            or args.get("workspaceRoot")
            or args.get("cwd")
            or self.config.get("defaultWorkspaceRoot")
            or os.getcwd()
        )
        return self._require_allowed_path(Path(str(value)))

    def _resolve_workspace_path(self, args: dict, relative: object) -> Path:
        text = str(relative or ".")
        candidate = Path(text)
        if candidate.is_absolute() or re.match(r"^[A-Za-z]:[\\/]", text):
            return self._require_allowed_path(candidate)
        return self._require_allowed_path(self._workspace_root(args) / candidate)

    def _node_inventory(self, args: dict) -> dict:
        root = self._workspace_root(args)
        return {
            "workspaceRoot": str(root),
            "workspaceExists": root.exists(),
            "workspaceIsDirectory": root.is_dir(),
            "allowedWorkspaceRootCount": len(self.allowed_workspace_roots()),
            "capabilities": self.capabilities(),
            "platform": sys.platform,
            "hostname": socket.gethostname(),
        }

    def _node_diagnostics(self, args: dict) -> dict:
        max_events = safe_int(args.get("maxEvents"), 50)
        max_log_lines = safe_int(args.get("maxLogLines"), 80)
        output = {"events": DIAGNOSTICS.recent(max_events), "summary": DIAGNOSTICS.summary(), "logPath": str(LOG_PATH)}
        if args.get("includeLogTail") is not False:
            try:
                output["logTail"] = LOG_PATH.read_text(encoding="utf-8", errors="replace").splitlines()[-max_log_lines:]
            except Exception as exc:
                output["logTailError"] = str(exc)
        return output

    def _workspace_ls(self, args: dict) -> dict:
        target = self._resolve_workspace_path(args, args.get("path") or ".")
        if not target.is_dir():
            raise NotADirectoryError(str(args.get("path") or "."))
        max_entries = max(1, min(safe_int(args.get("maxEntries"), 500), 2000))
        entries = []
        for child in target.iterdir():
            stat = child.stat()
            entries.append({
                "name": child.name,
                "path": self._relative_workspace_path(args, child),
                "type": "dir" if child.is_dir() else "file" if child.is_file() else "other",
                "sizeBytes": stat.st_size if child.is_file() else None,
                "modifiedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(stat.st_mtime)),
            })
        entries.sort(key=lambda item: (item["type"] != "dir", item["name"].lower()))
        truncated = len(entries) > max_entries
        return {"path": self._relative_workspace_path(args, target), "entries": entries[:max_entries], "truncated": truncated, "totalEntries": len(entries)}

    def _workspace_cat(self, args: dict) -> dict:
        target = self._resolve_workspace_path(args, args.get("path"))
        max_bytes = safe_int(args.get("maxBytes"), 65536)
        data = target.read_bytes()
        truncated = len(data) > max_bytes
        sample = data[:max_bytes]
        binary = b"\0" in sample
        return {
            "path": self._relative_workspace_path(args, target),
            "sizeBytes": len(data),
            "contentType": "application/octet-stream" if binary else "text/plain; charset=utf-8",
            "previewable": not binary,
            "content": None if binary else sample.decode("utf-8", errors="replace"),
            "truncated": truncated,
            "blocker": {"code": "hwpod_workspace_binary_preview_unsupported", "summary": "二进制文件不支持文本预览"} if binary else None,
        }

    def _relative_workspace_path(self, args: dict, target: Path) -> str:
        root = self._workspace_root(args)
        try:
            relative = target.relative_to(root)
        except ValueError:
            raise PermissionError("workspace path is outside the selected workspace root")
        text = relative.as_posix()
        return text if text else "."

    def _workspace_rg(self, args: dict) -> dict:
        pattern = str(args.get("pattern") or "")
        if not pattern:
            raise ValueError("pattern is required")
        flags = re.I if args.get("ignoreCase") is True else 0
        matcher = re.compile(pattern, flags)
        root = self._workspace_root(args)
        target = self._resolve_workspace_path(args, args.get("path") or ".")
        max_files = safe_int(args.get("maxFiles"), 5000)
        max_matches = safe_int(args.get("maxMatches"), 200)
        max_bytes = safe_int(args.get("maxBytesPerFile"), 1024 * 1024)
        matches: list[dict] = []
        state = {"scannedFiles": 0, "skippedFiles": 0, "skippedDirectories": 0, "truncated": False}
        files = [target] if target.is_file() else (path for path in target.rglob("*") if path.is_file())
        skipped_dirs = {".git", "node_modules", ".worktree", "dist", "build", "__pycache__"}
        for file_path in files:
            parts = set(file_path.parts)
            if parts & skipped_dirs:
                state["skippedDirectories"] += 1
                continue
            if state["scannedFiles"] >= max_files or len(matches) >= max_matches:
                state["truncated"] = True
                break
            try:
                if file_path.stat().st_size > max_bytes:
                    state["skippedFiles"] += 1
                    continue
                data = file_path.read_bytes()
                if b"\0" in data:
                    state["skippedFiles"] += 1
                    continue
                lines = data.decode("utf-8", errors="replace").splitlines()
            except Exception:
                state["skippedFiles"] += 1
                continue
            state["scannedFiles"] += 1
            display = str(file_path.relative_to(root)) if file_path.is_relative_to(root) else str(file_path)
            for number, line in enumerate(lines, start=1):
                if matcher.search(line):
                    matches.append({"path": display, "line": number, "text": line[:600]})
                    if len(matches) >= max_matches:
                        state["truncated"] = True
                        break
        return {"root": str(root), "path": str(target), "pattern": pattern, "matches": matches, **state}

    def _expected_sha_ok(self, target: Path, expected_sha: object) -> None:
        if not expected_sha:
            return
        current = sha256_bytes(target.read_bytes()) if target.exists() else None
        if current != str(expected_sha):
            raise ValueError(f"expectedSha mismatch for {target}: current={current} expected={expected_sha}")

    def _workspace_write(self, args: dict) -> dict:
        target = self._resolve_workspace_path(args, args.get("path"))
        content = str(args.get("content") or "")
        if args.get("lineEnding") == "crlf":
            content = content.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\r\n")
        if args.get("finalNewline") is True and not content.endswith(("\n", "\r\n")):
            content += "\r\n" if args.get("lineEnding") == "crlf" else "\n"
        before_sha = sha256_bytes(target.read_bytes()) if target.exists() else None
        if args.get("dryRun") is True:
            return {"path": str(target), "dryRun": True, "beforeSha": before_sha, "afterSha": sha256_bytes(content.encode("utf-8"))}
        self._expected_sha_ok(target, args.get("expectedSha"))
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(content, encoding="utf-8")
        return {"path": str(target), "beforeSha": before_sha, "afterSha": sha256_bytes(target.read_bytes()), "bytes": target.stat().st_size}

    def _workspace_replace(self, args: dict) -> dict:
        target = self._resolve_workspace_path(args, args.get("path"))
        find = str(args.get("find") or "")
        replace_with = str(args.get("replace") or "")
        if not find:
            raise ValueError("find is required")
        self._expected_sha_ok(target, args.get("expectedSha"))
        text = target.read_text(encoding="utf-8")
        count = text.count(find)
        if count == 0:
            raise ValueError("find text was not found")
        limit = safe_int(args.get("count"), count)
        new_text = text.replace(find, replace_with, limit)
        if args.get("dryRun") is not True:
            target.write_text(new_text, encoding="utf-8")
        return {"path": str(target), "replacements": min(count, limit), "dryRun": args.get("dryRun") is True, "afterSha": sha256_bytes(new_text.encode("utf-8"))}

    def _workspace_insert_after(self, args: dict) -> dict:
        target = self._resolve_workspace_path(args, args.get("path"))
        anchor = str(args.get("anchor") or "")
        line = str(args.get("line") or "")
        if not anchor:
            raise ValueError("anchor is required")
        self._expected_sha_ok(target, args.get("expectedSha"))
        text = target.read_text(encoding="utf-8")
        index = text.find(anchor)
        if index < 0:
            raise ValueError("anchor was not found")
        insert_at = text.find("\n", index)
        insert_at = len(text) if insert_at < 0 else insert_at + 1
        suffix = "" if line.endswith("\n") else "\n"
        new_text = text[:insert_at] + line + suffix + text[insert_at:]
        if args.get("dryRun") is not True:
            target.write_text(new_text, encoding="utf-8")
        return {"path": str(target), "anchor": anchor, "dryRun": args.get("dryRun") is True, "afterSha": sha256_bytes(new_text.encode("utf-8"))}

    def _cmd_run(self, args: dict, default_timeout_ms: int) -> dict:
        command = args.get("command")
        argv = args.get("argv") if isinstance(args.get("argv"), list) else []
        if not command:
            return {"ok": False, "blockerCode": "invalid_command", "stderr": "command is required", "exitCode": None}
        command_binding = args.get("commandBinding") if isinstance(args.get("commandBinding"), dict) else {}
        if command_binding.get("nativeHelper") == "io-probe-observation":
            return self._io_probe_observation(args, command_binding, default_timeout_ms)
        output = self._spawn_output([str(command), *[str(item) for item in argv]], self._workspace_root(args), safe_int(args.get("timeoutMs"), default_timeout_ms))
        observation = self._command_observation(command_binding, output)
        return {**output, **({"observation": observation} if observation else {})}

    def _command_observation(self, command_binding: dict, output: dict) -> dict:
        if command_binding.get("kind") not in ("io-probe", "board-comm"):
            return {}
        return self._parse_json(output.get("stdout"))

    def _io_probe_observation(self, args: dict, command_binding: dict, default_timeout_ms: int) -> dict:
        argv = args.get("argv") if isinstance(args.get("argv"), list) else []
        input_data = self._parse_json(argv[1] if len(argv) > 1 else None)
        command_base = input_data.get("commandBase") if isinstance(input_data.get("commandBase"), list) else []
        if not command_base:
            return {"ok": False, "blockerCode": "invalid_io_probe_binding", "stderr": "io-probe commandBase is required", "exitCode": None}
        count = max(1, min(safe_int(input_data.get("count"), 1), 50))
        interval_ms = self._nonnegative_int(input_data.get("intervalMs"))
        settle_ms = self._nonnegative_int(input_data.get("settleMs"))
        timeout_ms = safe_int(args.get("timeoutMs"), default_timeout_ms)
        samples = []
        response = None
        exit_code = 0
        commands = []
        if settle_ms:
            time.sleep(settle_ms / 1000)
        for index in range(count):
            command = self._io_probe_command(input_data, command_base)
            output = self._spawn_output(command, self._workspace_root(args), timeout_ms)
            commands.append(output)
            response = self._parse_json(output.get("stdout"))
            value = self._io_probe_value(response, str(input_data.get("valuePath") or ""))
            if value is not None:
                samples.append(value)
            if not output.get("ok"):
                exit_code = output.get("exitCode") or 1
            if index < count - 1 and interval_ms:
                time.sleep(interval_ms / 1000)
        stats = {}
        if samples:
            stats = {"count": len(samples), "mean": sum(samples) / len(samples), "min": min(samples), "max": max(samples)}
        observation = {
            "observationId": f"obs_{input_data.get('probeId')}_{int(time.time() * 1000)}",
            "probeId": input_data.get("probeId") or command_binding.get("probeId"),
            "quantity": input_data.get("quantity") or command_binding.get("quantity"),
            "unit": input_data.get("unit") or command_binding.get("unit"),
            "samples": samples,
            "stats": stats,
            "response": response,
            "rawArtifactRef": response.get("log_path") or response.get("logPath") or response.get("artifactRef") if isinstance(response, dict) else None,
        }
        final_exit_code = exit_code if exit_code or samples else 1
        return {
            "ok": final_exit_code == 0,
            "command": [str(item) for item in command_base],
            "cwd": str(self._workspace_root(args)),
            "exitCode": final_exit_code,
            "stdout": json.dumps(observation, ensure_ascii=False) + "\n",
            "stderr": "".join(str(item.get("stderr") or "") for item in commands),
            "observation": observation,
        }

    def _io_probe_command(self, input_data: dict, command_base: list) -> list[str]:
        return [
            *[str(item) for item in command_base],
            "jrpctcp",
            "--host",
            str(input_data.get("host") or ""),
            "--port",
            str(input_data.get("port") or 8000),
            *(["--transport", str(input_data["transport"])] if input_data.get("transport") else []),
            *(["--target-node-id", str(input_data["targetNodeId"])] if input_data.get("targetNodeId") is not None else []),
            str(input_data.get("method") or "get"),
            str(input_data.get("path") or ""),
            *[str(item) for item in input_data.get("params", [])],
        ]

    def _io_probe_value(self, response: dict, value_path: str) -> float | None:
        candidates = []
        nested_response = response.get("response") if isinstance(response, dict) else None
        if isinstance(nested_response, dict) and isinstance(nested_response.get("response"), dict):
            candidates.append(nested_response["response"])
        if isinstance(nested_response, dict):
            candidates.append(nested_response)
        if isinstance(response, dict):
            candidates.append(response)
        keys = [item for item in value_path.removeprefix("$").removeprefix(".").split(".") if item]
        for candidate in candidates:
            value = candidate
            for key in keys:
                value = value.get(key) if isinstance(value, dict) else None
            try:
                return float(value)
            except (TypeError, ValueError):
                continue
        return None

    def _nonnegative_int(self, value: object) -> int:
        try:
            return max(0, int(value or 0))
        except (TypeError, ValueError):
            return 0

    def _debug_command(self, op: str, args: dict) -> dict:
        runs = args.get("commandRuns") if isinstance(args.get("commandRuns"), list) else None
        if runs:
            outputs = []
            ok = True
            exit_code = 0
            for index, run in enumerate(runs):
                if not isinstance(run, dict) or not run.get("command"):
                    return {"ok": False, "blockerCode": "invalid_command_run", "stderr": f"commandRuns[{index}].command is required", "exitCode": None}
                argv = run.get("argv") if isinstance(run.get("argv"), list) else []
                output = self._spawn_output([str(run["command"]), *[str(item) for item in argv]], self._workspace_root(args), safe_int(run.get("timeoutMs") or args.get("timeoutMs"), 120000))
                outputs.append({"index": index, "command": [str(run["command"]), *[str(item) for item in argv]], **output})
                ok = ok and bool(output.get("ok"))
                exit_code = output.get("exitCode")
                if not output.get("ok"):
                    break
            return {"ok": ok, "cwd": str(self._workspace_root(args)), "op": op, "bindingSource": "hwpod-node-ops.commandRuns", "commands": outputs, "commandCount": len(outputs), "exitCode": exit_code, "stdout": "".join(item.get("stdout", "") for item in outputs), "stderr": "".join(item.get("stderr", "") for item in outputs)}
        if args.get("command"):
            output = self._cmd_run(args, default_timeout_ms=120000)
            return {"cwd": str(self._workspace_root(args)), "op": op, "bindingSource": "hwpod-node-ops.command", **output}
        return {"ok": False, "blockerCode": "hwpod_node_op_not_configured", "stderr": f"{op} requires command or commandRuns", "exitCode": None}

    def _spawn_output(self, command: list[str], cwd: Path, timeout_ms: int) -> dict:
        started = time.time()
        try:
            completed = subprocess.run(command, cwd=str(cwd), capture_output=True, text=True, timeout=max(1, timeout_ms / 1000), shell=False)
            return {"ok": completed.returncode == 0, "command": command, "cwd": str(cwd), "exitCode": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr, "elapsedMs": int((time.time() - started) * 1000)}
        except FileNotFoundError as exc:
            return {"ok": False, "blockerCode": "hwpod_node_command_spawn_failed", "command": command, "cwd": str(cwd), "exitCode": None, "stdout": "", "stderr": str(exc), "elapsedMs": int((time.time() - started) * 1000)}
        except subprocess.TimeoutExpired as exc:
            return {"ok": False, "blockerCode": "hwpod_node_command_timeout", "command": command, "cwd": str(cwd), "exitCode": None, "stdout": exc.stdout or "", "stderr": exc.stderr or "timeout", "elapsedMs": int((time.time() - started) * 1000)}

    def _serial_command_base(self, args: dict) -> list[str]:
        value = args.get("serialMonitorCommand")
        if isinstance(value, list) and value:
            return [str(item) for item in value]
        return ["bun", "scripts/serial-monitor-cli.ts"]

    def _uart_control(self, args: dict, action: str) -> dict:
        if str(args.get("uartBackend") or "").lower() == "pyserial":
            return self._pyserial_control(args, action)
        serial_dir = Path(str(args.get("serialMonitorDir") or Path.home() / ".agents" / "skills" / "serial-monitor"))
        command_base = self._serial_command_base(args)
        timeout_ms = safe_int(args.get("timeoutMs"), 10000)
        port = str(args.get("port") or "")
        baud = args.get("baudRate") or args.get("baudrate")
        command = [*command_base, "monitor", action]
        if action == "start":
            command.extend([*(["-p", port] if port else []), *(["-b", str(baud)] if baud else [])])
        output = self._spawn_output(command, serial_dir, timeout_ms)
        body = self._parse_json(output.get("stdout"))
        server_start = None
        if action == "start" and self._serial_server_unreachable(body):
            server_start = self._spawn_output([*command_base, "server", "start"], serial_dir, timeout_ms)
            server_body = self._parse_json(server_start.get("stdout"))
            server_start = {**server_start, "response": server_body or None}
            if server_start.get("ok") and server_body.get("success") is True:
                output = self._spawn_output(command, serial_dir, timeout_ms)
                body = self._parse_json(output.get("stdout"))
        return {**output, "ok": output.get("ok") and body.get("success") is True, "bindingSource": "serial-monitor-cli", "port": port or None, "baudRate": baud, "response": body or None, "serverStart": server_start}

    def _serial_server_unreachable(self, body: dict) -> bool:
        data = body.get("data") if isinstance(body.get("data"), dict) else {}
        request = data.get("request") if isinstance(data.get("request"), dict) else {}
        diagnosis = data.get("diagnosis") if isinstance(data.get("diagnosis"), dict) else {}
        return body.get("success") is False and (request.get("code") == "ECONNREFUSED" or diagnosis.get("status") == "not_running")

    def _uart_write(self, args: dict) -> dict:
        data = str(args.get("data") or "")
        if not data:
            return {"ok": False, "blockerCode": "hwpod_uart_data_required", "stderr": "UART data is required", "exitCode": None}
        if str(args.get("uartBackend") or "").lower() == "pyserial":
            return self._pyserial_write(args, data)
        serial_dir = Path(str(args.get("serialMonitorDir") or Path.home() / ".agents" / "skills" / "serial-monitor"))
        command = [*self._serial_command_base(args), "send", data]
        output = self._spawn_output(command, serial_dir, safe_int(args.get("timeoutMs"), 10000))
        body = self._parse_json(output.get("stdout"))
        return {**output, "ok": output.get("ok") and body.get("success") is True, "bindingSource": "serial-monitor-cli", "bytes": len(data.encode("utf-8")), "response": body or None}

    def _uart_read(self, args: dict) -> dict:
        if str(args.get("uartBackend") or "").lower() == "pyserial":
            return self._pyserial_read(args)
        serial_dir = Path(str(args.get("serialMonitorDir") or Path.home() / ".agents" / "skills" / "serial-monitor"))
        command_base = self._serial_command_base(args)
        timeout_ms = safe_int(args.get("timeoutMs"), 10000)
        port = str(args.get("port") or args.get("resolvedPort") or args.get("targetPort") or "")
        baud = args.get("baudRate") or args.get("baudrate")
        status = self._spawn_output([*command_base, "monitor", "status"], serial_dir, timeout_ms)
        status_body = self._parse_json(status.get("stdout"))
        data = status_body.get("data") if isinstance(status_body, dict) and isinstance(status_body.get("data"), dict) else {}
        active_port = str(data.get("port") or "")
        active_baud = data.get("baudRate")
        monitor_start = None
        matches = status.get("ok") and status_body.get("success") is True and data.get("isMonitoring") is True and (not port or active_port.lower() == port.lower()) and (not baud or int(active_baud or 0) == int(baud))
        if not matches:
            start_args = ["monitor", "start", *(["-p", port] if port else []), *(["-b", str(baud)] if baud else [])]
            start = self._spawn_output([*command_base, *start_args], serial_dir, timeout_ms)
            start_body = self._parse_json(start.get("stdout"))
            monitor_start = start_body if isinstance(start_body, dict) and start_body else start
            if not start.get("ok") or not isinstance(start_body, dict) or start_body.get("success") is not True:
                ports = self._spawn_output([*command_base, "ports"], serial_dir, timeout_ms)
                return {"ok": False, "blockerCode": "hwpod_uart_monitor_start_failed", "summary": f"serial-monitor could not start on {port or 'the requested UART'}{('/' + str(baud)) if baud else ''}", "details": {"requestedPort": port or None, "baudRate": baud, "serialMonitor": {"dir": str(serial_dir), "command": command_base, "monitorStatus": status_body or status, "start": monitor_start, "ports": self._parse_json(ports.get("stdout")) or ports}}}
            confirmed_status = self._spawn_output([*command_base, "monitor", "status"], serial_dir, timeout_ms)
            confirmed_body = self._parse_json(confirmed_status.get("stdout"))
            confirmed_data = confirmed_body.get("data") if isinstance(confirmed_body, dict) and isinstance(confirmed_body.get("data"), dict) else {}
            confirmed_port = str(confirmed_data.get("port") or "")
            confirmed_baud = confirmed_data.get("baudRate")
            confirmed_matches = confirmed_status.get("ok") and isinstance(confirmed_body, dict) and confirmed_body.get("success") is True and confirmed_data.get("isMonitoring") is True and (not port or confirmed_port.lower() == port.lower()) and (not baud or int(confirmed_baud or 0) == int(baud))
            if not confirmed_matches:
                return {"ok": False, "blockerCode": "hwpod_uart_monitor_binding_mismatch", "summary": "serial-monitor did not confirm the requested UART binding after start", "details": {"requestedPort": port or None, "baudRate": baud, "serialMonitor": {"dir": str(serial_dir), "command": command_base, "monitorStatus": status_body or status, "start": monitor_start, "confirmedStatus": confirmed_body or confirmed_status}}}
            status = confirmed_status
            status_body = confirmed_body
            data = confirmed_data
            active_port = confirmed_port
            active_baud = confirmed_baud
        limit = max(1, min(safe_int(args.get("limit"), max(1, safe_int(args.get("maxBytes"), 4096) // 80)), 200))
        fetch_args = ["fetch", "-l", str(limit), "--session-only"]
        if args.get("since"):
            fetch_args.extend(["-s", str(args["since"])])
        fetch = self._spawn_output([*command_base, *fetch_args], serial_dir, timeout_ms)
        fetch_body = self._parse_json(fetch.get("stdout"))
        if not fetch.get("ok") or fetch_body.get("success") is not True:
            return {"ok": False, "blockerCode": "hwpod_uart_read_failed", "summary": "serial-monitor fetch failed for io.uart.read", "details": {"serialMonitor": {"dir": str(serial_dir), "command": command_base, "fetch": fetch_body or fetch}}}
        rows = fetch_body.get("data") if isinstance(fetch_body.get("data"), list) else []
        text = "\n".join(str(row.get("data")) for row in rows if isinstance(row, dict) and row.get("data"))
        max_bytes = safe_int(args.get("maxBytes"), 16384)
        truncated_text = text[:max_bytes]
        resolved_port = active_port or port or None
        resolved_baud = baud or active_baud
        source_file = str(fetch_body.get("sourceFile") or "")
        observation = {"observationId": f"uart:{resolved_port or 'unknown'}:{source_file or 'session'}", "port": resolved_port, "baudRate": resolved_baud, "data": rows, "text": truncated_text, "truncated": len(text) > max_bytes or fetch_body.get("truncated") is True}
        if source_file:
            observation["rawArtifactRef"] = source_file
        return {"ok": True, "workspacePath": str(self._workspace_root(args)), "bindingSource": "serial-monitor-cli", "serialMonitorDir": str(serial_dir), "requestedPort": port or None, "resolvedPort": resolved_port, "baudRate": resolved_baud, "command": [*command_base, *fetch_args], "data": rows, "count": fetch_body.get("count", len(rows)), "totalCount": fetch_body.get("totalCount"), "hasMore": fetch_body.get("hasMore", False), "text": truncated_text, "truncated": len(text) > max_bytes or fetch_body.get("truncated") is True, "monitorStatus": status_body, "monitorStarted": not matches, "monitorStart": monitor_start, "sourceFile": source_file or None, "observation": observation}

    def _pyserial_control(self, args: dict, action: str) -> dict:
        with self.uart_lock:
            if action == "stop":
                if self.uart is not None:
                    self.uart.close()
                    self.uart = None
                return {"ok": True, "bindingSource": "pyserial", "status": "closed"}
            import serial
            port = str(args.get("port") or "")
            baud = safe_int(args.get("baudRate") or args.get("baudrate"), 115200)
            if self.uart is not None and (self.uart.port != port or self.uart.baudrate != baud):
                self.uart.close()
                self.uart = None
            if self.uart is None or not self.uart.is_open:
                self.uart = serial.Serial(port=port, baudrate=baud, timeout=0)
            return {"ok": True, "bindingSource": "pyserial", "status": "open", "port": port, "baudRate": baud}

    def _pyserial_write(self, args: dict, data: str) -> dict:
        with self.uart_lock:
            if self.uart is None or not self.uart.is_open:
                return {"ok": False, "blockerCode": "hwpod_uart_not_open", "stderr": "UART must be opened before write", "exitCode": None}
            payload = data.encode(str(args.get("encoding") or "utf-8"))
            written = self.uart.write(payload)
            return {"ok": written == len(payload), "bindingSource": "pyserial", "bytes": written, "port": self.uart.port, "baudRate": self.uart.baudrate}

    def _pyserial_read(self, args: dict) -> dict:
        with self.uart_lock:
            if self.uart is None or not self.uart.is_open:
                return {"ok": False, "blockerCode": "hwpod_uart_not_open", "summary": "UART must be opened before read"}
            max_bytes = max(1, min(safe_int(args.get("maxBytes"), 16384), 1048576))
            available = min(safe_int(getattr(self.uart, "in_waiting", 0), 0), max_bytes)
            payload = self.uart.read(available) if available else b""
            text = payload.decode(str(args.get("encoding") or "utf-8"), errors="replace")
            return {"ok": True, "bindingSource": "pyserial", "port": self.uart.port, "baudRate": self.uart.baudrate, "bytes": len(payload), "text": text, "dataBase64": base64.b64encode(payload).decode("ascii"), "truncated": safe_int(getattr(self.uart, "in_waiting", 0), 0) > 0}

    def _parse_json(self, text: object) -> dict:
        try:
            value = json.loads(str(text or ""))
            return value if isinstance(value, dict) else {}
        except Exception:
            return {}


class WebSocketNodeClient:
    def __init__(self, config: dict, executor: NodeOpsExecutor, logger: logging.Logger, bus: UiEventBus):
        self.config = config
        self.executor = executor
        self.logger = logger
        self.bus = bus
        self.stop_event = threading.Event()
        self.thread: threading.Thread | None = None
        self.sock: socket.socket | None = None
        self.manual_stop = False
        self.connected = False
        self.diagnostic_cursor = 0
        self.operation_retention_seconds = 0

    def start(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._run, name="hwlab-node-ws", daemon=True)
        self.thread.start()

    def stop(self) -> None:
        self.manual_stop = True
        self.stop_event.set()
        self._close_socket()

    def _run(self) -> None:
        while not self.stop_event.is_set():
            try:
                self.bus.emit("connection", "connecting")
                self._connect_once()
            except Exception as exc:
                self.logger.warning("websocket connection failed: %s", redact(exc))
                self.bus.emit("log", f"连接失败: {redact(exc)}")
            finally:
                self.connected = False
                self.bus.emit("connection", "manual-disconnected" if self.manual_stop else "reconnecting")
                self._close_socket()
            if self.manual_stop or self.stop_event.is_set():
                break
            time.sleep(3)
        self.bus.emit("connection", "manual-disconnected")

    def _connect_once(self) -> None:
        ws_url = self._ws_url()
        parsed = urllib.parse.urlparse(ws_url)
        port = parsed.port or (443 if parsed.scheme == "wss" else 80)
        host = parsed.hostname or ""
        raw = socket.create_connection((host, port), timeout=10)
        self.sock = ssl.create_default_context().wrap_socket(raw, server_hostname=host) if parsed.scheme == "wss" else raw
        self._handshake(parsed, host, port)
        self.sock.settimeout(1.0)
        self.bus.emit("log", f"WebSocket 已打开: {ws_url}")
        self._send_json({
            "type": "register",
            "nodeId": self.executor.node_id,
            "name": self.executor.node_id,
            "capabilities": self.executor.capabilities(),
            "maxInFlight": 1,
            "runtime": {
                "kind": "python-tkinter-gui",
                "installed": True,
                "desktopVisible": True,
                "currentVersion": APP_VERSION,
                "updateChannel": str(self.config.get("update", {}).get("channel") or "stable"),
            },
            "labels": {
                "platform": sys.platform,
                "hostname": socket.gethostname(),
                "version": NODE_VERSION,
                "startedAt": utc_now(),
                "allowedWorkspaceRootCount": len(self.executor.allowed_workspace_roots()),
                "diagnostics": DIAGNOSTICS.summary(),
            },
        })
        last_heartbeat = 0.0
        last_diagnostic_flush = 0.0
        while not self.stop_event.is_set():
            if time.time() - last_heartbeat > 15:
                self._send_json({"type": "heartbeat", "nodeId": self.executor.node_id, "at": utc_now()})
                last_heartbeat = time.time()
            if time.time() - last_diagnostic_flush > 5:
                self._flush_diagnostics()
                last_diagnostic_flush = time.time()
            try:
                opcode, payload = self._recv_frame()
            except socket.timeout:
                continue
            if opcode == 0x8:
                break
            if opcode == 0x9:
                self._send_frame(payload, opcode=0xA)
                continue
            if opcode != 0x1:
                continue
            self._handle_message(payload.decode("utf-8", errors="replace"))

    def _ws_url(self) -> str:
        base = str(self.config.get("serverUrl") or DEFAULT_CONFIG["serverUrl"]).rstrip("/")
        parsed = urllib.parse.urlparse(base)
        scheme = "wss" if parsed.scheme == "https" else "ws"
        return urllib.parse.urlunparse((scheme, parsed.netloc, "/v1/hwpod-node/ws", "", "", ""))

    def _handshake(self, parsed: urllib.parse.ParseResult, host: str, port: int) -> None:
        key = base64.b64encode(os.urandom(16)).decode("ascii")
        path = parsed.path or "/"
        if parsed.query:
            path += "?" + parsed.query
        credential = self._credential()
        credential_header = f"X-HWPOD-Node-Token: {credential}\r\n" if credential else ""
        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"
            f"{credential_header}\r\n"
        )
        assert self.sock is not None
        self.sock.sendall(request.encode("ascii"))
        response = b""
        while b"\r\n\r\n" not in response:
            chunk = self.sock.recv(4096)
            if not chunk:
                break
            response += chunk
            if len(response) > 16384:
                break
        if b" 101 " not in response.split(b"\r\n", 1)[0]:
            raise RuntimeError(f"websocket upgrade failed: {response[:200]!r}")

    def _credential(self) -> str:
        path_value = str(self.config.get("credentialFile") or "").strip()
        required = self.config.get("requireCredential") is True
        if not path_value:
            if required:
                raise RuntimeError("credentialFile is required")
            return ""
        path = Path(path_value).expanduser()
        try:
            credential = path.read_text(encoding="utf-8").strip()
        except FileNotFoundError:
            credential = ""
        if required and not credential:
            raise RuntimeError("HWPOD node credential is missing")
        return credential

    def _handle_message(self, text: str) -> None:
        message = json.loads(text)
        if message.get("type") == "ack" and message.get("requestId") == "register":
            self.connected = message.get("ok") is True
            self.operation_retention_seconds = safe_int(message.get("operationRetentionSeconds"), 0)
            self.bus.emit("connection", "connected" if self.connected else "error")
            self.bus.emit("log", f"注册结果: ok={message.get('ok')} message={message.get('message')}")
            return
        if message.get("type") != "hwpod-node-ops":
            return
        request_id = str(message.get("requestId") or "req_unknown")
        plan = message.get("plan") or {}
        plan_id = str(plan.get("planId") or request_id)
        ledger = self._operation_ledger()
        existing = ledger.get(plan_id)
        self._send_json({"type": "hwpod-node-ops-accepted", "nodeId": self.executor.node_id, "requestId": request_id, "planId": plan_id, "observedAt": utc_now()})
        if isinstance(existing, dict) and existing.get("status") == "completed" and isinstance(existing.get("result"), dict):
            self._send_json({"type": "hwpod-node-ops-result", "nodeId": self.executor.node_id, "requestId": request_id, "result": existing["result"], "observedAt": utc_now()})
            return
        ledger[plan_id] = {"status": "accepted", "acceptedAt": utc_now(), "result": None}
        self._save_operation_ledger(ledger)
        try:
            result = self.executor.execute(plan)
        except Exception as exc:
            result = {"ok": False, "status": "failed", "results": [], "blocker": {"code": "hwpod_node_ws_run_failed", "layer": "hwpod-node", "retryable": True, "summary": str(exc)}}
        ledger[plan_id] = {"status": "completed", "acceptedAt": ledger[plan_id]["acceptedAt"], "completedAt": utc_now(), "result": result}
        self._save_operation_ledger(ledger)
        self._send_json({"type": "hwpod-node-ops-result", "nodeId": self.executor.node_id, "requestId": request_id, "result": result, "observedAt": utc_now()})

    def _operation_ledger(self) -> dict:
        try:
            value = json.loads(OPERATION_LEDGER_PATH.read_text(encoding="utf-8"))
            ledger = value if isinstance(value, dict) else {}
        except Exception:
            ledger = {}
        if self.operation_retention_seconds > 0:
            cutoff = time.time() - self.operation_retention_seconds
            ledger = {key: value for key, value in ledger.items() if not isinstance(value, dict) or self._operation_timestamp(value) >= cutoff}
        return ledger

    def _save_operation_ledger(self, ledger: dict) -> None:
        atomic_write_json(OPERATION_LEDGER_PATH, ledger)

    def _operation_timestamp(self, value: dict) -> float:
        timestamp = str(value.get("completedAt") or value.get("acceptedAt") or "")
        try:
            return calendar.timegm(time.strptime(timestamp, "%Y-%m-%dT%H:%M:%SZ"))
        except Exception:
            return time.time()

    def _flush_diagnostics(self) -> None:
        for event in DIAGNOSTICS.since(self.diagnostic_cursor, 20):
            self._send_json({"type": "hwpod-node-diagnostic", "nodeId": self.executor.node_id, "diagnostic": event, "observedAt": utc_now()})
            self.diagnostic_cursor = max(self.diagnostic_cursor, safe_int(event.get("seq"), self.diagnostic_cursor))

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

    def _send_frame(self, payload: bytes, opcode: int = 0x1) -> None:
        assert self.sock is not None
        first = 0x80 | opcode
        length = len(payload)
        mask = os.urandom(4)
        if length < 126:
            header = struct.pack("!BB", first, 0x80 | length)
        elif length < 65536:
            header = struct.pack("!BBH", first, 0x80 | 126, length)
        else:
            header = struct.pack("!BBQ", first, 0x80 | 127, length)
        masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
        self.sock.sendall(header + mask + masked)

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

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

    def _close_socket(self) -> None:
        sock, self.sock = self.sock, None
        if sock:
            try:
                sock.close()
            except Exception:
                pass


class UpdateManager:
    def __init__(self, config: dict, logger: logging.Logger):
        self.config = config
        self.logger = logger

    def check(self) -> dict:
        update_config = self.config.get("update") if isinstance(self.config.get("update"), dict) else {}
        url = str(update_config.get("metadataUrl") or DEFAULT_CONFIG["update"]["metadataUrl"])
        channel = str(update_config.get("channel") or "stable")
        parsed = urllib.parse.urlparse(url)
        query = dict(urllib.parse.parse_qsl(parsed.query))
        query.update({"platform": "windows" if sys.platform == "win32" else sys.platform, "channel": channel, "current": APP_VERSION})
        request_url = urllib.parse.urlunparse(parsed._replace(query=urllib.parse.urlencode(query)))
        request = urllib.request.Request(request_url, headers={"user-agent": f"hwlab-node/{APP_VERSION}"})
        try:
            with urllib.request.urlopen(request, timeout=10) as response:
                if response.status == 204:
                    return {"ok": True, "updateAvailable": False, "latestVersion": APP_VERSION, "checkedAt": utc_now()}
                body = json.loads(response.read().decode("utf-8"))
                if not isinstance(body, dict):
                    raise ValueError("metadata response is not an object")
                body["checkedAt"] = utc_now()
                return body
        except Exception as exc:
            return {"ok": False, "updateAvailable": False, "error": str(exc), "checkedAt": utc_now()}

    def apply_update(self, metadata: dict) -> dict:
        if not metadata.get("updateAvailable"):
            return {"ok": False, "error": "no update available"}
        download_url = str(metadata.get("downloadUrl") or "")
        parsed = urllib.parse.urlparse(download_url)
        allowlist = self.config.get("update", {}).get("downloadHostAllowlist", ["hwlab.pikapython.com"])
        if parsed.hostname not in allowlist or not parsed.path.endswith(".py"):
            return {"ok": False, "error": f"download host/path is not allowed: {parsed.hostname}{parsed.path}"}
        tmp = UPDATE_DIR / "hwlab-node.py.tmp"
        backup = UPDATE_DIR / "hwlab-node.py.bak"
        current = Path(__file__).resolve()
        try:
            with urllib.request.urlopen(download_url, timeout=30) as response:
                data = response.read()
            expected = metadata.get("sha256")
            actual = sha256_bytes(data)
            if expected and actual.lower() != str(expected).lower():
                return {"ok": False, "error": f"sha256 mismatch: {actual}"}
            if b"HWLAB" not in data[:4096] or b"APP_VERSION" not in data[:4096]:
                return {"ok": False, "error": "downloaded file does not look like hwlab-node.py"}
            tmp.write_bytes(data)
            shutil.copy2(current, backup)
            os.replace(tmp, current)
            atomic_write_json(STATE_PATH, {"updatedAt": utc_now(), "fromVersion": APP_VERSION, "toVersion": metadata.get("latestVersion"), "backup": str(backup)})
            subprocess.Popen([sys.executable, str(current)], cwd=str(current.parent), close_fds=True)
            return {"ok": True, "restarting": True}
        except Exception as exc:
            return {"ok": False, "error": str(exc)}


class TrayIcon:
    def __init__(self, bus: UiEventBus, logger: logging.Logger):
        self.bus = bus
        self.logger = logger
        self.available = False
        self.connection_state = "manual-disconnected"
        self.update_available = False
        self._hwnd = None
        self._notify_data = None
        self._thread = None
        self._ready = threading.Event()
        self._wndproc_ref = None
        self._icon = None
        self._icon_cache: dict[tuple[str, bool], int] = {}

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

    def update(self, connection_state: str | None = None, update_available: bool | None = None) -> None:
        if connection_state is not None:
            self.connection_state = connection_state
        if update_available is not None:
            self.update_available = update_available
        if self.available:
            try:
                self._modify_icon()
            except Exception as exc:
                report_diagnostic("tray.update", exc, logger=self.logger, bus=self.bus, details={"trace": traceback.format_exc()})

    def remove(self) -> None:
        if not self.available or not self._notify_data:
            return
        try:
            shell32 = ctypes.windll.shell32
            shell32.Shell_NotifyIconW.argtypes = [wt.DWORD, ctypes.c_void_p]
            shell32.Shell_NotifyIconW.restype = wt.BOOL
            shell32.Shell_NotifyIconW(0x00000002, ctypes.byref(self._notify_data))
        except Exception as exc:
            report_diagnostic("tray.remove", exc, logger=self.logger, bus=self.bus, details={"trace": traceback.format_exc()})
        finally:
            self.available = False
            self._destroy_icon_cache()

    def _run(self) -> None:
        try:
            self._create_window()
            self._add_icon()
            self.available = True
            self._ready.set()
            msg = wt.MSG()
            user32 = ctypes.windll.user32
            while user32.GetMessageW(ctypes.byref(msg), None, 0, 0) != 0:
                user32.TranslateMessage(ctypes.byref(msg))
                user32.DispatchMessageW(ctypes.byref(msg))
        except Exception as exc:
            report_diagnostic("tray.init", exc, logger=self.logger, bus=self.bus, details={"trace": traceback.format_exc()})
            self._ready.set()

    def _create_window(self) -> None:
        user32 = ctypes.windll.user32
        self._configure_win32_api()
        hinst = ctypes.windll.kernel32.GetModuleHandleW(None)
        wndproc_type = ctypes.WINFUNCTYPE(WIN_LRESULT, wt.HWND, wt.UINT, WIN_WPARAM, WIN_LPARAM)

        def wndproc(hwnd, msg, wparam, lparam):
            try:
                if msg == 0x0400 + 20:
                    if int(lparam) == 0x0203:
                        self.bus.emit("tray", "show")
                    elif int(lparam) == 0x0205:
                        self._show_menu()
                    return 0
                if msg == 0x0010:
                    self.remove()
                    user32.PostQuitMessage(0)
                    return 0
                return self._def_window_proc(user32, hwnd, msg, wparam, lparam)
            except Exception as exc:
                report_diagnostic("tray.wndproc", exc, logger=self.logger, bus=self.bus, details={"msg": int(msg), "wparam": int(wparam), "lparam": int(lparam), "trace": traceback.format_exc()})
                try:
                    return self._def_window_proc(user32, hwnd, msg, wparam, lparam)
                except Exception:
                    return 0

        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", WIN_HINSTANCE), ("hIcon", WIN_HICON), ("hCursor", wt.HANDLE), ("hbrBackground", WIN_HBRUSH), ("lpszMenuName", wt.LPCWSTR), ("lpszClassName", wt.LPCWSTR)]

        class_name = "HWLABNodeTrayWindow"
        wc = WNDCLASSW(0, self._wndproc_ref, 0, 0, hinst, None, None, None, None, class_name)
        user32.RegisterClassW(ctypes.byref(wc))
        self._hwnd = user32.CreateWindowExW(0, class_name, APP_NAME, 0, 0, 0, 0, 0, None, None, hinst, None)
        if not self._hwnd:
            raise ctypes.WinError()

    def _configure_win32_api(self) -> None:
        user32 = ctypes.windll.user32
        kernel32 = ctypes.windll.kernel32
        shell32 = ctypes.windll.shell32
        kernel32.GetModuleHandleW.argtypes = [wt.LPCWSTR]
        kernel32.GetModuleHandleW.restype = WIN_HINSTANCE
        user32.DefWindowProcW.argtypes = [wt.HWND, wt.UINT, WIN_WPARAM, WIN_LPARAM]
        user32.DefWindowProcW.restype = WIN_LRESULT
        user32.PostQuitMessage.argtypes = [ctypes.c_int]
        user32.GetMessageW.argtypes = [ctypes.POINTER(wt.MSG), wt.HWND, wt.UINT, wt.UINT]
        user32.GetMessageW.restype = ctypes.c_int
        user32.TranslateMessage.argtypes = [ctypes.POINTER(wt.MSG)]
        user32.TranslateMessage.restype = wt.BOOL
        user32.DispatchMessageW.argtypes = [ctypes.POINTER(wt.MSG)]
        user32.DispatchMessageW.restype = WIN_LRESULT
        user32.RegisterClassW.argtypes = [ctypes.c_void_p]
        user32.RegisterClassW.restype = WIN_WORD
        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, WIN_HINSTANCE, ctypes.c_void_p]
        user32.CreateWindowExW.restype = wt.HWND
        shell32.Shell_NotifyIconW.argtypes = [wt.DWORD, ctypes.c_void_p]
        shell32.Shell_NotifyIconW.restype = wt.BOOL

    def _def_window_proc(self, user32, hwnd, msg, wparam, lparam):
        return user32.DefWindowProcW(hwnd, msg, WIN_WPARAM(int(wparam)).value, WIN_LPARAM(int(lparam)).value)

    def _add_icon(self) -> None:
        self._notify_data = self._make_notify_data()
        if not ctypes.windll.shell32.Shell_NotifyIconW(0x00000000, ctypes.byref(self._notify_data)):
            raise ctypes.WinError()

    def _modify_icon(self) -> None:
        if not self._notify_data:
            return
        self._notify_data.hIcon = self._make_icon()
        self._icon = self._notify_data.hIcon
        self._notify_data.szTip = self._tooltip()
        if not ctypes.windll.shell32.Shell_NotifyIconW(0x00000001, ctypes.byref(self._notify_data)):
            raise ctypes.WinError()

    def _make_notify_data(self):
        class GUID(ctypes.Structure):
            _fields_ = [("Data1", wt.DWORD), ("Data2", WIN_WORD), ("Data3", WIN_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", WIN_HICON), ("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", WIN_HICON)]

        data = NOTIFYICONDATAW()
        data.cbSize = ctypes.sizeof(NOTIFYICONDATAW)
        data.hWnd = self._hwnd
        data.uID = 1
        data.uFlags = 0x00000001 | 0x00000002 | 0x00000004
        data.uCallbackMessage = 0x0400 + 20
        data.hIcon = self._make_icon()
        self._icon = data.hIcon
        data.szTip = self._tooltip()
        return data

    def _tooltip(self) -> str:
        state_map = {"connected": "已连接", "connecting": "连接中", "reconnecting": "重连中", "error": "错误"}
        state = state_map.get(self.connection_state, "未连接")
        update = "有更新" if self.update_available else "已是最新"
        return f"HWLAB Node - {state} - {update}"[:127]

    def _make_icon(self):
        key = (self.connection_state, bool(self.update_available))
        cached = self._icon_cache.get(key)
        if cached:
            return cached
        try:
            icon = self._make_color_icon()
            if icon:
                self._icon_cache[key] = icon
                return icon
            raise RuntimeError("CreateIconIndirect returned empty icon handle")
        except Exception as exc:
            report_diagnostic("tray.icon", exc, level="WARN", logger=self.logger, bus=self.bus, details={"trace": traceback.format_exc()})
            if self._icon:
                return self._icon
            user32 = ctypes.windll.user32
            user32.LoadIconW.argtypes = [WIN_HINSTANCE, ctypes.c_void_p]
            user32.LoadIconW.restype = WIN_HICON
            return user32.LoadIconW(None, ctypes.c_void_p(32512))

    def _destroy_icon_cache(self) -> None:
        if not self._icon_cache:
            return
        user32 = ctypes.windll.user32
        user32.DestroyIcon.argtypes = [WIN_HICON]
        user32.DestroyIcon.restype = wt.BOOL
        for icon in set(self._icon_cache.values()):
            try:
                user32.DestroyIcon(icon)
            except Exception:
                pass
        self._icon_cache.clear()
        self._icon = None

    def _make_color_icon(self):
        size = 16
        colors = {
            "connected": (40, 180, 80),
            "connecting": (60, 130, 230),
            "reconnecting": (230, 170, 35),
            "error": (220, 55, 55),
        }
        rgb = colors.get(self.connection_state, (150, 150, 150))
        pixels = bytearray(size * size * 4)
        cx = cy = 7.5
        for y in range(size):
            for x in range(size):
                i = (y * size + x) * 4
                dist = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5
                if dist <= 7:
                    pixels[i:i + 4] = bytes((rgb[2], rgb[1], rgb[0], 255))
                else:
                    pixels[i:i + 4] = b"\x00\x00\x00\x00"
        if self.update_available:
            for y in range(0, 7):
                for x in range(9, 16):
                    if (x - 12) ** 2 + (y - 3) ** 2 <= 12:
                        i = (y * size + x) * 4
                        pixels[i:i + 4] = bytes((0, 220, 255, 255))

        class BITMAPINFOHEADER(ctypes.Structure):
            _fields_ = [("biSize", wt.DWORD), ("biWidth", ctypes.c_long), ("biHeight", ctypes.c_long), ("biPlanes", WIN_WORD), ("biBitCount", WIN_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", WIN_HBITMAP), ("hbmColor", WIN_HBITMAP)]

        gdi32 = ctypes.windll.gdi32
        user32 = ctypes.windll.user32
        user32.GetDC.argtypes = [wt.HWND]
        user32.GetDC.restype = wt.HDC
        user32.ReleaseDC.argtypes = [wt.HWND, wt.HDC]
        user32.ReleaseDC.restype = ctypes.c_int
        user32.CreateIconIndirect.argtypes = [ctypes.POINTER(ICONINFO)]
        user32.CreateIconIndirect.restype = WIN_HICON
        gdi32.CreateDIBSection.argtypes = [wt.HDC, ctypes.c_void_p, wt.UINT, ctypes.POINTER(ctypes.c_void_p), wt.HANDLE, wt.DWORD]
        gdi32.CreateDIBSection.restype = WIN_HBITMAP
        gdi32.CreateBitmap.argtypes = [ctypes.c_int, ctypes.c_int, wt.UINT, wt.UINT, ctypes.c_void_p]
        gdi32.CreateBitmap.restype = WIN_HBITMAP
        gdi32.DeleteObject.argtypes = [wt.HANDLE]
        gdi32.DeleteObject.restype = wt.BOOL
        hdc = user32.GetDC(None)
        if not hdc:
            raise ctypes.WinError()
        bits = ctypes.c_void_p()
        bmi = BITMAPINFO()
        bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
        bmi.bmiHeader.biWidth = size
        bmi.bmiHeader.biHeight = -size
        bmi.bmiHeader.biPlanes = 1
        bmi.bmiHeader.biBitCount = 32
        bmi.bmiHeader.biCompression = 0
        color = None
        mask = None
        try:
            color = gdi32.CreateDIBSection(hdc, ctypes.byref(bmi), 0, ctypes.byref(bits), None, 0)
            if not color or not bits.value:
                raise ctypes.WinError()
            ctypes.memmove(bits.value, bytes(pixels), len(pixels))
            mask_bits = (ctypes.c_ubyte * ((size * size) // 8))()
            mask = gdi32.CreateBitmap(size, size, 1, 1, mask_bits)
            if not mask:
                raise ctypes.WinError()
            icon_info = ICONINFO(True, 0, 0, mask, color)
            icon = user32.CreateIconIndirect(ctypes.byref(icon_info))
            if not icon:
                raise ctypes.WinError()
            return icon
        finally:
            if color:
                gdi32.DeleteObject(color)
            if mask:
                gdi32.DeleteObject(mask)
            user32.ReleaseDC(None, hdc)

    def _show_menu(self) -> None:
        user32 = ctypes.windll.user32
        menu = user32.CreatePopupMenu()
        items = [(1001, "打开"), (1002, "断开" if self.connection_state == "connected" else "连接"), (1003, "检查更新"), (1004, "退出")]
        for item_id, text in items:
            user32.AppendMenuW(menu, 0, item_id, text)
        point = wt.POINT()
        user32.GetCursorPos(ctypes.byref(point))
        user32.SetForegroundWindow(self._hwnd)
        command = user32.TrackPopupMenu(menu, 0x0100, point.x, point.y, 0, self._hwnd, None)
        ctypes.windll.user32.DestroyMenu(menu)
        actions = {1001: "show", 1002: "toggle", 1003: "check_update", 1004: "exit"}
        if command in actions:
            self.bus.emit("tray", actions[command])


class SettingsDialog:
    def __init__(self, parent, config: dict, on_save):
        self.parent = parent
        self.config = json.loads(json.dumps(config))
        self.on_save = on_save
        self.window = tk.Toplevel(parent)
        self.window.title("HWLAB Node 设置")
        self.window.resizable(False, False)
        self.server_var = tk.StringVar(value=self.config.get("serverUrl"))
        self.node_var = tk.StringVar(value=self.config.get("nodeId"))
        self.auto_connect_var = tk.BooleanVar(value=bool(self.config.get("autoConnect")))
        update = self.config.get("update", {})
        self.update_enabled_var = tk.BooleanVar(value=bool(update.get("enabled", True)))
        self.auto_update_var = tk.BooleanVar(value=bool(update.get("autoApplyWhenIdle", False)))
        self.interval_var = tk.StringVar(value=str(update.get("checkIntervalSeconds", 300)))
        self.channel_var = tk.StringVar(value=str(update.get("channel", "stable")))
        self._build()
        self.window.transient(parent)
        self.window.grab_set()

    def _build(self) -> None:
        frame = ttk.Frame(self.window, padding=12)
        frame.grid(row=0, column=0, sticky="nsew")
        labels = [("服务器 URL", self.server_var), ("节点 ID", self.node_var), ("更新频道", self.channel_var), ("检查间隔秒", self.interval_var)]
        for row, (label, var) in enumerate(labels):
            ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", pady=4)
            ttk.Entry(frame, textvariable=var, width=46).grid(row=row, column=1, sticky="ew", pady=4)
        ttk.Checkbutton(frame, text="启动后自动连接", variable=self.auto_connect_var).grid(row=4, column=0, columnspan=2, sticky="w", pady=4)
        ttk.Checkbutton(frame, text="启用更新检查", variable=self.update_enabled_var).grid(row=5, column=0, columnspan=2, sticky="w", pady=4)
        ttk.Checkbutton(frame, text="自动更新（空闲时）", variable=self.auto_update_var).grid(row=6, column=0, columnspan=2, sticky="w", pady=4)
        buttons = ttk.Frame(frame)
        buttons.grid(row=7, column=0, columnspan=2, sticky="e", pady=(12, 0))
        ttk.Button(buttons, text="保存", command=self._save).grid(row=0, column=0, padx=4)
        ttk.Button(buttons, text="取消", command=self.window.destroy).grid(row=0, column=1, padx=4)

    def _save(self) -> None:
        update = self.config.setdefault("update", {})
        self.config["serverUrl"] = self.server_var.get().strip() or DEFAULT_CONFIG["serverUrl"]
        self.config["nodeId"] = self.node_var.get().strip() or DEFAULT_CONFIG["nodeId"]
        self.config["autoConnect"] = bool(self.auto_connect_var.get())
        update["enabled"] = bool(self.update_enabled_var.get())
        update["autoApplyWhenIdle"] = bool(self.auto_update_var.get())
        update["checkIntervalSeconds"] = safe_int(self.interval_var.get(), 300)
        update["channel"] = self.channel_var.get().strip() or "stable"
        self.on_save(self.config)
        self.window.destroy()


class HwlabNodeApp:
    def __init__(self):
        if tk is None:
            raise RuntimeError("tkinter is not available")
        ensure_dirs()
        self.store = ConfigStore()
        self.config = self.store.config
        self.logger = setup_logger()
        self.bus = UiEventBus()
        install_exception_hooks(self.logger, self.bus)
        self.executor = NodeOpsExecutor(self.config, self.logger)
        self.client: WebSocketNodeClient | None = None
        self.update_manager = UpdateManager(self.config, self.logger)
        self.update_metadata: dict | None = None
        self.connection_state = "manual-disconnected"
        self.update_available = False
        self.root = tk.Tk()
        self.root.title(APP_NAME)
        self.root.geometry("720x460")
        self.status_var = tk.StringVar(value="未连接")
        self.server_var = tk.StringVar(value=self._display_server())
        self.version_var = tk.StringVar(value=f"v{APP_VERSION}")
        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(150, self._poll_events)
        self.root.after(800, self._startup_actions)

    def _build_ui(self) -> None:
        outer = ttk.Frame(self.root, padding=12)
        outer.pack(fill="both", expand=True)
        top = ttk.Frame(outer)
        top.pack(fill="x")
        ttk.Label(top, text=APP_NAME, font=("Segoe UI", 16, "bold")).grid(row=0, column=0, sticky="w")
        ttk.Label(top, textvariable=self.version_var).grid(row=0, column=1, sticky="w", padx=10)
        ttk.Label(top, text="状态:").grid(row=1, column=0, sticky="w", pady=(8, 0))
        ttk.Label(top, textvariable=self.status_var).grid(row=1, column=1, sticky="w", pady=(8, 0))
        ttk.Label(top, text="服务器:").grid(row=2, column=0, sticky="w")
        ttk.Label(top, textvariable=self.server_var).grid(row=2, column=1, sticky="w")
        buttons = ttk.Frame(outer)
        buttons.pack(fill="x", pady=10)
        self.connect_button = ttk.Button(buttons, text="连接", command=self.connect)
        self.connect_button.pack(side="left", padx=(0, 6))
        self.disconnect_button = ttk.Button(buttons, text="断开", command=self.disconnect)
        self.disconnect_button.pack(side="left", padx=6)
        ttk.Button(buttons, text="设置", command=self.show_settings).pack(side="left", padx=6)
        ttk.Button(buttons, text="检查更新", command=self.check_update).pack(side="left", padx=6)
        self.update_button = ttk.Button(buttons, text="更新", command=self.apply_update)
        self.update_button.pack(side="left", padx=6)
        self.update_button.state(["disabled"])
        self.log_text = tk.Text(outer, height=18, wrap="word", state="disabled")
        self.log_text.pack(fill="both", expand=True)
        self._append_log(f"{APP_NAME} v{APP_VERSION} 已启动，配置目录: {CONFIG_DIR}")

    def _startup_actions(self) -> None:
        if self.config.get("autoConnect") is True:
            self.connect()
        if self.config.get("update", {}).get("enabled", True):
            self.root.after(1000, self.check_update)
            self._schedule_update_timer()

    def _display_server(self) -> str:
        parsed = urllib.parse.urlparse(str(self.config.get("serverUrl") or ""))
        return parsed.netloc or str(self.config.get("serverUrl") or "")

    def connect(self) -> None:
        if self.client and self.client.thread and self.client.thread.is_alive():
            return
        self.executor = NodeOpsExecutor(self.config, self.logger)
        self.client = WebSocketNodeClient(self.config, self.executor, self.logger, self.bus)
        self.client.start()

    def disconnect(self) -> None:
        if self.client:
            self.client.stop()
        self._set_connection("manual-disconnected")

    def show_settings(self) -> None:
        SettingsDialog(self.root, self.config, self._save_settings)

    def _save_settings(self, config: dict) -> None:
        self.store.save(config)
        self.config = self.store.config
        self.server_var.set(self._display_server())
        self.update_manager = UpdateManager(self.config, self.logger)
        self._append_log("设置已保存")

    def check_update(self) -> None:
        self._append_log("正在检查更新...")
        threading.Thread(target=self._check_update_worker, daemon=True).start()

    def _check_update_worker(self) -> None:
        result = self.update_manager.check()
        self.bus.emit("update_checked", result)

    def apply_update(self) -> None:
        if not self.update_metadata:
            return
        self._append_log("正在应用更新...")
        threading.Thread(target=self._apply_update_worker, daemon=True).start()

    def _apply_update_worker(self) -> None:
        result = self.update_manager.apply_update(self.update_metadata or {})
        self.bus.emit("update_applied", result)

    def _schedule_update_timer(self) -> None:
        interval = safe_int(self.config.get("update", {}).get("checkIntervalSeconds"), 300)
        self.root.after(interval * 1000, self._scheduled_update_check)

    def _scheduled_update_check(self) -> None:
        if self.config.get("update", {}).get("enabled", True):
            self.check_update()
        self._schedule_update_timer()

    def _poll_events(self) -> None:
        while True:
            try:
                kind, payload = self.bus.queue.get_nowait()
            except queue.Empty:
                break
            if kind == "log":
                self._append_log(str(payload))
            elif kind == "connection":
                self._set_connection(str(payload))
            elif kind == "update_checked":
                self._handle_update_checked(payload if isinstance(payload, dict) else {})
            elif kind == "update_applied":
                self._handle_update_applied(payload if isinstance(payload, dict) else {})
            elif kind == "tray":
                self._handle_tray(str(payload))
        self.root.after(150, self._poll_events)

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

    def _handle_update_checked(self, result: dict) -> None:
        if not result.get("ok", True) and result.get("error"):
            self._append_log(f"更新检查失败: {redact(result.get('error'))}")
            return
        self.update_available = result.get("updateAvailable") is True
        self.update_metadata = result if self.update_available else None
        self.tray.update(update_available=self.update_available)
        if self.update_available:
            self.update_button.state(["!disabled"])
            self._append_log(f"发现新版本: {result.get('latestVersion')}")
            if self.config.get("update", {}).get("autoApplyWhenIdle") is True and self.connection_state in ("manual-disconnected", "error"):
                self.apply_update()
        else:
            self.update_button.state(["disabled"])
            self._append_log("当前已是最新版本")

    def _handle_update_applied(self, result: dict) -> None:
        if result.get("ok"):
            self._append_log("更新已应用，正在重启")
            self.root.after(300, self.quit)
        else:
            self._append_log(f"更新失败: {redact(result.get('error'))}")

    def _handle_tray(self, action: str) -> None:
        if action == "show":
            self.root.deiconify()
            self.root.lift()
        elif action == "toggle":
            if self.connection_state == "connected":
                self.disconnect()
            else:
                self.connect()
        elif action == "check_update":
            self.check_update()
        elif action == "exit":
            self.quit()

    def _append_log(self, text: str) -> None:
        line = f"[{time.strftime('%H:%M:%S')}] {redact(text)}"
        self.logger.info(redact(text))
        self.log_text.configure(state="normal")
        self.log_text.insert("end", line + "\n")
        max_lines = safe_int(self.config.get("logging", {}).get("maxUiLines"), 1000)
        current_lines = int(self.log_text.index("end-1c").split(".")[0])
        if current_lines > max_lines:
            self.log_text.delete("1.0", f"{current_lines - max_lines}.0")
        self.log_text.see("end")
        self.log_text.configure(state="disabled")

    def _hide_to_tray(self) -> None:
        if self.tray.available:
            self.root.withdraw()
            self._append_log("已最小化到托盘")
        else:
            self.root.iconify()
            self._append_log("托盘不可用，已最小化到任务栏")

    def quit(self) -> None:
        if self.client:
            self.client.stop()
        self.tray.remove()
        atomic_write_json(STATE_PATH, {"lastExitAt": utc_now(), "connectionState": self.connection_state, "updateAvailable": self.update_available})
        self.root.destroy()

    def run(self) -> None:
        self.root.mainloop()


def main() -> int:
    ensure_dirs()
    logger = setup_logger()
    install_exception_hooks(logger)
    try:
        app = HwlabNodeApp()
        app.run()
        return 0
    except Exception as exc:
        report_diagnostic("startup.fatal", exc, logger=logger, details={"trace": traceback.format_exc()})
        logger.error("fatal startup error: %s\n%s", redact(exc), traceback.format_exc())
        if messagebox is not None:
            try:
                messagebox.showerror(APP_NAME, f"启动失败: {exc}\n日志: {LOG_PATH}")
            except Exception:
                pass
        return 1


if __name__ == "__main__":
    sys.exit(main())
