global-hotkeys

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Global Hotkeys — System-Wide Keyboard Shortcuts

全局快捷键 — 系统级键盘快捷键

Critical Constraints

关键约束

  • ❌ DO NOT use only
    addGlobalMonitorForEvents
    → ✅ Also add
    addLocalMonitorForEvents
    (global doesn't fire when YOUR app is focused)
  • ❌ DO NOT forget Accessibility permission → ✅ Global event monitoring requires
    AXIsProcessTrusted()
  • ❌ DO NOT compare raw
    modifierFlags
    → ✅ Mask with
    .deviceIndependentFlagsMask
    first
  • ❌ DO NOT use NSEvent for system-wide hotkeys in sandboxed apps → ✅ Use Carbon
    RegisterEventHotKey
    for reliability
  • ❌ 请勿仅使用
    addGlobalMonitorForEvents
    → ✅ 同时添加
    addLocalMonitorForEvents
    (当你的应用处于焦点时,全局监控不会触发)
  • ❌ 请勿忘记辅助功能权限 → ✅ 全局事件监控需要
    AXIsProcessTrusted()
    授权
  • ❌ 请勿直接比较原始
    modifierFlags
    → ✅ 先使用
    .deviceIndependentFlagsMask
    进行掩码处理
  • ❌ 沙盒应用中请勿使用NSEvent实现系统级快捷键 → ✅ 使用Carbon
    RegisterEventHotKey
    以确保可靠性

Decision Tree

决策树

In-app shortcut only?
└── SwiftUI .onKeyPress or .keyboardShortcut

System-wide, non-sandboxed?
├── Simple → NSEvent global + local monitors
└── Reliable → Carbon RegisterEventHotKey

System-wide, sandboxed (App Store)?
└── Carbon RegisterEventHotKey + Accessibility entitlement
仅需应用内快捷键?
└── SwiftUI .onKeyPress 或 .keyboardShortcut

系统级需求,非沙盒应用?
├── 简单实现 → NSEvent 全局+本地监控
└── 可靠实现 → Carbon RegisterEventHotKey

系统级需求,沙盒应用(App Store)?
└── Carbon RegisterEventHotKey + 辅助功能权限

Method 1: NSEvent Monitors (Simple)

方法1:NSEvent监控(简单实现)

swift
import AppKit

class HotkeyManager {
    private var globalMonitor: Any?
    private var localMonitor: Any?
    var onHotkey: (() -> Void)?
    var modifiers: NSEvent.ModifierFlags = [.command, .shift]
    var keyCode: UInt16 = 49  // Space

    func start() {
        globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
            self?.handleEvent(event)
        }
        localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
            self?.handleEvent(event)
            return event
        }
    }

    func stop() {
        if let m = globalMonitor { NSEvent.removeMonitor(m) }
        if let m = localMonitor { NSEvent.removeMonitor(m) }
    }

    private func handleEvent(_ event: NSEvent) {
        let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
        if flags == modifiers && event.keyCode == keyCode {
            DispatchQueue.main.async { self.onHotkey?() }
        }
    }
}
swift
import AppKit

class HotkeyManager {
    private var globalMonitor: Any?
    private var localMonitor: Any?
    var onHotkey: (() -> Void)?
    var modifiers: NSEvent.ModifierFlags = [.command, .shift]
    var keyCode: UInt16 = 49  // Space

    func start() {
        globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
            self?.handleEvent(event)
        }
        localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
            self?.handleEvent(event)
            return event
        }
    }

    func stop() {
        if let m = globalMonitor { NSEvent.removeMonitor(m) }
        if let m = localMonitor { NSEvent.removeMonitor(m) }
    }

    private func handleEvent(_ event: NSEvent) {
        let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
        if flags == modifiers && event.keyCode == keyCode {
            DispatchQueue.main.async { self.onHotkey?() }
        }
    }
}

Method 2: Carbon API (Reliable, System-Wide)

方法2:Carbon API(可靠的系统级实现)

swift
import Carbon

class CarbonHotkeyManager {
    private var hotkeyRef: EventHotKeyRef?
    private var eventHandler: EventHandlerRef?
    var onHotkey: (() -> Void)?

    func register(keyCode: UInt32, modifiers: UInt32) {
        unregister()
        var hotkeyID = EventHotKeyID()
        hotkeyID.signature = OSType("HTKY".fourCharCodeValue)
        hotkeyID.id = 1

        var eventType = EventTypeSpec(
            eventClass: OSType(kEventClassKeyboard),
            eventKind: UInt32(kEventHotKeyPressed)
        )

        let handler: EventHandlerUPP = { _, event, userData in
            let mgr = Unmanaged<CarbonHotkeyManager>.fromOpaque(userData!).takeUnretainedValue()
            mgr.onHotkey?()
            return noErr
        }

        InstallEventHandler(GetApplicationEventTarget(), handler, 1, &eventType,
                           Unmanaged.passUnretained(self).toOpaque(), &eventHandler)
        RegisterEventHotKey(keyCode, modifiers, hotkeyID,
                           GetApplicationEventTarget(), 0, &hotkeyRef)
    }

