{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreiceul4tr2g2jc24kzwzddrwajf57tvmm5kprdkubsvvjt222panqq",
"uri": "at://did:plc:2u26gaflouttm3uj6jkgroyz/app.bsky.feed.post/3mf74ev6adsw2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreiaocy4zofz3tilnwrvxi4l3kwyfd5douc7geb7pimq5ljabz7nlpy"
},
"mimeType": "image/png",
"size": 2305755
},
"description": "If you have built a macOS app that intercepts media keys or system-wide keyboard events it can be a nightmare: the tap appears to install successfully, but after re-signing the binary and launching via the Dock or Finder, events never fire. Here’s what was happening to me, and how I fixed it.",
"path": "/til/2026/02/19/cgevent-taps-and-code-signing-the-silent-disable-race/",
"publishedAt": "2026-02-19T07:51:51.000Z",
"site": "https://danielraffel.me",
"textContent": "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.\n\nHere’s what was happening for me and what fixed it.\n\n### Quick repro\n\n * Re-sign the app → launch via Finder/Dock or `open MyApp.app` → tap installs but no events fire\n * Launch the Mach-O directly (`.../Contents/MacOS/MyApp`) → events fire normally\n\n\n\n## The Setup\n\nMedia 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:\n\n\n let eventMask: CGEventMask = (1 << CGEventType.systemDefined.rawValue)\n\n let tap = CGEvent.tapCreate(\n tap: .cgSessionEventTap,\n place: .headInsertEventTap,\n options: .defaultTap,\n eventsOfInterest: eventMask,\n callback: eventCallback,\n userInfo: nil\n )\n\nIn my case, `NSEvent.addGlobalMonitorForEvents(matching: .systemDefined)` was not reliable for the media keys I needed, so I used a CoreGraphics event tap.\n\n## **The Bug (What I Observed)**\n\nAfter re-signing a new build (e.g. `codesign --force --deep`) and launching via Finder/Dock or open MyApp.app:\n\n * `tapCreate()` returned a non-nil CFMachPort\n * `CGEvent.tapIsEnabled()` initially returned true\n * then… no callbacks ever fired\n\n\n\nLaunching the binary directly worked:\n\n\n /Applications/MyApp.app/Contents/MacOS/MyApp\n\nIn 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.\n\n## **The Fix**\n\n 1. **Deployment: launch the binary directly (avoid open)**\n\n\n\n\n nohup /Applications/MyApp.app/Contents/MacOS/MyApp \\\n >> ~/Library/Logs/MyApp.log 2>&1 & disown\n\nThe app still appears in the Dock and behaves like a normal app launch; the main difference is stdout/stderr goes to the log file.\n\n 1. **Safety net: continuously verify tap health and recover**\n\n\n\n\n Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in\n guard let tap = self.eventTap else { return }\n\n if !CGEvent.tapIsEnabled(tap: tap) {\n CGEvent.tapEnable(tap: tap, enable: true)\n\n // If tapEnable doesn't stick, reinstall the tap:\n // remove from RunLoop, create new tap, re-add.\n if !CGEvent.tapIsEnabled(tap: tap) {\n self.reinstallEventTap()\n }\n }\n }\n\nAnd handle both disable events in the callback:\n\n\n if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {\n if let tap = info?.tap {\n CGEvent.tapEnable(tap: tap, enable: true)\n }\n return nil\n }\n\n## **What to Check First (Permissions)**\n\nBefore installing the tap, verify Input Monitoring (“ListenEvent”) permission:\n\n\n if !CGPreflightListenEventAccess() {\n CGRequestListenEventAccess()\n }\n\nInput 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.\n\n## **Summary**\n\n**Symptom**| **Likely cause**\n---|---\nTap non-nil but no events, direct Terminal launch works| Identity/permission/trust differences after re-signing + Launch Services launch path (observed)\nTap is nil| Not permitted for that tap location/type, or creation failed\nTap dies mid-session| Timeout/user-input disable; recover in callback and via health checks\nNSEvent monitor misses keys| Not reliable for your target keys; use CoreGraphics tap\n\n**Key insight:** a non-nil tap is not a healthy tap. Always verify `tapIsEnabled` at runtime, not just at install time.\n\n## Working assumptions (based on observed behavior)\n\nI can’t prove the exact internal mechanism, but given the repeatable pattern in my setup, I’m operating under these assumptions:\n\n * **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.\n * **Launching via Launch Services** (`open`, Finder/Dock) is more likely to trigger that identity/permission re-evaluation than launching the Mach-O directly.\n * 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.\n\n\n\nIf the issue reproduces, the fastest way to validate the hypothesis is to compare:\n\n * direct exec vs `open`\n * `tapIsEnabled` over time\n * current TCC grants for Input Monitoring / Accessibility after each re-sign\n\n\n\n## Optional mitigation: delay tap installation\n\nIn 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.\n\n\n DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {\n self.installEventTap()\n }\n\nOr (often safer): install immediately, then recheck/reinstall after a short delay:\n\n\n installEventTap()\n DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {\n verifyOrReinstallTap()\n }",
"title": "CGEvent Taps and Code Signing: The Silent Disable Race",
"updatedAt": "2026-02-23T23:56:07.473Z"
}