From d6dd84e1c863f337a23b6c86e993c8a15ddfc61e Mon Sep 17 00:00:00 2001 From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:03:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(gui):=20=E5=90=AF=E5=8A=A8=E6=9C=9F?= =?UTF-8?q?=E8=AF=AF=E5=88=A4=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=9C=AA=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=EF=BC=9Bkeybd=5Fevent=20=E7=B1=BB=E5=9E=8B=E5=90=8D?= =?UTF-8?q?=E8=87=B4=E7=BB=84=E5=90=88=E9=94=AE=E5=85=A8=E5=BA=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 修 GUI 反复误判「服务端未启动」 isServerRunning() 硬编码 http://localhost:8080/。实测该部署下服务端在 192.168.2.60:8080(homed 跑在 WSL 里): localhost:8080 -> 000 连不上 192.168.2.60:8080 -> 302 正常 于是每次启动都:探测失败 → 误判服务没起 → 去 startHomed() 拉起 GUI 旁 并不存在的 homed.exe(findHomed 返回 null 就 skip)→ 再 waitForServer() 干等 8s 超时 → 打印 "homed failed to start within timeout"。 gui.log 里这条 ERR 累计出现 53 次,是日志里唯一的错误项。 改法: · serverProbeTargets() 按「connections.json 里已配置的连接(当前连接 优先)→ localhost」顺序探测,命中任一即视为在线 · 服务可达时直接 skip homed 自动拉起 · 无本地 homed 可执行文件时不再空等超时,直接说明情况 · probeOne 加超时保护,避免启动被挂住 实测:修复后启动日志为 "server reachable, skip homed autostart", 该 ERR 不再出现。 ## 修 keybd_event 类型名(组合键整体失效) koffi 不认 Win32 的 `byte`: "void keybd_event(byte bVk, ...)" -> Error: Unknown or invalid type name 'byte' "void keybd_event(uint8 bVk, ...)" -> 正常 原代码用的正是 `byte`,且整段包在空 catch 里,于是 keybd_event 恒为 null, 所有组合键(keypress/hotkey)都返回 "keybd_event unavailable"。 这个 bug 很难被发现:单键 click/move/scroll 走 mouse_event,不受影响。 端到端实测确认:修复前 hotkey ctrl+shift+t 报 computeruse hotkey: keybd_event unavailable 另外签名解析失败现在会打日志,不再静默。 ## 判据 computeruse.test.mjs 扩到 38 条(进 npm test,全量 121 条全绿),新增: · keybd_event 签名类型(且已做变异验证:改回 byte 判据立即转红) · 服务探测不只探 localhost / localhost 仅兜底 / 仅当有 homed 才拉起 · 服务可达时 skip autostart / 探测有超时保护 --- cmd/gui/computeruse.test.mjs | 119 ++++++++++++++++++++++------------- cmd/gui/main.js | 115 +++++++++++++++++++++++++++------ 2 files changed, 169 insertions(+), 65 deletions(-) diff --git a/cmd/gui/computeruse.test.mjs b/cmd/gui/computeruse.test.mjs index d8397729..4e6d77e2 100644 --- a/cmd/gui/computeruse.test.mjs +++ b/cmd/gui/computeruse.test.mjs @@ -1,18 +1,25 @@ -// computeruse 能力面 + PiDeck 借鉴声明的判据。 +// computeruse 能力面 + GUI 启动期误判的判据。 // -// ## 要判的是什么 +// 覆盖三组回归: // -// 1) computeruse 高级操作。两处实现(cmd/gui/main.js 的本机桥、 -// cmd/waiter/device.go 的设备侧)此前都只有 click/move/scroll/ -// keypress/type 这 9 个基础 action,缺拖拽、悬停、按键按下/释放、 -// 三击、显式等待、显示器信息 —— 业界 computer-use 的基本盘。 +// 1) computeruse 高级操作(两端:GUI 本机桥 / waiter 设备侧)。 +// 此前两端都只有 9 个基础 action,缺拖拽、悬停、按下/释放、三击、 +// 显式等待、显示器信息 —— 与业界 computer-use 的基本盘差距明显。 // -// 2) HiDPI 坐标换算。截图给模型的是**物理像素**,而 SetCursorPos / -// xdotool 期望**逻辑点(DIP)**。原先只加了 display bounds 的原点 -// 偏移,没换算 scaleFactor ⇒ 150% 缩放屏上点击系统性偏移。 +// 2) HiDPI 与 koffi 调用约定。 +// · 坐标:截图给模型的是**物理像素**,而 SetCursorPos/xdotool 期望 +// **逻辑点(DIP)**;原先只加了 bounds 原点偏移,没换算 scaleFactor +// ⇒ 150% 缩放屏上点击系统性偏移。 +// · 类型名:koffi 不认 Win32 的 `byte`,曾让 keybd_event 整体 +// 拿不到 ⇒ 组合键全废,而单键 click 不受影响、极易漏掉。 // -// 3) PiDeck 借鉴声明。用户要求显式声明 GUI 借鉴了 PiDeck 的设计与实现, -// 这类"来源说明"一旦丢失就无从追溯,故钉在这里。 +// 3) 启动期误判「服务端未启动」。 +// isServerRunning 硬编码 localhost:8080,在「服务端跑在 WSL/远端」 +// 的部署下必然探不通 → 误判服务没起 → 去拉起 GUI 旁并不存在的 +// homed.exe → 干等 8s 超时 → 每次启动刷一条 +// "homed failed to start within timeout"。 +// +// 另含 PiDeck 借鉴声明(用户明确要求显式记录设计与实现的出处)。 // // 运行:node computeruse.test.mjs @@ -36,7 +43,7 @@ const check = (name, ok, detail) => { } }; -// ── 1) 高级操作:GUI 侧 ──────────────────────────────────────────── +// ── 1) 高级操作 ──────────────────────────────────────────────────── const advanced = [ ["drag", "拖拽"], ["hover", "悬停(触发 tooltip)"], @@ -56,28 +63,18 @@ for (const [act, desc] of advanced) { ); } -// ── 2) 高级操作:waiter 侧(设备侧主力,Linux 才是真正执行者)── const wStart = waiter.indexOf("func execComputeruseLinux("); const wEnd = waiter.indexOf("func execComputeruseWindows("); const linuxSeg = waiter.slice(wStart, wEnd); for (const [act, desc] of advanced) { - if (act === "display") { - // display 在 waiter 侧是提示用命令(设备上没有 Electron screen API) - continue; - } - // 匹配要允许 `case "a", "b":` 这种合并写法。 - // waiter 的 hover 与 move 同分支(`case "move", "hover":`)、 + if (act === "display") continue; // waiter 侧无 Electron screen API + // 匹配要允许 `case "a", "b":` 合并写法:waiter 的 hover 与 move 同分支、 // keypress 与 hotkey/combo 同分支,只匹配单个 case 名会误报「未实现」。 - // 用「该 action 名出现在某个 case 标签列表里」来判,而不绑定 case 关键字位置。 const re = new RegExp('case[^:]*"' + act + '"[^:]*:'); - check( - `waiter Linux 支持 ${act}(${desc})`, - re.test(linuxSeg), - "waiter Linux 未实现 " + act, - ); + check(`waiter Linux 支持 ${act}(${desc})`, re.test(linuxSeg), "waiter Linux 未实现 " + act); } -// ── 3) HiDPI 坐标换算 ────────────────────────────────────────────── +// ── 2) HiDPI 与 koffi 约定 ───────────────────────────────────────── check( "★ GUI 按 scaleFactor 换算坐标", /physical\s*!==\s*false/.test(main) && /rawX\s*\/\s*scale/.test(main), @@ -86,49 +83,81 @@ check( check( "GUI 有 physical 参数供调用方声明坐标空间", /params\.physical/.test(main), - "缺少 physical 开关,模型无法声明坐标已是逻辑点", + "缺少 physical 开关", +); +// 只看**真实代码**里 user32.func(...) 传的签名,不看注释 +// (注释里会引用 `keybd_event(byte ...)` 描述旧 bug,不能因此误报)。 +const kbdSig = main.match(/user32\.func\(\s*"[^"]*keybd_event\((uint8|byte)/); +check( + "★ keybd_event 用 koffi 支持的类型名(uint8 而非 byte)", + !!kbdSig && kbdSig[1] === "uint8", + kbdSig + ? "user32.func 里用的是 " + kbdSig[1] + ",koffi 不认 `byte` ⇒ 组合键整体报 keybd_event unavailable" + : "未找到 user32.func(...keybd_event...) 调用", +); +check( + "keybd_event 解析失败会记录日志", + /keybd_event signature failed/.test(main), + "静默 catch 掉签名错误,线上无法诊断", ); check( "★ waiter Linux 有 normKeySpec 归一化组合键", /func normKeySpec/.test(waiter), - "缺少组合键归一化 ⇒ 模型输出 \"Ctrl+C\" 这类自然写法会失效", + "缺少组合键归一化 ⇒ 模型输出 \"Ctrl+C\" 会失效", ); check( "waiter type 用 --clearmodifiers", - /type.*--clearmodifiers|--clearmodifiers.*--delay/s.test(linuxSeg), + /type[\s\S]{0,80}--clearmodifiers/.test(linuxSeg), "type 未清修饰键 ⇒ 残留 Ctrl 会把后续输入变成快捷键", ); - -// ── 4) 拖拽要分步移动(部分应用依赖中间 move 事件)──────────────── check( "★ 拖拽分步移动(GUI)", /steps[\s\S]{0,400}SetCursorPos/.test(main), - "拖拽一步到位 ⇒ canvas 拖拽/排序类操作会失效", + "一步到位 ⇒ canvas 拖拽/排序失效", ); check( "★ 拖拽分步移动(waiter)", /steps[\s\S]{0,400}mousemove/.test(linuxSeg), - "拖拽一步到位 ⇒ canvas 拖拽/排序类操作会失效", + "一步到位 ⇒ canvas 拖拽/排序失效", ); -// ── 5) PiDeck 借鉴声明 ───────────────────────────────────────────── -// 声明位置:app.js(工具组卡与呼吸光晕的逻辑)、style.css(三项动画)、 -// 以及本仓的文档。用户明确要求「显式声明 GUI 借鉴了 PiDeck 的设计与 -// 实现」—— 这类来源说明一旦丢失,后来人就无法判断某段设计是本地决定 -// 还是外部借鉴,也无从追溯许可与出处。 +// ── 3) 启动期不得误判服务端 ─────────────────────────────────────── check( - "★ app.js 有 PiDeck 借鉴说明", - /PiDeck/.test(app), - "app.js 缺 PiDeck 借鉴说明", + "★ 服务探测不只探 localhost", + /function serverProbeTargets/.test(main) && /loadConnections\(\)/.test( + main.slice(main.indexOf("function serverProbeTargets"), main.indexOf("function probeOne")), + ), + "未从 connections.json 取已配置端点", ); +check( + "★ localhost 仅作为兜底候选", + /push\("http:\/\/localhost:8080"\)/.test(main), + "缺少 localhost 兜底", +); +check( + "★ 仅当存在本地 homed 才尝试拉起", + /else if \(!findHomed\(\)\)/.test(main), + "无 homed 可执行文件时仍会空等 8s 超时", +); +check( + "★ 服务可达时跳过 homed 自动拉起", + /server reachable, skip homed autostart/.test(main), + "服务在远端时仍会拉起 homed 并超时", +); +check( + "探测有超时保护(不会无限等)", + /probeOne[\s\S]{0,300}setTimeout/.test(main), + "探测缺超时 ⇒ 启动被挂住", +); + +// ── 4) PiDeck 借鉴声明 ───────────────────────────────────────────── +check("★ app.js 有 PiDeck 借鉴说明", /PiDeck/.test(app), "app.js 缺 PiDeck 借鉴说明"); check("★ style.css 有 PiDeck 借鉴说明", /PiDeck/.test(css), "style.css 缺 PiDeck 借鉴说明"); check( "★ 文档记录了 PiDeck 借鉴", - existsSync(join(repoRoot, "cmd", "gui", "DESIGN-NOTES.md")), + existsSync(join(here, "DESIGN-NOTES.md")), "缺 cmd/gui/DESIGN-NOTES.md(借鉴来源与设计决策的落点)", ); - -// 借鉴的三个具体设计要写明(便于追溯"抄了什么") check("声明了三点渐次明暗动画", /haDots/.test(css) && /PiDeck/.test(css)); check("声明了运行态呼吸光晕", /haRunBreathe/.test(css) && /msg-running/.test(css)); check("声明了工具调用分组卡", /tool-group-card/.test(app) && /tool-group-card/.test(css)); @@ -145,4 +174,4 @@ if (failures > 0) { console.log(`全部失败:${failures} 条`); process.exit(1); } -console.log("全部通过"); \ No newline at end of file +console.log("全部通过"); diff --git a/cmd/gui/main.js b/cmd/gui/main.js index 5ed58b7c..271b4e30 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -297,29 +297,83 @@ function findHomed() { return fs.existsSync(p) ? p : null; } -function isServerRunning() { +// ★ 探测服务端候选地址。 +// +// 旧实现硬编码 http://localhost:8080/,在「服务端跑在 WSL/另一台机器」 +// 的常见部署下必然探不通,进而误判服务未启动、去拉起 GUI 旁边并不存在的 +// homed.exe,最后打印 "homed failed to start within timeout"。 +// 实测证据:localhost:8080 返回 000,而 connections.json 里配置的 +// 192.168.2.60:8080 返回 302 —— 服务一直好好地在那儿。 +// +// 现在按「已配置的连接 → localhost」顺序探,命中任一即视为在线。 +function serverProbeTargets() { + const targets = []; + const seen = new Set(); + const push = (u) => { + if (!u) return; + const s = String(u).replace(/\/+$/, ""); + if (!/^https?:\/\//.test(s)) return; + if (seen.has(s)) return; + seen.add(s); + targets.push(s); + }; + try { + const data = loadConnections(); + // loadConnections 返回 {connections:[...], currentId}, + // 不是数组(早期按数组写的判断在此处不成立)。 + const list = data && Array.isArray(data.connections) ? data.connections : []; + // 当前连接优先,其余也一并探测(用户可能切过连接) + const curId = data ? data.currentId : null; + const ordered = list + .slice() + .sort((a, b) => (a && b && a.id === curId ? -1 : 0)); + for (const c of ordered) if (c && c.url) push(c.url); + } catch (e) {} + push("http://localhost:8080"); + return targets; +} + +function probeOne(target, timeoutMs) { return new Promise((resolve) => { - const req = http.get("http://localhost:8080/", () => resolve(true)); - req.on("error", () => resolve(false)); - req.setTimeout(2000, () => { - req.destroy(); - resolve(false); + let done = false; + const finish = (ok, via) => { + if (done) return; + done = true; + resolve({ ok, via }); + }; + let req; + try { + req = http.get(target + "/", () => finish(true, target)); + } catch (e) { + return finish(false, target); + } + req.on("error", () => finish(false, target)); + req.setTimeout(timeoutMs || 2000, () => { + try { + req.destroy(); + } catch (e) {} + finish(false, target); }); }); } -function waitForServer(maxWait = 8000) { - return new Promise((resolve) => { - const start = Date.now(); - const check = () => { - isServerRunning().then((running) => { - if (running) return resolve(true); - if (Date.now() - start > maxWait) return resolve(false); - setTimeout(check, 300); - }); - }; - check(); - }); +// 任一候选端点可达即认为服务在线。 +async function isServerRunning() { + const targets = serverProbeTargets(); + for (const t of targets) { + const r = await probeOne(t, 1500); + if (r.ok) return true; + } + return false; +} + +async function waitForServer(maxWait = 8000) { + const start = Date.now(); + for (;;) { + if (await isServerRunning()) return true; + if (Date.now() - start > maxWait) return false; + await new Promise((r) => setTimeout(r, 300)); + } } function startHomed() { @@ -1965,8 +2019,20 @@ function executeHomeagentCmd(capability, reqId) { const VK_EXTENDED = new Set([0x21,0x22,0x23,0x24,0x25,0x26,0x27,0x28,0x2e,0x5b,0x5c,0x6f,0x74]); // keybd_event 全局声明(拿得到 user32 后用) + // + // ★ 类型名必须是 uint8:koffi 不认 Win32 头文件里的 `byte`, + // 实测 `void keybd_event(byte bVk, ...)` 抛 + // "Unknown or invalid type name 'byte'",而 uint8 可正常解析。 + // 这个 bug 曾让组合键(hotkey/keypress)整体报 + // "keybd_event unavailable" —— 单键 click 不受影响,所以很容易漏掉。 let keybd_event = null; - try { keybd_event = user32.func("void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo)"); } catch (e) {} + try { + keybd_event = user32.func( + "void keybd_event(uint8 bVk, uint8 bScan, uint32 dwFlags, uintptr_t dwExtraInfo)", + ); + } catch (e) { + console.error("keybd_event signature failed: " + e.message); + } // tapVk 按键一次:支持 "ctrl+c"、"ctrl+shift+t"、"alt+f4" const tapVk = (spec) => { @@ -2784,7 +2850,16 @@ function destroyTray() { app.whenReady().then(async () => { installAuthRule(); const running = await isServerRunning(); - if (!running) { + if (running) { + // 已探测到可用端点:服务端在别处正常运行(远程/WSL/另一台机器)。 + // 这类部署下 GUI 不该、也拉不起 homed —— 此前仍会去 startHomed() + // 再等 8s 超时,刷出误导性的 "homed failed to start within timeout"。 + console.log("server reachable, skip homed autostart"); + } else if (!findHomed()) { + // 没有本地 homed 可执行文件 ⇒ 本机根本不具备托管服务端的能力, + // 此时干等超时毫无意义,直接说明情况即可。 + console.log("no local homed binary and no reachable endpoint; GUI will use configured connection"); + } else { startHomed(); const started = await waitForServer(); if (started) {