Skip to main content
  1. Posts/

What replaced CGEventPost in my Stream Deck daemon

Nick Liu
Author
Nick Liu
Building infrastructure for Facebook Feed Ranking at Meta. Previously at Walmart, Twitter, AWS, and eBay. MS in Computer Science at Georgia Tech.
Table of Contents
I press the Stream Deck key. The daemon logs the press, synthesizes `Cmd+Opt+;` through CoreGraphics, and exits cleanly. Wispr Flow does nothing. Three Apple subsystems and one decompiled Electron bundle later, the working trigger turned out to be a one-line URL.

The plan was the boring kind: Stream Deck key (the physical button on Elgato’s programmable USB grid) → WebSocket message → my daemon (long-running background process) → synthesized global hotkey → Wispr Flow’s hands-free dictation starts — Wispr Flow is the voice-to-text Mac app that types your speech into the focused window — → I talk → words show up in my editor. I’d done variants of this with osascript (macOS’s command-line AppleScript runner) years ago. Should have taken an afternoon.

It took three days. And the eventual fix bypassed keystroke synthesis entirely, by routing through an Electron URL handler — Electron apps (Slack, Discord, VS Code, Wispr Flow) are Chromium plus Node bundled into a desktop binary, and they often register custom URL schemes like slack:// — that Wispr Flow doesn’t publicly document but ships in every release.

This is the story of why hotkey synthesis is, on macOS 26.5 (codename Tahoe), a dead end for unsigned daemons — and how to find the side door that nearly every Electron app leaves open. (For the related “why does my macOS Accessibility grant keep resetting” story on the same daemon, see the TCC cdhash trap.)

The symptom
#

Logs from the daemon:

[wispr] press received, synthesizing Cmd+Opt+;
[wispr] CGEventPost done, 6 events posted, eventStateId=1

Logs from Wispr Flow: nothing. The hands-free indicator never appears. The transcription never starts.

A real keypress of the same combo opens hands-free immediately. So Wispr Flow’s listener works; my events aren’t reaching it.

Plan A: AppleScript
#

The lowest-friction attempt. AppleScript via osascript has been the macOS scripting hammer for two decades.

tell application "System Events"
  key code 41 using {command down, option down}
end tell

osascript returns 0. Wispr Flow doesn’t react.

I half-expected this. System Events injects events from a different layer than the real keyboard — it goes through AppleEvents (Apple’s decades-old inter-app message bus, the thing AppleScript actually runs on) into a high-level “post this to the focused app” path. Wispr Flow registers its hotkey via Carbon’s RegisterEventHotKey — Carbon is Apple’s pre-2007 C API layer; lots of it is deprecated but RegisterEventHotKey is still the standard “register a global keyboard shortcut” function — which sits much lower in the stack. The synthesized event never reaches the matcher.

Not great, but Plan A was always a long shot. On to CoreGraphics.

Plan B: CGEventPost via bun:ffi
#

CoreGraphics (the macOS framework behind everything that draws on screen, including event injection) is supposed to inject events early enough that even Carbon hotkeys see them. I wired up the six-event sequence (Cmd↓, Opt↓, ;↓, ;↑, Opt↑, Cmd↑) through Bun’s :ffi binding to the framework. (FFI — “Foreign Function Interface” — is the standard way for a high-level language to call C functions in a shared library; dlopen opens the .dylib, the schema tells Bun the argument types.)

import { dlopen, FFIType, ptr, CString } from "bun:ffi";

const cg = dlopen(
  "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics",
  {
    CGEventCreateKeyboardEvent: { args: ["ptr", "u16", "i32"], returns: "ptr" },
    CGEventPost: { args: ["i32", "ptr"], returns: "void" },
    CGEventSetFlags: { args: ["ptr", "u64"], returns: "void" },
    CFRelease: { args: ["ptr"], returns: "void" },
  },
);

const SEMICOLON = 0x29;       // virtual keycode for ;
const FLAG_CMD = 0x100000;
const FLAG_OPT = 0x080000;
const TAP_HID = 0;             // kCGHIDEventTap

The pattern is canonical. skhd, Hammerspoon, and yabai (popular macOS hotkey and window-management daemons that ship as open-source reference implementations of this exact approach) all do it the same way. I added 30 ms delays between events. I set kCGEventSourceStateHIDSystemState=1 so the modifier-state table — the one macOS keeps for “is Cmd currently held down” — looked right. I verified each event had the cmd+alt flags before posting.

Wispr Flow: still nothing.

But — and this is the part that wasted a full day — a bare key post did work:

