fix(gui): 启动期误判服务端未启动;keybd_event 类型名致组合键全废

## 修 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 / 探测有超时保护
This commit is contained in:
JianFeeeee
2026-10-02 13:03:17 +08:00
parent 40119e8116
commit d6dd84e1c8
2 changed files with 169 additions and 65 deletions

View File

@ -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("全部通过");
console.log("全部通过");

View File

@ -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) {