diff --git a/internal/cloud/hwpod-node-ops.test.ts b/internal/cloud/hwpod-node-ops.test.ts index 52a28844..102b474c 100644 --- a/internal/cloud/hwpod-node-ops.test.ts +++ b/internal/cloud/hwpod-node-ops.test.ts @@ -20,6 +20,40 @@ test("cloud-api exposes hwpod-node-ops contract on /v1", async () => { assert.equal(payload.hwpod.specDiscoveryRoute, "/v1/hwpod/specs"); 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.updateContractVersion, "hwlab-node-update-v1"); + } finally { + await close(server); + } +}); + +test("cloud-api exposes hwlab-node Python update metadata", async () => { + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + HWLAB_PUBLIC_ENDPOINT: "https://hwlab.pikapython.com", + HWLAB_NODE_PY_LATEST_VERSION: "0.1.1", + HWLAB_NODE_PY_DOWNLOAD_PATH: "/downloads/hwlab-node.py", + HWLAB_NODE_PY_SHA256: "abc123" + } + }); + 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.ok, true); + assert.equal(payload.contractVersion, "hwlab-node-update-v1"); + assert.equal(payload.updateAvailable, true); + assert.equal(payload.latestVersion, "0.1.1"); + assert.equal(payload.downloadUrl, "https://hwlab.pikapython.com/downloads/hwlab-node.py"); + assert.equal(payload.sha256, "abc123"); + + const currentResponse = await fetch(`${serverUrl(server)}/v1/hwlab-node/update?current=0.1.1&channel=stable&platform=windows`); + const currentPayload = await currentResponse.json(); + assert.equal(currentResponse.status, 200); + assert.equal(currentPayload.updateAvailable, false); + assert.equal(currentPayload.downloadUrl, null); } finally { await close(server); } diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 5dd9a0b5..4dfecc0f 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -623,11 +623,21 @@ async function handleRestAdapter(request, response, url, options) { nodeRole: "thin-hwpod-node-executor", supportedOps: Array.from(HWPOD_NODE_OPS) }, + hwlabNode: { + updateRoute: "/v1/hwlab-node/update", + updateContractVersion: "hwlab-node-update-v1", + defaultUpdateIntervalSeconds: 300 + }, m3IoControl: describeM3IoControl(options) }); return; } + if (url.pathname === "/v1/hwlab-node/update") { + handleHwlabNodeUpdateHttp(request, response, url, 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"); @@ -949,6 +959,80 @@ async function handleHwpodNodeOpsHttp(request, response, options) { } } +function handleHwlabNodeUpdateHttp(request, response, url, options) { + if (request.method !== "GET") { + sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "GET required." } }); + return; + } + const env = options.env ?? process.env; + 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 releaseNotesUrl = cleanText(env.HWLAB_NODE_PY_RELEASE_NOTES_URL) || null; + const downloadUrl = hwlabNodeDownloadUrl(env); + const sha256 = cleanText(env.HWLAB_NODE_PY_SHA256) || null; + const newer = currentVersion ? compareVersionStrings(latestVersion, currentVersion) > 0 : Boolean(latestVersion); + const updateAvailable = Boolean(downloadUrl && newer); + sendJson(response, 200, { + ok: true, + contractVersion: "hwlab-node-update-v1", + serviceId: "hwlab-node", + route: "/v1/hwlab-node/update", + channel, + platform, + currentVersion: currentVersion || null, + latestVersion, + updateAvailable, + downloadUrl: updateAvailable ? downloadUrl : null, + sha256: updateAvailable ? sha256 : null, + releaseNotesUrl, + manualDefault: true, + autoApplyDefault: false, + checkIntervalSeconds: parsePositiveInteger(env.HWLAB_NODE_PY_UPDATE_INTERVAL_SECONDS, 300), + observedAt: new Date().toISOString() + }); +} + +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; + if (/^https?:\/\//iu.test(downloadPath)) return downloadPath; + const publicEndpoint = cleanText(env.HWLAB_PUBLIC_ENDPOINT) || "https://hwlab.pikapython.com"; + try { + return new URL(downloadPath, publicEndpoint.endsWith("/") ? publicEndpoint : `${publicEndpoint}/`).toString(); + } catch { + return null; + } +} + +function compareVersionStrings(left, right) { + const a = versionParts(left); + const b = versionParts(right); + for (let index = 0; index < Math.max(a.length, b.length, 3); index += 1) { + const delta = (a[index] ?? 0) - (b[index] ?? 0); + if (delta !== 0) return delta > 0 ? 1 : -1; + } + return 0; +} + +function versionParts(value) { + return String(value ?? "") + .trim() + .replace(/^v/iu, "") + .split(/[.+-]/u) + .slice(0, 4) + .map((part) => parseInt(part, 10)) + .map((part) => (Number.isFinite(part) && part >= 0 ? part : 0)); +} + +function cleanText(value) { + const text = String(value ?? "").trim(); + return text || ""; +} + async function handleHwpodSpecDiscoveryHttp(request, response, url, options) { const probe = truthyFlag(url.searchParams.get("probe")); const observedAt = new Date().toISOString(); @@ -1041,7 +1125,7 @@ async function forwardHwpodNodeOpsPlan(targetUrl, plan, requestMeta, options) { }); } if (target.ok && target.nodeId && target.nodeId !== plan.nodeId) { - return directHwpodNodeBlocked(plan, "hwpod_node_not_connected", `${plan.nodeId} is not connected via WebSocket and HWLAB_HWPOD_NODE_OPS_URL points to ${target.nodeId}`, { + return directHwpodNodeBlocked(plan, "hwpod_node_id_mismatch", `HWLAB_HWPOD_NODE_OPS_URL points to ${target.nodeId}, not requested node ${plan.nodeId}`, { requestedNodeId: plan.nodeId, targetNodeId: target.nodeId, targetUrl: redactNodeOpsUrl(targetUrl), diff --git a/tools/hwlab-node.py b/tools/hwlab-node.py new file mode 100644 index 00000000..f79b570a --- /dev/null +++ b/tools/hwlab-node.py @@ -0,0 +1,1237 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Single-file HWLAB node GUI for D601/HWPOD. + +The user-facing entrypoint intentionally accepts no command line arguments and +does not require HWLAB_* environment variables. All durable configuration lives +under ~/.hwlab/ and is managed by the settings dialog. +""" + +from __future__ import annotations + +import base64 +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_LRESULT = getattr(wt, "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.0" +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" +LOG_PATH = LOG_DIR / "hwlab-node.log" + +DEFAULT_CONFIG = { + "schemaVersion": 1, + "serverUrl": "https://hwlab.pikapython.com", + "nodeId": "node-d601-f103-v2", + "autoConnect": False, + "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 "") + "", 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 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 + + @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", + "workspace.ls", + "workspace.cat", + "workspace.rg", + "workspace.write", + "workspace.replace", + "workspace.insert-after", + "cmd.run", + "debug.build", + "debug.download", + "debug.reset", + "io.uart.read", + ] + + 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()}) + 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 == "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.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)) + 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 os.getcwd() + return Path(str(value)).expanduser() + + 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 candidate + return 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(), "platform": sys.platform, "hostname": socket.gethostname()} + + def _workspace_ls(self, args: dict) -> dict: + target = self._resolve_workspace_path(args, args.get("path") or ".") + entries = [] + for child in target.iterdir(): + entries.append({"name": child.name, "type": "dir" if child.is_dir() else "file" if child.is_file() else "other"}) + return {"path": str(target), "entries": sorted(entries, key=lambda item: item["name"].lower())} + + 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 + return {"path": str(target), "content": data[:max_bytes].decode("utf-8", errors="replace"), "truncated": truncated} + + 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} + return self._spawn_output([str(command), *[str(item) for item in argv]], self._workspace_root(args), safe_int(args.get("timeoutMs"), default_timeout_ms)) + + 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_read(self, args: dict) -> dict: + 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.get("data"), dict) else {} + active_port = str(data.get("port") or "") + active_baud = data.get("baudRate") + 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: + ports = self._spawn_output([*command_base, "ports"], serial_dir, timeout_ms) + return {"ok": False, "blockerCode": "hwpod_uart_monitor_not_active", "summary": f"io.uart.read requires serial-monitor to be active 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, "ports": self._parse_json(ports.get("stdout")) or ports}}} + 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) + return {"ok": True, "bindingSource": "serial-monitor-cli", "serialMonitorDir": str(serial_dir), "requestedPort": port or None, "resolvedPort": active_port or port or None, "baudRate": baud or active_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": text[:max_bytes], "truncated": len(text) > max_bytes or fetch_body.get("truncated") is True, "monitorStatus": status_body, "sourceFile": fetch_body.get("sourceFile")} + + 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 + + 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) + raw.settimeout(1.0) + 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()}}) + last_heartbeat = 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() + 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 + request = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n" + ) + 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 _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.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") + try: + result = self.executor.execute(message.get("plan") or {}) + 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)}} + self._send_json({"type": "hwpod-node-ops-result", "nodeId": self.executor.node_id, "requestId": request_id, "result": result, "observedAt": utc_now()}) + + 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 + + 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: + self.logger.warning("tray update failed: %s", 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 + + 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: + self.logger.warning("tray init failed: %s", exc) + self._ready.set() + + def _create_window(self) -> None: + user32 = ctypes.windll.user32 + hinst = ctypes.windll.kernel32.GetModuleHandleW(None) + wndproc_type = ctypes.WINFUNCTYPE(WIN_LRESULT, wt.HWND, wt.UINT, wt.WPARAM, wt.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) + + 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) + + def _add_icon(self) -> None: + self._notify_data = self._make_notify_data() + ctypes.windll.shell32.Shell_NotifyIconW(0x00000000, ctypes.byref(self._notify_data)) + + 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 + + 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): + try: + return self._make_color_icon() + except Exception: + user32 = ctypes.windll.user32 + user32.LoadIconW.restype = WIN_HICON + return user32.LoadIconW(None, ctypes.c_void_p(32512)) + + 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.restype = wt.HDC + user32.CreateIconIndirect.restype = WIN_HICON + gdi32.CreateDIBSection.restype = WIN_HBITMAP + gdi32.CreateBitmap.restype = WIN_HBITMAP + hdc = user32.GetDC(None) + 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 = 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 + + 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() + 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() + try: + app = HwlabNodeApp() + app.run() + return 0 + except Exception as 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())