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:
241
cmd/gui/agent-cursor.js
Normal file
241
cmd/gui/agent-cursor.js
Normal file
@ -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 = `<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<style>
|
||||
html,body{margin:0;padding:0;width:100%;height:100%;background:transparent;overflow:hidden;
|
||||
cursor:none;user-select:none;-webkit-user-select:none;}
|
||||
/* 自绘指针:mascot 形象 + 一圈强调环 + 点击脉冲 */
|
||||
#c{position:absolute;left:0;top:0;width:64px;height:64px;margin:-32px 0 0 -32px;
|
||||
will-change:transform;transition:none;}
|
||||
#mascot{position:absolute;inset:0;width:64px;height:64px;object-fit:contain;
|
||||
filter:drop-shadow(0 2px 6px rgba(0,0,0,.45));}
|
||||
/* 点击脉冲环:从 0 扩到 1.9 并淡出,给用户"这一下点在这儿"的确定感 */
|
||||
#pulse{position:absolute;left:50%;top:50%;width:18px;height:18px;margin:-9px 0 0 -9px;
|
||||
border-radius:50%;border:2px solid rgba(96,165,250,.95);opacity:0;
|
||||
box-shadow:0 0 12px rgba(96,165,250,.55);}
|
||||
/* 移动拖尾:光标后的一小段渐隐线,指示"正在移动过去" */
|
||||
#trail{position:absolute;left:0;top:0;height:3px;transform-origin:0 50%;
|
||||
border-radius:2px;background:linear-gradient(90deg,rgba(96,165,250,0),rgba(96,165,250,.5));}
|
||||
</style></head>
|
||||
<body>
|
||||
<div id="c">
|
||||
<div id="trail"></div>
|
||||
<img id="mascot" alt="">
|
||||
<div id="pulse"></div>
|
||||
</div>
|
||||
<script>
|
||||
const c=document.getElementById('c'),pulse=document.getElementById('pulse'),
|
||||
trail=document.getElementById('trail'),mascot=document.getElementById('mascot');
|
||||
let cur={x:0,y:0},tgt={x:0,y:0},pulseAt=0,trailAt=0,busy=false;
|
||||
function render(t){
|
||||
// 缓动跟随:让指针有"飞过去"的观感,而不是瞬移
|
||||
cur.x+=(tgt.x-cur.x)*0.35; cur.y+=(tgt.y-cur.y)*0.35;
|
||||
c.style.transform='translate('+cur.x+'px,'+cur.y+'px)';
|
||||
// 点击脉冲:0.45s 内从 scale(0.4) 扩到 scale(2.2) 并淡出
|
||||
const dt=(t-pulseAt)/450;
|
||||
if(pulseAt&&dt>=0&&dt<=1){ pulse.style.opacity=String(1-dt);
|
||||
pulse.style.transform='scale('+(0.4+dt*1.8)+')'; }
|
||||
else { pulse.style.opacity='0'; pulse.style.transform='scale(0.4)'; }
|
||||
// 拖尾:移动后 0.4s 内衰减
|
||||
const dtr=(t-trailAt)/400;
|
||||
const mv=Math.hypot(tgt.x-cur.x,tgt.y-cur.y);
|
||||
if(trailAt&&dtr>=0&&dtr<=1&&mv>1){
|
||||
const len=Math.min(90,mv*1.6)*(1-dtr);
|
||||
const ang=Math.atan2(tgt.y-cur.y,tgt.x-cur.x)*180/Math.PI;
|
||||
trail.style.width=len+'px';
|
||||
trail.style.opacity=String((1-dtr)*0.75);
|
||||
trail.style.transform='translate('+(cur.x-30)+'px,'+(cur.y-30)+'px) rotate('+ang+'deg)';
|
||||
} else { trail.style.opacity='0'; }
|
||||
// 忙碌:mascot 轻微呼吸,提示"agent 正在操作,别急着抢鼠标"
|
||||
if(busy){ const s=1+0.06*Math.sin(t/160); mascot.style.transform='scale('+s+')'; }
|
||||
else { mascot.style.transform='scale(1)'; }
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
requestAnimationFrame(render);
|
||||
window.__agentCursor={
|
||||
move(x,y,motion){ tgt.x=x; tgt.y=y; if(motion){trailAt=performance.now();} },
|
||||
click(){ pulseAt=performance.now(); },
|
||||
setBusy(b){ busy=!!b; },
|
||||
setVisible(v){ c.style.display=v?'':'none'; },
|
||||
hide(){ c.style.display='none'; },
|
||||
};
|
||||
</script></body></html>`;
|
||||
|
||||
// 确保 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;
|
||||
},
|
||||
};
|
||||
335
cmd/gui/agent-inject.js
Normal file
335
cmd/gui/agent-inject.js
Normal file
@ -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,
|
||||
};
|
||||
@ -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(
|
||||
|
||||
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