Lab note · from ClipShelf
macOS global hotkey: RegisterEventHotKey vs an NSEvent monitor
If a Carbon RegisterEventHotKey hotkey never fires in a SwiftUI menu bar app, check when it is registered before blaming Carbon: ClipShelf set up its ⌘⌥V hotkey from a .task on its menu's content, so nothing was registered until the menu had been opened. The commit that fixed that also replaced Carbon with NSEvent global and local monitors, which work but need Accessibility for key events, can't stop ⌘⌥V reaching the frontmost app, and, as ClipShelf compares modifiers, miss the hotkey with Caps Lock on. The paste itself is two steps: write the item to NSPasteboard.general, then post ⌘V with CGEvent.
Symptoms
ClipShelf keeps recent clipboard entries and shows them in a dropdown at the pointer when you press ⌘⌥V. Picking one pastes it into the app you were typing in.
- In the first builds the hotkey did nothing. Commit
a424f6areplaced Carbon withNSEventmonitors and gave the reason: "Carbon event handler never fires in NSApplication run loop". - The same commit also did this: "Wire appState to AppDelegate on init instead of waiting for menu click." Hotkey registration sat behind that wiring.
- The shipped monitor doesn't match ⌘⌥V when Caps Lock is on, and the frontmost app receives ⌘⌥V as well as ClipShelf.
Why it happens
Nothing was registered until the menu opened
Before a424f6a, the app delegate's appState observer ran setupServices, which starts the clipboard monitor and registers the hotkey. The only code that set appState was a .task on the menu content:
MenuBarExtra {
MenuBarView(appState: appState)
.task {
if appDelegate.appState == nil {
appDelegate.appState = appState // -> setupServices -> RegisterEventHotKey
}
}
} label: { Image(systemName: "doc.on.clipboard") }
The Carbon code it eventually reached threw away both status codes:
InstallEventHandler(GetApplicationEventTarget(), handler, 1, &eventType, nil, &eventHandler)
let hotkeyID = EventHotKeyID(signature: OSType(0x434C5348), id: 1) // "CLSH"
RegisterEventHotKey(keyCode, modifiers, hotkeyID, GetApplicationEventTarget(), 0, &hotkeyRef)
Because the wiring fix and the API swap landed in one commit, the history can't say which one made the hotkey work, and Carbon was never tried again with the wiring fixed.
RegisterEventHotKey carries no deprecation marker in the macOS 27.0 SDK's CarbonEvents.h, and Apple changed its behaviour as recently as macOS 15, which at first refused hotkeys with no modifier other than Option or Shift. An Apple frameworks engineer called it "an intentional change in macOS Sequoia to limit the ability of key-logging malware to observe keys in other applications". macOS 15.2 beta 2 allowed them again. Of the three keyboard-monitoring APIs Quinn at Apple DTS lists, it's the one he likes most, but "it's intimately tied to the legacy Carbon toolbox and thus I can't honestly recommend it".
What an NSEvent global monitor can't do
Apple's reference for addGlobalMonitorForEvents(matching:handler:) sets three limits:
- "you can only observe the event; you cannot modify or otherwise prevent the event from being delivered to its original target application." The frontmost app gets ⌘⌥V too, and in Finder, Option-Command-V moves "the files in the Clipboard from their original location to the current location" (Mac keyboard shortcuts).
- "Key-related events may only be monitored if accessibility is enabled or if your application is trusted for accessibility access."
- "your handler will not be called for events that are sent to your own application." ClipShelf adds a local monitor for that case, since its dropdown takes keyboard focus.
The comparison is strict. deviceIndependentFlagsMask is 0xffff0000, which includes .capsLock (0x10000). A test that built a ⌘⌥V CGEvent without posting it, converted it with NSEvent(cgEvent:) and ran ClipShelf's comparison matched with ⌘⌥ alone and failed once .maskAlphaShift was set.
Paste is a pasteboard write plus a keystroke
CGEvent doesn't replace NSPasteboard. ClipShelf writes every stored type back to NSPasteboard.general, then posts ⌘V so the target app runs its own Paste. Posting has its own permission, which Apple DTS says "shows up in the UI as System Settings > Privacy & Security > Accessibility" but is limited to posting events. The repository's com.apple.security.automation.apple-events entitlement is for something else: prompting "for permission to send Apple events to other apps".
The fix
Services now start at launch, from the app's initializer:
init() {
let state = AppState()
_appState = StateObject(wrappedValue: state)
DispatchQueue.main.async { [appDelegate] in
appDelegate.appState = state
}
}
The hotkey is two monitors in HotkeyManager.swift:
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self = self else { return }
if UInt32(event.keyCode) == keyCode && event.modifierFlags.intersection(.deviceIndependentFlagsMask) == expectedModifiers {
Task { @MainActor in self.onHotkeyPressed?() }
}
}
localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self = self else { return event }
if UInt32(event.keyCode) == keyCode && event.modifierFlags.intersection(.deviceIndependentFlagsMask) == expectedModifiers {
Task { @MainActor in self.onHotkeyPressed?() }
return nil // consume the event
}
return event
}
The paste, in PasteAction.swift:
// paste(_:), trimmed
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
for (type, data) in item.pasteboardData {
pasteboard.setData(data, forType: type)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.simulateCmdV()
}
private func simulateCmdV() {
let source = CGEventSource(stateID: .hidSystemState)
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: true) // 9 = V
keyDown?.flags = .maskCommand
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: false)
keyUp?.flags = .maskCommand
keyDown?.post(tap: .cghidEventTap)
keyUp?.post(tap: .cghidEventTap)
}
Registration no longer waits for the menu. Once ClipShelf is trusted, the global monitor sees ⌘⌥V in other apps and the local monitor sees it in ClipShelf's own panel. The dropdown is an NSPanel with .nonactivatingPanel, which "does not activate the owning app", at level .popUpMenu (101), positioned from NSEvent.mouseLocation, so the app you were typing in stays active. post(tap:) "posts the specified event immediately before any event taps instantiated for that location", so posting at .cghidEventTap puts ⌘V where keyboard events enter the window server.
Two changes we'd make, neither in the repository:
// Compare only the modifiers a hotkey can use, so Caps Lock doesn't break it.
let mods = event.modifierFlags.intersection([.command, .option, .control, .shift])
// Or stay on Carbon: register at launch and check the result.
let status = RegisterEventHotKey(keyCode, modifiers, hotkeyID, GetApplicationEventTarget(), 0, &hotkeyRef)
guard status == noErr else { NSLog("RegisterEventHotKey failed: %d", status); return }
The masked comparison matched with Caps Lock in the same test. A probe launched as its own app on macOS 27.2, with AXIsProcessTrusted(), CGPreflightListenEventAccess() and CGPreflightPostEventAccess() all false, got noErr from RegisterEventHotKey. A second registration of the same combination in that process returned eventHotKeyExistsErr (-9878). The probe never pressed the key, so it shows that registration needs none of those permissions. Delivery wasn't tested.
How to check you've fixed it
- Launch without opening the menu and look for
[ClipShelf] Ready (accessibility: …), whichsetupServiceslogs. If it appears only after you click the menu bar icon, registration is still waiting on the menu. - On Carbon, log the
OSStatusofInstallEventHandlerandRegisterEventHotKey. - With the monitor, remove ClipShelf from Accessibility, relaunch and press ⌘⌥V in another app. Nothing should open. Grant access, relaunch, and it should.
- Turn Caps Lock on and press ⌘⌥V. The shipped comparison won't fire; the masked one will.
- Paste a rich-text entry into TextEdit. The formatting should survive, because every stored type is written back.
Caveats
- Targets.
Package.swiftsets swift-tools 5.9 and.macOS(.v13);Info.plistsetsLSMinimumSystemVersion13.0. The current code builds with Swift 6.4 against the macOS 27.0 SDK. - Timing. The README puts hotkey-to-paste at about 250 ms, but the repository contains no measurement. The code's fixed delays are 50 ms from selection to ⌘V, a 120 ms fade-in (the panel is made key at alpha 0, so input isn't held up by it) and an 80 ms fade-out.
- An ordering question we couldn't test. The panel orders out when the 80 ms fade-out finishes, after ⌘V has been posted at 50 ms. If the panel still has keyboard focus when ⌘V arrives, the keystroke goes to ClipShelf rather than the target app. Ordering the panel out before posting removes the question.
- Deduplication. Since
c744c6d,contentHashfeeds every stored type and its bytes into Swift'sHasher. A match at the top is skipped, and one further down moves to the top.Hasher"is usually randomly seeded" per run (Apple), which is fine for a history kept in memory. The hash is recomputed on every comparison, up to 2 + 2N times per capture with N entries (at most 20). A 10 MB payload took 5.3 ms to hash on the M5 Max test machine. - Key code 9 is
kVK_ANSI_V, a physical key position.Events.hnotes that other layouts "may have the 'A' key label on a different physical key".
More lab notes
- Hide a macOS app the moment it activates, then ask for Touch IDHide the app inside the didActivateApplicationNotification handler, then run LAContext. It can't promise no flash: the app is already active.
- Swift menu bar app stops updating: Timer run loop mode and App NapA Timer in the default run loop mode doesn't fire while a menu is open, and App Nap throttles it later. Add it in .common and hold a beginActivity token.
- CGWindowListCreateImage unavailable in macOS 15: use SCStreamCGWindowListCreateImage is deprecated in macOS 14 and a compile error from a macOS 15 target. Anchor still uses it; here is the SCStream replacement.
- Check a password against Have I Been Pwned without sending itSHA-1 the password, send only the first 5 hex characters to the Pwned Passwords range API, match the suffix locally, and add Add-Padding: true.