# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased](https://github.com/jackielii/skhd.zig/compare/v0.2.0...HEAD) ## [0.2.0](https://github.com/jackielii/skhd.zig/compare/v0.1.11...v0.2.0) - 2026-07-20 ### Added - **Multi-chord hotkey sequences.** A hotkey may now be a comma-separated run of chords, fired only when they arrive in order within a time budget: `cmd - q, cmd - q : command`. Each chord carries its own modifiers (nothing is inherited between chords), and every existing action form works on a sequence — commands, key forwarding, unbound (`~`) and passthrough (`->`) on the final chord, mode activation, command references, and process lists/groups. The motivating case (issue #51): require two presses before a destructive action in one app, while every other app keeps the key's normal behavior. - **`.sequence_timeout` directive.** Sets the maximum gap between consecutive chords (default 300ms). Accepts a bare integer, `500ms`, or `1s`. The `s`/`ms` suffix now also works in `.remap` tap-hold `timeout` fields, where the grammar had always documented it. - **Sequence fallback.** A shorter binding may be the prefix of a longer sequence; if the sequence doesn't complete, the shorter one fires — the same idea as Vim's `timeoutlen`, but the wait only happens in applications where a longer match actually applies. This lets a global binding and an application's native shortcut share one chord: `lcmd - k` focuses a window everywhere, while `cmd - k, cmd - k [ "Google Chrome" | cmd - k ]` sends Chrome its own Cmd-K on a double-tap. ### Fixed - **`skhd -k` now reports a bad keyspec instead of a config-file parse error.** `skhd -k "hello world"` previously printed `Mode 'hello' not found` and a stack trace, because the key argument was routed through the config-file grammar. It now parses a keyspec directly and, on failure, prints a clear message pointing at `-t/--text` for literal text. - **No spurious grabber-socket warning when no grabber is needed.** `skhd --status` (and other probes) no longer print `grabber socket not found …` for configs without `.remap`/`.taphold` rules, which need no `skhd-grabber` at all. The warning is now emitted only where a missing grabber actually breaks something. ## [0.1.11](https://github.com/jackielii/skhd.zig/compare/v0.1.10...v0.1.11) - 2026-07-12 ### Fixed - **Post-wake keyboard death where the device's IOKit registry ID did not change (mode-3 dead keyboard).** The 0.1.10 sleep-fix released and re-acquired the seize around every sleep transition; 0.1.9's DeviceNotify re-seized on re-enumeration. A third failure mode remained: the wake re-seize can grab the built-in keyboard before its HID stack is ready — the seize opens fine but delivers nothing, and with an unchanged registry ID no event re-triggers a re-seize. The grabber now **verifies** the seize is delivering input after every wake and re-seizes (up to 3 attempts over ~20s) until it is. - **Mid-session keyboard death from a wedged vhidd server session (mode-4 dead keyboard).** The virtual-HID injection connection can wedge server-side with no error anywhere: report posts keep succeeding, the old connection even answers a ready probe, yet nothing is injected — keys are seized but never re-emitted. Only a fresh connection heals it. Detected and repaired automatically by the new heartbeat watchdog (below). ### Added - **vhidd heartbeat watchdog.** The vhidd server heartbeats every connected client; the grabber previously never read them. A run-loop source now drains the client socket as frames arrive, and each heartbeat re-arms a deadline timer (the timer is slid in place — no per-heartbeat object churn). A missed deadline, or a live `keyboard_ready=false` push, forces a full vhidd-connection + HID-seize teardown and rebuild — catching a wedged injection path mid-session, not just at wake. Replaces a wake-time ready-probe that a wedged session could answer. - **Cord-flip master restore.** Unplugging and replugging the power cable twice within 10 seconds (4 AC↔battery transitions) forces the same full rebuild. This is the escape hatch that works when *all* key input is dead: the seize holds the keyboard exclusively at kernel level, so no key press — on any key — can be observed by anything while it's stale; the power connector is independent of the keyboard entirely. Deliberately awkward so it can't fire by accident; active in all builds; no-op while asleep or when nothing is seized. - **Recovery documentation.** The README gains a "recovery ladder" for an unresponsive keyboard: lid close/reopen → cord flip ×2 → external-keyboard replug → `skhd --restart-service` over SSH → grabber kickstart. ### Changed - **Power and battery diagnostics expanded (Debug/ReleaseSafe builds only).** The grabber now logs every power-source transition (battery ↔ AC, percentage, charging, low-battery warning level) and every raw IOKit power message, including non-sleep transitions that were previously invisible. All new lines are compiled out of ReleaseFast — verified against the release binary — so production logs stay quiet. ## [0.1.10](https://github.com/jackielii/skhd.zig/compare/v0.1.9...v0.1.10) - 2026-06-24 ### Fixed - **skhd-grabber no longer dead-keys the built-in keyboard across sleep (the remaining case).** 0.1.9 made the grabber re-seize when the keyboard *re-enumerates* on wake, but the keyboard could still come back dead with its IOKit registry id *unchanged* — so nothing re-enumerated, the re-enumeration watch correctly fired nothing, and re-seizing the same device in place couldn't revive it. Root cause: the grabber held its `IOHIDManager` seize *across* the sleep power transition, so when the keyboard powered down mid-sleep the seized connection went stale (on wake the device still reported present, but no events flowed). The grabber now **releases the seize before sleep and re-acquires a fresh one on wake** — it never holds the seize across the power transition (the same lifecycle Karabiner's grabber uses: devices are ungrabbable while the system is sleeping). On `kIOMessageSystemWillSleep` it tears down the seize and acks the sleep after a short delay so the release lands before the device loses power; on `kIOMessageSystemHasPoweredOn` it re-seizes against the now-healthy device. Validated across an 11-day soak with a single long-lived grabber process surviving multiple sleep cycles (including ~2-day and ~8-day suspends) with zero dead-keyboard recurrences. ### Added - **`skhd --status` now reports both daemons' versions.** It prints the running `skhd` version and the running `skhd-grabber` version — the grabber's is queried live over IPC, so it reflects the actually-running daemon and surfaces an "agent updated but grabber not restarted" mismatch at a glance. ### Changed - **`skhd-grabber --version` reports the real version** (it previously printed a hardcoded `skhd-grabber (D1 skeleton)` placeholder). - **Grabber log timestamps and noise.** Every grabber log line is now prefixed with a local `[YYYY-MM-DD HH:MM:SS]` timestamp so events line up with `pmset -g log` across multi-day idle periods. Routine per-wake events (keyboard re-seizes, device enumeration changes) and informational startup lines were demoted to `info`, so a release build's log grows only on genuine anomalies — important for a daemon meant to run for months without a restart. ## [0.1.9](https://github.com/jackielii/skhd.zig/compare/v0.1.8...v0.1.9) - 2026-06-12 ### Fixed - **Release builds no longer crash on Intel Macs (#46).** Signed x86_64 release binaries died at startup with SIGILL/SIGBUS in whatever function happened to sit first in the text section (`Keycodes.init`, `Parser.init`). Root cause is upstream [ziglang/zig#23704](https://github.com/ziglang/zig/issues/23704): Zig's MachO linker emits zero headerpad on x86_64, so Apple's `codesign` silently writes its `LC_CODE_SIGNATURE` load command over the first bytes of `__TEXT,__text`, corrupting the first function's prologue (arm64 was unaffected because Zig already reserves an ad-hoc signature load command there; unsigned binaries ran fine, and Debug builds escaped by layout luck). Both `skhd` and `skhd-grabber` now reserve `0x1000` of headerpad — the same amount Apple's clang/ld64 leaves — so signing no longer touches code. Verified by diffing `__text` across a `codesign` run: previously the prologue bytes were replaced by the signature load command, now they're untouched. Thanks @UnixMonky for the patient round-trips that pinned this down! - **skhd-grabber recovers the keyboard after DarkWake/hibernate.** The 0.1.7 sleep/wake fix re-seized on the system power-on notification, but a DarkWake or hibernate cycle re-enumerates the keyboard *without* sending power-on, so the grabber kept holding a dead device and the built-in keyboard came back unresponsive. The grabber now watches the keyboard's IOService directly (`IOServiceAddMatchingNotification`, `kIOFirstMatch` + `kIOTerminated`, in the new `src/grabber/DeviceNotify.zig`) and re-seizes whenever the device re-appears — event-driven, no polling, the same approach Karabiner's grabber uses. This replaces the power-notification path (`PowerNotify.zig` is gone). Reproduced via scheduled DarkWake and validated across a real hibernate cycle. ## [0.1.8](https://github.com/jackielii/skhd.zig/compare/v0.1.7...v0.1.8) - 2026-06-07 ### Fixed - **Built-in keyboard remaps no longer leak onto external keyboards (#47).** The `(0,0)` built-in `.device` alias matched on keyboard usage alone, so the grabber seized every connected keyboard and a `[device builtin]` remap could intermittently fire on an external one. The match is now scoped to the internal-bus transport (FIFO/SPI), capturing only the built-in keyboard. Thanks @ingara! - **`skhd --status` reports hotkey health reliably.** It used to print `Unknown` for ~30s after start and on installed builds, because it guessed from process uptime and log scraping; it now reads the event tap's live state directly via `CGGetEventTapList`. ### Added - **`skhd --status` shows skhd-grabber health when your config needs it** — required (and for which device), installed, running, and IPC reachable — for configs with tap-hold (`.remap` block-form) rules. Other configs stay quiet. ## [0.1.7](https://github.com/jackielii/skhd.zig/compare/v0.1.6...v0.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.gz` from the arm64 runner, but the Homebrew cask was arm64-only — so `brew install jackielii/tap/skhd-zig` refused to install on Intel. `Casks/skhd-zig.rb` is now dual-arch: an `arch arm: "arm64", intel: "x86_64"` stanza drives `skhd-#{arch}-macos.tar.gz`, with per-arch `sha256 arm:/intel:` checksums, and the `depends_on arch: :arm64` restriction is dropped. release.yml's `update-homebrew` job now rewrites both checksum lines on each release (anchored on the 64-hex value so the `arch` stanza's `arm:`/`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 no `VendorID`/`ProductID` in the IOKit registry. The three matching paths each mishandled this: `IOHIDManager` matching with `{VendorID:0, ProductID:0}` requires the properties to *exist*, so the seize (`HidSeize`) and presence check (`DeviceCheck`) matched zero devices, while `hidutil`'s `--matching '{"VendorID":0,"ProductID":0}'` treated 0/0 as a wildcard and applied the `UserKeyMapping` to *every* connected keyboard. Now: for a 0/0 alias, `HidSeize.setMatches` and `DeviceCheck.isPresent` omit the VID/PID keys (the Generic Desktop / Keyboard usage filter keeps the match to keyboards) and confirm at least one matched device genuinely lacks a `VendorID` (the Karabiner VHIDD, which *does* carry one, is excluded — and un-seized after open to avoid a feedback loop); `Hidutil.buildMatching` scopes 0/0 to `{Built-In:1, PrimaryUsagePage:1, PrimaryUsage:6}` so only the internal keyboard is touched. All three paths now key on `PrimaryUsagePage`/`PrimaryUsage` so they agree on the device set, `--list-devices` shows VID-less keyboards as `vendor: 0x0, product: 0x0` instead 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 with `permissive_hold`) and tapping a nested key — e.g. `space + caps - h` expected to resolve through the agent's `fn_layer < ctrl - h | ctrl - left` mapping — would intermittently land bare `ctrl-h` at the OS instead. The two rules ran as independent FSMs in the grabber; caps's `permissive_hold` fires on `h↑` and emitted `ctrl + h` to 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 from `doHoldCommit`. 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 `IOHIDManager` held 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 in `mach_msg`, IPC + vhidd sockets connected) but no keystrokes flowed. The grabber now registers for system power notifications (`IORegisterForSystemPower`, in the new `src/grabber/PowerNotify.zig`) and, on `kIOMessageSystemHasPoweredOn`, re-runs `applyLatestRules` — 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 in `HidSeize.zig`) is `info`-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](https://github.com/jackielii/skhd.zig/compare/v0.1.4...v0.1.6) - 2026-05-24 ### Added - **`NSMicrophoneUsageDescription` in 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](https://github.com/jackielii/skhd.zig/compare/v0.1.3...v0.1.4) - 2026-05-23 > First release distributed as a Homebrew **cask** (replacing the formula). `brew install jackielii/tap/skhd-zig` now installs `skhd.app` directly into `/Applications`, which the formula required users to do manually via `ln -sfn`. ### Added - **`--list-devices` prints connected HID keyboards as paste-ready `.device` blocks.** Authors of `.remap` / `.taphold` rules previously had to grep `hidutil list` (hundreds of SMC sensor rows alongside the actual keyboards) and copy VendorID/ProductID by hand. The new flag enumerates devices via `IOHIDManager` with a `DeviceUsagePage:1 / DeviceUsage:6` match 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 `.device` block 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.app` bundle into `/Applications`, runs `xattr -dr com.apple.quarantine` in `postflight` to clear Gatekeeper's quarantine flag on the self-signed binary, and surfaces the CLI on `PATH` via the cask's `binary` stanza pointing at `skhd.app/Contents/MacOS/skhd`. Uninstall stops the user LaunchAgent via `launchctl`. release.yml's `update-homebrew` job seds `Casks/skhd-zig.rb`'s `version` + `sha256` lines (the cask's `url` interpolates `#{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: 1. `isTransportError`'s allowlist was too narrow (`SendFailed` / `ShortWrite` only). `ConnectionResetByPeer`, `BrokenPipe`, `Unexpected` — exactly the errors the OS surfaces when the Karabiner daemon resets the socket — fell through to the "no-op" branch, so `markVhiddBroken` was 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. 2. `applyLatestRules`'s `teardownSeize` ran 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. `teardownSeize` now 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](https://github.com/jackielii/skhd.zig/compare/v0.1.2...v0.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 every `postKeyboardReport` returned `SendFailed`, so real keystrokes were silently dropped — keyboard appeared dead until reboot. Now: on transport failure, the grabber latches a `vhidd_broken` flag, schedules a one-shot `CFRunLoopTimer`, 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 existing `applyLatestRules` lazy-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: 0xE00002C2` log 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](https://github.com/jackielii/skhd.zig/compare/v0.1.1...v0.1.2) - 2026-05-09 ### Fixed - **`brew upgrade` now actually restarts the service.** The Homebrew formula gained a `post_install` hook that runs `skhd --start-service` after 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 — same `Label`, two definitions, and launchd refused to spawn either with `EX_CONFIG` (`108: Invalid path: Contents/MacOS/skhd`). The post_install hook chains through `installService → 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 upgrade` rebuilds the binary, the cdHash changes, and TCC silently invalidates the Input Monitoring grant — System Settings still shows the entry as granted, `IOHIDCheckAccess` returns 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 two `tccutil reset` commands by hand. The agent now detects this case (event tap creation fails AND `IOHIDCheckAccess == denied` AND we're launchd-managed) and runs `tccutil reset ListenEvent / Accessibility com.jackielii.skhd` itself, 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_at` rate-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](https://github.com/jackielii/skhd.zig/compare/v0.1.0...v0.1.1) - 2026-05-05 ### Added - **`--start-service` is 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_layer` rules and a target device is connected. Single command users reach for whether installing fresh, recovering from a stopped agent, or re-running after a `brew upgrade`. ### Fixed - **`--install-grabber` could leave the system in a half-broken state with no diagnostic.** Three layered issues conspired: `runLaunchctl` discarded launchctl's stderr/stdout so the actual error was never seen; `main.zig` swallowed grabber CLI errors with `catch std.process.exit(1)`, dropping the error name; and the `bootout`-then-`bootstrap` sequence had no delay between calls — macOS's `bootout` is async, so a follow-up `bootstrap` issued immediately can race the prior teardown and fail with EIO. Fixes: - `runLaunchctl` prints 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 `bootstrapService` helper: bootout → 300ms sleep → bootstrap (with one 800ms-delayed retry on failure) → enable → kickstart. Shared between `installGrabber` and `installVhiddDaemon`. - After the launchctl chain, `installGrabber` verifies `launchctl print system/