diff --git a/internal/cloud/hwpod-node-ops.test.ts b/internal/cloud/hwpod-node-ops.test.ts index 102b474c..7ac9f682 100644 --- a/internal/cloud/hwpod-node-ops.test.ts +++ b/internal/cloud/hwpod-node-ops.test.ts @@ -21,6 +21,7 @@ test("cloud-api exposes hwpod-node-ops contract on /v1", async () => { assert.ok(payload.hwpod.supportedOps.includes("workspace.ls")); assert.ok(payload.hwpod.supportedOps.includes("workspace.insert-after")); assert.equal(payload.hwlabNode.updateRoute, "/v1/hwlab-node/update"); + assert.equal(payload.hwlabNode.downloadRoute, "/v1/hwlab-node/download/hwlab-node.py"); assert.equal(payload.hwlabNode.updateContractVersion, "hwlab-node-update-v1"); } finally { await close(server); @@ -59,6 +60,28 @@ test("cloud-api exposes hwlab-node Python update metadata", async () => { } }); +test("cloud-api serves bundled hwlab-node Python updater artifact by default", async () => { + const server = createCloudApiServer({ env: { PATH: process.env.PATH, HWLAB_PUBLIC_ENDPOINT: "https://hwlab.pikapython.com" } }); + await listen(server); + try { + const response = await fetch(`${serverUrl(server)}/v1/hwlab-node/update?current=0.1.0&channel=stable&platform=windows`); + const payload = await response.json(); + assert.equal(response.status, 200); + assert.equal(payload.updateAvailable, true); + assert.equal(payload.latestVersion, "0.1.1"); + assert.equal(payload.downloadUrl, "https://hwlab.pikapython.com/v1/hwlab-node/download/hwlab-node.py"); + assert.match(payload.sha256, /^[a-f0-9]{64}$/u); + + const download = await fetch(`${serverUrl(server)}/v1/hwlab-node/download/hwlab-node.py`); + const text = await download.text(); + assert.equal(download.status, 200); + assert.equal(download.headers.get("x-hwlab-node-version"), "0.1.1"); + assert.ok(text.includes('APP_VERSION = "0.1.1"')); + } finally { + await close(server); + } +}); + test("cloud-api discovers preinstalled workspace hwpod-specs without hardcoded device constants", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-spec-discovery-")); const specDir = path.join(root, ".hwlab"); diff --git a/internal/cloud/hwpod-node-ws-registry.ts b/internal/cloud/hwpod-node-ws-registry.ts index e268f4f9..8036c9a3 100644 --- a/internal/cloud/hwpod-node-ws-registry.ts +++ b/internal/cloud/hwpod-node-ws-registry.ts @@ -9,6 +9,7 @@ type HwpodNodeConnection = { name: string | null; capabilities: string[]; labels: Record; + diagnostics: any[]; firstSeenAt: string; lastSeenAt: string; }; @@ -35,6 +36,7 @@ export function createHwpodNodeWsRegistry(options: any = {}) { name: null, capabilities: [], labels: {}, + diagnostics: [], firstSeenAt: now(), lastSeenAt: now() }; @@ -94,6 +96,9 @@ export function createHwpodNodeWsRegistry(options: any = {}) { capabilityCount: connection.capabilities.length, capabilities: connection.capabilities, labels: connection.labels, + diagnosticCount: connection.diagnostics.length, + lastDiagnostic: connection.diagnostics.at(-1) ?? null, + diagnostics: connection.diagnostics.slice(-10), firstSeenAt: connection.firstSeenAt, lastSeenAt: connection.lastSeenAt, ageMs: Math.max(0, clock() - Date.parse(connection.lastSeenAt || observedAt)) @@ -135,6 +140,14 @@ export function createHwpodNodeWsRegistry(options: any = {}) { if (nodeId && connection.nodeId === nodeId) connection.lastSeenAt = now(); return; } + if (messageType === "hwpod-node-diagnostic") { + if (nodeId && connection.nodeId === nodeId) connection.lastSeenAt = now(); + const diagnostic = normalizeDiagnostic(message.diagnostic, now()); + connection.diagnostics.push(diagnostic); + if (connection.diagnostics.length > 50) connection.diagnostics = connection.diagnostics.slice(-50); + sendJson(connection, { type: "ack", requestId: safeText(message.requestId) || "diagnostic", ok: true, message: "diagnostic recorded" }); + return; + } if (messageType === "hwpod-node-ops-result") completeDispatch(message); } @@ -193,6 +206,19 @@ function safeNodeId(value: unknown) { return /^[A-Za-z0-9._:-]{1,128}$/u.test(text) ? text : ""; } +function normalizeDiagnostic(value: any, observedAt: string) { + const input = value && typeof value === "object" && !Array.isArray(value) ? value : {}; + return { + seq: positiveInteger(input.seq, 0), + level: safeText(input.level) || "ERROR", + source: safeText(input.source) || "unknown", + message: safeText(input.message).slice(0, 2000), + details: input.details && typeof input.details === "object" && !Array.isArray(input.details) ? input.details : {}, + observedAt: safeText(input.observedAt) || observedAt, + receivedAt: observedAt + }; +} + function positiveInteger(value: unknown, fallback: number) { const numberValue = Number(value); return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : fallback; diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 4dfecc0f..f18d13e0 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -3,8 +3,8 @@ * 职责: Cloud API REST/SSE route dispatcher。Workbench 新资源路由应委托 read model / compat wrapper,不在 dispatcher 内写业务事实。 */ import { createServer } from "node:http"; -import { randomUUID } from "node:crypto"; -import { copyFileSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs"; +import { createHash, randomUUID } from "node:crypto"; +import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, symlinkSync } from "node:fs"; import path from "node:path"; import { @@ -625,6 +625,7 @@ async function handleRestAdapter(request, response, url, options) { }, hwlabNode: { updateRoute: "/v1/hwlab-node/update", + downloadRoute: "/v1/hwlab-node/download/hwlab-node.py", updateContractVersion: "hwlab-node-update-v1", defaultUpdateIntervalSeconds: 300 }, @@ -638,6 +639,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/hwlab-node/download/hwlab-node.py") { + handleHwlabNodeDownloadHttp(request, response, options); + return; + } + if (url.pathname === "/v1/auth/session" || url.pathname === "/v1/auth/login" || url.pathname === "/v1/auth/logout" || url.pathname === "/v1/auth/register") { const authUrl = new URL(url.href); authUrl.pathname = url.pathname.replace(/^\/v1\/auth/u, "/auth"); @@ -968,10 +974,11 @@ function handleHwlabNodeUpdateHttp(request, response, url, options) { const currentVersion = cleanText(url.searchParams.get("current") || url.searchParams.get("version")); const channel = cleanText(url.searchParams.get("channel")) || "stable"; const platform = cleanText(url.searchParams.get("platform")) || "unknown"; - const latestVersion = cleanText(env.HWLAB_NODE_PY_LATEST_VERSION) || "0.1.0"; + const bundled = hwlabNodeBundledMetadata(env); + const latestVersion = cleanText(env.HWLAB_NODE_PY_LATEST_VERSION) || bundled.version || "0.1.0"; const releaseNotesUrl = cleanText(env.HWLAB_NODE_PY_RELEASE_NOTES_URL) || null; const downloadUrl = hwlabNodeDownloadUrl(env); - const sha256 = cleanText(env.HWLAB_NODE_PY_SHA256) || null; + const sha256 = cleanText(env.HWLAB_NODE_PY_SHA256) || bundled.sha256 || null; const newer = currentVersion ? compareVersionStrings(latestVersion, currentVersion) > 0 : Boolean(latestVersion); const updateAvailable = Boolean(downloadUrl && newer); sendJson(response, 200, { @@ -994,11 +1001,43 @@ function handleHwlabNodeUpdateHttp(request, response, url, options) { }); } +function handleHwlabNodeDownloadHttp(request, response, options) { + if (request.method !== "GET") { + sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "GET required." } }); + return; + } + const bundled = hwlabNodeBundledMetadata(options.env ?? process.env); + if (!bundled.content) { + sendJson(response, 404, { ok: false, error: { code: "hwlab_node_bundle_missing", message: "bundled hwlab-node.py is not available" } }); + return; + } + const bytes = Buffer.from(bundled.content, "utf8"); + response.writeHead(200, { + "content-type": "text/x-python; charset=utf-8", + "cache-control": "no-store", + "x-hwlab-node-version": bundled.version || "unknown", + "x-hwlab-node-sha256": bundled.sha256 || "", + "content-length": String(bytes.length) + }); + response.end(bytes); +} + +function hwlabNodeBundledMetadata(env) { + const bundlePath = cleanText(env.HWLAB_NODE_PY_BUNDLE_PATH) || path.resolve(process.cwd(), "tools/hwlab-node.py"); + try { + const content = readFileSync(bundlePath, "utf8"); + const version = cleanText(content.match(/APP_VERSION\s*=\s*["']([^"']+)["']/u)?.[1]); + const sha256 = createHash("sha256").update(content, "utf8").digest("hex"); + return { path: bundlePath, content, version, sha256 }; + } catch { + return { path: bundlePath, content: "", version: "", sha256: "" }; + } +} + function hwlabNodeDownloadUrl(env) { const explicit = cleanText(env.HWLAB_NODE_PY_DOWNLOAD_URL); if (explicit) return explicit; - const downloadPath = cleanText(env.HWLAB_NODE_PY_DOWNLOAD_PATH); - if (!downloadPath) return null; + const downloadPath = cleanText(env.HWLAB_NODE_PY_DOWNLOAD_PATH) || "/v1/hwlab-node/download/hwlab-node.py"; if (/^https?:\/\//iu.test(downloadPath)) return downloadPath; const publicEndpoint = cleanText(env.HWLAB_PUBLIC_ENDPOINT) || "https://hwlab.pikapython.com"; try { diff --git a/tools/hwlab-node.py b/tools/hwlab-node.py index f79b570a..259d6816 100644 --- a/tools/hwlab-node.py +++ b/tools/hwlab-node.py @@ -38,7 +38,9 @@ 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_LRESULT = getattr(wt, "LRESULT", ctypes.c_ssize_t) +WIN_WPARAM = ctypes.c_size_t +WIN_LPARAM = ctypes.c_ssize_t +WIN_LRESULT = ctypes.c_ssize_t try: import tkinter as tk @@ -49,7 +51,7 @@ except Exception: # pragma: no cover - import failure is reported in main. messagebox = None -APP_VERSION = "0.1.0" +APP_VERSION = "0.1.1" NODE_VERSION = f"{APP_VERSION}-python-gui" CONTRACT_VERSION = "hwpod-node-ops-v1" APP_NAME = "HWLAB Node" @@ -177,6 +179,113 @@ def setup_logger() -> logging.Logger: 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() @@ -230,6 +339,7 @@ class NodeOpsExecutor: "node.health", "node.version", "node.inventory", + "node.diagnostics", "workspace.ls", "workspace.cat", "workspace.rg", @@ -262,11 +372,13 @@ class NodeOpsExecutor: 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()}) + 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": @@ -326,6 +438,17 @@ class NodeOpsExecutor: root = self._workspace_root(args) return {"workspaceRoot": str(root), "workspaceExists": root.exists(), "workspaceIsDirectory": root.is_dir(), "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 ".") entries = [] @@ -535,6 +658,7 @@ class WebSocketNodeClient: self.sock: socket.socket | None = None self.manual_stop = False self.connected = False + self.diagnostic_cursor = 0 def start(self) -> None: if self.thread and self.thread.is_alive(): @@ -576,12 +700,16 @@ class WebSocketNodeClient: self.sock = ssl.create_default_context().wrap_socket(raw, server_hostname=host) if parsed.scheme == "wss" else raw self._handshake(parsed, host, port) 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(), "labels": {"platform": sys.platform, "hostname": socket.gethostname(), "version": NODE_VERSION, "startedAt": utc_now()}}) + self._send_json({"type": "register", "nodeId": self.executor.node_id, "name": self.executor.node_id, "capabilities": self.executor.capabilities(), "labels": {"platform": sys.platform, "hostname": socket.gethostname(), "version": NODE_VERSION, "startedAt": utc_now(), "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: @@ -643,6 +771,11 @@ class WebSocketNodeClient: result = {"ok": False, "status": "failed", "results": [], "blocker": {"code": "hwpod_node_ws_run_failed", "layer": "hwpod-node", "retryable": True, "summary": str(exc)}} self._send_json({"type": "hwpod-node-ops-result", "nodeId": self.executor.node_id, "requestId": request_id, "result": result, "observedAt": utc_now()}) + 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) @@ -766,6 +899,7 @@ class TrayIcon: 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": @@ -783,15 +917,21 @@ class TrayIcon: try: self._modify_icon() except Exception as exc: - self.logger.warning("tray update failed: %s", 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: - ctypes.windll.shell32.Shell_NotifyIconW(0x00000002, ctypes.byref(self._notify_data)) - except Exception: - pass + 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: @@ -805,26 +945,34 @@ class TrayIcon: user32.TranslateMessage(ctypes.byref(msg)) user32.DispatchMessageW(ctypes.byref(msg)) except Exception as exc: - self.logger.warning("tray init failed: %s", 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, wt.WPARAM, wt.LPARAM) + wndproc_type = ctypes.WINFUNCTYPE(WIN_LRESULT, wt.HWND, wt.UINT, WIN_WPARAM, WIN_LPARAM) def wndproc(hwnd, msg, wparam, lparam): - if msg == 0x0400 + 20: - if lparam == 0x0203: - self.bus.emit("tray", "show") - elif lparam == 0x0205: - self._show_menu() - return 0 - if msg == 0x0010: - self.remove() - user32.PostQuitMessage(0) - return 0 - return user32.DefWindowProcW(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) @@ -835,24 +983,47 @@ class TrayIcon: 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() - ctypes.windll.shell32.Shell_NotifyIconW(0x00000000, ctypes.byref(self._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 - old_icon = self._icon self._notify_data.hIcon = self._make_icon() self._icon = self._notify_data.hIcon self._notify_data.szTip = self._tooltip() - ctypes.windll.shell32.Shell_NotifyIconW(0x00000001, ctypes.byref(self._notify_data)) - if old_icon: - try: - ctypes.windll.user32.DestroyIcon(old_icon) - except Exception: - pass + 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): @@ -879,13 +1050,39 @@ class TrayIcon: 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: - return self._make_color_icon() - except Exception: + 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 = { @@ -923,11 +1120,21 @@ class TrayIcon: 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) @@ -936,15 +1143,28 @@ class TrayIcon: bmi.bmiHeader.biPlanes = 1 bmi.bmiHeader.biBitCount = 32 bmi.bmiHeader.biCompression = 0 - color = gdi32.CreateDIBSection(hdc, ctypes.byref(bmi), 0, ctypes.byref(bits), None, 0) - ctypes.memmove(bits, bytes(pixels), len(pixels)) - mask = gdi32.CreateBitmap(size, size, 1, 1, (ctypes.c_ubyte * ((size * size) // 8))()) - icon_info = ICONINFO(True, 0, 0, mask, color) - icon = user32.CreateIconIndirect(ctypes.byref(icon_info)) - gdi32.DeleteObject(color) - gdi32.DeleteObject(mask) - user32.ReleaseDC(None, hdc) - return icon + 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 @@ -1019,6 +1239,7 @@ class HwlabNodeApp: 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) @@ -1219,11 +1440,13 @@ class HwlabNodeApp: 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: