返回 Skill 列表
extension
分类: 开发与工程无需 API Key

global-hotkeys

在macOS上使用NSEvent监控(简单,应用级别)和Carbon EventHotKey API(可靠,系统级)进行全系统键盘快捷键注册。涵盖NSEvent.addGlobalMonitorForEvents和addLocalMonitorForEvents、CGEvent tap用于按键模拟、Carbon RegisterEventHotKey用于系统级热键、修饰键处理(.deviceIndependentFlagsMask)、常见的键码映射、去抖动、Accessibility权限要求(AXIsProcessTrusted)以及SwiftUI .onKeyPress用于应用程序内部快捷键。在实现全局键盘快捷键、由热键触发的面板或全系统的按键事件监控时使用。

person作者: jakexiaohubgithub

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

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

Method 1: NSEvent Monitors (Simple)

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)

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 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))

Common Key Codes

| Key | Code | Key | Code | Key | Code | |-----|------|-----|------|-----|------| | Space | 49 | Return | 36 | Escape | 53 | | Tab | 48 | Delete | 51 | A | 0 | | S | 1 | D | 2 | F | 3 | | J | 38 | K | 40 | L | 37 |

SwiftUI In-App Shortcuts

// 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 }

Accessibility Permission Check

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

// Non-prompting check
func isAccessibilityGranted() -> Bool {
    AXIsProcessTrusted()
}

Common Mistakes & Fixes

| Mistake | Fix | |---------|-----| | Hotkey works everywhere except own app | Add local monitor alongside global monitor | | Modifier comparison fails | Mask with .deviceIndependentFlagsMask | | Hotkey fires twice | Debounce with timestamp check (0.3s threshold) | | Doesn't work on first launch | Check/request Accessibility permission |

References