mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
feat(gui): 模式 A agent 独立光标 + 后台注入;修坐标换算;固化计划与交接文档
## 模式 A:agent 独立光标(不抢用户鼠标)
现有 computeruse 走 SetCursorPos + mouse_event,agent 一干活就抢走用户
真实鼠标,用户无法同时使用电脑。现改为:GUI 自绘 agent 专属光标,实际
输入经 PostMessage 直接投递到目标窗口句柄,用户鼠标全程不动。
新增:
cmd/gui/agent-cursor.js 透明置顶层窗口 + mascot 指针 + 移动/点击脉冲
动画;记录上次命中的 hwnd 供键盘动作使用
cmd/gui/agent-inject.js Windows 后台注入:WindowFromPoint 定位、
客户区坐标换算、PostMessage 投递鼠标/键盘/
滚轮/文本;拖拽分步移动
配置 gui-prefs.deviceBridge.agentCursor.mode = "overlay"(默认) | "real"。
明确的设计边界(不是实现缺陷,是平台限制):
· 只对「接受窗口消息的程序」可靠。原生 Win32 程序可靠;Chromium/
Electron 自绘界面、游戏、DirectX 会忽略合成消息。
· 不做真实鼠标降级。用户既选了不抢鼠标,就不该偷偷改成操作真鼠标,
失败即 status=error 并说明原因。
· 键盘类动作依赖先前的鼠标操作(后台注入不动真光标,
GetCursorPos 拿到的是用户自己的光标位置)。
端到端 8/8 正确响应;「真实点击是否落到目标窗口」因当前环境无可注入的
可见窗口而未验证,已在 GUI-PLAN.md 列为待办。
## 修坐标换算(我自己上一轮引入的 bug)
上一轮我加了 rawX / scaleFactor 的换算,理由是「SetCursorPos 期望 DIP」。
**那个前提是错的** —— Electron 主进程是 per-monitor DPI-aware,Win32 坐标
不做虚拟化。实测:
Electron screen API (DIP) : 1260 x 840 scaleFactor=2
GetSystemMetrics : 2520 x 1680
SetCursorPos(200,200) -> GetCursorPos 读回 (200,200) 恒等,不缩放
加 /scale 会把原本正确的点击改坏:200% 屏上点 (600,400) 被送到
(400,267),越靠右下偏得越远。已改回 physical ? rawX : rawX*scale,
drag 终点同步修正(原先两处换算不一致)。
易误判之处:在普通 node 进程里测同一函数读到的是 1260x840(虚拟化值),
会以为 Win32 用 DIP —— 必须在 Electron 内测。
## 判据
computeruse.test.mjs 43 条(npm test 共 127 条全绿),新增 5 条钉住
模式 A 的核心承诺:
· agent-inject / agent-cursor 均无 SetCursorPos 调用(查前先剥注释,
因为注释里会引用该名字解释「为什么不用它」)
· koffi 指针参数用 Buffer(普通对象不写回,会拿错 hwnd)
· overlay 默认开启且可切回 real
· 自绘光标层 setIgnoreMouseEvents 穿透
核心不变量已做变异验证:注入 SetCursorPos 后判据立即转红。
另修正坐标判据方向(原判据断言 /scale 是对的,与实测相反)。
## 文档
GUI-PLAN.md 改造计划与进度的单一事实来源:已完成/待办、坐标
实测结论与踩坑、koffi 3.x 使用约定、端到端测试方法、
环境注意事项(含本机无 Go 工具链、waiter 改动未编译验证)
SERVER-HANDOFF.md 服务端需同步的部分:放开 action 白名单(schema enum
+ switch default 双重限制,不改则 11 个新动作下发不到)、
透传 tox/toy/steps/dx/ms、统一 scroll 方向语义、
写入坐标约定防重蹈
This commit is contained in:
191
cmd/gui/main.js
191
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);
|
||||
|
||||
Reference in New Issue
Block a user