Electrobun透明窗口在Windows上的输入穿透排查

34 阅读9分钟

现象:应用最大化之后,标题栏上的最大化按钮点击无响应,窗口无法还原。

这个问题前后排查了三轮,前两轮都改错了方向。记录一下每次测试做了什么、拿到什么结果,以及最后是怎么定案的。

一、环境配置

应用基于 Electrobun 构建,结构如下:

  • bun 侧主进程负责业务、SQLite、窗口管理;
  • 前端运行在 WebView2renderer: "native");
  • 标题栏为自绘,窗口采用无边框 + 隐藏原生标题栏。
mainWindow = new BrowserWindow({
    title: "应用管理",
    frame: { x: 0, y: 0, width: 1200, height: 800 },
    titleBarStyle: "hidden",   // 隐藏原生标题栏
    transparent: true,         // 透明窗口
    passthrough: false,
    renderer: "native",
});

最小化和关闭由 HTML 自绘按钮通过 RPC 调回 bun 侧操作原生窗口,双击标题栏同样触发最大化切换。切换逻辑本身只有五行:

windowMaximize: () => {
    if (mainWindow.isMaximized()) {
        mainWindow.unmaximize();
    } else {
        mainWindow.maximize();
    }
},

二、第一轮:怀疑 isMaximized() 状态判断

测试:点击最大化,再点击一次试图还原。

结果

  • 最大化:正常,窗口铺满屏幕;
  • 还原:无响应,窗口保持最大化状态。

分析:Electrobun 在 Windows 上对无边框窗口的 isMaximized() 返回值不可靠——窗口已经最大化,它仍然返回 false。这导致切换逻辑每次都走 maximize() 分支,而窗口已处于最大化时再次调用 maximize() 是空操作,表现为按钮失效。

坑点:无边框窗口的 isMaximized() 返回 false 不代表窗口未最大化,只代表原生层没有同步该状态。

改动 v1:程序自维护 winMaximized 标记,按标记选择分支;还原时除调用 unmaximize() 外,再用保存的窗口边界 setFrame 兜底,防止原生调用不生效。

验证结果:未解决。还原仍然失效。

三、第二轮:改用视口推断 + 显式下发目标状态

补充观察到的现象:双击标题栏可以正常还原

这一点在第二轮被忽略了。按钮和双击标题栏调用的是同一个后端切换函数,同一段逻辑两个入口表现不同,本应第一时间指向"事件是否送达",但当时的判断仍停留在状态管理上——认为用户可能通过 Win+↑、拖拽贴边等系统途径触发最大化,自维护标记会与真实状态脱节。

改动 v2:去掉标记,改为每次从事实推断。前端在 resize 中按视口尺寸反推最大化状态:

// 最大化 = 视口铺满屏幕工作区
maximized = window.innerWidth >= screen.availWidth
         && window.innerHeight >= screen.availHeight;

按钮与双击不再调用"切换",改为显式下发目标状态:

// 前端
const toggleMaximize = async () => {
    maximized = !maximized;
    setMaxIcon(maximized);            // 乐观更新图标,避免连点竞态
    await windowSetMaximized(maximized);
};

// bun 侧
windowSetMaximized: ({ maximized }) => {
    if (maximized) {
        savedWinBounds = { ...mainWindow.getPosition(), ...mainWindow.getSize() };
        mainWindow.maximize();
    } else {
        mainWindow.unmaximize();
        const b = savedWinBounds;
        if (b) { mainWindow.setPosition(b.x, b.y); mainWindow.setSize(b.width, b.height); }
    }
},

验证结果:仍未解决。但这一轮暴露出更完整的现象——最大化之后,标题栏上的全部控件(最小化、最大化、关闭、配色选择器)都失去响应。

问题范围由此从"还原逻辑"扩大为"最大化之后整个标题栏的鼠标输入失效"。

附带的一次无效排查

期间在开发模式下尝试复现,未能复现。曾怀疑是打包产物的问题,核对过 dev / release 的窗口参数与构建配置,无差异。最终确认原因:开发流程本身没有走原生 maximize() 路径,不具备复现条件。

由此确定后续验证方式:一律使用打包后的应用,走真实鼠标输入路径做黑盒测试

四、第三轮:黑盒取证,定位输入穿透

改动方向不再依赖推测,改为对打包后的应用做 UI 自动化黑盒操作,逐项取证。

测试 1:无障碍按压与真实鼠标的对比

WebView2 会将页面元素暴露进 Windows 无障碍树。对同一个最大化按钮分别施加两种输入:

输入方式结果
真实鼠标坐标点击❌ 窗口无响应
无障碍 AXPress 合成按压✅ 窗口正常还原

结论:前端 JS、RPC 通道、后端窗口操作全部正常,故障位于"真实鼠标事件 → 网页按钮"这一段递送链路。

测试 2:mousedown 与 click 的分界

真实鼠标点击后,无障碍树中该按钮出现 focused 状态。HTML 中 focus 跟随 mousedown,说明 mousedown 已送达按钮;但 click 处理器未执行(乐观更新的图标没有翻转)。

结论:输入链路在 mousedown 之后、click 之前中断。

测试 3:悬停时的桌面文件提示

在最大化状态下把鼠标移到标题栏按钮上悬停,屏幕上出现一个提示框,内容是桌面上某个 Word 文档的属性信息(创建日期 / 大小)

Windows 只向光标正下方的窗口派发悬浮提示。该提示框证明:该位置的鼠标事件穿透了应用窗口,落到下层的桌面 Explorer 上

测试 4:失效区域边界

分别点击旧窗口矩形内的元素(侧边栏导航)与矩形外的元素(最大化状态下右上角的按钮):

