0.0.21 fixes the actual root cause of "skhd doesn't start on reboot". The 0.0.18 rework was correct on packaging and signing but missed the gatekeeper: macOS Background Tasks Manager (BTM) silently marked any hand-installed LaunchAgent as
disallowed. 0.0.21 switches toSMAppService, which gets a proper[enabled, allowed, notified]BTM entry that auto-loads at every login.If you upgraded to 0.0.18–0.0.20 and skhd still doesn't always start after reboot, 0.0.21 is the fix you want.
brew upgrade skhd-zig
# Re-register via SMAppService. The `skhd` shim brew puts on PATH is a
# symlink into the bundle's inner binary, so SMAppService still sees the
# correct calling bundle and BTM gets a clean managed entry:
skhd --install-service
# Verify
skhd --status
# Expect: Registration status: enabled
# Daemon running: Yes (PID …)
# Hotkeys functional: Yes (event tap active)That's it. Your accessibility grant carries over (TCC entry is bundle-ID-keyed, the cert hasn't changed), and BTM now has a proper managed entry that auto-loads on every reboot.
The old disallowed legacy BTM entry from previous versions is harmless once the new managed entry is in place — but you can clean it up via System Settings → General → Login Items & Extensions if you like.
The formula's post_install hook now runs skhd --start-service automatically after every install and upgrade. That means:
- Legacy
~/Library/LaunchAgents/com.jackielii.skhd.plistgets booted out and deleted. If you ever installed a pre-0.0.21 version, this orphan plist shadowed the SMAppService registration on Tahoe — sameLabel, two definitions, launchd refused to spawn either (EX_CONFIG,108: Invalid path: Contents/MacOS/skhd). It's now cleaned up on every upgrade. - SMAppService rebinds to the current Cellar bundle path. The previous version's binding pointed at the now-deleted
Cellar/<old-version>/skhd.app; the post_install re-registration picks up the new path. - The daemon still self-heals the cdHash/TCC mismatch on the first event-tap attempt after the new binary spawns (see the section below). You may still need to re-toggle the Accessibility entry in System Settings once per upgrade — the daemon will print a single short instruction line in the log when this happens, instead of the previous wall of text repeated every 10 seconds.
If something looks wrong after a brew upgrade, the canonical recovery is still one command:
skhd --start-service # idempotent — same as what post_install just ran
skhd --status # verifyOn macOS 15+ (Tahoe), TCC stores Input Monitoring grants with a csreq anchored to the binary's cdHash rather than the signing cert root. Every rebuild produces a new cdHash, so a brew upgrade silently invalidates the grant — the System Settings entry still shows as granted, but no events flow into the daemon.
Symptoms:
skhd --statusshows the daemon running, but hotkeys don't fire.- System Settings → Privacy & Security → Accessibility (and/or Input Monitoring) shows skhd as enabled.
~/Library/Logs/skhd.logshows the event tap was created but no key activity.
Since 0.1.2 the daemon does this automatically when it's launchd-managed: on the first event-tap creation failure it calls tccutil reset itself, writes a marker at ~/Library/Caches/com.jackielii.skhd/tcc_auto_reset_at to avoid reset loops on subsequent respawns, and emits a single short "go re-toggle in Settings" line in the log. You only need to perform step 2 below.
If you're on an older release or the auto-reset failed, the manual fix is:
# 1. Drop the stale grants so macOS will store a fresh, cert-root-anchored csreq
tccutil reset ListenEvent com.jackielii.skhd
tccutil reset Accessibility com.jackielii.skhd
skhd --restart-service
# 2. Re-toggle the entry in System Settings → Privacy & Security → Accessibility
# (or accept the prompt if one appears). The first hotkey press triggers the
# Input Monitoring prompt — approve it.This issue recurs on every brew upgrade until Apple loosens the anchor policy; skhd --status includes the same fix in its remediation output when the tap is detected as denied.
Version 0.0.18 introduced the .app bundle structure and ~/Library/Logs/skhd.log. If you're coming from 0.0.17 or earlier you also need to perform the steps below before the SMAppService re-register above.
| Area | Before | After |
|---|---|---|
| Distribution layout | bare Mach-O at bin/skhd |
.app bundle (skhd.app/Contents/MacOS/skhd) with bare-binary symlink kept for CLI use |
| TCC entries | path-keyed (/opt/homebrew/Cellar/.../bin/skhd) |
bundle-ID-keyed (com.jackielii.skhd) |
| LaunchAgent commands | launchctl load -w / unload -w |
launchctl bootstrap / bootout (no persistent disable flag) |
Plist ProgramArguments |
version-pinned Cellar path | stable /opt/homebrew/opt/skhd-zig/... symlink |
| Plist log path | /tmp/skhd_$USER.log (wiped at boot) |
~/Library/Logs/skhd.log |
Plist ThrottleInterval |
30 s | 10 s |
CGEventTapCreate failures |
exit immediately, wait full throttle, repeat | retry up to 10× at 500 ms before giving up |
The combined effect: the daemon comes up reliably after a cold reboot, accessibility permissions persist across brew upgrade and rebuilds, and a previous --stop-service no longer prevents auto-load on the next login.
These steps assume you installed the previous version via Homebrew. The order matters.
skhd --stop-servicebrew upgrade jackielii/tap/skhd-zigThe new formula installs skhd.app to <prefix>/skhd.app (e.g. /opt/homebrew/opt/skhd-zig/skhd.app) and symlinks the CLI into bin/skhd. macOS will prompt for Accessibility on first launch via the popup deep-link — no manual +/navigate step in System Settings.
The old unload -w set a flag in launchd's persistent disable list. The new --start-service clears it automatically, but it's worth confirming it's gone:
launchctl print-disabled gui/$(id -u) | grep com.jackielii.skhd
# Expect: "com.jackielii.skhd" => enabledIf it shows disabled, run:
launchctl enable gui/$(id -u)/com.jackielii.skhdThe path-keyed accessibility entries from previous Cellar versions will silently shadow the new bundle-ID entry until removed:
sudo sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" \
"DELETE FROM access WHERE client LIKE '%skhd-zig%' AND client_type=1;"This deletes only path-keyed (client_type=1) rows. The new bundle-ID-keyed (client_type=0) entry created when you grant in step 6 below is left untouched on future runs.
skhd --install-serviceThe plist now points at /opt/homebrew/opt/skhd-zig/skhd.app/Contents/MacOS/skhd (stable across brew upgrade).
skhd --install-service triggers the macOS Accessibility prompt with a deep-link to the right pane. In the dialog, click Open System Settings and toggle the skhd entry on — no manual +/navigate step needed.
You will only need to do this once. The bundle-ID-keyed TCC entry now persists across rebuilds and Homebrew upgrades.
skhd --start-serviceWatch the log at ~/Library/Logs/skhd.log. You should see:
info(skhd): Starting event tap
info(skhd): Event tap created successfully. skhd is now running.
Or if the daemon hits the early-boot WindowServer race once or twice, the new retry loop handles it:
warning(event_tap): Event tap creation failed (attempt 1/10), retrying in 500ms...
info(event_tap): Event tap created on attempt 2/10
If you build from source rather than installing via Homebrew:
zig build sign-app # produces a signed zig-out/skhd.app
ln -sfn "$(pwd)/zig-out/skhd.app" /Applications/skhd.app
/Applications/skhd.app/Contents/MacOS/skhd --install-service
/Applications/skhd.app/Contents/MacOS/skhd --start-servicezig build (without app) still produces the bare binary at zig-out/bin/skhd for quick development iteration — it just won't be visible in the Accessibility picker.
If anything goes sideways, see docs/CODE_SIGNING.md — the troubleshooting section there covers stale TCC entries, picker rejections, the misleading --status output, and signing problems.