现象:应用最大化之后,标题栏上的最大化按钮点击无响应,窗口无法还原。
这个问题前后排查了三轮,前两轮都改错了方向。记录一下每次测试做了什么、拿到什么结果,以及最后是怎么定案的。
一、环境配置
应用基于 Electrobun 构建,结构如下:
- bun 侧主进程负责业务、SQLite、窗口管理;
- 前端运行在 WebView2 (
renderer: "native"); - 标题栏为自绘,窗口采用无边框 + 隐藏原生标题栏。
ts
mainWindow = new BrowserWindow({
title: "应用管理",
frame: { x: 0, y: 0, width: 1200, height: 800 },
titleBarStyle: "hidden", // 隐藏原生标题栏
transparent: true, // 透明窗口
passthrough: false,
renderer: "native",
});
最小化和关闭由 HTML 自绘按钮通过 RPC 调回 bun 侧操作原生窗口,双击标题栏同样触发最大化切换。切换逻辑本身只有五行:
ts
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 中按视口尺寸反推最大化状态:
ts
// 最大化 = 视口铺满屏幕工作区
maximized = window.innerWidth >= screen.availWidth
&& window.innerHeight >= screen.availHeight;
按钮与双击不再调用"切换",改为显式下发目标状态:
ts
// 前端
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:重设窗口边界以触发原生层重算
ts
const frame = mainWindow.getFrame();
mainWindow.setFrame(frame.x, frame.y, frame.width, frame.height);
结果:无效。同值重设被原生层视为空操作。
无效尝试 2:±1px 抖动制造真实 resize
ts
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 会同步命中区域(还原路径一直采用此方式,从未出问题):
ts
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 按窗口矩形处理:
ts
titleBarStyle: "hidden",
transparent: false, // 分层窗口在最大化后输入区域不同步,必须保持 false
最终方案
两条同时保留:
transparent: false------ 从配置层面消除该 Bug 类别;setFrame直设全屏矩形 + bun 侧自存还原边界 ------ 行为层面兜底,不再依赖原生maximize/unmaximize的可靠性。
回归测试(打包版本,真实鼠标操作):最大化 ✅ → 最大化状态下点击最小化 ✅ → 点击还原 ✅ → 配色菜单 ✅ → 关闭 ✅。全链路通过。
六、复盘
1. "同一逻辑两个入口表现不同"应作为第一个分叉判断。
双击可还原、点击按钮不还原,这一组对照已经排除了切换逻辑本身。正确的问题是:事件有没有到达处理器。区分"逻辑执行了但结果不对"和"逻辑压根没执行",一次日志就能分开,本例中耗费了两轮。
2. 输入递送类问题无法通过读代码和单测定案,必须黑盒验证。
定案依赖四条独立证据的交叉验证:
| 证据 | 指向 |
|---|---|
| 无障碍按压 ✅ / 真实鼠标 ❌ | 逻辑完好,输入递送断链 |
| mousedown 有 focus、click 未触发 | 事件在按下后、点击完成前丢失 |
| 点击位置出现桌面文件的悬浮提示 | 鼠标穿透至下层窗口 |
| 失效边界 = 旧窗口矩形 | 命中区域停留在 resize 之前 |
单条证据都只能算"异常现象",四条拼合才构成根因。而这类问题单元测试全部通过(逻辑确实正确),只有用真实输入路径操作、并观察屏幕细节(如桌面 tooltip)才能捕获。前两轮失败的共同点是:改完假设即交付,缺少真机黑盒验证这一环。
3. 从模板继承的配置项需要定期清理。
transparent: true 由配置模板带入,无任何功能依赖,却使窗口进入分层窗口路径并引入该 Bug。配置项与依赖同理,无用途即删除。
附:同类问题的排查顺序
使用 Electrobun 或任何自绘标题栏 + 透明窗口方案,遇到"最大化后控件点击无响应":
- 用无障碍接口(UIA / AXPress)触发同一控件------可触发说明逻辑正常,属输入递送问题;
- 检查失效区域边界是否等于上一次 resize 之前的窗口矩形;
- 在可疑区域悬停,观察是否出现下层窗口的悬浮提示或光标变化(穿透实锤);
- 确认命中区域问题后,最稳妥的解法是关闭
transparent; - 若必须保留最大化语义,用
setFrame直设目标矩形替代原生maximize(),并在 bun 侧保存还原边界自行管理"还原"。