Merge pull request #2107 from pikasTech/fix/2097-hwlab-node-diagnostics
fix(hwlab-node): stabilize tray diagnostics updater
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user