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