Tinycast 全局热键系统深度解析:Carbon 快捷键、双击修饰键与 Hyper Key 的实现架构
【免费下载链接】tinycastTinycast — a tiny, fully native macOS launcher, hotkeys, and clipboard history.项目地址: https://gitcode.com/GitHub_Trending/ti/tinycast
Tinycast 在Features/HotKeys/中实现了一套完全自研、零第三方依赖的全局热键子系统:既支持传统的「修饰键 + 按键」组合快捷键(底层使用 macOS CarbonRegisterEventHotKey),也支持独创的「双击孤立修饰键」触发方式,还内置了将 Caps Lock 或右侧修饰键改造成 ⌃⌥(⇧)⌘ Hyper 和弦的能力。本文以 docs/features/hotkeys.md 为骨架,结合 HotKeys 模块 的完整源码与 Tests/hotkey-test.swift 测试用例,逐层拆解其数据模型、持久化方案、两套触发引擎与录制器的工作原理,读完你将对 macOS 全局热键的工程化实现有完整的认识,并能直接在本仓库中找到每个结论对应的代码证据。
一、模块总览:五个组件,一个统一入口
Features/HotKeys/由五个核心组件构成,职责边界非常清晰:
| 组件 | 位置 | 职责 |
|---|---|---|
KeyShortcut | Service/KeyShortcut.swift | Sendable 模型,承载 Carbon keycode + modifiers,通过UCKeyTranslate/ASCIIKeyboardLayout生成布局感知的键帽字形 |
HotKeyBinding | Model/HotKeyBinding.swift | 动作真正绑定到的对象:.combo(KeyShortcut)或.doubleTap(DoubleTapModifier)二选一 |
HotKeyCenter | Service/HotKeyCenter.swift | CarbonRegisterEventHotKey注册层,支持整体暂停(pause) |
DoubleTapModifier/DoubleTapDetector/DoubleTapMonitor | Model/DoubleTapModifier.swift、Model/DoubleTapDetector.swift、Service/DoubleTapMonitor.swift | 双击修饰键识别栈 |
HotKeyManager | Service/HotKeyManager.swift | 统一所有者:持久化、冲突查找、事件分发 |
模块设计的核心思想体现在HotKeyBinding这个类型上:每个动作只绑定一个HotKeyBinding,而它的两个 case 对应两套不同的触发引擎。.combo走 Carbon 注册,.doubleTap由DoubleTapMonitor识别(因为 Carbon 根本看不到孤立修饰键的按下)。由于所有动作共享同一个绑定模型,持久化、冲突检测、录制器、键帽渲染都只有一份实现——只有底层引擎不同。
二、贯穿全局的五条不变量(Invariants)
源码与文档反复强调以下不变量,它们是这套系统不出 bug 的结构性保证:
热键以 JSON 字符串持久化在
hotkey.<action>对应的 UserDefaults key 下,而 key 的计算集中在一个地方——HotKeyAction.defaultsKey。这个值同时也是HotKeyCenter的注册 id,因此持久化 key 与注册 id 永远不会漂移。见 Model/HotKeyAction.swift 中的实现。命令的快捷键与其启动器(launcher)行走同一条分发漏斗。
HotKeyAction.command(CommandID)对整个命令目录参数化,最终通过LauncherCoordinator.runCommand分发。这意味着:新增一个内置命令不需要任何热键管线的额外代码即可被绑定,而且每个命令只有一份行为,而不是每种调用路径一份。打开某个 palette 模式的命令,快捷键具备切换(toggle)语义。它们统一经由
PaletteCoordinator.togglePalette(mode:)进入,所以第二次按下会关闭第一次打开的界面;而从启动器行进入时 palette 处于.launcher模式,因此行始终是重新指向(re-point)而不是切换。HotKeyBinding是动作绑定的唯一对象,两个 case 对应两套引擎,已在模块总览中说明;其Codable使用合成的实现(见下文持久化格式)。KeyShortcut手写的init(from:)不是格式兼容层,而是正确性接缝:它让所有解码都经过那个会把设备修饰位(device modifier bits)掩掉的初始化器,保证从磁盘读出的 modifiers 一定落在真实修饰键集合内。源码在 KeyShortcut.swift 中可以看到这个接缝的实现。
另外两条与工程纪律相关的约束也值得注意:Model/DoubleTapModifier.swift与Model/DoubleTapDetector.swift必须保持Foundation-only 且纯函数(时钟以参数注入),这样才能被hotkey-test直接驱动;所有CGEvent调用都收敛在Service/DoubleTapMonitor.swift,它是listen-only的,只有确实有动作绑定到双击时才安装,并且从不主动弹出辅助功能(Accessibility)授权提示。
三、持久化模型:一处计算 key,多套 bound-ID 索引
3.1 defaultsKey:UserDefaults key 与注册 id 的唯一出处
HotKeyAction枚举定义了 Tinycast 中一切可以绑定全局快捷键的目标,defaultsKey为每一种生成稳定的存储 key,例如:
case .togglePalette: "hotkey.togglePalette" case .command(let id): "hotkey." + id.rawValue // 如 hotkey.command:clipboard-history case .app(let bundleID): "hotkey.app." + bundleID case .customCommand(let id): "hotkey.customCommand." + id.uuidString.lowercased() case .systemAction(let id): "hotkey.systemAction." + id.rawValue case .windowCommand(let id): "hotkey.windowCommand." + id.rawValue case .windowLayout(let id): "hotkey.windowLayout." + id.uuidString.lowercased() case .quicklink(let id): "hotkey.quicklink." + id.uuidString.lowercased() case .appleShortcut(let id): "hotkey.appleShortcut." + id.uuidString.lowercased() case .extensionCommand(let entryID): "hotkey.extensionCommand." + entryID完整的 key 映射见 HotKeyAction.swift。值得注意extensionCommand以AppEntry.id为 key——这是扩展重装后依然存活的身份标识。
3.2 固定目录 vs 逐项记录:bound-ID 索引的两种形态
持久化层面存在两种截然不同的场景,处理方式也不同:
固定目录(fixed catalog):系统动作(System Actions)与窗口命令(Window Commands)持久化在
hotkey.systemAction.<raw-id>与hotkey.windowCommand.<raw-id>,不需要任何 bound-ID 索引。因为start()和冲突查找可以直接遍历allCases,而register对未绑定的项是 no-op(见 HotKeyManager.swift)。逐项记录(per-item):应用绑定、设置面板、自定义命令、快捷链接、窗口布局、自定义窗口尺寸等使用各自的 UUID 或 bundle ID。文档中列出对应的索引集合:
boundAppBundleIDs(应用)、boundPaneBundleIDs(设置面板)——启动时重新注册;boundCustomCommandIDs、boundQuicklinkIDs、boundWindowLayoutIDs、boundCustomWindowSizeIDs(以及源码中的boundQuickActionIDs、boundExtensionCommandEntryIDs、boundAppleShortcutIDs)——按 UUID 索引。
逐项场景没有
allCases可遍历,所以每个都需要一个索引,供start()重新注册,也用于清理(prune)在 Tinycast 未运行期间记录已被删除的绑定。这正是QuicklinkStore即使功能关闭也会在启动时加载的原因(详见 docs/features/quicklinks.md)。Apple Shortcuts 保留了同样的索引boundAppleShortcutIDs,但不在启动时清理,而是在首次成功读取快捷指令库之后清理——因为一次失败的读取与「库已被删空」在表现上完全相同,提前清理会误删(详见 docs/features/apple-shortcuts.md)。
3.3 磁盘格式:合成 Codable 与手写解码接缝
HotKeyBinding采用合成的Codable,因此两种绑定的磁盘 JSON 形态为:
// 组合快捷键 {"combo":{"_0":{"carbonKeyCode":N,"carbonModifiers":N}}} // 双击修饰键 {"doubleTap":{"_0":"command"}}KeyShortcut则保留手写的init(from:)——它不是格式接缝,而是保证每次解码都经过掩去设备修饰位(device modifier bits)的初始化器。SettingsBackup.HotkeyBackup存储的是同一份值,因此备份文件携带同样的结构;但文档明确指出,只有同一构建版本内的「导出 → 导入」能保证无损往返(round-trip)。
3.4 内置命令的 deny-list 语义
每个内置命令都可绑定:CommandID.hotKeyAction默认返回.command(self),并显式命名三个例外,见 CommandID.swift:
/// Query-driven: the typed text is their input, so they are built where offered, never listed. var isQueryDriven: Bool { self == .openInBrowser || self == .runShellCommand } /// A chord carries no query, and none should be able to terminate the app outright. var hotKeyAction: HotKeyAction? { isQueryDriven || self == .quit ? nil : .command(self) }- Open in Browser 与 Run Shell Command 是查询驱动(query-driven)的——它们的输入是用户键入的文本,而一个组合键没有任何键入文本;
- Quit 被扣留——不允许任何组合键直接终止应用。
关键是这份列表是deny-list(拒绝清单)而非 allow-list(允许清单),所以新增命令无需改动这里即可被绑定。绑定持久化在hotkey.<command raw value>下,例如hotkey.command:clipboard-history;这同时决定了命令行上出现录制器、每个启动器行上出现键帽。该行只存在于一个设置面板中——Settings ▸ Commands,或者当SettingsTab.ownedCommands指定时位于功能自己的面板。hotkey.togglePalette是唯一没有命令行的固定动作。HotKeyManager通过CommandID统一命名它们,因此冲突提示(callout)中动作的拼写与命令行完全一致。
与窗口命令一样,组合键的注册不依赖于启动器行是否隐藏——Search Files 与 Notes 在打开前会各自复查功能开关(见 docs/features/file-search.md 与 docs/features/notes.md)。隐藏启动器行不会禁用快捷键,但关闭功能会。SettingsBackup.HotkeyBackup以按CommandIDraw value 为 key 的单一commandsmap 承载它们。
窗口命令与系统动作还有一个统一的安全语义:注册的窗口命令快捷键在功能开关关闭时不会执行任何操作——WindowCommandCoordinator.runWindowCommand会重新检查开关(见 docs/features/window-management.md);系统动作快捷键同样经由SystemActionCoordinator.runSystemAction(id:),因此确认门槛(confirmation gate)对热键与 palette 完全一致。
四、双击修饰键引擎:Carbon 看不见的触发方式
任何动作都可以改为绑定到双击某个孤立修饰键——⌃、⌥、⇧ 或 ⌘。Carbon 根本无法注册纯修饰键快捷键,所以这是一套独立的引擎,只在HotKeyBinding处与组合键路径汇合。
4.1 识别器:纯函数、时钟注入、可测试
DoubleTapDetector是识别核心,Foundation-only、纯函数、时钟注入(now是调用方提供的单调时间戳),因此 Tests/hotkey-test.swift 无需事件 tap 就能驱动它做精确的边界测试。
一次tap(轻按)的定义非常严格:
- 按下从没有任何修饰键被按住的状态开始;
- 保持四个可绑定修饰键中恰好一个被按住,且不能同时按住
fn; - 期间没有按键按下或鼠标点击(
otherInput会立即使进行中的按压作废); - 在
maxHold内释放——250 ms,与HyperKeyTap判定「快速按压」的窗口一致。
一次double-tap(双击)是:从第一次释放起的maxGap(300 ms)内,同一修饰键开始第二次 tap。两次 tap 的完整状态机实现在 DoubleTapDetector.swift。
4.2 两个关键细节:Caps Lock 的 latch 与第二次释放触发
只有瞬时(momentary)按键才能进入
hasOtherModifiers判定。Caps Lock 必须被排除:maskAlphaShift追踪的是闩锁(latch)状态而非按压,一旦 Caps Lock 处于开启状态,用它来测试会让每一次 tap 都被判不合格,从而静默杀死整个功能。但 Caps Lock 依然不能作为绑定目标——那是 Hyper Key 的职责。它在第二次释放(second release)时触发,而不是第二次按下时。触发时修饰键已经抬起,因此 palette 不会带着幽灵 ⌘ 打开,焦点恢复也不会被污染;同时「双击并按住」被刻意设计为非事件(deliberate non-event)。测试文件明确断言了这一语义,见 hotkey-test.swift:第二次按下不触发,第二次释放才触发。
4.3 监视器:listen-only、按需安装、双平台细节
DoubleTapMonitor是唯一的平台文件,一个listen-only 的CGEventTap,且只在确有动作绑定到双击时安装,所以从不使用该功能的用户零开销。两个细节是承重墙:
它是
.tailAppendEventTap(追加式),与另外两个 head-inserted 的 tap 不同——它在HyperKeyTap改写之后观察事件。因此,被 Hyper 重映射过的右侧修饰键到达时已是完整的 ⌃⌥⇧⌘ 和弦,能正确判定为「不是孤立修饰键」;而其左侧孪生键依然可以双击。见 DoubleTapMonitor.swift 中tapCreate的place: .tailAppendEventTap参数。与所有键盘 tap 一样需要Accessibility 授权,但它从不主动弹出授权提示:绑定照常记录;录制器显示一条内联警告引导用户打开系统设置;一秒周期的健康计时器(
healthCheck,见 DoubleTapMonitor.swift)在授权落地的瞬间安装 tap,并能处理 tap 被系统禁用、授权被吊销等恢复场景。
⇧ 也可以这样绑定,尽管KeyShortcut拒绝裸 ⇧ 组合——因为双击是无歧义的,而裸 ⇧ 组合会遮蔽正常打字。DoubleTapModifier的键帽渲染为两个相同的修饰符字形(如⌃⌃),见 DoubleTapModifier.swift。
五、Hyper Key:把一颗物理键变成 ⌃⌥(⇧)⌘ 和弦
HyperKeyTap将一颗物理键——Caps Lock 或右侧修饰键——在全系统范围内改写成 ⌃⌥(⇧)⌘ 和弦。它是一个modifying(改写型)CGEventTap,与HotKeyCenter是相互独立的层,因为Carbon 根本无法拦截孤立按键。改写后的 flags 会继续流入 Carbon 匹配,所以既有的组合快捷键无需任何额外注册,就能从 Hyper+key 触发。
选择哪颗键以HyperKey字符串 raw value 持久化在AppSettings中——重命名一个 case 就是一次迁移,删除的 case 解码为.none。HyperKeyPhysicalKey的候选集合见 Model/HyperKey.swift:.none、.capsLock、.rightControl、.rightShift、.rightOption、.rightCommand。
F 键被刻意排除在候选之外:顶行媒体功能键在 tap 之下触发,把 F1 绑定为 Hyper 依然会导致屏幕变暗(F1 原本的亮度调节行为无法被拦截)。
5.1 Caps Lock 必须先在源头不再是 Caps Lock
大小写锁定开关——包括 LED 与闩锁——发生在每一个CGEventTap之下,任何 tap 都无法抑制它。因此该键必须在源头停止充当 Caps Lock:当 Caps Lock 被用作 Hyper 时,CapsLockRemap安装一个 IOKitUserKeyMapping,把它重映射为F18——这与hidutil用的是同一机制。tap 随后拦截 F18。重映射在解绑与退出时清除,绝不跨重启存续。重映射在串行队列上执行,因此快速 on→off→on 切换按调用顺序落地,而不会作为独立 detached 任务竞态、留下错误的最终状态。源码实现见 HyperKeyTap.swift 中的CapsLockRemap(映射 JSON 直接以 HID usage 写死:Caps Lock0x700000039→ F180x70000006D)。
由于重映射是异步的,在它生效前的窗口期有一个回退方案:按键仍以 Caps Lock 到达,tap 改走修饰键路径。该窗口内 LED 会切换——这是未重映射的 HID 行为,Tinycast 无法阻止。
一旦重映射完成,Caps Lock 以keyDown/keyUp(而非flagsChanged)到达。两端都会被转换成 Left Control 的flagsChanged转换事件,于是下游所有组件看到的是 Hyper 和弦随按键移动,而不是一次被吞掉的按压。这一转换也是从两端擦除 fn 位的原因:每个功能键都上报NX_SECONDARYFNMASK,在 keyDown 上无害,但一旦事件变成flagsChanged就会被读成真实的 fn 按压,从而在每次 Hyper 按下时触发任何绑定到 fn 的东西。只有 Hyper 键自己的两个事件被擦除,所以 Hyper+F 键、Hyper+方向键依然保留它们应得的 fn 位。此外,一个经典的IOHIDSystem连接用于读写 Caps Lock LED 与锁状态——它只服务于显式的 Quick Press 开关,以及安装重映射时的一次性解除闩锁。
5.2 按压跟踪使用 toggle 语义
flagsChanged不描述自身方向,所以修饰键型式的 Hyper 键靠**翻转(toggling)**来跟踪。显而易见的替代方案——查询CGEventSource的按键状态——会与释放产生竞态,导致状态机反转并破坏 Quick Press。因此一次遗漏的释放最多滞留到看门狗或下一次按下将其清除。凡是会投递事件或触碰 IOKit 的工作,都被推迟到下一个 runloop 轮次执行,而不是在 tap 回调内部运行(避免重入风险)。
每个被改写事件 OR 进去的 flags 是通用 ⌃⌥(⇧)⌘ 掩码加上左侧设备位(NX_DEVICE…KEYMASK,取自IOLLEvent.h)——某些消费者会区分左右侧,仅通用的 flags 不一定被读作「完全按下」。Hyper 键自身的残留也在同一次处理中擦除:Caps Lock 的 alpha-shift 位,或(对 Hyper 集合之外的修饰键)其通用掩码加两个设备位。tap 投递的事件在.eventSourceUserData中携带"TYCT"标记——与HotKeyCenter使用的同一个 FourCC(0x5459_4354,见 HotKeyCenter.swift)——这样 tap 永远不会响应自己产生的合成事件。
Quick Press 按键被投递时显式清空flags,与应用中所有其他合成事件一致。原因很微妙:从.combinedSessionState构建的键盘事件会继承事件源的修饰键,而结束按住的那次释放在一个 runloop 轮次后仍在飞行中——于是 Escape 曾以 ⌃⌥⇧⌘Escape 的形式发出。终端读取原始0x1B不关心;但聚焦的文本编辑器与任何精确匹配的键映射会吞掉它——这正是 Quick Press 在 Ghostty 中正常、却在 Zed 和 palette 自身中失效的原因。
5.3 ✦ 是记法,不是偏好设置
任何修饰键是 Hyper 和弦超集的组合,渲染时都会把 Hyper 集合折叠成一颗✦键帽——✦G,或当某个修饰键在减法后幸存时渲染为✦⇧G。这是无条件的:没有开关,因为折叠和弦本身就是这个功能的定义。唯一前提是确实配置了 Hyper 键——AppCore在未配置时向KeyShortcut.displayedHyperChord传入nil和弦,此时字面的 ⌃⌥⌘G 按原样渲染。该 hook 是闭包而非值:在视图体内读取它会注册AppSettings依赖,因此每次开关变动时所有键帽立即重渲染。折叠逻辑见 KeyShortcut.swift 的collapsedModifierSymbols。
5.4 Include Shift:重新指向已录制的内容
KeyShortcut存储的是绝对 Carbon 修饰键(从已被改写的 flags 捕获),因此在一个 Include Shift 设置下录制的和弦,在另一个设置下就是过期的:它既停止折叠为 ✦,也停止触发——因为 Carbon 注册的是 ⌃⌥⌘,而 tap 已开始发射 ⌃⌥⇧⌘。所以翻转开关会重新指向(re-point)每一个已存储的组合:HotKeyManager.retargetHyperBindings(见 HotKeyManager.swift)由与功能开关相同的AppCore.track观察驱动,把过期和弦通过setBinding换成当前和弦,让持久化与重新注册始终走在同一条路径上。
- 若重指向会落在一个已被其他动作占用的和弦上,则跳过而非覆盖——该行保留字面键帽;
retargetingHyper是幂等的,这正是设置导入是无操作而非损坏的原因;- 当 Hyper 键为
.none时什么都不重指向——且此时 Settings 行被禁用,开关在没有和弦可指时根本无法移动。
重指向算法本身在 KeyShortcut.swift:判断当前 modifiers 是否为过期和弦的超集,若是则做差集替换。
5.5 生命周期与看门狗
与所有键盘 tap 一样,它需要Accessibility授权且从不主动弹窗。当配置了 Hyper 键时,一个一秒看门狗持续运行(HealthCheckable协议):
- 重试安装直到授权落地;
- 察觉授权被吊销;
- 复活被系统因超时或用户输入而禁用的 tap;
- 清除卡住的按住状态。
快速用户切换(fast user switching)时另一会话拥有键盘,因此半按状态被丢弃、改写停止,直到本会话重新激活。HID 重映射的寿命超过进程,所以applicationWillTerminate在退出前把键交还给系统(CapsLockRemap.clearBlocking()同步清除)。
六、录制器:不获取焦点的捕获会话
设置中的录制器(Features/HotKeys/UI/ShortcutRecorder.swift)刻意不是一个可聚焦控件:当前激活的录制器是HotKeyManager.recordingAction状态,按键由本地 NSEvent 监视器捕获,同时两套引擎都处于暂停状态(HotKeyCenter.isPaused与DoubleTapMonitor.isPaused,见 HotKeyManager.swift)。
它同时录制两种绑定——敲一个组合键,或双击一个修饰键——方法是把.flagsChanged/.keyDown监视器喂给与全局监视器同一个DoubleTapDetector(见 ShortcutCaptureSession.swift),因此录制不需要事件 tap,也不需要任何权限。
设置recordingAction是开始与停止捕获的唯一入口,所以整个应用只有一个ShortcutCaptureSession(HotKeys/Service/),而不是每行一个——这正让字段上方的 callout 可以从打开它的行之外渲染实时状态。录制器字段本身只展示绑定;提示语、实时预览和冲突消息全部位于 callout 中。冲突检测通过HotKeyManager.conflictOwner(of:excluding:)做整体绑定比较(见 HotKeyManager.swift),因此组合键与双击两种绑定天然统一。callout 的交互细节可继续阅读 docs/ui.md。
七、测试与验证路径
本模块的工程纪律在测试中体现得最为直接:
- Tests/hotkey-test.swift 用一个虚拟时钟驱动
DoubleTapDetector,精确断言每个时间边界:maxHold/maxGap极限值、第二次释放才触发、慢第一次按压不算 tap、超过间隔不算双击、和弦回卷到单修饰键不算 tap、两个不同修饰键不构成双击、迟到的一次 tap 会为下一对播种等。其Keyboard辅助结构把press/release/otherInput抽象成与全局监视器相同的输入面,证明识别器本身与平台完全解耦。 - 录制器、Hyper 重指向、冲突检测等行为则通过
hotkey-test.swift中的hyperChord()/hyperRetargeting()组与 UI 测试共同覆盖。
整体架构关系图如下:HotKeyBinding位于中心,向上被HotKeyManager持久化/分发,向下分叉为 CarbonHotKeyCenter与DoubleTapMonitor两套引擎;HyperKeyTap在最底层改写事件流,同时为键帽渲染提供 ✦ 折叠语义。这套「单一绑定模型 + 双引擎 + 独立改写层」的设计,让 Tinycast 在不引入任何第三方热键库的前提下,同时获得了组合键、双击修饰键与 Hyper Key 三套完整能力,且每一层都可通过源码与测试独立验证。
【免费下载链接】tinycastTinycast — a tiny, fully native macOS launcher, hotkeys, and clipboard history.项目地址: https://gitcode.com/GitHub_Trending/ti/tinycast
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考