Merge pull request #2107 from pikasTech/fix/2097-hwlab-node-diagnostics

fix(hwlab-node): stabilize tray diagnostics updater
This commit is contained in:
Lyon
2026-06-25 10:25:33 +08:00
committed by GitHub
4 changed files with 357 additions and 46 deletions
+23
View File
@@ -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");
+26
View File
@@ -9,6 +9,7 @@ type HwpodNodeConnection = {
name: string | null;
capabilities: string[];
labels: Record<string, unknown>;
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;
+45 -6
View File
@@ -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 {
+263 -40
View File
@@ -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: