// 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;
},
};