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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user