Electron 桌面端的 Win32 浏览器辅助自动化边界
这篇记录一个 Electron 桌面端的实际边界:桌面应用主体用 React 做界面,原生能力留在 main/preload/native 层,并通过 koffi 调用 Win32 API 辅助控制第三方浏览器窗口,例如查找窗口、激活窗口、发送键盘鼠标输入、读写剪贴板、读取本地配置文件。
在这个场景里,关键问题不是“Electron 还是 Tauri 谁更好”,而是:原生能力放在哪一层、哪些操作应该绕过 UI 自动化、哪些操作必须承认 Win32 的限制。
Electron 与 Tauri 的取舍
Tauri 的优势是 Rust 后端天然适合写原生系统逻辑。Windows API、句柄、结构体、错误处理和类型边界都可以在 Rust 中明确表达。Tauri v2 的前端通过 invoke 调用注册的 Rust command,原生能力还可以通过 capabilities/permissions 控制暴露范围,这种模型适合原生能力占比高、希望用 Rust 严格约束底层实现的桌面工具。
Tauri 的代价是 Rust/前端双栈调试成本较高。每次新增原生能力都要同时处理 Rust 依赖、Tauri 权限、打包配置和前端调用。对主要工作都在 TypeScript/React 的项目来说,开发反馈会比纯 Node/Electron 慢一些。
Electron 的优势是工程链路统一在 Node/TypeScript 生态里。主进程可以使用 Node API、Electron API 和 npm 包,preload 可以把系统能力以窄接口暴露给 React。授权、工作区导入、剪贴板、文件对话框、全局快捷键和 Win32 FFI 可以留在同一个 TypeScript 工程里。
Electron 的代价是运行时更重,Chromium 和 Node 带来更大的安装包与内存占用。更重要的是安全边界必须严格维护:渲染进程不能直接导入 Node/Electron/native API,应保持 contextIsolation、禁用渲染进程直接 Node 能力,并通过 preload、contextBridge 和 ipcMain.handle 暴露白名单能力。Electron 官方安全文档也强调 preload 应包装具体 IPC 能力,而不是把 ipcRenderer 整体暴露给页面。
因此,这类项目选 Electron 并不是因为 Electron 更适合所有原生自动化,而是因为当前主要复杂度在 TypeScript/React 工作流和少量 Win32 helper 上。若项目从一开始就以大量 Windows 原生能力为核心,Tauri/Rust 或直接原生 Windows 技术栈可能更合理。
项目里的最小实现形态
实践中的落点是 Electron + TypeScript + koffi。React renderer 不碰 Node、Electron 或 Win32;preload 只暴露窄 API;main 负责授权和 IPC;Win32 调用集中在 native helper。
preload 层只包装具体通道,不把 ipcRenderer 整体暴露出去:
import { contextBridge, ipcRenderer } from "electron";
function invoke<Result>(channel: string, ...args: unknown[]) {
return ipcRenderer.invoke(channel, ...args) as Promise<Result>;
}
const api = {
commands: {
typeText: (text: string) => invoke("commands:type-text", text),
openTargetUrl: (request: { url: string }) =>
invoke("commands:open-target-url", request),
},
};
contextBridge.exposeInMainWorld("electronAPI", api);main 层接住 IPC,并在进入 native helper 前做授权、参数和错误边界:
ipcMain.handle("commands:type-text", async (_event, text: string) => {
await requireLicense();
return typeText(text);
});
ipcMain.handle("commands:open-target-url", async (_event, request) => {
await requireLicense(["browser-automation"]);
return openTargetUrl(request);
});这样的分层比“renderer 直接 import Electron/Node/Win32 helper”啰嗦一些,但好处是权限边界清楚:UI 只能调用被 preload 暴露出来的能力,main 才能决定是否允许执行。
Windows 自动化不是 DOM 控制
对第三方浏览器窗口的辅助自动化,不能按 Web 应用内部 DOM 控制来理解。Win32 输入和窗口 API 处理的是桌面窗口、前台焦点、输入队列、剪贴板和进程权限。
窗口查找不能只靠标题。标题会随页面变化、语言变化和无痕模式变化而变。更稳的做法是结合窗口标题、窗口类名和进程路径共同判断。典型链路是枚举顶层窗口,读取窗口标题和类名,再通过窗口所属进程拿到进程路径,最后交给匹配函数判断是否为目标浏览器窗口。
前台激活不可靠。Microsoft 对 SetForegroundWindow 的文档明确说明,Windows 会限制哪些进程可以把窗口带到前台;即使满足条件,也可能被拒绝。实际实现不能假设调用一次就成功,而应该组合显示、置顶、切前台等动作,并在调用后用当前前台窗口验证,失败时给出可操作错误。
输入注入也不是“发给某个窗口”。SendInput 把键盘和鼠标事件插入系统输入流,它不是目标窗口的私有消息。Microsoft 文档还说明它受 UIPI 影响,只允许向同等或更低完整性级别的应用注入输入。因此,发送输入前必须确认目标窗口确实成为前台窗口;发送后也要通过窗口句柄、配置文件或回读文本验证结果。
优先读配置,其次才操作界面
配置文件读取应优先于 UI 自动化。能直接读取本地配置,就不要打开或激活浏览器窗口。UI 自动化只适合配置文件无法覆盖、必须通过设置页提交、或者目标应用没有稳定文件接口的部分。
这条原则能减少三个风险:
- 不打扰用户当前前台窗口和输入状态。
- 不依赖第三方页面布局、缩放、语言和版本。
- 更容易用普通文件读写测试覆盖回归边界。
如果确实要操作界面,要先把窗口大小、位置、缩放和目标页面状态固定下来。坐标点击尤其脆弱:第三方设置页不是应用可控 DOM,页面元素可能因为窗口大小、浏览器缩放、语言或版本变化而移动。坐标自动化必须配合结果验证,而不能只靠“点击了某个点”判断成功。
剪贴板和输入副作用要当成用户可见风险
为了稳定输入中文、URL 或长配置文本,用剪贴板配合 Ctrl+V 通常比逐字模拟键盘更可靠。但剪贴板是用户状态,自动化过程会覆盖用户剪贴板。读取地址栏或粘贴配置时,如果临时改剪贴板,应尽量保存并恢复原内容;失败时也要让用户知道可能影响了剪贴板。
同样,SendInput 这种全局输入一旦目标窗口没有真正获得前台,输入就可能落到别的应用里。涉及删除、覆盖、提交配置、保存文件的动作,不能只依赖输入命令返回值;应该有独立的结果验证。
FFI 边界要比业务代码更保守
使用 koffi 这类 Node FFI 调 Win32 API 时,函数签名和结构体布局必须按 Win32 定义精确匹配。SendInput 依赖 INPUT 结构体和 union 布局,RECT、KEYBDINPUT、MOUSEINPUT 的字段类型、顺序和大小都不能凭感觉写。
项目中的 koffi 代码形态大致如下。先加载 DLL,再声明结构体、union、回调和函数签名:
import koffi from "koffi";
const user32 = koffi.load("user32.dll");
const KEYBDINPUT = koffi.struct("KEYBDINPUT", {
wVk: "uint16_t",
wScan: "uint16_t",
dwFlags: "uint32_t",
time: "uint32_t",
dwExtraInfo: "uintptr_t",
});
const MOUSEINPUT = koffi.struct("MOUSEINPUT", {
dx: "long",
dy: "long",
mouseData: "uint32_t",
dwFlags: "uint32_t",
time: "uint32_t",
dwExtraInfo: "uintptr_t",
});
const INPUT = koffi.struct("INPUT", {
type: "uint32_t",
u: koffi.union({
ki: KEYBDINPUT,
mi: MOUSEINPUT,
}),
});
const SetForegroundWindow = user32.func(
"int __stdcall SetForegroundWindow(void *hwnd)",
);
const SendInput = user32.func(
"unsigned int __stdcall SendInput(unsigned int cInputs, INPUT *pInputs, int cbSize)",
);发送输入时不要只看函数有没有抛错。SendInput 返回实际插入输入流的事件数量,所以要比较返回值和输入数组长度:
function sendInputs(inputs: Array<Record<string, unknown>>) {
const sent = SendInput(inputs.length, inputs, koffi.sizeof(INPUT)) as number;
if (sent !== inputs.length) {
throw new Error("SendInput 发送失败");
}
}FFI helper 应该隔离在 native 模块里,而不是散落在业务 UI 代码中。React 只处理 UI 状态和 DOM 事件;preload 只暴露受控 API;main 注册 IPC、授权拦截和 Electron 系统能力;复杂 Windows 自动化留在 native helper 模块。
新增系统能力时,先设计 IPC 边界,再实现 native helper。渲染进程不能直接访问 ipcRenderer、electron、koffi、fs 或其他 Node API。错误信息要能指导用户下一步操作,例如提示先打开目标浏览器普通窗口、绑定窗口已关闭、页面坐标需要调整等。
打包时还要确认 koffi 的平台 native 包进入 Electron 产物。使用 Electron Builder 时,可以把 Windows x64 的 @koromix/koffi-win32-x64 放进 asarUnpack,否则开发环境能跑,打包后可能加载 native module 失败:
module.exports = {
asar: true,
asarUnpack: [
"node_modules/@koromix/koffi-win32-x64/**/*",
],
};什么时候应该考虑 UI Automation
Microsoft UI Automation 是 Windows 的可访问性框架,能以编程方式访问大多数桌面 UI 元素,也允许自动化测试脚本和 UI 交互。它的模型是 Automation Element、属性和 Control Pattern,而不是坐标点击。
如果目标应用暴露了可靠的 UI Automation 树和控件模式,UI Automation 往往比坐标点击更稳。它适合读取控件名称、查找按钮、操作标准控件、监听 UI 事件。它不适合替代所有场景:浏览器扩展页、自绘 UI、跨用户进程、权限隔离或未暴露合适控件模式时,仍可能退回到窗口激活、输入注入和结果回读。
这意味着实现时可以按稳定性排序:
- 直接读写配置文件或调用官方接口。
- 使用 UI Automation 定位和操作可访问控件。
- 最后才使用前台窗口、剪贴板、坐标和
SendInput。
验证顺序
这类功能的验证不能只看 TypeScript 是否通过。更合理的顺序是:
- 类型检查,保证 Electron main/preload/native helper 的接口没有断。
- native helper 的结构体和函数签名检查,尽早暴露 FFI 布局错误。
- 配置文件读写测试,覆盖不依赖 UI 的稳定路径。
- 手动或半自动验证目标浏览器窗口:能找到窗口、能激活窗口、输入前后能验证结果。
- 打包验证,确认 Electron 运行时能加载 native module。
只要改动碰到 main、preload、native helper、打包配置或 native module,就应该把验证推进到真实 Electron 运行环境,而不只停在类型检查。
参考资料
- Electron Security: <https://www.electronjs.org/docs/latest/tutorial/security>
- Electron IPC: <https://www.electronjs.org/docs/latest/tutorial/ipc>
- Tauri v2 Calling Rust from the Frontend: <https://v2.tauri.app/develop/calling-rust/>
- Tauri v2 Plugin Permissions: <https://v2.tauri.app/learn/security/using-plugin-permissions/>
- Microsoft
SetForegroundWindow: <https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow> - Microsoft
SendInput: <https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendinput> - Microsoft UI Automation Overview: <https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-uiautomationoverview>