3 min read

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 CFMachPort
  • CGEvent.tapIsEnabled() initially returned true
  • then… no callbacks ever fired

Launching the binary directly worked:

/Applications/MyApp.app/Contents/MacOS/MyApp

In 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

  1. Deployment: launch the binary directly (avoid open)
nohup /Applications/MyApp.app/Contents/MacOS/MyApp \
  >> ~/Library/Logs/MyApp.log 2>&1 & disown

The app still appears in the Dock and behaves like a normal app launch; the main difference is stdout/stderr goes to the log file.

  1. 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 CGEvent tap 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
  • tapIsEnabled over 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()
}