macOS + webView + quick-panel
macOS 上“窗口还是 Key,但键盘没了”:一次 Tauri NSPanel + WKWebView 焦点失控排查复盘
适用对象:在 macOS 上用 Tauri/Wry 做 “Spotlight/Alfred 风格”面板(
NSPanel+.nonactivatingPanel)的同学。
关键词:key window / first responder / WKWebView / nonactivating panel / prevents-activation tag
这篇文章复盘一个非常“反直觉”的问题:quick-panel(horizontal layout) 明明仍然是 key window(看起来也“有焦点”),但打开 preview popup 之后,键盘事件却再也进不了 WebView。用户只能用鼠标点一下 quick-panel 才能恢复;而一旦切换 selected item、preview 更新,又会再次“丢键盘”。
最终修复其实非常“小”:把 refocus 逻辑改成幂等,只在“窗口还不是 key”时才做真正的回焦动作。这样就能避免在“已经 key”的窗口上重复触发 makeFirstResponder(...) 带来的副作用——它可能把 WKWebView(更准确说是内部的 WKView)踢出 responder chain,进而让键盘事件不再到达 WebView。
TL;DR(结论先行)
- 现象:从 VS Code / WeChat 等前台应用用热键拉起 quick-panel 后键盘操作正常;打开 preview popup 后,quick-panel 的
←/→/Shift+↑/Shift+↓全部失效,只能鼠标点回面板才能恢复。 - 关键误导:OS 层面 quick-panel 依旧是 key window,preview popup 也被配置为不可成为 key(
can_become_key=false),看起来不像“抢焦点”。 - 根因:我们的 “refocus quick-panel” 逻辑无条件调用了一个“一步到位”的
show_and_make_key()便捷方法;而这种实现里通常会执行:NSWindow.makeFirstResponder(contentView)orderFrontRegardless()makeKeyWindow()
makeFirstResponder会先对“当前 first responder”发送resignFirstResponder();在 Tauri/Wry 的WKWebView场景中,这一步会让 WebView 的 responder 状态被打断,最终 窗口仍 key,但 WebView 不再接收键盘事件。 - 修复:refocus 时 仅当 source panel 不是 key window 才执行
show()+make_key_window();避免在“已经 key”时再次触发makeFirstResponder(...)的副作用。
背景:Quick Panel + Preview Popup 的交互目标
我们在 macOS 上实现了类似 Spotlight 的面板体验:
quick-panel(horizontal):全局热键唤起,展示横向剪贴板条目;用户用←/→切换 selected item,用Shift+↑打开预览。preview popup window:展示当前 selected item 的预览内容;它必须 不打断 quick-panel 的键盘操作(preview 只是“浮层/镜头”,不是输入焦点的归属)。
理想状态:
- 全局热键唤出 quick-panel → 立即可键盘操作
Shift+↑打开 preview popup → preview 显示,但 键盘仍由 quick-panel 驱动←/→切换条目 → quick-panel selected item 变化,preview 同步更新,但 不发生“键盘输入目标”漂移
复现步骤(用户视角)
复现稳定,典型流程如下:
- 前台应用为 VS Code / WeChat,焦点在编辑区。
- 用快捷键唤出
quick-panel(horizontal),焦点在 quick-panel(正确)。 - 在 quick-panel 中按
Shift+↑打开 preview popup:- preview popup 能显示(正确)
- 但从此刻起,quick-panel 的键盘操作(
←/→/Shift+↑/Shift+↓)全部失效
- 想恢复键盘,只能鼠标点一下 quick-panel;但切换 selected item 后 preview 更新,又会再次失效,需要反复鼠标点回。
为了排查,我们在调试版里加入了结构化焦点日志(例如:窗口是否可见、是否 key、应用是否 active、以及关键的 refocus 分支是否被触发)。
为什么这么难排查:三层“焦点”经常被混为一谈
在 macOS + WebView 方案里,“焦点”至少有三层状态机:
- WindowServer / 激活态:哪个 app 是 active,谁拥有菜单栏,谁会收键盘事件。
- AppKit / NSWindow:哪个窗口是 key window / main window。
- Responder Chain / First Responder:key event 最终送到哪个 responder(
firstResponder很关键)。 - (再往上)WebView/DOM focus:Web 内容里哪个 element 处于
document.activeElement。
这次问题的“迷惑点”在于:第 2 层看起来正确(quick-panel 仍 key),但第 3 层已经坏了(WebView 不再是合适的 first responder),所以键盘进不了 WebView。
Apple 官方文档明确指出:NSWindow.makeFirstResponder(_:) 会先让当前 first responder 尝试 resignFirstResponder(),并且当目标 responder 不接受时,可能由 window 自身成为 first responder。[1][2]
证据:日志里“明明没丢 key”,却反复触发 refocus
为了说明这个问题的“证据链长什么样”,这里用一个简化的**示意日志(mock)**来表达真实事件顺序:
# before fix(问题版本)
[focus] quick_panel.window_did_become_key window="quick_panel"
[focus] preview_popup.show window="preview_popup" can_become_key=false
[focus] refocus_source window="quick_panel" window_is_key=true action="show_and_make_key"
[focus] show_and_make_key makeFirstResponder="contentView"
# after fix(修复后)
[focus] refocus_source window="quick_panel" window_is_key=true action="skip"
也就是说:quick-panel 已经成为 key window 之后,我们仍然在 preview popup 打开时调用了“refocus source”逻辑。如果 refocus 是“幂等”的,这本来不该造成任何影响。
但“看似无害的 refocus”之所以能导致键盘失效,是因为它在实现上做了一个非常有副作用的动作:makeFirstResponder(contentView)。
排查路程(时间线):从“像是抢焦点”到“其实是 responder chain 被踢掉”
这次问题从现象到根因收敛经历了多轮迭代。
- 阶段 1:把 popup 生命周期做稳
先补齐 preview popup 的外部关闭、重复打开、复用更新等边界场景,确保“状态机”本身不自相矛盾(否则你永远不知道是业务 bug 还是焦点 bug)。 - 阶段 2:加可观测性,让猜测变成事实
增加窗口 key/resign 事件、可见性、激活态与 refocus 分支的结构化日志。焦点问题最怕“凭感觉”,日志是唯一可复盘的证据链。 - 阶段 3:验证 nonactivating panel 的假设与生态限制
我们一度怀疑问题来自.nonactivatingPanel在 WindowServer 层的 prevents-activation tag 失配(Phil 的分析对这类现象很有解释力)[5],以及 Tauri/Tao 在 macOS 上对 focusable/activation 的已知限制。[6][7]
这一步的价值在于:排除“外层窗口抢 key”这类常见误判。 - 阶段 4:把焦点从“key window”下沉到“first responder”
最终发现:问题并不要求“preview popup 变成 key”,只要 refocus 路径里触发了makeFirstResponder(...),就可能让 WebView 丢键盘。修复因此回到一个极小但关键的策略:refocus 必须幂等,已 key 时禁止再次触发 responder 切换。
这也是一个典型教训:“看起来像抢 key”并不一定是 key 真的被抢了;对 WebView 而言,first responder 才是键盘事件能否到达的关键条件。
根因拆解:show_and_make_key() 里那一行 makeFirstResponder(contentView)
1) 一个“show_and_make_key()”封装通常做了什么
在我们当时使用的 macOS panel 包装层里,show_and_make_key() 的关键逻辑大致如下(伪代码 / 语义等价,删去无关细节):
// show_and_make_key() - 示意实现
_ = window.makeFirstResponder(window.contentView)
window.orderFrontRegardless()
window.makeKeyWindow()
注意:这里并没有把 WKWebView/WKView 设为 first responder,而是把 contentView(窗口根 view)设为 first responder。对 WebView 应用来说,这个动作非常“危险”。
orderFrontRegardless() 的语义是:即使应用不 active 也把窗口前置,但 不改变 key/main window。[3]
而我们随后又调用 makeKeyWindow,这会让 panel 成为 key window —— 但关键在于:键盘事件最终送到谁,取决于 first responder。
2) Apple 文档里 makeFirstResponder 的关键行为
Apple 文档(NSWindow.makeFirstResponder(_:))明确写到:[1]
- 如果目标 responder 不是当前 first responder,会先对当前 first responder 发送
resignFirstResponder(); - 如果当前 first responder 拒绝 resign,则保持原状并返回失败;
- 如果目标 responder 不接受 first responder,则 window 本身可能成为 first responder。
而 NSResponder.resignFirstResponder() 文档也强调:默认实现会允许 resign,且不应直接调用它(应通过 makeFirstResponder 驱动)。[2]
3) 为什么这会让 Tauri/Wry 的 WKWebView 丢键盘
对 Tauri/Wry 这种“把 UI 放在 WKWebView 里”的应用来说:
- 键盘事件要进到 Web 内容(DOM),通常需要 WebView(或其内部
WKView)在 responder chain 中处于可接收 key event 的位置; - 当我们把 first responder 改成
contentView(甚至退化为 window 自身)时,WebView 不再是 key event 的目标; - 于是就出现“窗口还是 key,但键盘事件不触发任何前端行为”的状态;
- 用户鼠标点击 WebView 后,AppKit 会把合适的 view 设为 first responder,键盘才恢复 —— 这就是“必须点一下才能好”的根源。
额外旁证:WebKit 甚至曾经有过 “直接对 WKWebView 调 makeFirstResponder 也不一定工作” 的历史 bug(macOS 10.10 时代)。[8] 这进一步说明:first responder 与 WebKit 的内部 view 之间存在细节与坑点,最好避免在不必要时触碰 responder 切换。
最终修复:refocus 变成幂等(只有在不 key 时才 make key)
修复点位于两个“回焦”分支中(概念上分别对应:preview popup 打开后回到 source、以及当 quick-panel 可见但焦点状态异常时的兜底回焦)。这两个分支共享同一条规则:
核心改动:
- refocus 前先判断:source panel 当前是否已经是 key window
- refocus 时 如果 panel 已是 key:不做任何“强制 first responder 切换”的动作
- 仅当
!isKeyWindow时才执行:panel.show()panel.make_key_window()
伪代码总结:
fn refocus(panel: PanelHandle) {
if !panel.is_key_window() {
panel.show();
panel.make_key_window();
}
// 不再触碰 makeFirstResponder(contentView)
}
这类修复的本质是:把“回焦”从 imperative 的“强制设置”改为 state-based 的“只在状态不满足时才修正”。
对 AppKit 这种多层状态机来说,这是让系统稳定下来的关键。
经验教训(可迁移的最佳实践)
- 明确你要恢复的是哪一层焦点:active app?key window?first responder?DOM focus?不要用“抢焦点/丢焦点”这种模糊描述。
- 让 focus 操作幂等化:任何“refocus/restoreFocus”都应该先检查状态,再决定是否执行有副作用的 API。
- 慎用
makeFirstResponder:它会触发resignFirstResponder链路,并且当目标 view 不接受时会有回退行为;在 WebView 场景里尤其容易造成“键盘黑洞”。[1][2] - nonactivating panel 并不只是一个 styleMask:当你动态切换
.nonactivatingPanel,WindowServer 的 prevents-activation tag 可能与 AppKit 视角不一致,导致“看起来 key 但收不到键盘”。Phil 的文章把这类失配解释得非常清楚,并给出 workaround 思路。[4][5] - 生态限制要纳入设计:Tauri/Tao 在 macOS 上对
focusable(false)等能力仍有已知边界与讨论;遇到“按配置不生效”时,先去上游确认已知问题与平台差异。[6][7]
参考(References)
- [1] Apple Doc:
NSWindow.makeFirstResponder(_:)— https://developer.apple.com/documentation/appkit/nswindow/makefirstresponder(_:) - [2] Apple Doc:
NSResponder.resignFirstResponder()— https://developer.apple.com/documentation/appkit/nsresponder/resignfirstresponder() - [3] Apple Doc:
NSWindow.orderFrontRegardless()(不改变 key/main)— https://developer.apple.com/documentation/appkit/nswindow/orderfrontregardless() - [4] Apple Doc:
NSWindow.StyleMask.nonactivatingPanel— https://developer.apple.com/documentation/appkit/nswindow/stylemask-swift.struct/nonactivatingpanel - [5] Phil: The Curious Case of NSPanel's Nonactivating Style Mask Flag(WindowServer tag 失配分析)— https://philz.blog/nspanel-nonactivating-style-mask-flag/
- [6] tao PR: focusable property /
Window::set_focusable— https://github.com/tauri-apps/tao/pull/1120 - [7] tauri issue: macOS 上
focusable: false行为异常 — https://github.com/tauri-apps/tauri/issues/14102 - [8] WebKit Bug 143482: Calling makeFirstResponder on WKWebView doesn't work — https://bugs.webkit.org/show_bug.cgi?id=143482