diff --git a/GUI-PLAN.md b/GUI-PLAN.md new file mode 100644 index 00000000..2ce0756e --- /dev/null +++ b/GUI-PLAN.md @@ -0,0 +1,291 @@ +# GUI 改造计划与进度 + +> 本文件是 GUI(`cmd/gui`)当前改造的**单一事实来源**:做完了什么、为什么这么做、 +> 哪些结论是实测得到的、哪些还没做完。 +> +> 相关文档: +> - `cmd/gui/DESIGN-NOTES.md` —— 借鉴 PiDeck 的设计与实现 +> - `SERVER-HANDOFF.md` —— 需要服务端同步配合的部分 +> - 判据:`cmd/gui/*.test.mjs`,`npm test` 全绿为准(当前 127 条) + +--- + +## 一、当前状态 + +分支:`feature/gui-starmap-pulse-vendor-align`(不合入 main) + +最近提交: + +| commit | 内容 | +|---|---| +| `02566f3` | 修启动期误判服务端未启动;keybd_event 类型名致组合键全废 | +| `0d8b129` | computeruse 补高级操作(两端 9→18);修 HiDPI 坐标;记录 PiDeck 借鉴 | +| `3059c99` | 加载动画改三点式 + 新增系统通知 | + +判据:`cd cmd/gui && npm test` —— **127 条全绿**(其中 `computeruse.test.mjs` 43 条)。 + +--- + +## 二、已完成 + +### 2.1 computeruse 能力面:9 → 18 个动作 + +此前 GUI 侧(`cmd/gui/main.js`)与 waiter 侧(`cmd/waiter/device.go`)都只有 +9 个基础动作,与业界 computer-use 的基本盘差距明显。 + +GUI 侧现支持 **18 个**(已用脚本从 `main.js` 的 `case "computeruse"` 块精确提取核对): + +``` +click doubleclick rightclick middleclick tripleclick +move hover drag mousedown mouseup +scroll keypress hotkey combo +type wait sleep display +``` + +waiter 侧(Linux)为 **20 个** —— 比 GUI 多 `middle`、`right` 两个 +「button 简写形式」,少 `physical` 相关处理;其余一致。 + +要点: + +- **拖拽分步移动**(默认 12 步,上限 60)。一步到位会让 canvas 拖拽、列表排序、 + 滑动条失效 —— 这类应用依赖中间 `mousemove` 事件。 +- **组合键归一化**(waiter 侧 `normKeySpec`)。旧实现把模型给的 key 字符串原样丢给 + `xdotool key`,于是 `"Ctrl+C"`、`"ctrl + c"`、`"CTRL-C"` **全部失效**。 +- **`type` 加 `--clearmodifiers`**(waiter 侧)。避免残留的 Ctrl/Alt 把后续输入 + 变成快捷键。 +- **scroll 修正**(两端)。旧实现固定滚 `click 4/5`,忽略方向语义也不支持横向。 + +### 2.2 修 HiDPI 坐标错位 ⚠️ 重要,详见第五节 + +截图给模型的是**物理像素**,Win32 坐标 API 在 Electron 里也是物理像素, +**不需要换算**。详见第五节的实测与踩坑记录。 + +### 2.3 修启动期误判「服务端未启动」 + +`isServerRunning()` 原硬编码 `http://localhost:8080/`。实测该部署下: + +``` +localhost:8080 -> 000 连不上 +192.168.2.60:8080 -> 302 正常(homed 跑在 WSL 里) +``` + +于是每次启动都:探测失败 → 误判服务没起 → 拉起 GUI 旁并不存在的 `homed.exe` +→ 干等 8s 超时 → 打印 `homed failed to start within timeout`。 +该 ERR 曾累计出现数十次(实测 40+ 次,日志轮转后计数会变), +且是 `gui.log` 里唯一的错误项。 + +改法:按 `connections.json` 里已配置的连接(当前优先)探测,命中即在线; +服务可达时跳过 homed 自动拉起;无本地 homed 可执行文件时不再空等超时。 + +实测验证:修复后启动日志为 `server reachable, skip homed autostart`,该 ERR 消失。 + +> 这条很可能就是此前「聊天链路有问题」的根因 —— GUI 自以为服务端由自己托管, +> 实际聊天依赖的是 `connections.json` 里的连接。 + +### 2.4 修 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, +所有组合键返回 `keybd_event unavailable`。 + +这个 bug 极难发现:单键 click/move/scroll 走 `mouse_event`,完全不受影响。 +现在签名解析失败会打日志,不再静默。 + +### 2.5 模式 A:agent 独立光标 + 后台注入(**代码已通,效果待验证**) + +见第三节。 + +--- + +## 三、模式 A:agent 独立光标(进行中) + +### 3.1 目标 + +现有 `computeruse` 走 `SetCursorPos` + `mouse_event`,**会抢走用户的真实鼠标**, +agent 一干活用户就没法用电脑。 + +模式 A 的目标:GUI 自绘一个 agent 专属光标(mascot 形象),实际输入通过 +`PostMessage` 直接投递到目标窗口句柄,**用户真实鼠标全程不动**。 + +### 3.2 新增文件 + +| 文件 | 职责 | +|---|---| +| `cmd/gui/agent-cursor.js` | 透明置顶层窗口 + mascot 指针 + 移动/点击脉冲动画;记录上次命中的窗口句柄 | +| `cmd/gui/agent-inject.js` | Windows 后台注入:`WindowFromPoint` 定位、坐标换算、`PostMessage` 投递鼠标/键盘/滚轮/文本 | + +### 3.3 配置 + +`gui-prefs.json` → `deviceBridge.agentCursor.mode`: + +| 值 | 行为 | +|---|---| +| `"overlay"` | **默认**。不动用户鼠标,后台注入 | +| `"real"` | 旧行为,操作真实鼠标 | + +### 3.4 已验证(端到端,8/8 有正确响应) + +用假网关(自实现 WS 服务端)下发 computeruse 命令,GUI 执行并回执: + +``` +[error] agent-overlay: 该坐标下没有窗口(WindowFromPoint 返回空)... +[error] agent-overlay: type 需要先有点击类操作确定目标窗口 +[ok] displays: [{...}] | coordinateSpace: physical-pixel (Win32 与截图像素一致, 无需换算) +[error] agent-overlay: unknown action __nope__ +``` + +**结论:代码路径已接通,错误处理正确 —— 失败时明确报错,不静默回退到真实鼠标。** + +### 3.5 ⚠️ 未完成:真实点击效果未验证 + +上面 7 个动作都报「该坐标下没有窗口」。原因是**当时的执行环境没有可供 +注入的可见目标窗口**(`WindowFromPoint` 对所有坐标返回 null)。 + +**因此:「点击真的落到目标窗口上」这一步尚未被证明。** 需要在有交互桌面的 +会话里跑一个端到端测试: + +> 打开记事本 → agent 点它 → 输入文字 → 截图回读验证 + +同时验证「用户鼠标位置在全过程未变」。 + +### 3.6 设计取舍(明确的边界) + +- **只对「接受窗口消息的程序」可靠。** 原生 Win32 程序(记事本、Excel、Office) + 可靠;Chromium/Electron 自绘界面、多数游戏、DirectX 程序会忽略合成消息。 + 这是 Windows 消息模型的硬限制,**不是实现缺陷**。 +- **不做真实鼠标降级。** 用户既然选择「不抢鼠标」,就不该偷偷改成操作真实鼠标。 + 失败即 `status=error` 并说明原因。 +- **键盘类动作依赖先前的鼠标操作。** `keypress`/`hotkey`/`type` 不带坐标, + 且后台注入不移动真实光标,所以 `GetCursorPos` 拿到的是**用户自己**的 + 光标位置。因此设备端记录「上次鼠标操作命中的 hwnd」,键盘动作投递到那里。 + 从未点过则报错。 + +### 3.7 跨平台状态 + +| 平台 | 状态 | +|---|---| +| Windows | 模式 A 已实现(效果待验证) | +| Linux | waiter 侧仍是 `xdotool`,**会动真鼠标**。未做模式 A | +| macOS | 同上 | + +--- + +## 四、待办 + +### 4.1 优先:验证模式 A 的真实效果 + +- [ ] 端到端测试:打开真实应用 → 点它 → 输入 → 截图回读 +- [ ] 断言全过程用户鼠标位置未变 +- [ ] 判据:把该测试固化进 `npm test`(需能在无桌面 CI 环境跳过) + +### 4.2 服务端配合(详见 `SERVER-HANDOFF.md`) + +- [ ] 放开 `device.go` 的 action 白名单(schema enum + switch default 双重限制) +- [ ] 透传新参数 `tox`/`toy`/`steps`/`dx`/`ms`/`physical` +- [ ] 统一 scroll 方向语义(服务端写「正=向下」,Windows 惯例是正=向上) +- [ ] 把坐标约定写进注释(防重蹈 `/scale` 覆辙) + +> ⚠ 不改白名单,则新增的 11 个动作**根本下发不到设备**。 + +### 4.3 设置页 + +- [ ] `agentCursor.mode` 的 UI(目前只能手改 `gui-prefs.json`) + +### 4.4 可选增强 + +- [ ] mascot 待机动作、常驻屏幕边缘(借鉴桌宠的视觉层) +- [ ] Linux 侧若也要「不抢鼠标」,需 Wayland + uinput 虚拟设备 + (X11 下做不到 —— XTEST 合成的是真实设备事件) + +--- + +## 五、坐标空间:实测结论与踩坑记录 ⚠️ + +**结论:截图给模型的像素、Win32 坐标 API 的坐标,是同一个坐标系 —— 物理像素。 +不要换算。** + +实测证据(Electron 主进程内,192 DPI / 200% 缩放屏): + +``` +Electron screen API (DIP) : 1260 x 840 scaleFactor=2 +GetSystemMetrics : 2520 x 1680 +SetCursorPos(200,200) → GetCursorPos 读回 (200,200) ← 恒等,不缩放 +``` + +原因是 Electron 主进程是 **per-monitor DPI-aware**,此感知下 Win32 坐标不做虚拟化。 + +### 踩坑:曾经加错的 `/scaleFactor` 换算 + +本仓曾在 `main.js` 加过: + +```js +absX = ox + rawX / scaleFactor // ← 错的 +``` + +理由是「SetCursorPos 期望 DIP」—— **那个前提不成立**,DIP 行为只出现在 +DPI-*unaware* 进程里。加了之后反而把原本正确的点击改坏:200% 缩放屏上 +点 (600,400) 会被送到 (400,267),**越靠右下偏得越远**。 + +已改回 `physical ? rawX : rawX * scale`,并在 `computeruse.test.mjs` 加判据 +防止回退(检测 `/scale` 是否回来了)。 + +### 为什么会误判(重要) + +**在普通 node 进程里测同一个函数,读到的是 1260×840**(虚拟化后的值), +会让人以为 Win32 用 DIP。**必须在 Electron 内测** —— 同一份代码, +不同进程的 DPI 感知状态不同,答案也不同。 + +同理,`python`/`go` 等非 DPI-aware 宿主里测同样不可作为依据。 + +--- + +## 六、端到端测试方法(可复用) + +不依赖真实服务端,用自实现的假网关驱动 GUI: + +1. 起一个最小 WS 服务端(自实现 HTTP `Upgrade` 握手 + 帧编解码) +2. 改写 `gui-prefs.json` 指向它,并置 `deviceBridge.authorized = true` +3. 启动 GUI,等 `hello` / `bind` 完成 +4. 下发 `{op:"cmd", cmd_type:"homeagent", command:"computeruse {...}"}` +5. 读取 GUI 回的 `{op:"cmd_result", status, output, error}` + +### 两个踩过的坑 + +- **服务端→客户端的帧不能加掩码。** RFC 6455 只要求客户端→服务端掩码。 + 照抄 GUI 主进程(客户端方向)的 `sendDeviceFrame` 会导致 GUI 解不出。 +- **客户端→服务端的帧是掩码的**,解帧时必须先解掩码。 + +--- + +## 七、koffi 3.x 使用约定(踩坑记录) + +| 事项 | 正确做法 | 错误做法 / 现象 | +|---|---|---| +| 类型名 | `uint8` / `int32_t` / `long` | `byte` → `Unknown or invalid type name` | +| 指针参数 | 传 **Node `Buffer`** | 传普通 JS 对象 → **不写回**,读回仍是原值 | +| 结构体 | `koffi.struct(name, {...})` 后传普通对象 | 重复 `koffi.struct("POINT")` → `Duplicate type name` | +| 字符串 | `Buffer.from(s + "\0", "utf16le")` | `koffi.alloc(str)` 把 str 当**类型名** | + +**指针参数必须用 Buffer** 这条尤其重要:`WindowFromPoint` 若用普通对象, +会拿不到真实 hwnd ⇒ 点击落到错误窗口上。 + +跨进程/跨模块重复声明同名结构体会抛 `Duplicate type name`,需 try/catch 换名注册。 + +--- + +## 八、环境注意事项 + +- 本仓库文件多为 **CRLF**。用 Node 脚本打补丁时先 `replace(/\r\n/g, "\n")` 处理, + 写回时按原行尾还原。 +- `git status` 会把大量文件标成 `M`,但 `git diff --numstat` 显示 `0 0` + —— 那是 CRLF 归一化噪声,不是真实改动。**提交前用 `--numstat` 甄别。** +- 本机没有 Go 工具链(`go: command not found`),`cmd/waiter/` 的改动 + **未经过编译验证**,需在有 Go 环境的机器上 `go build ./cmd/waiter` 确认。 +- 本会话多数为无头/无交互桌面环境,`WindowFromPoint` / 真实光标相关行为 + **无法在此验证**,需在有桌面的会话实测。 diff --git a/SERVER-HANDOFF.md b/SERVER-HANDOFF.md new file mode 100644 index 00000000..0b6de915 --- /dev/null +++ b/SERVER-HANDOFF.md @@ -0,0 +1,294 @@ +# 服务端交接:computeruse 协议对齐 + +> 面向服务端接手人。本文档描述 GUI 侧已完成的改动,以及**服务端必须同步修改**的部分。 +> 修改范围集中在 `internal/plugins/remotedevice/`(`device.go`)。 +> GUI 侧代码与判据见 `cmd/gui/`。 + +--- + +## 一、为什么需要这份文档 + +GUI 侧把 `computeruse` 从 9 个基础动作扩到 **18 个**,并新增了**模式 A(agent 独立光标 + 后台注入)**。 +但服务端 `device.go` 目前对 `action` 做了**两重硬限制**,导致新增能力根本下发不到设备: + +1. **JSON Schema 的 `enum`**(`device.go:129`)—— 7 个值之外直接被 schema 校验拒绝 +2. **`switch` 的 `default` 分支**(`device.go:471`)—— 返回「不支持的操作类型」 + +所以:**即使 GUI 已实现,服务端不改则新功能等于不存在。** + +--- + +## 二、必做项 1:放开 action 白名单 + +### 现状 + +```go +// device.go:129 —— schema enum,只有 7 个 +"enum": []interface{}{"click", "doubleclick", "rightclick", "move", "scroll", "keypress", "type"}, +``` + +```go +// device.go:471 —— default 直接报错 +default: + return nil, fmt.Errorf("不支持的操作类型 %s(可选 click/doubleclick/rightclick/move/scroll/keypress/type)", action) +``` + +### 需要改成 + +GUI 侧实际支持的 18 个 action(判据同源,`cmd/gui/computeruse.test.mjs` 会校验两端一致): + +``` +click doubleclick rightclick middleclick tripleclick +move hover drag mousedown mouseup +scroll keypress hotkey combo +type wait sleep display +``` + +建议在 `device.go` 里提取成单一常量,schema 与 switch 共用,避免两处漂移: + +```go +// computeruseActions 是设备端支持的完整动作集合。 +// ★ 必须与 cmd/gui/main.js 的 computeruse switch 保持一致; +// cmd/gui/computeruse.test.mjs 会校验 GUI 侧包含这些动作。 +var computeruseActions = []string{ + "click", "doubleclick", "rightclick", "middleclick", "tripleclick", + "move", "hover", "drag", "mousedown", "mouseup", + "scroll", "keypress", "hotkey", "combo", "type", + "wait", "sleep", "display", +} +``` + +然后: +- schema 的 `enum` 改为由 `computeruseActions` 生成 +- `switch` 的 `default` 分支的错误信息里列出这份清单 + +### 各动作的语义(供写 tool description 时参考) + +| action | 必需参数 | 说明 | +|---|---|---| +| `click` | `x`,`y` | 可选 `button`(left/right/middle) | +| `doubleclick` | `x`,`y` | | +| `rightclick` | `x`,`y` | 等价 `click` + `button:right` | +| `middleclick` | `x`,`y` | 中键 | +| `tripleclick` | `x`,`y` | 三击选中整行 | +| `move` / `hover` | `x`,`y` | 只移动不点击,用于触发 tooltip | +| `mousedown` / `mouseup` | `x`,`y` | 按下/释放分离,配对可做「按住」 | +| `drag` | `x`,`y`,`tox`,`toy` | 可选 `steps`(默认 12,上限 60) | +| `scroll` | `dy` | 可选 `dx`(横向) | +| `keypress` | `key` | 如 `ctrl+c`、`alt+f4` | +| `hotkey` / `combo` | `key` | 与 keypress 同义 | +| `type` | `text` | | +| `wait` / `sleep` | `ms` | 显式等待,等 UI/动画完成 | +| `display` | — | 返回各显示器 bounds + scaleFactor | + +> ⚠ **`drag` 的分步移动不是可有可无的**:canvas 拖拽、列表排序、滑动条 +> 这类应用依赖中间 `mousemove` 事件,一步到位会失效。所以 `steps` 建议透传。 + +--- + +## 三、必做项 2:透传新参数 + +### 现状 + +`device.go` 的 `switch action`(第 439 行起)只转发这些: + +```go +params["x"], params["y"], params["button"] +params["dy"] +params["key"] +params["text"] +``` + +其余参数**被静默丢弃**,即使 LLM 传了也到不了设备。 + +### 需要补上 + +| 参数 | 哪些动作需要 | 缺失后果 | +|---|---|---| +| `tox` / `toy` | `drag` | 拖拽终点丢失,拖不动 | +| `steps` | `drag` | 退化成一步到位,拖拽排序类操作失效 | +| `dx` | `scroll` | 横向滚动不可用 | +| `ms` / `duration` | `wait` / `sleep` | 等待时长不可控 | +| `button` | `hover`/`mousedown`/`mouseup`/`tripleclick` | 中键/右键按不下 | +| `physical` | 全部坐标类 | 无法声明坐标系(见第五节) | + +建议实现(保持与现有风格一致): + +```go +// 透传辅助:从 args 取整数键,缺省则不写入 params。 +func putInt(params map[string]interface{}, args map[string]interface{}, key string) { + if v, ok := args[key].(float64); ok { + params[key] = int(v) + } +} +``` + +在 `switch` 之后统一补一遍(避免每个 case 重复): + +```go +for _, k := range []string{"tox", "toy", "steps", "dx", "ms", "duration", "physical"} { + putInt(params, args, k) +} +``` + +> ⚠ `physical` 的类型是布尔或字符串都可能,取决于 LLM 怎么填。 +> 建议 schema 里显式声明 `"type": "boolean"`,并在 `putInt` 之外单独处理。 + +--- + +## 四、必做项 3:统一 scroll 方向语义 ⚠️ + +### 现状:两侧约定相反 + +**服务端**(`device.go:130`): + +```go +"dy": map[string]interface{}{"type": "integer", "description": "scroll 滚动量:正=向下,负=向上"}, +``` + +**GUI / Windows 惯例**:正 = 向上。 + +这不是笔误级的小问题——**方向反了会让 agent 在页面上滚到完全相反的位置**, +且因为「能滚」,不会报错,只会静默地做错事,比崩溃更难发现。 + +### 建议 + +统一为 **Windows 惯例:正 = 向上,负 = 向下**(与鼠标滚轮一致,也与 GUI 实现一致)。 +改 schema description: + +```go +"dy": map[string]interface{}{ + "type": "integer", + "description": "scroll 垂直滚动量:正=向上滚,负=向下滚(与鼠标滚轮方向一致)。可选 dx 做横向滚动,正=向右", +}, +``` + +GUI 侧对应实现(`cmd/gui/main.js`)已按此约定,无需改动。 + +> 如果服务端坚持保留「正=向下」,则必须**同步改 GUI**,否则两侧永远对不上。 +> 二选一,但必须写进注释并进判据。 + +--- + +## 五、建议项:把坐标约定写进注释(防重蹈) + +这条不是功能需求,而是**防止后来人再犯同一个错**——我本人就在这一轮犯过。 + +### 事实 + +**截图给模型的像素、Win32 坐标 API 的坐标,是同一个坐标系:物理像素。不要换算。** + +实测证据(Electron 主进程内,192 DPI / 200% 缩放屏): + +``` +Electron screen API (DIP) : 1260 x 840 scaleFactor=2 +GetSystemMetrics : 2520 x 1680 +SetCursorPos(200,200) → GetCursorPos 读回 (200,200) ← 恒等,不缩放 +``` + +原因是 Electron 主进程是 **per-monitor DPI-aware**,此感知下 Win32 坐标不做虚拟化。 + +### 曾经的坑 + +本仓曾在 `cmd/gui/main.js` 加过: + +```go +absX = ox + rawX / scaleFactor // ← 错的 +``` + +理由是「SetCursorPos 期望 DIP」——**那个前提不成立**,DIP 行为只出现在 +DPI-*unaware* 进程里。加了之后反而把原本正确的点击改坏:200% 缩放屏上 +点 (600,400) 会被送到 (400,267),**越靠右下偏得越远**。 + +(易误判之处:在普通 node 进程里测同一个函数,读到的是 1260×840, +那是虚拟化后的值,会把人带偏。必须在 Electron 内测。) + +### 建议 + +在两处加注释: + +1. `device.go` 的 `computeruse` 函数上方 +2. tool schema 里 `x`/`y` 的 description + +```go +// 坐标约定:x/y 为**物理像素**,与 screensee 截图坐标系一致, +// 也与设备端 Win32 坐标一致,**不需要按 scaleFactor 换算**。 +// (设备端 GUI 是 per-monitor DPI-aware,Win32 坐标不做虚拟化。) +// 仅当调用方显式传 physical=false 时,设备端才按 DIP 再乘回缩放比。 +``` + +`cmd/gui/computeruse.test.mjs` 已加判据防止 GUI 侧回退(会检测 `/scale` 是否回来了)。 +**服务端侧目前无对等判据**,建议补一个,锁定 schema 里的 description 不含 +「需换算」之类的误导表述。 + +--- + +## 六、模式 A(agent 独立光标 + 后台注入)需要服务端知道的事 + +### 行为变化 + +GUI 默认启用 `agentCursor.mode = "overlay"`: + +- agent 操作时**不再移动用户的真实鼠标**,用户可同时正常使用电脑 +- GUI 会在目标位置绘制一个 mascot 形象作为 agent 的虚拟光标,并有移动/点击脉冲动画 +- 实际输入通过 `PostMessage` 直接投递到目标窗口句柄 + +配置项(`gui-prefs.json` → `deviceBridge.agentCursor.mode`): + +| 值 | 行为 | +|---|---| +| `"overlay"` | **默认**。不动用户鼠标,后台注入 | +| `"real"` | 旧行为。操作真实鼠标(`SetCursorPos` + `mouse_event`) | + +### 服务端无需改动,但要知道三条限制 + +1. **只对「接受窗口消息的程序」可靠。** + 原生 Win32 程序(记事本、Excel、Office 等)可靠;Chromium/Electron 自绘界面、 + 多数游戏、DirectX 程序会忽略合成消息。这是 Windows 消息模型的硬限制, + 不是实现缺陷。 + +2. **注入失败会明确报错,不静默回退。** + 用户既然选择「不抢鼠标」,就不该偷偷改成操作真实鼠标。所以失败时 + `status=error`,`error` 里说明原因(例如「该坐标下没有窗口」)。 + +3. **键盘类动作需要先有点击类动作。** + `keypress`/`hotkey`/`type` 不带坐标,且后台注入**不移动真实光标**, + 所以 `GetCursorPos` 拿到的是**用户自己**的光标位置(通常不是 agent 要操作的地方)。 + 设备端记录「上一次鼠标操作命中的窗口」,键盘动作投递到那里。 + **若从未点过,键盘动作会报错。** 服务端在 tool description 里说明这一依赖关系, + 可以减少 LLM 调错。 + +--- + +## 七、验收方式 + +GUI 侧判据(服务端改完后跑): + +```bash +cd cmd/gui && npm test +``` + +含 `computeruse.test.mjs`,会校验两端 action 覆盖面的一致性。 + +服务端侧建议补的判据(放在 `internal/plugins/remotedevice/`): + +1. schema `enum` 覆盖全部 18 个 action +2. 每个 action 在 `switch` 里都有对应 case,不落 `default` +3. `tox`/`toy`/`steps`/`dx`/`ms` 能透传到下发 JSON(可用现有 + `binary_test.go:471` 的 computeruse 下发测试作模板,参考它如何断言收到的 JSON) + +--- + +## 八、附:本次 GUI 侧一并修掉的两个 bug(服务端可参考) + +1. **GUI 误判「服务端未启动」**:原实现只探 `localhost:8080`, + 服务端在 WSL/远端时探不通 → 每次启动误判服务没起 → 去拉起 GUI 旁不存在的 + `homed.exe` → 干等 8s 超时。已改为按 `connections.json` 里已配置的连接探测。 + (服务端侧无改动,但这条能解释历史上 GUI 日志里刷屏的 + `homed failed to start within timeout`。) + +2. **koffi 不认 Win32 的 `byte` 类型名**: + `keybd_event(byte bVk, ...)` 会抛 + `Unknown or invalid type name 'byte'`,必须写 `uint8`。 + 曾导致组合键整体返回 `keybd_event unavailable`,而单键 click 不受影响、极易漏掉。 + 与服务端无直接关系,但若服务端将来也走 FFI 调 Win32,注意这一点。 diff --git a/cmd/gui/agent-cursor.js b/cmd/gui/agent-cursor.js new file mode 100644 index 00000000..f909d350 --- /dev/null +++ b/cmd/gui/agent-cursor.js @@ -0,0 +1,241 @@ +// Agent 独立光标(模式 A):GUI 自绘 mascot 指针 + 后台注入,全程不动用户鼠标。 +// +// 目标:agent 操作用户电脑时,不再抢走真实光标 —— 用户可以同时干自己的事。 +// +// 三层结构: +// 1) overlay 窗口:全屏透明置顶层,setIgnoreMouseEvents(true) 让它不吃点击 +// 2) mascot 指针:在这个窗口里画 mascot.webp + 点击脉冲环,做移动/点击动画 +// 3) 后台注入:PostMessage 直接投递到目标窗口 hwnd,不经过系统光标 +// +// 已实测确认的两条硬事实(cmd/gui/probe2 与 probe-inject/electron-probe.js): +// A) 坐标空间:Win32 坐标 = 物理像素 = 截图像素,**不需要换算 scaleFactor** +// (Electron 主进程 per-monitor DPI-aware,Win32 不虚拟化) +// B) koffi 3.x 传指针参数必须用 **Buffer**;用普通 JS 对象不写回 +// (GetCursorPos 传 {x,y} 读回仍是原值,传 Buffer 才拿到真值) +// —— 这条决定了 WindowFromPoint 必须走 Buffer,写错会拿错目标窗口。 +// +// 不做的事(明确的取舍): +// · 不做真实鼠标降级。模式 A 只保证对「接受窗口消息的程序」可靠; +// Chromium/Electron 自绘、游戏、DirectX 会忽略合成消息,这是 +// Windows 消息模型的硬限制,不是实现缺陷。 +// · 不劫持用户鼠标。任何情况下都不调用 SetCursorPos。 + +const path = require("path"); +const fs = require("fs"); +const { BrowserWindow, screen } = require("electron"); + +// ── overlay 与动画状态 ────────────────────────────────────────────── +let cursorWin = null; // 自绘光标窗口 +let cursorVisible = false; +let cursorBusy = false; // 正在执行命令时(用于画"忙碌"光环) +let cursorRaf = null; +let lastHwnd = null; // 上一次鼠标操作命中的目标窗口(键盘动作投递到这里) +const cursorPos = { x: 0, y: 0 }; // 物理像素,屏幕坐标 +const cursorTarget = { x: 0, y: 0 }; +let cursorPulse = 0; // 点击脉冲环 0..1,>0 表示正在播放 +let cursorMotion = 0; // 移动拖尾强度 + +const CURSOR_PAGE = ` + + + +
+
+ +
+
+`; + +// 确保 overlay 窗口存在;dispIdx 决定铺在哪块屏 +function ensureCursorWindow(dispIdx) { + try { + if (cursorWin && !cursorWin.isDestroyed()) return cursorWin; + } catch (e) {} + const displays = screen.getAllDisplays(); + const d = displays[dispIdx] || displays[0] || screen.getPrimaryDisplay(); + const b = d.bounds; + cursorWin = new BrowserWindow({ + x: b.x, + y: b.y, + width: b.width, + height: b.height, + transparent: true, + frame: false, + resizable: false, + movable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + show: false, + hasShadow: false, + enableLargerThanScreen: false, + // ★ 关键:不参与焦点与输入,只作视觉层 + focusable: false, + acceptFirstMouse: true, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + backgroundThrottling: false, + }, + }); + // 鼠标穿透:光标层绝不能挡住用户点击 + try { + cursorWin.setIgnoreMouseEvents(true, { forward: true }); + } catch (e) {} + // 置顶但**不抢焦点**(screen-saver 级会盖住全屏应用,normal 会挡在普通窗口之上) + try { + cursorWin.setAlwaysOnTop(true, "screen-saver"); + } catch (e) {} + try { + cursorWin.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } catch (e) {} + cursorWin.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(CURSOR_PAGE)); + cursorWin.webContents.once("did-finish-load", () => { + // 把 mascot 注入为 data URL:file:// 在 data: 页面里取不到本地文件 + try { + const p = path.join(__dirname, "renderer", "mascot.webp"); + if (fs.existsSync(p)) { + const b64 = fs.readFileSync(p).toString("base64"); + cursorWin.webContents + .executeJavaScript( + "document.getElementById('mascot').src='data:image/webp;base64," + b64 + "'", + ) + .catch(() => {}); + } + } catch (e) {} + try { + cursorWin.showInactive(); + } catch (e) {} + }); + return cursorWin; +} + +function agentCursorEval(js, dispIdx) { + try { + const w = ensureCursorWindow(dispIdx); + if (!w || w.webContents.isLoading()) return null; + w.webContents.executeJavaScript(js).catch(() => {}); + } catch (e) {} + return null; +} + +// 移动自绘光标到屏幕物理坐标(不动真实鼠标) +function agentCursorMove(x, y, dispIdx) { + cursorPos.x = x; + cursorPos.y = y; + agentCursorEval( + "window.__agentCursor&&window.__agentCursor.move(" + + Math.round(x) + + "," + + Math.round(y) + + ",1)", + dispIdx, + ); + showAgentCursor(dispIdx); +} + +// 点击脉冲 +function agentCursorClick(dispIdx) { + agentCursorEval("window.__agentCursor&&window.__agentCursor.click()", dispIdx); +} + +// 忙碌呼吸 +function agentCursorBusy(busy, dispIdx) { + cursorBusy = !!busy; + agentCursorEval("window.__agentCursor&&window.__agentCursor.setBusy(" + (busy ? "1" : "0") + ")", dispIdx); +} + +function showAgentCursor(dispIdx) { + try { + const w = ensureCursorWindow(dispIdx); + cursorVisible = true; + agentCursorEval("window.__agentCursor&&window.__agentCursor.setVisible(1)", dispIdx); + if (!w.isVisible()) w.showInactive(); + } catch (e) {} +} + +function hideAgentCursor() { + cursorVisible = false; + try { + if (cursorWin && !cursorWin.isDestroyed()) { + agentCursorEval("window.__agentCursor&&window.__agentCursor.setVisible(0)", 0); + cursorWin.hide(); + } + } catch (e) {} +} + +module.exports = { + ensureCursorWindow, + agentCursorMove, + agentCursorClick, + agentCursorBusy, + showAgentCursor, + hideAgentCursor, + isVisible: () => cursorVisible, + pos: () => ({ ...cursorPos }), + // 记住最后一次点击命中的窗口句柄。 + // + // 键盘类 action(keypress/hotkey/type)不带坐标,Win32 又必须知道往哪个 + // 窗口发消息 —— 而后台注入**不会移动真实光标**,所以 GetCursorPos 拿到的是 + // **用户自己**的光标位置(很可能根本不在 agent 要操作的地方)。 + // 因此这里显式记录上一次鼠标操作的目标窗口,键盘动作就投递到那里。 + lastHwnd: null, + setLastHwnd(h) { + lastHwnd = h || null; + }, + get busy() { + return cursorBusy; + }, +}; diff --git a/cmd/gui/agent-inject.js b/cmd/gui/agent-inject.js new file mode 100644 index 00000000..36e5eef0 --- /dev/null +++ b/cmd/gui/agent-inject.js @@ -0,0 +1,335 @@ +// Windows 后台输入注入:不经过系统光标,直接把消息投递到目标窗口。 +// +// 为什么不调 SetCursorPos: +// 那样会抢走用户的真实光标,用户在 agent 操作期间无法正常使用电脑。 +// 本模块用 PostMessage 把输入直接送到目标窗口的 hwnd,系统光标纹丝不动。 +// +// 已实测确认(本轮 probe-inject/electron-probe.js): +// PostMessage(EDIT, WM_CHAR, 'H') 后读回控件文本 = "Ha" +// 且 GetCursorPos 前后一致 ⇒ 用户鼠标确实未被移动。 +// +// ★ koffi 3.x 的硬约定(踩过坑,务必遵守): +// 指针参数必须传 **Buffer**。传普通 JS 对象不会写回 —— +// GetCursorPos({x,y}) 读回仍是原值(无效) +// GetCursorPos(Buffer) 读到真实坐标(有效) +// 写错会导致拿错目标窗口 hwnd ⇒ 点击落到别的窗口上。 + +let koffi = null; +let u32 = null; +let cached = null; + +function lib() { + if (cached) return cached; + koffi = require("koffi"); + u32 = koffi.load("user32.dll"); + // ★ koffi 的类型名是**全局注册**的:同一个进程里重复 koffi.struct("POINT", ...) + // 会抛 "Duplicate type name 'POINT'"。main.js / 探针脚本可能已声明过同名结构体, + // 所以这里先试着复用,失败就换个名字注册。 + let POINT; + try { + POINT = koffi.struct("POINT", { x: "long", y: "long" }); + } catch (e) { + POINT = koffi.struct("HA_POINT", { x: "long", y: "long" }); + } + + const f = (sig) => u32.func(sig); + cached = { + POINT, + // 指针参数一律用 void *,调用时传 Buffer + WindowFromPoint: f("void *WindowFromPoint(void *pt)"), + ChildWindowFromPointEx: f( + "void *ChildWindowFromPointEx(void *hwndParent, void *pt, uint flags)", + ), + GetAncestor: f("void *GetAncestor(void *hwnd, uint flags)"), + IsWindow: f("int IsWindow(void *hwnd)"), + IsWindowVisible: f("int IsWindowVisible(void *hwnd)"), + GetWindowThreadProcessId: f("uint GetWindowThreadProcessId(void *hwnd, uint *pid)"), + GetClientRect: f("int GetClientRect(void *hwnd, void *rect)"), + ScreenToClient: f("int ScreenToClient(void *hwnd, void *pt)"), + GetCursorPos: f("bool GetCursorPos(void *pt)"), + PostMessageW: f("intptr_t PostMessageW(void *hwnd, uint msg, uintptr_t w, intptr_t l)"), + SendMessageW: f("intptr_t SendMessageW(void *hwnd, uint msg, uintptr_t w, intptr_t l)"), + // 这些用于"这类窗口吃不吃合成消息"的能力探测 + GetWindowLongPtrW: f("intptr_t GetWindowLongPtrW(void *hwnd, int i)"), + }; + return cached; +} + +// Win32 常量 +const GA_ROOT = 2; +const CWP_SKIPINVISIBLE = 0x0002; +const CWP_SKIPTRANSPARENT = 0x0004; +const GWL_STYLE = -16; +const GWL_EXSTYLE = -20; +const WS_EX_LAYERED = 0x00080000; +const WS_EX_TRANSPARENT = 0x00000020; +const WS_EX_NOACTIVATE = 0x08000000; + +const WM_MOUSEMOVE = 0x0200; +const WM_LBUTTONDOWN = 0x0201; +const WM_LBUTTONUP = 0x0202; +const WM_LBUTTONDBLCLK = 0x0203; +const WM_RBUTTONDOWN = 0x0204; +const WM_RBUTTONUP = 0x0205; +const WM_MBUTTONDOWN = 0x0207; +const WM_MBUTTONUP = 0x0208; +const WM_MOUSEWHEEL = 0x020A; +const WM_MOUSEHWHEEL = 0x020E; +const WM_KEYDOWN = 0x0100; +const WM_KEYUP = 0x0101; +const WM_CHAR = 0x0102; +const WM_SYSKEYDOWN = 0x0104; +const WM_SYSKEYUP = 0x0105; +const MK_LBUTTON = 0x0001; +const MK_RBUTTON = 0x0002; +const MK_MBUTTON = 0x0010; +const MK_CONTROL = 0x0008; +const MK_SHIFT = 0x0004; + +const SM_XVIRTUALSCREEN = 76; +const SM_YVIRTUALSCREEN = 77; +const SM_CXVIRTUALSCREEN = 78; +const SM_CYVIRTUALSCREEN = 79; + +// 虚拟键码(与 main.js 的 tapVk 共用同一张表) +const VK = { + ctrl: 0x11, control: 0x11, alt: 0x12, shift: 0x10, win: 0x5b, meta: 0x5b, super: 0x5b, + enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, + space: 0x20, backspace: 0x08, delete: 0x2e, del: 0x2e, + up: 0x26, down: 0x28, left: 0x25, right: 0x27, + home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22, +}; +const VK_EXTENDED = new Set([0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x2e, 0x5b, 0x5c, 0x6f, 0x74]); +const isExtVK = (vk) => VK_EXTENDED.has(vk); + +function getSystemMetrics() { + const m = u32.func("int GetSystemMetrics(int i)"); + return { + x: m(SM_XVIRTUALSCREEN), + y: m(SM_YVIRTUALSCREEN), + w: m(SM_CXVIRTUALSCREEN), + h: m(SM_CYVIRTUALSCREEN), + }; +} + +function hwndStr(h) { + return h ? "0x" + h.toString(16) : "(null)"; +} + +// 屏幕物理坐标 → 目标窗口 hwnd(含子窗口命中) +// ★ pt 必须是 Buffer +function windowAtPoint(sx, sy) { + const k = lib(); + const pt = Buffer.alloc(8); + pt.writeInt32LE(Math.round(sx), 0); + pt.writeInt32LE(Math.round(sy), 4); + let h = k.WindowFromPoint(pt); + if (!h) return null; + // WindowFromPoint 只返回顶层窗口;对 UI 框架要下钻到真实接收消息的子窗口 + const flags = CWP_SKIPINVISIBLE | CWP_SKIPTRANSPARENT; + let guard = 0; + for (;;) { + const child = k.ChildWindowFromPointEx(h, pt, flags); + if (!child || child === h || guard++ > 8) break; + h = child; + } + return h; +} + +// 屏幕坐标 → 目标窗口客户区坐标(lParam 用的就是客户区坐标) +function toClient(hwnd, sx, sy) { + const k = lib(); + const pt = Buffer.alloc(8); + pt.writeInt32LE(Math.round(sx), 0); + pt.writeInt32LE(Math.round(sy), 4); + k.ScreenToClient(hwnd, pt); + return { x: pt.readInt32LE(0), y: pt.readInt32LE(4) }; +} + +// 窗口是否"看起来"能接收合成消息(启发式判断,不保证) +function acceptsSynthetic(hwnd) { + const k = lib(); + if (!hwnd || !k.IsWindow(hwnd)) return { ok: false, why: "invalid hwnd" }; + if (!k.IsWindowVisible(hwnd)) return { ok: false, why: "window hidden" }; + const ex = k.GetWindowLongPtrW(hwnd, GWL_EXSTYLE); + // WS_EX_TRANSPARENT 的窗口通常不接收命中测试类消息 + if (ex & WS_EX_TRANSPARENT) return { ok: false, why: "WS_EX_TRANSPARENT" }; + return { ok: true, ex }; +} + +function post(hwnd, msg, w, l) { + const k = lib(); + const r = k.PostMessageW(hwnd, msg, w, l); + return r !== 0; +} + +function pack(x, y) { + // lParam: 低 16 位 x,高 16 位 y(signed 16-bit) + return ((y & 0xffff) << 16) | (x & 0xffff); +} + +// ── 鼠标操作 ─────────────────────────────────────────────────────── +const BTN_MSG = { + left: { down: WM_LBUTTONDOWN, up: WM_LBUTTONUP, dbl: WM_LBUTTONDBLCLK, mk: MK_LBUTTON }, + right: { down: WM_RBUTTONDOWN, up: WM_RBUTTONUP, mk: MK_RBUTTON }, + middle: { down: WM_MBUTTONDOWN, up: WM_MBUTTONUP, mk: MK_MBUTTON }, +}; + +function mouseOp(sx, sy, action, opts) { + const hwnd = windowAtPoint(sx, sy); + if (!hwnd) return { ok: false, error: "WindowFromPoint 返回空(该坐标无窗口)" }; + const c = toClient(hwnd, sx, sy); + const lp = pack(c.x, c.y); + const btn = BTN_MSG[(opts && opts.button) || "left"] || BTN_MSG.left; + const post_ = (m, w) => post(hwnd, m, w, lp); + + switch (action) { + case "move": + return { ok: post_(WM_MOUSEMOVE, 0), hwnd, client: c }; + case "click": { + post_(btn.down, btn.mk); + const up = post_(btn.up, 0); + return { ok: up, hwnd, client: c }; + } + case "doubleclick": { + post_(btn.down, btn.mk); + post_(btn.up, 0); + post_(btn.dbl, MK_LBUTTON); + const up = post_(btn.up, 0); + return { ok: up, hwnd, client: c }; + } + case "mousedown": + return { ok: post_(btn.down, btn.mk), hwnd, client: c }; + case "mouseup": + return { ok: post_(btn.up, 0), hwnd, client: c }; + default: + return { ok: false, error: "unsupported mouse action " + action }; + } +} + +function tripleClick(sx, sy) { + const hwnd = windowAtPoint(sx, sy); + if (!hwnd) return { ok: false, error: "WindowFromPoint 返回空" }; + const c = toClient(hwnd, sx, sy); + const lp = pack(c.x, c.y); + post(hwnd, WM_LBUTTONDOWN, MK_LBUTTON, lp); + post(hwnd, WM_LBUTTONUP, 0, lp); + post(hwnd, WM_LBUTTONDBLCLK, MK_LBUTTON, lp); + post(hwnd, WM_LBUTTONUP, 0, lp); + return { ok: true, hwnd, client: c }; +} + +function drag(sx, sy, tx, ty, opts) { + const hwnd = windowAtPoint(sx, sy); + if (!hwnd) return { ok: false, error: "WindowFromPoint 返回空" }; + const btn = BTN_MSG[(opts && opts.button) || "left"] || BTN_MSG.left; + const steps = Math.max(1, Math.min(60, (opts && opts.steps) || 12)); + const c0 = toClient(hwnd, sx, sy); + post(hwnd, btn.down, btn.mk, pack(c0.x, c0.y)); + // 分步移动:部分应用(canvas 拖拽、排序、滑块)依赖中间 move 事件 + for (let i = 1; i <= steps; i++) { + const t = i / steps; + const c = toClient(hwnd, sx + (tx - sx) * t, sy + (ty - sy) * t); + post(hwnd, WM_MOUSEMOVE, btn.mk, pack(c.x, c.y)); + } + const cEnd = toClient(hwnd, tx, ty); + const up = post(hwnd, btn.up, 0, pack(cEnd.x, cEnd.y)); + return { ok: up, hwnd, steps }; +} + +// 滚轮:lParam 用**屏幕**坐标,不是客户区坐标 +function scroll(sx, sy, dy, dx) { + const hwnd = windowAtPoint(sx, sy); + if (!hwnd) return { ok: false, error: "WindowFromPoint 返回空" }; + const lp = pack(Math.round(sx), Math.round(sy)); + let ok = true; + if (dy) ok = post(hwnd, WM_MOUSEWHEEL, deltaWord(Math.round(dy)), lp) && ok; + if (dx) ok = post(hwnd, WM_MOUSEHWHEEL, deltaWord(Math.round(dx)), lp) && ok; + return { ok, hwnd }; +} + +// 把像素量换算成滚轮"格"(WHEEL_DELTA=120) +function deltaWord(v) { + const clicks = Math.round(v / 120) || (v > 0 ? 1 : -1); + // high word 存 delta,low word 存按键状态 + return (clicks << 16) >>> 0; +} + +// ── 键盘操作 ─────────────────────────────────────────────────────── +function keyTap(hwnd, spec) { + const parts = String(spec || "") + .split("+") + .map((x) => x.trim().toLowerCase()) + .filter(Boolean); + if (!parts.length) return { ok: false, error: "empty key" }; + const last = parts[parts.length - 1]; + const mods = parts.slice(0, -1); + const modCodes = []; + for (const m of mods) { + const c = VK[m]; + if (c === undefined) return { ok: false, error: "unknown modifier: " + m }; + modCodes.push(c); + } + let keyCode = VK[last]; + if (keyCode === undefined) { + if (last.length === 1) keyCode = last.toUpperCase().charCodeAt(0); + else return { ok: false, error: "unknown key: " + last }; + } + let ok = true; + for (const c of modCodes) { + ok = post(hwnd, WM_KEYDOWN, c, 0) && ok; + } + ok = post(hwnd, WM_KEYDOWN, keyCode, isExtVK(keyCode) ? 1 : 0) && ok; + ok = post(hwnd, WM_KEYUP, keyCode, isExtVK(keyCode) ? 1 : 0) && ok; + for (const c of modCodes.slice().reverse()) { + ok = post(hwnd, WM_KEYUP, c, 0) && ok; + } + return { ok, mods: modCodes, key: keyCode }; +} + +// 文本注入:走 WM_CHAR,不做输入法合成,因此对原生控件最可靠。 +// 换行/回车单独发 VK_RETURN,否则 WM_CHAR 的 \n 多数控件不认。 +function typeText(hwnd, text) { + const t = String(text || ""); + if (!t) return { ok: false, error: "empty text" }; + let ok = true; + for (const ch of t) { + if (ch === "\n" || ch === "\r") { + ok = post(hwnd, WM_KEYDOWN, VK.enter, 0) && ok; + ok = post(hwnd, WM_KEYUP, VK.enter, 0) && ok; + continue; + } + if (ch === "\t") { + ok = post(hwnd, WM_KEYDOWN, VK.tab, 0) && ok; + ok = post(hwnd, WM_KEYUP, VK.tab, 0) && ok; + continue; + } + ok = post(hwnd, WM_CHAR, ch.codePointAt(0), 0) && ok; + } + return { ok, chars: t.length }; +} + +module.exports = { + available() { + try { + lib(); + return true; + } catch (e) { + return false; + } + }, + windowAtPoint, + toClient, + acceptsSynthetic, + getSystemMetrics, + hwndStr, + // 鼠标 + mouse: mouseOp, + tripleClick, + drag, + scroll, + // 键盘 + keyTap, + typeText, + VK, +}; diff --git a/cmd/gui/computeruse.test.mjs b/cmd/gui/computeruse.test.mjs index 4e6d77e2..6a4e6ca1 100644 --- a/cmd/gui/computeruse.test.mjs +++ b/cmd/gui/computeruse.test.mjs @@ -75,15 +75,36 @@ for (const [act, desc] of advanced) { } // ── 2) HiDPI 与 koffi 约定 ───────────────────────────────────────── +// ★ 坐标空间:Win32 = 物理像素 = 截图像素,**不应再除以 scaleFactor**。 +// +// 实测(Electron 主进程内): +// screen API (DIP) : 1260 x 840 scaleFactor=2 +// GetSystemMetrics : 2520 x 1680 +// SetCursorPos(200,200) -> GetCursorPos 读回 (200,200) ← 恒等,不缩放 +// +// 因为 Electron 主进程是 per-monitor DPI-aware,Win32 坐标不虚拟化。 +// (对照:普通 node 进程里同一函数读到 1260x840,那是 DPI-*unaware* +// 的虚拟化行为,不能拿来推断 Electron 内的行为。) +// +// 本仓曾按「SetCursorPos 期望 DIP」加过 `/ scaleFactor`,那个前提是错的, +// 会把原本正确的点击改坏(150% 屏上点 (600,400) 被送到 (400,267))。 +const kx = main.match(/const absX = Math\.round\([^;]*?physical \? ([^:]+):/); check( - "★ GUI 按 scaleFactor 换算坐标", - /physical\s*!==\s*false/.test(main) && /rawX\s*\/\s*scale/.test(main), - "未做 物理像素→DIP 换算 ⇒ HiDPI 屏上点击系统性偏移", + "★ Win32 坐标按物理像素直接用(不除 scaleFactor)", + !!kx && !/\/\s*scale\b/.test(kx[1]), + kx + ? "absX 仍对物理像素做了 /scale 换算 ⇒ HiDPI 屏上点击偏移" + : "未找到 absX 计算", ); check( - "GUI 有 physical 参数供调用方声明坐标空间", - /params\.physical/.test(main), - "缺少 physical 开关", + "physical:false 时才乘回 scaleFactor(DIP→物理)", + /physical \? rawX : rawX \* scale/.test(main), + "DIP 输入未乘回缩放比", +); +check( + "拖拽终点坐标与主坐标用同一套换算", + /physical \? \(parseFloat\(params\.tox[^\n]*\* scale\)/.test(main), + "drag 终点仍在用旧的 /scale 逻辑,会与起点偏移不一致", ); // 只看**真实代码**里 user32.func(...) 传的签名,不看注释 // (注释里会引用 `keybd_event(byte ...)` 描述旧 bug,不能因此误报)。 @@ -150,7 +171,40 @@ check( "探测缺超时 ⇒ 启动被挂住", ); -// ── 4) PiDeck 借鉴声明 ───────────────────────────────────────────── +// ── 4) 模式 A:agent 独立光标 + 后台注入 ───────────────────────── +// 模式 A 的核心承诺是「不动用户真实鼠标」。这条承诺必须由判据钉住 —— +// 一旦有人图省事改回 SetCursorPos,用户的光标就会被抢,且很难归因。 +const injectSrc = readFileSync(join(here, "agent-inject.js"), "utf8"); +const cursorSrc = readFileSync(join(here, "agent-cursor.js"), "utf8"); +// 去掉整行注释后再查 SetCursorPos —— 注释里会引用这个名字来解释「为什么不用它」 +const stripComments = (s) => s.replace(/^\s*\/\/.*$/gm, ""); +check( + "★ 模式 A 不碰真实鼠标(agent-inject 无 SetCursorPos 调用)", + !/SetCursorPos\(/.test(stripComments(injectSrc)), + "agent-inject 出现 SetCursorPos 调用 ⇒ 会抢用户鼠标", +); +check( + "★ 模式 A 不碰真实鼠标(agent-cursor 无 SetCursorPos 调用)", + !/SetCursorPos\(/.test(stripComments(cursorSrc)), + "agent-cursor 出现 SetCursorPos 调用 ⇒ 会抢用户鼠标", +); +check( + "★ koffi 指针参数用 Buffer(普通对象不写回)", + /Buffer\.alloc\(8\)/.test(injectSrc), + "指针参数未用 Buffer ⇒ WindowFromPoint 拿不到真实 hwnd,点击会落错窗口", +); +check( + "★ overlay 默认开启且可切回 real", + /cursorMode = "overlay"/.test(main) && /cursorMode = "real"/.test(main), + "未提供 overlay/real 切换", +); +check( + "自绘光标层不吃点击(setIgnoreMouseEvents)", + /setIgnoreMouseEvents\(true/.test(cursorSrc), + "光标层会挡住用户点击", +); + +// ── 5) PiDeck 借鉴声明 ───────────────────────────────────────────── check("★ app.js 有 PiDeck 借鉴说明", /PiDeck/.test(app), "app.js 缺 PiDeck 借鉴说明"); check("★ style.css 有 PiDeck 借鉴说明", /PiDeck/.test(css), "style.css 缺 PiDeck 借鉴说明"); check( diff --git a/cmd/gui/main.js b/cmd/gui/main.js index 271b4e30..0aaa3573 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -10,6 +10,8 @@ const { spawn } = require("child_process"); const http = require("http"); const crypto = require("crypto"); const { pathToFileURL } = require("url"); +const agentCursor = require("./agent-cursor"); +const agentInject = require("./agent-inject"); const CONNECTIONS_FILE = path.join(app.getPath("userData"), "connections.json"); const LOG_FILE = path.join(app.getPath("userData"), "gui.log"); @@ -1945,6 +1947,24 @@ function executeHomeagentCmd(capability, reqId) { params.action = t[2] || "click"; } const action = params.action || "click"; + + // ★ 模式 A:agent 独立光标 + 后台注入。 + // + // 取值 "overlay"(不动用户鼠标)/ "real"(旧行为,操作真实鼠标)。 + // 未配置时默认 "overlay" —— 用户明确要求不要抢鼠标。 + let cursorMode = "overlay"; + try { + const _p = loadGuiPrefs(); + const _ac = (_p && _p.deviceBridge && _p.deviceBridge.agentCursor) || {}; + if (_ac.mode === "real") cursorMode = "real"; + } catch (e) {} + const useOverlay = cursorMode === "overlay" && agentInject.available(); + + // 把内部参数提到 action 分支之前共用 + const cuNumber = (v, dflt) => { + const n = parseFloat(v); + return Number.isFinite(n) ? n : dflt; + }; // 跨平台输入模拟:Linux=xdotool / macOS=cliclick(或用osascript) / Windows=PowerShell user32 const os_ = platform === "win32" ? "win32" : platform === "darwin" ? "darwin" : "linux"; let tool = null; // {cmd, args, shell} @@ -1973,24 +1993,167 @@ function executeHomeagentCmd(capability, reqId) { const ox = disp ? (disp.bounds.x || 0) : 0; const oy = disp ? (disp.bounds.y || 0) : 0; - // ★ HiDPI 坐标换算。 + // ★ 坐标空间:Win32 坐标与截图一样,都是**物理像素**,不要换算。 // - // 截图(capturePage / screensee)给模型的是**物理像素**,而 - // SetCursorPos、xdotool、cliclick 期望的都是**逻辑点(DIP)**。 - // 直接拿截图像素去点,在 150% 缩放屏上会系统性偏移 - // (越靠右下偏得越多)—— 这正是 HiDPI 屏上"点击不准"的根因。 + // 实测(Electron 主进程内,cmd/gui/probe2): + // screen API (DIP) : 1260 x 840 scaleFactor=2 + // GetSystemMetrics : 2520 x 1680 ← Win32 用的是物理像素 + // SetCursorPos(1260,840) -> GetCursorPos 读回 (1260,840) + // 而 Electron 读回 DIP (630,420) + // 比值 = 2.000 // - // 旧实现只加了 display bounds 的原点偏移,完全没换算缩放比例。 - // 这里按目标屏的 scaleFactor 换算;若调用方显式声明坐标已是 - // 逻辑点(physical:false),则原样使用。 + // 原因是 Electron 主进程默认 per-monitor DPI-aware:这种感知下 + // Win32 坐标 API 不做虚拟化,直接就是物理像素。 + // + // ⚠ 本仓曾在这一段加过 `/ scaleFactor` 换算,理由是「SetCursorPos + // 期望 DIP」——那个前提是错的(那是 DPI-*unaware* 进程的行为)。 + // 加了之后反而把原本正确的点击改坏:150% 缩放屏上点 (600,400) + // 会被送到 (400,267),越靠右下偏得越远。现已改回直接使用。 + // + // 保留 physical 开关:多数截图工具给物理像素(默认),若调用方 + // 明确声明坐标已是 DIP(physical:false),再乘回 scaleFactor。 const scale = disp && disp.scaleFactor ? disp.scaleFactor : 1; const rawX = parseFloat(params.x) || 0; const rawY = parseFloat(params.y) || 0; - // 多数截图工具返回物理像素,故默认 physical=true。 - // 模型若已按 DIP 推理,可显式传 physical:false 免换算。 const physical = params.physical !== false; - const absX = Math.round(ox + (physical ? rawX / scale : rawX)); - const absY = Math.round(oy + (physical ? rawY / scale : rawY)); + const absX = Math.round(ox + (physical ? rawX : rawX * scale)); + const absY = Math.round(oy + (physical ? rawY : rawY * scale)); + + // === 模式 A:后台注入(PostMessage 到目标窗口),不动用户真实鼠标 === + if (os_ === "win32" && useOverlay) { + const btnName = params.button === "right" ? "right" : params.button === "middle" ? "middle" : "left"; + const beginBusy = () => agentCursor.agentCursorBusy(true, dispIdx); + const endBusy = () => agentCursor.agentCursorBusy(false, dispIdx); + const done = (ok, msg) => { + endBusy(); + sendCmdResult(reqId, baseResult(reqId, ok ? "ok" : "error", msg, ok ? "" : msg)); + }; + const fail = (msg) => done(false, "agent-overlay: " + msg); + // 位置类 action 之外(type/keypress 等)不需要先移动光标 + const needsXY = ["click", "doubleclick", "rightclick", "middleclick", "tripleclick", + "mousedown", "mouseup", "move", "hover", "drag", "scroll"].includes(action); + try { + if (needsXY) { + const h = agentInject.windowAtPoint(absX, absY); + if (!h) { + return fail( + "该坐标下没有窗口(WindowFromPoint 返回空)。" + + "后台注入只能作用于可见窗口,请确认 screensueDisplay 与截图用的屏一致。", + ); + } + // 自绘光标移到目标位置(不动真实鼠标) + agentCursor.agentCursorMove(absX, absY, dispIdx); + } + beginBusy(); + switch (action) { + case "move": + case "hover": { + const r = agentInject.mouse(absX, absY, "move", { button: btnName }); + agentCursor.setLastHwnd(r.hwnd); + return done(r.ok, "agent-overlay " + action + " @ (" + absX + "," + absY + ") hwnd=" + agentInject.hwndStr(r.hwnd)); + } + case "click": { + const r = agentInject.mouse(absX, absY, "click", { button: btnName }); + agentCursor.setLastHwnd(r.hwnd); + agentCursor.agentCursorClick(dispIdx); + return done(r.ok, "agent-overlay click @ (" + absX + "," + absY + ") hwnd=" + agentInject.hwndStr(r.hwnd)); + } + case "rightclick": { + const r = agentInject.mouse(absX, absY, "click", { button: "right" }); + agentCursor.setLastHwnd(r.hwnd); + agentCursor.agentCursorClick(dispIdx); + return done(r.ok, "agent-overlay rightclick @ (" + absX + "," + absY + ") hwnd=" + agentInject.hwndStr(r.hwnd)); + } + case "middleclick": { + const r = agentInject.mouse(absX, absY, "click", { button: "middle" }); + agentCursor.setLastHwnd(r.hwnd); + agentCursor.agentCursorClick(dispIdx); + return done(r.ok, "agent-overlay middleclick @ (" + absX + "," + absY + ") hwnd=" + agentInject.hwndStr(r.hwnd)); + } + case "doubleclick": { + const r = agentInject.mouse(absX, absY, "doubleclick", { button: btnName }); + agentCursor.agentCursorClick(dispIdx); + return done(r.ok, "agent-overlay doubleclick @ (" + absX + "," + absY + ")"); + } + case "tripleclick": { + const r = agentInject.tripleClick(absX, absY); + agentCursor.setLastHwnd(r.hwnd); + agentCursor.agentCursorClick(dispIdx); + return done(r.ok, "agent-overlay tripleclick @ (" + absX + "," + absY + ")"); + } + case "mousedown": { + const r = agentInject.mouse(absX, absY, "mousedown", { button: btnName }); + return done(r.ok, "agent-overlay mousedown(" + btnName + ")"); + } + case "mouseup": { + const r = agentInject.mouse(absX, absY, "mouseup", { button: btnName }); + return done(r.ok, "agent-overlay mouseup(" + btnName + ")"); + } + case "drag": { + const toX = Math.round(ox + (physical ? (parseFloat(params.tox || params.tx) || 0) : (parseFloat(params.tox || params.tx) || 0) * scale)); + const toY = Math.round(oy + (physical ? (parseFloat(params.toy || params.ty) || 0) : (parseFloat(params.toy || params.ty) || 0) * scale)); + agentCursor.agentCursorMove(absX, absY, dispIdx); + const steps = Math.max(1, Math.min(60, parseInt(params.steps || 12, 10) || 12)); + // 拖拽过程中让自绘光标跟着走,视觉上与真实拖拽一致 + const r = agentInject.drag(absX, absY, toX, toY, { button: btnName, steps }); + agentCursor.setLastHwnd(r.hwnd); + agentCursor.agentCursorMove(toX, toY, dispIdx); + agentCursor.agentCursorClick(dispIdx); + return done(r.ok, "agent-overlay drag (" + absX + "," + absY + ") -> (" + toX + "," + toY + ")"); + } + case "scroll": { + const dy = cuNumber(params.dy !== undefined ? params.dy : params.y, 0); + const dx = cuNumber(params.dx !== undefined ? params.dx : params.x, 0); + const r = agentInject.scroll(absX, absY, dy, dx); + return done(r.ok, "agent-overlay scroll dx=" + dx + " dy=" + dy); + } + case "keypress": + case "hotkey": + case "combo": { + const keys = String(params.keys || params.key || params.text || ""); + // 无坐标语义:发给「当前光标所在窗口」,即上次 agent 操作过的窗口 + const h = agentInject.windowAtPoint(absX, absY) || agentCursor.lastHwnd; + if (!h) { + return fail("keypress 需要先有点击类操作确定目标窗口(当前无法确定目标窗口)"); + } + const r = agentInject.keyTap(h, keys); + return done(r.ok, "agent-overlay keypress " + keys + " -> hwnd=" + agentInject.hwndStr(h)); + } + case "type": { + const t = String(params.text || ""); + const h = agentInject.windowAtPoint(absX, absY) || agentCursor.lastHwnd; + if (!h) return fail("type 需要先有点击类操作确定目标窗口"); + const r = agentInject.typeText(h, t); + return done(r.ok, "agent-overlay typed " + r.chars + " chars -> hwnd=" + agentInject.hwndStr(h)); + } + case "wait": + case "sleep": { + const ms = Math.max(0, Math.min(10000, parseInt(params.ms || params.duration || 500, 10) || 500)); + endBusy(); + setTimeout(() => sendCmdResult(reqId, baseResult(reqId, "ok", "agent-overlay waited " + ms + "ms", "")), ms); + return; + } + case "display": { + const info = displays.map((d, i) => ({ + index: i, bounds: d.bounds, scaleFactor: d.scaleFactor, + primary: d.id === screen.getPrimaryDisplay().id, + })); + endBusy(); + sendCmdResult(reqId, baseResult(reqId, "ok", + "displays: " + JSON.stringify(info) + + " | coordinateSpace: physical-pixel (Win32 与截图像素一致, 无需换算)", "")); + return; + } + default: + return fail("unknown action " + action); + } + } catch (e) { + endBusy(); + console.error("[agent-overlay] failed: " + e.message); + sendCmdResult(reqId, baseResult(reqId, "error", "", "agent-overlay: " + e.message)); + return; + } + } // === Windows: 用 koffi 直接调用 user32.dll,不依赖 PowerShell C# 编译 === if (os_ === "win32") { @@ -2119,10 +2282,10 @@ function executeHomeagentCmd(capability, reqId) { case "drag": { // 一步拖拽:从 (x,y) 按下 → 移动到 (tox,toy) → 释放 const toX = Math.round( - ox + (params.physical !== false ? (parseFloat(params.tox || params.tx) || 0) / scale : (parseFloat(params.tox || params.tx) || 0)), + ox + (physical ? (parseFloat(params.tox || params.tx) || 0) : (parseFloat(params.tox || params.tx) || 0) * scale), ); const toY = Math.round( - oy + (params.physical !== false ? (parseFloat(params.toy || params.ty) || 0) / scale : (parseFloat(params.toy || params.ty) || 0)), + oy + (physical ? (parseFloat(params.toy || params.ty) || 0) : (parseFloat(params.toy || params.ty) || 0) * scale), ); SetCursorPos(absX, absY); mouse_event(btnDown, 0, 0, 0, 0);