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() 便捷方法;而这种实现里通常会执行:
    1. NSWindow.makeFirstResponder(contentView)
    2. orderFrontRegardless()
    3. makeKeyWindow()
    根据 Apple 文档,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 只是“浮层/镜头”,不是输入焦点的归属)。

理想状态:

  1. 全局热键唤出 quick-panel → 立即可键盘操作
  2. Shift+↑ 打开 preview popup → preview 显示,但 键盘仍由 quick-panel 驱动
  3. ←/→ 切换条目 → quick-panel selected item 变化,preview 同步更新,但 不发生“键盘输入目标”漂移

复现步骤(用户视角)

复现稳定,典型流程如下:

  1. 前台应用为 VS Code / WeChat,焦点在编辑区。
  2. 用快捷键唤出 quick-panel(horizontal),焦点在 quick-panel(正确)。
  3. 在 quick-panel 中按 Shift+↑ 打开 preview popup:
    • preview popup 能显示(正确)
    • 但从此刻起,quick-panel 的键盘操作(←/→/Shift+↑/Shift+↓)全部失效
  4. 想恢复键盘,只能鼠标点一下 quick-panel;但切换 selected item 后 preview 更新,又会再次失效,需要反复鼠标点回。

为了排查,我们在调试版里加入了结构化焦点日志(例如:窗口是否可见、是否 key、应用是否 active、以及关键的 refocus 分支是否被触发)。


为什么这么难排查:三层“焦点”经常被混为一谈

在 macOS + WebView 方案里,“焦点”至少有三层状态机:

  1. WindowServer / 激活态:哪个 app 是 active,谁拥有菜单栏,谁会收键盘事件。
  2. AppKit / NSWindow:哪个窗口是 key window / main window。
  3. Responder Chain / First Responder:key event 最终送到哪个 responder(firstResponder 很关键)。
  4. (再往上)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 甚至曾经有过 “直接对 WKWebViewmakeFirstResponder 也不一定工作” 的历史 bug(macOS 10.10 时代)。[8] 这进一步说明:first responder 与 WebKit 的内部 view 之间存在细节与坑点,最好避免在不必要时触碰 responder 切换。


最终修复:refocus 变成幂等(只有在不 key 时才 make key)

修复点位于两个“回焦”分支中(概念上分别对应:preview popup 打开后回到 source、以及当 quick-panel 可见但焦点状态异常时的兜底回焦)。这两个分支共享同一条规则:

核心改动:

  1. refocus 前先判断:source panel 当前是否已经是 key window
  2. refocus 时 如果 panel 已是 key:不做任何“强制 first responder 切换”的动作
  3. 仅当 !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 这种多层状态机来说,这是让系统稳定下来的关键。


经验教训(可迁移的最佳实践)

  1. 明确你要恢复的是哪一层焦点:active app?key window?first responder?DOM focus?不要用“抢焦点/丢焦点”这种模糊描述。
  2. 让 focus 操作幂等化:任何“refocus/restoreFocus”都应该先检查状态,再决定是否执行有副作用的 API。
  3. 慎用 makeFirstResponder:它会触发 resignFirstResponder 链路,并且当目标 view 不接受时会有回退行为;在 WebView 场景里尤其容易造成“键盘黑洞”。[1][2]
  4. nonactivating panel 并不只是一个 styleMask:当你动态切换 .nonactivatingPanel,WindowServer 的 prevents-activation tag 可能与 AppKit 视角不一致,导致“看起来 key 但收不到键盘”。Phil 的文章把这类失配解释得非常清楚,并给出 workaround 思路。[4][5]
  5. 生态限制要纳入设计:Tauri/Tao 在 macOS 上对 focusable(false) 等能力仍有已知边界与讨论;遇到“按配置不生效”时,先去上游确认已知问题与平台差异。[6][7]

参考(References)