CGEvent Taps and Code Signing: The Silent Disable Race
If you’ve built a macOS tool that intercepts media keys or system-wide keyboard events using CGEvent.tapCreate(), you may have hit this: the tap appears to install successfully, but after re-signing the app and launching via the Dock/Finder (or open), events never fire. Pressing volume keys does nothing. No crash. No error. The tap just goes quiet.
Here’s what was happening for me and what fixed it.
Quick repro
- Re-sign the app → launch via Finder/Dock or
open MyApp.app→ tap installs but no events fire - Launch the Mach-O directly (
.../Contents/MacOS/MyApp) → events fire normally
The Setup
Media keys (play/pause, volume, brightness) often show up as “system defined” events (NX_SYSDEFINED) at the CoreGraphics layer. I intercepted them with a session event tap:
let eventMask: CGEventMask = (1 << CGEventType.systemDefined.rawValue)
let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: eventMask,
callback: eventCallback,
userInfo: nil
)In my case, NSEvent.addGlobalMonitorForEvents(matching: .systemDefined) was not reliable for the media keys I needed, so I used a CoreGraphics event tap.
The Bug (What I Observed)
After re-signing a new build (e.g. codesign --force --deep) and launching via Finder/Dock or open MyApp.app:
tapCreate()returned a non-nil CFMachPortCGEvent.tapIsEnabled()initially returned true- then… no callbacks ever fired
Launching the binary directly worked:
/Applications/MyApp.app/Contents/MacOS/MyAppIn my setup, the failure correlated with Launch Services launches after re-signing, and behaved like a permission/trust evaluation issue: the tap existed, but didn’t receive events, and the usual "disabled" callback path wasn’t dependable.
The Fix
- Deployment: launch the binary directly (avoid open)
nohup /Applications/MyApp.app/Contents/MacOS/MyApp \
>> ~/Library/Logs/MyApp.log 2>&1 & disownThe app still appears in the Dock and behaves like a normal app launch; the main difference is stdout/stderr goes to the log file.
- Safety net: continuously verify tap health and recover
Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
guard let tap = self.eventTap else { return }
if !CGEvent.tapIsEnabled(tap: tap) {
CGEvent.tapEnable(tap: tap, enable: true)
// If tapEnable doesn't stick, reinstall the tap:
// remove from RunLoop, create new tap, re-add.
if !CGEvent.tapIsEnabled(tap: tap) {
self.reinstallEventTap()
}
}
}And handle both disable events in the callback:
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
if let tap = info?.tap {
CGEvent.tapEnable(tap: tap, enable: true)
}
return nil
}What to Check First (Permissions)
Before installing the tap, verify Input Monitoring (“ListenEvent”) permission:
if !CGPreflightListenEventAccess() {
CGRequestListenEventAccess()
}Input Monitoring is required to listen for global events. Also note: if you use .defaultTap (not .listenOnly), you may additionally need Accessibility permission for full interception/interaction. Missing permissions can look identical to the "tap installed but no events" symptom.
Summary
Symptom | Likely cause |
|---|---|
Tap non-nil but no events, direct Terminal launch works | Identity/permission/trust differences after re-signing + Launch Services launch path (observed) |
Tap is nil | Not permitted for that tap location/type, or creation failed |
Tap dies mid-session | Timeout/user-input disable; recover in callback and via health checks |
NSEvent monitor misses keys | Not reliable for your target keys; use CoreGraphics tap |
Key insight: a non-nil tap is not a healthy tap. Always verify tapIsEnabled at runtime, not just at install time.
Working assumptions (based on observed behavior)
I can’t prove the exact internal mechanism, but given the repeatable pattern in my setup, I’m operating under these assumptions:
- TCC decisions are tied to code identity, and re-signing can effectively create a "new" identity that requires re-evaluation (or re-granting) for Input Monitoring / Accessibility.
- Launching via Launch Services (
open, Finder/Dock) is more likely to trigger that identity/permission re-evaluation than launching the Mach-O directly. - When this happens, a
CGEventtap can exist but be functionally inert (no callbacks), and the normal "tap disabled" callback path may not reliably fire—so health checks + reinstall are the pragmatic mitigation.
If the issue reproduces, the fastest way to validate the hypothesis is to compare:
- direct exec vs
open tapIsEnabledover time- current TCC grants for Input Monitoring / Accessibility after each re-sign
Optional mitigation: delay tap installation
In my setup, adding a short delay before installing the tap reduced failures after re-sign + Launch Services launch. I treat this as a best-effort workaround (timing-dependent), not the core fix.
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.installEventTap()
}Or (often safer): install immediately, then recheck/reinstall after a short delay:
installEventTap()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
verifyOrReinstallTap()
}