Skip to main content
  1. Tags/

Macos

What replaced CGEventPost in my Stream Deck daemon

··2356 words·12 mins
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. Building ClaudeDeck · Part 9 of 10 1 2 3 4 5 6 7 8 9 10 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.

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

··1734 words·9 mins
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. Building ClaudeDeck · Part 8 of 10 1 2 3 4 5 6 7 8 9 10 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. 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.

··1797 words·9 mins
`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. Building ClaudeDeck · Part 10 of 10 1 2 3 4 5 6 7 8 9 10 (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.)