    func unregister() {
        if let ref = hotkeyRef { UnregisterEventHotKey(ref) }
        if let h = eventHandler { RemoveEventHandler(h) }
    }
}

extension String {
    var fourCharCodeValue: FourCharCode {
        utf8.reduce(0) { ($0 << 8) + FourCharCode($1) }
    }
}
swift
import Carbon

class CarbonHotkeyManager {
    private var hotkeyRef: EventHotKeyRef?
    private var eventHandler: EventHandlerRef?
    var onHotkey: (() -> Void)?

    func register(keyCode: UInt32, modifiers: UInt32) {
        unregister()
        var hotkeyID = EventHotKeyID()
        hotkeyID.signature = OSType("HTKY".fourCharCodeValue)
        hotkeyID.id = 1

        var eventType = EventTypeSpec(
            eventClass: OSType(kEventClassKeyboard),
            eventKind: UInt32(kEventHotKeyPressed)
        )

        let handler: EventHandlerUPP = { _, event, userData in
            let mgr = Unmanaged<CarbonHotkeyManager>.fromOpaque(userData!).takeUnretainedValue()
            mgr.onHotkey?()
            return noErr
        }

        InstallEventHandler(GetApplicationEventTarget(), handler, 1, &eventType,
                           Unmanaged.passUnretained(self).toOpaque(), &eventHandler)
        RegisterEventHotKey(keyCode, modifiers, hotkeyID,
                           GetApplicationEventTarget(), 0, &hotkeyRef)
    }

    func unregister() {
        if let ref = hotkeyRef { UnregisterEventHotKey(ref) }
        if let h = eventHandler { RemoveEventHandler(h) }
    }
}

extension String {
    var fourCharCodeValue: FourCharCode {
        utf8.reduce(0) { ($0 << 8) + FourCharCode($1) }
    }
}

Carbon Modifier Constants

Carbon修饰符常量

swift
// Carbon modifier flags for RegisterEventHotKey
let cmdKey: UInt32    = UInt32(cmdKey)      // 256
let shiftKey: UInt32  = UInt32(shiftKey)    // 512
let optionKey: UInt32 = UInt32(optionKey)   // 2048
let controlKey: UInt32 = UInt32(controlKey) // 4096

// Example: Cmd+Shift+Space
register(keyCode: 49, modifiers: UInt32(cmdKey) | UInt32(shiftKey))
swift
// 用于RegisterEventHotKey的Carbon修饰符标志
let cmdKey: UInt32    = UInt32(cmdKey)      // 256
let shiftKey: UInt32  = UInt32(shiftKey)    // 512
let optionKey: UInt32 = UInt32(optionKey)   // 2048
let controlKey: UInt32 = UInt32(controlKey) // 4096

// 示例:Cmd+Shift+Space
register(keyCode: 49, modifiers: UInt32(cmdKey) | UInt32(shiftKey))

Common Key Codes

常用按键代码

KeyCodeKeyCodeKeyCode
Space49Return36Escape53
Tab48Delete51A0
S1D2F3
J38K40L37
按键代码按键代码按键代码
空格49回车36退出53
制表符48删除51A0
S1D2F3
J38K40L37

SwiftUI In-App Shortcuts

SwiftUI应用内快捷键

swift
// Keyboard shortcut on button
Button("Save") { save() }
    .keyboardShortcut("s", modifiers: .command)

// Raw key press handler
.onKeyPress(.escape) { dismiss(); return .handled }
.onKeyPress(.upArrow) { moveUp(); return .handled }
.onKeyPress(.downArrow) { moveDown(); return .handled }
swift
// 按钮绑定键盘快捷键
Button("保存") { save() }
    .keyboardShortcut("s", modifiers: .command)

// 原生按键事件处理
.onKeyPress(.escape) { dismiss(); return .handled }
.onKeyPress(.upArrow) { moveUp(); return .handled }
.onKeyPress(.downArrow) { moveDown(); return .handled }

Accessibility Permission Check

辅助功能权限检查

swift
func checkAccessibilityPermissions() -> Bool {
    let options = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as String: true]
    return AXIsProcessTrustedWithOptions(options as CFDictionary)
}

// Non-prompting check
func isAccessibilityGranted() -> Bool {
    AXIsProcessTrusted()
}
swift
func checkAccessibilityPermissions() -> Bool {
    let options = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as String: true]
    return AXIsProcessTrustedWithOptions(options as CFDictionary)
}

// 无提示检查
func isAccessibilityGranted() -> Bool {
    AXIsProcessTrusted()
}

Common Mistakes & Fixes

常见错误与修复方案

MistakeFix
Hotkey works everywhere except own appAdd local monitor alongside global monitor
Modifier comparison failsMask with
.deviceIndependentFlagsMask
Hotkey fires twiceDebounce with timestamp check (0.3s threshold)
Doesn't work on first launchCheck/request Accessibility permission
错误修复方案
快捷键在其他应用正常,但在自身应用中无效同时添加本地监控与全局监控
修饰符比较失败使用
.deviceIndependentFlagsMask
进行掩码处理
快捷键触发两次通过时间戳检查实现防抖(阈值0.3秒)
首次启动时无法工作检查/请求辅助功能权限

References

参考资料