点击位置结果
旧窗口矩形内✅ 页面正常响应
旧窗口矩形外❌ 穿透,伴随桌面悬浮提示

边界恰好等于最大化之前的窗口矩形

根因

Electrobun 的透明无边框窗口在 Windows 上被原生 maximize() 最大化后,输入命中区域(hit-region)不跟随窗口新尺寸,停留在旧窗口矩形上。

渲染是全屏的,输入仍是旧尺寸的。旧矩形之外的整个最大化区域,对鼠标而言是一块"看得见、摸不着"的玻璃。

该结论同时解释了此前的全部现象:最大化后点击按钮(位于旧矩形外)→ 穿透无响应;双击标题栏中部(位于旧矩形内)→ 事件正常到达 → 还原成功。

五、修复方案

无效尝试 1:重设窗口边界以触发原生层重算

const frame = mainWindow.getFrame();
mainWindow.setFrame(frame.x, frame.y, frame.width, frame.height);

结果:无效。同值重设被原生层视为空操作。

无效尝试 2:±1px 抖动制造真实 resize

mainWindow.setFrame(frame.x, frame.y, frame.width + 1, frame.height + 1);
mainWindow.setFrame(frame.x, frame.y, frame.width, frame.height);

结果:无效。窗口处于最大化状态时,Windows 忽略 SetWindowPos 带来的尺寸变化,抖动未产生真实 resize。

两次失败指向同一结论:对已最大化的窗口,通过改变尺寸来同步命中区域这条路走不通

方案 A:绕开原生最大化,改用 setFrame 直设全屏矩形

既然命中区域停留在"上一次真实 resize 的尺寸",就不再调用原生 maximize(),改用普通 setFrame 把窗口设为全屏矩形。普通窗口的真实 resize 会同步命中区域(还原路径一直采用此方式,从未出问题):

windowSetMaximized: ({ maximized, width, height }) => {
    if (!mainWindow) return;
    if (maximized) {
        // 首次最大化前记下还原目标
        savedWinBounds ??= { ...mainWindow.getPosition(), ...mainWindow.getSize() };
        // 不用原生 maximize():直接设成全屏矩形,保证输入区域同步
        mainWindow.setFrame(0, 0, width, height);
    } else {
        const b = savedWinBounds ?? { x: 0, y: 0, width: 1200, height: 800 };
        mainWindow.setFrame(b.x, b.y, b.width, b.height);
        savedWinBounds = null;
    }
},

宽高由前端在点击时传入(screen.width / screen.height,逻辑坐标与窗口坐标系一致)。

与原生最大化的可见差异为零——无边框透明窗口的原生最大化本就是铺满整屏、覆盖任务栏。

方案 B:关闭窗口透明,消除分层窗口

进一步检查配置:窗口带有 transparent: true,因此进入分层窗口(layered window)路径,才存在"命中区域不同步"这一 Bug 类别。

而实际 UI 是自绘的不透明背景、直角窗口,该透明配置无任何可见收益。改为 false 后,窗口退化为标准非分层窗口,命中区域由 Windows 按窗口矩形处理:

titleBarStyle: "hidden",
transparent: false,   // 分层窗口在最大化后输入区域不同步,必须保持 false

最终方案

两条同时保留:

  1. transparent: false —— 从配置层面消除该 Bug 类别;
  2. setFrame 直设全屏矩形 + bun 侧自存还原边界 —— 行为层面兜底,不再依赖原生 maximize/unmaximize 的可靠性。

回归测试(打包版本,真实鼠标操作):最大化 ✅ → 最大化状态下点击最小化 ✅ → 点击还原 ✅ → 配色菜单 ✅ → 关闭 ✅。全链路通过。

六、复盘

1. "同一逻辑两个入口表现不同"应作为第一个分叉判断。

双击可还原、点击按钮不还原,这一组对照已经排除了切换逻辑本身。正确的问题是:事件有没有到达处理器。区分"逻辑执行了但结果不对"和"逻辑压根没执行",一次日志就能分开,本例中耗费了两轮。

2. 输入递送类问题无法通过读代码和单测定案,必须黑盒验证。

定案依赖四条独立证据的交叉验证:

证据指向
无障碍按压 ✅ / 真实鼠标 ❌逻辑完好,输入递送断链
mousedown 有 focus、click 未触发事件在按下后、点击完成前丢失
点击位置出现桌面文件的悬浮提示鼠标穿透至下层窗口
失效边界 = 旧窗口矩形命中区域停留在 resize 之前

单条证据都只能算"异常现象",四条拼合才构成根因。而这类问题单元测试全部通过(逻辑确实正确),只有用真实输入路径操作、并观察屏幕细节(如桌面 tooltip)才能捕获。前两轮失败的共同点是:改完假设即交付,缺少真机黑盒验证这一环。

3. 从模板继承的配置项需要定期清理。

transparent: true 由配置模板带入,无任何功能依赖,却使窗口进入分层窗口路径并引入该 Bug。配置项与依赖同理,无用途即删除。

附:同类问题的排查顺序

使用 Electrobun 或任何自绘标题栏 + 透明窗口方案,遇到"最大化后控件点击无响应":

  1. 用无障碍接口(UIA / AXPress)触发同一控件——可触发说明逻辑正常,属输入递送问题;
  2. 检查失效区域边界是否等于上一次 resize 之前的窗口矩形
  3. 在可疑区域悬停,观察是否出现下层窗口的悬浮提示或光标变化(穿透实锤);
  4. 确认命中区域问题后,最稳妥的解法是关闭 transparent
  5. 若必须保留最大化语义,用 setFrame 直设目标矩形替代原生 maximize(),并在 bun 侧保存还原边界自行管理"还原"。