Changelog
May 29, 2026 · View on GitHub
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.1.7 - 2026-05-29
Added
- Intel (x86_64) Macs install via Homebrew again. The release pipeline already cross-compiled an
skhd-x86_64-macos.tar.gzfrom the arm64 runner, but the Homebrew cask was arm64-only — sobrew install jackielii/tap/skhd-zigrefused to install on Intel.Casks/skhd-zig.rbis now dual-arch: anarch arm: "arm64", intel: "x86_64"stanza drivesskhd-#{arch}-macos.tar.gz, with per-archsha256 arm:/intel:checksums, and thedepends_on arch: :arm64restriction is dropped. release.yml'supdate-homebrewjob now rewrites both checksum lines on each release (anchored on the 64-hex value so thearchstanza'sarm:/intel:keys are left alone).
Fixed
- Built-in keyboards with no IOKit VendorID/ProductID now match correctly (#45). Some Macs (e.g. M3 Max MacBook Pro,
Mac15,10) drive the built-in keyboard over Apple's FIFO transport, which exposes noVendorID/ProductIDin the IOKit registry. The three matching paths each mishandled this:IOHIDManagermatching with{VendorID:0, ProductID:0}requires the properties to exist, so the seize (HidSeize) and presence check (DeviceCheck) matched zero devices, whilehidutil's--matching '{"VendorID":0,"ProductID":0}'treated 0/0 as a wildcard and applied theUserKeyMappingto every connected keyboard. Now: for a 0/0 alias,HidSeize.setMatchesandDeviceCheck.isPresentomit the VID/PID keys (the Generic Desktop / Keyboard usage filter keeps the match to keyboards) and confirm at least one matched device genuinely lacks aVendorID(the Karabiner VHIDD, which does carry one, is excluded — and un-seized after open to avoid a feedback loop);Hidutil.buildMatchingscopes 0/0 to{Built-In:1, PrimaryUsagePage:1, PrimaryUsage:6}so only the internal keyboard is touched. All three paths now key onPrimaryUsagePage/PrimaryUsageso they agree on the device set,--list-devicesshows VID-less keyboards asvendor: 0x0, product: 0x0instead of skipping them, and a partial-zero alias (one ID zero, one not) now warns loudly. - Layer-hold + modifier-hold chord no longer occasionally drops the layer. Holding space (→
fn_layer, layer rule) and caps_lock (→lctrl, modifier rule withpermissive_hold) and tapping a nested key — e.g.space + caps - hexpected to resolve through the agent'sfn_layer < ctrl - h | ctrl - leftmapping — would intermittently land barectrl-hat the OS instead. The two rules ran as independent FSMs in the grabber; caps'spermissive_holdfires onh↑and emittedctrl + hto vhidd before space's 200ms timer ever pushed the layer, so the agent saw the chord without the layer context. The fix adds a dispatch-level arbitration hook (TapHold.arbitration_hook) invoked fromdoHoldCommit. When a non-layer slot is about to commit, dispatch forces any peer slot still pending on a layer rule to push its layer first; the layer's buffered events are split so the committing slot's own buffer flush covers shared nested keys without double-emitting them, while any prefix the layer alone witnessed (events that arrived before the modifier started pending) is replayed under the layer before the modifier-down lands. The tap path and single-rule timer-fire paths are untouched; modifier-tap roll-over behavior is unchanged. - skhd-grabber no longer dead-keys the built-in keyboard after sleep/wake. After the lid was closed for several minutes, the built-in keyboard could come back dead on wake: the grabber's
IOHIDManagerheld device references that deep sleep silently invalidated and never re-enumerated, so it sat in its run loop receiving no input while the keyboard stayed seized — the grabber process looked healthy (CFRunLoop parked inmach_msg, IPC + vhidd sockets connected) but no keystrokes flowed. The grabber now registers for system power notifications (IORegisterForSystemPower, in the newsrc/grabber/PowerNotify.zig) and, onkIOMessageSystemHasPoweredOn, re-runsapplyLatestRules— the same path an agent re-apply takes — tearing down and rebuilding the vhidd connection and the HID seize against the post-wake device set. Verified against a real 17-minute clamshell sleep. Diagnostic logging for this path (and new device matched/removed logging inHidSeize.zig) isinfo-level, so it is compiled out of the ReleaseFast release and does not accumulate on users' machines; build ReleaseSafe to trace it.
0.1.6 - 2026-05-24
Added
NSMicrophoneUsageDescriptionin the app bundle Info.plist. Lets hotkeys that shell out to audio-recording tools (voice-transcription commands, etc.) trigger the microphone TCC prompt instead of being silently denied. The string surfaces in System Settings → Privacy & Security → Microphone as "Allow skhd to launch hotkeys that record audio, such as voice transcription commands."
0.1.4 - 2026-05-23
First release distributed as a Homebrew cask (replacing the formula).
brew install jackielii/tap/skhd-zignow installsskhd.appdirectly into/Applications, which the formula required users to do manually vialn -sfn.
Added
--list-devicesprints connected HID keyboards as paste-ready.deviceblocks. Authors of.remap/.tapholdrules previously had to grephidutil list(hundreds of SMC sensor rows alongside the actual keyboards) and copy VendorID/ProductID by hand. The new flag enumerates devices viaIOHIDManagerwith aDeviceUsagePage:1 / DeviceUsage:6match dict, dedupes on(vendor, product)since IOKit returns one entry per HID interface (e.g. HHKB exposing both Keyboard and Consumer Control), slugifies the product name into a default alias, and prints a copy-paste-ready.deviceblock per device. A footnote flags that mouse receivers advertising a keyboard usage (Logitech Unifying et al.) will also show up — they really do present that usage, so filtering them would be a heuristic that hides legitimate config targets.
Changed
- Homebrew distribution switched from formula to cask. The cask installs the signed
skhd.appbundle into/Applications, runsxattr -dr com.apple.quarantineinpostflightto clear Gatekeeper's quarantine flag on the self-signed binary, and surfaces the CLI onPATHvia the cask'sbinarystanza pointing atskhd.app/Contents/MacOS/skhd. Uninstall stops the user LaunchAgent vialaunchctl. release.yml'supdate-homebrewjob sedsCasks/skhd-zig.rb'sversion+sha256lines (the cask'surlinterpolates#{version}so no URL rewrite is needed); the arm64-only cask drops the x86_64 sed steps from the previous formula bump.
Fixed
-
skhd-grabber no longer dead-keys the keyboard when the Karabiner vhidd transport drops. Two compounding gaps were leaving the keyboard seized while every re-injection failed:
isTransportError's allowlist was too narrow (SendFailed/ShortWriteonly).ConnectionResetByPeer,BrokenPipe,Unexpected— exactly the errors the OS surfaces when the Karabiner daemon resets the socket — fell through to the "no-op" branch, somarkVhiddBrokenwas never called and the seize was never released. Classification is now flipped: any post error means the pipe is dead. Only our-side logic bugs (PayloadTooLarge,TooManyKeys) are excluded — a reconnect can't fix a malformed report.applyLatestRules'steardownSeizeran AFTER the (blocking, up-to-5s) vhidd lazy-connect. On a fresh apply with a stale seize and a dead vhidd, the old seize was held for the full 5s timeout.teardownSeizenow runs before the vhidd connect so the physical keyboard is never seized while we're blocked (re)connecting.
Net effect: any vhidd failure now reliably triggers fail-open + reconnect (matching v0.1.3's recovery contract for the case where the socket simply resets rather than the daemon process exiting).
0.1.3 - 2026-05-16
Fixed
- skhd-grabber no longer dead-keys the keyboard when the Karabiner vhidd_server connection breaks. Before: the grabber kept the keyboard
IOHIDDeviceOpen(seize)-d while everypostKeyboardReportreturnedSendFailed, so real keystrokes were silently dropped — keyboard appeared dead until reboot. Now: on transport failure, the grabber latches avhidd_brokenflag, schedules a one-shotCFRunLoopTimer, and from that callback (running between runloop sources, not inside the IOHIDManager value callback) tears down seize so keys fall through to the OS, closes the dead client, and reconnects via the existingapplyLatestRuleslazy-connect path. Backoff progresses 1s → 2s → 5s → 10s capped, matching Karabiner-Elements' 1s reconnect baseline. Triggered by Karabiner daemon restart, dext deactivation, or vhidd server crash. IOHIDSetModifierLockState failed: 0xE00002C2log spam. This call fails permanently (kIOReturnNotPermitted) when the binary isn't signed with a real Apple Developer ID. One broken-grabber session produced ~5500 of these lines in/var/log/skhd-grabber.log. Now latched after the first failure with a "suppressing further attempts" hint, then becomes a no-op.
0.1.2 - 2026-05-09
Fixed
brew upgradenow actually restarts the service. The Homebrew formula gained apost_installhook that runsskhd --start-serviceafter every install/upgrade. Without this, an upgrade left the user-level legacy plist at~/Library/LaunchAgents/com.jackielii.skhd.plist(from any pre-0.0.21 install) shadowing the SMAppService registration on Tahoe — sameLabel, two definitions, and launchd refused to spawn either withEX_CONFIG(108: Invalid path: Contents/MacOS/skhd). The post_install hook chains throughinstallService → cleanupLegacyInstall → registerWithBTM, so the orphan plist gets booted out and removed and BTM rebinds to the current Cellar bundle path automatically.- Daemon self-heals stale TCC grants after binary swaps. Every
brew upgraderebuilds the binary, the cdHash changes, and TCC silently invalidates the Input Monitoring grant — System Settings still shows the entry as granted,IOHIDCheckAccessreturns denied, and key events never reach the tap. Previously launchd respawned the agent every 10s with the giant "ACCESSIBILITY PERMISSIONS REQUIRED" wall of text in the log on every cycle until the user noticed and ran the twotccutil resetcommands by hand. The agent now detects this case (event tap creation fails ANDIOHIDCheckAccess == deniedAND we're launchd-managed) and runstccutil reset ListenEvent / Accessibility com.jackielii.skhditself, then logs a single short "go re-toggle in Settings" message and exits. A marker file at~/Library/Caches/com.jackielii.skhd/tcc_auto_reset_atrate-limits this to once per 10 minutes so subsequent throttled respawns don't keep wiping the grant out from under the user before they get a chance to re-grant.
0.1.1 - 2026-05-05
Added
--start-serviceis now the canonical "make sure skhd is set up and running" entry point. Idempotent and safe to re-run; same flow as--install-service— registers the agent with BTM, then smart-prompts to install skhd-grabber via sudo if your config has.remap/.taphold/fn_layerrules and a target device is connected. Single command users reach for whether installing fresh, recovering from a stopped agent, or re-running after abrew upgrade.
Fixed
--install-grabbercould leave the system in a half-broken state with no diagnostic. Three layered issues conspired:runLaunchctldiscarded launchctl's stderr/stdout so the actual error was never seen;main.zigswallowed grabber CLI errors withcatch std.process.exit(1), dropping the error name; and thebootout-then-bootstrapsequence had no delay between calls — macOS'sbootoutis async, so a follow-upbootstrapissued immediately can race the prior teardown and fail with EIO. Fixes:runLaunchctlprints stderr/stdout when launchctl exits non-zero.- Grabber CLI commands (
--install-grabber,--install-dext,--uninstall-grabber,--grabber-status,--grabber-test-rule) print the error name before exit-1. - New
bootstrapServicehelper: bootout → 300ms sleep → bootstrap (with one 800ms-delayed retry on failure) → enable → kickstart. Shared betweeninstallGrabberandinstallVhiddDaemon. - After the launchctl chain,
installGrabberverifieslaunchctl print system/<label>succeeds and aborts witherror.GrabberRegistrationFailedif not — catches the silent-failure mode where the plist is on disk but the service isn't registered.
0.1.0 - 2026-05-04
Major release introducing skhd-grabber — a system daemon that handles caps_lock-class tap-hold rules through HID seize, enabling QMK-style keyboard remapping that the user-session-level event tap can't reach. The wire format between agent and grabber and the new
.remap/.taphold/.devicedirectives are now considered stable.
Added
.remap/.taphold/.devicedirectives for QMK-style keyboard remapping. Two paths depending on what the rule needs:- Colon-form
.remap key : key— driveshidutil'sUserKeyMappingtable directly. Works for non-conflicting remaps (e.g. swapcaps_lock→escape); doesn't need the grabber. Original mappings are saved on startup and restored on shutdown so the keyboard isn't left remapped when skhd exits. - Block-form
.remap { ... }and.taphold key : tap, hold, ...— handled by skhd-grabber, which seizes the keyboard at the IOKit/HID level via Karabiner-DriverKit and rewrites events before they reach the OS event chain. Required for caps_lock-class rules (the kernel layer abovehidutilsilently dropscaps_lock → modifiermappings on Tahoe), modifier-as-hold rules, and any rule that needs to distinguish tap vs hold by timing. .device "alias" vendor=0xVVVV product=0xPPPPscopes rules to a specific keyboard. A config shared between a laptop and an external keyboard targets only the relevant device.- Layer holds —
key : tap, hold: <mode>switches skhd into a temporary mode for the duration of the hold and back when released. Push IPC from grabber → agent so layer modes activate on the agent's run loop.
- Colon-form
skhd-grabber— system daemon (LaunchDaemon, root) for the HID-seize path. Installed viasudo skhd --install-grabber. Communicates with the agent over a Unix socket at/var/run/skhd/grabber.sock. Per-uid rule filtering tracks the active console user so fast-user-switching does the right thing. The agent forwards rules on every config load (and re-forwards on hot-reload + auto-reconnect after a grabber restart).skhd --install-dext— downloads the pinned Karabiner-DriverKit-VirtualHIDDevice.pkg(URL + SHA-256 verified in-process viastd.crypto), runsinstaller -pkg, self-elevates viasudoif not root. Cached at~/.cache/skhd/(or/tmp/under root) so re-runs skip the download. Runs entirely from the binary — no external scripts needed, so brew users get the same code path aszig build install-dext.skhd --install-serviceauto-installs the dext when grabber is needed and the dext is missing. Brew install becomes one command:brew install jackielii/tap/skhd-zig skhd --install-service # registers agent, installs dext if missing, registers grabberHID daemonline inskhd --status— surfaces the four-state probe (not_installed/plist_unregistered/stopped/running) plus the installed dext version, with state-specific remediation. Catches the broken-launchd-registration case where the dext is loaded butlaunchctl print system/<label>returns "could not find service" (kickstart can't recover; needs a.pkgreinstall).Input Monitoringline inskhd --status— callsIOHIDCheckAccess(kIOHIDRequestTypeListenEvent)directly. Catches the silent cdHash-mismatch case (#36) where the grant looks granted in System Settings but TCC's stored csreq is anchored to a stale cdHash from a previous build, so key-down events are silently dropped before reaching skhd's event tap. Includes thetccutil reset ListenEvent com.jackielii.skhdworkaround.- Karabiner-Elements conflict warning —
--statusand--install-grabberflag whenkarabiner_grabberis running, since both daemons compete for HID seize. - Bundle-shared TCC for skhd-grabber — the grabber runs from inside
skhd.app/Contents/MacOS/skhd-grabberinstead of being copied to/usr/local/libexec/. Both binaries are signed with-i com.jackielii.skhd, so a single Input Monitoring grant on skhd.app covers both processes via TCC's bundle keying. No more separate "add the grabber binary path to Input Monitoring" step. - Auto VHIDD daemon launchd registration —
--install-dextwrites the LaunchDaemon plist fororg.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemonafter the.pkginstaller runs. Without this, the daemon never registers with launchd on machines without Karabiner-Elements (which historically provided the launchd entry via SMAppService). Coexistence-aware: skipped when launchd already has the label registered. - Interactive Input Monitoring auto-prompt —
--install-servicecallsIOHIDRequestAccess(kIOHIDRequestTypeListenEvent)after a successful grabber install, popping the system IM dialog while the user is at a terminal. Same UX as the Accessibility auto-pop; no manual System Settings navigation. --uninstall-servicepost-uninstall hints — surfaces follow-up cleanup commands (skhd-grabber, VHIDD daemon, pqrs uninstaller) when those pieces are still on disk so users don't forget the sudo step.
Changed
- Karabiner DriverKit version is pinned in
build.zig(currently v6.14.0).--statusand--install-grabbercompare the installed version against the pinned major; same major is treated as wire-compatible (pqrs follows SemVer), older major refuses to proceed with a remediation pointer tozig build install-dext, newer major proceeds with an "untested" advisory. Bump procedure documented inline above the constants. scripts/install-dext.shremoved in favor of the in-binary--install-dextsubcommand. Removes shell duplication and means the dev path (zig build install-dext) and brew path (skhd --install-dext) share the same code.scripts/install-grabber.shandscripts/uninstall-grabber.shremoved — install/uninstall logic moved into Zig (grabber_cli.zig) with the LaunchDaemon plist embedded via@embedFile. Works from any cwd (a brew bundle without a checked-out repo can still install). Uninstall also tears down the VHIDD daemon's launchd registration if--install-dextput it there.make-app.shbundlesskhd-grabberintoskhd.app/Contents/MacOS/— release tarballs andzig build install-localship both binaries inside the bundle.codesign.shsigns both inner Mach-Os with the bundle ID so the bundle's seal stays valid.
Fixed
Hotkeys functionalfalse negative in--status— the log-tail scan now anchors on the current daemon's(PID N)start marker so staleACCESSIBILITY PERMISSIONS REQUIREDlines from prior crashed instances no longer poison the read. ReturnsUnknowninstead ofDeniedwhen the marker isn't in the read window yet.
Internal
- Toolchain upgraded to Zig 0.16.
std.Iois plumbed throughSkhd/Mappings/Hotload/Parser/CarbonEvent/TrackingAllocatoras a struct field set at init, and throughservice/grabber_cliper call.main()takesstd.process.Initso gpa, io, arena, and args come from the runtime. File I/O moves tostd.Io.Dir/std.Io.File, process spawning tostd.process.spawn(io, ...)/std.process.run(gpa, io, ...), unix sockets tostd.Io.net.UnixAddress. Format methods adopt the new(self, w: *std.Io.Writer)signature. mappings.tapholds/mappings.remaps/mappings.device_aliases— parser and runtime data for the new directives.grabber_protocol— shared module defining the agent ↔ grabber wire format. Versioned (protocol_version) so handshake mismatches surface clearly. Currently v2.- Daemon refactored around
CFRunLoop-driven IPC listener so the agent can react to grabber pushes (layer-hold mode changes) without polling. Hidutil.zig— parses + merges existingUserKeyMappingso colon-form.remapdoesn't clobber whatever System Settings → Modifier Keys (or other tooling) already set. Restores on shutdown.- Test surface expanded to cover
RuleSetparsing, the IPC framing,KbState/TapHoldstate machines, and the new HID-daemon version compat helpers.
0.0.24 - 2026-04-28
Fixed
- v0.0.23 binaries refused to launch on macOS 15.x with
You can't use this version of the application 'skhd' with this version of macOS.(#35). Without an explicitos_version_min, Zig stamps the Mach-O'sLC_BUILD_VERSION minoswith the build host's OS version, and themacos-latestCI runner is now Tahoe 26 — so the binary's minimum-OS field jumped past Sequoia.build.zignow pinsos_version_minto 13.0 (matchingInfo.plist'sLSMinimumSystemVersionand the SMAppService floor); settingos_version_minflips Zig out of native-SDK mode, so the build also probesxcrunonce and threads the SDK's framework / include / lib paths into every artifact. PATHinheritance under SMAppService now works for users withoutSHELLset, and fails loudly when it doesn't. v0.0.22's$SHELL -ilcapproach silently returned the launchd minimalPATHin several real cases:SHELLwas unset under launchd, or-itriggered shell-specific weirdness with no controlling tty (zshcompinitwarnings, fish prompt probes, rc files assuming a tty).detectLoginShellnow prefers$SHELLand falls back togetpwuid(getuid()).pw_shell— the same Open Directory sourcelogin(1)uses, so it resolves even when launchd doesn't setSHELL.capturePathuses shell-specific argv: fish runs-c 'string join : $PATH'(fish'sPATHis a list andconfig.fish/conf.dare always sourced), bash/zsh run-lc 'printenv PATH'(-lsources~/.zprofile/~/.bash_profilewhere Homebrew'sshellenvlives, dropping-iavoids the interactive-init noise). Every failure path now logs atwarnso the breakdown appears in~/Library/Logs/skhd.loginstead of being invisible, and the final inherited PATH is logged so users can see what skhd actually resolved.
Added
.pathdirective for explicit PATH additions. Escape hatch for the cases where shell-inheritedPATHisn't enough — mostly version-manager shims (mise/asdf/nvm) which only land inPATHvia shell hooks that-lcdoesn't always trigger, and any directory the user wants resolved before system tools of the same name. Single-entry and list forms (matching.shell/.blackliststyle):.path "/opt/homebrew/bin" .path [ "$HOME/.local/share/mise/shims" "~/bin" ]~and$HOMEexpand at parse time; no arbitrary$VARbecause parse-time env can differ from command-exec-time env. Entries are prepended toPATHafter the shell-inheritedPATHis resolved (declaration order preserved), so explicit user paths take precedence.
Changed
- x86_64 prebuilt releases are back, paused since v0.0.19. Instead of spinning up a
macos-13Intel runner (slow queue, on GitHub's deprecation timeline), the arm64 runner cross-compiles via-Dtarget=x86_64-macos.13.0. The macOS SDK is universal so x86_64 stubs are present, andcodesignon arm64 signs x86_64 Mach-Os fine.
Internal
- Portable
BOOLmarshalling insm_app_service.zigfor x86_64. Apple'sobjc.hgatesBOOLon__OBJC_BOOL_IS_BOOL, which Clang only sets for arm64-darwin — soc.BOOLtranslates to Zigboolon arm64 but toi8on x86_64, and the existingif (!ok)only typechecked on arm64. Theobjc_msgSendreturn is now declaredu8(both archs returnBOOLin the low byte regardless of C-level typedef) with explicit!= 0comparisons. Unblocks the cross-compile.
0.0.23 - 2026-04-26
Fixed
- Event tap now actually detaches on Accessibility revoke. v0.0.22 relied on
kCGEventTapDisabledByUserInputfiring when Accessibility was toggled off at runtime, but the callback isn't actually fired in that case — the OS just stops delivering events to the tap, leaving the keyboard captured with no signalkeyHandlercould react to. The one-shot recovery timer is replaced with an always-on 1 sCFRunLoopTimerthat reconciles the tap withAXIsProcessTrustedin both directions: detach proactively on revoke, recreate on re-grant.AXIsProcessTrustedis cached at the OS level and runs in ~µs, so the poll has no measurable overhead and the keydown /NX_SYSDEFINEDhot path is untouched. - Daemon log lines actually reach
~/Library/Logs/skhd.log. SMAppService's bundledLaunchAgent.plistsets noStandardErrorPath, so stderr went to/dev/nulland everylog.err/log.infofrom the daemon was silently dropped. Stderr is now redirected to the log file when the process is launchd-managed (detected byXPC_SERVICE_NAME != "0"— the variable is set to the placeholder"0"for normal user-shell processes, so a plain null-check matched everything). Foreground-Vruns andzig build-spawned subprocesses keep stderr at the terminal/pipe. std_options.log_levelfloored at.warnso the session-start marker (=== skhd <ver> started at <iso ts> (PID N) ===) survives ReleaseFast builds, which would otherwise filter everything below.err.
Changed
- Foreground runs use silent
AXIsProcessTrustedinstead ofAXIsProcessTrustedWithOptions(prompt: true). The TCC dialog popped on everyzig build run/zig build allociteration was noise, and on Tahoe TCC mis-displays the path when self-signed dev/prod bundles share acom.jackielii.skhd*prefix. The daemon install path still prompts on first install.
Internal
zig build allocis routed through the dev.app+ sign chain so the alloc binary inherits the dev TCC slot. A bare Mach-O can't be granted Accessibility on Tahoe, so the previous setup couldn't actually run end-to-end.
0.0.22 - 2026-04-26
Fixed
- Event tap survives runtime Accessibility revoke. When Accessibility was toggled off while skhd was running, macOS sent
kCGEventTapDisabledByUserInputand the in-placeCGEventTapEnableretry silently failed — the tap stayed in the event chain as an active filter that couldn't forward events, leaving the keyboard unresponsive until skhd was killed. The tap is now detached on the disabled callback, and a 1 sCFRunLoopTimerwatches forAXIsProcessTrustedto flip back and recreates the tap on re-grant.EventTap.deinitalso cleans up when the tap is system-disabled, not just whenenabled(). --statusno longer false-negatives in the first 30 s after daemon start.getEventTapHealthscanned the daemon log for theACCESSIBILITY PERMISSIONS REQUIREDmarker, but SMAppService routes the daemon's stderr to/dev/null, so stale denial lines from previous runs dominated the tail. The log scan is now skipped when the log file is older than the running daemon, and reportsunknownin that window instead.
Changed
- Daemon sources
PATHfrom$SHELL -ilcat startup. Hotkeys that exec/opt/homebrew/bin/yabai,/opt/homebrew/bin/aerospace, etc. previously failed under launchd's minimalPATH(/usr/bin:/bin:/usr/sbin:/sbin). The interactive-login shell is queried once at startup so command lookups match what the user sees in their terminal.
Internal
zig build install-localstages the local build into the brew-installed bundle in place (preferring/Applications/skhd.appif you've manually symlinked it there, otherwise/opt/homebrew/opt/skhd-zig/skhd.app), re-signs withskhd-cert, and restarts the SMAppService daemon — for testing the packaged path without cutting a release.
0.0.21 - 2026-04-26
Fixed
- The actual root cause of "skhd doesn't always start after reboot" on macOS Tahoe. Hand-installed LaunchAgents under
~/Library/LaunchAgents/get registered with macOS's Background Tasks Manager (BTM, introduced in Sequoia, enforced in Tahoe) asType: legacy agentwithDisposition: [enabled, disallowed, not notified]— and BTM silently refuses to auto-load them at login until the user manually approves the agent in System Settings → General → Login Items & Extensions. The previous fixes (launchctl bootstrap migration, retry loops, plist paths) addressed real but secondary issues; BTM was the gatekeeper all along.
Changed
--install-servicenow usesSMAppServiceinstead of writing to~/Library/LaunchAgents/. The bundled plist lives insideskhd.app/Contents/Library/LaunchAgents/com.jackielii.skhd.plistand registration goes throughSMAppService.agent(plistName:).register(). BTM creates a proper managed entry (Type: agent,Disposition: [enabled, allowed, notified]) that auto-loads cleanly at every login.--uninstall-servicenow unregisters via SMAppService. Both install and uninstall also clean up any pre-0.0.21 hand-installed plist at~/Library/LaunchAgents/com.jackielii.skhd.plistso the legacy and new managed entries don't race.--statusreads SMAppService registration state directly. ReportsRegistration status: enabled/requires approval/not registeredso the user knows what BTM thinks.
Migration
On upgrade from 0.0.20 or earlier, run skhd --install-service once. The brew-installed skhd shim is a symlink into the bundle, so SMAppService gets the right calling bundle automatically; if you're running a source build, invoke the inner binary directly (<path-to>/skhd.app/Contents/MacOS/skhd --install-service). The legacy disallowed BTM entry from previous versions is harmless after the new managed entry is in place but can be removed via System Settings → General → Login Items & Extensions if desired. See docs/UPGRADING.md for the full walkthrough.
0.0.20 - 2026-04-26
Local-development quality-of-life release. No runtime changes.
Internal
zig build runnow produces a signed dev.appbundle atzig-out/skhd-dev.app, signed with a separateskhd-dev-certand bundle IDcom.jackielii.skhd.dev. On macOS Tahoe, an adhoc-signed bare binary cannot be granted Accessibility, sozig build runpreviously failed with permission errors during local debugging. The dev TCC slot is fully isolated from the prod entry (com.jackielii.skhd+skhd-cert) used by the Homebrew install, and re-signing every build keeps permissions stable across rebuilds. See docs/CODE_SIGNING.md.- First-run Accessibility popup.
AXIsProcessTrustedWithOptions(prompt=true)is now called before event tap setup so unknown bundles surface the macOS popup and System Settings deep-link, instead of failing silently after 10 retries. AccessibilityPermissionDeniederror message prefers the.appbundle that actually contains the running binary over/Applications/skhd.app, so the displayed path matches what a grant would apply to.scripts/codesign.shreadsSKHD_BUNDLE_IDenv var (defaults tocom.jackielii.skhd).scripts/make-app.shaccepts an optional bundle ID as the third argument.
0.0.19 - 2026-04-26
Small follow-up to v0.0.18 fixing a reporting bug.
Fixed
--statusreportedHotkeys functional: Nowhile the daemon was actually working. The previous logic read the daemon log's tail looking for "Event tap created successfully" markers — but ReleaseFast (Homebrew's build mode) suppresseslog.info, so the log stayed silent on success and old failure entries dominated. The daemon's event tap was active, only the status reporter was misled. Now uses process uptime viasysctl(kern.proc.pid)as the primary signal: a daemon alive for >30 s necessarily has a working event tap (otherwise launchd would have respawned it). Log tail kept as a fallback for very recent starts.AccessibilityPermissionDeniederror message wording. Previously said macOS Tahoe's picker "only accepts.appbundles". The picker actually accepts bare binaries — they're just hidden from the visible Accessibility list, so users can't toggle them on. Updated message describes the actual behavior.
Internal
- Release pipeline robustness. Validate that the git tag is annotated before reading its message; force-fetch tag objects post-checkout; fall back to
CHANGELOG.mdif the tag annotation is missing. v0.0.18 initially shipped with a release body containing a random commit message becauseactions/checkout@v4'sfetch-tags: truedoesn't reliably fetch annotated tag objects.
0.0.18 - 2026-04-26
macOS Tahoe (26) compatibility
This release reworks distribution and service management for macOS 26 (Tahoe). See docs/UPGRADING.md for the one-time setup users on 0.0.17 or earlier need to perform after upgrading.
Added
.appbundle distribution — skhd now ships asskhd.appinstead of a bare Mach-O. TCC accessibility entries are keyed by bundle ID (com.jackielii.skhd) instead of by file path, so permissions persist across rebuilds andbrew upgrade.zig build app/zig build sign-app— build steps for producing and signing the.appbundle locally.- Daemon health in
--status— now reportsDaemon running(fromlaunchctl list) andHotkeys functional(from log file tail), instead of the misleadingAXIsProcessTrustedcheck on the CLI process. - docs/UPGRADING.md — step-by-step guide for users moving from 0.0.17 to 0.0.18.
Changed
- Logs moved to
~/Library/Logs/skhd.log(was/tmp/skhd_$USER.log). The previous path was wiped at every boot, hiding boot-time failures. - Service management uses
launchctl bootstrap/bootoutinstead of legacyload -w/unload -w.--stop-serviceno longer leaves the agent in a persistently-disabled state across reboots. - Plist
ProgramArgumentspoints at the stable/opt/homebrew/opt/skhd-zig/skhd.app/Contents/MacOS/skhdsymlink instead of a version-pinned Cellar path. - Plist
ThrottleIntervallowered from 30 s to 10 s for faster recovery from boot-time failures. AccessibilityPermissionDeniederror message now points at the.appbundle path (which Tahoe's picker accepts) instead of the inner binary.
Removed
- Intel (x86_64) prebuilt releases paused. Apple Silicon only as of v0.0.18. Intel users can still build from source via
zig build sign-app. Re-enable hooks documented in.github/workflows/release.ymlandFormula/skhd-zig.rb(kept commented for easy restoration). - Homebrew
brew servicesintegration. Replaced by skhd's own--install-service, which produces a properly Tahoe-tuned launchd plist (retry loop, log path, ThrottleInterval, bundle-aware ProgramArguments). Migrate withbrew services stop skhd-zig 2>/dev/null && skhd --install-service && skhd --start-service. The two agents would race for the event tap if both were enabled.
Fixed
- Boot-time
CGEventTapCreaterace — added a 10-attempt retry loop with 500 ms backoff. The daemon used to exit and wait the fullThrottleIntervalwhen WindowServer/TCC weren't ready immediately at login. scripts/codesign.shcert auto-creation — fixed empty-password p12 import rejection on macOS Tahoe + OpenSSL 3.6, and the missingextendedKeyUsage = codeSigningthat hid the cert fromfind-identity -p codesigning.- Homebrew formula auto-bump regex — replaced the buggy
[0-9.(-preview)]\+character class withv[0-9.]+(-[A-Za-z0-9]+)?so pre-release tags (v0.0.18-preview,v0.0.19-rc1) update correctly.
0.0.17 - 2025-12-08
Added
- Media key support - Added support for media keys as forward/remap targets (#28)
- Supported media keys:
play,pause,next,previous,fast,rewind,brightness_up,brightness_down,illumination_up,illumination_down,sound_up,sound_down,mute - Example:
cmd - p | playforwards Cmd+P to the play/pause media key
- Supported media keys:
0.0.16 - 2025-11-30
Fixed
- CFString null pointer crash - Fixed crash during keyboard layout initialization on certain keyboard layouts (#19, #20)
- Added null check for
CFStringCreateWithCharacterswhich can return NULL for some keycodes - skhd now gracefully skips problematic keycodes and continues initialization
- Added null check for
0.0.15 - 2025-10-17
Added
- Code signing support for macOS 15+ - Accessibility permissions now persist across builds (#15)
- Added
Info.plistwith bundle identifier for stable TCC identity - Added
zig build signcommand for local development signing - Release binaries are now automatically signed
- See
docs/CODE_SIGNING.mdfor setup instructions
- Added
Fixed
- Missing F16-F20 keycodes - Added support for F16-F20 function keys in observe mode (#14)
- These keys were already usable in configs but showed as "unknown" in
-omode - Note: F21-F24 cannot be supported as they are not defined in macOS's HIToolbox framework
- These keys were already usable in configs but showed as "unknown" in
- Homebrew release artifact URL - Fixed regex to handle preview tags in release URLs
- Thanks to @tdjordan for the contribution (#17)
Changed
- Removed unused
Info.plistfile from assets directory
0.0.13 - 2025-08-27
Added
- Support for backtick (`) special character - Added backtick to the list of recognized special characters in the tokenizer
- Enables hotkey bindings with the backtick key
- Thanks to @danielfalbo for the contribution (#8)
Fixed
- Duplicate keycode from layout - Fixed issue where keycodes could be duplicated when retrieved from keyboard layout
- ZBench vendor dependency - Fixed vendor import for zbench benchmarking library
Changed
- Improved error messages - Enhanced parser error reporting with contextual information
- Added helpful error messages for invalid hex keycodes with examples
- Improved duplicate command detection with specific context about conflicts
- Added suggestions for common mistakes (e.g., "Did you forget to declare it with '::mode'?")
- Better error reporting for file loading, blacklist, and shell configuration failures
- Duplicate command handling - Allow identical duplicate commands in process groups
- This enables more flexible configuration with overlapping process groups
- Duplicate detection still prevents conflicting commands for the same process
- Build optimization - Only build all targets on main branch to speed up development builds
- Code improvements - Various internal refactoring and simplifications
- Simplified activation equality check
- Use Zig field syntax for cleaner code
- Added error sets for type safety in Hotkey methods
0.0.12 - 2025-07-15
Added
- Mode activation with optional command execution - Enhanced mode switching with command execution support
- New syntax:
keysym ; mode : commandexecutes command when switching to mode - Process-specific mode activation in process lists (e.g.,
"terminal" ; vim_mode) - Process group mode activation (e.g.,
@browsers ; browser_mode) - Comprehensive test coverage for all activation scenarios
- New syntax:
- Added
activationvariant toProcessCommandenum for proper mode activation tracking
Changed
- Refactored command parsing to eliminate code duplication with helper function
parse_command - Removed redundant
flags.activatefield fromModifierFlag - Updated SYNTAX.md and README.md with comprehensive mode activation documentation
Fixed
- Fixed mode activation implementation to use dedicated enum variant instead of borrowing command enum
- Improved error handling for empty commands followed by references
0.0.11 - 2025-07-13
Changed
- Optimized command execution by using null-terminated strings throughout, eliminating runtime allocations in exec.zig
- Refactored Hotkey API to have separate methods for each action type (add_process_command, add_process_forward, add_process_unbound)
Fixed
- Fixed benchmark to use new Hotkey API methods
0.0.10 - 2025-07-08
Fixed
- Critical bug fix: Capture mode now respects passthrough and unbound actions
- Previously, capture mode would consume all keys including those explicitly marked as passthrough (
->) or unbound (~) - Now these keys are properly passed through to applications even in capture mode
- Previously, capture mode would consume all keys including those explicitly marked as passthrough (
Added
- Support for unbound action syntax:
<keysym> ~- Keys marked as unbound are not captured and pass through to applications
- Compatible with all existing features (modes, process lists, etc.)
- Added
--messageflag to release script for custom tag messages
Changed
- Refactored hotkey processing to use
HotkeyResultenum instead of boolean return- Clearer distinction between consumed, passthrough, and not_found states
- Eliminated code duplication between
handleKeyDownandhandleSystemKey
Internal
- Added comprehensive tests for capture mode behavior with passthrough and unbound actions
- Extracted common hotkey result handling into
handleHotkeyResulthelper function - Updated SYNTAX.md documentation to include unbound action syntax
0.0.9 - 2025-07-07
Fixed
- A subtle but critical bug only happens in release mode due to how memory allocation works with aggressive allocators like
smp_allocatororc_allocator. This bug caused HashMaps to silently point to different objects after destroying an object that was still referenced in the map. This has been fixed by using a array list to track the hotkeys instead of a HashMap, which avoids this issue entirely.
Added
- Improved duplicate hotkey detection with better error reporting
Internal
- Added issue template for better bug reporting
- Updated CI workflow configuration
- Include build mode in version string output
0.0.8 - 2025-07-06
Changed
- Major performance improvement: Achieved allocation-free event loop
- Replaced dynamic allocation for process names with fixed-size buffer
- Zero allocations during runtime after initialization
- Event loop is now completely allocation-free in release builds
- Refactored hotkey implementation for simplicity and performance
- Removed HotkeyArrayHashMap and HotkeyMultiArrayList (740+ lines removed)
- Consolidated hotkey functionality in Hotkey.zig
- Enhanced test coverage with comprehensive duplicate detection tests
- CarbonEvent now uses a pre-allocated buffer for process names to avoid runtime allocations
- Moved VERSION file from src/VERSION to root directory for better visibility
- Code cleanup and formatting improvements across multiple modules
Fixed
- Fixed cleanup logic when sending SIGINT to the process
- Fixed memory leaks in Hotkey.zig and improved memory management
- Duplicate definition detection: Now reports errors instead of silently overwriting duplicate entries in config
- Fixed CI/CD release workflow by replacing deprecated upload-release-asset action with gh CLI
Internal
- Added TrackingAllocator for monitoring memory allocations during development
- Created new exec.zig module for command execution
- Improved error handling in Parser, Mappings, and Keycodes modules
0.0.7 - 2025-07-05
Fixed
- Accessibility permission check reliability - Replaced unreliable event tap creation with
AXIsProcessTrusted()API --statuscommand now correctly reports accessibility permission state- Fixed issue where permissions showed as "not granted" even when properly configured
Changed
- Permission checking now uses the official macOS API for more accurate results
0.0.6 - 2025-07-04
Added
- Command definitions feature with
.definedirective for reusable command templates- Support for placeholders (
{{1}},{{2}}, etc.) in command templates - Reference commands with
@command_name("arg1", "arg2")syntax - Reduces configuration duplication and improves maintainability
- Support for placeholders (
- Enhanced error handling for command definition parsing with clear error messages
Changed
- Refactored tokenizer to clean up token text representation
- Optimized command definition storage by moving it directly to Parser
- Updated documentation to include command definition examples
Fixed
- Command definition parsing now properly handles escaped characters in templates
- Improved error reporting for invalid placeholder syntax
0.0.5 - 2025-07-02
Changed
- Improved service mode execution to always use fork/exec for better reliability
- Refactored hotkey storage to use MultiArrayList for better memory layout and performance
- Updated README to explicitly mention key remapping/forwarding feature
Added
- MIT License file
- Integrated Homebrew tap update directly into release workflow
Fixed
- Import statement cleanup for better code organization
- GitHub Actions workflow now directly triggers Homebrew tap updates
0.0.4 - 2025-07-02
Added
- Comprehensive execution tracer with
-P/--profileflag for performance analysis - Benchmark suite using zBench for hot path optimization
- Carbon event handler for efficient app switching notifications
Changed
- Major performance optimization: Cache process name lookups (25μs → 21ns)
- Eliminated double hotkey lookup: Combined into single
processHotkeyfunction (169ns → 83ns) - CPU usage reduced from ~1.2% to ~0.5% (matching original skhd)
Fixed
- High CPU usage compared to original skhd implementation
- Unnecessary system calls in hot path
0.0.3 - 2025-07-01
Added
--start-servicenow automatically installs/updates the service plist to ensure it uses the current binary--statuscommand to check service installation status, running state, and accessibility permissions- Clear startup message in service mode to confirm skhd is running
- Improved accessibility permission error message with troubleshooting steps for when permissions are "stuck"
Changed
- Service mode now only logs errors and startup messages, reducing log verbosity
- Removed unnecessary stdout/stderr syncing in logger for better performance
Fixed
- Service management commands now provide better error messages and guidance
- Homebrew service integration now works more reliably with proper binary path updates
0.0.2 - 2025-07-01
Fixed
- Support for uppercase option names (.SHELL, .BLACKLIST) in configuration files
- Improved error reporting to show parse errors with line numbers during initialization
- Parser now properly handles comma-separated lists in .define directives
- Exit with proper error when config file is not a regular file (e.g., /dev/null)
- Fixed release workflow permissions for uploading artifacts
- Simplified release workflow to build natively for each architecture
0.0.1 - 2025-07-01
Added
- Initial release of skhd.zig - a complete Zig port of skhd
- Full compatibility with original skhd configuration format
- Core features:
- Event tap creation and keyboard event handling
- Hotkey mapping with modifier support (cmd, alt, ctrl, shift)
- Left/right modifier distinction (lcmd, rcmd, etc.)
- Modal system with mode switching and capture modes
- Process-specific hotkey bindings
- Key forwarding/remapping
- Blacklist support for applications
- Shell command execution
- Configuration file loading with
.loaddirective - Custom shell support with
.shelldirective
- Command-line interface:
-c/--config- Specify config file-o/--observe- Observe mode for testing keys-V/--verbose- Verbose output-k/--key- Synthesize keypress-t/--text- Synthesize text-r/--reload- Reload configuration-h/--no-hotload- Disable hot reloading-v/--version- Show version
- Service management:
--install-service- Install launchd service--uninstall-service- Remove launchd service--start-service- Start service--restart-service- Restart service--stop-service- Stop service
- Enhanced features:
- Process group variables (New!) - Define reusable process groups with
.definedirective - Improved error reporting with line numbers and file paths
- Unicode character handling in process names
- Fixed key repeating issue with event forwarding
- Comprehensive test suite
- CI/CD workflow with GitHub Actions
- Process group variables (New!) - Define reusable process groups with
Fixed
- Key repeating issue when forwarding events to applications
- Unicode invisible character handling in process names
- Modifier matching logic to properly handle general vs specific modifiers
- Memory management and hot reload stability
Performance
- Optimized hot path to minimize allocations during key events
- Efficient HashMap-based hotkey lookup
- Stack-based buffers for process name retrieval