mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
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:
@ -1,18 +1,25 @@
|
|||||||
// computeruse 能力面 + PiDeck 借鉴声明的判据。
|
// computeruse 能力面 + GUI 启动期误判的判据。
|
||||||
//
|
//
|
||||||
// ## 要判的是什么
|
// 覆盖三组回归:
|
||||||
//
|
//
|
||||||
// 1) computeruse 高级操作。两处实现(cmd/gui/main.js 的本机桥、
|
// 1) computeruse 高级操作(两端:GUI 本机桥 / waiter 设备侧)。
|
||||||
// cmd/waiter/device.go 的设备侧)此前都只有 click/move/scroll/
|
// 此前两端都只有 9 个基础 action,缺拖拽、悬停、按下/释放、三击、
|
||||||
// keypress/type 这 9 个基础 action,缺拖拽、悬停、按键按下/释放、
|
// 显式等待、显示器信息 —— 与业界 computer-use 的基本盘差距明显。
|
||||||
// 三击、显式等待、显示器信息 —— 业界 computer-use 的基本盘。
|
|
||||||
//
|
//
|
||||||
// 2) HiDPI 坐标换算。截图给模型的是**物理像素**,而 SetCursorPos /
|
// 2) HiDPI 与 koffi 调用约定。
|
||||||
// xdotool 期望**逻辑点(DIP)**。原先只加了 display bounds 的原点
|
// · 坐标:截图给模型的是**物理像素**,而 SetCursorPos/xdotool 期望
|
||||||
// 偏移,没换算 scaleFactor ⇒ 150% 缩放屏上点击系统性偏移。
|
// **逻辑点(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
|
// 运行:node computeruse.test.mjs
|
||||||
|
|
||||||
@ -36,7 +43,7 @@ const check = (name, ok, detail) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 1) 高级操作:GUI 侧 ────────────────────────────────────────────
|
// ── 1) 高级操作 ────────────────────────────────────────────────────
|
||||||
const advanced = [
|
const advanced = [
|
||||||
["drag", "拖拽"],
|
["drag", "拖拽"],
|
||||||
["hover", "悬停(触发 tooltip)"],
|
["hover", "悬停(触发 tooltip)"],
|
||||||
@ -56,28 +63,18 @@ for (const [act, desc] of advanced) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2) 高级操作:waiter 侧(设备侧主力,Linux 才是真正执行者)──
|
|
||||||
const wStart = waiter.indexOf("func execComputeruseLinux(");
|
const wStart = waiter.indexOf("func execComputeruseLinux(");
|
||||||
const wEnd = waiter.indexOf("func execComputeruseWindows(");
|
const wEnd = waiter.indexOf("func execComputeruseWindows(");
|
||||||
const linuxSeg = waiter.slice(wStart, wEnd);
|
const linuxSeg = waiter.slice(wStart, wEnd);
|
||||||
for (const [act, desc] of advanced) {
|
for (const [act, desc] of advanced) {
|
||||||
if (act === "display") {
|
if (act === "display") continue; // waiter 侧无 Electron screen API
|
||||||
// display 在 waiter 侧是提示用命令(设备上没有 Electron screen API)
|
// 匹配要允许 `case "a", "b":` 合并写法:waiter 的 hover 与 move 同分支、
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// 匹配要允许 `case "a", "b":` 这种合并写法。
|
|
||||||
// waiter 的 hover 与 move 同分支(`case "move", "hover":`)、
|
|
||||||
// keypress 与 hotkey/combo 同分支,只匹配单个 case 名会误报「未实现」。
|
// keypress 与 hotkey/combo 同分支,只匹配单个 case 名会误报「未实现」。
|
||||||
// 用「该 action 名出现在某个 case 标签列表里」来判,而不绑定 case 关键字位置。
|
|
||||||
const re = new RegExp('case[^:]*"' + act + '"[^:]*:');
|
const re = new RegExp('case[^:]*"' + act + '"[^:]*:');
|
||||||
check(
|
check(`waiter Linux 支持 ${act}(${desc})`, re.test(linuxSeg), "waiter Linux 未实现 " + act);
|
||||||
`waiter Linux 支持 ${act}(${desc})`,
|
|
||||||
re.test(linuxSeg),
|
|
||||||
"waiter Linux 未实现 " + act,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3) HiDPI 坐标换算 ──────────────────────────────────────────────
|
// ── 2) HiDPI 与 koffi 约定 ─────────────────────────────────────────
|
||||||
check(
|
check(
|
||||||
"★ GUI 按 scaleFactor 换算坐标",
|
"★ GUI 按 scaleFactor 换算坐标",
|
||||||
/physical\s*!==\s*false/.test(main) && /rawX\s*\/\s*scale/.test(main),
|
/physical\s*!==\s*false/.test(main) && /rawX\s*\/\s*scale/.test(main),
|
||||||
@ -86,49 +83,81 @@ check(
|
|||||||
check(
|
check(
|
||||||
"GUI 有 physical 参数供调用方声明坐标空间",
|
"GUI 有 physical 参数供调用方声明坐标空间",
|
||||||
/params\.physical/.test(main),
|
/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(
|
check(
|
||||||
"★ waiter Linux 有 normKeySpec 归一化组合键",
|
"★ waiter Linux 有 normKeySpec 归一化组合键",
|
||||||
/func normKeySpec/.test(waiter),
|
/func normKeySpec/.test(waiter),
|
||||||
"缺少组合键归一化 ⇒ 模型输出 \"Ctrl+C\" 这类自然写法会失效",
|
"缺少组合键归一化 ⇒ 模型输出 \"Ctrl+C\" 会失效",
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
"waiter type 用 --clearmodifiers",
|
"waiter type 用 --clearmodifiers",
|
||||||
/type.*--clearmodifiers|--clearmodifiers.*--delay/s.test(linuxSeg),
|
/type[\s\S]{0,80}--clearmodifiers/.test(linuxSeg),
|
||||||
"type 未清修饰键 ⇒ 残留 Ctrl 会把后续输入变成快捷键",
|
"type 未清修饰键 ⇒ 残留 Ctrl 会把后续输入变成快捷键",
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 4) 拖拽要分步移动(部分应用依赖中间 move 事件)────────────────
|
|
||||||
check(
|
check(
|
||||||
"★ 拖拽分步移动(GUI)",
|
"★ 拖拽分步移动(GUI)",
|
||||||
/steps[\s\S]{0,400}SetCursorPos/.test(main),
|
/steps[\s\S]{0,400}SetCursorPos/.test(main),
|
||||||
"拖拽一步到位 ⇒ canvas 拖拽/排序类操作会失效",
|
"一步到位 ⇒ canvas 拖拽/排序失效",
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
"★ 拖拽分步移动(waiter)",
|
"★ 拖拽分步移动(waiter)",
|
||||||
/steps[\s\S]{0,400}mousemove/.test(linuxSeg),
|
/steps[\s\S]{0,400}mousemove/.test(linuxSeg),
|
||||||
"拖拽一步到位 ⇒ canvas 拖拽/排序类操作会失效",
|
"一步到位 ⇒ canvas 拖拽/排序失效",
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 5) PiDeck 借鉴声明 ─────────────────────────────────────────────
|
// ── 3) 启动期不得误判服务端 ───────────────────────────────────────
|
||||||
// 声明位置:app.js(工具组卡与呼吸光晕的逻辑)、style.css(三项动画)、
|
|
||||||
// 以及本仓的文档。用户明确要求「显式声明 GUI 借鉴了 PiDeck 的设计与
|
|
||||||
// 实现」—— 这类来源说明一旦丢失,后来人就无法判断某段设计是本地决定
|
|
||||||
// 还是外部借鉴,也无从追溯许可与出处。
|
|
||||||
check(
|
check(
|
||||||
"★ app.js 有 PiDeck 借鉴说明",
|
"★ 服务探测不只探 localhost",
|
||||||
/PiDeck/.test(app),
|
/function serverProbeTargets/.test(main) && /loadConnections\(\)/.test(
|
||||||
"app.js 缺 PiDeck 借鉴说明",
|
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("★ style.css 有 PiDeck 借鉴说明", /PiDeck/.test(css), "style.css 缺 PiDeck 借鉴说明");
|
||||||
check(
|
check(
|
||||||
"★ 文档记录了 PiDeck 借鉴",
|
"★ 文档记录了 PiDeck 借鉴",
|
||||||
existsSync(join(repoRoot, "cmd", "gui", "DESIGN-NOTES.md")),
|
existsSync(join(here, "DESIGN-NOTES.md")),
|
||||||
"缺 cmd/gui/DESIGN-NOTES.md(借鉴来源与设计决策的落点)",
|
"缺 cmd/gui/DESIGN-NOTES.md(借鉴来源与设计决策的落点)",
|
||||||
);
|
);
|
||||||
|
|
||||||
// 借鉴的三个具体设计要写明(便于追溯"抄了什么")
|
|
||||||
check("声明了三点渐次明暗动画", /haDots/.test(css) && /PiDeck/.test(css));
|
check("声明了三点渐次明暗动画", /haDots/.test(css) && /PiDeck/.test(css));
|
||||||
check("声明了运行态呼吸光晕", /haRunBreathe/.test(css) && /msg-running/.test(css));
|
check("声明了运行态呼吸光晕", /haRunBreathe/.test(css) && /msg-running/.test(css));
|
||||||
check("声明了工具调用分组卡", /tool-group-card/.test(app) && /tool-group-card/.test(css));
|
check("声明了工具调用分组卡", /tool-group-card/.test(app) && /tool-group-card/.test(css));
|
||||||
|
|||||||
115
cmd/gui/main.js
115
cmd/gui/main.js
@ -297,29 +297,83 @@ function findHomed() {
|
|||||||
return fs.existsSync(p) ? p : null;
|
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) => {
|
return new Promise((resolve) => {
|
||||||
const req = http.get("http://localhost:8080/", () => resolve(true));
|
let done = false;
|
||||||
req.on("error", () => resolve(false));
|
const finish = (ok, via) => {
|
||||||
req.setTimeout(2000, () => {
|
if (done) return;
|
||||||
req.destroy();
|
done = true;
|
||||||
resolve(false);
|
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) => {
|
async function isServerRunning() {
|
||||||
const start = Date.now();
|
const targets = serverProbeTargets();
|
||||||
const check = () => {
|
for (const t of targets) {
|
||||||
isServerRunning().then((running) => {
|
const r = await probeOne(t, 1500);
|
||||||
if (running) return resolve(true);
|
if (r.ok) return true;
|
||||||
if (Date.now() - start > maxWait) return resolve(false);
|
}
|
||||||
setTimeout(check, 300);
|
return false;
|
||||||
});
|
}
|
||||||
};
|
|
||||||
check();
|
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() {
|
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]);
|
const VK_EXTENDED = new Set([0x21,0x22,0x23,0x24,0x25,0x26,0x27,0x28,0x2e,0x5b,0x5c,0x6f,0x74]);
|
||||||
|
|
||||||
// keybd_event 全局声明(拿得到 user32 后用)
|
// 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;
|
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"
|
// tapVk 按键一次:支持 "ctrl+c"、"ctrl+shift+t"、"alt+f4"
|
||||||
const tapVk = (spec) => {
|
const tapVk = (spec) => {
|
||||||
@ -2784,7 +2850,16 @@ function destroyTray() {
|
|||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
installAuthRule();
|
installAuthRule();
|
||||||
const running = await isServerRunning();
|
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();
|
startHomed();
|
||||||
const started = await waitForServer();
|
const started = await waitForServer();
|
||||||
if (started) {
|
if (started) {
|
||||||
|
|||||||
Reference in New Issue
Block a user