Skip to content
C.W.K.
Stream
Lesson 05 of 06 · published

Global Hotkeys: Chords, Bare Modifiers, and Keys Without Names

~17 min · macos-citizen, hotkeys, carbon, accessibility, keycodes

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"The chord was recordable and unnameable at once."

Three Ways to Hear a Key You Do Not Own

A utility that answers a shortcut while another app is frontmost has three tools, and they cost different things.

  • Carbon RegisterEventHotKey registers a chord (a key plus modifiers) system-wide and delivers a hot-key event to your app. It needs no Accessibility grant, it does not see other keystrokes, and it is still the tool the family's text transformer, dictation tool and launchers use. The API is old; nothing newer does this job.
  • NSEvent.addGlobalMonitorForEvents observes events headed to other apps. Key-related events arrive only when the app is trusted for Accessibility, and a monitor can observe but never consume.
  • A CGEventTap can listen to events or, as an active filter, modify and swallow them. Listening needs Input Monitoring, an active tap needs Accessibility, and a tap that suppresses the wrong event eats that key for every app on the Mac.

Registration Can Fail, Per Chord

RegisterEventHotKey returns eventHotKeyExistsErr (-9878) when the chord is already registered. Measured by registering ⌘⌥K twice in one process: the first call succeeded and the second returned -9878. The text transformer registers every enabled macro separately and returns a failure per binding for its Settings window, so one collision does not disable the rest, and a duplicate inside the app is reported by name before it ever reaches Carbon.

Function Keys Are Not a Range

Virtual key codes are positions on a keyboard, not a sequence. kVK_F1 is 0x7A and kVK_F12 is 0x6F, so UInt16(kVK_F1)...UInt16(kVK_F12) has its lower bound above its upper bound, and a shortcut recorder built on it trapped with "Range requires lowerBound <= upperBound" the instant any key was pressed. Use an explicit set of the constants. The recorder was coupled to NSEvent and had no tests, so the check moved into a pure helper with a self-test.

A Key the App Cannot Name, It Cannot Accept

Three apps carried copies of the same keycode-to-glyph table. One recorder accepted any key except Escape, Caps Lock and Fn, while its table lacked Home, End, Page Up and Down, F13 to F20 and the keypad. So ⌘⌥Home recorded, saved and registered, and the hotkey worked, while Settings displayed it as "⌘⌥Key 115". The shared kit type now answers "can this key be named" separately from "does this app accept it": a key value can only be constructed through the complete table, so a label with no name cannot exist. Each app still layers its own exclusions on top.

Hold-to-Talk on a Bare Modifier

Carbon cannot express a chord that is only a modifier, like holding right ⌘ or Fn to dictate. The dictation tool watches flagsChanged under the Accessibility grant it already holds, with a global monitor for other apps and a local one for its own windows (a global monitor never sees events sent to its own app), and decides pressed or released with CGEventSource.keyState, because the modifier flag stays set while the other ⌘ key is still down. Fn doubles as the Globe key, so users set the Globe key's action to Do Nothing.

Code

Carbon chord registration that reports each failure, and function keys as a set·swift
import AppKit
import Carbon.HIToolbox

/// kVK_F1 is 0x7A and kVK_F12 is 0x6F: the function keys are not a range.
let functionKeyCodes: Set<UInt16> = Set([
    kVK_F1, kVK_F2, kVK_F3, kVK_F4, kVK_F5, kVK_F6, kVK_F7, kVK_F8, kVK_F9, kVK_F10,
    kVK_F11, kVK_F12, kVK_F13, kVK_F14, kVK_F15, kVK_F16, kVK_F17, kVK_F18, kVK_F19, kVK_F20,
].map { UInt16($0) })

func carbonModifiers(from flags: NSEvent.ModifierFlags) -> UInt32 {
    var mask: UInt32 = 0
    if flags.contains(.command) { mask |= UInt32(cmdKey) }
    if flags.contains(.option) { mask |= UInt32(optionKey) }
    if flags.contains(.control) { mask |= UInt32(controlKey) }
    if flags.contains(.shift) { mask |= UInt32(shiftKey) }
    return mask
}

@MainActor
final class HotkeyCenter {
    private var handler: EventHandlerRef?
    private var refs: [UInt32: EventHotKeyRef] = [:]
    private var actions: [UInt32: () -> Void] = [:]
    private var nextID: UInt32 = 1

    /// Returns nil on success, or a message naming the chord that could not be registered.
    func register(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, label: String,
                  action: @escaping () -> Void) -> String? {
        if handler == nil {
            var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
            InstallEventHandler(GetApplicationEventTarget(), { _, event, userData in
                var hotKeyID = EventHotKeyID()
                GetEventParameter(event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID),
                                  nil, MemoryLayout<EventHotKeyID>.size, nil, &hotKeyID)
                let center = Unmanaged<HotkeyCenter>.fromOpaque(userData!).takeUnretainedValue()
                let id = hotKeyID.id
                Task { @MainActor in center.actions[id]?() }
                return noErr
            }, 1, &spec, Unmanaged.passUnretained(self).toOpaque(), &handler)
        }
        var ref: EventHotKeyRef?
        let id = EventHotKeyID(signature: OSType(0x5350524B), id: nextID)   // 'SPRK'
        let status = RegisterEventHotKey(UInt32(keyCode), carbonModifiers(from: modifiers), id,
                                         GetApplicationEventTarget(), 0, &ref)
        guard status == noErr, let ref else { return "\(label) is already taken (\(status))" }
        refs[nextID] = ref
        actions[nextID] = action
        nextID += 1
        return nil
    }
}
Hold-to-talk on right Command: observe flagsChanged, then ask the key itself·swift
import AppKit
import Carbon.HIToolbox

@MainActor
final class HoldToTalk {
    private var monitors: [Any] = []
    private let keyCode = CGKeyCode(kVK_RightCommand)
    private var held = false

    func start(onChange: @escaping @MainActor (Bool) -> Void) {
        let handle: (NSEvent) -> Void = { [weak self] event in
            guard let self, event.keyCode == UInt16(self.keyCode) else { return }
            // The flag alone lies while the LEFT Command key is also down. Ask the key itself.
            let down = CGEventSource.keyState(.combinedSessionState, key: self.keyCode)
            if down != self.held { self.held = down; onChange(down) }
        }
        if let global = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged, handler: { event in
            MainActor.assumeIsolated { handle(event) }
        }) { monitors.append(global) }
        if let local = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged, handler: { event in
            handle(event); return event
        }) { monitors.append(local) }
    }
}

External links

Exercise

Put HotkeyCenter into Spark's Mac app and register ⌘⌥K to print a line. Register the same chord a second time and show the returned message in the log. Then write a recorder check isAcceptableChordKey(_ keyCode: UInt16) -> Bool that accepts letters, digits and F1 to F20 and rejects Escape, and prove it in the self-test executable, including a case that would have trapped with a range.
Hint
The self-test cannot press keys, and it does not need to: the check is a pure function of a UInt16. Test kVK_F5, kVK_F12 and kVK_F20 explicitly, because they sit at scattered codes.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.