Showing preview only (967K chars total). Download the full file or copy to clipboard to get everything.
Repository: jackielii/skhd.zig
Branch: main
Commit: bfca2c0abfc1
Files: 91
Total size: 927.1 KB
Directory structure:
gitextract_ldysld9j/
├── .envrc
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ └── bug_report.md
│ └── workflows/
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── CHANGELOG.md
├── CLAUDE.md
├── LICENSE
├── README.md
├── SYNTAX.md
├── TODO.md
├── VERSION
├── assets/
│ ├── Info.plist.grabber.template
│ ├── Info.plist.template
│ ├── LaunchAgent.plist
│ └── karabiner-virtualhiddevice-daemon.plist
├── build.zig
├── build.zig.zon
├── docs/
│ ├── CODE_SIGNING.md
│ ├── PLAN_ADVANCED_FEATURES.md
│ ├── PLAN_GRABBER.md
│ ├── UPGRADING.md
│ └── command-definitions.md
├── scripts/
│ ├── codesign.sh
│ ├── com.jackielii.skhd.grabber.plist
│ ├── install-local.sh
│ ├── make-app.sh
│ ├── make-grabber-app.sh
│ └── release.sh
├── src/
│ ├── CarbonEvent.zig
│ ├── DeviceCheck.zig
│ ├── EventTap.zig
│ ├── HidKeyMap.zig
│ ├── Hidutil.zig
│ ├── Hotkey.zig
│ ├── Hotload.zig
│ ├── Keycodes.zig
│ ├── Mappings.zig
│ ├── Mode.zig
│ ├── ParseError.zig
│ ├── Parser.zig
│ ├── Tokenizer.zig
│ ├── Tracer.zig
│ ├── TrackingAllocator.zig
│ ├── agent_grabber_client.zig
│ ├── agent_layer_listener.zig
│ ├── benchmark.zig
│ ├── c.zig
│ ├── echo.zig
│ ├── exec.zig
│ ├── grabber/
│ │ ├── HidSeize.zig
│ │ ├── HidSystem.zig
│ │ ├── Ipc.zig
│ │ ├── KbState.zig
│ │ ├── TapHold.zig
│ │ ├── Vhidd.zig
│ │ ├── c.zig
│ │ └── main.zig
│ ├── grabber_cli.zig
│ ├── grabber_protocol.zig
│ ├── main.zig
│ ├── service.zig
│ ├── skhd.zig
│ ├── sm_app_service.zig
│ ├── synthesize.zig
│ ├── tests.zig
│ └── utils.zig
├── taphold_test.skhdrc
└── testdata/
├── example_process_groups.skhdrc
├── hotload_test.skhdrc
├── loader.skhdrc
├── parse_errors.skhdrc
├── reload_test.skhdrc
├── sub.skhdrc
├── test-forward.skhdrc
├── test-lr-modifiers.skhdrc
├── test-process.skhdrc
├── test-shell.skhdrc
├── test-skhdrc
├── test-synthesis.skhdrc
├── test.skhdrc
├── test_debug_match.skhdrc
├── test_forward_logging.skhdrc
├── test_home_key.skhdrc
├── test_included.skhdrc
├── test_included_mode.skhdrc
├── test_logging.skhdrc
├── test_media_key_forward.skhdrc
├── test_modifier_matching.skhdrc
├── test_nested1.skhdrc
└── test_nested2.skhdrc
================================================
FILE CONTENTS
================================================
================================================
FILE: .envrc
================================================
use zig 0.14.0
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve skhd.zig
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. My hotkey configuration that causes the issue:
```bash
# paste relevant lines from your skhdrc here
```
2. The application I'm trying to use the hotkey in:
3. What happens when I press the hotkey:
4. Any error messages in the log `/tmp/skhd_$USER.log` (this is usually the configuration error):
```bash
# paste relevant log lines here
```
**Expected behavior**
A clear and concise description of what you expected to happen.
**Debug Information**
Please provide debug logs by following these steps:
1. **Get a debug build** (choose one):
- Download pre-built debug binary from [GitHub Actions](https://github.com/jackielii/skhd.zig/actions/workflows/ci.yml) (click latest run → Artifacts → `skhd-Debug`)
- Or build from source: `git clone https://github.com/jackielii/skhd.zig && cd skhd.zig && zig build`
2. **Run debug version with verbose logging**:
```bash
# Optional: Stop the service if you're running skhd as a service
# skhd --stop-service
# Run with verbose logging
./skhd -V > skhd-debug.log 2>&1
```
3. **Reproduce the issue** while skhd is running with verbose logging
4. **Stop skhd** with Ctrl+C and attach the `skhd-debug.log` file to this issue
**Environment**
- macOS version: [e.g. macOS 14.0 Sonoma]
- skhd.zig version: (run `skhd --version` to get this)
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup Zig
uses: mlugg/setup-zig@v2
with:
version: 0.14.0
- name: Run tests
run: zig build test
- name: Build Debug
run: zig build
- name: Lint shell scripts
run: bash -n scripts/make-app.sh && bash -n scripts/codesign.sh && bash -n scripts/release.sh
build-all:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: macos-latest
strategy:
matrix:
optimize: [Debug, ReleaseSafe, ReleaseFast, ReleaseSmall]
steps:
- uses: actions/checkout@v4
- name: Setup Zig
uses: mlugg/setup-zig@v2
with:
version: 0.14.0
- name: Setup Code Signing Certificate
# Mirrors release.yml: imports skhd-cert from MACOS_CERTIFICATE so the
# uploaded skhd.app is signed with the same identity as tagged
# releases. Without this the bundle would be adhoc-signed and lose
# TCC accessibility grants on every install/upgrade.
env:
CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE }}
CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
if [ -z "$CERTIFICATE_P12" ]; then
echo "::error::MACOS_CERTIFICATE secret is not configured."
echo "::error::build-all uploads user-runnable artifacts and requires skhd-cert."
exit 1
fi
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
echo -n "$CERTIFICATE_P12" | base64 --decode -o $CERTIFICATE_PATH
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $CERTIFICATE_PATH -P "$CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
- name: Build skhd.app (${{ matrix.optimize }})
run: |
zig build app -Doptimize=${{ matrix.optimize }}
mv zig-out/skhd.app skhd.app
- name: Code sign skhd.app
env:
SKHD_CERT: skhd-cert
SKHD_BUNDLE_ID: com.jackielii.skhd
SKHD_NO_AUTO_GENERATE_CERT: "1"
run: |
bash scripts/codesign.sh skhd.app
codesign --verify --verbose=2 skhd.app
- name: Verify bundle layout
run: |
test -f skhd.app/Contents/MacOS/skhd
test -f skhd.app/Contents/MacOS/skhd-grabber
test -f skhd.app/Contents/Info.plist
grep -q "<string>com.jackielii.skhd</string>" skhd.app/Contents/Info.plist
plutil -lint skhd.app/Contents/Info.plist
- name: Create tarball
run: tar -czf skhd-arm64-macos.tar.gz skhd.app
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: skhd-${{ matrix.optimize }}
path: skhd-arm64-macos.tar.gz
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
create-release:
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Force-fetch annotated tag objects
# actions/checkout@v4's `fetch-tags: true` reliably fetches the tag
# ref but does NOT always fetch the annotated tag *object* — when
# missing, `git tag -l --format='%(contents)' vX.Y.Z` silently falls
# back to the pointed-to commit's message and the release body ends
# up as a random commit message. Explicit fetch makes this reliable.
run: git fetch --tags --force origin
- name: Create Release
id: create_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG_NAME="${{ github.ref_name }}"
VERSION="${TAG_NAME#v}"
# Detect pre-release tags (-alpha / -beta / -rc, optionally
# followed by .N or -N). GitHub keeps pre-releases out of the
# repo's "latest release" badge and labels them clearly so
# watchers don't read the email as "you should upgrade".
PRERELEASE_FLAG=""
if [[ "$TAG_NAME" =~ -(alpha|beta|rc)([.-][A-Za-z0-9]+)?$ ]]; then
PRERELEASE_FLAG="--prerelease"
echo "Detected pre-release tag: $TAG_NAME"
fi
# Verify the tag is annotated. A lightweight tag would make
# `%(contents)` silently return the commit message — refuse the
# release rather than ship surprising notes.
TAG_TYPE=$(git cat-file -t "$TAG_NAME" 2>/dev/null || echo "missing")
if [ "$TAG_TYPE" = "tag" ]; then
NOTES=$(git tag -l --format='%(contents)' "$TAG_NAME")
echo "Using tag annotation as release notes."
else
echo "::warning::Tag $TAG_NAME has type '$TAG_TYPE' (expected 'tag') — falling back to CHANGELOG.md"
NOTES=""
fi
# Fall back to CHANGELOG.md when the tag annotation is missing.
if [ -z "$(echo "$NOTES" | tr -d '[:space:]')" ]; then
echo "Extracting release notes from CHANGELOG.md entry for $VERSION..."
NOTES=$(awk "/## \[$VERSION\]/{flag=1; next} /## \[/{flag=0} flag" CHANGELOG.md)
fi
if [ -z "$(echo "$NOTES" | tr -d '[:space:]')" ]; then
echo "::error::No release notes found in tag annotation or CHANGELOG.md for $TAG_NAME"
exit 1
fi
INSTALLATION_NOTES=$'## Installation\n\nInstall via Homebrew:\n```bash\nbrew install jackielii/tap/skhd-zig\n```\n\nor upgrade:\n```bash\nbrew upgrade jackielii/tap/skhd-zig\n```\n\n## Full Changelog\n\nSee [CHANGELOG.md](https://github.com/jackielii/skhd.zig/blob/main/CHANGELOG.md) for complete version history.'
FULL_NOTES="$NOTES"$'\n\n'"$INSTALLATION_NOTES"
gh release create "$TAG_NAME" \
--repo ${{ github.repository }} \
--title "$TAG_NAME" \
--notes "$FULL_NOTES" \
--draft \
$PRERELEASE_FLAG
build-release:
needs: create-release
strategy:
matrix:
include:
- os: macos-latest
arch: arm64
target: ""
# Cross-compile x86_64 from the arm64 runner. The macOS SDK is
# universal (x86_64 stubs ship in the same SDK arm64 uses), and
# codesign on arm64 happily signs an x86_64 Mach-O. Avoids
# depending on the macos-13 Intel runner, which is on GitHub's
# deprecation timeline. -Dtarget carries the explicit os version so
# build.zig's default_target (macos 13.0) is preserved.
- os: macos-latest
arch: x86_64
target: x86_64-macos.13.0
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Setup Zig
uses: mlugg/setup-zig@v2
with:
version: 0.14.0
- name: Setup Code Signing Certificate
env:
CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE }}
CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Create variables
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# Import certificate if available; otherwise hard-fail. Tagged releases
# without skhd-cert produce adhoc-signed bundles that silently lose
# TCC accessibility permissions on every install/upgrade — never the
# right outcome for a public release. Override only by setting
# ALLOW_UNSIGNED_RELEASE=true in the workflow env.
if [ -n "$CERTIFICATE_P12" ]; then
echo "Certificate found, setting up code signing..."
# Decode base64 certificate
echo -n "$CERTIFICATE_P12" | base64 --decode -o $CERTIFICATE_PATH
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate to keychain
security import $CERTIFICATE_PATH -P "$CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# Allow codesign to access the certificate
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
echo "SHOULD_SIGN=true" >> $GITHUB_ENV
elif [ "${ALLOW_UNSIGNED_RELEASE:-false}" = "true" ]; then
echo "::warning::ALLOW_UNSIGNED_RELEASE=true — shipping adhoc-signed bundles."
echo "::warning::Tahoe users will lose accessibility permissions on this release."
echo "SHOULD_SIGN=false" >> $GITHUB_ENV
else
echo "::error::MACOS_CERTIFICATE secret is not configured."
echo "::error::Tagged releases require code signing — see docs/CODE_SIGNING.md."
echo "::error::Set ALLOW_UNSIGNED_RELEASE=true in this workflow's env to opt out (not recommended)."
exit 1
fi
- name: Build Release .app bundle
run: |
TARGET_ARG=""
if [ -n "${{ matrix.target }}" ]; then
TARGET_ARG="-Dtarget=${{ matrix.target }}"
fi
zig build app -Doptimize=ReleaseFast $TARGET_ARG
# Stage the bundle at the working tree root for tarballing.
mv zig-out/skhd.app skhd.app
- name: Code Sign Bundle
if: env.SHOULD_SIGN == 'true'
env:
# Codesign skhd.app via the same script `zig build install-local`
# uses locally — keeps the inner-Mach-O signing order (helpers
# first, principal last) and the bundle ID identifier consistent
# between dev iteration and release builds.
SKHD_CERT: skhd-cert
SKHD_BUNDLE_ID: com.jackielii.skhd
# CI imports the cert from the MACOS_CERTIFICATE secret; if it's
# not in the keychain, that's a deployment misconfiguration —
# don't paper over it by generating a throwaway local cert.
SKHD_NO_AUTO_GENERATE_CERT: "1"
run: |
bash scripts/codesign.sh skhd.app
codesign --verify --verbose=2 skhd.app
- name: Create tarball
run: |
# Tarball name kept identical to pre-bundle releases so the homebrew
# formula auto-bump regex still matches; content is now skhd.app/.
tar -czf skhd-${{ matrix.arch }}-macos.tar.gz skhd.app
- name: Cleanup Keychain
if: always() && env.SHOULD_SIGN == 'true'
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
- name: Upload Release Asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload ${{ github.ref_name }} \
./skhd-${{ matrix.arch }}-macos.tar.gz \
--repo ${{ github.repository }} \
--clobber
publish-release:
needs: build-release
runs-on: ubuntu-latest
steps:
- name: Publish Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release edit ${{ github.ref_name }} \
--repo ${{ github.repository }} \
--draft=false
update-homebrew:
needs: publish-release
# Skip on pre-releases (-alpha / -beta / -rc) — bumping the formula
# would silently push every brew-installed user onto the pre-release
# via `brew upgrade`. Stable users stay on whatever the last final
# tag was; we install pre-releases manually via `gh release download`
# for testing.
if: ${{ !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && !contains(github.ref_name, '-rc') }}
runs-on: ubuntu-latest
steps:
- name: Checkout homebrew-tap
uses: actions/checkout@v4
with:
repository: jackielii/homebrew-tap
token: ${{ secrets.HOMEBREW_TAP_TOKEN || secrets.GITHUB_TOKEN }}
path: homebrew-tap
- name: Get release info
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ github.ref_name }}"
echo "version=${VERSION#v}" >> $GITHUB_OUTPUT
# Download release assets to calculate SHA256
cd ${{ github.workspace }}
gh release download ${VERSION} --repo jackielii/skhd.zig --pattern "*.tar.gz"
ARM64_SHA=$(shasum -a 256 skhd-arm64-macos.tar.gz | awk '{print $1}')
echo "arm64_sha=${ARM64_SHA}" >> $GITHUB_OUTPUT
X86_64_SHA=$(shasum -a 256 skhd-x86_64-macos.tar.gz | awk '{print $1}')
echo "x86_64_sha=${X86_64_SHA}" >> $GITHUB_OUTPUT
- name: Update Formula
env:
VERSION: ${{ steps.release.outputs.version }}
ARM64_SHA: ${{ steps.release.outputs.arm64_sha }}
X86_64_SHA: ${{ steps.release.outputs.x86_64_sha }}
run: |
cd homebrew-tap
# Update version line if present (the formula may scan version from
# URL instead). No-op when missing.
sed -i "s/version \".*\"/version \"${VERSION}\"/" Formula/skhd-zig.rb
sed -i -E "s|download/v[0-9.]+(-[A-Za-z0-9]+)?/skhd-arm64|download/v${VERSION}/skhd-arm64|" Formula/skhd-zig.rb
sed -i "/arm64-macos.tar.gz/,/sha256/ s/sha256 \".*\"/sha256 \"${ARM64_SHA}\"/" Formula/skhd-zig.rb
sed -i -E "s|download/v[0-9.]+(-[A-Za-z0-9]+)?/skhd-x86_64|download/v${VERSION}/skhd-x86_64|" Formula/skhd-zig.rb
sed -i "/x86_64-macos.tar.gz/,/sha256/ s/sha256 \".*\"/sha256 \"${X86_64_SHA}\"/" Formula/skhd-zig.rb
- name: Commit and push
run: |
cd homebrew-tap
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git add Formula/skhd-zig.rb
git commit -m "Update skhd-zig to v${{ steps.release.outputs.version }}"
git push
================================================
FILE: .gitignore
================================================
# This file is for zig-specific build artifacts.
# If you have OS-specific or editor-specific files to ignore,
# such as *.swp or .DS_Store, put those in your global
# ~/.gitignore and put this in your ~/.gitconfig:
#
# [core]
# excludesfile = ~/.gitignore
#
# Cheers!
# -andrewrk
.zig-cache/
zig-cache/
zig-out/
/release/
/debug/
/build/
/build-*/
/docgen_tmp/
/.claude/settings.local.json
================================================
FILE: CHANGELOG.md
================================================
# 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]
## [0.1.0-alpha] - 2026-04-28
> 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. This is an alpha; the wire format between agent and grabber and the new `.remap` / `.taphold` / `.device` directives are still subject to change. Run for a while before relying on it for everything.
### Added
- **`.remap` / `.taphold` / `.device` directives** for QMK-style keyboard remapping. Two paths depending on what the rule needs:
- **Colon-form `.remap key : key`** — drives `hidutil`'s `UserKeyMapping` table directly. Works for non-conflicting remaps (e.g. swap `caps_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 above `hidutil` silently drops `caps_lock → modifier` mappings on Tahoe), modifier-as-hold rules, and any rule that needs to distinguish tap vs hold by timing.
- **`.device "alias" vendor=0xVVVV product=0xPPPP`** scopes 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.
- **`skhd-grabber`** — system daemon (LaunchDaemon, root) for the HID-seize path. Installed via `sudo 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 via `std.crypto`), runs `installer -pkg`, self-elevates via `sudo` if 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 as `zig build install-dext`.
- **`skhd --install-service` auto-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 grabber
```
- **`HID daemon` line in `skhd --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 but `launchctl print system/<label>` returns "could not find service" (kickstart can't recover; needs a `.pkg` reinstall).
- **`Input Monitoring` line in `skhd --status`** — calls `IOHIDCheckAccess(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 the `tccutil reset ListenEvent com.jackielii.skhd` workaround.
- **Karabiner-Elements conflict warning** — `--status` and `--install-grabber` flag when `karabiner_grabber` is running, since both daemons compete for HID seize.
- **Bundle-shared TCC for skhd-grabber** — the grabber runs from inside `skhd.app/Contents/MacOS/skhd-grabber` instead 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-dext` writes the LaunchDaemon plist for `org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon` after the `.pkg` installer 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-service` calls `IOHIDRequestAccess(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-service` post-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). `--status` and `--install-grabber` compare 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 to `zig build install-dext`, newer major proceeds with an "untested" advisory. Bump procedure documented inline above the constants.
- **`scripts/install-dext.sh` removed** in favor of the in-binary `--install-dext` subcommand. 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.sh` and `scripts/uninstall-grabber.sh` removed** — 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-dext` put it there.
- **`make-app.sh` bundles `skhd-grabber` into `skhd.app/Contents/MacOS/`** — release tarballs and `zig build install-local` ship both binaries inside the bundle. `codesign.sh` signs both inner Mach-Os with the bundle ID so the bundle's seal stays valid.
### Fixed
- **`Hotkeys functional` false negative in `--status`** — the log-tail scan now anchors on the current daemon's `(PID N)` start marker so stale `ACCESSIBILITY PERMISSIONS REQUIRED` lines from prior crashed instances no longer poison the read. Returns `Unknown` instead of `Denied` when the marker isn't in the read window yet.
### Internal
- **`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 existing `UserKeyMapping` so colon-form `.remap` doesn't clobber whatever System Settings → Modifier Keys (or other tooling) already set. Restores on shutdown.
- **Test surface expanded** to cover `RuleSet` parsing, the IPC framing, `KbState` / `TapHold` state 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 explicit `os_version_min`, Zig stamps the Mach-O's `LC_BUILD_VERSION minos` with the build host's OS version, and the `macos-latest` CI runner is now Tahoe 26 — so the binary's minimum-OS field jumped past Sequoia. `build.zig` now pins `os_version_min` to 13.0 (matching `Info.plist`'s `LSMinimumSystemVersion` and the SMAppService floor); setting `os_version_min` flips Zig out of native-SDK mode, so the build also probes `xcrun` once and threads the SDK's framework / include / lib paths into every artifact.
- **`PATH` inheritance under SMAppService now works for users without `SHELL` set, and fails loudly when it doesn't.** v0.0.22's `$SHELL -ilc` approach silently returned the launchd minimal `PATH` in several real cases: `SHELL` was unset under launchd, or `-i` triggered shell-specific weirdness with no controlling tty (zsh `compinit` warnings, fish prompt probes, rc files assuming a tty). `detectLoginShell` now prefers `$SHELL` and falls back to `getpwuid(getuid()).pw_shell` — the same Open Directory source `login(1)` uses, so it resolves even when launchd doesn't set `SHELL`. `capturePath` uses shell-specific argv: fish runs `-c 'string join : $PATH'` (fish's `PATH` is a list and `config.fish`/`conf.d` are always sourced), bash/zsh run `-lc 'printenv PATH'` (`-l` sources `~/.zprofile` / `~/.bash_profile` where Homebrew's `shellenv` lives, dropping `-i` avoids the interactive-init noise). Every failure path now logs at `warn` so the breakdown appears in `~/Library/Logs/skhd.log` instead of being invisible, and the final inherited PATH is logged so users can see what skhd actually resolved.
### Added
- **`.path` directive for explicit PATH additions.** Escape hatch for the cases where shell-inherited `PATH` isn't enough — mostly version-manager shims (mise/asdf/nvm) which only land in `PATH` via shell hooks that `-lc` doesn't always trigger, and any directory the user wants resolved before system tools of the same name. Single-entry and list forms (matching `.shell` / `.blacklist` style):
```
.path "/opt/homebrew/bin"
.path [
"$HOME/.local/share/mise/shims"
"~/bin"
]
```
`~` and `$HOME` expand at parse time; no arbitrary `$VAR` because parse-time env can differ from command-exec-time env. Entries are prepended to `PATH` after the shell-inherited `PATH` is 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-13` Intel 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, and `codesign` on arm64 signs x86_64 Mach-Os fine.
### Internal
- **Portable `BOOL` marshalling in `sm_app_service.zig` for x86_64.** Apple's `objc.h` gates `BOOL` on `__OBJC_BOOL_IS_BOOL`, which Clang only sets for arm64-darwin — so `c.BOOL` translates to Zig `bool` on arm64 but to `i8` on x86_64, and the existing `if (!ok)` only typechecked on arm64. The `objc_msgSend` return is now declared `u8` (both archs return `BOOL` in the low byte regardless of C-level typedef) with explicit `!= 0` comparisons. Unblocks the cross-compile.
## [0.0.23] - 2026-04-26
### Fixed
- **Event tap now actually detaches on Accessibility revoke.** v0.0.22 relied on `kCGEventTapDisabledByUserInput` firing 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 signal `keyHandler` could react to. The one-shot recovery timer is replaced with an always-on 1 s `CFRunLoopTimer` that reconciles the tap with `AXIsProcessTrusted` in both directions: detach proactively on revoke, recreate on re-grant. `AXIsProcessTrusted` is cached at the OS level and runs in ~µs, so the poll has no measurable overhead and the keydown / `NX_SYSDEFINED` hot path is untouched.
- **Daemon log lines actually reach `~/Library/Logs/skhd.log`.** SMAppService's bundled `LaunchAgent.plist` sets no `StandardErrorPath`, so stderr went to `/dev/null` and every `log.err` / `log.info` from the daemon was silently dropped. Stderr is now redirected to the log file when the process is launchd-managed (detected by `XPC_SERVICE_NAME != "0"` — the variable is set to the placeholder `"0"` for normal user-shell processes, so a plain null-check matched everything). Foreground `-V` runs and `zig build`-spawned subprocesses keep stderr at the terminal/pipe.
- **`std_options.log_level` floored at `.warn`** so 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 `AXIsProcessTrusted` instead of `AXIsProcessTrustedWithOptions(prompt: true)`.** The TCC dialog popped on every `zig build run` / `zig build alloc` iteration was noise, and on Tahoe TCC mis-displays the path when self-signed dev/prod bundles share a `com.jackielii.skhd*` prefix. The daemon install path still prompts on first install.
### Internal
- **`zig build alloc` is 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 `kCGEventTapDisabledByUserInput` and the in-place `CGEventTapEnable` retry 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 s `CFRunLoopTimer` watches for `AXIsProcessTrusted` to flip back and recreates the tap on re-grant. `EventTap.deinit` also cleans up when the tap is system-disabled, not just when `enabled()`.
- **`--status` no longer false-negatives in the first 30 s after daemon start.** `getEventTapHealth` scanned the daemon log for the `ACCESSIBILITY PERMISSIONS REQUIRED` marker, 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 reports `unknown` in that window instead.
### Changed
- **Daemon sources `PATH` from `$SHELL -ilc` at startup.** Hotkeys that exec `/opt/homebrew/bin/yabai`, `/opt/homebrew/bin/aerospace`, etc. previously failed under launchd's minimal `PATH` (`/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-local`** stages the local build into `/Applications/skhd.app` (the slot a brew install would occupy), re-signs with `skhd-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) as `Type: legacy agent` with `Disposition: [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-service` now uses `SMAppService`** instead of writing to `~/Library/LaunchAgents/`. The bundled plist lives inside `skhd.app/Contents/Library/LaunchAgents/com.jackielii.skhd.plist` and registration goes through `SMAppService.agent(plistName:).register()`. BTM creates a proper managed entry (`Type: agent`, `Disposition: [enabled, allowed, notified]`) that auto-loads cleanly at every login.
- **`--uninstall-service`** now unregisters via SMAppService. Both install and uninstall also clean up any pre-0.0.21 hand-installed plist at `~/Library/LaunchAgents/com.jackielii.skhd.plist` so the legacy and new managed entries don't race.
- **`--status`** reads SMAppService registration state directly. Reports `Registration status: enabled` / `requires approval` / `not registered` so the user knows what BTM thinks.
### Migration
On upgrade from 0.0.20 or earlier, run `skhd --install-service` once (preferably from `/Applications/skhd.app/Contents/MacOS/skhd` so SMAppService treats `/Applications/skhd.app` as the registering bundle). 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](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 run` now produces a signed dev `.app` bundle** at `zig-out/skhd-dev.app`, signed with a separate `skhd-dev-cert` and bundle ID `com.jackielii.skhd.dev`. On macOS Tahoe, an adhoc-signed bare binary cannot be granted Accessibility, so `zig build run` previously 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](docs/CODE_SIGNING.md#local-debug-workflow-zig-build-run).
- **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.
- **`AccessibilityPermissionDenied` error message** prefers the `.app` bundle that actually contains the running binary over `/Applications/skhd.app`, so the displayed path matches what a grant would apply to.
- **`scripts/codesign.sh`** reads `SKHD_BUNDLE_ID` env var (defaults to `com.jackielii.skhd`).
- **`scripts/make-app.sh`** accepts 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
- **`--status` reported `Hotkeys functional: No` while 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) suppresses `log.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 via `sysctl(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.
- **`AccessibilityPermissionDenied` error message wording.** Previously said macOS Tahoe's picker "only accepts `.app` bundles". 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.md` if the tag annotation is missing. v0.0.18 initially shipped with a release body containing a random commit message because `actions/checkout@v4`'s `fetch-tags: true` doesn'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](docs/UPGRADING.md) for the one-time setup users on 0.0.17 or earlier need to perform after upgrading.
### Added
- **`.app` bundle distribution** — skhd now ships as `skhd.app` instead 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 and `brew upgrade`.
- **`zig build app` / `zig build sign-app`** — build steps for producing and signing the `.app` bundle locally.
- **Daemon health in `--status`** — now reports `Daemon running` (from `launchctl list`) and `Hotkeys functional` (from log file tail), instead of the misleading `AXIsProcessTrusted` check on the CLI process.
- **[docs/UPGRADING.md](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` / `bootout`** instead of legacy `load -w` / `unload -w`. `--stop-service` no longer leaves the agent in a persistently-disabled state across reboots.
- **Plist `ProgramArguments`** points at the stable `/opt/homebrew/opt/skhd-zig/skhd.app/Contents/MacOS/skhd` symlink instead of a version-pinned Cellar path.
- **Plist `ThrottleInterval`** lowered from 30 s to 10 s for faster recovery from boot-time failures.
- **`AccessibilityPermissionDenied` error message** now points at the `.app` bundle 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.yml` and `Formula/skhd-zig.rb` (kept commented for easy restoration).
- **Homebrew `brew services` integration.** Replaced by skhd's own `--install-service`, which produces a properly Tahoe-tuned launchd plist (retry loop, log path, ThrottleInterval, bundle-aware ProgramArguments). Migrate with `brew 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 `CGEventTapCreate` race** — added a 10-attempt retry loop with 500 ms backoff. The daemon used to exit and wait the full `ThrottleInterval` when WindowServer/TCC weren't ready immediately at login.
- **`scripts/codesign.sh` cert auto-creation** — fixed empty-password p12 import rejection on macOS Tahoe + OpenSSL 3.6, and the missing `extendedKeyUsage = codeSigning` that hid the cert from `find-identity -p codesigning`.
- **Homebrew formula auto-bump regex** — replaced the buggy `[0-9.(-preview)]\+` character class with `v[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 | play` forwards Cmd+P to the play/pause media key
## [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 `CFStringCreateWithCharacters` which can return NULL for some keycodes
- skhd now gracefully skips problematic keycodes and continues initialization
## [0.0.15] - 2025-10-17
### Added
- **Code signing support for macOS 15+** - Accessibility permissions now persist across builds (#15)
- Added `Info.plist` with bundle identifier for stable TCC identity
- Added `zig build sign` command for local development signing
- Release binaries are now automatically signed
- See `docs/CODE_SIGNING.md` for setup instructions
### 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 `-o` mode
- Note: F21-F24 cannot be supported as they are not defined in macOS's HIToolbox framework
- **Homebrew release artifact URL** - Fixed regex to handle preview tags in release URLs
- Thanks to @tdjordan for the contribution (#17)
### Changed
- Removed unused `Info.plist` file 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 : command` executes 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
- Added `activation` variant to `ProcessCommand` enum for proper mode activation tracking
### Changed
- Refactored command parsing to eliminate code duplication with helper function `parse_command`
- Removed redundant `flags.activate` field from `ModifierFlag`
- 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
### 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 `--message` flag to release script for custom tag messages
### Changed
- Refactored hotkey processing to use `HotkeyResult` enum instead of boolean return
- Clearer distinction between consumed, passthrough, and not_found states
- Eliminated code duplication between `handleKeyDown` and `handleSystemKey`
### Internal
- Added comprehensive tests for capture mode behavior with passthrough and unbound actions
- Extracted common hotkey result handling into `handleHotkeyResult` helper 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_allocator` or `c_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
- `--status` command 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 `.define` directive 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
- 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/--profile` flag 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 `processHotkey` function (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-service` now automatically installs/updates the service plist to ensure it uses the current binary
- `--status` command 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 `.load` directive
- Custom shell support with `.shell` directive
- 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 `.define` directive
- 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
### 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
[Unreleased]: https://github.com/jackielii/skhd.zig/compare/v0.0.24...HEAD
[0.0.24]: https://github.com/jackielii/skhd.zig/compare/v0.0.23...v0.0.24
[0.0.23]: https://github.com/jackielii/skhd.zig/compare/v0.0.22...v0.0.23
[0.0.22]: https://github.com/jackielii/skhd.zig/compare/v0.0.21...v0.0.22
[0.0.21]: https://github.com/jackielii/skhd.zig/compare/v0.0.20...v0.0.21
[0.0.20]: https://github.com/jackielii/skhd.zig/compare/v0.0.19...v0.0.20
[0.0.19]: https://github.com/jackielii/skhd.zig/compare/v0.0.18...v0.0.19
[0.0.18]: https://github.com/jackielii/skhd.zig/compare/v0.0.17...v0.0.18
[0.0.17]: https://github.com/jackielii/skhd.zig/compare/v0.0.16...v0.0.17
[0.0.16]: https://github.com/jackielii/skhd.zig/compare/v0.0.15...v0.0.16
[0.0.15]: https://github.com/jackielii/skhd.zig/compare/v0.0.13...v0.0.15
[0.0.13]: https://github.com/jackielii/skhd.zig/compare/v0.0.12...v0.0.13
[0.0.12]: https://github.com/jackielii/skhd.zig/compare/v0.0.11...v0.0.12
[0.0.11]: https://github.com/jackielii/skhd.zig/compare/v0.0.10...v0.0.11
[0.0.10]: https://github.com/jackielii/skhd.zig/compare/v0.0.9...v0.0.10
[0.0.9]: https://github.com/jackielii/skhd.zig/compare/v0.0.8...v0.0.9
[0.0.8]: https://github.com/jackielii/skhd.zig/compare/v0.0.7...v0.0.8
[0.0.7]: https://github.com/jackielii/skhd.zig/compare/v0.0.6...v0.0.7
[0.0.6]: https://github.com/jackielii/skhd.zig/compare/v0.0.5...v0.0.6
[0.0.5]: https://github.com/jackielii/skhd.zig/compare/v0.0.4...v0.0.5
[0.0.4]: https://github.com/jackielii/skhd.zig/compare/v0.0.3...v0.0.4
[0.0.3]: https://github.com/jackielii/skhd.zig/compare/v0.0.2...v0.0.3
[0.0.2]: https://github.com/jackielii/skhd.zig/compare/v0.0.1...v0.0.2
[0.0.1]: https://github.com/jackielii/skhd.zig/releases/tag/v0.0.1
================================================
FILE: CLAUDE.md
================================================
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a Zig port of skhd (Simple Hotkey Daemon for macOS). The project reimplements the original C-based skhd in Zig, maintaining compatibility with the same config file format and hotkey DSL.
## Build Commands
```bash
# Build the project (creates executable in zig-out/bin/)
zig build
# Run skhd locally (signed dev .app — bare binary can't be granted
# Accessibility / Input Monitoring on Tahoe)
zig build run -- -V 2>&1 | tee /tmp/skhd.log
# Run tests (use this — single-file `zig test` no longer works since
# module tests now need build_options / grabber_protocol / plist imports)
zig build test
ZIG_PROGRESS=0 zig build test # if it hangs
# Run benchmarks (ReleaseFast)
zig build bench
# Run the grabber daemon from this checkout. Requires sudo. If
# `skhd --install-grabber` was run, stop the installed LaunchDaemon
# first or it will hold the IPC socket:
# sudo launchctl bootout system/com.jackielii.skhd.grabber
zig build run-grabber 2>&1 | tee /tmp/skhd-grabber.log
```
## Architecture
The codebase follows a modular architecture with clear separation of concerns:
### Core Components
1. **Parser.zig** - Parses skhd configuration files using the DSL syntax
- Uses Tokenizer for lexical analysis
- Builds hotkey mappings from config syntax
- Handles mode declarations and options
2. **Tokenizer.zig** - Lexical analyzer for the configuration DSL
- Handles UTF-8 text processing
- Recognizes tokens like modifiers, keys, commands, etc.
3. **EventTap.zig** - macOS event tap interface for capturing keyboard events
- Wraps Core Graphics event tap APIs
- Manages event capture and filtering
4. **Hotkey.zig** - Hotkey data structure and management
- Stores modifier flags and key codes
- Maps process names to commands
- Supports wildcard commands and key forwarding
5. **Mode.zig** - Modal hotkey system implementation
- Each mode has its own hotkey map
- Supports mode-specific commands and capture behavior
6. **Mappings.zig** - Central registry for all hotkeys and modes
- Manages global hotkey map and mode map
- Handles application blacklisting
- Stores shell configuration for command execution
7. **Keycodes.zig** - Key code and modifier flag definitions
- Maps between string representations and numeric codes
- Handles Carbon/Cocoa key constants
8. **CarbonEvent.zig** - Application switching detection
- Monitors app switch events for process-specific hotkeys
- Caches process names for performance optimization
- Reduces lookup overhead from 25μs to 21ns
9. **exec.zig** - Command execution module
- Implements double-fork technique for proper daemon process creation
- Ensures child processes are fully detached from parent
- Prevents zombie processes and terminal output interference
10. **Tracer.zig** - Performance profiling infrastructure
- Provides execution tracing with `-P/--profile` flag
- Helps identify performance bottlenecks
- Available in Debug and ReleaseSafe builds only
### Key Implementation Notes
- The project links against macOS frameworks: Cocoa, Carbon, and CoreServices
- Uses packed structs and unmanaged slices for memory efficiency
- Event handling follows the original skhd's approach but with Zig's safety features
- Config parsing maintains compatibility with the original DSL
- **Performance**: The event loop is allocation-free in release builds
- Zero allocations during runtime after initialization
- CPU usage reduced from ~1.2% to ~0.5% (matching original skhd)
- Process name lookups cached for 25μs → 21ns improvement
## Configuration DSL
The project supports the same configuration syntax as the original skhd, plus additional features:
### Core Syntax
- Hotkey definitions: `mod - key : command`
- Modal system: `:: mode_name` or `:: mode_name @` (capture mode)
- Process-specific bindings: `key [ "app_name" : command ]`
- Key forwarding/remapping: `ctrl - 1 | cmd - 1`
- Passthrough mode: `cmd - p -> : command` (execute command but still send keypress)
- Unbound actions: `cmd - a ~` (key is not captured and passes through to the application)
- String escape sequences: `\"` for quotes, `\\` for backslash, `\n` for newline, `\t` for tab
### Enhanced Features (New in skhd.zig!)
- **Process groups**: `.define group_name ["app1", "app2", "app3"]`
- Use with `@group_name` in process lists
- **Command definitions**: `.define name : command` with placeholders `{{1}}`, `{{2}}`, etc.
- Reference with `@name("arg1", "arg2")`
- **Mode activation with command**: `key ; mode : command`
- Executes command when switching to mode
- Works in global hotkeys, process lists, and process groups
## Related Codebase
The original C implementation is available at `/Users/jackieli/personal/skhd/` for reference. Key differences:
- Original uses C with manual memory management
- This port uses Zig with explicit allocators and safer memory handling
- Both share the same configuration format and core functionality
My active configuration file is located at `/Users/jackieli/.config/skhd/skhdrc`. Make sure to support all features present in this file.
## Test Infrastructure
The project follows a localized testing strategy:
- **Unit tests**: Write tests for functions in the same file where they are defined (e.g., Parser.zig, Tokenizer.zig, Hotkey.zig)
- **Integration tests**: Use `src/tests.zig` only for tests that span multiple modules or test the interaction between different components
- Use `zig build test` to run all tests (both unit and integration)
- Test configuration files should be placed in the `testdata/` directory
- Follow existing test patterns for consistency
**Important**: Always run `zig build test` after completing any implementation to ensure all tests pass and no regressions are introduced.
## Debugging and Profiling
### Debug vs Release Builds
**Important**: The logging and profiling behavior differs between build modes:
- **ReleaseFast builds** (installed via Homebrew or built with `-Doptimize=ReleaseFast`):
- Only show errors and warnings, even with `-V`/`--verbose` flag
- Profiling (`-P`/`--profile`) is disabled - all tracing code is compiled out for maximum performance
- **ReleaseSafe builds** (built with `-Doptimize=ReleaseSafe`):
- Show errors, warnings, and info messages with `-V`/`--verbose` flag
- Profiling (`-P`/`--profile`) is available for production debugging
- **Debug builds** (default `zig build`):
- Show all log levels including debug messages with `-V`/`--verbose` flag
- Profiling (`-P`/`--profile`) is available with full trace details
### Debugging Commands
```bash
# Verbose logging for troubleshooting config issues
zig build run -- -V
# Test key combinations and hex codes (observe mode)
zig build run -- -o
# Profile event handling (Debug/ReleaseSafe only)
zig build && ./zig-out/bin/skhd -P
# Debug memory allocations with real-time tracking
zig build alloc -- -V
```
### Code Signing (macOS 15+)
Code signing is required for accessibility permissions to persist on macOS 15+. See [docs/CODE_SIGNING.md](docs/CODE_SIGNING.md) for detailed setup instructions.
```bash
zig build sign # Optional, skipped in CI
```
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2017 Åsmund Vikane
Copyright (c) 2025 Jackie Li
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# SKHD in Zig
Simple Hotkey Daemon for macOS, ported from [skhd](https://github.com/koekeishiya/skhd) to Zig.
This implementation is **fully compatible with the original skhd configuration format** - your existing `.skhdrc` files will work without modification. Additionally, it includes new features like process groups and command definitions (`.define`) for cleaner configs, key forwarding/remapping, and improved error reporting.
📋 [View Changelog](CHANGELOG.md)
## v0.1.0-alpha — QMK-style keyboard remapping
skhd.zig now ships a system-level **grabber daemon** that enables remapping the user-session event tap can't reach. New directives:
- **`.remap caps_lock [device <alias>] : escape`** — instant 1:1 swap, applied via `hidutil` (no daemon).
- **`.remap key [device <alias>] { tap: …, hold: …, … }`** — tap vs. hold on the same key (e.g. `caps_lock` tapped = escape, held = control). Routed through `skhd-grabber` (root LaunchDaemon) which seizes the keyboard at the IOKit/HID level via Karabiner DriverKit. Required for caps-lock-class rules and modifier-as-hold rules that `hidutil` silently drops.
- **Layer holds** — `hold` can target a skhd mode instead of a key, so holding the source key activates the layer for the duration of the hold (e.g. hold `space` to enter `fn_layer`, where `fn_layer < 1 | f1` rebinds the number row to F-keys).
- **`.device <alias> { vendor: 0x…, product: 0x… }`** — scope rules to a specific keyboard, so one config does the right thing on each machine.
**Install the alpha** (Homebrew formula stays on 0.0.24 until v0.1.0 stable; eager testers download from GitHub releases):
```bash
gh release download v0.1.0-alpha --repo jackielii/skhd.zig --pattern '*-arm64-macos.tar.gz'
tar -xzf skhd-arm64-macos.tar.gz -C /tmp
sudo mv /tmp/skhd.app /Applications/
/Applications/skhd.app/Contents/MacOS/skhd --install-service
```
`--install-service` registers the agent, auto-installs the Karabiner DriverKit pkg if your config has tap-hold rules, prompts for the grabber install via sudo, and pops Accessibility + Input Monitoring dialogs in sequence — one click each.
**Try it out** — drop this into `~/.config/skhd/skhdrc` (replace the vendor/product IDs with your own — find them via `skhd --grabber-status` once installed, or System Information → USB):
```bash
# 1. Declare the keyboard you want to remap.
.device builtin { vendor: 0x05AC, product: 0x0342 }
# 2. caps_lock acts like ctrl when held, escape when tapped.
.remap caps_lock [device builtin] {
tap : escape
hold : lctrl
timeout : 120ms
permissive_hold : on
}
# 3. Hold space to enter a "function layer", release to exit.
:: fn_layer @
.remap space [device builtin] {
tap : space
hold : fn_layer
timeout : 200ms
retro_tap : on
}
# 4. While the layer is held, number row → F-row.
fn_layer < 1 | f1
fn_layer < 2 | f2
fn_layer < 3 | f3
# … etc
```
Save, then `skhd --restart-service`. Tap `caps_lock` → escape. Hold `caps_lock` + `c` → ctrl-c. Hold `space` then press `1` → F1.
**Verify** with `skhd --status` (single command shows the agent, grabber, dext, and TCC state) or `skhd --grabber-status` (drills into the grabber-side dependency chain). **Roll back** with `skhd --uninstall-service` (it prints follow-up `sudo skhd --uninstall-grabber` instructions if anything's still on disk).
**Larger real-world example** — my own `builtin.skhdrc`, which makes a MacBook built-in keyboard behave like a [Keebio Convolution](https://keeb.io/products/convolution-65xt-keyboard) running custom QMK firmware (caps_lock as ctrl/escape, space as fn_layer, fn_layer < hjkl as arrows, number row → F-row under layer, etc.):
- skhd config: <https://gist.github.com/jackielii/9d24095af57ec35df0d46d38bbbe0449>
- QMK source-of-truth keymap it mirrors: <https://github.com/jackielii/qmk_firmware/blob/jackie/keyboards/keebio/convolution/keymaps/jackie/keymap.c>
See [skhd-grabber](#skhd-grabber-caps_lock-class-tap-hold) below for the full architecture, [SYNTAX.md](SYNTAX.md) for the new directive grammar, and the [v0.1.0-alpha CHANGELOG entry](CHANGELOG.md#010-alpha---2026-04-28) for everything that changed.
## Installation
### Homebrew
The easiest way to install skhd.zig:
```bash
brew install jackielii/tap/skhd-zig
```
> Upgrading from 0.0.17 or earlier? See [docs/UPGRADING.md](docs/UPGRADING.md). The 0.0.18 release switches to an `.app` bundle to keep accessibility permissions working on macOS Tahoe; one-time re-grant is required.
### Pre-built Binaries
Apple Silicon only (Intel builds paused as of v0.0.19; build from source for Intel):
- `skhd-arm64-macos.tar.gz` (contains `skhd.app`)
Extract and install:
```bash
tar -xzf skhd-arm64-macos.tar.gz
mv skhd.app /Applications/
# Optional: expose the CLI on your PATH
sudo ln -sfn /Applications/skhd.app/Contents/MacOS/skhd /usr/local/bin/skhd
```
Then grant accessibility (see [Granting Accessibility](#granting-accessibility) below).
### Development Builds from GitHub Actions
If you want a main-branch build at a specific optimization level (Debug, ReleaseSafe, ReleaseFast, ReleaseSmall), you can download one directly from GitHub Actions. These are signed `skhd.app` bundles with `skhd-grabber` included — same layout as the release tarballs, just built from `main` instead of a tag. Apple Silicon only.
1. Go to the [CI workflow](https://github.com/jackielii/skhd.zig/actions/workflows/ci.yml?query=branch%3Amain) in Actions tab. Filter by branch `main`.
2. Click on the latest successful run
3. Scroll down to the "Artifacts" section
4. Download the artifact for your desired optimization level:
- `skhd-Debug` - Debug build with full debugging symbols
- `skhd-ReleaseSafe` - Release build with runtime safety checks
- `skhd-ReleaseFast` - Optimized for performance (recommended for daily use)
- `skhd-ReleaseSmall` - Optimized for binary size
GitHub wraps each artifact in a `.zip`. Inside is `skhd-arm64-macos.tar.gz`; extract and install the same way as a release tarball:
```bash
unzip skhd-ReleaseFast.zip
tar -xzf skhd-arm64-macos.tar.gz
mv skhd.app /Applications/
sudo ln -sfn /Applications/skhd.app/Contents/MacOS/skhd /usr/local/bin/skhd
```
Then grant accessibility (see [Granting Accessibility](#granting-accessibility) below).
### Build from Source
```bash
# Clone the repository
git clone https://github.com/jackielii/skhd.zig
cd skhd.zig
# Build the .app bundle and code-sign it
# (required for Accessibility to persist on macOS Tahoe / Sequoia)
zig build sign-app -Doptimize=ReleaseFast
# Install: symlink the bundle into /Applications, expose the CLI
ln -sfn "$(pwd)/zig-out/skhd.app" /Applications/skhd.app
sudo ln -sfn /Applications/skhd.app/Contents/MacOS/skhd /usr/local/bin/skhd
```
For quick dev iteration without the bundle wrapper, `zig build` still produces a bare binary at `zig-out/bin/skhd`. The `.app` is only needed for the System Settings → Accessibility picker.
The first run of `zig build sign-app` creates a self-signed `skhd-cert` certificate in your login keychain. See [docs/CODE_SIGNING.md](docs/CODE_SIGNING.md) for details and troubleshooting.
### Granting Accessibility
skhd captures keyboard events via macOS Core Graphics, which requires Accessibility permission:
1. Open **System Settings → Privacy & Security → Accessibility**
2. Click **`+`**, navigate to `/Applications/skhd.app`, add it
3. Toggle the entry on
4. Run `skhd --restart-service` (or `skhd --start-service` if not yet running)
You only need to do this once. The bundle's stable identifier (`com.jackielii.skhd`) means TCC entries persist across rebuilds and `brew upgrade`.
If your config uses `.remap` or `.taphold` rules, macOS will additionally
prompt you for **Input Monitoring** the first time the grabber starts.
The grabber binary lives inside the same `skhd.app` bundle, so the same
bundle identifier covers it — one click in **System Settings → Privacy
& Security → Input Monitoring** approves both processes.
## Running as Service
After installation, run skhd as a service for automatic startup:
```bash
# Install and start the service
skhd --install-service
skhd --start-service
# Check if skhd is running properly
skhd --status
# Restart service (useful for restarting after giving accessibility permissions)
skhd --restart-service
# Stop service
skhd --stop-service
# Uninstall service
skhd --uninstall-service
```
The service will:
- Start automatically on login
- Write logs to `~/Library/Logs/skhd.log`
- Use your config from `~/.config/skhd/skhdrc` or `~/.skhdrc`
- Automatically reload on config changes
## Features
### Core Functionality
- **Event capturing**: Uses macOS Core Graphics Event Tap for system-wide keyboard event interception
- **Hotkey mapping**: Maps key combinations to shell commands with full modifier support
- **Process-specific bindings**: Different commands for different applications
- **Key forwarding/remapping**: Remap keys to other key combinations
- **Modal system**: Multi-level modal hotkey system with capture modes
- **Configuration file**: Compatible with original skhd configuration format
- **Hot reloading**: Automatic config reload on file changes
- **Device-aware HID remapping** (v0.1.0-alpha): per-keyboard 1:1 remaps via `hidutil` and tap-vs-hold rules via the optional `skhd-grabber` daemon. See [Device-aware remapping](#device-aware-remapping-device--remap).
### Additional Features (New in skhd.zig!)
- **Aliases**: Name a modifier combo or a single key (`.alias $hyper cmd + alt + ctrl + shift`, `.alias $grave 0x32`)
- **Mouse buttons**: Bind on `mouse1`–`mouse5` (e.g. `cmd - mouse1 : ...`); use `->` for passthrough so the click still reaches the app
- **Process groups**: Define named groups of applications for cleaner configs
- **Command definitions**: Define reusable commands with placeholders to reduce repetition
- **Key Forwarding**: Forward / remap key binding to another key binding
- **Mode activation with command**: Execute a command when switching modes (e.g., `cmd - w ; window : echo "Window mode"`)
- **`.device` + `.remap` (v0.1.0-alpha)**: per-device HID-layer remapping, both colon (1:1) and block (tap/hold) forms.
- **Layer holds (v0.1.0-alpha)**: a `.remap` `hold:` target can be a skhd mode, so holding a key activates a layer for the duration of the hold.
### Command-Line Interface
- `--version` / `-v` - Display version information
- `--help` - Show usage information
- `-c` / `--config` - Specify config file location
- `-o` / `--observe` - Observe mode (echo keycodes and modifiers)
- `-V` / `--verbose` - Debug output with detailed logging
- `-k` / `--key` - Synthesize keypress for testing
- `-t` / `--text` - Synthesize text input
- `-r` / `--reload` - Signal reload to running instance
- `-h` / `--no-hotload` - Disable hotloading
- `-P` / `--profile` - Profile event handling (Debug and ReleaseSafe builds only)
### Service Management
- `--install-service` - Install launchd service (also auto-installs the DriverKit dext + grabber if your config uses `.remap`/`.taphold`)
- `--uninstall-service` - Remove launchd service
- `--start-service` - Start as service
- `--restart-service` - Restart service
- `--stop-service` - Stop service
- `--status` - Combined health: agent PID, event tap, grabber, dext, TCC
- PID file management (`/tmp/skhd_$USER.pid`)
- Service logging (`~/Library/Logs/skhd.log`)
### System Grabber (caps_lock-class tap-hold, opt-in)
- `--install-grabber` - Install `skhd-grabber` LaunchDaemon (sudo)
- `--uninstall-grabber` - Remove `skhd-grabber` LaunchDaemon (sudo)
- `--install-dext` - Install the pinned Karabiner DriverKit VirtualHIDDevice (and its launchd plist)
- `--grabber-status` - Drill into the grabber dependency chain (socket, dext version, IOKit match)
- Grabber logging (`/var/log/skhd-grabber.log`)
### Advanced Features
- **Blacklisting**: Exclude applications from hotkey processing
- **Shell customization**: Use custom shell for command execution
- **Left/right modifier distinction**: Support for lcmd, rcmd, lalt, ralt, etc.
- **Special key support**: Function keys, media keys, arrow keys
- **Passthrough mode**: Execute command but still send keypress to application
- **Config includes**: Load additional config files with `.load` directive
- **Comprehensive error reporting**: Detailed error messages with line numbers
- **Per-device HID remapping**: colon-form `.remap` for instant 1:1 swaps, block-form for tap-vs-hold semantics
- **Layer holds**: `hold:` can target a skhd mode instead of a key, so holding the source key activates a layer
- **DriverKit injection**: caps_lock-class keys (and modifier-as-hold rules) routed through `skhd-grabber` + Karabiner DriverKit, sidestepping limits of the user-session event tap
### Build Commands
```bash
# Build the project (creates executable in zig-out/bin/)
zig build
# Build in release mode with optimizations
zig build -Doptimize=ReleaseFast
# Run the application
zig build run
# Run with arguments
zig build run -- -V -c ~/.config/skhd/skhdrc
# Run tests
zig build test
```
## Configuration & Usage
### Default Configuration Locations
skhd.zig looks for configuration files in the following order:
1. Path specified with `-c` flag
2. `~/.config/skhd/skhdrc`
3. `~/.skhdrc`
The configuration syntax is fully compatible with the original skhd. See [SYNTAX.md](SYNTAX.md) for the complete syntax reference and grammar.
### Configuration Directives
```bash
# Use custom shell (skips interactive shell overhead)
.shell "/bin/dash"
# Blacklist applications (skip hotkey processing)
.blacklist [
"dota2"
"Microsoft Remote Desktop"
"VMware Fusion"
]
# Load additional config files
.load "~/.config/skhd/extra.skhdrc"
# Define aliases (New in skhd.zig!)
.alias $hyper cmd + alt + ctrl + shift # modifier alias
.alias $super cmd + alt
.alias $grave 0x32 # key alias (UK keyboard backtick)
# Define process groups for reuse (New in skhd.zig!)
.define terminal_apps ["kitty", "wezterm", "terminal"]
.define native_apps ["kitty", "wezterm", "chrome", "whatsapp"]
.define browser_apps ["chrome", "safari", "firefox", "edge"]
# Define reusable commands with placeholders (New in skhd.zig!)
.define yabai_focus : yabai -m window --focus {{1}} || yabai -m display --focus {{1}}
.define yabai_swap : yabai -m window --swap {{1}} || (yabai -m window --display {{1}} && yabai -m display --focus {{1}})
.define toggle_app : open -a "{{1}}" || osascript -e 'tell app "{{1}}" to quit'
.define resize_window : yabai -m window --resize {{1}}:{{2}}:{{3}}
.define toggle_scratchpad : yabai -m window --toggle {{1}} || open -a "{{2}}"
# Declare a keyboard by VendorID/ProductID (v0.1.0-alpha)
# See "Device-aware remapping" below for full details.
.device builtin { vendor: 0x05AC, product: 0x0342 }
# Per-device HID remap — colon form (1:1 swap, applied via hidutil)
.remap caps_lock [device builtin] : escape
# Per-device tap-hold (routed through skhd-grabber)
.remap caps_lock [device builtin] {
tap : escape
hold : lctrl
}
```
### Basic Hotkey Syntax
```bash
# Basic format: modifier - key : command
cmd - a : echo "Command+A pressed"
# Multiple modifiers
cmd + shift - t : open -a Terminal
# Different modifier combinations
ctrl - h : echo "Control+H"
alt - space : echo "Alt+Space"
shift - f1 : echo "Shift+F1"
```
### Supported Modifiers
```bash
# Basic modifiers
cmd # Command key
ctrl # Control key
alt # Alt/Option key
shift # Shift key
fn # Function key
# Left/right specific modifiers
lcmd, rcmd # Left/right Command
lctrl, rctrl # Left/right Control
lalt, ralt # Left/right Alt
lshift, rshift # Left/right Shift
# Special modifier combinations
hyper # cmd + shift + alt + ctrl
meh # shift + alt + ctrl
```
### Special Keys
```bash
# Navigation keys
cmd - left : echo "Left arrow"
cmd - right : echo "Right arrow"
cmd - up : echo "Up arrow"
cmd - down : echo "Down arrow"
# Special keys
cmd - space : echo "Space"
cmd - return : echo "Return/Enter"
cmd - tab : echo "Tab"
cmd - escape : echo "Escape"
cmd - delete : echo "Delete/Backspace"
cmd - home : echo "Home"
cmd - end : echo "End"
cmd - pageup : echo "Page Up"
cmd - pagedown : echo "Page Down"
# Function keys
cmd - f1 : echo "F1"
cmd - f12 : echo "F12"
# Media keys
sound_up : echo "Volume Up"
sound_down : echo "Volume Down"
mute : echo "Mute"
brightness_up : echo "Brightness Up"
brightness_down : echo "Brightness Down"
```
### Process-Specific Bindings
```bash
# Different commands for different applications
cmd - n [
"terminal" : echo "New terminal window"
"safari" : echo "New safari window"
"finder" : echo "New finder window"
* : echo "New window in other apps"
]
```
### Key Forwarding/Remapping
```bash
# Keyboard layout fixes
0xa | 0x32 # UK keyboard § to `
shift - 0xa | shift - 0x32 # shift - § to ~
# Function key navigation (for laptop keyboards)
fn - j | down
fn - k | up
fn - h | left
fn - l | right
# When you have cmd - number for yabai spaces,
# and you still want the cmd - number to work in applications
ctrl - 1 | cmd - 1
ctrl - 2 | cmd - 2
ctrl - 3 | cmd - 3
```
### Passthrough Mode
```bash
# Execute command but still send keypress to application
cmd - p -> : echo "This runs but Cmd+P still goes to app"
```
### Modal Workflow with Visual Indicators
```bash
# Window management mode with anybar visual indicator
# Install anybar: brew install --cask anybar
# Define window management mode for warp/stack operations
# Use anybar to indicate the mode: https://github.com/tonsky/AnyBar
:: winmode @ : echo -n "red" | nc -4u -w0 localhost 1738
:: default : echo -n "hollow" | nc -4u -w0 localhost 1738
# Enter window mode with meh + m (shift + alt + ctrl + m)
meh - w ; winmode
winmode < escape ; default
winmode < meh - w ; default
# Alternative: Enter window mode AND show notification (New in skhd.zig!)
# This executes the command when switching to the mode
# It allows for different commands to execute and switch to another mode
meh - w ; winmode : osascript -e 'display notification "Window mode active" with title "skhd"'
winmode < escape ; default : osascript -e 'display notification "Normal mode" with title "skhd"'
# Focus operations - basic hjkl for focus
winmode < h : yabai -m window --focus west || yabai -m display --focus west
winmode < j : yabai -m window --focus south || yabai -m display --focus south
winmode < k : yabai -m window --focus north || yabai -m display --focus north
winmode < l : yabai -m window --focus east || yabai -m display --focus east
# Move operations - shift + hjkl for moving
winmode < shift - h : yabai -m window --move rel:-80:0
winmode < shift - j : yabai -m window --move rel:0:80
winmode < shift - k : yabai -m window --move rel:0:-80
winmode < shift - l : yabai -m window --move rel:80:0
# Warp operations - alt + shift + hjkl for warping
winmode < alt + shift - h : yabai -m window --warp west
winmode < alt + shift - j : yabai -m window --warp south
winmode < alt + shift - k : yabai -m window --warp north
winmode < alt + shift - l : yabai -m window --warp east
# Stack operations - ctrl + shift + hjkl for stacking
winmode < ctrl + shift - h : yabai -m window --stack west
winmode < ctrl + shift - j : yabai -m window --stack south
winmode < ctrl + shift - k : yabai -m window --stack north
winmode < ctrl + shift - l : yabai -m window --stack east
# Stack management shortcuts
winmode < s : yabai -m window --insert stack # Toggle stack mode
winmode < u : yabai -m window --toggle float; yabai -m window --toggle float # Unstack window
winmode < n : yabai -m window --focus stack.next # Navigate stack next
winmode < p : yabai -m window --focus stack.prev # Navigate stack prev
# Resize submode
winmode < r ; resize
:: resize @ : echo -n "orange" | nc -4u -w0 localhost 1738
resize < h : yabai -m window --resize left:-20:0
resize < j : yabai -m window --resize bottom:0:20
resize < k : yabai -m window --resize top:0:-20
resize < l : yabai -m window --resize right:20:0
resize < escape ; winmode
```
### Window Management Example
```bash
# Focus windows using command definitions (New in skhd.zig!)
cmd - h : @yabai_focus("west")
cmd - j : @yabai_focus("south")
cmd - k : @yabai_focus("north")
cmd - l : @yabai_focus("east")
# Move/swap windows using command definitions
cmd + shift - h : @yabai_swap("west")
cmd + shift - j : @yabai_swap("south")
cmd + shift - k : @yabai_swap("north")
cmd + shift - l : @yabai_swap("east")
# Resize windows using command definitions
cmd + ctrl - h : @resize_window("left", "-20", "0")
cmd + ctrl - l : @resize_window("right", "20", "0")
# Switch spaces
cmd - 1 : yabai -m space --focus 1
cmd - 2 : yabai -m space --focus 2
```
### Application Launching Example
```bash
# Quick app launching (traditional way)
alt - return : open -a Terminal
alt - b : open -a Safari
# Toggle apps using command definitions (New in skhd.zig!)
alt - f : @toggle_app("Finder")
alt - c : @toggle_app("Visual Studio Code")
# Scratchpad apps with yabai (New in skhd.zig!)
# In yabairc: yabai -m rule --add app="^YouTube Music$" scratchpad=music grid=11:11:1:1:9:9
alt - m : @toggle_scratchpad("music", "YouTube Music")
alt - n : @toggle_scratchpad("notes", "Notes")
```
### Text Editing Enhancements Example
```bash
# Linux-style word navigation and deletion
ctrl - backspace [
@native_apps ~ # Terminal apps handle natively
* | alt - backspace # Other apps: delete word
]
ctrl - left [
@native_apps ~ # Terminal apps handle natively
* | alt - left # Other apps: move word left
]
ctrl - right [
@native_apps ~ # Terminal apps handle natively
* | alt - right # Other apps: move word right
]
# Home/End key behavior (with shift for selection)
home [
@native_apps ~ # Terminal apps handle natively
* | cmd - left # Other apps: line start
]
shift - home [
@native_apps ~ # Terminal apps handle natively
* | cmd + shift - left # Other apps: select to line start
]
# Ctrl+Home/End for document navigation
ctrl - home [
@native_apps ~ # Terminal apps handle natively
* | cmd - up # Other apps: document start
]
ctrl - end [
@native_apps ~ # Terminal apps handle natively
* | cmd - down # Other apps: document end
]
```
## Device-aware remapping (`.device` + `.remap`)
`.remap` rewrites keys at the HID layer per device. Two forms:
**Colon form** — instant 1:1 swap, applied via `hidutil` (no daemon needed):
```
# Declare the device once, by VendorID/ProductID.
.device builtin { vendor: 0x05AC, product: 0x0342 }
# UK ISO MacBook: make § (top-left) act as the ISO grave key, so it types `.
.remap non_us_backslash [device builtin] : grave
```
**Block form** — tap-hold semantics (caps_lock → tap=escape / hold=ctrl,
space → fn_layer, etc.). Goes through `skhd-grabber` (see below) because
hidutil can't do tap vs. hold.
```
.remap caps_lock [device builtin] {
tap : escape
hold : lctrl
timeout : 120ms
permissive_hold : on
retro_tap : off
}
.remap space [device builtin] {
tap : space
hold : fn_layer
timeout : 200ms
permissive_hold : on
retro_tap : on
}
```
Source/destination names use HID-standard physical-position naming
(layout-independent) — different from the macOS virtual-keycode names
shown by `skhd -o`. Run `skhd --grabber-status` or check
`src/HidKeyMap.zig` for the full list. Common: `a-z`, `0-9`, `caps_lock`,
`escape`, `space`, `return`, `tab`, `backspace`, `lctrl`, `lshift`, `lalt`,
`lcmd` (and `r*` variants), `f1..f20`, `minus`, `equal`, `lbracket`,
`rbracket`, `backslash`, `semicolon`, `quote`, `grave`, `comma`,
`period`, `slash`, `non_us_backslash`.
## skhd-grabber: caps_lock-class tap-hold
macOS's user-level event tap can't see caps_lock or rewrite it cleanly
without LED toggle artifacts. Block-form `.remap` rules go through a
small system daemon (`skhd-grabber`) that runs as root, seizes the
matched keyboard via IOHIDManager, and injects through the Karabiner
DriverKit virtual HID device.
### Install
```bash
# Single command — installs the per-user agent and, if your config has
# caps_lock-class rules with a connected target device, prompts (Y/n)
# to install the system grabber via sudo. The same prompt also auto-
# downloads + installs the Karabiner DriverKit .pkg if it's missing
# and writes the launchd plist for its userland daemon (the .pkg's
# postinstall is a no-op `killall`, so we wire up launchd ourselves —
# but we skip it cleanly when Karabiner-Elements is already managing
# that label via SMAppService).
skhd --install-service
```
After the grabber install succeeds the agent triggers the **Input
Monitoring** approval dialog. Granting it to `skhd.app` covers the
grabber too: both binaries are signed with the same bundle ID and
the grabber runs from inside `skhd.app/Contents/MacOS/`, so TCC
bundle-keys the grant — one click, both processes covered. No
manual "add `/usr/local/libexec/skhd-grabber` to Input Monitoring"
step.
If the prompt didn't fire (e.g. you added `.remap` rules later) or
you want to install the grabber separately:
```bash
sudo skhd --install-grabber
```
Diagnostic walk-through of every prerequisite (dext, VHIDD daemon,
grabber plist + process, IPC socket):
```bash
skhd --grabber-status
```
### Dependencies
`skhd-grabber` requires the **Karabiner DriverKit VirtualHIDDevice**
extension to inject HID events. The agent's install flow auto-installs
the pinned version (currently v6.14.0; sha-256 verified) the first time
you run `--install-service`, so most users never touch this directly.
If you'd rather install it ahead of time:
```bash
skhd --install-dext # downloads + installs the pkg, writes the
# VHIDD daemon launchd plist (or skips if
# Karabiner-Elements is already handling it)
```
Upstream releases: https://github.com/pqrs-org/Karabiner-DriverKit-VirtualHIDDevice
After install, macOS will prompt you to approve the system extension
in **System Settings → General → Login Items & Extensions → Driver
Extensions**.
### Uninstall
```bash
skhd --uninstall-service # removes the LaunchAgent
sudo skhd --uninstall-grabber # removes skhd-grabber + the VHIDD
# daemon launchd plist we wrote
```
`--uninstall-service` prints any follow-up commands (the grabber
isn't auto-removed because it's a separate sudo step). For the
Karabiner DriverKit pkg files and the kernel-loaded dext (pqrs's
domain), run their uninstall scripts under
`/Library/Application Support/org.pqrs/Karabiner-DriverKit-VirtualHIDDevice/scripts/uninstall/`,
or toggle the dext off via System Settings → Login Items &
Extensions → Driver Extensions.
### Mac Studio / external keyboard only
If you share one config across a laptop and a desktop, `.remap`
block-form rules targeting the laptop's built-in keyboard simply
don't fire on the desktop — `--install-service` and the agent both
detect that the target device isn't connected and skip the grabber
entirely on that machine. No need to install the grabber or the dext
on a machine that doesn't need them.
### Caveats
- **Signing**: setting `HIDKeyboardCapsLockDelayOverride` requires an
Apple Developer ID signature. Unsigned builds fall back to a reactive
workaround: the grabber reads the OS caps_lock state via
`CGEventSourceFlagsState` and, when Apple's firmware-level toggle
fires, injects a vhidd caps_lock toggle to flip it back. Works
cleanly in practice (no LED flash); see `src/grabber/HidSeize.zig`
for the rationale.
- **Coexistence with Karabiner-Elements**: KE registers the **same**
VHIDD daemon launchd label (`org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon`)
via `SMAppService.daemon`, so we share that piece — `--install-dext`
detects KE's registration and skips writing our own plist. But the
*grabber* layer (`karabiner_grabber` for KE, `skhd-grabber` for us)
still wants exclusive seize on the same keyboard. If you're running
Karabiner-Elements, disable its grabber
(`sudo launchctl bootout system/org.pqrs.service.daemon.karabiner_grabber`)
before starting `skhd-grabber`. `skhd --status` and
`--install-grabber` flag this conflict when detected.
- **F-row behavior**: with macOS's "Use F1, F2..." setting **off**
(default), the grabber translates F1..F12 keyboard events to the
appropriate Consumer / Apple-Vendor media events (volume,
brightness, mission control, …) so the F-row stays "media keys"
under seize.
## Built on Karabiner-Elements
This whole feature exists because of **[Karabiner-Elements](https://karabiner-elements.pqrs.org/)** by [Takayama Fumihiko (pqrs.org)](https://pqrs.org/). The architecture, the idea, and the runtime dependency all come from there — skhd.zig is reusing pqrs's lower-level work to plug "QMK-style remapping" into a config format more skhd users already know.
### What Karabiner-Elements does (and we don't)
Karabiner-Elements is the comprehensive keyboard customizer for macOS: a polished GUI, complex modifications, simultaneous keys, parameterized rules, an event viewer, profiles, a giant community library of rule presets, and years of refinement. If you want a turnkey keyboard remapper with a UI, **install Karabiner-Elements** — it's the right tool. skhd.zig's grabber path is intentionally narrower:
- **One config format** — your existing `.skhdrc` plus a few new directives. No JSON, no GUI, no separate rule-set system.
- **Tap-hold + layer holds + 1:1 remaps + device scoping**. That's it. Anything outside that is still standard skhd hotkey territory (event-tap based, no grabber needed).
- **No menu bar app, no preferences pane, no event viewer**. Diagnostics live in `skhd --status` and `skhd --grabber-status`.
If you need anything more sophisticated than the four directives in this README, Karabiner-Elements is unequivocally the better choice.
### What we borrow (with credit)
**From Karabiner — architecture and runtime infrastructure:**
- **[Karabiner-DriverKit-VirtualHIDDevice](https://github.com/pqrs-org/Karabiner-DriverKit-VirtualHIDDevice)** — the kernel-side system extension + userland helper daemon that lets us inject synthesized HID events on modern macOS. Apple removed kernel extension support; pqrs's DriverKit replacement is, as far as we know, the only practical route for HID injection on Big Sur and later. We auto-install the pinned version via `skhd --install-dext`, share the launchd registration if Karabiner-Elements is already there, and never modify their code. Apache-2.0 licensed; the .pkg ships under pqrs's signing identity.
- **The architectural pattern** — userland agent talks to a root daemon, which seizes HID via IOHIDManager and injects through DriverKit. Karabiner-Elements pioneered this approach on macOS; skhd-grabber is a smaller config-driven implementation of the same shape.
- **HID-standard key naming** — `caps_lock`, `non_us_backslash`, `lctrl`, etc. (layout-independent physical positions, distinct from the macOS virtual-keycode names skhd uses elsewhere) — Karabiner uses the same identifiers, so cross-referencing their docs for which name maps to which physical key is direct.
**From QMK — tap-hold parameters and defaults:**
We deliberately do *not* use Karabiner's [complex modifications](https://karabiner-elements.pqrs.org/docs/json/complex-modifications-manipulator-definition/) JSON dialect (verbose, camelCase, `to_if_alone` / `to_if_held_down` / etc.). skhd users come from a `.skhdrc` background and want a config that reads like the rest of skhd. We follow **[QMK firmware](https://github.com/qmk/qmk_firmware)**'s tap-hold model instead: snake_case keywords, the same parameter set you'd put in a QMK `config.h`, and the same defaults.
- `timeout` ↔ QMK `TAPPING_TERM` (default 200ms).
- `permissive_hold` ↔ QMK [`PERMISSIVE_HOLD`](https://github.com/qmk/qmk_firmware/blob/master/docs/tap_hold.md#permissive-hold).
- `hold_on_other_key_press` ↔ QMK [`HOLD_ON_OTHER_KEY_PRESS`](https://github.com/qmk/qmk_firmware/blob/master/docs/tap_hold.md#hold-on-other-key-press).
- `retro_tap` ↔ QMK [`RETRO_TAPPING`](https://github.com/qmk/qmk_firmware/blob/master/docs/tap_hold.md#retro-tapping).
If you've configured a custom keyboard in QMK, you already know how every knob in `.taphold` behaves — they're direct ports. **QMK's [`docs/tap_hold.md`](https://github.com/qmk/qmk_firmware/blob/master/docs/tap_hold.md) is the canonical long-form reference** for why each parameter exists and what edge cases it solves; our parser implements the same semantics rule-for-rule.
### Coexistence
skhd-grabber and Karabiner-Elements both want exclusive HID seize on the same keyboard, so running both grabbers at once doesn't work. We coexist at the **DriverKit daemon** layer (we share the same `org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon` registration; if Karabiner-Elements is installed, our `--install-dext` detects its SMAppService registration and skips writing our own plist), but you have to pick **one grabber** at a time. See the Coexistence caveat above for the disable command.
If you're switching from Karabiner-Elements to skhd.zig: keep the dext, keep the daemon, just disable `karabiner_grabber`. If you want to go back the other way: `sudo skhd --uninstall-grabber` and re-enable Karabiner-Elements.
> Thanks again to pqrs and the Karabiner-Elements community. None of this is novel work on our side — we're just packaging a narrow slice in a different config format.
## Testing and Debugging
### Quick health check
When something isn't working, start with these:
```bash
skhd --status # one-line summary: agent, grabber, dext, TCC
skhd --grabber-status # drills into the grabber dependency chain
# (socket, dext version, IOKit match, …)
```
`--status` is the fastest way to spot a missing piece (e.g. agent running
but grabber not installed, or dext loaded but not enabled).
`--grabber-status` is what to run when a `.remap`/`.taphold` rule isn't
firing.
### Logs
Two processes, two log files:
```bash
# Agent (user-session skhd) — config parsing, event tap, hotkey dispatch.
tail -f ~/Library/Logs/skhd.log
# skhd-grabber (root LaunchDaemon) — only present if you installed the
# grabber. Captures HID seize, tap-hold timing, layer pushes, IPC traffic.
sudo tail -f /var/log/skhd-grabber.log
```
For unified-logging captures across both processes:
```bash
log show --last 5m --predicate 'process == "skhd" OR process == "skhd-grabber"'
```
### Build modes (logging + profiling are mode-gated)
| Build | `-V` shows | `-P` profiling |
|----------------------------------|--------------------------|---------------------------------|
| ReleaseFast (Homebrew default) | errors + warnings only | disabled (compiled out) |
| ReleaseSafe | + info | available |
| Debug (`zig build`) | + debug | available with full traces |
Homebrew installs are ReleaseFast, so `-V` against the installed binary is
intentionally quiet. To dig into a hotkey misbehaviour, run a Debug or
ReleaseSafe build directly:
```bash
zig build run -- -V # debug logs
zig build -Doptimize=ReleaseSafe && \
./zig-out/bin/skhd -V # info logs, prod-shaped binary
```
Verbose mode also preserves child-command stdout/stderr (useful for
diagnosing why a `: command` is silent), at a small per-event cost.
### Observe and synthesize
```bash
skhd -o # echo every keycode + modifier the tap sees
skhd -k "cmd + shift - t" # synthesize a keypress
skhd -t "hello world" # synthesize text
skhd -r # reload running instance's config
```
`skhd -o` prints macOS *virtual* keycodes (e.g. `0x35` for escape).
`.remap`/`.taphold` rules use HID-standard names (e.g. `escape`) — see
[Device-aware remapping](#device-aware-remapping-device--remap).
### Profiling
```bash
zig build && ./zig-out/bin/skhd -P # Debug
zig build -Doptimize=ReleaseSafe && \
./zig-out/bin/skhd -P # ReleaseSafe — closer to prod
```
Ctrl-C prints the trace summary.
### Allocation tracking
```bash
zig build alloc -- -V
```
The event loop is allocation-free in release builds, so any allocation in
the hot path during interactive use is a regression.
### Common problem → first thing to check
| Symptom | Start here |
|---------|-----------|
| Hotkey isn't firing | `skhd --status` — agent running? Accessibility granted? |
| TCC granted but keys silently swallowed | `tccutil reset ListenEvent com.jackielii.skhd && tccutil reset Accessibility com.jackielii.skhd`, then `skhd --restart-service` and re-grant |
| `.remap` / `.taphold` does nothing | `skhd --grabber-status` — dext enabled? grabber running? device matched? |
| Caps lock LED toggles weirdly | `/var/log/skhd-grabber.log`; search for `HIDKeyboardCapsLockDelayOverride` |
| Karabiner-Elements also installed | `sudo launchctl bootout system/org.pqrs.service.daemon.karabiner_grabber` |
### Clean slate
```bash
skhd --uninstall-service # removes the LaunchAgent
sudo skhd --uninstall-grabber # removes skhd-grabber
# (Karabiner DriverKit dext stays — see Uninstall above)
systemextensionsctl list # inspect dext activation/enabled state
```
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Run tests: `zig build test`
5. Submit a pull request
## License
This project maintains compatibility with the original skhd license.
================================================
FILE: SYNTAX.md
================================================
# SKHD Configuration Syntax Reference
This document provides a comprehensive reference for the skhd configuration syntax. The skhd.zig implementation is fully compatible with the original skhd syntax, with additional features.
## Grammar Overview
The configuration syntax follows these formal rules:
```
hotkey = <mode> '<' <action> | <action>
mode = 'name of mode' | <mode> ',' <mode>
action = <keysym> '[' <proc_map_lst> ']' | <keysym> '->' '[' <proc_map_lst> ']'
<keysym> ':' <command> | <keysym> '->' ':' <command>
<keysym> ';' <mode_activation> | <keysym> '->' ';' <mode_activation>
<keysym> '~'
keysym = <mod> '-' <key> | <key>
mod = 'modifier keyword' | <mod> '+' <mod>
key = <literal> | <keycode>
literal = 'single letter or built-in keyword'
keycode = 'apple keyboard kVK_<Key> values (0x3C)'
proc_map_lst = * <proc_map>
proc_map = <string> ':' <command> | <string> '~' |
'*' ':' <command> | '*' '~' |
<string> ';' <mode_activation> |
'*' ';' <mode_activation> |
'@' <group_name> ':' <command> |
'@' <group_name> '~' |
'@' <group_name> ';' <mode_activation>
string = '"' 'sequence of characters' '"'
group_name = 'process group name defined with .define directive'
command = <shell_command> | <command_reference>
shell_command = command is executed through '$SHELL -c' and
follows valid shell syntax. if the $SHELL environment
variable is not set, it will default to '/bin/bash'.
when bash is used, the ';' delimiter can be specified
to chain commands.
to allow a command to extend into multiple lines,
prepend '\' at the end of the previous line.
an EOL character signifies the end of the bind.
command_reference = '@' <identifier> |
'@' <identifier> '(' <arg_list> ')'
arg_list = <string> | <string> ',' <arg_list>
mode_activation = <mode> | <mode> ':' <command>
-> = keypress is not consumed by skhd
* = matches every application not specified in <proc_map_lst>
~ = application is unbound and keypress is forwarded per usual
```
## Mode Activation
Mode activation allows switching between different hotkey modes. The syntax is:
```
mode_activation = <mode> | <mode> ':' <command>
```
- `;` followed by a mode name switches to that mode
- An optional `:` followed by a command executes that command when switching modes
### Examples:
- `cmd - w ; window` - Switch to window mode
- `cmd - w ; window : echo "Window mode"` - Switch to window mode and execute command
- `escape ; default` - Switch back to default mode
Mode activation can be used in:
1. **Global hotkeys**: `cmd - w ; window`
2. **Process-specific bindings**: `"terminal" ; vim_mode`
3. **Process group bindings**: `@browsers ; browser_mode`
## Mode Declaration
Modes are declared using the following syntax:
```
mode_decl = '::' <name> '@' ':' <command> | '::' <name> ':' <command> |
'::' <name> '@' | '::' <name>
name = desired name for this mode
@ = capture keypresses regardless of being bound to an action
command = command is executed through '$SHELL -c'
```
## Modifiers
### Basic Modifiers
- `cmd` - Command key (⌘)
- `ctrl` - Control key (⌃)
- `alt` - Alt/Option key (⌥)
- `shift` - Shift key (⇧)
- `fn` - Function key
### Left/Right Specific Modifiers
- `lcmd`, `rcmd` - Left/right Command
- `lctrl`, `rctrl` - Left/right Control
- `lalt`, `ralt` - Left/right Alt
- `lshift`, `rshift` - Left/right Shift
### Special Modifier Combinations
- `hyper` - cmd + shift + alt + ctrl
- `meh` - shift + alt + ctrl
## Key Literals
### Navigation Keys
- `left`, `right`, `up`, `down` - Arrow keys
- `home`, `end` - Home/End keys
- `pageup`, `pagedown` - Page Up/Down
### Special Keys
- `return` - Return/Enter key
- `tab` - Tab key
- `space` - Space bar
- `backspace` - Delete/Backspace (kVK_Delete)
- `delete` - Forward Delete (kVK_ForwardDelete)
- `escape` - Escape key
- `backtick` - Backtick/Grave Accent key (`)
### Function Keys
- `f1` through `f20` - Function keys
### Media Keys
- `sound_up`, `sound_down` - Volume controls
- `mute` - Mute key
- `brightness_up`, `brightness_down` - Screen brightness
- `illumination_up`, `illumination_down` - Keyboard backlight
- `play`, `previous`, `next` - Media playback
- `rewind`, `fast` - Media navigation
### Mouse Buttons (New in skhd.zig!)
- `mouse1` - Left button
- `mouse2` - Right button
- `mouse3` - Middle button
- `mouse4` - Back / fourth button
- `mouse5` - Forward / fifth button
Used the same way as keys — combine with modifiers via `-`, or stand alone:
```bash
cmd - mouse1 : echo "cmd-click"
meh - mouse3 : open -a "Mission Control"
mouse4 -> : echo "back button" # passthrough: still goes to the app
```
Mouse buttons can also be the **target** of a forward, so you can
synthesize a click from a key (e.g. inside a layer):
```bash
fn_layer < enter | mouse1 # in fn_layer, enter = left-click
fn_layer < space | cmd - mouse1 # cmd-click via the layer
```
⚠️ Binding `mouse1` (or any mouse button) **without** a modifier and
**without** `->` consumes every click in non-blacklisted apps and
effectively breaks the trackpad. Pair with a modifier (`cmd - mouse1`)
or use passthrough (`mouse1 -> : ...`) unless you really mean it.
Mouse-up and drag events are not captured (skhd only sees the down
edge); scroll-wheel events aren't bindable either.
## Configuration Directives
Configuration directives follow this syntax:
```
directive = '.shell' <string> |
'.blacklist' '[' <string_list> ']' |
'.load' <string> |
'.path' <string> | '.path' '[' <string_list> ']' |
'.define' <identifier> '[' <string_list> ']' |
'.define' <identifier> ':' <command_template> |
'.device' <identifier> '{' <device_attrs> '}' |
'.remap' <hid_key> <device_clause> ':' <hid_key> |
'.remap' <hid_key> <device_clause> '{' <taphold_attrs> '}'
device_attrs = 'vendor:' <hex> ',' 'product:' <hex>
device_clause = '[' 'device' <identifier> ']'
taphold_attrs = ( <taphold_attr> )+
taphold_attr = 'tap' ':' <hid_key>
| 'hold' ':' <hid_key_or_layer>
| 'timeout' ':' <duration>
| 'permissive_hold' ':' ('on' | 'off')
| 'hold_on_other_key_press' ':' ('on' | 'off')
| 'retro_tap' ':' ('on' | 'off')
hid_key_or_layer = <hid_key> | <mode_identifier> // mode = layer hold
duration = <integer> ('ms' | 's')
string_list = <string> | <string> ',' <string_list>
```
### HID key names vs macOS virtual keycodes
`.remap` / `.taphold` / `.device` operate at the **HID layer**, before
macOS translates keys through the active layout. They use HID-standard
**layout-independent physical-position** names (`caps_lock`, `lctrl`,
`non_us_backslash`, `a`–`z`, `0`–`9`, `f1`–`f20`, `minus`, `equal`,
`lbracket`, `rbracket`, `backslash`, `semicolon`, `quote`, `grave`,
`comma`, `period`, `slash`, `space`, `return`, `tab`, `escape`,
`backspace`, etc.) — different from the macOS virtual-keycode names
(`0x32`, `0x29`, etc.) that the regular `cmd - a` hotkey table uses.
Run `skhd --grabber-status` once installed (or check
`src/HidKeyMap.zig`) for the full list. These are the same identifiers
Karabiner-Elements uses, so its [docs](https://karabiner-elements.pqrs.org/docs/help/symbols-and-keycodes/)
work as a cross-reference.
### Shell Configuration
```bash
.shell "/bin/zsh"
```
### Application Blacklist
```bash
.blacklist [
"loginwindow"
"screensaver"
"VMware Fusion"
]
```
### Include Files
```bash
.load "~/.config/skhd/extra.skhdrc"
```
### Extra PATH entries
At startup skhd inherits PATH from the user's login shell (`~/.zprofile`,
`~/.bash_profile`, fish's `config.fish`, etc.) so commands installed by
Homebrew and similar work out of the box. For tools whose location isn't in
the shell's PATH — most commonly version-manager shims like mise/asdf/nvm —
declare the directory with `.path`:
```bash
.path "$HOME/.local/share/mise/shims"
.path "~/.cargo/bin"
# Or list form:
.path [
"/opt/custom/bin"
"$HOME/bin"
]
```
`.path` entries are prepended to PATH (declaration order preserved), so they
take precedence over shell-inherited locations. `~` and `$HOME` are
expanded; other `$VAR` forms are not — use absolute paths for everything
else.
### Aliases (New in skhd.zig!)
Give a name to a modifier combination or to a single key. The name is
referenced with a `$` prefix, must start with a letter, and is expanded
at parse time (zero runtime cost).
#### Modifier alias
```bash
.alias $hyper cmd + alt + ctrl + shift
.alias $super cmd + alt
# Use as the modifier prefix of a hotkey
$hyper - h : echo "hyper-h"
$super - return : open -a Terminal.app
# Combine with other modifiers via '+'
$super + shift - h : echo "super+shift+h"
# A modifier alias may reference an earlier modifier alias
.alias $mega $super + shift + ctrl
```
#### Key alias
```bash
.alias $grave 0x32 # Hex keycode (e.g., UK keyboard backtick)
.alias $del delete # Literal key (carries any implicit fn/nx flag)
# Use after the dash, or standalone
ctrl - $grave : open -a Notes
$del : echo plain-delete
# A key alias may reference another key alias
.alias $tilde $grave
```
#### Rules
- Aliases must be defined before use; redefinition is an error.
- A **modifier alias** appears in modifier position only (before `-`, or
chained with `+`). Using it as a key (`ctrl - $hyper`) is an error.
- A **key alias** appears in key position only (after `-`, or standalone).
Using it as a modifier (`$grave - h`) is an error.
- Combining modifiers into a baked-in keysym (e.g. `.alias $foo cmd - h`)
is not supported — define the modifier and key parts separately and
combine them at the use site.
### Process Groups (New in skhd.zig!)
```bash
.define terminal_apps ["kitty", "wezterm", "terminal"]
.define browser_apps ["chrome", "safari", "firefox"]
# Use with @ prefix in proc_map
ctrl - left [
@terminal_apps ~
* | alt - left
]
```
### Command Definitions (New in skhd.zig!)
```bash
# Simple command without placeholders
.define focus_recent : yabai -m window --focus recent
# Command with placeholders
.define yabai_focus : yabai -m window --focus {{1}} || yabai -m display --focus {{1}}
.define window_action : yabai -m window --{{1}} {{2}}
# Use with @ prefix and arguments
cmd - tab : @focus_recent
cmd - h : @yabai_focus("west")
cmd + shift - h : @window_action("swap", "west")
```
### Device Aliases (`.device`)
Declare a USB keyboard once by `vendor`/`product` ID, then reference it
by alias from `.remap` / `.taphold` rules. The alias is a config-local
name — pick anything (`builtin`, `corsair`, `keychron`, …). Find your
keyboard's IDs via System Information → USB, or run any `.remap` rule
with verbose mode to see currently-attached vendor/product pairs in
the log.
```bash
.device builtin { vendor: 0x05AC, product: 0x0342 }
.device keychron { vendor: 0x05AC, product: 0x024F }
```
A config shared between machines targeting different hardware is fine —
rules whose `[device <alias>]` doesn't match a connected device are
silently skipped on that machine. No grabber is installed on a
machine without any matching device.
### Key Remapping (`.remap` colon form)
Instant 1:1 key swap, applied via `hidutil`'s `UserKeyMapping` table.
**No daemon needed** for the colon form — works without installing
skhd-grabber. Original mappings are saved on startup and restored on
shutdown so the keyboard isn't left remapped when skhd exits.
```bash
# UK ISO MacBook: map § (top-left key, HID-named non_us_backslash) to
# the ISO grave key so it types `.
.remap non_us_backslash [device builtin] : grave
# Swap caps_lock with escape on an external keyboard.
.remap caps_lock [device keychron] : escape
```
**Limitations** (use the block form below instead for these cases):
- Cannot map `caps_lock` to a modifier — macOS's kernel layer above
`hidutil` silently drops `caps_lock → ctrl/shift/alt/cmd`.
- Cannot do tap-hold or layer-hold semantics.
### Tap-Hold Rules (`.remap` block form)
Distinguish tap vs. hold timing on the same physical key, plus layer
holds. Routed through `skhd-grabber` (root LaunchDaemon) — see
[skhd-grabber](README.md#skhd-grabber-caps_lock-class-tap-hold) in the
README for install + permission setup.
```bash
.remap caps_lock [device builtin] {
tap : escape
hold : lctrl
timeout : 120ms
permissive_hold : on
retro_tap : off
}
```
**Attributes** — names, semantics, and defaults all follow
[QMK firmware's tap-hold model](https://github.com/qmk/qmk_firmware/blob/master/docs/tap_hold.md)
(snake_case keywords, same parameter set as a QMK `config.h`).
We deliberately don't use Karabiner-Elements' complex-modifications
JSON dialect — skhd users want a config that reads like the rest of
`.skhdrc`, not a separate verbose camelCase format.
| Attribute | Type | Default | QMK equivalent | Description |
|---|---|---|---|---|
| `tap` | hid_key | required | `LT(layer, kc)` tap behavior | Key emitted on a quick tap (press + release within `timeout`). |
| `hold` | hid_key or mode | required | `LT(layer, kc)` hold behavior | Key emitted while held past `timeout`. A mode identifier here makes it a **layer hold** (see below). |
| `timeout` | duration | `200ms` | [`TAPPING_TERM`](https://github.com/qmk/qmk_firmware/blob/master/docs/tap_hold.md#tapping-term) | How long the source key has to be held to commit the hold action. |
| `permissive_hold` | `on`/`off` | `on` | [`PERMISSIVE_HOLD`](https://github.com/qmk/qmk_firmware/blob/master/docs/tap_hold.md#permissive-hold) | If `on`, a nested down+up of another key while the source is held also commits the hold modifier. Useful for typing Ctrl+A by holding caps for ~50ms then quickly tapping `a`. |
| `hold_on_other_key_press` | `on`/`off` | `off` | [`HOLD_ON_OTHER_KEY_PRESS`](https://github.com/qmk/qmk_firmware/blob/master/docs/tap_hold.md#hold-on-other-key-press) | If `on`, any other key pressed (even without release) while the source is held immediately commits the hold action. Stricter than `permissive_hold`. |
| `retro_tap` | `on`/`off` | `off` | [`RETRO_TAPPING`](https://github.com/qmk/qmk_firmware/blob/master/docs/tap_hold.md#retro-tapping) | If `on`, releasing the source key without committing a hold still emits the tap key. Useful for over-held keys (e.g. holding `space` then releasing without pressing anything else still types a space). |
### Layer Holds
When `hold` references a **mode identifier** instead of a key, the
source key acts as a temporary layer activator: hold to enter the
mode, release to exit. Layer hold rules push IPC messages from grabber
→ agent so the mode change happens on the agent's run loop (where
mode bindings are evaluated).
```bash
# Declare a capture-mode for unbound keys not to leak through.
:: fn_layer @
# Hold space → enter fn_layer; release → back to default.
.remap space [device builtin] {
tap : space
hold : fn_layer
timeout : 200ms
retro_tap : on
}
# While the layer is held, number row maps to F-row.
fn_layer < 1 | f1
fn_layer < 2 | f2
fn_layer < tab | alt - tab # cmd-tab style app switcher
fn_layer < 0x1B | f11 # virtual keycodes also work in layer
```
Layer-hold modes use the same `:: <name> @` declaration syntax as
[regular modes](#mode-declaration) — capture (`@`) decides whether
unbound keys in the layer leak through to the focused app or are
absorbed.
## Syntax Examples
### Basic Hotkey
```bash
cmd - a : echo "Command+A pressed"
```
### Multiple Modifiers
```bash
cmd + shift + alt - x : echo "Complex hotkey"
```
### Process-Specific Bindings
```bash
cmd - n [
"terminal" : echo "New terminal window"
"safari" : echo "New browser window"
* : echo "New window in other apps"
]
```
### Key Forwarding
```bash
# Simple forwarding
ctrl - h | left
# Process-specific forwarding
home [
"kitty" ~ # Let kitty handle it
* | cmd - left # In other apps, send Cmd+Left
]
```
### Modal System
```bash
# Declare mode
:: window : echo "Window mode"
# Enter mode
cmd - w ; window
# Enter mode and execute command
cmd - w ; window : echo "Switching to window mode"
# Commands in mode
window < h : yabai -m window --focus west
window < escape ; default : echo "Returning to default mode"
```
### Process-Specific Mode Activation
Mode activation can also be used in process lists, allowing different applications to trigger different modes:
```bash
# Define terminal and browser app groups
.define terminal_apps ["kitty", "wezterm", "terminal"]
.define browser_apps ["chrome", "safari", "firefox"]
# Different apps switch to different modes with Cmd+M
cmd - m [
@terminal_apps ; vim_mode : echo "Vim mode for terminals"
@browser_apps ; browser_mode : echo "Browser mode activated"
* ; default : echo "Back to default"
]
# Mode activation with command in process list
cmd - e [
"code" ; edit_mode : osascript -e 'display notification "Edit mode for VS Code"'
"xcode" ; edit_mode : osascript -e 'display notification "Edit mode for Xcode"'
* : echo "No special mode for this app"
]
```
### Passthrough Mode
```bash
# Execute command but still send keypress
cmd - p -> : echo "Command runs but Cmd+P goes to app"
```
### Multi-line Commands
```bash
cmd - x : echo "Line 1" ; \
echo "Line 2" ; \
echo "Line 3"
```
## Special Syntax Notes
1. **Comments**: Use `#` for comments
2. **Unbinding**: Use `~` to unbind a key in specific applications
3. **Wildcard**: Use `*` to match all applications not explicitly specified
4. **Keycode**: Use hex values like `0x3C` for specific key codes
5. **Mode Capture**: Use `@` after mode name to capture all keypresses
## Common Patterns
### Vim-like Navigation
```bash
# Global navigation
cmd - h : focus west
cmd - j : focus south
cmd - k : focus north
cmd - l : focus east
```
### Application Launching
```bash
alt - return : open -a Terminal
alt - b : open -a Safari
```
### Mode-based Workflows
```bash
:: resize @ : echo "Resize mode"
cmd - r ; resize
resize < h : resize left
resize < l : resize right
resize < escape ; default
```
### Linux-style Text Editing
```bash
# Word movement
ctrl - left [
@terminal_apps ~
* | alt - left
]
# Line start/end
home [
@native_apps ~
* | cmd - left
]
```
================================================
FILE: TODO.md
================================================
# TODO - Future Features and Improvements
This file tracks features and improvements that are not yet implemented but could be added in future versions.
## Clean up
- [ ] clean up tests by moving the tests tests.zig to their respective zig files and remove tests.zig
- [ ] clean up pub declarations where possible
## Advanced Input Handling
### macOS Integration
- [ ] **Keyboard layout change handling**: Adapt to keyboard layout changes dynamically
- [ ] **Secure keyboard entry detection**: Detect and handle secure input fields
- [ ] **macOS notification support**: Show notifications for mode changes and errors
- [ ] **Locale-aware keycode mapping**: Support for different keyboard layouts and locales
### Mouse Support
- [ ] **Mouse button support**: Add support for left, right, middle, and extra mouse buttons
- [ ] **Mouse event handling**: Support mouse clicks, drag, and scroll events in hotkeys
- [ ] **Mouse gesture recognition**: Basic mouse gesture support for hotkey triggers
## Power Management and System Control
### System Integration
- [ ] **Power management integration**: Integration with macOS power management
- [ ] **Sleep system command**: `iokit_power_management_sleep_system` - Command to put system to sleep
- [ ] **Display control**: Commands to control display brightness, sleep, etc.
- [ ] **Volume and media control**: Direct system volume and media control commands
### Device Detection
- [ ] **Input device detection**: Detect and handle multiple keyboards/input devices
- [ ] **Device-specific mappings**: Different hotkey mappings for different input devices
- [ ] **USB device hotplug**: Handle USB keyboard connect/disconnect events
## Configuration Enhancements
### Syntax Extensions
- [ ] **Negation syntax**: Apply hotkeys to all apps except specified ones (e.g., `! ["kitty", "wezterm"]`)
- [ ] **Conditional hotkeys**: Hotkeys that activate based on system state (time, app state, etc.)
## User Interface and Experience
### Platform Support
- [ ] **Universal binary**: Build universal binaries for Intel and Apple Silicon
## Testing and Quality Assurance
### Testing Infrastructure
- [ ] **Integration tests**: Comprehensive integration test suite
- [ ] **Performance benchmarks**: Automated performance testing and regression detection
- [ ] **Fuzzing**: Fuzz testing for configuration parsing and event handling
## Community and Ecosystem
### Community Features
- [ ] **Configuration sharing**: Platform for sharing configuration files
================================================
FILE: VERSION
================================================
0.1.0-alpha
================================================
FILE: assets/Info.plist.grabber.template
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>__BUNDLE_ID__</string>
<key>CFBundleName</key>
<string>skhd-grabber</string>
<key>CFBundleDisplayName</key>
<string>skhd-grabber</string>
<key>CFBundleExecutable</key>
<string>skhd-grabber</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleVersion</key>
<string>__VERSION__</string>
<key>CFBundleShortVersionString</key>
<string>__VERSION__</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSBackgroundOnly</key>
<true/>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Released under the MIT License.</string>
</dict>
</plist>
================================================
FILE: assets/Info.plist.template
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>__BUNDLE_ID__</string>
<key>CFBundleName</key>
<string>skhd</string>
<key>CFBundleDisplayName</key>
<string>skhd</string>
<key>CFBundleExecutable</key>
<string>skhd</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleVersion</key>
<string>__VERSION__</string>
<key>CFBundleShortVersionString</key>
<string>__VERSION__</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSBackgroundOnly</key>
<true/>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Released under the MIT License.</string>
</dict>
</plist>
================================================
FILE: assets/LaunchAgent.plist
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.jackielii.skhd</string>
<key>BundleProgram</key>
<string>Contents/MacOS/skhd</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ProcessType</key>
<string>Interactive</string>
<key>ThrottleInterval</key>
<integer>10</integer>
</dict>
</plist>
================================================
FILE: assets/karabiner-virtualhiddevice-daemon.plist
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!--
LaunchDaemon plist for Karabiner-DriverKit-VirtualHIDDevice's userland
helper daemon. Installed by `skhd --install-dext` to
/Library/LaunchDaemons/org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon.plist.
Why we ship this: pqrs's standalone DriverKit .pkg has a no-op
postinstall (it just `killall`s the existing daemon and lets launchd
respawn). The launchd plist that historically registered the daemon
ships with Karabiner-Elements, not the DriverKit pkg itself. Without
Karabiner-Elements installed, the daemon never gets a launchd entry —
the dext loads but the userland half stays dark, and grabber-side
vhidd_server connect attempts fail silently. This plist fills that gap.
Idempotent — `--install-dext` skips writing it if a file already
exists at this path (so a Karabiner-Elements user keeps theirs).
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon</string>
<key>ProgramArguments</key>
<array>
<string>/Library/Application Support/org.pqrs/Karabiner-DriverKit-VirtualHIDDevice/Applications/Karabiner-VirtualHIDDevice-Daemon.app/Contents/MacOS/Karabiner-VirtualHIDDevice-Daemon</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<!--
Interactive: don't throttle under user-driven keyboard load. Same
reasoning as skhd-grabber's plist.
-->
<key>ProcessType</key>
<string>Interactive</string>
<key>StandardOutPath</key>
<string>/var/log/karabiner-virtualhiddevice-daemon.log</string>
<key>StandardErrorPath</key>
<string>/var/log/karabiner-virtualhiddevice-daemon.log</string>
</dict>
</plist>
================================================
FILE: build.zig
================================================
const std = @import("std");
fn linkFrameworks(b: *std.Build, exe: *std.Build.Step.Compile) void {
// Explicit os_version_min flips Zig out of "native" mode, so it stops
// auto-adding the macOS SDK to the framework search path. Re-add it
// from the SDK path stashed by build().
if (sdk_path) |sdk| {
exe.addFrameworkPath(.{ .cwd_relative = b.fmt("{s}/System/Library/Frameworks", .{sdk}) });
exe.addSystemIncludePath(.{ .cwd_relative = b.fmt("{s}/usr/include", .{sdk}) });
exe.addLibraryPath(.{ .cwd_relative = b.fmt("{s}/usr/lib", .{sdk}) });
}
exe.linkFramework("Cocoa");
exe.linkFramework("Carbon");
exe.linkFramework("CoreServices");
// ServiceManagement: SMAppService.agent / register / unregister, used
// by --register-service to register the bundled LaunchAgent with BTM.
exe.linkFramework("ServiceManagement");
// IOKit: IOHIDManager enumeration in DeviceCheck.zig (decides
// whether to dial the grabber based on connected devices).
exe.linkFramework("IOKit");
}
// macOS SDK path resolved once via xcrun and reused for every artifact's
// framework / include / library search paths.
var sdk_path: ?[]const u8 = null;
fn addVersionImport(b: *std.Build, exe: *std.Build.Step.Compile) void {
// Get build mode string
const mode_str = switch (exe.root_module.optimize.?) {
.Debug => "debug",
.ReleaseSafe => "safe",
.ReleaseFast => "fast",
.ReleaseSmall => "small",
};
const version_step = b.addSystemCommand(&[_][]const u8{
"sh", "-c",
b.fmt(
\\VERSION=$(cat VERSION | tr -d '\n')
\\GIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')
\\# Check if working tree is dirty
\\if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
\\ DIRTY="-dirty"
\\else
\\ DIRTY=""
\\fi
\\# Check if we're on a tagged commit
\\if git describe --exact-match --tags HEAD >/dev/null 2>&1; then
\\ # On a tag, just show version-hash
\\ printf "%s-%s%s ({s})" "$VERSION" "$GIT_HASH" "$DIRTY"
\\else
\\ # Not on a tag, show version-dev-hash
\\ printf "%s-dev-%s%s ({s})" "$VERSION" "$GIT_HASH" "$DIRTY"
\\fi
, .{ mode_str, mode_str }),
});
version_step.has_side_effects = true;
const version_file = version_step.captureStdOut();
exe.root_module.addAnonymousImport("VERSION", .{
.root_source_file = version_file,
});
}
/// Register the embedded launchd plists used by `--install-grabber` and
/// `--install-dext`. Plists live outside `src/` so anonymous imports are
/// the right shape (Zig restricts `@embedFile` to within the module's
/// package). Call this on every binary that links grabber_cli (currently
/// skhd, skhd-alloc, and unit-test executables).
fn addGrabberPlistImports(b: *std.Build, exe: *std.Build.Step.Compile) void {
exe.root_module.addAnonymousImport("grabber_plist", .{
.root_source_file = b.path("scripts/com.jackielii.skhd.grabber.plist"),
});
exe.root_module.addAnonymousImport("vhidd_plist", .{
.root_source_file = b.path("assets/karabiner-virtualhiddevice-daemon.plist"),
});
}
const track_alloc_option = "track_alloc";
// Pinned Karabiner-DriverKit-VirtualHIDDevice version. skhd-grabber's IPC
// is validated against this exact version of the dext + userland daemon.
// Same-major versions are assumed wire-compatible (pqrs project follows
// SemVer); different major triggers a runtime warning. Bump procedure:
// 1. Update _version to the new tag.
// 2. Update _url accordingly.
// 3. `curl -fsSL <url> | shasum -a 256` and paste into _sha256.
// 4. Test `zig build install-dext` end-to-end on a clean machine.
const karabiner_dext_version = "6.14.0";
const karabiner_dext_url = "https://github.com/pqrs-org/Karabiner-DriverKit-VirtualHIDDevice/releases/download/v" ++ karabiner_dext_version ++ "/Karabiner-DriverKit-VirtualHIDDevice-" ++ karabiner_dext_version ++ ".pkg";
const karabiner_dext_sha256 = "ebfb6a643ea98bb7c2e08a4f99353b2a3129e397f4302340443bbd936f12eb1c";
// Although this function looks imperative, note that its job is to
// declaratively construct a build graph that will be executed by an external
// runner.
pub fn build(b: *std.Build) void {
// Pin macOS deployment target. Without this, Zig stamps the Mach-O's
// LC_BUILD_VERSION minos with the build host's OS version, so binaries
// built on macos-latest CI runners (now Tahoe 26) refuse to launch on
// macOS 15.x with "You can't use this version of application 'skhd' with
// this version of macOS." 13.0 matches the Info.plist's
// LSMinimumSystemVersion and is the floor required by SMAppService.
const target = b.standardTargetOptions(.{
.default_target = .{
.os_tag = .macos,
.os_version_min = .{ .semver = .{ .major = 13, .minor = 0, .patch = 0 } },
},
});
const optimize = b.standardOptimizeOption(.{});
// Setting os_version_min above makes Zig treat the target as non-native
// and stop auto-resolving the macOS SDK, so framework links fail. Probe
// xcrun for the SDK and add its paths to every artifact via
// linkFrameworks. Setting b.sysroot would double-prefix paths added with
// cwd_relative, so we stash the SDK path in a module-level var instead.
if (target.result.os.tag == .macos) {
const out = b.run(&.{ "xcrun", "--sdk", "macosx", "--show-sdk-path" });
sdk_path = std.mem.trim(u8, out, " \n\r\t");
}
// Shared protocol module: types + framing for the agent ↔ grabber
// IPC. Both binaries (and tests that exercise either side of the
// protocol) addImport this so they agree on the wire format.
const grabber_protocol_mod = b.createModule(.{
.root_source_file = b.path("src/grabber_protocol.zig"),
.target = target,
.optimize = optimize,
});
// Main executable
const exe = b.addExecutable(.{
.name = "skhd",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
const options = b.addOptions();
options.addOption(bool, track_alloc_option, false);
options.addOption([]const u8, "karabiner_dext_version", karabiner_dext_version);
options.addOption([]const u8, "karabiner_dext_url", karabiner_dext_url);
options.addOption([]const u8, "karabiner_dext_sha256", karabiner_dext_sha256);
linkFrameworks(b, exe);
addVersionImport(b, exe);
addGrabberPlistImports(b, exe);
exe.root_module.addOptions("build_options", options);
exe.root_module.addImport("grabber_protocol", grabber_protocol_mod);
b.installArtifact(exe);
// skhd-grabber: system daemon (root) for caps_lock-class tap-hold.
// Plain Mach-O — installed by `skhd --install-grabber` to
// /usr/local/libexec/skhd-grabber and started by launchd. Needs
// IOKit (D3 seize + run loop) and CoreFoundation (matching dicts).
const grabber_exe = b.addExecutable(.{
.name = "skhd-grabber",
.root_source_file = b.path("src/grabber/main.zig"),
.target = target,
.optimize = optimize,
});
if (sdk_path) |sdk| {
grabber_exe.addFrameworkPath(.{ .cwd_relative = b.fmt("{s}/System/Library/Frameworks", .{sdk}) });
grabber_exe.addSystemIncludePath(.{ .cwd_relative = b.fmt("{s}/usr/include", .{sdk}) });
grabber_exe.addLibraryPath(.{ .cwd_relative = b.fmt("{s}/usr/lib", .{sdk}) });
}
grabber_exe.linkFramework("IOKit");
grabber_exe.linkFramework("CoreFoundation");
// CoreGraphics for CGEventSourceFlagsState — used to detect when
// Apple firmware has toggled caps_lock against our intent so we
// can flip it back via a vhidd-injected caps_lock toggle.
grabber_exe.linkFramework("CoreGraphics");
// SystemConfiguration for SCDynamicStoreCopyConsoleUser — D5
// tracks the active console user and only applies rules from
// their agent. Multi-user / fast-user-switching support.
grabber_exe.linkFramework("SystemConfiguration");
grabber_exe.root_module.addImport("grabber_protocol", grabber_protocol_mod);
b.installArtifact(grabber_exe);
// `zig build grabber-app` — build the grabber binary, wrap it in
// skhd-grabber-dev.app, and code-sign with the local dev cert.
//
// Why a .app bundle? macOS Tahoe's TCC keys Input Monitoring (and
// other HID-related) grants on bundle ID for .app bundles. A bare
// Mach-O is keyed by cdhash + path, which gets invalidated every
// rebuild and doesn't even render in System Settings → Input
// Monitoring (so the user can't toggle approval). Wrapping the
// grabber in a bundle gives it a stable ID, makes it visible in
// the privacy panel, and survives `zig build` recompiles. Same
// pattern skhd-dev.app uses for the agent.
//
// The actual binary inside the bundle is signed with skhd-dev-cert
// and identifier com.jackielii.skhd.grabber.dev. Run as:
// sudo zig-out/skhd-grabber-dev.app/Contents/MacOS/skhd-grabber [args]
const grabber_dev_cert = "skhd-dev-cert";
const grabber_dev_bundle_id = "com.jackielii.skhd.grabber.dev";
const installed_grabber_app = b.getInstallPath(.prefix, "skhd-grabber-dev.app");
const grabber_app_cmd = b.addSystemCommand(&[_][]const u8{
"bash",
"scripts/make-grabber-app.sh",
});
grabber_app_cmd.addArg(b.getInstallPath(.bin, grabber_exe.name));
grabber_app_cmd.addArg(installed_grabber_app);
grabber_app_cmd.addArg(grabber_dev_bundle_id);
grabber_app_cmd.step.dependOn(b.getInstallStep());
const sign_grabber_app_cmd = b.addSystemCommand(&[_][]const u8{
"bash",
"scripts/codesign.sh",
});
sign_grabber_app_cmd.addArg(installed_grabber_app);
sign_grabber_app_cmd.setEnvironmentVariable("SKHD_CERT", grabber_dev_cert);
sign_grabber_app_cmd.setEnvironmentVariable("SKHD_BUNDLE_ID", grabber_dev_bundle_id);
sign_grabber_app_cmd.step.dependOn(&grabber_app_cmd.step);
const grabber_app_step = b.step("grabber-app", "Build skhd-grabber-dev.app (signed bundle for TCC-stable Input Monitoring grants)");
grabber_app_step.dependOn(&sign_grabber_app_cmd.step);
// `zig build run-grabber` — build + sign the bundle, then exec it
// under sudo with --foreground. The bundle path is the entry point
// because TCC keys Input Monitoring on it; running the bare binary
// gets denied silently after the next rebuild invalidates its cdhash.
// Extra args after `--` flow through (e.g. `zig build run-grabber --
// --socket-path /tmp/x.sock`).
const grabber_inner_exe = b.pathJoin(&.{ installed_grabber_app, "Contents", "MacOS", "skhd-grabber" });
const run_grabber_cmd = b.addSystemCommand(&[_][]const u8{ "sudo", grabber_inner_exe, "--foreground" });
run_grabber_cmd.step.dependOn(&sign_grabber_app_cmd.step);
if (b.args) |args| run_grabber_cmd.addArgs(args);
const run_grabber_step = b.step("run-grabber", "Build skhd-grabber-dev.app and run it under sudo --foreground");
run_grabber_step.dependOn(&run_grabber_cmd.step);
const installed_exe = b.getInstallPath(.bin, exe.name);
const installed_app = b.getInstallPath(.prefix, "skhd.app");
// .app bundle step. Wraps the binary into skhd.app so macOS Tahoe's
// Accessibility picker accepts it and TCC keys entries by bundle ID
// (com.jackielii.skhd) instead of by the binary's path. Inner binary at
// skhd.app/Contents/MacOS/skhd is a copy, not a symlink, so codesigning
// works. Scripts have bash shebangs and use bash-only `[[ ... ]]` syntax,
// so invoke via bash explicitly (`/bin/sh` may not be bash on every
// system that runs `zig build`).
const app_cmd = b.addSystemCommand(&[_][]const u8{
"bash",
"scripts/make-app.sh",
});
app_cmd.addArg(installed_exe);
app_cmd.addArg(installed_app);
app_cmd.step.dependOn(b.getInstallStep());
const app_step = b.step("app", "Build the skhd.app bundle wrapper");
app_step.dependOn(&app_cmd.step);
// Code signing.
// `zig build sign` - signs the bare binary at zig-out/bin/skhd.
// `zig build sign-app` - signs the .app bundle (inner Mach-O + bundle
// layer); use this after `zig build app` for
// Tahoe-compatible installs.
const sign_cmd = b.addSystemCommand(&[_][]const u8{
"bash",
"scripts/codesign.sh",
});
sign_cmd.addArg(installed_exe);
sign_cmd.step.dependOn(b.getInstallStep());
const sign_step = b.step("sign", "Code sign the bare binary");
sign_step.dependOn(&sign_cmd.step);
const sign_app_cmd = b.addSystemCommand(&[_][]const u8{
"bash",
"scripts/codesign.sh",
});
sign_app_cmd.addArg(installed_app);
sign_app_cmd.step.dependOn(&app_cmd.step);
const sign_app_step = b.step("sign-app", "Build and code sign the skhd.app bundle (Tahoe-compatible)");
sign_app_step.dependOn(&sign_app_cmd.step);
// Local debug bundle. Uses a separate path, cert (skhd-dev-cert), and
// bundle ID (com.jackielii.skhd.dev) so debug runs get their own TCC slot
// and don't disturb the prod entry (com.jackielii.skhd + skhd-cert) used
// by the Homebrew install. On Tahoe, TCC is bundle-ID-keyed and validates
// against the stored csreq, so the running process must carry the right
// bundle ID and a signature matching the granted entry — the bare binary
// at zig-out/bin/skhd is adhoc-signed and unbundled, so it can't be
// granted accessibility on Tahoe.
const installed_dev_app = b.getInstallPath(.prefix, "skhd-dev.app");
const dev_bundle_id = "com.jackielii.skhd.dev";
const dev_cert_name = "skhd-dev-cert";
const dev_app_cmd = b.addSystemCommand(&[_][]const u8{
"bash",
"scripts/make-app.sh",
});
dev_app_cmd.addArg(installed_exe);
dev_app_cmd.addArg(installed_dev_app);
dev_app_cmd.addArg(dev_bundle_id);
dev_app_cmd.step.dependOn(b.getInstallStep());
const sign_dev_app_cmd = b.addSystemCommand(&[_][]const u8{
"bash",
"scripts/codesign.sh",
});
sign_dev_app_cmd.addArg(installed_dev_app);
sign_dev_app_cmd.setEnvironmentVariable("SKHD_CERT", dev_cert_name);
sign_dev_app_cmd.setEnvironmentVariable("SKHD_BUNDLE_ID", dev_bundle_id);
sign_dev_app_cmd.step.dependOn(&dev_app_cmd.step);
const inner_exe = b.pathJoin(&.{ installed_dev_app, "Contents", "MacOS", "skhd" });
const run_cmd = b.addSystemCommand(&[_][]const u8{inner_exe});
run_cmd.step.dependOn(&sign_dev_app_cmd.step);
if (b.args) |args| {
run_cmd.addArgs(args);
}
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);
// `zig build install-local` — stage the local build into the slot a
// brew install would occupy: replace the binary inside
// /Applications/skhd.app, re-sign with skhd-cert + prod bundle id, and
// restart the SMAppService daemon. Lets you exercise the packaged path
// (real bundle id, real launchd registration, real TCC slot) without
// cutting a release. Pass -Doptimize=ReleaseFast to match the brew
// binary's perf profile.
const install_local_cmd = b.addSystemCommand(&[_][]const u8{
"bash",
"scripts/install-local.sh",
});
install_local_cmd.addArg(installed_exe);
install_local_cmd.step.dependOn(b.getInstallStep());
const install_local_step = b.step("install-local", "Install the local build into /Applications/skhd.app and restart the service (test the packaged path without releasing)");
install_local_step.dependOn(&install_local_cmd.step);
// `zig build install-dext` — download + install the pinned Karabiner
// DriverKit .pkg by invoking the just-built skhd binary's
// `--install-dext` subcommand. Same code path brew users hit, so dev
// and prod stay in lockstep. Cached under ~/.cache/skhd so re-runs
// skip the download; pqrs's installer is a no-op when the same
// version is already installed.
const install_dext_cmd = b.addSystemCommand(&[_][]const u8{installed_exe});
install_dext_cmd.addArg("--install-dext");
install_dext_cmd.has_side_effects = true;
install_dext_cmd.step.dependOn(b.getInstallStep());
const install_dext_step = b.step("install-dext", "Download and install pinned Karabiner-DriverKit-VirtualHIDDevice (required by skhd-grabber)");
install_dext_step.dependOn(&install_dext_cmd.step);
const test_step = b.step("test", "Run unit tests");
// Benchmark executable
const bench_exe = b.addExecutable(.{
.name = "benchmark",
.root_source_file = b.path("src/benchmark.zig"),
.target = target,
.optimize = .ReleaseFast,
});
linkFrameworks(b, bench_exe);
addVersionImport(b, bench_exe);
const zbench = b.dependency("zbench", .{
.target = target,
.optimize = .ReleaseFast,
});
bench_exe.root_module.addImport("zbench", zbench.module("zbench"));
const bench_cmd = b.addRunArtifact(bench_exe);
const bench_step = b.step("bench", "Run benchmarks");
bench_step.dependOn(&bench_cmd.step);
// Allocation tracking executable. Goes through the same dev .app + sign
// path as `zig build run` so TCC's accessibility grant covers it — bare
// Mach-O can't be granted on Tahoe. Same skhd-dev-cert + bundle id, so
// there's only one TCC slot to manage; the .app's inner binary swaps
// between the regular dev build and the alloc-tracking build depending
// on which step you run last.
const alloc_exe = b.addExecutable(.{
.name = "skhd-alloc",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
linkFrameworks(b, alloc_exe);
addVersionImport(b, alloc_exe);
addGrabberPlistImports(b, alloc_exe);
const alloc_options = b.addOptions();
alloc_options.addOption(bool, track_alloc_option, true);
alloc_options.addOption([]const u8, "karabiner_dext_version", karabiner_dext_version);
alloc_options.addOption([]const u8, "karabiner_dext_url", karabiner_dext_url);
alloc_options.addOption([]const u8, "karabiner_dext_sha256", karabiner_dext_sha256);
alloc_exe.root_module.addOptions("build_options", alloc_options);
alloc_exe.root_module.addImport("grabber_protocol", grabber_protocol_mod);
b.installArtifact(alloc_exe);
const installed_alloc_exe = b.getInstallPath(.bin, alloc_exe.name);
const alloc_app_cmd = b.addSystemCommand(&[_][]const u8{
"bash",
"scripts/make-app.sh",
});
alloc_app_cmd.addArg(installed_alloc_exe);
alloc_app_cmd.addArg(installed_dev_app);
alloc_app_cmd.addArg(dev_bundle_id);
alloc_app_cmd.step.dependOn(b.getInstallStep());
const sign_alloc_app_cmd = b.addSystemCommand(&[_][]const u8{
"bash",
"scripts/codesign.sh",
});
sign_alloc_app_cmd.addArg(installed_dev_app);
sign_alloc_app_cmd.setEnvironmentVariable("SKHD_CERT", dev_cert_name);
sign_alloc_app_cmd.setEnvironmentVariable("SKHD_BUNDLE_ID", dev_bundle_id);
sign_alloc_app_cmd.step.dependOn(&alloc_app_cmd.step);
const alloc_cmd = b.addSystemCommand(&[_][]const u8{inner_exe});
alloc_cmd.step.dependOn(&sign_alloc_app_cmd.step);
if (b.args) |args| {
alloc_cmd.addArgs(args);
}
const alloc_step = b.step("alloc", "Run skhd with allocation logging (signed dev .app)");
alloc_step.dependOn(&alloc_cmd.step);
// Tests for main.zig
const exe_unit_tests = b.addTest(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
linkFrameworks(b, exe_unit_tests);
addVersionImport(b, exe_unit_tests);
addGrabberPlistImports(b, exe_unit_tests);
exe_unit_tests.root_module.addOptions("build_options", options);
exe_unit_tests.root_module.addImport("grabber_protocol", grabber_protocol_mod);
const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests);
test_step.dependOn(&run_exe_unit_tests.step);
// Tests for tests.zig
const tests_unit_tests = b.addTest(.{
.root_source_file = b.path("src/tests.zig"),
.target = target,
.optimize = optimize,
});
linkFrameworks(b, tests_unit_tests);
addVersionImport(b, exe_unit_tests);
addGrabberPlistImports(b, tests_unit_tests);
tests_unit_tests.root_module.addOptions("build_options", options);
tests_unit_tests.root_module.addImport("grabber_protocol", grabber_protocol_mod);
const run_tests_unit_tests = b.addRunArtifact(tests_unit_tests);
test_step.dependOn(&run_tests_unit_tests.step);
// Tests for individual modules
const test_files = [_][]const u8{
"src/Tokenizer.zig",
"src/Parser.zig",
"src/Mappings.zig",
"src/Keycodes.zig",
"src/EventTap.zig",
"src/synthesize.zig",
"src/grabber_cli.zig",
"src/grabber_protocol.zig",
"src/grabber/Vhidd.zig",
"src/grabber/KbState.zig",
"src/grabber/TapHold.zig",
// "src/Hotload.zig", // Skip hot load test for local test only
};
for (test_files) |test_file| {
const module_tests = b.addTest(.{
.root_source_file = b.path(test_file),
.target = target,
.optimize = optimize,
});
linkFrameworks(b, module_tests);
addVersionImport(b, module_tests);
addGrabberPlistImports(b, module_tests);
module_tests.root_module.addOptions("build_options", options);
// RuleSet's test imports the shared protocol module by name;
// grabber_protocol.zig itself is the module's root, so it
// doesn't need (and can't have) an import of itself.
if (!std.mem.eql(u8, test_file, "src/grabber_protocol.zig")) {
module_tests.root_module.addImport("grabber_protocol", grabber_protocol_mod);
}
const run_module_tests = b.addRunArtifact(module_tests);
test_step.dependOn(&run_module_tests.step);
}
}
================================================
FILE: build.zig.zon
================================================
.{
// This is the default name used by packages depending on this one. For
// example, when a user runs `zig fetch --save <url>`, this field is used
// as the key in the `dependencies` table. Although the user can choose a
// different name, most users will stick with this provided value.
//
// It is redundant to include "zig" in this name because it is already
// within the Zig package namespace.
.name = .skhd_zig,
// This is a [Semantic Version](https://semver.org/).
// In a future version of Zig it will be used for package deduplication.
.version = "0.0.0",
// Together with name, this represents a globally unique package
// identifier. This field is generated by the Zig toolchain when the
// package is first created, and then *never changes*. This allows
// unambiguous detection of one package being an updated version of
// another.
//
// When forking a Zig project, this id should be regenerated (delete the
// field and run `zig build`) if the upstream project is still maintained.
// Otherwise, the fork is *hostile*, attempting to take control over the
// original project's identity. Thus it is recommended to leave the comment
// on the following line intact, so that it shows up in code reviews that
// modify the field.
.fingerprint = 0xebb8dbd815cfd426, // Changing this has security and trust implications.
// Tracks the earliest Zig version that the package considers to be a
// supported use case.
.minimum_zig_version = "0.14.0",
// This field is optional.
// Each dependency must either provide a `url` and `hash`, or a `path`.
// `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
// Once all dependencies are fetched, `zig build` no longer requires
// internet connectivity.
.dependencies = .{
.zbench = .{
.url = "https://github.com/hendriknielaender/zbench/archive/ad7ccbdb06476affc512c12574b54f7d4386622c.tar.gz",
.hash = "zbench-0.10.0-YTdc7-cmAQCnYOFNUAy3wZ-Sx9-_r8lW4uwpn87wydTn",
},
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
// For example...
//"LICENSE",
//"README.md",
},
}
================================================
FILE: docs/CODE_SIGNING.md
================================================
# Code Signing & .app Bundle for Accessibility Permissions
## Why Both Are Required
Starting with macOS 15 (Sequoia) and especially macOS 26 (Tahoe) released in September 2025, **two things are required for accessibility permissions to behave well**:
1. **Code signing with a stable identity** — so TCC (Transparency, Consent, Control) recognizes the binary across rebuilds.
2. **An `.app` bundle wrapper** — so the System Settings → Accessibility picker accepts the binary, and so TCC keys the entry by bundle identifier (`com.jackielii.skhd`) instead of by file path.
### What goes wrong without these
- **Without a stable signature** (Zig's default adhoc signing): every rebuild looks like a "different" binary to TCC, permissions reset, you keep seeing "ACCESSIBILITY PERMISSIONS REQUIRED".
- **Without the `.app` bundle** (bare Mach-O): on macOS Tahoe the Accessibility `+` picker silently rejects the binary; the entry never appears in the list. TCC entries that *do* get created are path-based (`client_type=1`) and break on `brew upgrade` because the Cellar version path changes.
- **CVE-2025-43312**: Unsigned services are now blocked from launching on Intel Macs.
### The Solution
Use a **self-signed code signing certificate** (`skhd-cert`) with a stable bundle identifier (`com.jackielii.skhd`), AND wrap the binary in an `.app` bundle. The combination produces TCC entries keyed by bundle ID that survive both rebuilds and Homebrew version upgrades.
## Setting Up Code Signing
### 1. Create a Self-Signed Certificate (One-Time Setup)
`zig build sign` will try to create the cert automatically via `openssl` + `security import`. If that fails (e.g. on some keychain configurations), create it by hand in Keychain Access:
```bash
open "/Applications/Utilities/Keychain Access.app"
```
Then:
1. Menu: **Keychain Access** → **Certificate Assistant** → **Create a Certificate**
2. Name: `skhd-cert`
3. Identity Type: **Self-Signed Root**
4. Certificate Type: **Code Signing**
5. Click **Create**
### 2. Build, Bundle, and Sign
```bash
# Build the bare binary (development iteration)
zig build
# Build skhd.app + sign both the inner Mach-O and the bundle
zig build sign-app
# Equivalent to:
zig build app # produces zig-out/skhd.app
./scripts/codesign.sh zig-out/skhd.app # signs both layers
```
### 3. Grant Accessibility Permissions
1. Move or symlink the bundle into `/Applications` (Tahoe's picker prefers paths there):
```bash
ln -sfn "$(pwd)/zig-out/skhd.app" /Applications/skhd.app
```
2. Open: **System Settings** → **Privacy & Security** → **Accessibility**
3. Enable the checkbox next to `skhd`
4. Restart skhd
### 4. Done!
Permissions will now **persist across rebuilds** as long as you sign each new build with the same certificate.
## Local Debug Workflow (`zig build run`)
`zig build run` does not run the bare binary at `zig-out/bin/skhd` — on Tahoe, an adhoc-signed bare binary cannot be granted accessibility. Instead it builds and signs a **separate dev bundle** so debug runs have their own TCC slot:
| | Path | Bundle ID | Cert |
|---|---|---|---|
| Prod (`sign-app`) | `zig-out/skhd.app` | `com.jackielii.skhd` | `skhd-cert` |
| Dev (`run`) | `zig-out/skhd-dev.app` | `com.jackielii.skhd.dev` | `skhd-dev-cert` |
The dev cert is auto-created on first `zig build run`. One-time setup: add `zig-out/skhd-dev.app` in **System Settings → Privacy & Security → Accessibility** and toggle it on. Permissions persist across rebuilds because every `zig build run` re-signs with the same `skhd-dev-cert`.
The two bundles never overlap, so debugging never disturbs the prod TCC entry that the Homebrew-installed daemon relies on. If you want to debug without the prod daemon also receiving keypresses, stop it first: `skhd --stop-service`.
To override the dev cert/bundle ID, set `SKHD_CERT` and `SKHD_BUNDLE_ID` before invoking `scripts/codesign.sh` directly.
## Verifying Code Signature
```bash
codesign -dv --verbose=2 ./zig-out/skhd.app
```
Expected output with proper signing:
```
Executable=/path/to/zig-out/skhd.app/Contents/MacOS/skhd
Identifier=com.jackielii.skhd
Format=app bundle with Mach-O thin (arm64)
Authority=skhd-cert
Signed Time=...
TeamIdentifier=not set
Sealed Resources version=2 ...
```
Key things to confirm:
- `Format=app bundle with Mach-O thin (arm64)` — proves the bundle layer is signed, not just the inner binary
- `Authority=skhd-cert` (not "adhoc")
- `Identifier=com.jackielii.skhd` (stable bundle ID)
You can also verify TCC has bundle-ID-keyed entries (rather than path-keyed):
```bash
sudo sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" \
"SELECT service, client, client_type FROM access WHERE client='com.jackielii.skhd';"
```
Look for rows with `client_type=0` (bundle ID) — those are the entries that survive rebuilds and `brew upgrade`.
## CI/CD Compatibility
Code signing is **optional** for CI environments:
- Builds will succeed without signing
- GitHub Actions and other CI systems don't need certificates
- Local development requires signing for accessibility permissions to persist
## Homebrew Installation
For users installing via Homebrew:
1. The formula will attempt to create a certificate and sign the binary automatically
2. Users will be prompted to grant accessibility permissions once
3. Permissions will persist across Homebrew updates
## Troubleshooting
### "codesign wants to sign using key in your keychain"
This is normal - click **Always Allow** to avoid repeated prompts.
### Permissions stop working after replacing the binary in-place
If you `cp` a freshly-built binary on top of an existing path (e.g. into `/opt/homebrew/Cellar/skhd-zig/<ver>/...`), TCC may invalidate the previously-granted entry because the on-disk code signature stops matching the stored requirement (`csreq`). The entry stays in the table with `auth_value=2` but the daemon reports "not granted".
Two ways to recover:
1. **Use the .app bundle approach** (recommended): bundle-ID-keyed TCC entries (`client_type=0`) survive in-place replacements as long as the new binary keeps the same `Identifier` (`com.jackielii.skhd`) and signing certificate. This is why `zig build sign-app` is the right local workflow.
2. **Drop and re-grant**: if you have a stale path-keyed entry blocking re-add, delete it directly:
```bash
sudo sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" \
"DELETE FROM access WHERE client LIKE '%skhd%' AND client_type=1;"
```
Then restart skhd and grant fresh.
### "skhd doesn't appear in the Accessibility list"
On macOS Tahoe the Accessibility picker silently rejects bare Mach-O binaries. You need the `.app` bundle. Build with `zig build sign-app`, symlink it into `/Applications`, and add `/Applications/skhd.app` (not the inner binary) in System Settings.
### `--status` says "Not granted" even though the daemon works
`AXIsProcessTrusted()` checks the *responsible* process, which for terminal-launched commands is the terminal, not skhd. The launchd-spawned daemon is the one whose permissions matter — check `launchctl list | grep com.jackielii.skhd` for a non-zero PID, and the log at `~/Library/Logs/skhd.log` for `Event tap created successfully`.
### Permissions still reset after signing
1. Verify the signature: `codesign -dv --verbose=2 ./zig-out/skhd.app`
2. Check that `Authority=skhd-cert` (not "adhoc")
3. Check that `Format=app bundle with Mach-O thin (...)` — bundle layer is signed
4. Check that `Identifier=com.jackielii.skhd` is present
5. Remove old accessibility entries (especially path-keyed ones) and re-add
6. Ensure you're signing with the same certificate each time
### Certificate not found when running `zig build sign`
1. Verify certificate exists: `security find-identity -v -p codesigning`
2. If not found, create it manually using Keychain Access (see step 1 above)
3. The script will provide detailed instructions if the certificate is missing
## References
- [Issue #15: Accessibility permission fails on macOS 26](https://github.com/jackielii/skhd.zig/issues/15)
- [Apple TN2206: macOS Code Signing In Depth](https://developer.apple.com/library/archive/technotes/tn2206/)
- [macOS 26 (Tahoe) Release Notes](https://developer.apple.com/documentation/macos-release-notes/macos-26-release-notes)
================================================
FILE: docs/PLAN_ADVANCED_FEATURES.md
================================================
# Advanced Features Implementation Plan for skhd.zig
## Executive Summary
This document outlines the plan to implement advanced Karabiner-Elements features in skhd.zig, specifically:
1. Device-specific hotkey filtering (e.g., different behavior for built-in keyboard vs external HHKB)
2. Dual-function keys with `to_if_alone` functionality (e.g., Caps Lock → Escape when tapped, Control when held)
## Feature 1: Device Filtering
### How Karabiner-Elements Implements Device Filtering
Based on research of the Karabiner-Elements codebase:
1. **Device Identification**:
- Uses vendor_id and product_id to identify devices
- Maintains a device_properties_manager that tracks all connected devices
- Device information is queried from IOKit
2. **Condition System**:
- Four condition types: `device_if`, `device_unless`, `device_exists_if`, `device_exists_unless`
- Conditions are evaluated before executing manipulators
- Located in `src/share/manipulator/conditions/device.hpp`
3. **Configuration Format**:
```json
"conditions": [{
"type": "device_if",
"identifiers": [{
"vendor_id": 1452,
"product_id": 834,
"description": "Apple Internal Keyboard"
}]
}]
```
### Proposed skhd.zig Implementation
1. **Add Device Detection**:
- Create a new `DeviceManager.zig` module
- Use IOKit APIs to enumerate HID devices
- Track vendor_id, product_id for each device
2. **Extend Configuration Syntax**:
```
# Device-specific binding
ctrl - h [device:1452,834] : echo "Built-in keyboard"
ctrl - h [device:1278,33] : echo "HHKB keyboard"
```
3. **Modify Parser**:
- Add device condition parsing in `Parser.zig`
- Store device conditions in `Hotkey` structure
4. **Event Processing**:
- In `EventTap.zig`, identify source device for each event
- Match against device conditions before executing commands
## Feature 2: to_if_alone (Dual-Function Keys)
### How Karabiner-Elements Implements to_if_alone
Based on analysis of `src/share/manipulator/manipulators/basic/`:
1. **State Tracking**:
- `manipulated_original_event` tracks "alone" state
- Records key down timestamp
- `alone_` flag set to true on key down
2. **Alone State Interruption**:
- Flag set to false when:
- Another key is pressed
- Mouse wheel is scrolled
- Handled by `unset_alone_if_needed()` method
3. **Timeout Logic**:
- Default timeout: 1000ms (configurable)
- Stored in `basic_to_if_alone_timeout_milliseconds`
4. **Event Processing**:
- Key down: Send normal `to` events
- Key up (if alone and within timeout): Send `to_if_alone` events
### Proposed skhd.zig Implementation
1. **Configuration Syntax**:
```
# Caps Lock → Escape (tap) / Control (hold)
caps_lock : ctrl
caps_lock [alone] : escape
# Alternative syntax
caps_lock -> ctrl | escape
```
2. **State Management**:
- Create `DualFunctionKeyManager.zig`
- Track key press timestamps
- Monitor for interrupting events
3. **Integration Points**:
- Modify `EventTap.zig` to track alone state
- Add timeout handling (use dispatch timers)
- Inject synthetic events for alone actions
## Architecture Comparison: Virtual Driver vs Event Tap
### Karabiner-Elements: Virtual HID Driver Approach
**Pros**:
- Complete control over event flow
- Can suppress original events reliably
- Lower-level access allows complex manipulations
- Better for system-wide modifications
- Can handle all input types (keyboard, mouse, etc.)
**Cons**:
- Requires kernel extension (security implications)
- More complex installation/permissions
- Higher development complexity
- Potential system stability risks
**Implementation**:
- Uses `pqrs::karabiner::driverkit::virtual_hid_device`
- Intercepts events at driver level
- Posts modified events to virtual device
### skhd: Event Tap Approach
**Pros**:
- Simpler implementation
- No kernel extensions required
- Easier to debug and maintain
- Less invasive to system
- Good enough for most hotkey use cases
**Cons**:
- Limited to CGEventTap capabilities
- Can't suppress all events reliably
- Higher latency than driver approach
- Some edge cases with event ordering
**Current Implementation**:
- Uses CGEventTapCreate
- Processes events at user-space level
- Limited to keyboard events
### Recommendation
For skhd.zig, continue with the Event Tap approach because:
1. Maintains simplicity and compatibility with original skhd
2. Sufficient for hotkey daemon functionality
3. Avoids kernel extension complexity
4. Device filtering and to_if_alone can be implemented with event taps
However, we need to enhance the current implementation:
- Add mouse event monitoring for alone state interruption
- Implement proper event suppression for dual-function keys
- Add timing mechanisms for alone detection
## Implementation Roadmap
### Phase 1: Device Filtering (Foundation)
1. Create DeviceManager module
2. Implement IOKit device enumeration
3. Add device tracking to EventTap
4. Extend Parser for device conditions
5. Update Hotkey structure
6. Add device matching logic
7. Write comprehensive tests
### Phase 2: Basic to_if_alone
1. Create DualFunctionKeyManager
2. Add state tracking for key presses
3. Implement timeout handling
4. Add alone state interruption logic
5. Integrate with EventTap
6. Test with simple use cases
### Phase 3: Advanced Features
1. Add configuration for timeout values
2. Support multiple alone actions
3. Add to_if_held_down support
4. Optimize performance
5. Handle edge cases
### Phase 4: Testing & Polish
1. Comprehensive test suite
2. Performance benchmarking
3. Documentation updates
4. Example configurations
## Open Questions
1. **Configuration Syntax**: Should we maintain compatibility with skhd syntax or adopt Karabiner-style JSON?
- Proposal: Extend skhd syntax to maintain backwards compatibility
2. **Event Suppression**: How to reliably suppress original events in dual-function scenarios?
- May need to explore CGEventTapProxy options
3. **Mouse Integration**: Should we monitor mouse events for alone interruption?
- Yes, for feature parity with Karabiner
4. **Performance**: Will state tracking impact hotkey responsiveness?
- Need benchmarking, but likely minimal impact
5. **Persistence**: Should device configurations persist across disconnections?
- Yes, match devices by vendor/product ID
## Next Steps
1. Review and approve this plan
2. Begin Phase 1 implementation with DeviceManager
3. Create test harness for device simulation
4. Iterate based on testing results
================================================
FILE: docs/PLAN_GRABBER.md
================================================
# `skhd-grabber` — system daemon for caps_lock-class tap-hold
Hybrid (Option D) plan to support `.remap caps_lock { … }` and other
sources where macOS's HID layer prevents the user-agent path from
working. Layered on top of the existing user-agent skhd, opt-in.
## Why two binaries
macOS's `IOHIDDeviceOpen(kIOHIDOptionsTypeSeizeDevice)` requires root,
and the Karabiner DriverKit `vhidd_server` daemon refuses non-root
clients. Tap-hold for caps_lock therefore can't live in the per-user
user-agent. But also: users who don't need caps_lock tap-hold should
not pay any cost (no system daemon, no system extension on their
machine, no root processes). Hence the split:
- **`skhd`** — per-user agent (today's model). Handles all
CGEventTap-based features: regular hotkeys, modes, process lists,
`.device` matching, `.remap` colon-form for non-caps targets via
hidutil. **Unchanged install path**, runs as the user.
- **`skhd-grabber`** — system daemon, root. Owns the seize on
configured devices, runs the tap-hold state machine on the seized
HID stream, injects results through Karabiner's virtual HID device.
**Opt-in** via `skhd --install-grabber`.
Communication: the user-agent talks to the grabber through a Unix
domain socket when (and only when) the user's config contains a
caps-class `.remap {}` rule.
## Architecture
```
┌─ User A session ──────────────┐ ┌─ User B session ─────────────┐
│ skhd (user agent, today) │ │ skhd (user agent, today) │
│ • CGEventTap │ │ • CGEventTap │
│ • [device guard] │ │ • [device guard] │
│ • non-caps .remap (hidutil) │ │ • non-caps .remap (hidutil) │
│ • regular hotkeys │ │ • regular hotkeys │
└────────────┬──────────────────┘ └────────────┬─────────────────┘
│ Unix socket only when │
│ config has caps .remap{} │ (no socket — no caps rule)
▼
┌──────────────────────────────────────────────────────────────────┐
│ skhd-grabber (system daemon, root, optional) │
│ • Listens on /var/run/skhd/grabber.sock │
│ • Tracks console user via SCDynamicStoreCopyConsoleUser │
│ • Per-user rule sets (only active user's apply) │
│ • IOHIDDeviceOpen(seize) on matched devices │
│ • TapHoldMachine on seized HID stream │
│ • Injects via Karabiner vhidd Unix socket │
└─────────────┬────────────────────────────────────┬───────────────┘
▼ ▼
Real keyboards Karabiner-DriverKit-VirtualHIDDevice
(seized; kernel sees (already-installed signed dext;
nothing while held) we are a client)
```
## What's reused vs. new
**Reused (no churn):**
- All existing user-agent code: `Parser.zig`, `Tokenizer.zig`,
`Hotkey.zig`, `Mappings.zig`, `EventTap.zig`, `Mode.zig`,
`CarbonEvent.zig`, `Hidutil.zig`, `HidMonitor.zig`.
- `.device`, `.remap` (colon and block) parsing.
- The `TapHoldMachine` design — but it'll need a refactor to
accept HID events instead of CGEvents (the abstraction is small
enough that one struct can cover both).
**New code:**
- `src/grabber/` — new binary's sources.
- `main.zig` — daemon entry, launchd integration.
- `Vhidd.zig` — Karabiner virtual-HID-device client (Unix socket
protocol to `vhidd_server`).
- `Seize.zig` — `IOHIDDeviceOpen(seize)` per device, value-callback
handling.
- `RuleSet.zig` — per-user rules, switched on console-user change.
- `Ipc.zig` — Unix socket server for the user-agent IPC.
- `src/agent_grabber_client.zig` — IPC client used by the user-agent
to push rules to the grabber when configured.
- New CLI: `skhd --install-grabber`, `--uninstall-grabber`,
`--grabber-status`.
**Modified:**
- `Mappings.zig` — partition `tapholds` into "caps-class"
(handled by grabber) and "non-caps" (handled by user-agent's
CGEventTap). The user-agent forwards the caps-class set to grabber.
- `skhd.zig` (user-agent) — at startup, if `mappings.tapholds` has
any caps-class entries, dial the grabber socket; on parse-reload,
resend.
## Key design decisions
### 1. Where does config live?
Agent owns config. Per-user `~/.config/skhd/skhdrc` parsed by the
user-agent; the agent ships the parsed caps-class subset to the
grabber over the socket. Grabber is stateless re: content — it
holds whatever the agent gave it for the current console user.
Rationale: preserves per-user separation. Grabber doesn't read user
files (avoids privilege boundary issues). Each user's
caps-class rules only ever apply during their session.
### 2. Console-user tracking
Grabber subscribes to `kSCDynamicStoreDomainState/Console User` via
`SCDynamicStoreNotificationCallBack`. On change:
- Apply the new console user's rule set (if any agent is connected
for that uid).
- Pause the previous user's rules.
- If no active rule set, release seize on all devices.
Fast user switching: ~hundreds of ms gap during which the keyboard
behaves natively. Acceptable.
### 3. When does grabber seize?
Only when there's at least one caps-class rule for the active
console user. No active rules → no seize → keyboard fully native.
Adding a rule (config reload by agent) → grabber re-evaluates and
seizes if needed.
### 4. Coexistence with Karabiner-Elements
Both share the same Karabiner DriverKit dext. Karabiner-Elements
also seizes devices. Conflict on a given device: first seizer wins,
second gets `kIOReturnExclusiveAccess`. Detect on grabber startup
and log a clear warning ("Karabiner-Elements is seizing this device
— skhd's caps tap-hold won't apply to it").
### 5. What happens on grabber crash
`IOHIDDeviceOpen` reclaim on process death is the kernel's job.
launchd respawns. Agent's socket connection drops; agent retries
with backoff. ~1–3s of native keyboard behaviour, then back online.
### 6. What if user installs grabber but vhidd dext isn't installed
Grabber checks at startup via `systemextensionsctl list` (or by
attempting socket connect to `vhidd_server`). On failure: log a
clear error pointing at pqrs.org's installer URL, refuse to start.
launchd will keep retrying — when user installs the dext and
reboots, grabber comes up.
### 7. What if user-agent has caps rule but grabber isn't installed
Agent's socket connection fails. Log a `warn`-level diagnostic
("caps_lock tap-hold rule found but skhd-grabber is not installed
or running. Run `skhd --install-grabber` to enable.") and continue
without caps support. Other rules still work.
### 8. Out of scope for D
- Multiple simultaneously-active users (Sharing, Caching) — only
the console user's rules apply.
- Phase 4 layer holds (`hold: fn_layer`) — keep deferred to its
own phase. Once the grabber pipeline exists, layer holds slot in
on top of it.
- Auto-install of vhidd dext (we ask user to install pqrs.org's
signed pkg manually; skhd points at the URL).
## IPC protocol
Length-prefixed JSON messages over `/var/run/skhd/grabber.sock`
(grabber-side socket, mode 0666, ACL'd to local console users).
**Agent → grabber:**
```json
{"type": "hello", "uid": 501, "version": 1}
{"type": "apply_rules", "rules": [
{"src_usage": 0x39, "tap_usage": 0x29, "hold_usage": 0xE0,
"device": {"vendor": 0x05AC, "product": 0x0342},
"timeout_ms": 120, "permissive_hold": true,
"hold_on_other_key_press": false, "retro_tap": false}
]}
{"type": "bye"}
```
**Grabber → agent:**
```json
{"type": "ok"}
{"type": "error", "code": "vhidd_not_installed", "message": "..."}
{"type": "warn", "code": "device_seized_by_other", "device": "0x05AC:0x0342"}
```
## Install / uninstall flow
`skhd --install-grabber`:
1. Check `systemextensionsctl list` for `org.pqrs.Karabiner-DriverKit-VirtualHIDDevice`. If absent, print install link, abort.
2. Sudo-escalate (or instruct user to re-run with sudo).
3. Copy `skhd-grabber` binary to `/usr/local/libexec/skhd-grabber`.
4. Write `/Library/LaunchDaemons/com.jackielii.skhd.grabber.plist`
with `RunAtLoad=true`, `KeepAlive=true`, `ProcessType=Interactive`.
5. `launchctl bootstrap system /Library/LaunchDaemons/...`.
6. Verify daemon is running and reachable on the socket.
`skhd --uninstall-grabber`:
1. `launchctl bootout system /Library/LaunchDaemons/...`.
2. Remove plist and binary.
3. (User may also want to uninstall pqrs.org dext separately.)
## Phasing & estimates
### D1 — grabber skeleton (2–3 days)
- New binary `src/grabber/main.zig` builds & runs.
- Unix socket server with hello/apply_rules/bye protocol.
- launchd plist + install/uninstall scripts.
- Agent stub: detects caps rule in config, dials socket, sends
apply_rules, gets `ok` back. No actual HID work yet.
### D2 — Karabiner vhidd client (2–3 days)
- Connect to `/Library/Application Support/org.pqrs/tmp/rootonly/vhidd_server/*.sock`.
- Implement the small protocol surface needed for keyboard injection
(Karabiner publishes the protocol — we'd port it from their C++
client lib to Zig).
- Test injection: send a simulated `escape` keydown/up, verify it
shows up in the focused app.
### D3 — HID seize (2–3 days)
- `IOHIDDeviceOpen(kIOHIDOptionsTypeSeizeDevice)` on the (vendor,
product) pairs supplied by the active rule set.
- Input value callback receiving raw HID events from seized device.
- Pass-through events not matched by any rule (synthesize identical
events through vhidd so the user can keep typing while we hold the
seize).
### D4 — TapHoldMachine on seized stream (2–3 days)
- Refactor `TapHoldMachine` to accept HID events directly (it
currently takes CGEvents). Both call sites can use the same state
machine — only event types differ.
- caps_lock specifically: source key arrives as raw HID 0x39 from
the seized device; tap action emits HID 0x29 (escape) via vhidd;
hold action emits HID 0xE0 (lctrl). No more F18 proxy.
- All four QMK knobs (timeout / permissive_hold /
hold_on_other_key_press / retro_tap) work as before.
### D5 — per-user lifecycle (2–3 days)
- `SCDynamicStoreCopyConsoleUser` polling or notification.
- Switch active rule set on console-user change.
- Release / acquire seize as needed.
- IPC: track per-uid client connections.
### D6 — polish (1–2 days)
- `skhd --install-grabber`, `--uninstall-grabber`,
`--grabber-status`.
- Failure paths: dext missing, vhidd_server down, seize race with
Karabiner-Elements.
- README docs + clear startup messages from the user-agent when
grabber is needed but not running.
**Total: ~2.5 weeks of focused work.**
## Risk register
- **Karabiner DriverKit protocol changes**: their client lib gets
versioned releases. Pin to a known-working version, document in
README, update when needed.
- **Apple changes DriverKit policies**: low likelihood given
Karabiner's track record on Apple Silicon Tahoe, but if Apple
tightens further, the entire approach (and Karabiner) is at risk.
Mitigation: keep a fallback to the F18-proxy / right_alt path so
users have *some* tap-hold even if the dext stops working.
- **vhidd_server crashes**: launchd respawns; we reconnect with
backoff. ~1–3s gap per crash.
- **User installs grabber, then uninstalls vhidd dext**: grabber
fails to start. Loud error message; uninstall instructions in
README.
## What to commit incrementally
Each Dn ends in a runnable state:
- After D1: socket plumbing, install scripts work, end-to-end
rule-pass-through is testable (no actual injection).
- After D2: prove vhidd injection works with a hard-coded escape
stream.
- After D3: prove seize works (verify seized keyboard is "dead" to
other apps, all events flow only to grabber).
- After D4: end-to-end caps_lock tap-hold for the active user.
- After D5: multi-user behaviour matches design.
- After D6: install instructions + docs are user-ready.
================================================
FILE: docs/UPGRADING.md
================================================
# Upgrading to skhd.zig 0.0.21 (macOS Tahoe compatibility)
> **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 to `SMAppService`, 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**.
## Migrating from 0.0.20 → 0.0.21
```bash
brew upgrade skhd-zig
# Re-register via SMAppService. Run this from inside the .app — SMAppService
# binds to the calling bundle path, and /Applications/skhd.app is what BTM
# accepts cleanly:
/Applications/skhd.app/Contents/MacOS/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.
## If keys stop working after `brew upgrade` (macOS Tahoe)
On 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 --status` shows the daemon running, but hotkeys don't fire.
- System Settings → Privacy & Security → Accessibility (and/or Input Monitoring) shows skhd as enabled.
- `~/Library/Logs/skhd.log` shows the event tap was created but no key activity.
Fix — drop the stale grant so macOS re-prompts and stores a fresh, cert-root-anchored csreq:
```bash
tccutil reset ListenEvent com.jackielii.skhd
tccutil reset Accessibility com.jackielii.skhd
skhd --restart-service
```
Then re-toggle the entry in System Settings (or accept the prompt if one appears). 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.
## Migrating from 0.0.17 or earlier (the original Tahoe rework)
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.
## What changed and why
| 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.
## Required actions
These steps assume you installed the previous version via Homebrew. The order matters.
### 1. Stop the old service
```bash
skhd --stop-service
```
### 2. Upgrade
```bash
brew upgrade jackielii/tap/skhd-zig
```
The new formula installs `skhd.app` to `<prefix>/skhd.app`, symlinks the CLI into `bin/skhd`, and creates `/Applications/skhd.app` so macOS Settings can find it.
### 3. Clear the legacy disable flag (if you ever ran the old `--stop-service`)
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:
```bash
launchctl print-disabled gui/$(id -u) | grep com.jackielii.skhd
# Expect: "com.jackielii.skhd" => enabled
```
If it shows `disabled`, run:
```bash
launchctl enable gui/$(id -u)/com.jackielii.skhd
```
### 4. Drop stale TCC entries from previous installs
The path-keyed accessibility entries from previous Cellar versions will silently shadow the new bundle-ID entry until removed:
```bash
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.
### 5. Install the new LaunchAgent plist
```bash
skhd --install-service
```
The plist now points at `/opt/homebrew/opt/skhd-zig/skhd.app/Contents/MacOS/skhd` (stable across `brew upgrade`).
### 6. Grant Accessibility for `skhd.app`
1. Open **System Settings → Privacy & Security → Accessibility**
2. Click `+`, navigate to `/Applications/skhd.app`, add it
3. Toggle the entry on
You will only need to do this once. The bundle-ID-keyed TCC entry now persists across rebuilds and Homebrew upgrades.
### 7. Start
```bash
skhd --start-service
```
Watch 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
```
## Notes for source builds
If you build from source rather than installing via Homebrew:
```bash
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-service
```
`zig 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.
## Troubleshooting
If anything goes sideways, see [docs/CODE_SIGNING.md](CODE_SIGNING.md) — the troubleshooting section there covers stale TCC entries, picker rejections, the misleading `--status` output, and signing problems.
================================================
FILE: docs/command-definitions.md
================================================
# Command Definitions with .define
This document describes the command definition feature that allows reducing repetition in skhd configuration files.
## Overview
The `.define` directive has been extended to support command definitions with positional placeholders. This allows you to define reusable command templates that can be referenced throughout your configuration.
## Syntax
### Simple Command Definition (No Placeholders)
Define a command without any parameters:
```
.define focus_recent : yabai -m window --focus recent || yabai -m space --focus recent
```
Use it in a hotkey:
```
cmd - tab : @focus_recent
```
### Template Command Definition (With Placeholders)
Define a command template with positional placeholders using `{{n}}` syntax:
```
.define yabai_focus : yabai -m window --focus {{1}} || yabai -m display --focus {{1}}
.define window_action : yabai -m window --{{1}} {{2}} || yabai -m display --{{1}} {{2}}
```
Use it with arguments in double quotes:
```
lcmd - h : @yabai_focus("west")
lcmd - j : @yabai_focus("south")
cmd + shift - h : @window_action("swap", "west")
cmd + shift - j : @window_action("swap", "south")
```
## Rules
1. **Placeholder Numbering**: Placeholders must be numbered starting from 1 (e.g., `{{1}}`, `{{2}}`, etc.)
2. **Argument Quoting**: Arguments must be enclosed in double quotes when calling a command
3. **Argument Count**: The number of arguments must match the highest placeholder number in the template
4. **Multiple Occurrences**: The same placeholder can appear multiple times in a template
5. **Escape Sequences**: Within quoted arguments, use `\"` to include a literal double quote
## Examples
### Window Management
```
# Define reusable yabai commands
.define yabai_focus : yabai -m window --focus {{1}} || yabai -m display --focus {{1}}
.define yabai_move : yabai -m window --swap {{1}} || ( yabai -m window --display {{1}} ; yabai -m display --focus {{1}} )
.define yabai_space : yabai -m window --space {{1}}
# Use in hotkeys
lcmd - h : @yabai_focus("west")
lcmd - l : @yabai_focus("east")
cmd + shift - h : @yabai_move("west")
cmd + shift - 1 : @yabai_space("1")
cmd + shift - 2 : @yabai_space("2")
```
### Application Toggling
```
# Define app toggle command
.define toggle_app : yabai -m window --toggle {{1}} || open -a "{{1}}"
# Use for different applications
ralt - m : @toggle_app("YT Music")
ralt - n : @toggle_app("Notes")
ralt - t : @toggle_app("Microsoft Teams")
```
### Window Resizing
```
# Define resize command with multiple parameters
.define resize_win : yabai -m window --resize {{1}}:{{2}}:{{3}}
# Use with different resize operations
cmd + ctrl + shift - k : @resize_win("top", "0", "-10")
cmd + ctrl + shift - j : @resize_win("bottom", "0", "10")
cmd + ctrl + shift - h : @resize_win("left", "-10", "0")
cmd + ctrl + shift - l : @resize_win("right", "10", "0")
```
### Complex Commands
```
# Define notification command
.define notify : osascript -e 'display notification "{{2}}" with title "{{1}}"'
# Use with different messages
cmd - n : @notify("Reminder", "Time for a break!")
cmd - m : @notify("Meeting", "Team standup in 5 minutes")
```
## Error Messages
- **Undefined Command**: `"@unknown_cmd not defined"`
- **Argument Mismatch**: `"@cmd expects 2 arguments, got 1"`
- **Missing Arguments**: `"@cmd requires arguments but none provided"`
- **No Arguments Expected**: `"@cmd expects no arguments"`
## Disambiguation from Process Groups
The `.define` directive distinguishes between process groups and commands by syntax:
- **Process Groups**: `.define name ["app1", "app2"]` (uses array syntax)
- **Commands**: `.define name : command text` (uses colon syntax)
This ensures backward compatibility with existing process group definitions.
================================================
FILE: scripts/codesign.sh
================================================
#!/bin/bash
set -e
# Configuration
TARGET_PATH="${1:-./zig-out/bin/skhd}"
CERT_NAME="${SKHD_CERT:-skhd-cert}"
BUNDLE_ID="${SKHD_BUNDLE_ID:-com.jackielii.skhd}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo "Code signing skhd..."
# Resolve target: accept either a bare Mach-O binary or a .app bundle.
# For .app bundles we read CFBundleExecutable from Info.plist so the
# script works for both skhd.app and skhd-grabber.app (different inner
# binary names).
if [ -d "$TARGET_PATH" ] && [[ "$TARGET_PATH" == *.app ]]; then
APP_PATH="$TARGET_PATH"
EXEC_NAME=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$APP_PATH/Contents/Info.plist" 2>/dev/null || echo "skhd")
INNER_BINARY="$APP_PATH/Contents/MacOS/$EXEC_NAME"
if [ ! -f "$INNER_BINARY" ]; then
echo -e "${RED}Error: $APP_PATH does not contain Contents/MacOS/$EXEC_NAME${NC}"
exit 1
fi
elif [ -f "$TARGET_PATH" ]; then
APP_PATH=""
INNER_BINARY="$TARGET_PATH"
else
echo -e "${RED}Error: $TARGET_PATH not found (expected a binary or a .app bundle)${NC}"
echo "Build the project first: zig build (or zig build app)"
exit 1
fi
# Check if a certificate with this CN exists in the default keychain
# search list. `security find-certificate -c <name>` (no explicit path)
# walks the user's default search list, which covers both local dev
# (login.keychain) and CI's temporary keychain swapped in via
# `security list-keychain -d user -s ...`. We deliberately don't filter
# by codeSigning EKU here: the user's CI cert is a self-signed import
# that codesign accepts but find-identity -p codesigning rejects (no
# EKU in the cert), so the EKU filter would false-negative in CI.
if ! security find-certificate -c "$CERT_NAME" >/dev/null 2>&1; then
if [ -n "$SKHD_NO_AUTO_GENERATE_CERT" ]; then
echo -e "${RED}Certificate '$CERT_NAME' not found in any keychain.${NC}"
echo "SKHD_NO_AUTO_GENERATE_CERT is set — refusing to generate a"
echo "fresh local cert (would diverge from the trust chain the"
echo "caller expects). Import the cert before running this script."
exit 1
fi
echo -e "${YELLOW}Certificate '$CERT_NAME' not found.${NC}"
echo "Creating self-signed code signing certificate..."
echo ""
TEMP_DIR=$(mktemp -d)
trap 'rm -rf "$TEMP_DIR"' EXIT
TEMP_KEY="$TEMP_DIR/key.pem"
TEMP_CERT="$TEMP_DIR/cert.pem"
TEMP_P12="$TEMP_DIR/cert.p12"
TEMP_CONFIG="$TEMP_DIR/openssl.cnf"
# Generate openssl config that marks the cert as critical for codeSigning EKU.
# Without the codeSigning EKU, `security find-identity -p codesigning` filters
# the cert out and codesign cannot use it.
cat > "$TEMP_CONFIG" <<EOF
[req]
distinguished_name = req_dn
prompt = no
x509_extensions = v3_ca
[req_dn]
CN = $CERT_NAME
O = skhd Development
C = US
[v3_ca]
basicConstraints = critical,CA:false
keyUsage = critical,digitalSignature
extendedKeyUsage = critical,codeSigning
EOF
openssl genrsa -out "$TEMP_KEY" 2048 2>/dev/null
openssl req -new -x509 -key "$TEMP_KEY" -out "$TEMP_CERT" -days 3650 \
-config "$TEMP_CONFIG" 2>/dev/null
# macOS `security import` rejects empty-password p12 files produced by
# OpenSSL 3+ ("MAC verification failed during PKCS12 import"). Use a
# throwaway password and pass it to both export and import. The cert
# itself isn't password-protected once in the keychain.
P12_PASS="skhd-cert-import"
# OpenSSL 3+ uses a stronger PKCS12 MAC by default that older `security`
# tools can't read. -legacy falls back to the algorithm macOS understands.
openssl pkcs12 -export -legacy -out "$TEMP_P12" -inkey "$TEMP_KEY" -in "$TEMP_CERT" \
-passout "pass:$P12_PASS" 2>/dev/null
if security import "$TEMP_P12" -k ~/Library/Keychains/login.keychain-db -P "$P12_PASS" \
-T /usr/bin/codesign -T /usr/bin/security >/dev/null 2>&1; then
echo -e "${GREEN}✓ Certificate created successfully${NC}"
# Allow codesign to use the key without prompting on every invocation.
security set-key-partition-list -S apple-tool:,apple: -k "" \
~/Library/Keychains/login.keychain-db >/dev/null 2>&1 || true
else
echo -e "${RED}Failed to import certificate programmatically.${NC}"
echo ""
echo -e "${YELLOW}Please create a code signing certificate manually:${NC}"
echo "1. Open Keychain Access (in /Applications/Utilities/)"
echo "2. Go to: Keychain Access > Certificate Assistant > Create a Certificate"
echo "3. Name: $CERT_NAME"
echo "4. Identity Type: Self-Signed Root"
echo "5. Certificate Type: Code Signing"
echo "6. Click 'Create'"
echo ""
echo "After creating the certificate, run this script again."
exit 1
fi
echo ""
fi
if [ -n "$APP_PATH" ]; then
# Sign helpers first, principal last: codesign'ing a bundle's
# principal Mach-O (the file pointed at by CFBundleExecutable) walks
# the bundle to compute the resource seal and rejects the operation
# if any sibling Mach-O in Contents/MacOS/ is still unsigned, with
# "code object is not signed at all / In subcomponent: <helper>".
# Signing skhd-grabber first lets the principal seal succeed cleanly.
# The trailing bundle-layer codesign re-seals the wrapper for safety.
if [ -f "$APP_PATH/Contents/MacOS/skhd-grabber" ] && \
[ "$INNER_BINARY" != "$APP_PATH/Contents/MacOS/skhd-grabber" ]; then
echo "Signing helper: $APP_PATH/Contents/MacOS/skhd-grabber"
codesign -f -s "$CERT_NAME" -i "$BUNDLE_ID" \
"$APP_PATH/Contents/MacOS/skhd-grabber"
fi
echo "Signing inner binary: $INNER_BINARY"
codesign -f -s "$CERT_NAME" -i "$BUNDLE_ID" "$INNER_BINARY"
echo "Signing bundle: $APP_PATH"
codesign -f -s "$CERT_NAME" -i "$BUNDLE_ID" "$APP_PATH"
VERIFY_TARGET="$APP_PATH"
else
echo "Signing binary: $INNER_BINARY"
codesign -f -s "$CERT_NAME" -i "$BUNDLE_ID" "$INNER_BINARY"
VERIFY_TARGET="$INNER_BINARY"
fi
if codesign -v "$VERIFY_TARGET" 2>/dev/null; then
echo -e "${GREEN}✓ Successfully signed $VERIFY_TARGET${NC}"
echo ""
echo "Signature details:"
codesign -dv --verbose=2 "$VERIFY_TARGET" 2>&1 | grep -E "Authority|Identifier|Signature|Format"
else
echo -e "${RED}✗ Signature verification failed${NC}"
exit 1
fi
echo ""
echo -e "${GREEN}Code signing complete!${NC}"
if [ -n "$APP_PATH" ]; then
echo "The bundle is now signed with certificate '$CERT_NAME'"
echo ""
echo "Next steps:"
echo "1. Add $APP_PATH in System Settings → Privacy & Security → Accessibility"
echo "2. Toggle the entry on"
echo "3. Run: skhd --install-service && skhd --start-service"
else
echo "The binary is now signed with certificate '$CERT_NAME'"
echo ""
echo "Next steps:"
echo "1. Run skhd: $INNER_BINARY"
echo "2. Grant accessibility permissions in System Settings → Privacy & Security → Accessibility"
echo "3. The permissions should persist across rebuilds now"
fi
================================================
FILE: scripts/com.jackielii.skhd.grabber.plist
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!--
Template launchd plist for skhd-grabber.
Installed at /Library/LaunchDaemons/com.jackielii.skhd.grabber.plist
by `skhd --install-grabber` (Zig in-process — no shell script needed).
This is a system daemon (NOT a LaunchAgent) — runs as root, regardless
of which user is logged in, because IOHIDDeviceOpen(seize) and the
Karabiner vhidd_server both require root.
ProgramArguments is filled in at install time with the absolute path
of `skhd-grabber` inside the running skhd's `.app` bundle. Running from
inside the bundle is what makes TCC bundle-keyed — granting Input
Monitoring to skhd.app once covers both the agent and the grabber,
since both binaries are signed with `-i com.jackielii.skhd`.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.jackielii.skhd.grabber</string>
<key>ProgramArguments</
gitextract_ldysld9j/
├── .envrc
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ └── bug_report.md
│ └── workflows/
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── CHANGELOG.md
├── CLAUDE.md
├── LICENSE
├── README.md
├── SYNTAX.md
├── TODO.md
├── VERSION
├── assets/
│ ├── Info.plist.grabber.template
│ ├── Info.plist.template
│ ├── LaunchAgent.plist
│ └── karabiner-virtualhiddevice-daemon.plist
├── build.zig
├── build.zig.zon
├── docs/
│ ├── CODE_SIGNING.md
│ ├── PLAN_ADVANCED_FEATURES.md
│ ├── PLAN_GRABBER.md
│ ├── UPGRADING.md
│ └── command-definitions.md
├── scripts/
│ ├── codesign.sh
│ ├── com.jackielii.skhd.grabber.plist
│ ├── install-local.sh
│ ├── make-app.sh
│ ├── make-grabber-app.sh
│ └── release.sh
├── src/
│ ├── CarbonEvent.zig
│ ├── DeviceCheck.zig
│ ├── EventTap.zig
│ ├── HidKeyMap.zig
│ ├── Hidutil.zig
│ ├── Hotkey.zig
│ ├── Hotload.zig
│ ├── Keycodes.zig
│ ├── Mappings.zig
│ ├── Mode.zig
│ ├── ParseError.zig
│ ├── Parser.zig
│ ├── Tokenizer.zig
│ ├── Tracer.zig
│ ├── TrackingAllocator.zig
│ ├── agent_grabber_client.zig
│ ├── agent_layer_listener.zig
│ ├── benchmark.zig
│ ├── c.zig
│ ├── echo.zig
│ ├── exec.zig
│ ├── grabber/
│ │ ├── HidSeize.zig
│ │ ├── HidSystem.zig
│ │ ├── Ipc.zig
│ │ ├── KbState.zig
│ │ ├── TapHold.zig
│ │ ├── Vhidd.zig
│ │ ├── c.zig
│ │ └── main.zig
│ ├── grabber_cli.zig
│ ├── grabber_protocol.zig
│ ├── main.zig
│ ├── service.zig
│ ├── skhd.zig
│ ├── sm_app_service.zig
│ ├── synthesize.zig
│ ├── tests.zig
│ └── utils.zig
├── taphold_test.skhdrc
└── testdata/
├── example_process_groups.skhdrc
├── hotload_test.skhdrc
├── loader.skhdrc
├── parse_errors.skhdrc
├── reload_test.skhdrc
├── sub.skhdrc
├── test-forward.skhdrc
├── test-lr-modifiers.skhdrc
├── test-process.skhdrc
├── test-shell.skhdrc
├── test-skhdrc
├── test-synthesis.skhdrc
├── test.skhdrc
├── test_debug_match.skhdrc
├── test_forward_logging.skhdrc
├── test_home_key.skhdrc
├── test_included.skhdrc
├── test_included_mode.skhdrc
├── test_logging.skhdrc
├── test_media_key_forward.skhdrc
├── test_modifier_matching.skhdrc
├── test_nested1.skhdrc
└── test_nested2.skhdrc
Condensed preview — 91 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (990K chars).
[
{
"path": ".envrc",
"chars": 15,
"preview": "use zig 0.14.0\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 1573,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve skhd.zig\ntitle: ''\nlabels: bug\nassignees: ''\n\n---\n\n**Desc"
},
{
"path": ".github/workflows/ci.yml",
"chars": 3388,
"preview": "name: CI\n\non:\n push:\n branches: [ main ]\n pull_request:\n branches: [ main ]\n\njobs:\n test:\n runs-on: macos-la"
},
{
"path": ".github/workflows/release.yml",
"chars": 11039,
"preview": "name: Release\n\non:\n push:\n tags:\n - 'v*'\n\npermissions:\n contents: write\n\njobs:\n create-release:\n runs-on: "
},
{
"path": ".gitignore",
"chars": 396,
"preview": "# This file is for zig-specific build artifacts.\n# If you have OS-specific or editor-specific files to ignore,\n# such as"
},
{
"path": "CHANGELOG.md",
"chars": 37785,
"preview": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Change"
},
{
"path": "CLAUDE.md",
"chars": 7337,
"preview": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## "
},
{
"path": "LICENSE",
"chars": 1099,
"preview": "MIT License\n\nCopyright (c) 2017 Åsmund Vikane\nCopyright (c) 2025 Jackie Li\n\nPermission is hereby granted, free of charge"
},
{
"path": "README.md",
"chars": 38070,
"preview": "# SKHD in Zig\n\nSimple Hotkey Daemon for macOS, ported from [skhd](https://github.com/koekeishiya/skhd) to Zig.\n\nThis imp"
},
{
"path": "SYNTAX.md",
"chars": 18930,
"preview": "# SKHD Configuration Syntax Reference\n\nThis document provides a comprehensive reference for the skhd configuration synta"
},
{
"path": "TODO.md",
"chars": 2500,
"preview": "# TODO - Future Features and Improvements\n\nThis file tracks features and improvements that are not yet implemented but c"
},
{
"path": "VERSION",
"chars": 12,
"preview": "0.1.0-alpha\n"
},
{
"path": "assets/Info.plist.grabber.template",
"chars": 966,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "assets/Info.plist.template",
"chars": 942,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "assets/LaunchAgent.plist",
"chars": 511,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "assets/karabiner-virtualhiddevice-daemon.plist",
"chars": 1869,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n LaunchDaemon plist for Karabiner-DriverKit-VirtualHIDDevice's userland\n h"
},
{
"path": "build.zig",
"chars": 22397,
"preview": "const std = @import(\"std\");\n\nfn linkFrameworks(b: *std.Build, exe: *std.Build.Step.Compile) void {\n // Explicit os_ve"
},
{
"path": "build.zig.zon",
"chars": 2294,
"preview": ".{\n // This is the default name used by packages depending on this one. For\n // example, when a user runs `zig fet"
},
{
"path": "docs/CODE_SIGNING.md",
"chars": 8336,
"preview": "# Code Signing & .app Bundle for Accessibility Permissions\n\n## Why Both Are Required\n\nStarting with macOS 15 (Sequoia) a"
},
{
"path": "docs/PLAN_ADVANCED_FEATURES.md",
"chars": 6614,
"preview": "# Advanced Features Implementation Plan for skhd.zig\n\n## Executive Summary\n\nThis document outlines the plan to implement"
},
{
"path": "docs/PLAN_GRABBER.md",
"chars": 11927,
"preview": "# `skhd-grabber` — system daemon for caps_lock-class tap-hold\n\nHybrid (Option D) plan to support `.remap caps_lock { … }"
},
{
"path": "docs/UPGRADING.md",
"chars": 6924,
"preview": "# Upgrading to skhd.zig 0.0.21 (macOS Tahoe compatibility)\n\n> **0.0.21 fixes the actual root cause of \"skhd doesn't star"
},
{
"path": "docs/command-definitions.md",
"chars": 3763,
"preview": "# Command Definitions with .define\n\nThis document describes the command definition feature that allows reducing repetiti"
},
{
"path": "scripts/codesign.sh",
"chars": 7152,
"preview": "#!/bin/bash\nset -e\n\n# Configuration\nTARGET_PATH=\"${1:-./zig-out/bin/skhd}\"\nCERT_NAME=\"${SKHD_CERT:-skhd-cert}\"\nBUNDLE_ID"
},
{
"path": "scripts/com.jackielii.skhd.grabber.plist",
"chars": 1543,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n Template launchd plist for skhd-grabber.\n\n Installed at /Library/LaunchDa"
},
{
"path": "scripts/install-local.sh",
"chars": 3472,
"preview": "#!/bin/bash\n# Install the local skhd build into /Applications/skhd.app (the slot a brew\n# install would occupy) and rest"
},
{
"path": "scripts/make-app.sh",
"chars": 2861,
"preview": "#!/bin/bash\n# Wrap the skhd binary into a minimal .app bundle so macOS Tahoe / Sequoia\n# accept it for accessibility per"
},
{
"path": "scripts/make-grabber-app.sh",
"chars": 1713,
"preview": "#!/bin/bash\n# Wrap the skhd-grabber binary into a minimal .app bundle so macOS\n# Tahoe shows it in System Settings → Pri"
},
{
"path": "scripts/release.sh",
"chars": 11258,
"preview": "#!/bin/bash\n\n# Script to create a release and bump version for next cycle\n# Usage: ./scripts/release.sh [--bump major|mi"
},
{
"path": "src/CarbonEvent.zig",
"chars": 3088,
"preview": "const std = @import(\"std\");\nconst c = @import(\"c.zig\");\n\nconst CarbonEvent = @This();\nconst log = std.log.scoped(.carbon"
},
{
"path": "src/DeviceCheck.zig",
"chars": 4998,
"preview": "//! Quick \"is a HID device with this (vendor, product) connected?\"\n//! check, used by the agent to decide whether to for"
},
{
"path": "src/EventTap.zig",
"chars": 3612,
"preview": "const std = @import(\"std\");\nconst c = @import(\"c.zig\");\n\nhandle: c.CFMachPortRef = null,\nrunloop_source: c.CFRunLoopSour"
},
{
"path": "src/HidKeyMap.zig",
"chars": 4983,
"preview": "//! Mapping from skhd keysym names to HID Keyboard/Keypad usage codes\n//! (page 0x07). Used by `.remap` to translate con"
},
{
"path": "src/Hidutil.zig",
"chars": 10463,
"preview": "//! HID-level key remap management via the `hidutil` command-line tool.\n//!\n//! Used by the `.remap` feature: collect al"
},
{
"path": "src/Hotkey.zig",
"chars": 27633,
"preview": "const std = @import(\"std\");\nconst testing = std.testing;\nconst Hotkey = @This();\nconst Mode = @import(\"Mode.zig\");\nconst"
},
{
"path": "src/Hotload.zig",
"chars": 9485,
"preview": "const std = @import(\"std\");\nconst c = @import(\"c.zig\");\n\n/// File system event monitoring using macOS FSEvents API.\n///\n"
},
{
"path": "src/Keycodes.zig",
"chars": 20568,
"preview": "const std = @import(\"std\");\nconst c = @import(\"c.zig\");\nconst log = std.log.scoped(.keycodes);\n\nconst layout_dependent_k"
},
{
"path": "src/Mappings.zig",
"chars": 15836,
"preview": "const std = @import(\"std\");\nconst Mode = @import(\"Mode.zig\");\nconst Hotkey = @import(\"Hotkey.zig\");\nconst utils = @impor"
},
{
"path": "src/Mode.zig",
"chars": 2890,
"preview": "// struct mode\n// {\n// char *name;\n// char *command;\n// bool capture;\n// bool initialized;\n// struct"
},
{
"path": "src/ParseError.zig",
"chars": 2821,
"preview": "const std = @import(\"std\");\nconst Token = @import(\"Tokenizer.zig\").Token;\n\npub const ParseError = struct {\n allocator"
},
{
"path": "src/Parser.zig",
"chars": 123667,
"preview": "const std = @import(\"std\");\nconst c = @import(\"c.zig\");\nconst Tokenizer = @import(\"Tokenizer.zig\");\nconst Token = Tokeni"
},
{
"path": "src/Tokenizer.zig",
"chars": 23747,
"preview": "const std = @import(\"std\");\nconst print = std.debug.print;\nconst eql = std.mem.eql;\nconst unicode = std.unicode;\nconst a"
},
{
"path": "src/Tracer.zig",
"chars": 7955,
"preview": "const std = @import(\"std\");\nconst builtin = @import(\"builtin\");\nconst Tracer = @This();\n\n// Simple execution tracer for "
},
{
"path": "src/TrackingAllocator.zig",
"chars": 11175,
"preview": "const std = @import(\"std\");\nconst log = std.log.scoped(.tracking_allocator);\n\n/// TrackingAllocator - A debugging alloca"
},
{
"path": "src/agent_grabber_client.zig",
"chars": 4178,
"preview": "//! Agent-side client for the system-grabber IPC.\n//!\n//! Used by the user-agent skhd to push the caps-class subset of i"
},
{
"path": "src/agent_layer_listener.zig",
"chars": 6280,
"preview": "//! Agent-side listener for `mode_change` push messages from the\n//! grabber.\n//!\n//! After the agent finishes apply_rul"
},
{
"path": "src/benchmark.zig",
"chars": 6563,
"preview": "const std = @import(\"std\");\nconst zbench = @import(\"zbench\");\nconst Skhd = @import(\"skhd.zig\");\nconst Hotkey = @import(\""
},
{
"path": "src/c.zig",
"chars": 1980,
"preview": "// Unified C imports for the project\npub usingnamespace @cImport({\n @cInclude(\"Carbon/Carbon.h\");\n @cInclude(\"Core"
},
{
"path": "src/echo.zig",
"chars": 5970,
"preview": "const std = @import(\"std\");\nconst EventTap = @import(\"EventTap.zig\");\nconst Keycodes = @import(\"Keycodes.zig\");\n\nconst c"
},
{
"path": "src/exec.zig",
"chars": 2712,
"preview": "const c = @import(\"c.zig\");\nconst std = @import(\"std\");\n\n/// Fork and exec a command, detaching it from the parent proce"
},
{
"path": "src/grabber/HidSeize.zig",
"chars": 12057,
"preview": "//! IOHIDManager-based seize for the grabber.\n//!\n//! Opens a set of (vendor, product) keyboards with\n//! `kIOHIDOptions"
},
{
"path": "src/grabber/HidSystem.zig",
"chars": 2456,
"preview": "//! Minimal IOHIDSystem client for forcing caps_lock state off.\n//!\n//! When IOHIDManager seize captures an Apple-built-"
},
{
"path": "src/grabber/Ipc.zig",
"chars": 8336,
"preview": "//! Server-side handling of one IPC client session.\n//!\n//! Reads framed JSON messages, dispatches by `type`, writes bac"
},
{
"path": "src/grabber/KbState.zig",
"chars": 7154,
"preview": "//! HID keyboard state aggregator.\n//!\n//! HID input value events arrive one transition at a time (key X\n//! went down, "
},
{
"path": "src/grabber/TapHold.zig",
"chars": 36898,
"preview": "//! Tap-hold state machine for one rule.\n//!\n//! HID events come in one transition at a time (key X went up/down).\n//! T"
},
{
"path": "src/grabber/Vhidd.zig",
"chars": 15446,
"preview": "//! Karabiner-DriverKit-VirtualHIDDevice client.\n//!\n//! Talks to the `vhidd_server` daemon shipped by pqrs.org's signed"
},
{
"path": "src/grabber/c.zig",
"chars": 14114,
"preview": "//! Minimal C bindings for the grabber binary.\n//!\n//! We hand-declare the IOKit / CoreFoundation symbols we need\n//! in"
},
{
"path": "src/grabber/main.zig",
"chars": 67100,
"preview": "//! `skhd-grabber` — system daemon, root-only.\n//!\n//! D1 scope: socket plumbing only. The daemon binds a Unix domain\n//"
},
{
"path": "src/grabber_cli.zig",
"chars": 46352,
"preview": "//! CLI subcommands that touch the system-grabber daemon.\n//!\n//! Implementations of `--install-grabber`, `--uninstall-g"
},
{
"path": "src/grabber_protocol.zig",
"chars": 5191,
"preview": "//! Shared types and framing for the user-agent ↔ system-grabber IPC.\n//!\n//! Wire format: 4-byte big-endian length pref"
},
{
"path": "src/main.zig",
"chars": 25798,
"preview": "const std = @import(\"std\");\nconst builtin = @import(\"builtin\");\nconst track_alloc = @import(\"build_options\").track_alloc"
},
{
"path": "src/service.zig",
"chars": 30614,
"preview": "const std = @import(\"std\");\nconst builtin = @import(\"builtin\");\nconst c = @import(\"c.zig\");\nconst sm = @import(\"sm_app_s"
},
{
"path": "src/skhd.zig",
"chars": 67461,
"preview": "const std = @import(\"std\");\nconst builtin = @import(\"builtin\");\n\nconst c = @import(\"c.zig\");\nconst agent_grabber_client "
},
{
"path": "src/sm_app_service.zig",
"chars": 5443,
"preview": "/// Thin Zig bridge to Apple's `SMAppService` Obj-C class (ServiceManagement\n/// framework, macOS 13+). Required for reg"
},
{
"path": "src/synthesize.zig",
"chars": 5490,
"preview": "const std = @import(\"std\");\nconst c = @import(\"c.zig\");\nconst Parser = @import(\"Parser.zig\");\nconst Mappings = @import(\""
},
{
"path": "src/tests.zig",
"chars": 64901,
"preview": "const std = @import(\"std\");\nconst testing = std.testing;\n\n// Import our modules\nconst Hotkey = @import(\"Hotkey.zig\");\nco"
},
{
"path": "src/utils.zig",
"chars": 1024,
"preview": "const std = @import(\"std\");\n\npub fn indentPrint(alloc: std.mem.Allocator, writer: anytype, padding: []const u8, comptime"
},
{
"path": "taphold_test.skhdrc",
"chars": 2874,
"preview": "# skhd.zig built-in keyboard test config\n#\n# Goal: make the MacBook built-in keyboard feel like the convolution QMK\n# bo"
},
{
"path": "testdata/example_process_groups.skhdrc",
"chars": 1978,
"preview": "# Example configuration demonstrating process group variables\n# This feature is new in skhd.zig and helps reduce configu"
},
{
"path": "testdata/hotload_test.skhdrc",
"chars": 37,
"preview": "# Initial content\n# Modified content\n"
},
{
"path": "testdata/loader.skhdrc",
"chars": 47,
"preview": ".load \"sub.skhdrc\"\ncmd - l : echo 'from loader'"
},
{
"path": "testdata/parse_errors.skhdrc",
"chars": 527,
"preview": "# Test file for parser error messages\n\n# Missing '<' after mode\nmymode cmd - a : echo test\n\n# Unknown modifier\nfoo - b :"
},
{
"path": "testdata/reload_test.skhdrc",
"chars": 169,
"preview": "# Modified config for reload test\ncmd - a : echo \" A key now does something different!\"\ncmd - b : echo \" B key also chan"
},
{
"path": "testdata/sub.skhdrc",
"chars": 28,
"preview": "cmd - s : echo 'from subdir'"
},
{
"path": "testdata/test-forward.skhdrc",
"chars": 354,
"preview": "# Test key forwarding/remapping\n\n# Simple key forwarding - remap Ctrl+H to Left Arrow\nctrl - h | left\n\n# Process-specifi"
},
{
"path": "testdata/test-lr-modifiers.skhdrc",
"chars": 1102,
"preview": "# Test left/right modifier distinction\n\n# General modifiers (should match any side)\ncmd - a : echo \"CMD+A: General comma"
},
{
"path": "testdata/test-process.skhdrc",
"chars": 581,
"preview": "# Test process-specific hotkeys\n\n# Process-specific hotkey example\n# Different commands for different applications\ncmd -"
},
{
"path": "testdata/test-shell.skhdrc",
"chars": 137,
"preview": "# Test shell option\n.SHELL \"/usr/bin/env zsh\"\n\n# Simple test command\ncmd - t : echo \"Test from custom shell: $SHELL\" > /"
},
{
"path": "testdata/test-skhdrc",
"chars": 1196,
"preview": "# Test configuration with simple echo commands\n# Run with: ./zig-out/bin/skhd.zig -V -c test-skhdrc\n\n# Basic test hotkey"
},
{
"path": "testdata/test-synthesis.skhdrc",
"chars": 575,
"preview": "# Test configuration for synthesis testing\n\n# Test basic hotkey that we can trigger with synthesis\ncmd - f1 : echo \"SUCC"
},
{
"path": "testdata/test.skhdrc",
"chars": 4839,
"preview": "# Test configuration for skhd.zig\n# This file tests various features of the hotkey daemon\n\n# Blacklist some applications"
},
{
"path": "testdata/test_debug_match.skhdrc",
"chars": 455,
"preview": ".define focus_direction : yabai -m window --focus {{1}} || yabai -m display --focus {{1}} || yabai -m display --focus {{"
},
{
"path": "testdata/test_forward_logging.skhdrc",
"chars": 248,
"preview": "# Test key forwarding with logging\n# This should log the forwarded key details\n\n# Forward cmd+h to cmd+m for Terminal\ncm"
},
{
"path": "testdata/test_home_key.skhdrc",
"chars": 246,
"preview": "# Test home key handling\n# This tests process-specific unbound vs wildcard forwarding\n\nhome [\n \"kitty\" ~\n \"Termina"
},
{
"path": "testdata/test_included.skhdrc",
"chars": 56,
"preview": "# Test included file\ncmd - i : echo 'from included file'"
},
{
"path": "testdata/test_included_mode.skhdrc",
"chars": 51,
"preview": "mymode < cmd - t : echo 'cross-file mode reference'"
},
{
"path": "testdata/test_logging.skhdrc",
"chars": 73,
"preview": "# Test config to verify logging\ncmd - l : echo \"Logging test successful!\""
},
{
"path": "testdata/test_media_key_forward.skhdrc",
"chars": 207,
"preview": "# Forward delete to next media key\ndelete | next\n\n# Forward backslash to previous\n0x2A | previous\n\n# Forward with modifi"
},
{
"path": "testdata/test_modifier_matching.skhdrc",
"chars": 401,
"preview": "# Test modifier matching behavior\n# General modifiers in config should match any specific modifier from keyboard\n\n# Gene"
},
{
"path": "testdata/test_nested1.skhdrc",
"chars": 57,
"preview": ".load \"test_nested2.skhdrc\"\ncmd - o : echo 'from nested1'"
},
{
"path": "testdata/test_nested2.skhdrc",
"chars": 29,
"preview": "cmd - n : echo 'from nested2'"
}
]
About this extraction
This page contains the full source code of the jackielii/skhd.zig GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 91 files (927.1 KB), approximately 243.2k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.