按下 Fn 就说话:一个 macOS 语音输入工具的三个坑

一个键换掉一串动作

我日常重度使用豆包输入法的语音输入(豆包是字节跳动的 AI 助手产品,配套的输入法自带语音识别)。它很好用,但启动路径有点长:先把系统输入法切到豆包,再双击 Option 启动语音识别;说完之后,还要把输入法切回原来常用的那个。这套动作高频重复,每次都会打断手上的节奏。

动作本身不复杂,烦在频次。于是我给自己做了一个 macOS 菜单栏工具,把整套动作压缩成一个键:

  • 按一下 Fn:切到豆包输入法,模拟双击左 Option,语音输入开始;
  • 再按一下 Fn:停止语音输入,恢复最近一次使用的非豆包输入源。

工具常驻菜单栏,没有主窗口,日常的全部交互就是那一个 Fn 键。

实现上有两个不起眼但关键的细节。一是 Fn 键的识别:按下 Fn 时,系统产生的是一个 keycode 为 179 的 keyDown 事件,需要在事件流里把它识别为 Fn 本身。二是时序:Fn 触发后要延迟 0.2 秒再模拟 Option 双击——立刻双击也能启动语音输入,但识别准确度会明显下降,推测是语音引擎还没就绪。

最初版本是用 Hammerspoon(macOS 上用 Lua 写自动化脚本的工具)实现的,很快跑通了主流程。日常用了一段时间后,我决定用 Swift 重写成原生菜单栏 App:脚本方案的事件处理稳定性和启动依赖都不可控,一个打算长期日用的工具,值得原生化。重写过程里踩了三类坑,都在 macOS 系统编程的深水区,逐个记录。

坑一:CGEventTap 回调被系统静默禁用

症状很有迷惑性:工具闲置一段时间后,第一次按 Fn 没反应,再按一下又恢复正常。没有崩溃,也没有任何日志。

根因是 CGEventTap 的一个保护机制:tap 的回调如果在约 1 秒内没有返回,系统会认定它出了问题,直接把这个 tap 禁用,后续事件不再经过它。这个机制本身合理——事件 tap 处在系统输入链路上,一个卡住的回调会拖累所有应用的按键响应——但「禁用」这个动作完全静默,不主动检查,就永远不会发现 tap 已经失效。

我的回调里恰好有阻塞调用:用 TIS(Text Input Source Services)接口读取当前输入法。这类接口涉及跨进程通信,偶尔会慢,一慢就可能超过约 1 秒的限制。闲置后更容易复现,和 App Nap 有关:macOS 会把闲置进程降频调度,回调超时的概率随之变大。

最终的解法是四条一起上:

  • EventTap 挪到独立线程,与主线程隔离,避免互相拖累;
  • 加 watchdog,定时检查 tap 的启用状态,发现被禁用就重建——销毁旧的 tap,重新创建并挂回事件链路,代价很小;
  • 进程禁用 App Nap,避免闲置时被降频;
  • 回调内不做任何阻塞调用——回调只做最轻量的判断,重活全部抛出去异步执行。

四条各有分工:独立线程和零阻塞回调降低超时的概率,禁用 App Nap 处理闲置场景,watchdog 负责兜底——就算前面全部失效,tap 也会在下一次检查时被重建拉起。

坑二:输入法「假切换」

第二个坑出在输入法切换上。在一些 Electron 应用里,调用 TIS 接口切换输入法之后,菜单栏的输入法图标已经变了,但继续打字,实际生效的还是旧输入法:系统层面切换成功,前台应用没有跟上。

解法是对前台 App 做一次焦点刷新,强制它重新感知输入法的变化。但焦点刷新会带来可见的闪动,不适合无差别启用,所以做成了配置白名单:只对已知存在这个问题的 Electron 应用开启,其余应用维持默认行为。

这个坑又牵出一个相关的问题:怎么可靠地判断「前台 App」。最直觉的 NSWorkspace.frontmostApplication 并不可靠——Raycast 这类浮窗应用弹出时,它会给出误判。最终采用三层判定顺序:

  1. 先用 AX(辅助功能)接口取系统焦点所在的应用;
  2. 取不到,就找最上层的可见窗口,反查它所属的应用;
  3. 都失败,才用 frontmostApplication 兜底。

顺带一提,AX 接口和 CGEventTap 都依赖系统的辅助功能授权,这是这类键盘工具绕不开的前置条件。

坑三:模拟修饰键的状态残留

第三类坑出在「模拟双击 Option」这个看似最简单的环节。双击 Option 的合成序列是四个事件:按下、抬起、按下、抬起。事件本身不难发,问题全部出在修饰键的状态管理上。

  • 事件源要用 .hidSystemState 创建,让合成事件携带的修饰键状态和系统实际状态保持一致;
  • 释放修饰键时,只移除对应的 flag 位。图省事把 flags 整个清零的话,用户真实按着的其他修饰键也会被一起清掉——比如用户恰好按着 Shift,合成事件一结束,这个 Shift 就「失灵」了;
  • 整个流程结束后检查残留位,发现还有修饰键停留在按下状态,就补发抬起事件。

这一节还有一个结论是「不修」。录屏软件的按键可视化一直显示我合成的 Option 处于按住状态,看起来像抬起事件丢了。逐项核对后确认:事件的收发都正确,行为本身没有问题,是录屏软件对合成事件的显示有偏差。这条我标记为不修。排障时把「真 bug」和「观测工具的假象」区分开,本身就是一课——观测手段也是软件,也有自己的 bug。

稳定性比功能费功夫

这个工具的功能一句话就能说完,但回头看代码,真正花功夫的部分几乎都和功能无关:独立线程、watchdog、App Nap、状态残留检查、白名单降级。原因在于这类常驻工具的验收标准是「几个月无感常驻」——不用打开界面,不用重启,最好想不起它的存在。任何一次「按了没反应」都在消耗信任,次数多了,我自己都会退回手动切输入法的老路。

这也解释了为什么这些坑大多要在常驻运行中才暴露:短时间试用什么问题都没有,跑得久了,tap 被禁用、修饰键残留这类低概率事件才会一一浮现。

从 Hammerspoon 到原生重写的决定,逻辑也是同一条:脚本方案验证想法很快,但要做到「无感常驻」,事件处理和启动依赖都得握在自己手里。功能很快就能写完,稳定是在日常使用里一点点修出来的。

这个工具的定位、取舍和当前状态,写在它的项目页