$ osascript -e 'tell app "System Events" to key code 36'
# Enter key lands in TextEdit, types a newline

So the daemon has some synthesis capability. Bare keys go through. Modifier-bearing keys destined for Carbon hotkey listeners do not. The daemon is granted Accessibility. AXIsProcessTrustedWithOptions(NULL) — the “is my process allowed to use the Accessibility APIs” check — returns false anyway, which I assumed was the problem. (See the TCC cdhash trap post for why TCC grants can be present in the UI but invisible to APIs.)

It wasn’t. AX trust and event-post privilege are different TCC services. Quinn at Apple DTS (Developer Technical Support — Apple’s official engineer-staffed forum where “Quinn” is a recognised name engineers cite by) spells this out on the dev forums: kTCCServiceAccessibility, kTCCServicePostEvent, and kTCCServiceListenEvent are three independent buckets — three separate rows in TCC.db, each tracked independently. The Accessibility toggle flips all three for keyboard-poster apps, but the APIs query them separately. CGEventPost was working — my events were reaching WindowServer (the macOS process that owns the screen, all windows, and the keyboard/mouse event pipeline; analogous to Xorg on Linux). They were just being dropped before the hotkey matcher.

The gate I’d been ignoring
#

Three days in, I went looking for the actual filter. The answer is in WindowServer.

Carbon’s RegisterEventHotKey is a thin shim over a private SkyLight RPC (SkyLight is the private framework WindowServer exposes — undocumented by Apple, but reverse-engineered for years; “RPC” here just means cross-process function call) that registers a per-process entry in WindowServer’s hotkey table. The match happens inside the WindowServer process, before per-app dispatch. WindowServer can distinguish events that came from the IOHIDSystem driver (real hardware — the kernel module that reads bytes off the USB keyboard) from events that came from CGEventPost. The function that draws the line is CGXSenderCanSynthesizeEvents().

Jamf’s reverse-engineering writeup quotes WindowServer’s own log strings: “Dropping mouse down event because sender’s PID (899) isn’t 0 or self (828)”. That’s the gate refusing a synthesized event because the sender PID isn’t WindowServer itself. The same gate fires for keyboard events destined for the hotkey matcher.

I was thinking about it wrong. On older macOS, ad-hoc-signed binaries with a TCC PostEvent grant could synthesize events that reached the matcher, sometimes. I have no source confirming that’s ever been reliable. What I can say is what I observed on macOS 26.5: the bare-key post lands in the focused field, the modifier-bearing post to a Carbon hotkey listener does not — for a daemon signed only with codesign --sign -. Same daemon, same TCC grants, two different outcomes.

The two paths past the gate, as far as I can tell, are:

  1. Ship through the Mac App Store and inherit a real signing identity.
  2. Inject events through a virtual HID device that registers as real hardware. Karabiner-Elements ships exactly that — Karabiner-DriverKit-VirtualHIDDevice — and the Karabiner team explicitly calls out CGEventPost as inadequate for the same reason.

Neither was realistic for a hobby daemon whose code-signing budget is “what security create-certificate lets me do for free.” Time to find another way in.

Plan C: ask the app, not the OS
#

Reframe: if I can’t make the OS deliver the keystroke, can I trigger Wispr Flow’s start-dictation function some other way? The hotkey is one entry point. Surely it’s not the only one.

