
一个点击穿透浮层的两种失败模式
GeekBye 的窗口浮在一切之上,让你的点击直接穿过它——除了它有按钮的地方。这是一份两面的契约,而 v1.8.5 和 v1.8.14 正是每一面被打破时的样子:一个版本里浮层吞掉了一个系统对话框,另一个里它偷走了你的按键。第二个问题的取胜修复,是删代码。
GeekBye 的主窗口不是一个普通窗口。它无边框、透明、置顶——一块玻璃,浮在你正在做的任何事情之上,上面画着几个控件。有意思的工程不在于它会浮;而在于你的鼠标本应在除控件之外的所有地方_径直穿过那块玻璃_。点浮层上的一个按钮,浮层拿到这次点击。点它旁边的空白处,这次点击就落到下面你的会议应用上,仿佛浮层压根不在那儿。
这是一份有两面的契约:在有 UI 的地方精确地接收输入,并在其他所有地方对输入完全隐形。 两面都必须每时每刻成立,而应用这一部分里的每一个 bug 都是这两面中某一面的违约。相隔两个月的两个版本,是尽可能干净的一次示范——因为它们各自打破了不同的一面。
这份契约,从机制上说
它的实现是一个 hook,useMousePassthrough,而且很小。窗口_默认_是点击穿透的。一个全局的 mousemove 监听器在每次移动时都问一个问题:光标在一个控件上面吗?控件被打上一个 data-ui-region 属性的标签,所以这个检测就字面上是 target.closest('[data-ui-region]')。在控件上,它调用 setIgnoreMouseEvents(false),窗口就捕获点击。在透明空间上,它调用 setIgnoreMouseEvents(true, { forward: true }),点击就穿过去。
那个 forward: true 是承重的那处细节。一个窗口一旦开始忽略鼠标事件,它通常就完全不再收到它们了——包括 hook 用来察觉光标_重新_回到控件上所需要的那些 mousemove 事件。forward: true 告诉 Electron 无论如何都继续把移动转发给这个正在忽略的窗口,好让它能重新武装自己。没有它,浮层就会变成点击穿透一次然后再也回不来。这个 hook 还只在状态_跃迁_时——光标越过 UI 与玻璃之间的边界时——才跨过 IPC 边界去调用,而不是在移动的每一个像素上。记住这个优化;它有个洞,而 v1.8.5 掉进去了。
v1.8.5:当透明那一面被打破
契约里透明的那一面是"在没有 UI 的所有地方对输入隐形"。v1.8.5 就是浮层_太喧宾夺主_时的样子——当它捕获或挡住了本该放过去的输入时。触发点是 macOS 权限对话框,而这里有两个各不相同、又叠在彼此之上的失败。
浮层在物理上就在对话框上面。 一个置顶窗口有一个_层级_,而 GeekBye 的浮层坐在 macOS 提供的最高那一级上——screen-saver。那几乎在一切之上,包括系统自己的 Microphone 和 Screen Recording 权限对话框。所以在引导过程中,就在 macOS 索要权限的那一刻,对话框可能被绘制在浮层_下面_而不可见。修复办法 setPermissionMode 只在 macOS 上,做的是一件反直觉的事:它在一次权限请求持续期间把浮层_降_到 floating 层级,然后事后再把它还原。而还原它需要一个特定的把戏,就记录在那条 commit 里:macOS "在设置一个它此前已经所在的相同层级时,并不总会在视觉上把窗口重新升起来",所以代码让窗口经过一个中间层级逐级走过去——floating → status → screen-saver——好逼着窗口服务器重新计算堆叠次序。你不是直接设成你想要的层级;你是走到它那里去。
浮层在吃掉对话框的点击。 引导会画一块覆盖整个窗口的黑色遮罩,而穿透 hook 对它做了特例处理:在那块遮罩支起来的时候,它强制整个窗口捕获点击,好让向导完全可交互。但"捕获每一次点击"在一个系统对话框正试图收集一次点击时恰恰是错的。修复办法在一次权限请求期间剥掉遮罩的标记属性,于是 hook 退出捕获模式,对话框的按钮就拿到了它们的点击。浮层保持它自己的控件可交互;它只是不再假装那块空玻璃是个按钮。
优化里的那个洞。 记住这个 hook 只在跃迁时动作。当窗口离开捕获模式时,它重新跑了一遍 hover 检测——但如果那一刻你的光标_已经_停在一个控件上,"我们在 UI 上面吗?"这个答案本来就已经是"是",所以没有跃迁触发,点击穿透也就从没被关掉过。控件看起来是活的,其实是死的。修复(1b6eb20,引用如下)不再信任跃迁:"when exiting onboarding mode, directly evaluate whether cursor is over a UI region and make the correct IPC call." 当你切换模式时,你不去等下一次移动来告诉你光标在哪——你去看。
并且让让出可逆。 这一切里那个微妙的风险是,浮层现在临时交出了它的 z 序和它的点击捕获,而总得有什么把它们_还回去_。如果那次还原调用抛了异常——一次 IPC 打嗝,一个在请求中途被拆掉的窗口——应用就可能被搁浅在 isRequestingPermission 卡在 true、一块黑遮罩永久挡住你整个屏幕的状态里。所以退出路径被包了起来,好让状态重置"总会执行",外加一个几分钟的安全超时和一个窗口销毁时的清理。由此落下来的那条规则:每次一个浮层让出输入,那次收回让出就必须在一个 finally 里,而不是在顺利路径上。
v1.8.14:当捕获那一面被打破
契约的另一面是"在有 UI 的地方接收输入"。v1.8.14 就是浮层_太爱抢_时的样子——当跟它交互让你付出了你真正在乎的那个应用的输入焦点作代价时。
changelog 里的症状是抢焦点,而这里是诚实的版本,因为 git 历史比发布说明更有教益。浮层的快捷键处理器——用 Cmd+B 切换可见性、打开聊天窗口、滚动它——过去每一个都会调用 mainWindow.focus(),在 macOS 上还有 app.focus({ steal: true }),好确保你触发它时浮层在最前。问题在于,给浮层聚焦会_让此前在最前的任何东西失活_——你的会议、你的编辑器——于是一次本该发给它们的按键可能落在虚空里,或者落在错的地方。浮层在抢一样它其实并不需要的东西。
最后这部分就是整条教训,而团队是吃了苦头才找到它的。后来成为 v1.8.14 的那条分支,最初专门想解决_拖拽_——因为拖拽有它自己这个 bug 的漂亮版本:当你拖一个全屏浮层时,窗口在一个静止的光标下移动,于是 hover 模型以为指针"离开"了控件,就在_拖拽半途_翻成点击穿透,把这次拖拽给丢了。所以他们加了一个 dragLock。然后他们试着让整个窗口不可聚焦——那把打字和拖拽都弄坏了,于是他们把可聚焦性还原回去,接着把不可聚焦性收窄到只针对文本输入框,再接着把那个开关整个撕掉。他们造了一套自定义拖拽 IPC 去替换 Electron 的原生拖拽。他们加了在意外聚焦时失焦。这样折腾了一周。
然后他们几乎把它全删了。发货的那条 commit 回退了自定义拖拽、可聚焦开关和失焦处理器,只留下恰好一个念头:别再调用 focus()。 那个写进代码注释里的顿悟是,浮层从一开始就根本不需要操作系统焦点——"IPC messages (webContents.send) work without window focus." 浮层直接驱动它自己的 renderer;焦点纯粹是附带损害。可发货的修复不是那个花一天造出来的聪明的自定义拖拽重写。它是把那些本就不该在那儿的焦点调用减掉。(值得直说:changelog 说的是 "while dragging it",但实际发出去的是从键盘快捷键处理器里移除抢焦点——那套拖拽专用的机械装置是在同一周被探索、又被回退的。)
把赌注摆到台面上
如果你想要证据说明这份契约不是纸上谈兵,就看看几个月后浮层卡在它_错误_那一面时发生了什么。在一个晚得多的版本里,renderer 进程在浮层处于捕获模式时崩了——setIgnoreMouseEvents(false),窗口攥着所有点击——而没有一个活着的 UI 去释放它,那个不可见的、铺满整个桌面的窗口就杵在那里,吃掉用户机器上的每一次点击,直到他们强制退出。一个卡在捕获模式的透明窗口不是一个死按钮;它是一个覆盖整个桌面的点击陷阱。(那个问题得到了它自己的诊断——见当你的 AI 记录助手在会议中途停止录制,它把这个问题跟同一个版本里一个很不一样的 bug 配在一起讲。)这是对整个问题最锋利的一句陈述:把开关卡在一个方向,你的 UI 就死了;卡在另一个方向,你的整个桌面就死了。
这些版本教给我们的三件事
- 一个点击穿透浮层是一份两面的契约,而两面失败的方式各不相同。 太透明,你自己的控件就死了;太爱抢,你就吞掉别的应用的输入。大多数浮层 bug 都只是这两者之一,而说清你在哪一面,就告诉了你该往哪看。
- 置顶的最高层级不是一个奖品。 坐在_一切_之上,意味着坐在你需要用户看见的那些系统对话框之上。为你的浮层该在何时礼貌地退让一步而设计——并且让重新站回来这件事在一个
finally里可逆,因为一次只完成一半的让出,比从不让出更糟。 - 减法胜过聪明。 那个取胜的 v1.8.14 修复,是删掉浮层从不需要的
focus()调用,而这只有在一周的搭建和回退自定义拖拽处理之后才被发现。当一个功能不停地跟平台较劲时,头一个该问的问题是,你能不能把那个正在较劲的东西直接拿掉。
关于 v1 故事的上一章,见把发布放进 CI,两次(v1.8.4);整段弧线则见把软件打磨到完美地发布出去的解剖学。