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 "com.jackielii.skhd" 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: ` 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/