Wispr Flow is an Electron app. Electron apps almost universally register a URL scheme through Info.plist’s CFBundleURLTypes key (Info.plist is the XML/binary metadata file inside every .app bundle at Contents/Info.plist; the CFBundleURLTypes array tells macOS “this app handles these foo:// URLs”) — that’s how install flows and “open in app” buttons on websites work. If Wispr Flow’s URL handler routes to the same internal function as the hotkey, I’d have a working path with zero synthesis.

Step one: does it have a scheme?

$ /usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes" \
    "/Applications/Wispr Flow.app/Contents/Info.plist"
Array {
    Dict {
        CFBundleURLName = ai.wispr.flow.deeplink
        CFBundleURLSchemes = Array {
            wispr-flow
        }
    }
}

It does. wispr-flow://. But what URLs does it accept?

Step two: grep the bundle. (Electron ships its JavaScript inside app.asar — a single archive file mounted like a tar, containing the entire Electron app’s JS source. It’s plain enough that strings lifts readable text out of it directly.)

$ strings "/Applications/Wispr Flow.app/Contents/Resources/app.asar" \
    | grep -E "wispr-flow://[a-z/-]+" | sort -u
wispr-flow://auth/transfer/success
wispr-flow://billing/cancel
wispr-flow://billing/success
wispr-flow://open
wispr-flow://start-hands-free
wispr-flow://stop-hands-free
wispr-flow://switch-mic

start-hands-free was sitting there the whole time.

Step three: confirm it routes through the same code path as the hotkey, not some second-class deeplink-only handler that does half the work.

$ strings "/Applications/Wispr Flow.app/Contents/Resources/app.asar" \
    | grep -B 1 -A 4 "DeeplinkStartHandsFree"
  if (status === Idle || status === Dismissed) {
      Qw(SB.Deeplink);
      sendEvent("DeeplinkStartHandsFree", { success: true });
  } else {
      sendEvent("DeeplinkStartHandsFree",
                { success: false, reason: "not_idle" });
  }

Qw(SB.Deeplink) is the same state-machine call as the hotkey path. SB.Deeplink is just an enum tag for analytics. The handler even emits a DeeplinkStartHandsFree event with a success: false, reason: "not_idle" branch — which means it’s a real, tested code path, not a vestigial scaffold.

The fix
#

open -g wispr-flow://start-hands-free

In the daemon, that’s a single spawn (daemon/src/wisprFlowTrigger.ts):

spawn("open", ["-g", url], {
  detached: true,
  stdio: ["ignore", "pipe", "pipe"],
}).unref();

The -g flag is load-bearing. Without it, open activates Wispr Flow and steals focus from your editor, so when you finish dictating, the transcribed text lands in Wispr Flow’s own window instead of where you were typing. With -g, LaunchServices (the macOS subsystem that knows which app handles which file type or URL scheme — what fires when you double-click a .pdf or click a slack:// link) delivers the URL without bringing the app forward.

No CGEventPost. No bun:ffi. No fight with CGXSenderCanSynthesizeEvents. No TCC PostEvent grant needed for the trigger. The daemon doesn’t even need Accessibility for this path — just permission to spawn open, which every process has.

Side notes that took time to verify
#

A few caveats that aren’t load-bearing for the fix but were worth checking:

Cold-start latency. If Wispr Flow isn’t running, the first open launches it; ~1–2 seconds in my testing on this M-series machine. Subsequent presses dispatch instantly. Adding Wispr Flow to Login Items skips the cold start entirely.

Stopping dictation. wispr-flow://stop-hands-free exists and works the same way. In practice Wispr Flow auto-stops on silence, so I haven’t wired stop to the Stream Deck yet.

Wispr Flow’s own focus call. I traced the deeplink dispatcher further and found it calls hubWindow.focus() unconditionally at the entry point, before routing to the start-hands-free handler. If Wispr Flow’s hub window happens to be visible, that focus call can still steal focus even with open -g. The mitigation is a 200–400 ms timer that restores the original window’s focus after firing the URL. I haven’t shipped that yet because the hub window is hidden in my workflow, so the hubWindow.focus() is a no-op visually. If the focus steal becomes a problem, the research doc has the full restore pattern, lifted from Hammerspoon and BetterTouchTool.

Why this path is structurally better
#

Hotkey synthesis, when it works, traverses something like:

daemon → CGEventPost → WindowServer → CGXSenderCanSynthesizeEvents
       → Carbon hotkey table match → Wispr Flow.startHandsFree()

Four kernel-side gates. Two of them care about whether you’re signed by Apple.

The deeplink path:

daemon → open → LaunchServices → Wispr Flow.appDelegate.application(_:open:)
       → Wispr Flow.startHandsFree()

Two gates. Neither one cares about signing. Same end state.

The synthesis path was the workaround. The deeplink is the direct call.

Lessons
#

A few principles that would have saved me three days, in order of how often I’ll need them again:

  • When the OS won’t deliver your event, the app probably exposes what you want via a URL scheme. Check Info.plist’s CFBundleURLTypes first. For Electron apps, strings app.asar | grep '://' finds every registered scheme in 30 seconds.
  • AXIsProcessTrusted returning false is not always the problem. TCC has separate buckets for Accessibility, PostEvent, and ListenEvent. Use CGPreflightPostEventAccess if all you do is post events.
  • WindowServer can tell synthesized events from real ones. No public flag on CGEventRef changes that. If you need to pass for real hardware, you need a virtual HID device, not a smarter CGEventPost invocation.
  • Carbon RegisterEventHotKey matches inside WindowServer, before per-app dispatch. Synthesizing events for a foreground app to consume is a different problem from synthesizing events for a Carbon hotkey listener to consume. Plan A and Plan B were the same dead end; I just didn’t know it.
  • If you’re going to decompile an Electron app to find an undocumented entry point, do it in the first hour, not the fourth day.

Hotkey synthesis is a 2010 technique. On Tahoe, for unsigned binaries, it’s deprecated in practice if not on paper. If you’re hitting it for a daemon you’re not going to ship through the App Store, jump straight to the URL-scheme search.

Related #

References
#

  • Aditya Vaidyam, Building a Better RegisterEventHotKey (2018-03-16) — reverse-engineering of the SkyLight RPC behind Carbon hotkeys.
  • Jamf, Synthetic Reality — WindowServer log strings showing the CGXSenderCanSynthesizeEvents filter rejecting synthesized events by PID.
  • Tencent KeenLab, WindowServer: The privilege chameleon on macOS Pt.1 (2016-07-22) — internal IPC structure of WindowServer, including the synthesizer-privilege gate.
  • Apple Developer Forums #744440 — Quinn on CGEventTap vs AXIsProcessTrusted, with the recommendation to use CGPreflight{Post,Listen}EventAccess.
  • Apple, CGRequestPostEventAccess — the PostEvent TCC gate.
  • Karabiner-Elements DEVELOPMENT.md — explicit rationale for not using CGEventPost, and the virtual HID driver that bypasses the filter.
  • Hammerspoon issue #313 — independent confirmation that hs.eventtap.keyStroke cannot trigger hs.hotkey listeners, with the same WindowServer root cause.
  • ClaudeDeck deeplink trigger implementation (Bucket A): daemon/src/wisprFlowTrigger.ts.
  • ClaudeDeck full research notes for this post (Bucket A): docs/2026-05-09-wispr-flow-hotkey-research.md.
  • The wispr-flow://start-hands-free URL was not found in any public Wispr Flow documentation. I discovered it by running /usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes" against Wispr Flow.app/Contents/Info.plist and then strings Contents/Resources/app.asar | grep 'wispr-flow://'. Reproducible on any installed copy.

Related

TCC pins your Accessibility grant to a cdhash. Every rebuild breaks it.

My daemon's preflight log said `osascript is not allowed assistive access. (-1719)`. System Settings disagreed — the entry was right there, toggled on. Spoiler: ad-hoc codesigning pins TCC's designated requirement to the binary's cdhash, and `bun build --compile` produces a different cdhash on every rebuild. I’m building a Stream Deck plugin called ClaudeDeck — Stream Deck is Elgato’s little USB grid of programmable keys with LCD displays under each one. The plugin talks to a background daemon (a long-running process that starts at login and waits for events), and that daemon needs to call System Events via AppleScript to switch Ghostty tabs (Ghostty is my terminal emulator) whenever I press a Stream Deck key. macOS gates that capability — automating other apps — through System Settings → Privacy & Security → Accessibility, the pane you’ve probably toggled for tools like Rectangle or BetterTouchTool. So on first install I added the daemon, toggled it on, and got back to work.

launchctl unload returned 0. The daemon was still running. KeepAlive raced.

`launchctl unload ~/Library/LaunchAgents/com.nickboy.claudedeck.plist` exited 0. Then `pgrep -f claudedeck-daemon` printed a fresh PID. Three seconds after the "unload succeeded" line. Spoiler: KeepAlive is a polling supervisor, not an event-driven one, and when you tell launchd to tear a job down, there is a window where the supervisor has already noticed the previous PID is gone and started a replacement. (One-paragraph grounding if launchd isn’t your daily driver: launchd is macOS’s init system — the equivalent of systemd on Linux or Windows Services on Windows. It boots PID 1, brings up daemons, restarts them when they crash. A LaunchAgent is a per-user launchd job, defined by an XML plist — property list — at ~/Library/LaunchAgents/<name>.plist. KeepAlive is one of the plist keys; set it to true and launchd will respawn the job whenever it exits. launchctl is the CLI you use to load, unload, and inspect those jobs. The Linux mental model: think systemctl driving systemd unit files. The Stream Deck plugin and its daemon are described in the TCC cdhash trap post if you want the project context.)

Five Stream Deck keys, N Claude sessions: LRU that keeps the order I see

A Stream Deck has five session keys. I usually have six or seven Claude Code sessions running. When a new one shows up, the muscle memory test isn't "does the right session get evicted" — it's "do the four survivors stay on the keys they were already on." (Two bits of context for anyone new to the stack: Stream Deck is Elgato’s USB grid of programmable LCD keys, and a “session” here is a single Claude Code conversation — claude running in one terminal tab, with its own working directory, its own context window, its own history. LRU stands for “least-recently used,” the standard cache-eviction policy: when you need to make room, drop the entry nobody has touched in the longest time.)