[
  {
    "path": ".envrc",
    "content": "use zig 0.14.0\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve skhd.zig\ntitle: ''\nlabels: bug\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. My hotkey configuration that causes the issue:\n   ```bash\n   # paste relevant lines from your skhdrc here\n   ```\n2. The application I'm trying to use the hotkey in:\n3. What happens when I press the hotkey:\n4. Any error messages in the log `/tmp/skhd_$USER.log` (this is usually the configuration error):\n   ```bash\n   # paste relevant log lines here\n   ```\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Debug Information**\nPlease provide debug logs by following these steps:\n\n1. **Get a debug build** (choose one):\n   - Download pre-built debug binary from [GitHub Actions](https://github.com/jackielii/skhd.zig/actions/workflows/ci.yml) (click latest run → Artifacts → `skhd-Debug`)\n   - Or build from source: `git clone https://github.com/jackielii/skhd.zig && cd skhd.zig && zig build`\n\n2. **Run debug version with verbose logging**:\n   ```bash\n   # Optional: Stop the service if you're running skhd as a service\n   # skhd --stop-service\n   \n   # Run with verbose logging\n   ./skhd -V > skhd-debug.log 2>&1\n   ```\n   \n3. **Reproduce the issue** while skhd is running with verbose logging\n\n4. **Stop skhd** with Ctrl+C and attach the `skhd-debug.log` file to this issue\n\n**Environment**\n - macOS version: [e.g. macOS 14.0 Sonoma]\n - skhd.zig version: (run `skhd --version` to get this)\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [ main ]\n  pull_request:\n    branches: [ main ]\n\njobs:\n  test:\n    runs-on: macos-latest\n    steps:\n    - uses: actions/checkout@v4\n\n    - name: Setup Zig\n      uses: mlugg/setup-zig@v2\n      with:\n        version: 0.14.0\n\n    - name: Run tests\n      run: zig build test\n\n    - name: Build Debug\n      run: zig build\n\n    - name: Lint shell scripts\n      run: bash -n scripts/make-app.sh && bash -n scripts/codesign.sh && bash -n scripts/release.sh\n\n  build-all:\n    if: github.event_name == 'push' && github.ref == 'refs/heads/main'\n    runs-on: macos-latest\n    strategy:\n      matrix:\n        optimize: [Debug, ReleaseSafe, ReleaseFast, ReleaseSmall]\n    steps:\n    - uses: actions/checkout@v4\n\n    - name: Setup Zig\n      uses: mlugg/setup-zig@v2\n      with:\n        version: 0.14.0\n\n    - name: Setup Code Signing Certificate\n      # Mirrors release.yml: imports skhd-cert from MACOS_CERTIFICATE so the\n      # uploaded skhd.app is signed with the same identity as tagged\n      # releases. Without this the bundle would be adhoc-signed and lose\n      # TCC accessibility grants on every install/upgrade.\n      env:\n        CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE }}\n        CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}\n        KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}\n      run: |\n        if [ -z \"$CERTIFICATE_P12\" ]; then\n          echo \"::error::MACOS_CERTIFICATE secret is not configured.\"\n          echo \"::error::build-all uploads user-runnable artifacts and requires skhd-cert.\"\n          exit 1\n        fi\n\n        CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12\n        KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db\n\n        echo -n \"$CERTIFICATE_P12\" | base64 --decode -o $CERTIFICATE_PATH\n\n        security create-keychain -p \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH\n        security set-keychain-settings -lut 21600 $KEYCHAIN_PATH\n        security unlock-keychain -p \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH\n\n        security import $CERTIFICATE_PATH -P \"$CERTIFICATE_PASSWORD\" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH\n        security list-keychain -d user -s $KEYCHAIN_PATH\n\n        security set-key-partition-list -S apple-tool:,apple: -s -k \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH\n\n    - name: Build skhd.app (${{ matrix.optimize }})\n      run: |\n        zig build app -Doptimize=${{ matrix.optimize }}\n        mv zig-out/skhd.app skhd.app\n\n    - name: Code sign skhd.app\n      env:\n        SKHD_CERT: skhd-cert\n        SKHD_BUNDLE_ID: com.jackielii.skhd\n        SKHD_NO_AUTO_GENERATE_CERT: \"1\"\n      run: |\n        bash scripts/codesign.sh skhd.app\n        codesign --verify --verbose=2 skhd.app\n\n    - name: Verify bundle layout\n      run: |\n        test -f skhd.app/Contents/MacOS/skhd\n        test -f skhd.app/Contents/MacOS/skhd-grabber\n        test -f skhd.app/Contents/Info.plist\n        grep -q \"<string>com.jackielii.skhd</string>\" skhd.app/Contents/Info.plist\n        plutil -lint skhd.app/Contents/Info.plist\n\n    - name: Create tarball\n      run: tar -czf skhd-arm64-macos.tar.gz skhd.app\n\n    - name: Cleanup Keychain\n      if: always()\n      run: |\n        security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true\n\n    - name: Upload artifact\n      uses: actions/upload-artifact@v4\n      with:\n        name: skhd-${{ matrix.optimize }}\n        path: skhd-arm64-macos.tar.gz\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    tags:\n      - 'v*'\n\npermissions:\n  contents: write\n\njobs:\n  create-release:\n    runs-on: ubuntu-latest\n    outputs:\n      upload_url: ${{ steps.create_release.outputs.upload_url }}\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v4\n      with:\n        fetch-depth: 0\n        fetch-tags: true\n\n    - name: Force-fetch annotated tag objects\n      # actions/checkout@v4's `fetch-tags: true` reliably fetches the tag\n      # ref but does NOT always fetch the annotated tag *object* — when\n      # missing, `git tag -l --format='%(contents)' vX.Y.Z` silently falls\n      # back to the pointed-to commit's message and the release body ends\n      # up as a random commit message. Explicit fetch makes this reliable.\n      run: git fetch --tags --force origin\n\n    - name: Create Release\n      id: create_release\n      env:\n        GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      run: |\n        TAG_NAME=\"${{ github.ref_name }}\"\n        VERSION=\"${TAG_NAME#v}\"\n\n        # Detect pre-release tags (-alpha / -beta / -rc, optionally\n        # followed by .N or -N). GitHub keeps pre-releases out of the\n        # repo's \"latest release\" badge and labels them clearly so\n        # watchers don't read the email as \"you should upgrade\".\n        PRERELEASE_FLAG=\"\"\n        if [[ \"$TAG_NAME\" =~ -(alpha|beta|rc)([.-][A-Za-z0-9]+)?$ ]]; then\n          PRERELEASE_FLAG=\"--prerelease\"\n          echo \"Detected pre-release tag: $TAG_NAME\"\n        fi\n\n        # Verify the tag is annotated. A lightweight tag would make\n        # `%(contents)` silently return the commit message — refuse the\n        # release rather than ship surprising notes.\n        TAG_TYPE=$(git cat-file -t \"$TAG_NAME\" 2>/dev/null || echo \"missing\")\n        if [ \"$TAG_TYPE\" = \"tag\" ]; then\n          NOTES=$(git tag -l --format='%(contents)' \"$TAG_NAME\")\n          echo \"Using tag annotation as release notes.\"\n        else\n          echo \"::warning::Tag $TAG_NAME has type '$TAG_TYPE' (expected 'tag') — falling back to CHANGELOG.md\"\n          NOTES=\"\"\n        fi\n\n        # Fall back to CHANGELOG.md when the tag annotation is missing.\n        if [ -z \"$(echo \"$NOTES\" | tr -d '[:space:]')\" ]; then\n          echo \"Extracting release notes from CHANGELOG.md entry for $VERSION...\"\n          NOTES=$(awk \"/## \\[$VERSION\\]/{flag=1; next} /## \\[/{flag=0} flag\" CHANGELOG.md)\n        fi\n\n        if [ -z \"$(echo \"$NOTES\" | tr -d '[:space:]')\" ]; then\n          echo \"::error::No release notes found in tag annotation or CHANGELOG.md for $TAG_NAME\"\n          exit 1\n        fi\n\n        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.'\n\n        FULL_NOTES=\"$NOTES\"$'\\n\\n'\"$INSTALLATION_NOTES\"\n\n        gh release create \"$TAG_NAME\" \\\n          --repo ${{ github.repository }} \\\n          --title \"$TAG_NAME\" \\\n          --notes \"$FULL_NOTES\" \\\n          --draft \\\n          $PRERELEASE_FLAG\n\n  build-release:\n    needs: create-release\n    strategy:\n      matrix:\n        include:\n          - os: macos-latest\n            arch: arm64\n            target: \"\"\n          # Cross-compile x86_64 from the arm64 runner. The macOS SDK is\n          # universal (x86_64 stubs ship in the same SDK arm64 uses), and\n          # codesign on arm64 happily signs an x86_64 Mach-O. Avoids\n          # depending on the macos-13 Intel runner, which is on GitHub's\n          # deprecation timeline. -Dtarget carries the explicit os version so\n          # build.zig's default_target (macos 13.0) is preserved.\n          - os: macos-latest\n            arch: x86_64\n            target: x86_64-macos.13.0\n    runs-on: ${{ matrix.os }}\n    \n    steps:\n    - uses: actions/checkout@v4\n    \n    - name: Setup Zig\n      uses: mlugg/setup-zig@v2\n      with:\n        version: 0.14.0\n    \n    - name: Setup Code Signing Certificate\n      env:\n        CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE }}\n        CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}\n        KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}\n      run: |\n        # Create variables\n        CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12\n        KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db\n\n        # Import certificate if available; otherwise hard-fail. Tagged releases\n        # without skhd-cert produce adhoc-signed bundles that silently lose\n        # TCC accessibility permissions on every install/upgrade — never the\n        # right outcome for a public release. Override only by setting\n        # ALLOW_UNSIGNED_RELEASE=true in the workflow env.\n        if [ -n \"$CERTIFICATE_P12\" ]; then\n          echo \"Certificate found, setting up code signing...\"\n\n          # Decode base64 certificate\n          echo -n \"$CERTIFICATE_P12\" | base64 --decode -o $CERTIFICATE_PATH\n\n          # Create temporary keychain\n          security create-keychain -p \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH\n          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH\n          security unlock-keychain -p \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH\n\n          # Import certificate to keychain\n          security import $CERTIFICATE_PATH -P \"$CERTIFICATE_PASSWORD\" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH\n          security list-keychain -d user -s $KEYCHAIN_PATH\n\n          # Allow codesign to access the certificate\n          security set-key-partition-list -S apple-tool:,apple: -s -k \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH\n\n          echo \"SHOULD_SIGN=true\" >> $GITHUB_ENV\n        elif [ \"${ALLOW_UNSIGNED_RELEASE:-false}\" = \"true\" ]; then\n          echo \"::warning::ALLOW_UNSIGNED_RELEASE=true — shipping adhoc-signed bundles.\"\n          echo \"::warning::Tahoe users will lose accessibility permissions on this release.\"\n          echo \"SHOULD_SIGN=false\" >> $GITHUB_ENV\n        else\n          echo \"::error::MACOS_CERTIFICATE secret is not configured.\"\n          echo \"::error::Tagged releases require code signing — see docs/CODE_SIGNING.md.\"\n          echo \"::error::Set ALLOW_UNSIGNED_RELEASE=true in this workflow's env to opt out (not recommended).\"\n          exit 1\n        fi\n\n    - name: Build Release .app bundle\n      run: |\n        TARGET_ARG=\"\"\n        if [ -n \"${{ matrix.target }}\" ]; then\n          TARGET_ARG=\"-Dtarget=${{ matrix.target }}\"\n        fi\n        zig build app -Doptimize=ReleaseFast $TARGET_ARG\n        # Stage the bundle at the working tree root for tarballing.\n        mv zig-out/skhd.app skhd.app\n\n    - name: Code Sign Bundle\n      if: env.SHOULD_SIGN == 'true'\n      env:\n        # Codesign skhd.app via the same script `zig build install-local`\n        # uses locally — keeps the inner-Mach-O signing order (helpers\n        # first, principal last) and the bundle ID identifier consistent\n        # between dev iteration and release builds.\n        SKHD_CERT: skhd-cert\n        SKHD_BUNDLE_ID: com.jackielii.skhd\n        # CI imports the cert from the MACOS_CERTIFICATE secret; if it's\n        # not in the keychain, that's a deployment misconfiguration —\n        # don't paper over it by generating a throwaway local cert.\n        SKHD_NO_AUTO_GENERATE_CERT: \"1\"\n      run: |\n        bash scripts/codesign.sh skhd.app\n        codesign --verify --verbose=2 skhd.app\n\n    - name: Create tarball\n      run: |\n        # Tarball name kept identical to pre-bundle releases so the homebrew\n        # formula auto-bump regex still matches; content is now skhd.app/.\n        tar -czf skhd-${{ matrix.arch }}-macos.tar.gz skhd.app\n\n    - name: Cleanup Keychain\n      if: always() && env.SHOULD_SIGN == 'true'\n      run: |\n        security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true\n    \n    - name: Upload Release Asset\n      env:\n        GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      run: |\n        gh release upload ${{ github.ref_name }} \\\n          ./skhd-${{ matrix.arch }}-macos.tar.gz \\\n          --repo ${{ github.repository }} \\\n          --clobber\n\n  publish-release:\n    needs: build-release\n    runs-on: ubuntu-latest\n    steps:\n    - name: Publish Release\n      env:\n        GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      run: |\n        gh release edit ${{ github.ref_name }} \\\n          --repo ${{ github.repository }} \\\n          --draft=false\n\n  update-homebrew:\n    needs: publish-release\n    # Skip on pre-releases (-alpha / -beta / -rc) — bumping the formula\n    # would silently push every brew-installed user onto the pre-release\n    # via `brew upgrade`. Stable users stay on whatever the last final\n    # tag was; we install pre-releases manually via `gh release download`\n    # for testing.\n    if: ${{ !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && !contains(github.ref_name, '-rc') }}\n    runs-on: ubuntu-latest\n    steps:\n    - name: Checkout homebrew-tap\n      uses: actions/checkout@v4\n      with:\n        repository: jackielii/homebrew-tap\n        token: ${{ secrets.HOMEBREW_TAP_TOKEN || secrets.GITHUB_TOKEN }}\n        path: homebrew-tap\n\n    - name: Get release info\n      id: release\n      env:\n        GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      run: |\n        VERSION=\"${{ github.ref_name }}\"\n        echo \"version=${VERSION#v}\" >> $GITHUB_OUTPUT\n        \n        # Download release assets to calculate SHA256\n        cd ${{ github.workspace }}\n        gh release download ${VERSION} --repo jackielii/skhd.zig --pattern \"*.tar.gz\"\n\n        ARM64_SHA=$(shasum -a 256 skhd-arm64-macos.tar.gz | awk '{print $1}')\n        echo \"arm64_sha=${ARM64_SHA}\" >> $GITHUB_OUTPUT\n\n        X86_64_SHA=$(shasum -a 256 skhd-x86_64-macos.tar.gz | awk '{print $1}')\n        echo \"x86_64_sha=${X86_64_SHA}\" >> $GITHUB_OUTPUT\n\n    - name: Update Formula\n      env:\n        VERSION: ${{ steps.release.outputs.version }}\n        ARM64_SHA: ${{ steps.release.outputs.arm64_sha }}\n        X86_64_SHA: ${{ steps.release.outputs.x86_64_sha }}\n      run: |\n        cd homebrew-tap\n\n        # Update version line if present (the formula may scan version from\n        # URL instead). No-op when missing.\n        sed -i \"s/version \\\".*\\\"/version \\\"${VERSION}\\\"/\" Formula/skhd-zig.rb\n\n        sed -i -E \"s|download/v[0-9.]+(-[A-Za-z0-9]+)?/skhd-arm64|download/v${VERSION}/skhd-arm64|\" Formula/skhd-zig.rb\n        sed -i \"/arm64-macos.tar.gz/,/sha256/ s/sha256 \\\".*\\\"/sha256 \\\"${ARM64_SHA}\\\"/\" Formula/skhd-zig.rb\n\n        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\n        sed -i \"/x86_64-macos.tar.gz/,/sha256/ s/sha256 \\\".*\\\"/sha256 \\\"${X86_64_SHA}\\\"/\" Formula/skhd-zig.rb\n\n    - name: Commit and push\n      run: |\n        cd homebrew-tap\n        git config user.name \"GitHub Actions\"\n        git config user.email \"actions@github.com\"\n        git add Formula/skhd-zig.rb\n        git commit -m \"Update skhd-zig to v${{ steps.release.outputs.version }}\"\n        git push\n\n"
  },
  {
    "path": ".gitignore",
    "content": "# This file is for zig-specific build artifacts.\n# If you have OS-specific or editor-specific files to ignore,\n# such as *.swp or .DS_Store, put those in your global\n# ~/.gitignore and put this in your ~/.gitconfig:\n#\n# [core]\n#     excludesfile = ~/.gitignore\n#\n# Cheers!\n# -andrewrk\n\n.zig-cache/\nzig-cache/\nzig-out/\n/release/\n/debug/\n/build/\n/build-*/\n/docgen_tmp/\n/.claude/settings.local.json\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [Unreleased]\n\n## [0.1.0-alpha] - 2026-04-28\n\n> 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.\n\n### Added\n- **`.remap` / `.taphold` / `.device` directives** for QMK-style keyboard remapping. Two paths depending on what the rule needs:\n  - **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.\n  - **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.\n  - **`.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.\n  - **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.\n- **`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).\n- **`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`.\n- **`skhd --install-service` auto-installs the dext** when grabber is needed and the dext is missing. Brew install becomes one command:\n  ```\n  brew install jackielii/tap/skhd-zig\n  skhd --install-service     # registers agent, installs dext if missing, registers grabber\n  ```\n- **`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).\n- **`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.\n- **Karabiner-Elements conflict warning** — `--status` and `--install-grabber` flag when `karabiner_grabber` is running, since both daemons compete for HID seize.\n- **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.\n- **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.\n- **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.\n- **`--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.\n\n### Changed\n- **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.\n- **`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.\n- **`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.\n- **`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.\n\n### Fixed\n- **`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.\n\n### Internal\n- **`mappings.tapholds` / `mappings.remaps` / `mappings.device_aliases`** — parser and runtime data for the new directives.\n- **`grabber_protocol`** — shared module defining the agent ↔ grabber wire format. Versioned (`protocol_version`) so handshake mismatches surface clearly. Currently v2.\n- **Daemon refactored around `CFRunLoop`-driven IPC listener** so the agent can react to grabber pushes (layer-hold mode changes) without polling.\n- **`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.\n- **Test surface expanded** to cover `RuleSet` parsing, the IPC framing, `KbState` / `TapHold` state machines, and the new HID-daemon version compat helpers.\n\n## [0.0.24] - 2026-04-28\n\n### Fixed\n- **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.\n- **`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.\n\n### Added\n- **`.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):\n  ```\n  .path \"/opt/homebrew/bin\"\n  .path [\n      \"$HOME/.local/share/mise/shims\"\n      \"~/bin\"\n  ]\n  ```\n  `~` 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.\n\n### Changed\n- **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.\n\n### Internal\n- **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.\n\n## [0.0.23] - 2026-04-26\n\n### Fixed\n- **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.\n- **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.\n- **`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`.\n\n### Changed\n- **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.\n\n### Internal\n- **`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.\n\n## [0.0.22] - 2026-04-26\n\n### Fixed\n- **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()`.\n- **`--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.\n\n### Changed\n- **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.\n\n### Internal\n- **`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.\n\n## [0.0.21] - 2026-04-26\n\n### Fixed\n- **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.\n\n### Changed\n- **`--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.\n- **`--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.\n- **`--status`** reads SMAppService registration state directly. Reports `Registration status: enabled` / `requires approval` / `not registered` so the user knows what BTM thinks.\n\n### Migration\nOn 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.\n\n## [0.0.20] - 2026-04-26\n\nLocal-development quality-of-life release. No runtime changes.\n\n### Internal\n- **`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).\n- **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.\n- **`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.\n- **`scripts/codesign.sh`** reads `SKHD_BUNDLE_ID` env var (defaults to `com.jackielii.skhd`).\n- **`scripts/make-app.sh`** accepts an optional bundle ID as the third argument.\n\n## [0.0.19] - 2026-04-26\n\nSmall follow-up to v0.0.18 fixing a reporting bug.\n\n### Fixed\n- **`--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.\n- **`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.\n\n### Internal\n- **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.\n\n## [0.0.18] - 2026-04-26\n\n### macOS Tahoe (26) compatibility\n\nThis 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.\n\n### Added\n- **`.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`.\n- **`zig build app` / `zig build sign-app`** — build steps for producing and signing the `.app` bundle locally.\n- **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.\n- **[docs/UPGRADING.md](docs/UPGRADING.md)** — step-by-step guide for users moving from 0.0.17 to 0.0.18.\n\n### Changed\n- **Logs moved to `~/Library/Logs/skhd.log`** (was `/tmp/skhd_$USER.log`). The previous path was wiped at every boot, hiding boot-time failures.\n- **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.\n- **Plist `ProgramArguments`** points at the stable `/opt/homebrew/opt/skhd-zig/skhd.app/Contents/MacOS/skhd` symlink instead of a version-pinned Cellar path.\n- **Plist `ThrottleInterval`** lowered from 30 s to 10 s for faster recovery from boot-time failures.\n- **`AccessibilityPermissionDenied` error message** now points at the `.app` bundle path (which Tahoe's picker accepts) instead of the inner binary.\n\n### Removed\n- **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).\n- **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.\n\n### Fixed\n- **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.\n- **`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`.\n- **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.\n\n## [0.0.17] - 2025-12-08\n\n### Added\n- **Media key support** - Added support for media keys as forward/remap targets (#28)\n  - Supported media keys: `play`, `pause`, `next`, `previous`, `fast`, `rewind`, `brightness_up`, `brightness_down`, `illumination_up`, `illumination_down`, `sound_up`, `sound_down`, `mute`\n  - Example: `cmd - p | play` forwards Cmd+P to the play/pause media key\n\n## [0.0.16] - 2025-11-30\n\n### Fixed\n- **CFString null pointer crash** - Fixed crash during keyboard layout initialization on certain keyboard layouts (#19, #20)\n  - Added null check for `CFStringCreateWithCharacters` which can return NULL for some keycodes\n  - skhd now gracefully skips problematic keycodes and continues initialization\n\n## [0.0.15] - 2025-10-17\n\n### Added\n- **Code signing support for macOS 15+** - Accessibility permissions now persist across builds (#15)\n  - Added `Info.plist` with bundle identifier for stable TCC identity\n  - Added `zig build sign` command for local development signing\n  - Release binaries are now automatically signed\n  - See `docs/CODE_SIGNING.md` for setup instructions\n\n### Fixed\n- **Missing F16-F20 keycodes** - Added support for F16-F20 function keys in observe mode (#14)\n  - These keys were already usable in configs but showed as \"unknown\" in `-o` mode\n  - Note: F21-F24 cannot be supported as they are not defined in macOS's HIToolbox framework\n- **Homebrew release artifact URL** - Fixed regex to handle preview tags in release URLs\n  - Thanks to @tdjordan for the contribution (#17)\n\n### Changed\n- Removed unused `Info.plist` file from assets directory\n\n## [0.0.13] - 2025-08-27\n\n### Added\n- **Support for backtick (`) special character** - Added backtick to the list of recognized special characters in the tokenizer\n  - Enables hotkey bindings with the backtick key\n  - Thanks to @danielfalbo for the contribution (#8)\n\n### Fixed\n- **Duplicate keycode from layout** - Fixed issue where keycodes could be duplicated when retrieved from keyboard layout\n- **ZBench vendor dependency** - Fixed vendor import for zbench benchmarking library\n\n### Changed\n- **Improved error messages** - Enhanced parser error reporting with contextual information\n  - Added helpful error messages for invalid hex keycodes with examples\n  - Improved duplicate command detection with specific context about conflicts\n  - Added suggestions for common mistakes (e.g., \"Did you forget to declare it with '::mode'?\")\n  - Better error reporting for file loading, blacklist, and shell configuration failures\n- **Duplicate command handling** - Allow identical duplicate commands in process groups\n  - This enables more flexible configuration with overlapping process groups\n  - Duplicate detection still prevents conflicting commands for the same process\n- **Build optimization** - Only build all targets on main branch to speed up development builds\n- **Code improvements** - Various internal refactoring and simplifications\n  - Simplified activation equality check\n  - Use Zig field syntax for cleaner code\n  - Added error sets for type safety in Hotkey methods\n\n## [0.0.12] - 2025-07-15\n\n### Added\n- **Mode activation with optional command execution** - Enhanced mode switching with command execution support\n  - New syntax: `keysym ; mode : command` executes command when switching to mode\n  - Process-specific mode activation in process lists (e.g., `\"terminal\" ; vim_mode`)\n  - Process group mode activation (e.g., `@browsers ; browser_mode`)\n  - Comprehensive test coverage for all activation scenarios\n- Added `activation` variant to `ProcessCommand` enum for proper mode activation tracking\n\n### Changed\n- Refactored command parsing to eliminate code duplication with helper function `parse_command`\n- Removed redundant `flags.activate` field from `ModifierFlag` \n- Updated SYNTAX.md and README.md with comprehensive mode activation documentation\n\n### Fixed\n- Fixed mode activation implementation to use dedicated enum variant instead of borrowing command enum\n- Improved error handling for empty commands followed by references\n\n## [0.0.11] - 2025-07-13\n\n### Changed\n- Optimized command execution by using null-terminated strings throughout, eliminating runtime allocations in exec.zig\n- Refactored Hotkey API to have separate methods for each action type (add_process_command, add_process_forward, add_process_unbound)\n\n### Fixed\n- Fixed benchmark to use new Hotkey API methods\n\n## [0.0.10] - 2025-07-08\n\n### Fixed\n- **Critical bug fix**: Capture mode now respects passthrough and unbound actions\n  - Previously, capture mode would consume all keys including those explicitly marked as passthrough (`->`) or unbound (`~`)\n  - Now these keys are properly passed through to applications even in capture mode\n\n### Added\n- Support for unbound action syntax: `<keysym> ~`\n  - Keys marked as unbound are not captured and pass through to applications\n  - Compatible with all existing features (modes, process lists, etc.)\n- Added `--message` flag to release script for custom tag messages\n\n### Changed\n- Refactored hotkey processing to use `HotkeyResult` enum instead of boolean return\n  - Clearer distinction between consumed, passthrough, and not_found states\n  - Eliminated code duplication between `handleKeyDown` and `handleSystemKey`\n\n### Internal\n- Added comprehensive tests for capture mode behavior with passthrough and unbound actions\n- Extracted common hotkey result handling into `handleHotkeyResult` helper function\n- Updated SYNTAX.md documentation to include unbound action syntax\n\n## [0.0.9] - 2025-07-07\n\n### Fixed\n\n- 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.\n\n### Added\n- Improved duplicate hotkey detection with better error reporting\n\n### Internal\n- Added issue template for better bug reporting\n- Updated CI workflow configuration\n- Include build mode in version string output\n\n## [0.0.8] - 2025-07-06\n\n### Changed\n- **Major performance improvement**: Achieved allocation-free event loop\n  - Replaced dynamic allocation for process names with fixed-size buffer\n  - Zero allocations during runtime after initialization\n  - Event loop is now completely allocation-free in release builds\n- Refactored hotkey implementation for simplicity and performance\n  - Removed HotkeyArrayHashMap and HotkeyMultiArrayList (740+ lines removed)\n  - Consolidated hotkey functionality in Hotkey.zig\n- Enhanced test coverage with comprehensive duplicate detection tests\n- CarbonEvent now uses a pre-allocated buffer for process names to avoid runtime allocations\n- Moved VERSION file from src/VERSION to root directory for better visibility\n- Code cleanup and formatting improvements across multiple modules\n\n### Fixed\n- Fixed cleanup logic when sending SIGINT to the process\n- Fixed memory leaks in Hotkey.zig and improved memory management\n- **Duplicate definition detection**: Now reports errors instead of silently overwriting duplicate entries in config\n- Fixed CI/CD release workflow by replacing deprecated upload-release-asset action with gh CLI\n\n### Internal\n- Added TrackingAllocator for monitoring memory allocations during development\n- Created new exec.zig module for command execution\n- Improved error handling in Parser, Mappings, and Keycodes modules\n\n## [0.0.7] - 2025-07-05\n\n### Fixed\n- **Accessibility permission check reliability** - Replaced unreliable event tap creation with `AXIsProcessTrusted()` API\n- `--status` command now correctly reports accessibility permission state\n- Fixed issue where permissions showed as \"not granted\" even when properly configured\n\n### Changed\n- Permission checking now uses the official macOS API for more accurate results\n\n## [0.0.6] - 2025-07-04\n\n### Added\n- **Command definitions feature** with `.define` directive for reusable command templates\n  - Support for placeholders (`{{1}}`, `{{2}}`, etc.) in command templates\n  - Reference commands with `@command_name(\"arg1\", \"arg2\")` syntax\n  - Reduces configuration duplication and improves maintainability\n- Enhanced error handling for command definition parsing with clear error messages\n\n### Changed\n- Refactored tokenizer to clean up token text representation\n- Optimized command definition storage by moving it directly to Parser\n- Updated documentation to include command definition examples\n\n### Fixed\n- Command definition parsing now properly handles escaped characters in templates\n- Improved error reporting for invalid placeholder syntax\n\n## [0.0.5] - 2025-07-02\n\n### Changed\n- Improved service mode execution to always use fork/exec for better reliability\n- Refactored hotkey storage to use MultiArrayList for better memory layout and performance\n- Updated README to explicitly mention key remapping/forwarding feature\n\n### Added\n- MIT License file\n- Integrated Homebrew tap update directly into release workflow\n\n### Fixed\n- Import statement cleanup for better code organization\n- GitHub Actions workflow now directly triggers Homebrew tap updates\n\n## [0.0.4] - 2025-07-02\n\n### Added\n- Comprehensive execution tracer with `-P/--profile` flag for performance analysis\n- Benchmark suite using zBench for hot path optimization\n- Carbon event handler for efficient app switching notifications\n\n### Changed\n- **Major performance optimization**: Cache process name lookups (25μs → 21ns)\n- **Eliminated double hotkey lookup**: Combined into single `processHotkey` function (169ns → 83ns)\n- CPU usage reduced from ~1.2% to ~0.5% (matching original skhd)\n\n### Fixed\n- High CPU usage compared to original skhd implementation\n- Unnecessary system calls in hot path\n\n## [0.0.3] - 2025-07-01\n\n### Added\n- `--start-service` now automatically installs/updates the service plist to ensure it uses the current binary\n- `--status` command to check service installation status, running state, and accessibility permissions\n- Clear startup message in service mode to confirm skhd is running\n- Improved accessibility permission error message with troubleshooting steps for when permissions are \"stuck\"\n\n### Changed\n- Service mode now only logs errors and startup messages, reducing log verbosity\n- Removed unnecessary stdout/stderr syncing in logger for better performance\n\n### Fixed\n- Service management commands now provide better error messages and guidance\n- Homebrew service integration now works more reliably with proper binary path updates\n\n## [0.0.2] - 2025-07-01\n\n### Fixed\n- Support for uppercase option names (.SHELL, .BLACKLIST) in configuration files\n- Improved error reporting to show parse errors with line numbers during initialization\n- Parser now properly handles comma-separated lists in .define directives\n- Exit with proper error when config file is not a regular file (e.g., /dev/null)\n- Fixed release workflow permissions for uploading artifacts\n- Simplified release workflow to build natively for each architecture\n\n## [0.0.1] - 2025-07-01\n\n### Added\n- Initial release of skhd.zig - a complete Zig port of skhd\n- Full compatibility with original skhd configuration format\n- Core features:\n  - Event tap creation and keyboard event handling\n  - Hotkey mapping with modifier support (cmd, alt, ctrl, shift)\n  - Left/right modifier distinction (lcmd, rcmd, etc.)\n  - Modal system with mode switching and capture modes\n  - Process-specific hotkey bindings\n  - Key forwarding/remapping\n  - Blacklist support for applications\n  - Shell command execution\n  - Configuration file loading with `.load` directive\n  - Custom shell support with `.shell` directive\n- Command-line interface:\n  - `-c/--config` - Specify config file\n  - `-o/--observe` - Observe mode for testing keys\n  - `-V/--verbose` - Verbose output\n  - `-k/--key` - Synthesize keypress\n  - `-t/--text` - Synthesize text\n  - `-r/--reload` - Reload configuration\n  - `-h/--no-hotload` - Disable hot reloading\n  - `-v/--version` - Show version\n- Service management:\n  - `--install-service` - Install launchd service\n  - `--uninstall-service` - Remove launchd service\n  - `--start-service` - Start service\n  - `--restart-service` - Restart service\n  - `--stop-service` - Stop service\n- Enhanced features:\n  - **Process group variables** (New!) - Define reusable process groups with `.define` directive\n  - Improved error reporting with line numbers and file paths\n  - Unicode character handling in process names\n  - Fixed key repeating issue with event forwarding\n  - Comprehensive test suite\n  - CI/CD workflow with GitHub Actions\n\n### Fixed\n- Key repeating issue when forwarding events to applications\n- Unicode invisible character handling in process names\n- Modifier matching logic to properly handle general vs specific modifiers\n- Memory management and hot reload stability\n\n### Performance\n- Optimized hot path to minimize allocations during key events\n- Efficient HashMap-based hotkey lookup\n- Stack-based buffers for process name retrieval\n\n[Unreleased]: https://github.com/jackielii/skhd.zig/compare/v0.0.24...HEAD\n[0.0.24]: https://github.com/jackielii/skhd.zig/compare/v0.0.23...v0.0.24\n[0.0.23]: https://github.com/jackielii/skhd.zig/compare/v0.0.22...v0.0.23\n[0.0.22]: https://github.com/jackielii/skhd.zig/compare/v0.0.21...v0.0.22\n[0.0.21]: https://github.com/jackielii/skhd.zig/compare/v0.0.20...v0.0.21\n[0.0.20]: https://github.com/jackielii/skhd.zig/compare/v0.0.19...v0.0.20\n[0.0.19]: https://github.com/jackielii/skhd.zig/compare/v0.0.18...v0.0.19\n[0.0.18]: https://github.com/jackielii/skhd.zig/compare/v0.0.17...v0.0.18\n[0.0.17]: https://github.com/jackielii/skhd.zig/compare/v0.0.16...v0.0.17\n[0.0.16]: https://github.com/jackielii/skhd.zig/compare/v0.0.15...v0.0.16\n[0.0.15]: https://github.com/jackielii/skhd.zig/compare/v0.0.13...v0.0.15\n[0.0.13]: https://github.com/jackielii/skhd.zig/compare/v0.0.12...v0.0.13\n[0.0.12]: https://github.com/jackielii/skhd.zig/compare/v0.0.11...v0.0.12\n[0.0.11]: https://github.com/jackielii/skhd.zig/compare/v0.0.10...v0.0.11\n[0.0.10]: https://github.com/jackielii/skhd.zig/compare/v0.0.9...v0.0.10\n[0.0.9]: https://github.com/jackielii/skhd.zig/compare/v0.0.8...v0.0.9\n[0.0.8]: https://github.com/jackielii/skhd.zig/compare/v0.0.7...v0.0.8\n[0.0.7]: https://github.com/jackielii/skhd.zig/compare/v0.0.6...v0.0.7\n[0.0.6]: https://github.com/jackielii/skhd.zig/compare/v0.0.5...v0.0.6\n[0.0.5]: https://github.com/jackielii/skhd.zig/compare/v0.0.4...v0.0.5\n[0.0.4]: https://github.com/jackielii/skhd.zig/compare/v0.0.3...v0.0.4\n[0.0.3]: https://github.com/jackielii/skhd.zig/compare/v0.0.2...v0.0.3\n[0.0.2]: https://github.com/jackielii/skhd.zig/compare/v0.0.1...v0.0.2\n[0.0.1]: https://github.com/jackielii/skhd.zig/releases/tag/v0.0.1\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Project Overview\n\nThis 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.\n\n## Build Commands\n\n```bash\n# Build the project (creates executable in zig-out/bin/)\nzig build\n\n# Run skhd locally (signed dev .app — bare binary can't be granted\n# Accessibility / Input Monitoring on Tahoe)\nzig build run -- -V 2>&1 | tee /tmp/skhd.log\n\n# Run tests (use this — single-file `zig test` no longer works since\n# module tests now need build_options / grabber_protocol / plist imports)\nzig build test\nZIG_PROGRESS=0 zig build test   # if it hangs\n\n# Run benchmarks (ReleaseFast)\nzig build bench\n\n# Run the grabber daemon from this checkout. Requires sudo. If\n# `skhd --install-grabber` was run, stop the installed LaunchDaemon\n# first or it will hold the IPC socket:\n#     sudo launchctl bootout system/com.jackielii.skhd.grabber\nzig build run-grabber 2>&1 | tee /tmp/skhd-grabber.log\n```\n\n## Architecture\n\nThe codebase follows a modular architecture with clear separation of concerns:\n\n### Core Components\n\n1. **Parser.zig** - Parses skhd configuration files using the DSL syntax\n   - Uses Tokenizer for lexical analysis\n   - Builds hotkey mappings from config syntax\n   - Handles mode declarations and options\n\n2. **Tokenizer.zig** - Lexical analyzer for the configuration DSL\n   - Handles UTF-8 text processing\n   - Recognizes tokens like modifiers, keys, commands, etc.\n\n3. **EventTap.zig** - macOS event tap interface for capturing keyboard events\n   - Wraps Core Graphics event tap APIs\n   - Manages event capture and filtering\n\n4. **Hotkey.zig** - Hotkey data structure and management\n   - Stores modifier flags and key codes\n   - Maps process names to commands\n   - Supports wildcard commands and key forwarding\n\n5. **Mode.zig** - Modal hotkey system implementation\n   - Each mode has its own hotkey map\n   - Supports mode-specific commands and capture behavior\n\n6. **Mappings.zig** - Central registry for all hotkeys and modes\n   - Manages global hotkey map and mode map\n   - Handles application blacklisting\n   - Stores shell configuration for command execution\n\n7. **Keycodes.zig** - Key code and modifier flag definitions\n   - Maps between string representations and numeric codes\n   - Handles Carbon/Cocoa key constants\n\n8. **CarbonEvent.zig** - Application switching detection\n   - Monitors app switch events for process-specific hotkeys\n   - Caches process names for performance optimization\n   - Reduces lookup overhead from 25μs to 21ns\n\n9. **exec.zig** - Command execution module\n   - Implements double-fork technique for proper daemon process creation\n   - Ensures child processes are fully detached from parent\n   - Prevents zombie processes and terminal output interference\n\n10. **Tracer.zig** - Performance profiling infrastructure\n    - Provides execution tracing with `-P/--profile` flag\n    - Helps identify performance bottlenecks\n    - Available in Debug and ReleaseSafe builds only\n\n### Key Implementation Notes\n\n- The project links against macOS frameworks: Cocoa, Carbon, and CoreServices\n- Uses packed structs and unmanaged slices for memory efficiency\n- Event handling follows the original skhd's approach but with Zig's safety features\n- Config parsing maintains compatibility with the original DSL\n- **Performance**: The event loop is allocation-free in release builds\n  - Zero allocations during runtime after initialization\n  - CPU usage reduced from ~1.2% to ~0.5% (matching original skhd)\n  - Process name lookups cached for 25μs → 21ns improvement\n\n## Configuration DSL\n\nThe project supports the same configuration syntax as the original skhd, plus additional features:\n\n### Core Syntax\n- Hotkey definitions: `mod - key : command`\n- Modal system: `:: mode_name` or `:: mode_name @` (capture mode)\n- Process-specific bindings: `key [ \"app_name\" : command ]`\n- Key forwarding/remapping: `ctrl - 1 | cmd - 1`\n- Passthrough mode: `cmd - p -> : command` (execute command but still send keypress)\n- Unbound actions: `cmd - a ~` (key is not captured and passes through to the application)\n- String escape sequences: `\\\"` for quotes, `\\\\` for backslash, `\\n` for newline, `\\t` for tab\n\n### Enhanced Features (New in skhd.zig!)\n- **Process groups**: `.define group_name [\"app1\", \"app2\", \"app3\"]`\n  - Use with `@group_name` in process lists\n- **Command definitions**: `.define name : command` with placeholders `{{1}}`, `{{2}}`, etc.\n  - Reference with `@name(\"arg1\", \"arg2\")`\n- **Mode activation with command**: `key ; mode : command`\n  - Executes command when switching to mode\n  - Works in global hotkeys, process lists, and process groups\n\n## Related Codebase\n\nThe original C implementation is available at `/Users/jackieli/personal/skhd/` for reference. Key differences:\n- Original uses C with manual memory management\n- This port uses Zig with explicit allocators and safer memory handling\n- Both share the same configuration format and core functionality\n\nMy active configuration file is located at `/Users/jackieli/.config/skhd/skhdrc`. Make sure to support all features present in this file.\n\n## Test Infrastructure\n\nThe project follows a localized testing strategy:\n- **Unit tests**: Write tests for functions in the same file where they are defined (e.g., Parser.zig, Tokenizer.zig, Hotkey.zig)\n- **Integration tests**: Use `src/tests.zig` only for tests that span multiple modules or test the interaction between different components\n- Use `zig build test` to run all tests (both unit and integration)\n- Test configuration files should be placed in the `testdata/` directory\n- Follow existing test patterns for consistency\n\n**Important**: Always run `zig build test` after completing any implementation to ensure all tests pass and no regressions are introduced.\n\n## Debugging and Profiling\n\n### Debug vs Release Builds\n\n**Important**: The logging and profiling behavior differs between build modes:\n\n- **ReleaseFast builds** (installed via Homebrew or built with `-Doptimize=ReleaseFast`): \n  - Only show errors and warnings, even with `-V`/`--verbose` flag\n  - Profiling (`-P`/`--profile`) is disabled - all tracing code is compiled out for maximum performance\n- **ReleaseSafe builds** (built with `-Doptimize=ReleaseSafe`):\n  - Show errors, warnings, and info messages with `-V`/`--verbose` flag\n  - Profiling (`-P`/`--profile`) is available for production debugging\n- **Debug builds** (default `zig build`): \n  - Show all log levels including debug messages with `-V`/`--verbose` flag\n  - Profiling (`-P`/`--profile`) is available with full trace details\n\n### Debugging Commands\n\n```bash\n# Verbose logging for troubleshooting config issues\nzig build run -- -V\n\n# Test key combinations and hex codes (observe mode)\nzig build run -- -o\n\n# Profile event handling (Debug/ReleaseSafe only)\nzig build && ./zig-out/bin/skhd -P\n\n# Debug memory allocations with real-time tracking\nzig build alloc -- -V\n```\n\n\n### Code Signing (macOS 15+)\n\nCode signing is required for accessibility permissions to persist on macOS 15+. See [docs/CODE_SIGNING.md](docs/CODE_SIGNING.md) for detailed setup instructions.\n\n```bash\nzig build sign  # Optional, skipped in CI\n```\n\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2017 Åsmund Vikane\nCopyright (c) 2025 Jackie Li\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# SKHD in Zig\n\nSimple Hotkey Daemon for macOS, ported from [skhd](https://github.com/koekeishiya/skhd) to Zig.\n\nThis 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.\n\n📋 [View Changelog](CHANGELOG.md)\n\n## v0.1.0-alpha — QMK-style keyboard remapping\n\nskhd.zig now ships a system-level **grabber daemon** that enables remapping the user-session event tap can't reach. New directives:\n\n- **`.remap caps_lock [device <alias>] : escape`** — instant 1:1 swap, applied via `hidutil` (no daemon).\n- **`.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.\n- **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).\n- **`.device <alias> { vendor: 0x…, product: 0x… }`** — scope rules to a specific keyboard, so one config does the right thing on each machine.\n\n**Install the alpha** (Homebrew formula stays on 0.0.24 until v0.1.0 stable; eager testers download from GitHub releases):\n\n```bash\ngh release download v0.1.0-alpha --repo jackielii/skhd.zig --pattern '*-arm64-macos.tar.gz'\ntar -xzf skhd-arm64-macos.tar.gz -C /tmp\nsudo mv /tmp/skhd.app /Applications/\n/Applications/skhd.app/Contents/MacOS/skhd --install-service\n```\n\n`--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.\n\n**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):\n\n```bash\n# 1. Declare the keyboard you want to remap.\n.device builtin { vendor: 0x05AC, product: 0x0342 }\n\n# 2. caps_lock acts like ctrl when held, escape when tapped.\n.remap caps_lock [device builtin] {\n    tap             : escape\n    hold            : lctrl\n    timeout         : 120ms\n    permissive_hold : on\n}\n\n# 3. Hold space to enter a \"function layer\", release to exit.\n:: fn_layer @\n.remap space [device builtin] {\n    tap             : space\n    hold            : fn_layer\n    timeout         : 200ms\n    retro_tap       : on\n}\n\n# 4. While the layer is held, number row → F-row.\nfn_layer < 1 | f1\nfn_layer < 2 | f2\nfn_layer < 3 | f3\n# … etc\n```\n\nSave, then `skhd --restart-service`. Tap `caps_lock` → escape. Hold `caps_lock` + `c` → ctrl-c. Hold `space` then press `1` → F1.\n\n**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).\n\n**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.):\n\n- skhd config: <https://gist.github.com/jackielii/9d24095af57ec35df0d46d38bbbe0449>\n- QMK source-of-truth keymap it mirrors: <https://github.com/jackielii/qmk_firmware/blob/jackie/keyboards/keebio/convolution/keymaps/jackie/keymap.c>\n\nSee [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.\n\n## Installation\n\n### Homebrew\n\nThe easiest way to install skhd.zig:\n\n```bash\nbrew install jackielii/tap/skhd-zig\n```\n\n> 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.\n\n### Pre-built Binaries\n\nApple Silicon only (Intel builds paused as of v0.0.19; build from source for Intel):\n\n- `skhd-arm64-macos.tar.gz` (contains `skhd.app`)\n\nExtract and install:\n\n```bash\ntar -xzf skhd-arm64-macos.tar.gz\nmv skhd.app /Applications/\n# Optional: expose the CLI on your PATH\nsudo ln -sfn /Applications/skhd.app/Contents/MacOS/skhd /usr/local/bin/skhd\n```\n\nThen grant accessibility (see [Granting Accessibility](#granting-accessibility) below).\n\n### Development Builds from GitHub Actions\n\nIf 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.\n\n1. 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`.\n2. Click on the latest successful run\n3. Scroll down to the \"Artifacts\" section\n4. Download the artifact for your desired optimization level:\n   - `skhd-Debug` - Debug build with full debugging symbols\n   - `skhd-ReleaseSafe` - Release build with runtime safety checks\n   - `skhd-ReleaseFast` - Optimized for performance (recommended for daily use)\n   - `skhd-ReleaseSmall` - Optimized for binary size\n\nGitHub wraps each artifact in a `.zip`. Inside is `skhd-arm64-macos.tar.gz`; extract and install the same way as a release tarball:\n\n```bash\nunzip skhd-ReleaseFast.zip\ntar -xzf skhd-arm64-macos.tar.gz\nmv skhd.app /Applications/\nsudo ln -sfn /Applications/skhd.app/Contents/MacOS/skhd /usr/local/bin/skhd\n```\n\nThen grant accessibility (see [Granting Accessibility](#granting-accessibility) below).\n\n### Build from Source\n\n```bash\n# Clone the repository\ngit clone https://github.com/jackielii/skhd.zig\ncd skhd.zig\n\n# Build the .app bundle and code-sign it\n# (required for Accessibility to persist on macOS Tahoe / Sequoia)\nzig build sign-app -Doptimize=ReleaseFast\n\n# Install: symlink the bundle into /Applications, expose the CLI\nln -sfn \"$(pwd)/zig-out/skhd.app\" /Applications/skhd.app\nsudo ln -sfn /Applications/skhd.app/Contents/MacOS/skhd /usr/local/bin/skhd\n```\n\nFor 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.\n\nThe 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.\n\n### Granting Accessibility\n\nskhd captures keyboard events via macOS Core Graphics, which requires Accessibility permission:\n\n1. Open **System Settings → Privacy & Security → Accessibility**\n2. Click **`+`**, navigate to `/Applications/skhd.app`, add it\n3. Toggle the entry on\n4. Run `skhd --restart-service` (or `skhd --start-service` if not yet running)\n\nYou only need to do this once. The bundle's stable identifier (`com.jackielii.skhd`) means TCC entries persist across rebuilds and `brew upgrade`.\n\nIf your config uses `.remap` or `.taphold` rules, macOS will additionally\nprompt you for **Input Monitoring** the first time the grabber starts.\nThe grabber binary lives inside the same `skhd.app` bundle, so the same\nbundle identifier covers it — one click in **System Settings → Privacy\n& Security → Input Monitoring** approves both processes.\n\n## Running as Service\n\nAfter installation, run skhd as a service for automatic startup:\n\n```bash\n# Install and start the service\nskhd --install-service\nskhd --start-service\n\n# Check if skhd is running properly\nskhd --status\n\n# Restart service (useful for restarting after giving accessibility permissions)\nskhd --restart-service\n\n# Stop service\nskhd --stop-service\n\n# Uninstall service\nskhd --uninstall-service\n```\n\nThe service will:\n- Start automatically on login\n- Write logs to `~/Library/Logs/skhd.log`\n- Use your config from `~/.config/skhd/skhdrc` or `~/.skhdrc`\n- Automatically reload on config changes\n\n## Features\n\n### Core Functionality\n\n- **Event capturing**: Uses macOS Core Graphics Event Tap for system-wide keyboard event interception\n- **Hotkey mapping**: Maps key combinations to shell commands with full modifier support\n- **Process-specific bindings**: Different commands for different applications\n- **Key forwarding/remapping**: Remap keys to other key combinations\n- **Modal system**: Multi-level modal hotkey system with capture modes\n- **Configuration file**: Compatible with original skhd configuration format\n- **Hot reloading**: Automatic config reload on file changes\n- **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).\n\n### Additional Features (New in skhd.zig!)\n\n- **Aliases**: Name a modifier combo or a single key (`.alias $hyper cmd + alt + ctrl + shift`, `.alias $grave 0x32`)\n- **Mouse buttons**: Bind on `mouse1`–`mouse5` (e.g. `cmd - mouse1 : ...`); use `->` for passthrough so the click still reaches the app\n- **Process groups**: Define named groups of applications for cleaner configs\n- **Command definitions**: Define reusable commands with placeholders to reduce repetition\n- **Key Forwarding**: Forward / remap key binding to another key binding\n- **Mode activation with command**: Execute a command when switching modes (e.g., `cmd - w ; window : echo \"Window mode\"`)\n- **`.device` + `.remap` (v0.1.0-alpha)**: per-device HID-layer remapping, both colon (1:1) and block (tap/hold) forms.\n- **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.\n\n### Command-Line Interface\n\n- `--version` / `-v` - Display version information\n- `--help` - Show usage information\n- `-c` / `--config` - Specify config file location\n- `-o` / `--observe` - Observe mode (echo keycodes and modifiers)\n- `-V` / `--verbose` - Debug output with detailed logging\n- `-k` / `--key` - Synthesize keypress for testing\n- `-t` / `--text` - Synthesize text input\n- `-r` / `--reload` - Signal reload to running instance\n- `-h` / `--no-hotload` - Disable hotloading\n- `-P` / `--profile` - Profile event handling (Debug and ReleaseSafe builds only)\n\n### Service Management\n\n- `--install-service` - Install launchd service (also auto-installs the DriverKit dext + grabber if your config uses `.remap`/`.taphold`)\n- `--uninstall-service` - Remove launchd service\n- `--start-service` - Start as service\n- `--restart-service` - Restart service\n- `--stop-service` - Stop service\n- `--status` - Combined health: agent PID, event tap, grabber, dext, TCC\n- PID file management (`/tmp/skhd_$USER.pid`)\n- Service logging (`~/Library/Logs/skhd.log`)\n\n### System Grabber (caps_lock-class tap-hold, opt-in)\n\n- `--install-grabber` - Install `skhd-grabber` LaunchDaemon (sudo)\n- `--uninstall-grabber` - Remove `skhd-grabber` LaunchDaemon (sudo)\n- `--install-dext` - Install the pinned Karabiner DriverKit VirtualHIDDevice (and its launchd plist)\n- `--grabber-status` - Drill into the grabber dependency chain (socket, dext version, IOKit match)\n- Grabber logging (`/var/log/skhd-grabber.log`)\n\n### Advanced Features\n\n- **Blacklisting**: Exclude applications from hotkey processing\n- **Shell customization**: Use custom shell for command execution\n- **Left/right modifier distinction**: Support for lcmd, rcmd, lalt, ralt, etc.\n- **Special key support**: Function keys, media keys, arrow keys\n- **Passthrough mode**: Execute command but still send keypress to application\n- **Config includes**: Load additional config files with `.load` directive\n- **Comprehensive error reporting**: Detailed error messages with line numbers\n- **Per-device HID remapping**: colon-form `.remap` for instant 1:1 swaps, block-form for tap-vs-hold semantics\n- **Layer holds**: `hold:` can target a skhd mode instead of a key, so holding the source key activates a layer\n- **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\n\n### Build Commands\n\n```bash\n# Build the project (creates executable in zig-out/bin/)\nzig build\n\n# Build in release mode with optimizations\nzig build -Doptimize=ReleaseFast\n\n# Run the application\nzig build run\n\n# Run with arguments\nzig build run -- -V -c ~/.config/skhd/skhdrc\n\n# Run tests\nzig build test\n```\n\n## Configuration & Usage\n\n### Default Configuration Locations\n\nskhd.zig looks for configuration files in the following order:\n\n1. Path specified with `-c` flag\n2. `~/.config/skhd/skhdrc`\n3. `~/.skhdrc`\n\nThe configuration syntax is fully compatible with the original skhd. See [SYNTAX.md](SYNTAX.md) for the complete syntax reference and grammar.\n\n### Configuration Directives\n\n```bash\n# Use custom shell (skips interactive shell overhead)\n.shell \"/bin/dash\"\n\n# Blacklist applications (skip hotkey processing)\n.blacklist [\n    \"dota2\"\n    \"Microsoft Remote Desktop\"\n    \"VMware Fusion\"\n]\n\n# Load additional config files\n.load \"~/.config/skhd/extra.skhdrc\"\n\n# Define aliases (New in skhd.zig!)\n.alias $hyper cmd + alt + ctrl + shift   # modifier alias\n.alias $super cmd + alt\n.alias $grave 0x32                        # key alias (UK keyboard backtick)\n\n# Define process groups for reuse (New in skhd.zig!)\n.define terminal_apps [\"kitty\", \"wezterm\", \"terminal\"]\n.define native_apps [\"kitty\", \"wezterm\", \"chrome\", \"whatsapp\"]\n.define browser_apps [\"chrome\", \"safari\", \"firefox\", \"edge\"]\n\n# Define reusable commands with placeholders (New in skhd.zig!)\n.define yabai_focus : yabai -m window --focus {{1}} || yabai -m display --focus {{1}}\n.define yabai_swap : yabai -m window --swap {{1}} || (yabai -m window --display {{1}} && yabai -m display --focus {{1}})\n.define toggle_app : open -a \"{{1}}\" || osascript -e 'tell app \"{{1}}\" to quit'\n.define resize_window : yabai -m window --resize {{1}}:{{2}}:{{3}}\n.define toggle_scratchpad : yabai -m window --toggle {{1}} || open -a \"{{2}}\"\n\n# Declare a keyboard by VendorID/ProductID (v0.1.0-alpha)\n# See \"Device-aware remapping\" below for full details.\n.device builtin { vendor: 0x05AC, product: 0x0342 }\n\n# Per-device HID remap — colon form (1:1 swap, applied via hidutil)\n.remap caps_lock [device builtin] : escape\n\n# Per-device tap-hold (routed through skhd-grabber)\n.remap caps_lock [device builtin] {\n    tap  : escape\n    hold : lctrl\n}\n```\n\n### Basic Hotkey Syntax\n\n```bash\n# Basic format: modifier - key : command\ncmd - a : echo \"Command+A pressed\"\n\n# Multiple modifiers\ncmd + shift - t : open -a Terminal\n\n# Different modifier combinations\nctrl - h : echo \"Control+H\"\nalt - space : echo \"Alt+Space\"\nshift - f1 : echo \"Shift+F1\"\n```\n\n### Supported Modifiers\n\n```bash\n# Basic modifiers\ncmd     # Command key\nctrl    # Control key\nalt     # Alt/Option key\nshift   # Shift key\nfn      # Function key\n\n# Left/right specific modifiers\nlcmd, rcmd    # Left/right Command\nlctrl, rctrl  # Left/right Control\nlalt, ralt    # Left/right Alt\nlshift, rshift # Left/right Shift\n\n# Special modifier combinations\nhyper   # cmd + shift + alt + ctrl\nmeh     # shift + alt + ctrl\n```\n\n### Special Keys\n\n```bash\n# Navigation keys\ncmd - left : echo \"Left arrow\"\ncmd - right : echo \"Right arrow\"\ncmd - up : echo \"Up arrow\"\ncmd - down : echo \"Down arrow\"\n\n# Special keys\ncmd - space : echo \"Space\"\ncmd - return : echo \"Return/Enter\"\ncmd - tab : echo \"Tab\"\ncmd - escape : echo \"Escape\"\ncmd - delete : echo \"Delete/Backspace\"\ncmd - home : echo \"Home\"\ncmd - end : echo \"End\"\ncmd - pageup : echo \"Page Up\"\ncmd - pagedown : echo \"Page Down\"\n\n# Function keys\ncmd - f1 : echo \"F1\"\ncmd - f12 : echo \"F12\"\n\n# Media keys\nsound_up : echo \"Volume Up\"\nsound_down : echo \"Volume Down\"\nmute : echo \"Mute\"\nbrightness_up : echo \"Brightness Up\"\nbrightness_down : echo \"Brightness Down\"\n```\n\n### Process-Specific Bindings\n\n```bash\n# Different commands for different applications\ncmd - n [\n    \"terminal\" : echo \"New terminal window\"\n    \"safari\"   : echo \"New safari window\"\n    \"finder\"   : echo \"New finder window\"\n    *          : echo \"New window in other apps\"\n]\n```\n\n### Key Forwarding/Remapping\n\n```bash\n# Keyboard layout fixes\n0xa | 0x32             # UK keyboard § to `\nshift - 0xa | shift - 0x32  # shift - § to ~\n\n# Function key navigation (for laptop keyboards)\nfn - j | down\nfn - k | up\nfn - h | left\nfn - l | right\n\n# When you have cmd - number for yabai spaces,\n# and you still want the cmd - number to work in applications\nctrl - 1 | cmd - 1\nctrl - 2 | cmd - 2\nctrl - 3 | cmd - 3\n```\n\n### Passthrough Mode\n\n```bash\n# Execute command but still send keypress to application\ncmd - p -> : echo \"This runs but Cmd+P still goes to app\"\n```\n\n### Modal Workflow with Visual Indicators\n\n```bash\n# Window management mode with anybar visual indicator\n# Install anybar: brew install --cask anybar\n\n# Define window management mode for warp/stack operations\n# Use anybar to indicate the mode: https://github.com/tonsky/AnyBar\n:: winmode @ : echo -n \"red\" | nc -4u -w0 localhost 1738\n:: default : echo -n \"hollow\" | nc -4u -w0 localhost 1738\n\n# Enter window mode with meh + m (shift + alt + ctrl + m)\nmeh - w ; winmode\nwinmode < escape ; default\nwinmode < meh - w ; default\n\n# Alternative: Enter window mode AND show notification (New in skhd.zig!)\n# This executes the command when switching to the mode\n# It allows for different commands to execute and switch to another mode\nmeh - w ; winmode : osascript -e 'display notification \"Window mode active\" with title \"skhd\"'\nwinmode < escape ; default : osascript -e 'display notification \"Normal mode\" with title \"skhd\"'\n\n# Focus operations - basic hjkl for focus\nwinmode < h : yabai -m window --focus west || yabai -m display --focus west\nwinmode < j : yabai -m window --focus south || yabai -m display --focus south\nwinmode < k : yabai -m window --focus north || yabai -m display --focus north\nwinmode < l : yabai -m window --focus east || yabai -m display --focus east\n\n# Move operations - shift + hjkl for moving\nwinmode < shift - h : yabai -m window --move rel:-80:0\nwinmode < shift - j : yabai -m window --move rel:0:80\nwinmode < shift - k : yabai -m window --move rel:0:-80\nwinmode < shift - l : yabai -m window --move rel:80:0\n\n# Warp operations - alt + shift + hjkl for warping\nwinmode < alt + shift - h : yabai -m window --warp west\nwinmode < alt + shift - j : yabai -m window --warp south\nwinmode < alt + shift - k : yabai -m window --warp north\nwinmode < alt + shift - l : yabai -m window --warp east\n\n# Stack operations - ctrl + shift + hjkl for stacking\nwinmode < ctrl + shift - h : yabai -m window --stack west\nwinmode < ctrl + shift - j : yabai -m window --stack south\nwinmode < ctrl + shift - k : yabai -m window --stack north\nwinmode < ctrl + shift - l : yabai -m window --stack east\n\n# Stack management shortcuts\nwinmode < s : yabai -m window --insert stack  # Toggle stack mode\nwinmode < u : yabai -m window --toggle float; yabai -m window --toggle float  # Unstack window\nwinmode < n : yabai -m window --focus stack.next  # Navigate stack next\nwinmode < p : yabai -m window --focus stack.prev  # Navigate stack prev\n\n# Resize submode\nwinmode < r ; resize\n:: resize @ : echo -n \"orange\" | nc -4u -w0 localhost 1738\nresize < h : yabai -m window --resize left:-20:0\nresize < j : yabai -m window --resize bottom:0:20\nresize < k : yabai -m window --resize top:0:-20\nresize < l : yabai -m window --resize right:20:0\nresize < escape ; winmode\n```\n\n### Window Management Example\n\n```bash\n# Focus windows using command definitions (New in skhd.zig!)\ncmd - h : @yabai_focus(\"west\")\ncmd - j : @yabai_focus(\"south\")\ncmd - k : @yabai_focus(\"north\")\ncmd - l : @yabai_focus(\"east\")\n\n# Move/swap windows using command definitions\ncmd + shift - h : @yabai_swap(\"west\")\ncmd + shift - j : @yabai_swap(\"south\")\ncmd + shift - k : @yabai_swap(\"north\")\ncmd + shift - l : @yabai_swap(\"east\")\n\n# Resize windows using command definitions\ncmd + ctrl - h : @resize_window(\"left\", \"-20\", \"0\")\ncmd + ctrl - l : @resize_window(\"right\", \"20\", \"0\")\n\n# Switch spaces\ncmd - 1 : yabai -m space --focus 1\ncmd - 2 : yabai -m space --focus 2\n```\n\n### Application Launching Example\n\n```bash\n# Quick app launching (traditional way)\nalt - return : open -a Terminal\nalt - b : open -a Safari\n\n# Toggle apps using command definitions (New in skhd.zig!)\nalt - f : @toggle_app(\"Finder\")\nalt - c : @toggle_app(\"Visual Studio Code\")\n\n# Scratchpad apps with yabai (New in skhd.zig!)\n# In yabairc: yabai -m rule --add app=\"^YouTube Music$\" scratchpad=music grid=11:11:1:1:9:9\nalt - m : @toggle_scratchpad(\"music\", \"YouTube Music\")\nalt - n : @toggle_scratchpad(\"notes\", \"Notes\")\n```\n\n### Text Editing Enhancements Example\n\n```bash\n# Linux-style word navigation and deletion\nctrl - backspace [\n    @native_apps ~         # Terminal apps handle natively\n    *            | alt - backspace  # Other apps: delete word\n]\n\nctrl - left [\n    @native_apps ~         # Terminal apps handle natively\n    *            | alt - left       # Other apps: move word left\n]\n\nctrl - right [\n    @native_apps ~         # Terminal apps handle natively\n    *            | alt - right      # Other apps: move word right\n]\n\n# Home/End key behavior (with shift for selection)\nhome [\n    @native_apps ~         # Terminal apps handle natively\n    *            | cmd - left       # Other apps: line start\n]\n\nshift - home [\n    @native_apps ~         # Terminal apps handle natively\n    *            | cmd + shift - left  # Other apps: select to line start\n]\n\n# Ctrl+Home/End for document navigation\nctrl - home [\n    @native_apps ~         # Terminal apps handle natively\n    *            | cmd - up         # Other apps: document start\n]\n\nctrl - end [\n    @native_apps ~         # Terminal apps handle natively\n    *            | cmd - down       # Other apps: document end\n]\n```\n\n\n## Device-aware remapping (`.device` + `.remap`)\n\n`.remap` rewrites keys at the HID layer per device. Two forms:\n\n**Colon form** — instant 1:1 swap, applied via `hidutil` (no daemon needed):\n\n```\n# Declare the device once, by VendorID/ProductID.\n.device builtin { vendor: 0x05AC, product: 0x0342 }\n\n# UK ISO MacBook: make § (top-left) act as the ISO grave key, so it types `.\n.remap non_us_backslash [device builtin] : grave\n```\n\n**Block form** — tap-hold semantics (caps_lock → tap=escape / hold=ctrl,\nspace → fn_layer, etc.). Goes through `skhd-grabber` (see below) because\nhidutil can't do tap vs. hold.\n\n```\n.remap caps_lock [device builtin] {\n    tap             : escape\n    hold            : lctrl\n    timeout         : 120ms\n    permissive_hold : on\n    retro_tap       : off\n}\n\n.remap space [device builtin] {\n    tap             : space\n    hold            : fn_layer\n    timeout         : 200ms\n    permissive_hold : on\n    retro_tap       : on\n}\n```\n\nSource/destination names use HID-standard physical-position naming\n(layout-independent) — different from the macOS virtual-keycode names\nshown by `skhd -o`. Run `skhd --grabber-status` or check\n`src/HidKeyMap.zig` for the full list. Common: `a-z`, `0-9`, `caps_lock`,\n`escape`, `space`, `return`, `tab`, `backspace`, `lctrl`, `lshift`, `lalt`,\n`lcmd` (and `r*` variants), `f1..f20`, `minus`, `equal`, `lbracket`,\n`rbracket`, `backslash`, `semicolon`, `quote`, `grave`, `comma`,\n`period`, `slash`, `non_us_backslash`.\n\n## skhd-grabber: caps_lock-class tap-hold\n\nmacOS's user-level event tap can't see caps_lock or rewrite it cleanly\nwithout LED toggle artifacts. Block-form `.remap` rules go through a\nsmall system daemon (`skhd-grabber`) that runs as root, seizes the\nmatched keyboard via IOHIDManager, and injects through the Karabiner\nDriverKit virtual HID device.\n\n### Install\n\n```bash\n# Single command — installs the per-user agent and, if your config has\n# caps_lock-class rules with a connected target device, prompts (Y/n)\n# to install the system grabber via sudo. The same prompt also auto-\n# downloads + installs the Karabiner DriverKit .pkg if it's missing\n# and writes the launchd plist for its userland daemon (the .pkg's\n# postinstall is a no-op `killall`, so we wire up launchd ourselves —\n# but we skip it cleanly when Karabiner-Elements is already managing\n# that label via SMAppService).\nskhd --install-service\n```\n\nAfter the grabber install succeeds the agent triggers the **Input\nMonitoring** approval dialog. Granting it to `skhd.app` covers the\ngrabber too: both binaries are signed with the same bundle ID and\nthe grabber runs from inside `skhd.app/Contents/MacOS/`, so TCC\nbundle-keys the grant — one click, both processes covered. No\nmanual \"add `/usr/local/libexec/skhd-grabber` to Input Monitoring\"\nstep.\n\nIf the prompt didn't fire (e.g. you added `.remap` rules later) or\nyou want to install the grabber separately:\n\n```bash\nsudo skhd --install-grabber\n```\n\nDiagnostic walk-through of every prerequisite (dext, VHIDD daemon,\ngrabber plist + process, IPC socket):\n\n```bash\nskhd --grabber-status\n```\n\n### Dependencies\n\n`skhd-grabber` requires the **Karabiner DriverKit VirtualHIDDevice**\nextension to inject HID events. The agent's install flow auto-installs\nthe pinned version (currently v6.14.0; sha-256 verified) the first time\nyou run `--install-service`, so most users never touch this directly.\nIf you'd rather install it ahead of time:\n\n```bash\nskhd --install-dext     # downloads + installs the pkg, writes the\n                        # VHIDD daemon launchd plist (or skips if\n                        # Karabiner-Elements is already handling it)\n```\n\nUpstream releases: https://github.com/pqrs-org/Karabiner-DriverKit-VirtualHIDDevice\n\nAfter install, macOS will prompt you to approve the system extension\nin **System Settings → General → Login Items & Extensions → Driver\nExtensions**.\n\n### Uninstall\n\n```bash\nskhd --uninstall-service       # removes the LaunchAgent\nsudo skhd --uninstall-grabber  # removes skhd-grabber + the VHIDD\n                               # daemon launchd plist we wrote\n```\n\n`--uninstall-service` prints any follow-up commands (the grabber\nisn't auto-removed because it's a separate sudo step). For the\nKarabiner DriverKit pkg files and the kernel-loaded dext (pqrs's\ndomain), run their uninstall scripts under\n`/Library/Application Support/org.pqrs/Karabiner-DriverKit-VirtualHIDDevice/scripts/uninstall/`,\nor toggle the dext off via System Settings → Login Items &\nExtensions → Driver Extensions.\n\n### Mac Studio / external keyboard only\n\nIf you share one config across a laptop and a desktop, `.remap`\nblock-form rules targeting the laptop's built-in keyboard simply\ndon't fire on the desktop — `--install-service` and the agent both\ndetect that the target device isn't connected and skip the grabber\nentirely on that machine. No need to install the grabber or the dext\non a machine that doesn't need them.\n\n### Caveats\n\n- **Signing**: setting `HIDKeyboardCapsLockDelayOverride` requires an\n  Apple Developer ID signature. Unsigned builds fall back to a reactive\n  workaround: the grabber reads the OS caps_lock state via\n  `CGEventSourceFlagsState` and, when Apple's firmware-level toggle\n  fires, injects a vhidd caps_lock toggle to flip it back. Works\n  cleanly in practice (no LED flash); see `src/grabber/HidSeize.zig`\n  for the rationale.\n- **Coexistence with Karabiner-Elements**: KE registers the **same**\n  VHIDD daemon launchd label (`org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon`)\n  via `SMAppService.daemon`, so we share that piece — `--install-dext`\n  detects KE's registration and skips writing our own plist. But the\n  *grabber* layer (`karabiner_grabber` for KE, `skhd-grabber` for us)\n  still wants exclusive seize on the same keyboard. If you're running\n  Karabiner-Elements, disable its grabber\n  (`sudo launchctl bootout system/org.pqrs.service.daemon.karabiner_grabber`)\n  before starting `skhd-grabber`. `skhd --status` and\n  `--install-grabber` flag this conflict when detected.\n- **F-row behavior**: with macOS's \"Use F1, F2...\" setting **off**\n  (default), the grabber translates F1..F12 keyboard events to the\n  appropriate Consumer / Apple-Vendor media events (volume,\n  brightness, mission control, …) so the F-row stays \"media keys\"\n  under seize.\n\n## Built on Karabiner-Elements\n\nThis 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.\n\n### What Karabiner-Elements does (and we don't)\n\nKarabiner-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:\n\n- **One config format** — your existing `.skhdrc` plus a few new directives. No JSON, no GUI, no separate rule-set system.\n- **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).\n- **No menu bar app, no preferences pane, no event viewer**. Diagnostics live in `skhd --status` and `skhd --grabber-status`.\n\nIf you need anything more sophisticated than the four directives in this README, Karabiner-Elements is unequivocally the better choice.\n\n### What we borrow (with credit)\n\n**From Karabiner — architecture and runtime infrastructure:**\n\n- **[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.\n- **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.\n- **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.\n\n**From QMK — tap-hold parameters and defaults:**\n\nWe 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.\n\n- `timeout` ↔ QMK `TAPPING_TERM` (default 200ms).\n- `permissive_hold` ↔ QMK [`PERMISSIVE_HOLD`](https://github.com/qmk/qmk_firmware/blob/master/docs/tap_hold.md#permissive-hold).\n- `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).\n- `retro_tap` ↔ QMK [`RETRO_TAPPING`](https://github.com/qmk/qmk_firmware/blob/master/docs/tap_hold.md#retro-tapping).\n\nIf 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.\n\n### Coexistence\n\nskhd-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.\n\nIf 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.\n\n> 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.\n\n## Testing and Debugging\n\n### Quick health check\n\nWhen something isn't working, start with these:\n\n```bash\nskhd --status            # one-line summary: agent, grabber, dext, TCC\nskhd --grabber-status    # drills into the grabber dependency chain\n                         # (socket, dext version, IOKit match, …)\n```\n\n`--status` is the fastest way to spot a missing piece (e.g. agent running\nbut grabber not installed, or dext loaded but not enabled).\n`--grabber-status` is what to run when a `.remap`/`.taphold` rule isn't\nfiring.\n\n### Logs\n\nTwo processes, two log files:\n\n```bash\n# Agent (user-session skhd) — config parsing, event tap, hotkey dispatch.\ntail -f ~/Library/Logs/skhd.log\n\n# skhd-grabber (root LaunchDaemon) — only present if you installed the\n# grabber. Captures HID seize, tap-hold timing, layer pushes, IPC traffic.\nsudo tail -f /var/log/skhd-grabber.log\n```\n\nFor unified-logging captures across both processes:\n\n```bash\nlog show --last 5m --predicate 'process == \"skhd\" OR process == \"skhd-grabber\"'\n```\n\n### Build modes (logging + profiling are mode-gated)\n\n| Build                            | `-V` shows               | `-P` profiling                  |\n|----------------------------------|--------------------------|---------------------------------|\n| ReleaseFast (Homebrew default)   | errors + warnings only   | disabled (compiled out)         |\n| ReleaseSafe                      | + info                   | available                       |\n| Debug (`zig build`)              | + debug                  | available with full traces      |\n\nHomebrew installs are ReleaseFast, so `-V` against the installed binary is\nintentionally quiet. To dig into a hotkey misbehaviour, run a Debug or\nReleaseSafe build directly:\n\n```bash\nzig build run -- -V                              # debug logs\nzig build -Doptimize=ReleaseSafe && \\\n    ./zig-out/bin/skhd -V                        # info logs, prod-shaped binary\n```\n\nVerbose mode also preserves child-command stdout/stderr (useful for\ndiagnosing why a `: command` is silent), at a small per-event cost.\n\n### Observe and synthesize\n\n```bash\nskhd -o                       # echo every keycode + modifier the tap sees\nskhd -k \"cmd + shift - t\"     # synthesize a keypress\nskhd -t \"hello world\"         # synthesize text\nskhd -r                       # reload running instance's config\n```\n\n`skhd -o` prints macOS *virtual* keycodes (e.g. `0x35` for escape).\n`.remap`/`.taphold` rules use HID-standard names (e.g. `escape`) — see\n[Device-aware remapping](#device-aware-remapping-device--remap).\n\n### Profiling\n\n```bash\nzig build && ./zig-out/bin/skhd -P                # Debug\nzig build -Doptimize=ReleaseSafe && \\\n    ./zig-out/bin/skhd -P                         # ReleaseSafe — closer to prod\n```\n\nCtrl-C prints the trace summary.\n\n### Allocation tracking\n\n```bash\nzig build alloc -- -V\n```\n\nThe event loop is allocation-free in release builds, so any allocation in\nthe hot path during interactive use is a regression.\n\n### Common problem → first thing to check\n\n| Symptom | Start here |\n|---------|-----------|\n| Hotkey isn't firing | `skhd --status` — agent running? Accessibility granted? |\n| 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 |\n| `.remap` / `.taphold` does nothing | `skhd --grabber-status` — dext enabled? grabber running? device matched? |\n| Caps lock LED toggles weirdly | `/var/log/skhd-grabber.log`; search for `HIDKeyboardCapsLockDelayOverride` |\n| Karabiner-Elements also installed | `sudo launchctl bootout system/org.pqrs.service.daemon.karabiner_grabber` |\n\n### Clean slate\n\n```bash\nskhd --uninstall-service          # removes the LaunchAgent\nsudo skhd --uninstall-grabber     # removes skhd-grabber\n                                  # (Karabiner DriverKit dext stays — see Uninstall above)\n\nsystemextensionsctl list          # inspect dext activation/enabled state\n```\n\n## Contributing\n\n1. Fork the repository\n2. Create a feature branch\n3. Make your changes\n4. Run tests: `zig build test`\n5. Submit a pull request\n\n## License\n\nThis project maintains compatibility with the original skhd license.\n"
  },
  {
    "path": "SYNTAX.md",
    "content": "# SKHD Configuration Syntax Reference\n\nThis 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.\n\n## Grammar Overview\n\nThe configuration syntax follows these formal rules:\n\n```\nhotkey       = <mode> '<' <action> | <action>\n\nmode         = 'name of mode' | <mode> ',' <mode>\n\naction       = <keysym> '[' <proc_map_lst> ']'   | <keysym> '->' '[' <proc_map_lst> ']'\n               <keysym> ':' <command>            | <keysym> '->' ':' <command>\n               <keysym> ';' <mode_activation>    | <keysym> '->' ';' <mode_activation>\n               <keysym> '~'\n\nkeysym       = <mod> '-' <key> | <key>\n\nmod          = 'modifier keyword' | <mod> '+' <mod>\n\nkey          = <literal> | <keycode>\n\nliteral      = 'single letter or built-in keyword'\n\nkeycode      = 'apple keyboard kVK_<Key> values (0x3C)'\n\nproc_map_lst = * <proc_map>\n\nproc_map     = <string> ':' <command>         | <string> '~'   |\n               '*'      ':' <command>         | '*'      '~'   |\n               <string> ';' <mode_activation> |\n               '*'      ';' <mode_activation> |\n               '@' <group_name> ':' <command> |\n               '@' <group_name> '~'           |\n               '@' <group_name> ';' <mode_activation>\n\nstring       = '\"' 'sequence of characters' '\"'\n\ngroup_name   = 'process group name defined with .define directive'\n\ncommand      = <shell_command> | <command_reference>\n\nshell_command = command is executed through '$SHELL -c' and\n                follows valid shell syntax. if the $SHELL environment\n                variable is not set, it will default to '/bin/bash'.\n                when bash is used, the ';' delimiter can be specified\n                to chain commands.\n\n                to allow a command to extend into multiple lines,\n                prepend '\\' at the end of the previous line.\n\n                an EOL character signifies the end of the bind.\n\ncommand_reference = '@' <identifier> |\n                    '@' <identifier> '(' <arg_list> ')'\n\narg_list     = <string> | <string> ',' <arg_list>\n\nmode_activation = <mode> | <mode> ':' <command>\n\n->           = keypress is not consumed by skhd\n\n*            = matches every application not specified in <proc_map_lst>\n\n~            = application is unbound and keypress is forwarded per usual\n```\n\n## Mode Activation\n\nMode activation allows switching between different hotkey modes. The syntax is:\n\n```\nmode_activation = <mode> | <mode> ':' <command>\n```\n\n- `;` followed by a mode name switches to that mode\n- An optional `:` followed by a command executes that command when switching modes\n\n### Examples:\n- `cmd - w ; window` - Switch to window mode\n- `cmd - w ; window : echo \"Window mode\"` - Switch to window mode and execute command\n- `escape ; default` - Switch back to default mode\n\nMode activation can be used in:\n1. **Global hotkeys**: `cmd - w ; window`\n2. **Process-specific bindings**: `\"terminal\" ; vim_mode`\n3. **Process group bindings**: `@browsers ; browser_mode`\n\n## Mode Declaration\n\nModes are declared using the following syntax:\n\n```\nmode_decl = '::' <name> '@' ':' <command> | '::' <name> ':' <command> |\n            '::' <name> '@'               | '::' <name>\n\nname      = desired name for this mode\n\n@         = capture keypresses regardless of being bound to an action\n\ncommand   = command is executed through '$SHELL -c'\n```\n\n## Modifiers\n\n### Basic Modifiers\n- `cmd` - Command key (⌘)\n- `ctrl` - Control key (⌃)\n- `alt` - Alt/Option key (⌥)\n- `shift` - Shift key (⇧)\n- `fn` - Function key\n\n### Left/Right Specific Modifiers\n- `lcmd`, `rcmd` - Left/right Command\n- `lctrl`, `rctrl` - Left/right Control\n- `lalt`, `ralt` - Left/right Alt\n- `lshift`, `rshift` - Left/right Shift\n\n### Special Modifier Combinations\n- `hyper` - cmd + shift + alt + ctrl\n- `meh` - shift + alt + ctrl\n\n## Key Literals\n\n### Navigation Keys\n- `left`, `right`, `up`, `down` - Arrow keys\n- `home`, `end` - Home/End keys\n- `pageup`, `pagedown` - Page Up/Down\n\n### Special Keys\n- `return` - Return/Enter key\n- `tab` - Tab key\n- `space` - Space bar\n- `backspace` - Delete/Backspace (kVK_Delete)\n- `delete` - Forward Delete (kVK_ForwardDelete)\n- `escape` - Escape key\n- `backtick` - Backtick/Grave Accent key (`)\n\n### Function Keys\n- `f1` through `f20` - Function keys\n\n### Media Keys\n- `sound_up`, `sound_down` - Volume controls\n- `mute` - Mute key\n- `brightness_up`, `brightness_down` - Screen brightness\n- `illumination_up`, `illumination_down` - Keyboard backlight\n- `play`, `previous`, `next` - Media playback\n- `rewind`, `fast` - Media navigation\n\n### Mouse Buttons (New in skhd.zig!)\n- `mouse1` - Left button\n- `mouse2` - Right button\n- `mouse3` - Middle button\n- `mouse4` - Back / fourth button\n- `mouse5` - Forward / fifth button\n\nUsed the same way as keys — combine with modifiers via `-`, or stand alone:\n\n```bash\ncmd - mouse1 : echo \"cmd-click\"\nmeh - mouse3 : open -a \"Mission Control\"\nmouse4 -> : echo \"back button\"   # passthrough: still goes to the app\n```\n\nMouse buttons can also be the **target** of a forward, so you can\nsynthesize a click from a key (e.g. inside a layer):\n\n```bash\nfn_layer < enter | mouse1        # in fn_layer, enter = left-click\nfn_layer < space | cmd - mouse1  # cmd-click via the layer\n```\n\n⚠️ Binding `mouse1` (or any mouse button) **without** a modifier and\n**without** `->` consumes every click in non-blacklisted apps and\neffectively breaks the trackpad. Pair with a modifier (`cmd - mouse1`)\nor use passthrough (`mouse1 -> : ...`) unless you really mean it.\nMouse-up and drag events are not captured (skhd only sees the down\nedge); scroll-wheel events aren't bindable either.\n\n## Configuration Directives\n\nConfiguration directives follow this syntax:\n\n```\ndirective = '.shell' <string> |\n            '.blacklist' '[' <string_list> ']' |\n            '.load' <string> |\n            '.path' <string> | '.path' '[' <string_list> ']' |\n            '.define' <identifier> '[' <string_list> ']' |\n            '.define' <identifier> ':' <command_template> |\n            '.device' <identifier> '{' <device_attrs> '}' |\n            '.remap' <hid_key> <device_clause> ':' <hid_key> |\n            '.remap' <hid_key> <device_clause> '{' <taphold_attrs> '}'\n\ndevice_attrs    = 'vendor:' <hex>  ',' 'product:' <hex>\ndevice_clause   = '[' 'device' <identifier> ']'\ntaphold_attrs   = ( <taphold_attr> )+\ntaphold_attr    = 'tap'                ':' <hid_key>\n                | 'hold'               ':' <hid_key_or_layer>\n                | 'timeout'            ':' <duration>\n                | 'permissive_hold'    ':' ('on' | 'off')\n                | 'hold_on_other_key_press' ':' ('on' | 'off')\n                | 'retro_tap'          ':' ('on' | 'off')\nhid_key_or_layer = <hid_key> | <mode_identifier>   // mode = layer hold\nduration         = <integer> ('ms' | 's')\n\nstring_list = <string> | <string> ',' <string_list>\n```\n\n### HID key names vs macOS virtual keycodes\n\n`.remap` / `.taphold` / `.device` operate at the **HID layer**, before\nmacOS translates keys through the active layout. They use HID-standard\n**layout-independent physical-position** names (`caps_lock`, `lctrl`,\n`non_us_backslash`, `a`–`z`, `0`–`9`, `f1`–`f20`, `minus`, `equal`,\n`lbracket`, `rbracket`, `backslash`, `semicolon`, `quote`, `grave`,\n`comma`, `period`, `slash`, `space`, `return`, `tab`, `escape`,\n`backspace`, etc.) — different from the macOS virtual-keycode names\n(`0x32`, `0x29`, etc.) that the regular `cmd - a` hotkey table uses.\n\nRun `skhd --grabber-status` once installed (or check\n`src/HidKeyMap.zig`) for the full list. These are the same identifiers\nKarabiner-Elements uses, so its [docs](https://karabiner-elements.pqrs.org/docs/help/symbols-and-keycodes/)\nwork as a cross-reference.\n\n### Shell Configuration\n```bash\n.shell \"/bin/zsh\"\n```\n\n### Application Blacklist\n```bash\n.blacklist [\n    \"loginwindow\"\n    \"screensaver\"\n    \"VMware Fusion\"\n]\n```\n\n### Include Files\n```bash\n.load \"~/.config/skhd/extra.skhdrc\"\n```\n\n### Extra PATH entries\nAt startup skhd inherits PATH from the user's login shell (`~/.zprofile`,\n`~/.bash_profile`, fish's `config.fish`, etc.) so commands installed by\nHomebrew and similar work out of the box. For tools whose location isn't in\nthe shell's PATH — most commonly version-manager shims like mise/asdf/nvm —\ndeclare the directory with `.path`:\n\n```bash\n.path \"$HOME/.local/share/mise/shims\"\n.path \"~/.cargo/bin\"\n\n# Or list form:\n.path [\n    \"/opt/custom/bin\"\n    \"$HOME/bin\"\n]\n```\n\n`.path` entries are prepended to PATH (declaration order preserved), so they\ntake precedence over shell-inherited locations. `~` and `$HOME` are\nexpanded; other `$VAR` forms are not — use absolute paths for everything\nelse.\n\n### Aliases (New in skhd.zig!)\n\nGive a name to a modifier combination or to a single key. The name is\nreferenced with a `$` prefix, must start with a letter, and is expanded\nat parse time (zero runtime cost).\n\n#### Modifier alias\n\n```bash\n.alias $hyper cmd + alt + ctrl + shift\n.alias $super cmd + alt\n\n# Use as the modifier prefix of a hotkey\n$hyper - h : echo \"hyper-h\"\n$super - return : open -a Terminal.app\n\n# Combine with other modifiers via '+'\n$super + shift - h : echo \"super+shift+h\"\n\n# A modifier alias may reference an earlier modifier alias\n.alias $mega $super + shift + ctrl\n```\n\n#### Key alias\n\n```bash\n.alias $grave 0x32         # Hex keycode (e.g., UK keyboard backtick)\n.alias $del   delete       # Literal key (carries any implicit fn/nx flag)\n\n# Use after the dash, or standalone\nctrl - $grave : open -a Notes\n$del : echo plain-delete\n\n# A key alias may reference another key alias\n.alias $tilde $grave\n```\n\n#### Rules\n\n- Aliases must be defined before use; redefinition is an error.\n- A **modifier alias** appears in modifier position only (before `-`, or\n  chained with `+`). Using it as a key (`ctrl - $hyper`) is an error.\n- A **key alias** appears in key position only (after `-`, or standalone).\n  Using it as a modifier (`$grave - h`) is an error.\n- Combining modifiers into a baked-in keysym (e.g. `.alias $foo cmd - h`)\n  is not supported — define the modifier and key parts separately and\n  combine them at the use site.\n\n### Process Groups (New in skhd.zig!)\n```bash\n.define terminal_apps [\"kitty\", \"wezterm\", \"terminal\"]\n.define browser_apps [\"chrome\", \"safari\", \"firefox\"]\n\n# Use with @ prefix in proc_map\nctrl - left [\n    @terminal_apps ~\n    *              | alt - left\n]\n```\n\n### Command Definitions (New in skhd.zig!)\n```bash\n# Simple command without placeholders\n.define focus_recent : yabai -m window --focus recent\n\n# Command with placeholders\n.define yabai_focus : yabai -m window --focus {{1}} || yabai -m display --focus {{1}}\n.define window_action : yabai -m window --{{1}} {{2}}\n\n# Use with @ prefix and arguments\ncmd - tab : @focus_recent\ncmd - h : @yabai_focus(\"west\")\ncmd + shift - h : @window_action(\"swap\", \"west\")\n```\n\n### Device Aliases (`.device`)\n\nDeclare a USB keyboard once by `vendor`/`product` ID, then reference it\nby alias from `.remap` / `.taphold` rules. The alias is a config-local\nname — pick anything (`builtin`, `corsair`, `keychron`, …). Find your\nkeyboard's IDs via System Information → USB, or run any `.remap` rule\nwith verbose mode to see currently-attached vendor/product pairs in\nthe log.\n\n```bash\n.device builtin { vendor: 0x05AC, product: 0x0342 }\n.device keychron { vendor: 0x05AC, product: 0x024F }\n```\n\nA config shared between machines targeting different hardware is fine —\nrules whose `[device <alias>]` doesn't match a connected device are\nsilently skipped on that machine. No grabber is installed on a\nmachine without any matching device.\n\n### Key Remapping (`.remap` colon form)\n\nInstant 1:1 key swap, applied via `hidutil`'s `UserKeyMapping` table.\n**No daemon needed** for the colon form — works without installing\nskhd-grabber. Original mappings are saved on startup and restored on\nshutdown so the keyboard isn't left remapped when skhd exits.\n\n```bash\n# UK ISO MacBook: map § (top-left key, HID-named non_us_backslash) to\n# the ISO grave key so it types `.\n.remap non_us_backslash [device builtin] : grave\n\n# Swap caps_lock with escape on an external keyboard.\n.remap caps_lock [device keychron] : escape\n```\n\n**Limitations** (use the block form below instead for these cases):\n- Cannot map `caps_lock` to a modifier — macOS's kernel layer above\n  `hidutil` silently drops `caps_lock → ctrl/shift/alt/cmd`.\n- Cannot do tap-hold or layer-hold semantics.\n\n### Tap-Hold Rules (`.remap` block form)\n\nDistinguish tap vs. hold timing on the same physical key, plus layer\nholds. Routed through `skhd-grabber` (root LaunchDaemon) — see\n[skhd-grabber](README.md#skhd-grabber-caps_lock-class-tap-hold) in the\nREADME for install + permission setup.\n\n```bash\n.remap caps_lock [device builtin] {\n    tap             : escape\n    hold            : lctrl\n    timeout         : 120ms\n    permissive_hold : on\n    retro_tap       : off\n}\n```\n\n**Attributes** — names, semantics, and defaults all follow\n[QMK firmware's tap-hold model](https://github.com/qmk/qmk_firmware/blob/master/docs/tap_hold.md)\n(snake_case keywords, same parameter set as a QMK `config.h`).\nWe deliberately don't use Karabiner-Elements' complex-modifications\nJSON dialect — skhd users want a config that reads like the rest of\n`.skhdrc`, not a separate verbose camelCase format.\n\n| Attribute | Type | Default | QMK equivalent | Description |\n|---|---|---|---|---|\n| `tap` | hid_key | required | `LT(layer, kc)` tap behavior | Key emitted on a quick tap (press + release within `timeout`). |\n| `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). |\n| `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. |\n| `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`. |\n| `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`. |\n| `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). |\n\n### Layer Holds\n\nWhen `hold` references a **mode identifier** instead of a key, the\nsource key acts as a temporary layer activator: hold to enter the\nmode, release to exit. Layer hold rules push IPC messages from grabber\n→ agent so the mode change happens on the agent's run loop (where\nmode bindings are evaluated).\n\n```bash\n# Declare a capture-mode for unbound keys not to leak through.\n:: fn_layer @\n\n# Hold space → enter fn_layer; release → back to default.\n.remap space [device builtin] {\n    tap             : space\n    hold            : fn_layer\n    timeout         : 200ms\n    retro_tap       : on\n}\n\n# While the layer is held, number row maps to F-row.\nfn_layer < 1 | f1\nfn_layer < 2 | f2\nfn_layer < tab | alt - tab          # cmd-tab style app switcher\nfn_layer < 0x1B | f11               # virtual keycodes also work in layer\n```\n\nLayer-hold modes use the same `:: <name> @` declaration syntax as\n[regular modes](#mode-declaration) — capture (`@`) decides whether\nunbound keys in the layer leak through to the focused app or are\nabsorbed.\n\n## Syntax Examples\n\n### Basic Hotkey\n```bash\ncmd - a : echo \"Command+A pressed\"\n```\n\n### Multiple Modifiers\n```bash\ncmd + shift + alt - x : echo \"Complex hotkey\"\n```\n\n### Process-Specific Bindings\n```bash\ncmd - n [\n    \"terminal\" : echo \"New terminal window\"\n    \"safari\"   : echo \"New browser window\"\n    *          : echo \"New window in other apps\"\n]\n```\n\n### Key Forwarding\n```bash\n# Simple forwarding\nctrl - h | left\n\n# Process-specific forwarding\nhome [\n    \"kitty\"    ~           # Let kitty handle it\n    *          | cmd - left # In other apps, send Cmd+Left\n]\n```\n\n### Modal System\n```bash\n# Declare mode\n:: window : echo \"Window mode\"\n\n# Enter mode\ncmd - w ; window\n\n# Enter mode and execute command\ncmd - w ; window : echo \"Switching to window mode\"\n\n# Commands in mode\nwindow < h : yabai -m window --focus west\nwindow < escape ; default : echo \"Returning to default mode\"\n```\n\n### Process-Specific Mode Activation\nMode activation can also be used in process lists, allowing different applications to trigger different modes:\n\n```bash\n# Define terminal and browser app groups\n.define terminal_apps [\"kitty\", \"wezterm\", \"terminal\"]\n.define browser_apps [\"chrome\", \"safari\", \"firefox\"]\n\n# Different apps switch to different modes with Cmd+M\ncmd - m [\n    @terminal_apps ; vim_mode : echo \"Vim mode for terminals\"\n    @browser_apps ; browser_mode : echo \"Browser mode activated\"\n    * ; default : echo \"Back to default\"\n]\n\n# Mode activation with command in process list\ncmd - e [\n    \"code\" ; edit_mode : osascript -e 'display notification \"Edit mode for VS Code\"'\n    \"xcode\" ; edit_mode : osascript -e 'display notification \"Edit mode for Xcode\"'\n    * : echo \"No special mode for this app\"\n]\n```\n\n### Passthrough Mode\n```bash\n# Execute command but still send keypress\ncmd - p -> : echo \"Command runs but Cmd+P goes to app\"\n```\n\n### Multi-line Commands\n```bash\ncmd - x : echo \"Line 1\" ; \\\n          echo \"Line 2\" ; \\\n          echo \"Line 3\"\n```\n\n## Special Syntax Notes\n\n1. **Comments**: Use `#` for comments\n2. **Unbinding**: Use `~` to unbind a key in specific applications\n3. **Wildcard**: Use `*` to match all applications not explicitly specified\n4. **Keycode**: Use hex values like `0x3C` for specific key codes\n5. **Mode Capture**: Use `@` after mode name to capture all keypresses\n\n## Common Patterns\n\n### Vim-like Navigation\n```bash\n# Global navigation\ncmd - h : focus west\ncmd - j : focus south\ncmd - k : focus north\ncmd - l : focus east\n```\n\n### Application Launching\n```bash\nalt - return : open -a Terminal\nalt - b : open -a Safari\n```\n\n### Mode-based Workflows\n```bash\n:: resize @ : echo \"Resize mode\"\ncmd - r ; resize\nresize < h : resize left\nresize < l : resize right\nresize < escape ; default\n```\n\n### Linux-style Text Editing\n```bash\n# Word movement\nctrl - left [\n    @terminal_apps ~\n    *              | alt - left\n]\n\n# Line start/end\nhome [\n    @native_apps ~\n    *            | cmd - left\n]\n```\n"
  },
  {
    "path": "TODO.md",
    "content": "# TODO - Future Features and Improvements\n\nThis file tracks features and improvements that are not yet implemented but could be added in future versions.\n\n## Clean up\n\n- [ ] clean up tests by moving the tests tests.zig to their respective zig files and remove tests.zig\n- [ ] clean up pub declarations where possible\n\n## Advanced Input Handling\n\n### macOS Integration\n- [ ] **Keyboard layout change handling**: Adapt to keyboard layout changes dynamically\n- [ ] **Secure keyboard entry detection**: Detect and handle secure input fields\n- [ ] **macOS notification support**: Show notifications for mode changes and errors\n- [ ] **Locale-aware keycode mapping**: Support for different keyboard layouts and locales\n\n### Mouse Support\n- [ ] **Mouse button support**: Add support for left, right, middle, and extra mouse buttons\n- [ ] **Mouse event handling**: Support mouse clicks, drag, and scroll events in hotkeys\n- [ ] **Mouse gesture recognition**: Basic mouse gesture support for hotkey triggers\n\n## Power Management and System Control\n\n### System Integration\n- [ ] **Power management integration**: Integration with macOS power management\n- [ ] **Sleep system command**: `iokit_power_management_sleep_system` - Command to put system to sleep\n- [ ] **Display control**: Commands to control display brightness, sleep, etc.\n- [ ] **Volume and media control**: Direct system volume and media control commands\n\n### Device Detection\n- [ ] **Input device detection**: Detect and handle multiple keyboards/input devices\n- [ ] **Device-specific mappings**: Different hotkey mappings for different input devices\n- [ ] **USB device hotplug**: Handle USB keyboard connect/disconnect events\n\n## Configuration Enhancements\n\n### Syntax Extensions\n- [ ] **Negation syntax**: Apply hotkeys to all apps except specified ones (e.g., `! [\"kitty\", \"wezterm\"]`)\n- [ ] **Conditional hotkeys**: Hotkeys that activate based on system state (time, app state, etc.)\n\n## User Interface and Experience\n\n### Platform Support\n- [ ] **Universal binary**: Build universal binaries for Intel and Apple Silicon\n\n## Testing and Quality Assurance\n\n### Testing Infrastructure\n- [ ] **Integration tests**: Comprehensive integration test suite\n- [ ] **Performance benchmarks**: Automated performance testing and regression detection\n- [ ] **Fuzzing**: Fuzz testing for configuration parsing and event handling\n\n## Community and Ecosystem\n\n### Community Features\n- [ ] **Configuration sharing**: Platform for sharing configuration files\n"
  },
  {
    "path": "VERSION",
    "content": "0.1.0-alpha\n"
  },
  {
    "path": "assets/Info.plist.grabber.template",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>CFBundleIdentifier</key>\n    <string>__BUNDLE_ID__</string>\n    <key>CFBundleName</key>\n    <string>skhd-grabber</string>\n    <key>CFBundleDisplayName</key>\n    <string>skhd-grabber</string>\n    <key>CFBundleExecutable</key>\n    <string>skhd-grabber</string>\n    <key>CFBundlePackageType</key>\n    <string>APPL</string>\n    <key>CFBundleVersion</key>\n    <string>__VERSION__</string>\n    <key>CFBundleShortVersionString</key>\n    <string>__VERSION__</string>\n    <key>CFBundleInfoDictionaryVersion</key>\n    <string>6.0</string>\n    <key>LSMinimumSystemVersion</key>\n    <string>13.0</string>\n    <key>LSBackgroundOnly</key>\n    <true/>\n    <key>LSUIElement</key>\n    <true/>\n    <key>NSHumanReadableCopyright</key>\n    <string>Released under the MIT License.</string>\n</dict>\n</plist>\n"
  },
  {
    "path": "assets/Info.plist.template",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>CFBundleIdentifier</key>\n    <string>__BUNDLE_ID__</string>\n    <key>CFBundleName</key>\n    <string>skhd</string>\n    <key>CFBundleDisplayName</key>\n    <string>skhd</string>\n    <key>CFBundleExecutable</key>\n    <string>skhd</string>\n    <key>CFBundlePackageType</key>\n    <string>APPL</string>\n    <key>CFBundleVersion</key>\n    <string>__VERSION__</string>\n    <key>CFBundleShortVersionString</key>\n    <string>__VERSION__</string>\n    <key>CFBundleInfoDictionaryVersion</key>\n    <string>6.0</string>\n    <key>LSMinimumSystemVersion</key>\n    <string>13.0</string>\n    <key>LSBackgroundOnly</key>\n    <true/>\n    <key>LSUIElement</key>\n    <true/>\n    <key>NSHumanReadableCopyright</key>\n    <string>Released under the MIT License.</string>\n</dict>\n</plist>\n"
  },
  {
    "path": "assets/LaunchAgent.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>Label</key>\n    <string>com.jackielii.skhd</string>\n    <key>BundleProgram</key>\n    <string>Contents/MacOS/skhd</string>\n    <key>RunAtLoad</key>\n    <true/>\n    <key>KeepAlive</key>\n    <true/>\n    <key>ProcessType</key>\n    <string>Interactive</string>\n    <key>ThrottleInterval</key>\n    <integer>10</integer>\n</dict>\n</plist>\n"
  },
  {
    "path": "assets/karabiner-virtualhiddevice-daemon.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n  LaunchDaemon plist for Karabiner-DriverKit-VirtualHIDDevice's userland\n  helper daemon. Installed by `skhd --install-dext` to\n  /Library/LaunchDaemons/org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon.plist.\n\n  Why we ship this: pqrs's standalone DriverKit .pkg has a no-op\n  postinstall (it just `killall`s the existing daemon and lets launchd\n  respawn). The launchd plist that historically registered the daemon\n  ships with Karabiner-Elements, not the DriverKit pkg itself. Without\n  Karabiner-Elements installed, the daemon never gets a launchd entry —\n  the dext loads but the userland half stays dark, and grabber-side\n  vhidd_server connect attempts fail silently. This plist fills that gap.\n\n  Idempotent — `--install-dext` skips writing it if a file already\n  exists at this path (so a Karabiner-Elements user keeps theirs).\n-->\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>Label</key>\n    <string>org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon</string>\n\n    <key>ProgramArguments</key>\n    <array>\n        <string>/Library/Application Support/org.pqrs/Karabiner-DriverKit-VirtualHIDDevice/Applications/Karabiner-VirtualHIDDevice-Daemon.app/Contents/MacOS/Karabiner-VirtualHIDDevice-Daemon</string>\n    </array>\n\n    <key>RunAtLoad</key>\n    <true/>\n\n    <key>KeepAlive</key>\n    <true/>\n\n    <!--\n      Interactive: don't throttle under user-driven keyboard load. Same\n      reasoning as skhd-grabber's plist.\n    -->\n    <key>ProcessType</key>\n    <string>Interactive</string>\n\n    <key>StandardOutPath</key>\n    <string>/var/log/karabiner-virtualhiddevice-daemon.log</string>\n\n    <key>StandardErrorPath</key>\n    <string>/var/log/karabiner-virtualhiddevice-daemon.log</string>\n</dict>\n</plist>\n"
  },
  {
    "path": "build.zig",
    "content": "const std = @import(\"std\");\n\nfn linkFrameworks(b: *std.Build, exe: *std.Build.Step.Compile) void {\n    // Explicit os_version_min flips Zig out of \"native\" mode, so it stops\n    // auto-adding the macOS SDK to the framework search path. Re-add it\n    // from the SDK path stashed by build().\n    if (sdk_path) |sdk| {\n        exe.addFrameworkPath(.{ .cwd_relative = b.fmt(\"{s}/System/Library/Frameworks\", .{sdk}) });\n        exe.addSystemIncludePath(.{ .cwd_relative = b.fmt(\"{s}/usr/include\", .{sdk}) });\n        exe.addLibraryPath(.{ .cwd_relative = b.fmt(\"{s}/usr/lib\", .{sdk}) });\n    }\n    exe.linkFramework(\"Cocoa\");\n    exe.linkFramework(\"Carbon\");\n    exe.linkFramework(\"CoreServices\");\n    // ServiceManagement: SMAppService.agent / register / unregister, used\n    // by --register-service to register the bundled LaunchAgent with BTM.\n    exe.linkFramework(\"ServiceManagement\");\n    // IOKit: IOHIDManager enumeration in DeviceCheck.zig (decides\n    // whether to dial the grabber based on connected devices).\n    exe.linkFramework(\"IOKit\");\n}\n\n// macOS SDK path resolved once via xcrun and reused for every artifact's\n// framework / include / library search paths.\nvar sdk_path: ?[]const u8 = null;\n\nfn addVersionImport(b: *std.Build, exe: *std.Build.Step.Compile) void {\n    // Get build mode string\n    const mode_str = switch (exe.root_module.optimize.?) {\n        .Debug => \"debug\",\n        .ReleaseSafe => \"safe\",\n        .ReleaseFast => \"fast\",\n        .ReleaseSmall => \"small\",\n    };\n\n    const version_step = b.addSystemCommand(&[_][]const u8{\n        \"sh\", \"-c\",\n        b.fmt(\n            \\\\VERSION=$(cat VERSION | tr -d '\\n')\n            \\\\GIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')\n            \\\\# Check if working tree is dirty\n            \\\\if [ -n \"$(git status --porcelain 2>/dev/null)\" ]; then\n            \\\\    DIRTY=\"-dirty\"\n            \\\\else\n            \\\\    DIRTY=\"\"\n            \\\\fi\n            \\\\# Check if we're on a tagged commit\n            \\\\if git describe --exact-match --tags HEAD >/dev/null 2>&1; then\n            \\\\    # On a tag, just show version-hash\n            \\\\    printf \"%s-%s%s ({s})\" \"$VERSION\" \"$GIT_HASH\" \"$DIRTY\"\n            \\\\else\n            \\\\    # Not on a tag, show version-dev-hash\n            \\\\    printf \"%s-dev-%s%s ({s})\" \"$VERSION\" \"$GIT_HASH\" \"$DIRTY\"\n            \\\\fi\n        , .{ mode_str, mode_str }),\n    });\n    version_step.has_side_effects = true;\n\n    const version_file = version_step.captureStdOut();\n    exe.root_module.addAnonymousImport(\"VERSION\", .{\n        .root_source_file = version_file,\n    });\n}\n\n/// Register the embedded launchd plists used by `--install-grabber` and\n/// `--install-dext`. Plists live outside `src/` so anonymous imports are\n/// the right shape (Zig restricts `@embedFile` to within the module's\n/// package). Call this on every binary that links grabber_cli (currently\n/// skhd, skhd-alloc, and unit-test executables).\nfn addGrabberPlistImports(b: *std.Build, exe: *std.Build.Step.Compile) void {\n    exe.root_module.addAnonymousImport(\"grabber_plist\", .{\n        .root_source_file = b.path(\"scripts/com.jackielii.skhd.grabber.plist\"),\n    });\n    exe.root_module.addAnonymousImport(\"vhidd_plist\", .{\n        .root_source_file = b.path(\"assets/karabiner-virtualhiddevice-daemon.plist\"),\n    });\n}\n\nconst track_alloc_option = \"track_alloc\";\n\n// Pinned Karabiner-DriverKit-VirtualHIDDevice version. skhd-grabber's IPC\n// is validated against this exact version of the dext + userland daemon.\n// Same-major versions are assumed wire-compatible (pqrs project follows\n// SemVer); different major triggers a runtime warning. Bump procedure:\n//   1. Update _version to the new tag.\n//   2. Update _url accordingly.\n//   3. `curl -fsSL <url> | shasum -a 256` and paste into _sha256.\n//   4. Test `zig build install-dext` end-to-end on a clean machine.\nconst karabiner_dext_version = \"6.14.0\";\nconst karabiner_dext_url = \"https://github.com/pqrs-org/Karabiner-DriverKit-VirtualHIDDevice/releases/download/v\" ++ karabiner_dext_version ++ \"/Karabiner-DriverKit-VirtualHIDDevice-\" ++ karabiner_dext_version ++ \".pkg\";\nconst karabiner_dext_sha256 = \"ebfb6a643ea98bb7c2e08a4f99353b2a3129e397f4302340443bbd936f12eb1c\";\n\n// Although this function looks imperative, note that its job is to\n// declaratively construct a build graph that will be executed by an external\n// runner.\npub fn build(b: *std.Build) void {\n    // Pin macOS deployment target. Without this, Zig stamps the Mach-O's\n    // LC_BUILD_VERSION minos with the build host's OS version, so binaries\n    // built on macos-latest CI runners (now Tahoe 26) refuse to launch on\n    // macOS 15.x with \"You can't use this version of application 'skhd' with\n    // this version of macOS.\" 13.0 matches the Info.plist's\n    // LSMinimumSystemVersion and is the floor required by SMAppService.\n    const target = b.standardTargetOptions(.{\n        .default_target = .{\n            .os_tag = .macos,\n            .os_version_min = .{ .semver = .{ .major = 13, .minor = 0, .patch = 0 } },\n        },\n    });\n    const optimize = b.standardOptimizeOption(.{});\n\n    // Setting os_version_min above makes Zig treat the target as non-native\n    // and stop auto-resolving the macOS SDK, so framework links fail. Probe\n    // xcrun for the SDK and add its paths to every artifact via\n    // linkFrameworks. Setting b.sysroot would double-prefix paths added with\n    // cwd_relative, so we stash the SDK path in a module-level var instead.\n    if (target.result.os.tag == .macos) {\n        const out = b.run(&.{ \"xcrun\", \"--sdk\", \"macosx\", \"--show-sdk-path\" });\n        sdk_path = std.mem.trim(u8, out, \" \\n\\r\\t\");\n    }\n\n    // Shared protocol module: types + framing for the agent ↔ grabber\n    // IPC. Both binaries (and tests that exercise either side of the\n    // protocol) addImport this so they agree on the wire format.\n    const grabber_protocol_mod = b.createModule(.{\n        .root_source_file = b.path(\"src/grabber_protocol.zig\"),\n        .target = target,\n        .optimize = optimize,\n    });\n\n\n    // Main executable\n    const exe = b.addExecutable(.{\n        .name = \"skhd\",\n        .root_source_file = b.path(\"src/main.zig\"),\n        .target = target,\n        .optimize = optimize,\n    });\n\n    const options = b.addOptions();\n    options.addOption(bool, track_alloc_option, false);\n    options.addOption([]const u8, \"karabiner_dext_version\", karabiner_dext_version);\n    options.addOption([]const u8, \"karabiner_dext_url\", karabiner_dext_url);\n    options.addOption([]const u8, \"karabiner_dext_sha256\", karabiner_dext_sha256);\n\n    linkFrameworks(b, exe);\n    addVersionImport(b, exe);\n    addGrabberPlistImports(b, exe);\n    exe.root_module.addOptions(\"build_options\", options);\n    exe.root_module.addImport(\"grabber_protocol\", grabber_protocol_mod);\n\n    b.installArtifact(exe);\n\n    // skhd-grabber: system daemon (root) for caps_lock-class tap-hold.\n    // Plain Mach-O — installed by `skhd --install-grabber` to\n    // /usr/local/libexec/skhd-grabber and started by launchd. Needs\n    // IOKit (D3 seize + run loop) and CoreFoundation (matching dicts).\n    const grabber_exe = b.addExecutable(.{\n        .name = \"skhd-grabber\",\n        .root_source_file = b.path(\"src/grabber/main.zig\"),\n        .target = target,\n        .optimize = optimize,\n    });\n    if (sdk_path) |sdk| {\n        grabber_exe.addFrameworkPath(.{ .cwd_relative = b.fmt(\"{s}/System/Library/Frameworks\", .{sdk}) });\n        grabber_exe.addSystemIncludePath(.{ .cwd_relative = b.fmt(\"{s}/usr/include\", .{sdk}) });\n        grabber_exe.addLibraryPath(.{ .cwd_relative = b.fmt(\"{s}/usr/lib\", .{sdk}) });\n    }\n    grabber_exe.linkFramework(\"IOKit\");\n    grabber_exe.linkFramework(\"CoreFoundation\");\n    // CoreGraphics for CGEventSourceFlagsState — used to detect when\n    // Apple firmware has toggled caps_lock against our intent so we\n    // can flip it back via a vhidd-injected caps_lock toggle.\n    grabber_exe.linkFramework(\"CoreGraphics\");\n    // SystemConfiguration for SCDynamicStoreCopyConsoleUser — D5\n    // tracks the active console user and only applies rules from\n    // their agent. Multi-user / fast-user-switching support.\n    grabber_exe.linkFramework(\"SystemConfiguration\");\n    grabber_exe.root_module.addImport(\"grabber_protocol\", grabber_protocol_mod);\n    b.installArtifact(grabber_exe);\n\n    // `zig build grabber-app` — build the grabber binary, wrap it in\n    // skhd-grabber-dev.app, and code-sign with the local dev cert.\n    //\n    // Why a .app bundle? macOS Tahoe's TCC keys Input Monitoring (and\n    // other HID-related) grants on bundle ID for .app bundles. A bare\n    // Mach-O is keyed by cdhash + path, which gets invalidated every\n    // rebuild and doesn't even render in System Settings → Input\n    // Monitoring (so the user can't toggle approval). Wrapping the\n    // grabber in a bundle gives it a stable ID, makes it visible in\n    // the privacy panel, and survives `zig build` recompiles. Same\n    // pattern skhd-dev.app uses for the agent.\n    //\n    // The actual binary inside the bundle is signed with skhd-dev-cert\n    // and identifier com.jackielii.skhd.grabber.dev. Run as:\n    //   sudo zig-out/skhd-grabber-dev.app/Contents/MacOS/skhd-grabber [args]\n    const grabber_dev_cert = \"skhd-dev-cert\";\n    const grabber_dev_bundle_id = \"com.jackielii.skhd.grabber.dev\";\n    const installed_grabber_app = b.getInstallPath(.prefix, \"skhd-grabber-dev.app\");\n\n    const grabber_app_cmd = b.addSystemCommand(&[_][]const u8{\n        \"bash\",\n        \"scripts/make-grabber-app.sh\",\n    });\n    grabber_app_cmd.addArg(b.getInstallPath(.bin, grabber_exe.name));\n    grabber_app_cmd.addArg(installed_grabber_app);\n    grabber_app_cmd.addArg(grabber_dev_bundle_id);\n    grabber_app_cmd.step.dependOn(b.getInstallStep());\n\n    const sign_grabber_app_cmd = b.addSystemCommand(&[_][]const u8{\n        \"bash\",\n        \"scripts/codesign.sh\",\n    });\n    sign_grabber_app_cmd.addArg(installed_grabber_app);\n    sign_grabber_app_cmd.setEnvironmentVariable(\"SKHD_CERT\", grabber_dev_cert);\n    sign_grabber_app_cmd.setEnvironmentVariable(\"SKHD_BUNDLE_ID\", grabber_dev_bundle_id);\n    sign_grabber_app_cmd.step.dependOn(&grabber_app_cmd.step);\n\n    const grabber_app_step = b.step(\"grabber-app\", \"Build skhd-grabber-dev.app (signed bundle for TCC-stable Input Monitoring grants)\");\n    grabber_app_step.dependOn(&sign_grabber_app_cmd.step);\n\n    // `zig build run-grabber` — build + sign the bundle, then exec it\n    // under sudo with --foreground. The bundle path is the entry point\n    // because TCC keys Input Monitoring on it; running the bare binary\n    // gets denied silently after the next rebuild invalidates its cdhash.\n    // Extra args after `--` flow through (e.g. `zig build run-grabber --\n    // --socket-path /tmp/x.sock`).\n    const grabber_inner_exe = b.pathJoin(&.{ installed_grabber_app, \"Contents\", \"MacOS\", \"skhd-grabber\" });\n    const run_grabber_cmd = b.addSystemCommand(&[_][]const u8{ \"sudo\", grabber_inner_exe, \"--foreground\" });\n    run_grabber_cmd.step.dependOn(&sign_grabber_app_cmd.step);\n    if (b.args) |args| run_grabber_cmd.addArgs(args);\n\n    const run_grabber_step = b.step(\"run-grabber\", \"Build skhd-grabber-dev.app and run it under sudo --foreground\");\n    run_grabber_step.dependOn(&run_grabber_cmd.step);\n\n    const installed_exe = b.getInstallPath(.bin, exe.name);\n    const installed_app = b.getInstallPath(.prefix, \"skhd.app\");\n\n    // .app bundle step. Wraps the binary into skhd.app so macOS Tahoe's\n    // Accessibility picker accepts it and TCC keys entries by bundle ID\n    // (com.jackielii.skhd) instead of by the binary's path. Inner binary at\n    // skhd.app/Contents/MacOS/skhd is a copy, not a symlink, so codesigning\n    // works. Scripts have bash shebangs and use bash-only `[[ ... ]]` syntax,\n    // so invoke via bash explicitly (`/bin/sh` may not be bash on every\n    // system that runs `zig build`).\n    const app_cmd = b.addSystemCommand(&[_][]const u8{\n        \"bash\",\n        \"scripts/make-app.sh\",\n    });\n    app_cmd.addArg(installed_exe);\n    app_cmd.addArg(installed_app);\n    app_cmd.step.dependOn(b.getInstallStep());\n\n    const app_step = b.step(\"app\", \"Build the skhd.app bundle wrapper\");\n    app_step.dependOn(&app_cmd.step);\n\n    // Code signing.\n    //   `zig build sign`     - signs the bare binary at zig-out/bin/skhd.\n    //   `zig build sign-app` - signs the .app bundle (inner Mach-O + bundle\n    //                          layer); use this after `zig build app` for\n    //                          Tahoe-compatible installs.\n    const sign_cmd = b.addSystemCommand(&[_][]const u8{\n        \"bash\",\n        \"scripts/codesign.sh\",\n    });\n    sign_cmd.addArg(installed_exe);\n    sign_cmd.step.dependOn(b.getInstallStep());\n\n    const sign_step = b.step(\"sign\", \"Code sign the bare binary\");\n    sign_step.dependOn(&sign_cmd.step);\n\n    const sign_app_cmd = b.addSystemCommand(&[_][]const u8{\n        \"bash\",\n        \"scripts/codesign.sh\",\n    });\n    sign_app_cmd.addArg(installed_app);\n    sign_app_cmd.step.dependOn(&app_cmd.step);\n\n    const sign_app_step = b.step(\"sign-app\", \"Build and code sign the skhd.app bundle (Tahoe-compatible)\");\n    sign_app_step.dependOn(&sign_app_cmd.step);\n\n    // Local debug bundle. Uses a separate path, cert (skhd-dev-cert), and\n    // bundle ID (com.jackielii.skhd.dev) so debug runs get their own TCC slot\n    // and don't disturb the prod entry (com.jackielii.skhd + skhd-cert) used\n    // by the Homebrew install. On Tahoe, TCC is bundle-ID-keyed and validates\n    // against the stored csreq, so the running process must carry the right\n    // bundle ID and a signature matching the granted entry — the bare binary\n    // at zig-out/bin/skhd is adhoc-signed and unbundled, so it can't be\n    // granted accessibility on Tahoe.\n    const installed_dev_app = b.getInstallPath(.prefix, \"skhd-dev.app\");\n    const dev_bundle_id = \"com.jackielii.skhd.dev\";\n    const dev_cert_name = \"skhd-dev-cert\";\n\n    const dev_app_cmd = b.addSystemCommand(&[_][]const u8{\n        \"bash\",\n        \"scripts/make-app.sh\",\n    });\n    dev_app_cmd.addArg(installed_exe);\n    dev_app_cmd.addArg(installed_dev_app);\n    dev_app_cmd.addArg(dev_bundle_id);\n    dev_app_cmd.step.dependOn(b.getInstallStep());\n\n    const sign_dev_app_cmd = b.addSystemCommand(&[_][]const u8{\n        \"bash\",\n        \"scripts/codesign.sh\",\n    });\n    sign_dev_app_cmd.addArg(installed_dev_app);\n    sign_dev_app_cmd.setEnvironmentVariable(\"SKHD_CERT\", dev_cert_name);\n    sign_dev_app_cmd.setEnvironmentVariable(\"SKHD_BUNDLE_ID\", dev_bundle_id);\n    sign_dev_app_cmd.step.dependOn(&dev_app_cmd.step);\n\n    const inner_exe = b.pathJoin(&.{ installed_dev_app, \"Contents\", \"MacOS\", \"skhd\" });\n    const run_cmd = b.addSystemCommand(&[_][]const u8{inner_exe});\n    run_cmd.step.dependOn(&sign_dev_app_cmd.step);\n\n    if (b.args) |args| {\n        run_cmd.addArgs(args);\n    }\n\n    const run_step = b.step(\"run\", \"Run the app\");\n    run_step.dependOn(&run_cmd.step);\n\n    // `zig build install-local` — stage the local build into the slot a\n    // brew install would occupy: replace the binary inside\n    // /Applications/skhd.app, re-sign with skhd-cert + prod bundle id, and\n    // restart the SMAppService daemon. Lets you exercise the packaged path\n    // (real bundle id, real launchd registration, real TCC slot) without\n    // cutting a release. Pass -Doptimize=ReleaseFast to match the brew\n    // binary's perf profile.\n    const install_local_cmd = b.addSystemCommand(&[_][]const u8{\n        \"bash\",\n        \"scripts/install-local.sh\",\n    });\n    install_local_cmd.addArg(installed_exe);\n    install_local_cmd.step.dependOn(b.getInstallStep());\n\n    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)\");\n    install_local_step.dependOn(&install_local_cmd.step);\n\n    // `zig build install-dext` — download + install the pinned Karabiner\n    // DriverKit .pkg by invoking the just-built skhd binary's\n    // `--install-dext` subcommand. Same code path brew users hit, so dev\n    // and prod stay in lockstep. Cached under ~/.cache/skhd so re-runs\n    // skip the download; pqrs's installer is a no-op when the same\n    // version is already installed.\n    const install_dext_cmd = b.addSystemCommand(&[_][]const u8{installed_exe});\n    install_dext_cmd.addArg(\"--install-dext\");\n    install_dext_cmd.has_side_effects = true;\n    install_dext_cmd.step.dependOn(b.getInstallStep());\n\n    const install_dext_step = b.step(\"install-dext\", \"Download and install pinned Karabiner-DriverKit-VirtualHIDDevice (required by skhd-grabber)\");\n    install_dext_step.dependOn(&install_dext_cmd.step);\n\n    const test_step = b.step(\"test\", \"Run unit tests\");\n\n    // Benchmark executable\n    const bench_exe = b.addExecutable(.{\n        .name = \"benchmark\",\n        .root_source_file = b.path(\"src/benchmark.zig\"),\n        .target = target,\n        .optimize = .ReleaseFast,\n    });\n    linkFrameworks(b, bench_exe);\n    addVersionImport(b, bench_exe);\n\n    const zbench = b.dependency(\"zbench\", .{\n        .target = target,\n        .optimize = .ReleaseFast,\n    });\n    bench_exe.root_module.addImport(\"zbench\", zbench.module(\"zbench\"));\n\n    const bench_cmd = b.addRunArtifact(bench_exe);\n    const bench_step = b.step(\"bench\", \"Run benchmarks\");\n    bench_step.dependOn(&bench_cmd.step);\n\n    // Allocation tracking executable. Goes through the same dev .app + sign\n    // path as `zig build run` so TCC's accessibility grant covers it — bare\n    // Mach-O can't be granted on Tahoe. Same skhd-dev-cert + bundle id, so\n    // there's only one TCC slot to manage; the .app's inner binary swaps\n    // between the regular dev build and the alloc-tracking build depending\n    // on which step you run last.\n    const alloc_exe = b.addExecutable(.{\n        .name = \"skhd-alloc\",\n        .root_source_file = b.path(\"src/main.zig\"),\n        .target = target,\n        .optimize = optimize,\n    });\n    linkFrameworks(b, alloc_exe);\n    addVersionImport(b, alloc_exe);\n    addGrabberPlistImports(b, alloc_exe);\n\n    const alloc_options = b.addOptions();\n    alloc_options.addOption(bool, track_alloc_option, true);\n    alloc_options.addOption([]const u8, \"karabiner_dext_version\", karabiner_dext_version);\n    alloc_options.addOption([]const u8, \"karabiner_dext_url\", karabiner_dext_url);\n    alloc_options.addOption([]const u8, \"karabiner_dext_sha256\", karabiner_dext_sha256);\n    alloc_exe.root_module.addOptions(\"build_options\", alloc_options);\n    alloc_exe.root_module.addImport(\"grabber_protocol\", grabber_protocol_mod);\n    b.installArtifact(alloc_exe);\n    const installed_alloc_exe = b.getInstallPath(.bin, alloc_exe.name);\n\n    const alloc_app_cmd = b.addSystemCommand(&[_][]const u8{\n        \"bash\",\n        \"scripts/make-app.sh\",\n    });\n    alloc_app_cmd.addArg(installed_alloc_exe);\n    alloc_app_cmd.addArg(installed_dev_app);\n    alloc_app_cmd.addArg(dev_bundle_id);\n    alloc_app_cmd.step.dependOn(b.getInstallStep());\n\n    const sign_alloc_app_cmd = b.addSystemCommand(&[_][]const u8{\n        \"bash\",\n        \"scripts/codesign.sh\",\n    });\n    sign_alloc_app_cmd.addArg(installed_dev_app);\n    sign_alloc_app_cmd.setEnvironmentVariable(\"SKHD_CERT\", dev_cert_name);\n    sign_alloc_app_cmd.setEnvironmentVariable(\"SKHD_BUNDLE_ID\", dev_bundle_id);\n    sign_alloc_app_cmd.step.dependOn(&alloc_app_cmd.step);\n\n    const alloc_cmd = b.addSystemCommand(&[_][]const u8{inner_exe});\n    alloc_cmd.step.dependOn(&sign_alloc_app_cmd.step);\n    if (b.args) |args| {\n        alloc_cmd.addArgs(args);\n    }\n    const alloc_step = b.step(\"alloc\", \"Run skhd with allocation logging (signed dev .app)\");\n    alloc_step.dependOn(&alloc_cmd.step);\n\n    // Tests for main.zig\n    const exe_unit_tests = b.addTest(.{\n        .root_source_file = b.path(\"src/main.zig\"),\n        .target = target,\n        .optimize = optimize,\n    });\n    linkFrameworks(b, exe_unit_tests);\n    addVersionImport(b, exe_unit_tests);\n    addGrabberPlistImports(b, exe_unit_tests);\n\n    exe_unit_tests.root_module.addOptions(\"build_options\", options);\n    exe_unit_tests.root_module.addImport(\"grabber_protocol\", grabber_protocol_mod);\n    const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests);\n    test_step.dependOn(&run_exe_unit_tests.step);\n\n    // Tests for tests.zig\n    const tests_unit_tests = b.addTest(.{\n        .root_source_file = b.path(\"src/tests.zig\"),\n        .target = target,\n        .optimize = optimize,\n    });\n    linkFrameworks(b, tests_unit_tests);\n    addVersionImport(b, exe_unit_tests);\n    addGrabberPlistImports(b, tests_unit_tests);\n    tests_unit_tests.root_module.addOptions(\"build_options\", options);\n    tests_unit_tests.root_module.addImport(\"grabber_protocol\", grabber_protocol_mod);\n    const run_tests_unit_tests = b.addRunArtifact(tests_unit_tests);\n    test_step.dependOn(&run_tests_unit_tests.step);\n\n    // Tests for individual modules\n    const test_files = [_][]const u8{\n        \"src/Tokenizer.zig\",\n        \"src/Parser.zig\",\n        \"src/Mappings.zig\",\n        \"src/Keycodes.zig\",\n        \"src/EventTap.zig\",\n        \"src/synthesize.zig\",\n        \"src/grabber_cli.zig\",\n        \"src/grabber_protocol.zig\",\n        \"src/grabber/Vhidd.zig\",\n        \"src/grabber/KbState.zig\",\n        \"src/grabber/TapHold.zig\",\n        // \"src/Hotload.zig\", // Skip hot load test for local test only\n    };\n\n    for (test_files) |test_file| {\n        const module_tests = b.addTest(.{\n            .root_source_file = b.path(test_file),\n            .target = target,\n            .optimize = optimize,\n        });\n        linkFrameworks(b, module_tests);\n        addVersionImport(b, module_tests);\n        addGrabberPlistImports(b, module_tests);\n        module_tests.root_module.addOptions(\"build_options\", options);\n        // RuleSet's test imports the shared protocol module by name;\n        // grabber_protocol.zig itself is the module's root, so it\n        // doesn't need (and can't have) an import of itself.\n        if (!std.mem.eql(u8, test_file, \"src/grabber_protocol.zig\")) {\n            module_tests.root_module.addImport(\"grabber_protocol\", grabber_protocol_mod);\n        }\n        const run_module_tests = b.addRunArtifact(module_tests);\n        test_step.dependOn(&run_module_tests.step);\n    }\n}\n"
  },
  {
    "path": "build.zig.zon",
    "content": ".{\n    // This is the default name used by packages depending on this one. For\n    // example, when a user runs `zig fetch --save <url>`, this field is used\n    // as the key in the `dependencies` table. Although the user can choose a\n    // different name, most users will stick with this provided value.\n    //\n    // It is redundant to include \"zig\" in this name because it is already\n    // within the Zig package namespace.\n    .name = .skhd_zig,\n\n    // This is a [Semantic Version](https://semver.org/).\n    // In a future version of Zig it will be used for package deduplication.\n    .version = \"0.0.0\",\n\n    // Together with name, this represents a globally unique package\n    // identifier. This field is generated by the Zig toolchain when the\n    // package is first created, and then *never changes*. This allows\n    // unambiguous detection of one package being an updated version of\n    // another.\n    //\n    // When forking a Zig project, this id should be regenerated (delete the\n    // field and run `zig build`) if the upstream project is still maintained.\n    // Otherwise, the fork is *hostile*, attempting to take control over the\n    // original project's identity. Thus it is recommended to leave the comment\n    // on the following line intact, so that it shows up in code reviews that\n    // modify the field.\n    .fingerprint = 0xebb8dbd815cfd426, // Changing this has security and trust implications.\n\n    // Tracks the earliest Zig version that the package considers to be a\n    // supported use case.\n    .minimum_zig_version = \"0.14.0\",\n\n    // This field is optional.\n    // Each dependency must either provide a `url` and `hash`, or a `path`.\n    // `zig build --fetch` can be used to fetch all dependencies of a package, recursively.\n    // Once all dependencies are fetched, `zig build` no longer requires\n    // internet connectivity.\n    .dependencies = .{\n        .zbench = .{\n            .url = \"https://github.com/hendriknielaender/zbench/archive/ad7ccbdb06476affc512c12574b54f7d4386622c.tar.gz\",\n            .hash = \"zbench-0.10.0-YTdc7-cmAQCnYOFNUAy3wZ-Sx9-_r8lW4uwpn87wydTn\",\n        },\n    },\n    .paths = .{\n        \"build.zig\",\n        \"build.zig.zon\",\n        \"src\",\n        // For example...\n        //\"LICENSE\",\n        //\"README.md\",\n    },\n}\n"
  },
  {
    "path": "docs/CODE_SIGNING.md",
    "content": "# Code Signing & .app Bundle for Accessibility Permissions\n\n## Why Both Are Required\n\nStarting with macOS 15 (Sequoia) and especially macOS 26 (Tahoe) released in September 2025, **two things are required for accessibility permissions to behave well**:\n\n1. **Code signing with a stable identity** — so TCC (Transparency, Consent, Control) recognizes the binary across rebuilds.\n2. **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.\n\n### What goes wrong without these\n\n- **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\".\n- **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.\n- **CVE-2025-43312**: Unsigned services are now blocked from launching on Intel Macs.\n\n### The Solution\n\nUse 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.\n\n## Setting Up Code Signing\n\n### 1. Create a Self-Signed Certificate (One-Time Setup)\n\n`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:\n\n```bash\nopen \"/Applications/Utilities/Keychain Access.app\"\n```\n\nThen:\n1. Menu: **Keychain Access** → **Certificate Assistant** → **Create a Certificate**\n2. Name: `skhd-cert`\n3. Identity Type: **Self-Signed Root**\n4. Certificate Type: **Code Signing**\n5. Click **Create**\n\n### 2. Build, Bundle, and Sign\n\n```bash\n# Build the bare binary (development iteration)\nzig build\n\n# Build skhd.app + sign both the inner Mach-O and the bundle\nzig build sign-app\n\n# Equivalent to:\nzig build app                          # produces zig-out/skhd.app\n./scripts/codesign.sh zig-out/skhd.app # signs both layers\n```\n\n### 3. Grant Accessibility Permissions\n\n1. Move or symlink the bundle into `/Applications` (Tahoe's picker prefers paths there):\n   ```bash\n   ln -sfn \"$(pwd)/zig-out/skhd.app\" /Applications/skhd.app\n   ```\n2. Open: **System Settings** → **Privacy & Security** → **Accessibility**\n3. Enable the checkbox next to `skhd`\n4. Restart skhd\n\n### 4. Done!\n\nPermissions will now **persist across rebuilds** as long as you sign each new build with the same certificate.\n\n## Local Debug Workflow (`zig build run`)\n\n`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:\n\n| | Path | Bundle ID | Cert |\n|---|---|---|---|\n| Prod (`sign-app`) | `zig-out/skhd.app` | `com.jackielii.skhd` | `skhd-cert` |\n| Dev (`run`) | `zig-out/skhd-dev.app` | `com.jackielii.skhd.dev` | `skhd-dev-cert` |\n\nThe 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`.\n\nThe 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`.\n\nTo override the dev cert/bundle ID, set `SKHD_CERT` and `SKHD_BUNDLE_ID` before invoking `scripts/codesign.sh` directly.\n\n## Verifying Code Signature\n\n```bash\ncodesign -dv --verbose=2 ./zig-out/skhd.app\n```\n\nExpected output with proper signing:\n```\nExecutable=/path/to/zig-out/skhd.app/Contents/MacOS/skhd\nIdentifier=com.jackielii.skhd\nFormat=app bundle with Mach-O thin (arm64)\nAuthority=skhd-cert\nSigned Time=...\nTeamIdentifier=not set\nSealed Resources version=2 ...\n```\n\nKey things to confirm:\n- `Format=app bundle with Mach-O thin (arm64)` — proves the bundle layer is signed, not just the inner binary\n- `Authority=skhd-cert` (not \"adhoc\")\n- `Identifier=com.jackielii.skhd` (stable bundle ID)\n\nYou can also verify TCC has bundle-ID-keyed entries (rather than path-keyed):\n```bash\nsudo sqlite3 \"/Library/Application Support/com.apple.TCC/TCC.db\" \\\n  \"SELECT service, client, client_type FROM access WHERE client='com.jackielii.skhd';\"\n```\nLook for rows with `client_type=0` (bundle ID) — those are the entries that survive rebuilds and `brew upgrade`.\n\n## CI/CD Compatibility\n\nCode signing is **optional** for CI environments:\n- Builds will succeed without signing\n- GitHub Actions and other CI systems don't need certificates\n- Local development requires signing for accessibility permissions to persist\n\n## Homebrew Installation\n\nFor users installing via Homebrew:\n1. The formula will attempt to create a certificate and sign the binary automatically\n2. Users will be prompted to grant accessibility permissions once\n3. Permissions will persist across Homebrew updates\n\n## Troubleshooting\n\n### \"codesign wants to sign using key in your keychain\"\n\nThis is normal - click **Always Allow** to avoid repeated prompts.\n\n### Permissions stop working after replacing the binary in-place\n\nIf 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\".\n\nTwo ways to recover:\n\n1. **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.\n\n2. **Drop and re-grant**: if you have a stale path-keyed entry blocking re-add, delete it directly:\n   ```bash\n   sudo sqlite3 \"/Library/Application Support/com.apple.TCC/TCC.db\" \\\n     \"DELETE FROM access WHERE client LIKE '%skhd%' AND client_type=1;\"\n   ```\n   Then restart skhd and grant fresh.\n\n### \"skhd doesn't appear in the Accessibility list\"\n\nOn 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.\n\n### `--status` says \"Not granted\" even though the daemon works\n\n`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`.\n\n### Permissions still reset after signing\n\n1. Verify the signature: `codesign -dv --verbose=2 ./zig-out/skhd.app`\n2. Check that `Authority=skhd-cert` (not \"adhoc\")\n3. Check that `Format=app bundle with Mach-O thin (...)` — bundle layer is signed\n4. Check that `Identifier=com.jackielii.skhd` is present\n5. Remove old accessibility entries (especially path-keyed ones) and re-add\n6. Ensure you're signing with the same certificate each time\n\n### Certificate not found when running `zig build sign`\n\n1. Verify certificate exists: `security find-identity -v -p codesigning`\n2. If not found, create it manually using Keychain Access (see step 1 above)\n3. The script will provide detailed instructions if the certificate is missing\n\n## References\n\n- [Issue #15: Accessibility permission fails on macOS 26](https://github.com/jackielii/skhd.zig/issues/15)\n- [Apple TN2206: macOS Code Signing In Depth](https://developer.apple.com/library/archive/technotes/tn2206/)\n- [macOS 26 (Tahoe) Release Notes](https://developer.apple.com/documentation/macos-release-notes/macos-26-release-notes)\n"
  },
  {
    "path": "docs/PLAN_ADVANCED_FEATURES.md",
    "content": "# Advanced Features Implementation Plan for skhd.zig\n\n## Executive Summary\n\nThis document outlines the plan to implement advanced Karabiner-Elements features in skhd.zig, specifically:\n1. Device-specific hotkey filtering (e.g., different behavior for built-in keyboard vs external HHKB)\n2. Dual-function keys with `to_if_alone` functionality (e.g., Caps Lock → Escape when tapped, Control when held)\n\n## Feature 1: Device Filtering\n\n### How Karabiner-Elements Implements Device Filtering\n\nBased on research of the Karabiner-Elements codebase:\n\n1. **Device Identification**:\n   - Uses vendor_id and product_id to identify devices\n   - Maintains a device_properties_manager that tracks all connected devices\n   - Device information is queried from IOKit\n\n2. **Condition System**:\n   - Four condition types: `device_if`, `device_unless`, `device_exists_if`, `device_exists_unless`\n   - Conditions are evaluated before executing manipulators\n   - Located in `src/share/manipulator/conditions/device.hpp`\n\n3. **Configuration Format**:\n   ```json\n   \"conditions\": [{\n       \"type\": \"device_if\",\n       \"identifiers\": [{\n           \"vendor_id\": 1452,\n           \"product_id\": 834,\n           \"description\": \"Apple Internal Keyboard\"\n       }]\n   }]\n   ```\n\n### Proposed skhd.zig Implementation\n\n1. **Add Device Detection**:\n   - Create a new `DeviceManager.zig` module\n   - Use IOKit APIs to enumerate HID devices\n   - Track vendor_id, product_id for each device\n\n2. **Extend Configuration Syntax**:\n   ```\n   # Device-specific binding\n   ctrl - h [device:1452,834] : echo \"Built-in keyboard\"\n   ctrl - h [device:1278,33] : echo \"HHKB keyboard\"\n   ```\n\n3. **Modify Parser**:\n   - Add device condition parsing in `Parser.zig`\n   - Store device conditions in `Hotkey` structure\n\n4. **Event Processing**:\n   - In `EventTap.zig`, identify source device for each event\n   - Match against device conditions before executing commands\n\n## Feature 2: to_if_alone (Dual-Function Keys)\n\n### How Karabiner-Elements Implements to_if_alone\n\nBased on analysis of `src/share/manipulator/manipulators/basic/`:\n\n1. **State Tracking**:\n   - `manipulated_original_event` tracks \"alone\" state\n   - Records key down timestamp\n   - `alone_` flag set to true on key down\n\n2. **Alone State Interruption**:\n   - Flag set to false when:\n     - Another key is pressed\n     - Mouse wheel is scrolled\n   - Handled by `unset_alone_if_needed()` method\n\n3. **Timeout Logic**:\n   - Default timeout: 1000ms (configurable)\n   - Stored in `basic_to_if_alone_timeout_milliseconds`\n\n4. **Event Processing**:\n   - Key down: Send normal `to` events\n   - Key up (if alone and within timeout): Send `to_if_alone` events\n\n### Proposed skhd.zig Implementation\n\n1. **Configuration Syntax**:\n   ```\n   # Caps Lock → Escape (tap) / Control (hold)\n   caps_lock : ctrl\n   caps_lock [alone] : escape\n   \n   # Alternative syntax\n   caps_lock -> ctrl | escape\n   ```\n\n2. **State Management**:\n   - Create `DualFunctionKeyManager.zig`\n   - Track key press timestamps\n   - Monitor for interrupting events\n\n3. **Integration Points**:\n   - Modify `EventTap.zig` to track alone state\n   - Add timeout handling (use dispatch timers)\n   - Inject synthetic events for alone actions\n\n## Architecture Comparison: Virtual Driver vs Event Tap\n\n### Karabiner-Elements: Virtual HID Driver Approach\n\n**Pros**:\n- Complete control over event flow\n- Can suppress original events reliably\n- Lower-level access allows complex manipulations\n- Better for system-wide modifications\n- Can handle all input types (keyboard, mouse, etc.)\n\n**Cons**:\n- Requires kernel extension (security implications)\n- More complex installation/permissions\n- Higher development complexity\n- Potential system stability risks\n\n**Implementation**:\n- Uses `pqrs::karabiner::driverkit::virtual_hid_device`\n- Intercepts events at driver level\n- Posts modified events to virtual device\n\n### skhd: Event Tap Approach\n\n**Pros**:\n- Simpler implementation\n- No kernel extensions required\n- Easier to debug and maintain\n- Less invasive to system\n- Good enough for most hotkey use cases\n\n**Cons**:\n- Limited to CGEventTap capabilities\n- Can't suppress all events reliably\n- Higher latency than driver approach\n- Some edge cases with event ordering\n\n**Current Implementation**:\n- Uses CGEventTapCreate\n- Processes events at user-space level\n- Limited to keyboard events\n\n### Recommendation\n\nFor skhd.zig, continue with the Event Tap approach because:\n1. Maintains simplicity and compatibility with original skhd\n2. Sufficient for hotkey daemon functionality\n3. Avoids kernel extension complexity\n4. Device filtering and to_if_alone can be implemented with event taps\n\nHowever, we need to enhance the current implementation:\n- Add mouse event monitoring for alone state interruption\n- Implement proper event suppression for dual-function keys\n- Add timing mechanisms for alone detection\n\n## Implementation Roadmap\n\n### Phase 1: Device Filtering (Foundation)\n1. Create DeviceManager module\n2. Implement IOKit device enumeration\n3. Add device tracking to EventTap\n4. Extend Parser for device conditions\n5. Update Hotkey structure\n6. Add device matching logic\n7. Write comprehensive tests\n\n### Phase 2: Basic to_if_alone\n1. Create DualFunctionKeyManager\n2. Add state tracking for key presses\n3. Implement timeout handling\n4. Add alone state interruption logic\n5. Integrate with EventTap\n6. Test with simple use cases\n\n### Phase 3: Advanced Features\n1. Add configuration for timeout values\n2. Support multiple alone actions\n3. Add to_if_held_down support\n4. Optimize performance\n5. Handle edge cases\n\n### Phase 4: Testing & Polish\n1. Comprehensive test suite\n2. Performance benchmarking\n3. Documentation updates\n4. Example configurations\n\n## Open Questions\n\n1. **Configuration Syntax**: Should we maintain compatibility with skhd syntax or adopt Karabiner-style JSON?\n   - Proposal: Extend skhd syntax to maintain backwards compatibility\n\n2. **Event Suppression**: How to reliably suppress original events in dual-function scenarios?\n   - May need to explore CGEventTapProxy options\n\n3. **Mouse Integration**: Should we monitor mouse events for alone interruption?\n   - Yes, for feature parity with Karabiner\n\n4. **Performance**: Will state tracking impact hotkey responsiveness?\n   - Need benchmarking, but likely minimal impact\n\n5. **Persistence**: Should device configurations persist across disconnections?\n   - Yes, match devices by vendor/product ID\n\n## Next Steps\n\n1. Review and approve this plan\n2. Begin Phase 1 implementation with DeviceManager\n3. Create test harness for device simulation\n4. Iterate based on testing results"
  },
  {
    "path": "docs/PLAN_GRABBER.md",
    "content": "# `skhd-grabber` — system daemon for caps_lock-class tap-hold\n\nHybrid (Option D) plan to support `.remap caps_lock { … }` and other\nsources where macOS's HID layer prevents the user-agent path from\nworking. Layered on top of the existing user-agent skhd, opt-in.\n\n## Why two binaries\n\nmacOS's `IOHIDDeviceOpen(kIOHIDOptionsTypeSeizeDevice)` requires root,\nand the Karabiner DriverKit `vhidd_server` daemon refuses non-root\nclients. Tap-hold for caps_lock therefore can't live in the per-user\nuser-agent. But also: users who don't need caps_lock tap-hold should\nnot pay any cost (no system daemon, no system extension on their\nmachine, no root processes). Hence the split:\n\n- **`skhd`** — per-user agent (today's model). Handles all\n  CGEventTap-based features: regular hotkeys, modes, process lists,\n  `.device` matching, `.remap` colon-form for non-caps targets via\n  hidutil. **Unchanged install path**, runs as the user.\n- **`skhd-grabber`** — system daemon, root. Owns the seize on\n  configured devices, runs the tap-hold state machine on the seized\n  HID stream, injects results through Karabiner's virtual HID device.\n  **Opt-in** via `skhd --install-grabber`.\n\nCommunication: the user-agent talks to the grabber through a Unix\ndomain socket when (and only when) the user's config contains a\ncaps-class `.remap {}` rule.\n\n## Architecture\n\n```\n┌─ User A session ──────────────┐  ┌─ User B session ─────────────┐\n│ skhd (user agent, today)      │  │ skhd (user agent, today)     │\n│ • CGEventTap                  │  │ • CGEventTap                 │\n│ • [device guard]              │  │ • [device guard]             │\n│ • non-caps .remap (hidutil)   │  │ • non-caps .remap (hidutil)  │\n│ • regular hotkeys             │  │ • regular hotkeys            │\n└────────────┬──────────────────┘  └────────────┬─────────────────┘\n             │ Unix socket only when            │\n             │ config has caps .remap{}         │ (no socket — no caps rule)\n             ▼\n┌──────────────────────────────────────────────────────────────────┐\n│ skhd-grabber (system daemon, root, optional)                     │\n│ • Listens on /var/run/skhd/grabber.sock                          │\n│ • Tracks console user via SCDynamicStoreCopyConsoleUser          │\n│ • Per-user rule sets (only active user's apply)                  │\n│ • IOHIDDeviceOpen(seize) on matched devices                      │\n│ • TapHoldMachine on seized HID stream                            │\n│ • Injects via Karabiner vhidd Unix socket                        │\n└─────────────┬────────────────────────────────────┬───────────────┘\n              ▼                                    ▼\n       Real keyboards                Karabiner-DriverKit-VirtualHIDDevice\n       (seized; kernel sees           (already-installed signed dext;\n        nothing while held)            we are a client)\n```\n\n## What's reused vs. new\n\n**Reused (no churn):**\n- All existing user-agent code: `Parser.zig`, `Tokenizer.zig`,\n  `Hotkey.zig`, `Mappings.zig`, `EventTap.zig`, `Mode.zig`,\n  `CarbonEvent.zig`, `Hidutil.zig`, `HidMonitor.zig`.\n- `.device`, `.remap` (colon and block) parsing.\n- The `TapHoldMachine` design — but it'll need a refactor to\n  accept HID events instead of CGEvents (the abstraction is small\n  enough that one struct can cover both).\n\n**New code:**\n- `src/grabber/` — new binary's sources.\n  - `main.zig` — daemon entry, launchd integration.\n  - `Vhidd.zig` — Karabiner virtual-HID-device client (Unix socket\n    protocol to `vhidd_server`).\n  - `Seize.zig` — `IOHIDDeviceOpen(seize)` per device, value-callback\n    handling.\n  - `RuleSet.zig` — per-user rules, switched on console-user change.\n  - `Ipc.zig` — Unix socket server for the user-agent IPC.\n- `src/agent_grabber_client.zig` — IPC client used by the user-agent\n  to push rules to the grabber when configured.\n- New CLI: `skhd --install-grabber`, `--uninstall-grabber`,\n  `--grabber-status`.\n\n**Modified:**\n- `Mappings.zig` — partition `tapholds` into \"caps-class\"\n  (handled by grabber) and \"non-caps\" (handled by user-agent's\n  CGEventTap). The user-agent forwards the caps-class set to grabber.\n- `skhd.zig` (user-agent) — at startup, if `mappings.tapholds` has\n  any caps-class entries, dial the grabber socket; on parse-reload,\n  resend.\n\n## Key design decisions\n\n### 1. Where does config live?\n\nAgent owns config. Per-user `~/.config/skhd/skhdrc` parsed by the\nuser-agent; the agent ships the parsed caps-class subset to the\ngrabber over the socket. Grabber is stateless re: content — it\nholds whatever the agent gave it for the current console user.\n\nRationale: preserves per-user separation. Grabber doesn't read user\nfiles (avoids privilege boundary issues). Each user's\ncaps-class rules only ever apply during their session.\n\n### 2. Console-user tracking\n\nGrabber subscribes to `kSCDynamicStoreDomainState/Console User` via\n`SCDynamicStoreNotificationCallBack`. On change:\n- Apply the new console user's rule set (if any agent is connected\n  for that uid).\n- Pause the previous user's rules.\n- If no active rule set, release seize on all devices.\n\nFast user switching: ~hundreds of ms gap during which the keyboard\nbehaves natively. Acceptable.\n\n### 3. When does grabber seize?\n\nOnly when there's at least one caps-class rule for the active\nconsole user. No active rules → no seize → keyboard fully native.\nAdding a rule (config reload by agent) → grabber re-evaluates and\nseizes if needed.\n\n### 4. Coexistence with Karabiner-Elements\n\nBoth share the same Karabiner DriverKit dext. Karabiner-Elements\nalso seizes devices. Conflict on a given device: first seizer wins,\nsecond gets `kIOReturnExclusiveAccess`. Detect on grabber startup\nand log a clear warning (\"Karabiner-Elements is seizing this device\n— skhd's caps tap-hold won't apply to it\").\n\n### 5. What happens on grabber crash\n\n`IOHIDDeviceOpen` reclaim on process death is the kernel's job.\nlaunchd respawns. Agent's socket connection drops; agent retries\nwith backoff. ~1–3s of native keyboard behaviour, then back online.\n\n### 6. What if user installs grabber but vhidd dext isn't installed\n\nGrabber checks at startup via `systemextensionsctl list` (or by\nattempting socket connect to `vhidd_server`). On failure: log a\nclear error pointing at pqrs.org's installer URL, refuse to start.\nlaunchd will keep retrying — when user installs the dext and\nreboots, grabber comes up.\n\n### 7. What if user-agent has caps rule but grabber isn't installed\n\nAgent's socket connection fails. Log a `warn`-level diagnostic\n(\"caps_lock tap-hold rule found but skhd-grabber is not installed\nor running. Run `skhd --install-grabber` to enable.\") and continue\nwithout caps support. Other rules still work.\n\n### 8. Out of scope for D\n\n- Multiple simultaneously-active users (Sharing, Caching) — only\n  the console user's rules apply.\n- Phase 4 layer holds (`hold: fn_layer`) — keep deferred to its\n  own phase. Once the grabber pipeline exists, layer holds slot in\n  on top of it.\n- Auto-install of vhidd dext (we ask user to install pqrs.org's\n  signed pkg manually; skhd points at the URL).\n\n## IPC protocol\n\nLength-prefixed JSON messages over `/var/run/skhd/grabber.sock`\n(grabber-side socket, mode 0666, ACL'd to local console users).\n\n**Agent → grabber:**\n```json\n{\"type\": \"hello\", \"uid\": 501, \"version\": 1}\n{\"type\": \"apply_rules\", \"rules\": [\n   {\"src_usage\": 0x39, \"tap_usage\": 0x29, \"hold_usage\": 0xE0,\n    \"device\": {\"vendor\": 0x05AC, \"product\": 0x0342},\n    \"timeout_ms\": 120, \"permissive_hold\": true,\n    \"hold_on_other_key_press\": false, \"retro_tap\": false}\n]}\n{\"type\": \"bye\"}\n```\n\n**Grabber → agent:**\n```json\n{\"type\": \"ok\"}\n{\"type\": \"error\", \"code\": \"vhidd_not_installed\", \"message\": \"...\"}\n{\"type\": \"warn\", \"code\": \"device_seized_by_other\", \"device\": \"0x05AC:0x0342\"}\n```\n\n## Install / uninstall flow\n\n`skhd --install-grabber`:\n1. Check `systemextensionsctl list` for `org.pqrs.Karabiner-DriverKit-VirtualHIDDevice`. If absent, print install link, abort.\n2. Sudo-escalate (or instruct user to re-run with sudo).\n3. Copy `skhd-grabber` binary to `/usr/local/libexec/skhd-grabber`.\n4. Write `/Library/LaunchDaemons/com.jackielii.skhd.grabber.plist`\n   with `RunAtLoad=true`, `KeepAlive=true`, `ProcessType=Interactive`.\n5. `launchctl bootstrap system /Library/LaunchDaemons/...`.\n6. Verify daemon is running and reachable on the socket.\n\n`skhd --uninstall-grabber`:\n1. `launchctl bootout system /Library/LaunchDaemons/...`.\n2. Remove plist and binary.\n3. (User may also want to uninstall pqrs.org dext separately.)\n\n## Phasing & estimates\n\n### D1 — grabber skeleton (2–3 days)\n- New binary `src/grabber/main.zig` builds & runs.\n- Unix socket server with hello/apply_rules/bye protocol.\n- launchd plist + install/uninstall scripts.\n- Agent stub: detects caps rule in config, dials socket, sends\n  apply_rules, gets `ok` back. No actual HID work yet.\n\n### D2 — Karabiner vhidd client (2–3 days)\n- Connect to `/Library/Application Support/org.pqrs/tmp/rootonly/vhidd_server/*.sock`.\n- Implement the small protocol surface needed for keyboard injection\n  (Karabiner publishes the protocol — we'd port it from their C++\n  client lib to Zig).\n- Test injection: send a simulated `escape` keydown/up, verify it\n  shows up in the focused app.\n\n### D3 — HID seize (2–3 days)\n- `IOHIDDeviceOpen(kIOHIDOptionsTypeSeizeDevice)` on the (vendor,\n  product) pairs supplied by the active rule set.\n- Input value callback receiving raw HID events from seized device.\n- Pass-through events not matched by any rule (synthesize identical\n  events through vhidd so the user can keep typing while we hold the\n  seize).\n\n### D4 — TapHoldMachine on seized stream (2–3 days)\n- Refactor `TapHoldMachine` to accept HID events directly (it\n  currently takes CGEvents). Both call sites can use the same state\n  machine — only event types differ.\n- caps_lock specifically: source key arrives as raw HID 0x39 from\n  the seized device; tap action emits HID 0x29 (escape) via vhidd;\n  hold action emits HID 0xE0 (lctrl). No more F18 proxy.\n- All four QMK knobs (timeout / permissive_hold /\n  hold_on_other_key_press / retro_tap) work as before.\n\n### D5 — per-user lifecycle (2–3 days)\n- `SCDynamicStoreCopyConsoleUser` polling or notification.\n- Switch active rule set on console-user change.\n- Release / acquire seize as needed.\n- IPC: track per-uid client connections.\n\n### D6 — polish (1–2 days)\n- `skhd --install-grabber`, `--uninstall-grabber`,\n  `--grabber-status`.\n- Failure paths: dext missing, vhidd_server down, seize race with\n  Karabiner-Elements.\n- README docs + clear startup messages from the user-agent when\n  grabber is needed but not running.\n\n**Total: ~2.5 weeks of focused work.**\n\n## Risk register\n\n- **Karabiner DriverKit protocol changes**: their client lib gets\n  versioned releases. Pin to a known-working version, document in\n  README, update when needed.\n- **Apple changes DriverKit policies**: low likelihood given\n  Karabiner's track record on Apple Silicon Tahoe, but if Apple\n  tightens further, the entire approach (and Karabiner) is at risk.\n  Mitigation: keep a fallback to the F18-proxy / right_alt path so\n  users have *some* tap-hold even if the dext stops working.\n- **vhidd_server crashes**: launchd respawns; we reconnect with\n  backoff. ~1–3s gap per crash.\n- **User installs grabber, then uninstalls vhidd dext**: grabber\n  fails to start. Loud error message; uninstall instructions in\n  README.\n\n## What to commit incrementally\n\nEach Dn ends in a runnable state:\n- After D1: socket plumbing, install scripts work, end-to-end\n  rule-pass-through is testable (no actual injection).\n- After D2: prove vhidd injection works with a hard-coded escape\n  stream.\n- After D3: prove seize works (verify seized keyboard is \"dead\" to\n  other apps, all events flow only to grabber).\n- After D4: end-to-end caps_lock tap-hold for the active user.\n- After D5: multi-user behaviour matches design.\n- After D6: install instructions + docs are user-ready.\n"
  },
  {
    "path": "docs/UPGRADING.md",
    "content": "# Upgrading to skhd.zig 0.0.21 (macOS Tahoe compatibility)\n\n> **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.\n>\n> 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**.\n\n## Migrating from 0.0.20 → 0.0.21\n\n```bash\nbrew upgrade skhd-zig\n\n# Re-register via SMAppService. Run this from inside the .app — SMAppService\n# binds to the calling bundle path, and /Applications/skhd.app is what BTM\n# accepts cleanly:\n/Applications/skhd.app/Contents/MacOS/skhd --install-service\n\n# Verify\nskhd --status\n# Expect: Registration status: enabled\n#         Daemon running: Yes (PID …)\n#         Hotkeys functional: Yes (event tap active)\n```\n\nThat'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.\n\nThe 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.\n\n## If keys stop working after `brew upgrade` (macOS Tahoe)\n\nOn 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.\n\nSymptoms:\n- `skhd --status` shows the daemon running, but hotkeys don't fire.\n- System Settings → Privacy & Security → Accessibility (and/or Input Monitoring) shows skhd as enabled.\n- `~/Library/Logs/skhd.log` shows the event tap was created but no key activity.\n\nFix — drop the stale grant so macOS re-prompts and stores a fresh, cert-root-anchored csreq:\n\n```bash\ntccutil reset ListenEvent com.jackielii.skhd\ntccutil reset Accessibility com.jackielii.skhd\nskhd --restart-service\n```\n\nThen 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.\n\n## Migrating from 0.0.17 or earlier (the original Tahoe rework)\n\nVersion 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.\n\n## What changed and why\n\n| Area | Before | After |\n|---|---|---|\n| Distribution layout | bare Mach-O at `bin/skhd` | `.app` bundle (`skhd.app/Contents/MacOS/skhd`) with bare-binary symlink kept for CLI use |\n| TCC entries | path-keyed (`/opt/homebrew/Cellar/.../bin/skhd`) | bundle-ID-keyed (`com.jackielii.skhd`) |\n| LaunchAgent commands | `launchctl load -w` / `unload -w` | `launchctl bootstrap` / `bootout` (no persistent disable flag) |\n| Plist `ProgramArguments` | version-pinned Cellar path | stable `/opt/homebrew/opt/skhd-zig/...` symlink |\n| Plist log path | `/tmp/skhd_$USER.log` (wiped at boot) | `~/Library/Logs/skhd.log` |\n| Plist `ThrottleInterval` | 30 s | 10 s |\n| `CGEventTapCreate` failures | exit immediately, wait full throttle, repeat | retry up to 10× at 500 ms before giving up |\n\nThe 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.\n\n## Required actions\n\nThese steps assume you installed the previous version via Homebrew. The order matters.\n\n### 1. Stop the old service\n\n```bash\nskhd --stop-service\n```\n\n### 2. Upgrade\n\n```bash\nbrew upgrade jackielii/tap/skhd-zig\n```\n\nThe 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.\n\n### 3. Clear the legacy disable flag (if you ever ran the old `--stop-service`)\n\nThe 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:\n\n```bash\nlaunchctl print-disabled gui/$(id -u) | grep com.jackielii.skhd\n# Expect: \"com.jackielii.skhd\" => enabled\n```\n\nIf it shows `disabled`, run:\n```bash\nlaunchctl enable gui/$(id -u)/com.jackielii.skhd\n```\n\n### 4. Drop stale TCC entries from previous installs\n\nThe path-keyed accessibility entries from previous Cellar versions will silently shadow the new bundle-ID entry until removed:\n\n```bash\nsudo sqlite3 \"/Library/Application Support/com.apple.TCC/TCC.db\" \\\n  \"DELETE FROM access WHERE client LIKE '%skhd-zig%' AND client_type=1;\"\n```\n\nThis 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.\n\n### 5. Install the new LaunchAgent plist\n\n```bash\nskhd --install-service\n```\n\nThe plist now points at `/opt/homebrew/opt/skhd-zig/skhd.app/Contents/MacOS/skhd` (stable across `brew upgrade`).\n\n### 6. Grant Accessibility for `skhd.app`\n\n1. Open **System Settings → Privacy & Security → Accessibility**\n2. Click `+`, navigate to `/Applications/skhd.app`, add it\n3. Toggle the entry on\n\nYou will only need to do this once. The bundle-ID-keyed TCC entry now persists across rebuilds and Homebrew upgrades.\n\n### 7. Start\n\n```bash\nskhd --start-service\n```\n\nWatch the log at `~/Library/Logs/skhd.log`. You should see:\n\n```\ninfo(skhd): Starting event tap\ninfo(skhd): Event tap created successfully. skhd is now running.\n```\n\nOr if the daemon hits the early-boot `WindowServer` race once or twice, the new retry loop handles it:\n```\nwarning(event_tap): Event tap creation failed (attempt 1/10), retrying in 500ms...\ninfo(event_tap): Event tap created on attempt 2/10\n```\n\n## Notes for source builds\n\nIf you build from source rather than installing via Homebrew:\n\n```bash\nzig build sign-app                          # produces a signed zig-out/skhd.app\nln -sfn \"$(pwd)/zig-out/skhd.app\" /Applications/skhd.app\n/Applications/skhd.app/Contents/MacOS/skhd --install-service\n/Applications/skhd.app/Contents/MacOS/skhd --start-service\n```\n\n`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.\n\n## Troubleshooting\n\nIf 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.\n"
  },
  {
    "path": "docs/command-definitions.md",
    "content": "# Command Definitions with .define\n\nThis document describes the command definition feature that allows reducing repetition in skhd configuration files.\n\n## Overview\n\nThe `.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.\n\n## Syntax\n\n### Simple Command Definition (No Placeholders)\n\nDefine a command without any parameters:\n\n```\n.define focus_recent : yabai -m window --focus recent || yabai -m space --focus recent\n```\n\nUse it in a hotkey:\n\n```\ncmd - tab : @focus_recent\n```\n\n### Template Command Definition (With Placeholders)\n\nDefine a command template with positional placeholders using `{{n}}` syntax:\n\n```\n.define yabai_focus : yabai -m window --focus {{1}} || yabai -m display --focus {{1}}\n.define window_action : yabai -m window --{{1}} {{2}} || yabai -m display --{{1}} {{2}}\n```\n\nUse it with arguments in double quotes:\n\n```\nlcmd - h : @yabai_focus(\"west\")\nlcmd - j : @yabai_focus(\"south\")\ncmd + shift - h : @window_action(\"swap\", \"west\")\ncmd + shift - j : @window_action(\"swap\", \"south\")\n```\n\n## Rules\n\n1. **Placeholder Numbering**: Placeholders must be numbered starting from 1 (e.g., `{{1}}`, `{{2}}`, etc.)\n2. **Argument Quoting**: Arguments must be enclosed in double quotes when calling a command\n3. **Argument Count**: The number of arguments must match the highest placeholder number in the template\n4. **Multiple Occurrences**: The same placeholder can appear multiple times in a template\n5. **Escape Sequences**: Within quoted arguments, use `\\\"` to include a literal double quote\n\n## Examples\n\n### Window Management\n\n```\n# Define reusable yabai commands\n.define yabai_focus : yabai -m window --focus {{1}} || yabai -m display --focus {{1}}\n.define yabai_move : yabai -m window --swap {{1}} || ( yabai -m window --display {{1}} ; yabai -m display --focus {{1}} )\n.define yabai_space : yabai -m window --space {{1}}\n\n# Use in hotkeys\nlcmd - h : @yabai_focus(\"west\")\nlcmd - l : @yabai_focus(\"east\")\ncmd + shift - h : @yabai_move(\"west\")\ncmd + shift - 1 : @yabai_space(\"1\")\ncmd + shift - 2 : @yabai_space(\"2\")\n```\n\n### Application Toggling\n\n```\n# Define app toggle command\n.define toggle_app : yabai -m window --toggle {{1}} || open -a \"{{1}}\"\n\n# Use for different applications\nralt - m : @toggle_app(\"YT Music\")\nralt - n : @toggle_app(\"Notes\")\nralt - t : @toggle_app(\"Microsoft Teams\")\n```\n\n### Window Resizing\n\n```\n# Define resize command with multiple parameters\n.define resize_win : yabai -m window --resize {{1}}:{{2}}:{{3}}\n\n# Use with different resize operations\ncmd + ctrl + shift - k : @resize_win(\"top\", \"0\", \"-10\")\ncmd + ctrl + shift - j : @resize_win(\"bottom\", \"0\", \"10\")\ncmd + ctrl + shift - h : @resize_win(\"left\", \"-10\", \"0\")\ncmd + ctrl + shift - l : @resize_win(\"right\", \"10\", \"0\")\n```\n\n### Complex Commands\n\n```\n# Define notification command\n.define notify : osascript -e 'display notification \"{{2}}\" with title \"{{1}}\"'\n\n# Use with different messages\ncmd - n : @notify(\"Reminder\", \"Time for a break!\")\ncmd - m : @notify(\"Meeting\", \"Team standup in 5 minutes\")\n```\n\n## Error Messages\n\n- **Undefined Command**: `\"@unknown_cmd not defined\"`\n- **Argument Mismatch**: `\"@cmd expects 2 arguments, got 1\"`\n- **Missing Arguments**: `\"@cmd requires arguments but none provided\"`\n- **No Arguments Expected**: `\"@cmd expects no arguments\"`\n\n## Disambiguation from Process Groups\n\nThe `.define` directive distinguishes between process groups and commands by syntax:\n\n- **Process Groups**: `.define name [\"app1\", \"app2\"]` (uses array syntax)\n- **Commands**: `.define name : command text` (uses colon syntax)\n\nThis ensures backward compatibility with existing process group definitions."
  },
  {
    "path": "scripts/codesign.sh",
    "content": "#!/bin/bash\nset -e\n\n# Configuration\nTARGET_PATH=\"${1:-./zig-out/bin/skhd}\"\nCERT_NAME=\"${SKHD_CERT:-skhd-cert}\"\nBUNDLE_ID=\"${SKHD_BUNDLE_ID:-com.jackielii.skhd}\"\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m' # No Color\n\necho \"Code signing skhd...\"\n\n# Resolve target: accept either a bare Mach-O binary or a .app bundle.\n# For .app bundles we read CFBundleExecutable from Info.plist so the\n# script works for both skhd.app and skhd-grabber.app (different inner\n# binary names).\nif [ -d \"$TARGET_PATH\" ] && [[ \"$TARGET_PATH\" == *.app ]]; then\n    APP_PATH=\"$TARGET_PATH\"\n    EXEC_NAME=$(/usr/libexec/PlistBuddy -c \"Print :CFBundleExecutable\" \"$APP_PATH/Contents/Info.plist\" 2>/dev/null || echo \"skhd\")\n    INNER_BINARY=\"$APP_PATH/Contents/MacOS/$EXEC_NAME\"\n    if [ ! -f \"$INNER_BINARY\" ]; then\n        echo -e \"${RED}Error: $APP_PATH does not contain Contents/MacOS/$EXEC_NAME${NC}\"\n        exit 1\n    fi\nelif [ -f \"$TARGET_PATH\" ]; then\n    APP_PATH=\"\"\n    INNER_BINARY=\"$TARGET_PATH\"\nelse\n    echo -e \"${RED}Error: $TARGET_PATH not found (expected a binary or a .app bundle)${NC}\"\n    echo \"Build the project first: zig build (or zig build app)\"\n    exit 1\nfi\n\n# Check if a certificate with this CN exists in the default keychain\n# search list. `security find-certificate -c <name>` (no explicit path)\n# walks the user's default search list, which covers both local dev\n# (login.keychain) and CI's temporary keychain swapped in via\n# `security list-keychain -d user -s ...`. We deliberately don't filter\n# by codeSigning EKU here: the user's CI cert is a self-signed import\n# that codesign accepts but find-identity -p codesigning rejects (no\n# EKU in the cert), so the EKU filter would false-negative in CI.\nif ! security find-certificate -c \"$CERT_NAME\" >/dev/null 2>&1; then\n    if [ -n \"$SKHD_NO_AUTO_GENERATE_CERT\" ]; then\n        echo -e \"${RED}Certificate '$CERT_NAME' not found in any keychain.${NC}\"\n        echo \"SKHD_NO_AUTO_GENERATE_CERT is set — refusing to generate a\"\n        echo \"fresh local cert (would diverge from the trust chain the\"\n        echo \"caller expects). Import the cert before running this script.\"\n        exit 1\n    fi\n    echo -e \"${YELLOW}Certificate '$CERT_NAME' not found.${NC}\"\n    echo \"Creating self-signed code signing certificate...\"\n    echo \"\"\n\n    TEMP_DIR=$(mktemp -d)\n    trap 'rm -rf \"$TEMP_DIR\"' EXIT\n    TEMP_KEY=\"$TEMP_DIR/key.pem\"\n    TEMP_CERT=\"$TEMP_DIR/cert.pem\"\n    TEMP_P12=\"$TEMP_DIR/cert.p12\"\n    TEMP_CONFIG=\"$TEMP_DIR/openssl.cnf\"\n\n    # Generate openssl config that marks the cert as critical for codeSigning EKU.\n    # Without the codeSigning EKU, `security find-identity -p codesigning` filters\n    # the cert out and codesign cannot use it.\n    cat > \"$TEMP_CONFIG\" <<EOF\n[req]\ndistinguished_name = req_dn\nprompt = no\nx509_extensions = v3_ca\n\n[req_dn]\nCN = $CERT_NAME\nO = skhd Development\nC = US\n\n[v3_ca]\nbasicConstraints = critical,CA:false\nkeyUsage = critical,digitalSignature\nextendedKeyUsage = critical,codeSigning\nEOF\n\n    openssl genrsa -out \"$TEMP_KEY\" 2048 2>/dev/null\n\n    openssl req -new -x509 -key \"$TEMP_KEY\" -out \"$TEMP_CERT\" -days 3650 \\\n        -config \"$TEMP_CONFIG\" 2>/dev/null\n\n    # macOS `security import` rejects empty-password p12 files produced by\n    # OpenSSL 3+ (\"MAC verification failed during PKCS12 import\"). Use a\n    # throwaway password and pass it to both export and import. The cert\n    # itself isn't password-protected once in the keychain.\n    P12_PASS=\"skhd-cert-import\"\n\n    # OpenSSL 3+ uses a stronger PKCS12 MAC by default that older `security`\n    # tools can't read. -legacy falls back to the algorithm macOS understands.\n    openssl pkcs12 -export -legacy -out \"$TEMP_P12\" -inkey \"$TEMP_KEY\" -in \"$TEMP_CERT\" \\\n        -passout \"pass:$P12_PASS\" 2>/dev/null\n\n    if security import \"$TEMP_P12\" -k ~/Library/Keychains/login.keychain-db -P \"$P12_PASS\" \\\n        -T /usr/bin/codesign -T /usr/bin/security >/dev/null 2>&1; then\n        echo -e \"${GREEN}✓ Certificate created successfully${NC}\"\n        # Allow codesign to use the key without prompting on every invocation.\n        security set-key-partition-list -S apple-tool:,apple: -k \"\" \\\n            ~/Library/Keychains/login.keychain-db >/dev/null 2>&1 || true\n    else\n        echo -e \"${RED}Failed to import certificate programmatically.${NC}\"\n        echo \"\"\n        echo -e \"${YELLOW}Please create a code signing certificate manually:${NC}\"\n        echo \"1. Open Keychain Access (in /Applications/Utilities/)\"\n        echo \"2. Go to: Keychain Access > Certificate Assistant > Create a Certificate\"\n        echo \"3. Name: $CERT_NAME\"\n        echo \"4. Identity Type: Self-Signed Root\"\n        echo \"5. Certificate Type: Code Signing\"\n        echo \"6. Click 'Create'\"\n        echo \"\"\n        echo \"After creating the certificate, run this script again.\"\n        exit 1\n    fi\n    echo \"\"\nfi\n\nif [ -n \"$APP_PATH\" ]; then\n    # Sign helpers first, principal last: codesign'ing a bundle's\n    # principal Mach-O (the file pointed at by CFBundleExecutable) walks\n    # the bundle to compute the resource seal and rejects the operation\n    # if any sibling Mach-O in Contents/MacOS/ is still unsigned, with\n    # \"code object is not signed at all / In subcomponent: <helper>\".\n    # Signing skhd-grabber first lets the principal seal succeed cleanly.\n    # The trailing bundle-layer codesign re-seals the wrapper for safety.\n    if [ -f \"$APP_PATH/Contents/MacOS/skhd-grabber\" ] && \\\n       [ \"$INNER_BINARY\" != \"$APP_PATH/Contents/MacOS/skhd-grabber\" ]; then\n        echo \"Signing helper:       $APP_PATH/Contents/MacOS/skhd-grabber\"\n        codesign -f -s \"$CERT_NAME\" -i \"$BUNDLE_ID\" \\\n            \"$APP_PATH/Contents/MacOS/skhd-grabber\"\n    fi\n    echo \"Signing inner binary: $INNER_BINARY\"\n    codesign -f -s \"$CERT_NAME\" -i \"$BUNDLE_ID\" \"$INNER_BINARY\"\n    echo \"Signing bundle: $APP_PATH\"\n    codesign -f -s \"$CERT_NAME\" -i \"$BUNDLE_ID\" \"$APP_PATH\"\n    VERIFY_TARGET=\"$APP_PATH\"\nelse\n    echo \"Signing binary: $INNER_BINARY\"\n    codesign -f -s \"$CERT_NAME\" -i \"$BUNDLE_ID\" \"$INNER_BINARY\"\n    VERIFY_TARGET=\"$INNER_BINARY\"\nfi\n\nif codesign -v \"$VERIFY_TARGET\" 2>/dev/null; then\n    echo -e \"${GREEN}✓ Successfully signed $VERIFY_TARGET${NC}\"\n    echo \"\"\n    echo \"Signature details:\"\n    codesign -dv --verbose=2 \"$VERIFY_TARGET\" 2>&1 | grep -E \"Authority|Identifier|Signature|Format\"\nelse\n    echo -e \"${RED}✗ Signature verification failed${NC}\"\n    exit 1\nfi\n\necho \"\"\necho -e \"${GREEN}Code signing complete!${NC}\"\nif [ -n \"$APP_PATH\" ]; then\n    echo \"The bundle is now signed with certificate '$CERT_NAME'\"\n    echo \"\"\n    echo \"Next steps:\"\n    echo \"1. Add $APP_PATH in System Settings → Privacy & Security → Accessibility\"\n    echo \"2. Toggle the entry on\"\n    echo \"3. Run: skhd --install-service && skhd --start-service\"\nelse\n    echo \"The binary is now signed with certificate '$CERT_NAME'\"\n    echo \"\"\n    echo \"Next steps:\"\n    echo \"1. Run skhd: $INNER_BINARY\"\n    echo \"2. Grant accessibility permissions in System Settings → Privacy & Security → Accessibility\"\n    echo \"3. The permissions should persist across rebuilds now\"\nfi\n"
  },
  {
    "path": "scripts/com.jackielii.skhd.grabber.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n  Template launchd plist for skhd-grabber.\n\n  Installed at /Library/LaunchDaemons/com.jackielii.skhd.grabber.plist\n  by `skhd --install-grabber` (Zig in-process — no shell script needed).\n\n  This is a system daemon (NOT a LaunchAgent) — runs as root, regardless\n  of which user is logged in, because IOHIDDeviceOpen(seize) and the\n  Karabiner vhidd_server both require root.\n\n  ProgramArguments is filled in at install time with the absolute path\n  of `skhd-grabber` inside the running skhd's `.app` bundle. Running from\n  inside the bundle is what makes TCC bundle-keyed — granting Input\n  Monitoring to skhd.app once covers both the agent and the grabber,\n  since both binaries are signed with `-i com.jackielii.skhd`.\n-->\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>Label</key>\n    <string>com.jackielii.skhd.grabber</string>\n\n    <key>ProgramArguments</key>\n    <array>\n        <string>__GRABBER_PATH__</string>\n    </array>\n\n    <key>RunAtLoad</key>\n    <true/>\n\n    <key>KeepAlive</key>\n    <true/>\n\n    <!--\n      Interactive ProcessType: macOS won't throttle us under user-driven\n      load. Same setting Karabiner-Elements uses for grabber_server.\n    -->\n    <key>ProcessType</key>\n    <string>Interactive</string>\n\n    <key>StandardOutPath</key>\n    <string>/var/log/skhd-grabber.log</string>\n\n    <key>StandardErrorPath</key>\n    <string>/var/log/skhd-grabber.log</string>\n</dict>\n</plist>\n"
  },
  {
    "path": "scripts/install-local.sh",
    "content": "#!/bin/bash\n# Install the local skhd build into /Applications/skhd.app (the slot a brew\n# install would occupy) and restart the SMAppService daemon. Lets you test\n# the packaged path — real bundle ID, real launchd registration, real TCC\n# slot — without cutting a release.\n#\n# First install on a fresh box requires a one-time accessibility re-grant\n# because the local skhd-cert keypair differs from CI's; subsequent installs\n# reuse the same local cert so the TCC entry stays valid.\n#\n# usage: install-local.sh <built-binary>\nset -e\n\nREPO_ROOT=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\nSRC_BINARY=\"${1:?usage: install-local.sh <built-binary>}\"\n\nPROD_LABEL=\"com.jackielii.skhd\"\nDOMAIN=\"gui/$(id -u)\"\n\n# Prefer /Applications/skhd.app if the user manually `ln -sfn`'d the bundle\n# there (caveats document this as optional — brew's sandbox can't create the\n# symlink in post_install). Otherwise write to the brew opt dir directly. The\n# OS dereferences the symlink for cp / codesign, so when /Applications is\n# present we don't need to resolve it manually.\nAPP_PATH=\"/Applications/skhd.app\"\nif [ ! -d \"$APP_PATH\" ]; then\n    APP_PATH=\"/opt/homebrew/opt/skhd-zig/skhd.app\"\nfi\nif [ ! -d \"$APP_PATH\" ]; then\n    echo \"Error: prod skhd.app not found at /Applications or brew opt path\" >&2\n    echo \"Install once via 'brew install jackielii/tap/skhd-zig' before using install-local.\" >&2\n    exit 1\nfi\n\nINNER_DST=\"$APP_PATH/Contents/MacOS/skhd\"\nPROD_PLIST=\"$APP_PATH/Contents/Library/LaunchAgents/${PROD_LABEL}.plist\"\n\nif [ ! -f \"$SRC_BINARY\" ]; then\n    echo \"Error: built binary not found at $SRC_BINARY\" >&2\n    exit 1\nfi\nif [ ! -f \"$PROD_PLIST\" ]; then\n    echo \"Error: prod LaunchAgent plist not found at $PROD_PLIST\" >&2\n    exit 1\nfi\n\necho \"Prod app:    $APP_PATH\"\necho \"Replacing:   $INNER_DST\"\n\n# KeepAlive would respawn the old binary mid-write, so unload first.\nwas_loaded=0\nif launchctl print \"$DOMAIN/$PROD_LABEL\" >/dev/null 2>&1; then\n    was_loaded=1\n    echo \"Stopping prod service...\"\n    launchctl bootout \"$DOMAIN/$PROD_LABEL\" 2>/dev/null || true\n    for _ in 1 2 3 4 5 6 7 8 9 10; do\n        launchctl print \"$DOMAIN/$PROD_LABEL\" >/dev/null 2>&1 || break\n        sleep 0.2\n    done\nfi\n\ncp \"$SRC_BINARY\" \"$INNER_DST\"\nchmod 755 \"$INNER_DST\"\n\n# Bundle skhd-grabber alongside skhd if it was built. resolveGrabberBinary()\n# in src/grabber_cli.zig looks for `skhd-grabber` next to the running skhd\n# binary first; without this overlay step a brew bundle would still lack it.\nGRABBER_SRC=\"$(dirname \"$SRC_BINARY\")/skhd-grabber\"\nGRABBER_DST=\"$APP_PATH/Contents/MacOS/skhd-grabber\"\nif [ -f \"$GRABBER_SRC\" ]; then\n    cp \"$GRABBER_SRC\" \"$GRABBER_DST\"\n    chmod 755 \"$GRABBER_DST\"\n    echo \"  + overlaid skhd-grabber\"\nfi\n\necho \"Signing with skhd-cert (prod bundle id)...\"\nSKHD_CERT=\"skhd-cert\" SKHD_BUNDLE_ID=\"$PROD_LABEL\" \\\n    bash \"$REPO_ROOT/scripts/codesign.sh\" \"$APP_PATH\" >/dev/null\n\necho \"Starting prod service...\"\nif launchctl bootstrap \"$DOMAIN\" \"$PROD_PLIST\" 2>/dev/null; then\n    :\nelif [ \"$was_loaded\" = \"1\" ] && launchctl kickstart -k \"$DOMAIN/$PROD_LABEL\" 2>/dev/null; then\n    :\nelse\n    echo \"Warning: launchctl bootstrap failed — run 'skhd --start-service' to recover.\" >&2\nfi\n\necho\necho \"Deployed locally-built skhd → $APP_PATH\"\necho \"If hotkeys stop working, the local skhd-cert is fresh and differs from\"\necho \"the cert TCC has on file. Open System Settings → Privacy & Security →\"\necho \"Accessibility and toggle skhd off and back on (one-time re-grant).\"\n"
  },
  {
    "path": "scripts/make-app.sh",
    "content": "#!/bin/bash\n# Wrap the skhd binary into a minimal .app bundle so macOS Tahoe / Sequoia\n# accept it for accessibility permission grants. The bundle structure also\n# makes TCC entries bundle-ID-keyed (com.jackielii.skhd) instead of path-keyed,\n# so permissions persist across `brew upgrade`.\nset -e\n\nBINARY_PATH=\"${1:?usage: make-app.sh <binary> <app-path> [bundle-id]}\"\nAPP_PATH=\"${2:?usage: make-app.sh <binary> <app-path> [bundle-id]}\"\nBUNDLE_ID=\"${3:-com.jackielii.skhd}\"\n\nREPO_ROOT=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\nTEMPLATE=\"$REPO_ROOT/assets/Info.plist.template\"\nLAUNCH_AGENT_PLIST=\"$REPO_ROOT/assets/LaunchAgent.plist\"\nVERSION_FILE=\"$REPO_ROOT/VERSION\"\n\nif [ ! -f \"$BINARY_PATH\" ]; then\n    echo \"Error: binary not found at $BINARY_PATH\" >&2\n    exit 1\nfi\nif [ ! -f \"$TEMPLATE\" ]; then\n    echo \"Error: Info.plist template not found at $TEMPLATE\" >&2\n    exit 1\nfi\nif [ ! -f \"$LAUNCH_AGENT_PLIST\" ]; then\n    echo \"Error: LaunchAgent.plist not found at $LAUNCH_AGENT_PLIST\" >&2\n    exit 1\nfi\nif [ ! -f \"$VERSION_FILE\" ]; then\n    echo \"Error: VERSION file not found at $VERSION_FILE\" >&2\n    exit 1\nfi\n\nVERSION=$(tr -d '\\n' < \"$VERSION_FILE\")\n\n# Build the new bundle in a temp path, then swap it in only after every step\n# has succeeded. If any command above the swap fails, set -e aborts and the\n# previously-installed $APP_PATH is left intact.\nTMP_APP=\"${APP_PATH}.tmp\"\nrm -rf \"$TMP_APP\"\nmkdir -p \"$TMP_APP/Contents/MacOS\"\nmkdir -p \"$TMP_APP/Contents/Library/LaunchAgents\"\n\nsed -e \"s/__VERSION__/${VERSION}/g\" -e \"s/__BUNDLE_ID__/${BUNDLE_ID}/g\" \\\n    \"$TEMPLATE\" > \"$TMP_APP/Contents/Info.plist\"\n# SMAppService.agent(plistName:) reads its target launchd plist from the\n# bundle's Contents/Library/LaunchAgents/<plistName>. Filename matches\n# the bundle id so the runtime register call can find it by passing\n# \"${BUNDLE_ID}.plist\".\ncp \"$LAUNCH_AGENT_PLIST\" \"$TMP_APP/Contents/Library/LaunchAgents/${BUNDLE_ID}.plist\"\ncp \"$BINARY_PATH\" \"$TMP_APP/Contents/MacOS/skhd\"\nchmod 755 \"$TMP_APP/Contents/MacOS/skhd\"\n\n# Bundle skhd-grabber alongside skhd if it was built. resolveGrabberBinary()\n# in src/grabber_cli.zig looks for `skhd-grabber` next to the running skhd\n# binary first, so this is what makes `--install-grabber` work for brew\n# users (no checked-out repo, no zig-out/bin/skhd-grabber to fall back to).\nGRABBER_BIN=\"$(dirname \"$BINARY_PATH\")/skhd-grabber\"\nif [ -f \"$GRABBER_BIN\" ]; then\n    cp \"$GRABBER_BIN\" \"$TMP_APP/Contents/MacOS/skhd-grabber\"\n    chmod 755 \"$TMP_APP/Contents/MacOS/skhd-grabber\"\n    echo \"  + bundled skhd-grabber\"\nelse\n    echo \"  ! skhd-grabber not found at $GRABBER_BIN — bundle will not\"\n    echo \"    include the grabber binary; --install-grabber will fail\"\n    echo \"    unless run from the repo root with zig-out/bin/skhd-grabber.\"\nfi\n\nrm -rf \"$APP_PATH\"\nmv \"$TMP_APP\" \"$APP_PATH\"\necho \"Bundle created at $APP_PATH\"\n"
  },
  {
    "path": "scripts/make-grabber-app.sh",
    "content": "#!/bin/bash\n# Wrap the skhd-grabber binary into a minimal .app bundle so macOS\n# Tahoe shows it in System Settings → Privacy & Security and TCC keys\n# the Input Monitoring grant on the bundle ID instead of the bare\n# binary's path/cdhash. Without bundling, every rebuild changes the\n# cdhash and forces re-approval.\n#\n# usage: make-grabber-app.sh <binary> <app-path> [bundle-id]\nset -e\n\nBINARY_PATH=\"${1:?usage: make-grabber-app.sh <binary> <app-path> [bundle-id]}\"\nAPP_PATH=\"${2:?usage: make-grabber-app.sh <binary> <app-path> [bundle-id]}\"\nBUNDLE_ID=\"${3:-com.jackielii.skhd.grabber}\"\n\nREPO_ROOT=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\nTEMPLATE=\"$REPO_ROOT/assets/Info.plist.grabber.template\"\nVERSION_FILE=\"$REPO_ROOT/VERSION\"\n\nif [ ! -f \"$BINARY_PATH\" ]; then\n    echo \"Error: binary not found at $BINARY_PATH\" >&2\n    exit 1\nfi\nif [ ! -f \"$TEMPLATE\" ]; then\n    echo \"Error: Info.plist template not found at $TEMPLATE\" >&2\n    exit 1\nfi\nif [ ! -f \"$VERSION_FILE\" ]; then\n    echo \"Error: VERSION file not found at $VERSION_FILE\" >&2\n    exit 1\nfi\n\nVERSION=$(tr -d '\\n' < \"$VERSION_FILE\")\n\n# Build the new bundle in a temp path, then swap it in atomically.\nTMP_APP=\"${APP_PATH}.tmp\"\nrm -rf \"$TMP_APP\"\nmkdir -p \"$TMP_APP/Contents/MacOS\"\n\nsed -e \"s/__VERSION__/${VERSION}/g\" -e \"s/__BUNDLE_ID__/${BUNDLE_ID}/g\" \\\n    \"$TEMPLATE\" > \"$TMP_APP/Contents/Info.plist\"\n\n# Inner binary is named skhd-grabber (matches CFBundleExecutable). The\n# actual executable lives at Contents/MacOS/skhd-grabber and is what\n# launchd / sudo invokes.\ncp \"$BINARY_PATH\" \"$TMP_APP/Contents/MacOS/skhd-grabber\"\nchmod 755 \"$TMP_APP/Contents/MacOS/skhd-grabber\"\n\nrm -rf \"$APP_PATH\"\nmv \"$TMP_APP\" \"$APP_PATH\"\necho \"Bundle created at $APP_PATH\"\n"
  },
  {
    "path": "scripts/release.sh",
    "content": "#!/bin/bash\n\n# Script to create a release and bump version for next cycle\n# Usage: ./scripts/release.sh [--bump major|minor|patch] [--no-bump]\n\nset -e\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\n# Parse arguments\nBUMP_VERSION=true  # Default to true - always bump version\nBUMP_TYPE=\"patch\"  # Default to patch bump\nNON_INTERACTIVE=false  # When true, skip all read prompts and assume yes\nwhile [[ $# -gt 0 ]]; do\n    case $1 in\n        --bump)\n            BUMP_VERSION=true\n            if [[ -n \"$2\" && \"$2\" != -* ]]; then\n                BUMP_TYPE=\"$2\"\n                shift\n            fi\n            shift\n            ;;\n        --no-bump)\n            BUMP_VERSION=false\n            shift\n            ;;\n        --yes|-y)\n            NON_INTERACTIVE=true\n            shift\n            ;;\n        *)\n            echo \"Usage: $0 [--bump major|minor|patch] [--no-bump] [--yes|-y]\"\n            echo \"  Default: bump patch version after release\"\n            echo \"  Use --no-bump to skip version bump\"\n            echo \"  Use --yes/-y to run non-interactively (assume yes for all prompts;\"\n            echo \"  also fails fast if a step would normally need a manual decision,\"\n            echo \"  e.g. CHANGELOG missing or homebrew-tap fetch failure)\"\n            exit 1\n            ;;\n    esac\ndone\n\n# Helper: prompt unless --yes is set. In non-interactive mode, just print the\n# message (so logs show what was skipped) and continue without reading stdin.\nconfirm_or_skip() {\n    local message=\"$1\"\n    if [ \"$NON_INTERACTIVE\" = true ]; then\n        echo \"[--yes] auto-confirming: $message\"\n        return 0\n    fi\n    echo -e \"${YELLOW}$message${NC}\"\n    read -r\n}\n\n# Helper: prompt for y/n confirmation, or auto-yes in non-interactive mode.\n# Returns 0 on yes, 1 on anything else.\nconfirm_yn() {\n    local message=\"$1\"\n    if [ \"$NON_INTERACTIVE\" = true ]; then\n        echo \"[--yes] auto-confirming: $message\"\n        return 0\n    fi\n    echo -e \"${YELLOW}$message (y/n)${NC}\"\n    local reply\n    read -r reply\n    [ \"$reply\" = \"y\" ]\n}\n\nCURRENT_VERSION=$(cat VERSION)\nTAG=\"v$CURRENT_VERSION\"\n\n# Determine total steps\nif [ \"$BUMP_VERSION\" = true ]; then\n    TOTAL_STEPS=8\nelse\n    TOTAL_STEPS=7\nfi\n\n# Step-by-step guide\necho \"\"\necho -e \"${BLUE}═══════════════════════════════════════════════════════════════${NC}\"\necho -e \"${BLUE}                    Release Process for v$CURRENT_VERSION${NC}\"\necho -e \"${BLUE}═══════════════════════════════════════════════════════════════${NC}\"\necho \"\"\necho \"Steps to complete:\"\necho \"  1. Pre-flight checks (branch, uncommitted changes, tag exists)\"\necho \"  2. Pull latest changes from origin\"\necho \"  3. Check/update CHANGELOG.md\"\necho \"  4. Run tests\"\necho \"  5. Build release binaries\"\necho \"  6. Generate release notes\"\necho \"  7. Create and push tag\"\nif [ \"$BUMP_VERSION\" = true ]; then\necho \"  8. Bump version for next development cycle\"\nfi\necho \"\"\nconfirm_or_skip \"Press Enter to start, or Ctrl+C to cancel\"\necho \"\"\n\n# Step 1: Pre-flight checks\necho -e \"${BLUE}[Step 1/$TOTAL_STEPS] Pre-flight checks...${NC}\"\n\n# Check if we're on main branch\nCURRENT_BRANCH=$(git branch --show-current)\nif [ \"$CURRENT_BRANCH\" != \"main\" ]; then\n    echo -e \"${RED}Error: Not on main branch. Current branch: $CURRENT_BRANCH${NC}\"\n    echo \"Please switch to main branch before releasing\"\n    exit 1\nfi\n\n# Check for uncommitted changes\nif ! git diff-index --quiet HEAD --; then\n    echo -e \"${RED}Error: There are uncommitted changes${NC}\"\n    echo \"Please commit or stash your changes before releasing\"\n    exit 1\nfi\n\n# Check if tag already exists\nif git rev-parse \"$TAG\" >/dev/null 2>&1; then\n    echo -e \"${RED}Error: Tag $TAG already exists${NC}\"\n    echo \"If you want to create a new release, bump the version first\"\n    exit 1\nfi\n\n# Verify homebrew-tap is bundle-aware. The release.yml auto-bump rewrites\n# the formula's URL + sha256, but does NOT touch the install block. With\n# 0.0.18+ the tarball contains skhd.app/ instead of a bare binary, so a\n# pre-bundle install block (`bin.install \"skhd-arm64-macos\" => \"skhd\"`)\n# would silently produce broken installs after the auto-bump runs. Block\n# the release until the formula has the bundle-aware install logic.\necho \"Checking homebrew-tap formula is bundle-aware...\"\nHEAD_FORMULA=$(curl -fsSL https://raw.githubusercontent.com/jackielii/homebrew-tap/main/Formula/skhd-zig.rb 2>/dev/null || true)\nif [ -z \"$HEAD_FORMULA\" ]; then\n    echo -e \"${YELLOW}Warning: could not fetch homebrew-tap formula${NC}\"\n    if ! confirm_yn \"Continue anyway?\"; then\n        exit 1\n    fi\nelif ! echo \"$HEAD_FORMULA\" | grep -q 'File.directory?(\"skhd.app\")'; then\n    echo -e \"${RED}Error: homebrew-tap/main formula is NOT bundle-aware.${NC}\"\n    echo \"Releasing v$CURRENT_VERSION now will break 'brew install jackielii/tap/skhd-zig'\"\n    echo \"for everyone, because the auto-bump rewrites a stale install block.\"\n    echo \"\"\n    echo \"Merge the bundle-aware formula PR first, then re-run this script.\"\n    exit 1\nfi\n\necho -e \"${GREEN}✓ Pre-flight checks passed${NC}\"\necho \"\"\n\n# Step 2: Pull latest changes\necho -e \"${BLUE}[Step 2/$TOTAL_STEPS] Pulling latest changes from origin...${NC}\"\ngit pull origin main\necho -e \"${GREEN}✓ Up to date with origin${NC}\"\n\n# Step 3: Check/update CHANGELOG.md\necho \"\"\necho -e \"${BLUE}[Step 3/$TOTAL_STEPS] Checking CHANGELOG.md...${NC}\"\nif ! grep -q \"## \\[$CURRENT_VERSION\\]\" CHANGELOG.md; then\n    echo -e \"${YELLOW}Warning: No changelog entry found for version $CURRENT_VERSION${NC}\"\n    echo \"Using Claude to generate changelog entry...\"\n\n    # Get git diff since last tag\n    LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo \"\")\n    if [ -n \"$LAST_TAG\" ]; then\n        GIT_LOG=$(git log --oneline $LAST_TAG..HEAD)\n    else\n        GIT_LOG=$(git log --oneline -20)\n    fi\n\n    # Use Claude to analyze changes and update CHANGELOG.md\n    # --dangerously-skip-permissions allows file edits without prompts\n    claude --dangerously-skip-permissions -p \"Please analyze these git commits and update CHANGELOG.md with an entry for version $CURRENT_VERSION. Follow the existing format in the file. Here are the commits since the last release:\n\n$GIT_LOG\n\nPlease add the new entry after ## [Unreleased] and before the previous version entry. Use the current date.\n\nIMPORTANT: Also update the link reference section at the bottom of the file:\n  1. Update the [Unreleased] link to compare against v$CURRENT_VERSION (i.e. v$CURRENT_VERSION...HEAD).\n  2. Add a new [$CURRENT_VERSION] link comparing the previous version to v$CURRENT_VERSION (i.e. v<previous>...v$CURRENT_VERSION).\nThe entry will not render correctly on GitHub without these link references.\" CHANGELOG.md\n\n    echo \"\"\n    echo \"CHANGELOG.md has been updated. Please review the changes.\"\n    confirm_or_skip \"Press Enter to continue with the updated changelog, or Ctrl+C to cancel\"\n\n    # Commit the changelog update\n    git add CHANGELOG.md\n    git commit -m \"Update CHANGELOG.md for version $CURRENT_VERSION\"\nelse\n    echo -e \"${GREEN}✓ CHANGELOG.md already has entry for $CURRENT_VERSION${NC}\"\nfi\n\n# Step 4: Run tests\necho \"\"\necho -e \"${BLUE}[Step 4/$TOTAL_STEPS] Running tests...${NC}\"\nif ! zig build test; then\n    echo -e \"${RED}Error: Tests failed${NC}\"\n    exit 1\nfi\necho -e \"${GREEN}✓ Tests passed${NC}\"\n\n# Step 5: Build release artifacts (bare binary + .app bundle)\n# The release pipeline ships skhd.app inside skhd-<arch>-macos.tar.gz, so\n# verify the bundle layout builds cleanly here before tagging.\necho \"\"\necho -e \"${BLUE}[Step 5/$TOTAL_STEPS] Building release artifacts...${NC}\"\nzig build -Doptimize=ReleaseFast\nzig build app -Doptimize=ReleaseFast\ntest -f zig-out/skhd.app/Contents/MacOS/skhd || {\n    echo -e \"${RED}Error: skhd.app missing inner binary${NC}\"\n    exit 1\n}\ntest -f zig-out/skhd.app/Contents/Info.plist || {\n    echo -e \"${RED}Error: skhd.app missing Info.plist${NC}\"\n    exit 1\n}\necho -e \"${GREEN}✓ Release artifacts built (bare binary + skhd.app bundle)${NC}\"\n\n# Step 6: Generate release notes using Claude\necho \"\"\necho -e \"${BLUE}[Step 6/$TOTAL_STEPS] Generating release notes...${NC}\"\nCHANGELOG_ENTRY=$(awk \"/## \\[$CURRENT_VERSION\\]/{flag=1; next} /## \\[/{flag=0} flag\" CHANGELOG.md)\n\nRELEASE_NOTES=$(claude -p \"Generate concise GitHub release notes for skhd.zig version $CURRENT_VERSION based on this changelog entry. Format it nicely with markdown, highlighting the most important changes first. Keep it user-friendly and avoid technical jargon where possible:\\n\\n$CHANGELOG_ENTRY\" </dev/null)\n\necho \"\"\necho -e \"${YELLOW}Release Notes:${NC}\"\necho \"----------------------------------------\"\necho \"$RELEASE_NOTES\"\necho \"----------------------------------------\"\necho \"\"\nif ! confirm_yn \"Do you want to proceed with creating tag $TAG with these release notes?\"; then\n    echo -e \"${RED}Release cancelled${NC}\"\n    exit 1\nfi\n\n# Step 7: Create tag and push\necho \"\"\necho -e \"${BLUE}[Step 7/$TOTAL_STEPS] Creating and pushing tag...${NC}\"\n# --cleanup=verbatim: preserve markdown headings (lines starting with `#`).\n# Default cleanup mode strips them as comments, which silently drops a\n# leading \"# Title\" line from the release notes.\ngit tag -a \"$TAG\" --cleanup=verbatim -m \"$RELEASE_NOTES\"\necho -e \"${GREEN}✓ Tag $TAG created${NC}\"\n\necho \"Pushing tag to origin...\"\ngit push origin \"$TAG\"\necho -e \"${GREEN}✓ Tag pushed to origin${NC}\"\n\necho \"\"\necho \"GitHub Actions will now automatically:\"\necho \"  - Build skhd.app bundles for both architectures (arm64 + x86_64)\"\necho \"  - Code-sign with skhd-cert (if MACOS_CERTIFICATE secret is set)\"\necho \"  - Create a GitHub release with skhd-<arch>-macos.tar.gz containing skhd.app\"\necho \"  - Update the Homebrew formula (URL + sha256)\"\n\n# Step 8: Bump version if requested\nif [ \"$BUMP_VERSION\" = true ]; then\n    echo \"\"\n    echo -e \"${BLUE}[Step 8/$TOTAL_STEPS] Bumping version for next development cycle...${NC}\"\n    \n    # Parse current version\n    IFS='.' read -r -a version_parts <<< \"$CURRENT_VERSION\"\n    MAJOR=\"${version_parts[0]}\"\n    MINOR=\"${version_parts[1]}\"\n    PATCH=\"${version_parts[2]}\"\n    \n    # Bump version based on type\n    case $BUMP_TYPE in\n        major)\n            MAJOR=$((MAJOR + 1))\n            MINOR=0\n            PATCH=0\n            ;;\n        minor)\n            MINOR=$((MINOR + 1))\n            PATCH=0\n            ;;\n        patch)\n            PATCH=$((PATCH + 1))\n            ;;\n        *)\n            echo -e \"${RED}Invalid bump type: $BUMP_TYPE${NC}\"\n            echo \"Valid types: major, minor, patch\"\n            exit 1\n            ;;\n    esac\n    \n    NEW_VERSION=\"$MAJOR.$MINOR.$PATCH\"\n    \n    # Update VERSION file\n    echo \"$NEW_VERSION\" > VERSION\n    \n    # Commit and push\n    git add VERSION\n    git commit -m \"Bump version to $NEW_VERSION for next development cycle\"\n    git push origin main\n\n    echo -e \"${GREEN}✓ Version bumped from $CURRENT_VERSION to $NEW_VERSION${NC}\"\n    echo \"Development builds will now show as '$NEW_VERSION-dev-<commit>'\"\nfi\n\necho \"\"\necho -e \"${GREEN}═══════════════════════════════════════════════════════════════${NC}\"\necho -e \"${GREEN}                    Release Complete! 🎉${NC}\"\necho -e \"${GREEN}═══════════════════════════════════════════════════════════════${NC}\""
  },
  {
    "path": "src/CarbonEvent.zig",
    "content": "const std = @import(\"std\");\nconst c = @import(\"c.zig\");\n\nconst CarbonEvent = @This();\nconst log = std.log.scoped(.carbon_event);\n\nallocator: std.mem.Allocator,\nhandler_ref: c.EventHandlerRef,\nevent_type: c.EventTypeSpec,\nprocess_buffer: [512]u8 = undefined,\nbuffer_len: usize = 0,\nmutex: std.Thread.Mutex = .{},\n\npub fn init(allocator: std.mem.Allocator) !*CarbonEvent {\n    const self = try allocator.create(CarbonEvent);\n    errdefer allocator.destroy(self);\n\n    self.* = CarbonEvent{\n        .allocator = allocator,\n        .handler_ref = undefined,\n        .event_type = .{\n            .eventClass = c.kEventClassApplication,\n            .eventKind = c.kEventAppFrontSwitched,\n        },\n    };\n\n    // Get initial process name\n    try self.updateProcessName();\n\n    // Install event handler\n    const status = c.InstallApplicationEventHandler(\n        carbonEventHandler,\n        1,\n        &self.event_type,\n        self,\n        &self.handler_ref,\n    );\n\n    if (status != c.noErr) {\n        return error.CarbonEventInitFailed;\n    }\n\n    return self;\n}\n\npub fn deinit(self: *CarbonEvent) void {\n    // Remove event handler\n    _ = c.RemoveEventHandler(self.handler_ref);\n\n    // Free self\n    self.allocator.destroy(self);\n}\n\n/// Get the cached process name (thread-safe)\npub fn getProcessName(self: *CarbonEvent) []const u8 {\n    self.mutex.lock();\n    defer self.mutex.unlock();\n\n    // Return \"unknown\" if we don't have a process name\n    if (self.buffer_len == 0) {\n        return \"unknown\";\n    }\n    return self.process_buffer[0..self.buffer_len];\n}\n\n/// Update the cached process name (called by event handler)\nfn updateProcessName(self: *CarbonEvent) !void {\n    var psn: c.ProcessSerialNumber = undefined;\n\n    self.mutex.lock();\n    defer self.mutex.unlock();\n\n    const status = c.GetFrontProcess(&psn);\n    if (status != c.noErr) {\n        self.buffer_len = 0;\n        return;\n    }\n\n    var ref: c.CFStringRef = undefined;\n    const copy_status = c.CopyProcessName(&psn, &ref);\n    if (copy_status != c.noErr) {\n        self.buffer_len = 0;\n        return;\n    }\n    defer c.CFRelease(ref);\n\n    const success = c.CFStringGetCString(\n        ref,\n        &self.process_buffer,\n        self.process_buffer.len,\n        c.kCFStringEncodingUTF8,\n    );\n\n    if (success == 0) {\n        self.buffer_len = 0;\n        return;\n    }\n\n    // Find actual length\n    const c_string_len = std.mem.len(@as([*:0]const u8, @ptrCast(&self.process_buffer)));\n    self.buffer_len = c_string_len;\n\n    // Convert to lowercase in-place\n    for (self.process_buffer[0..self.buffer_len]) |*char| {\n        char.* = std.ascii.toLower(char.*);\n    }\n}\n\n/// Carbon event handler callback\nfn carbonEventHandler(\n    _: c.EventHandlerCallRef,\n    event: c.EventRef,\n    user_data: ?*anyopaque,\n) callconv(.c) c.OSStatus {\n    _ = event;\n\n    if (user_data) |data| {\n        const self = @as(*CarbonEvent, @ptrCast(@alignCast(data)));\n        self.updateProcessName() catch |err| {\n            std.log.err(\"Failed to update process name: {}\", .{err});\n        };\n    }\n\n    return c.noErr;\n}\n"
  },
  {
    "path": "src/DeviceCheck.zig",
    "content": "//! Quick \"is a HID device with this (vendor, product) connected?\"\n//! check, used by the agent to decide whether to forward block-form\n//! `.remap` rules to the grabber.\n//!\n//! Without this, a config that targets `[device builtin]` on a Mac\n//! Studio (no built-in keyboard) would still try to dial the grabber\n//! socket and emit a warning when the grabber isn't installed —\n//! forcing the user to install Karabiner-DriverKit-VirtualHIDDevice\n//! and the grabber on a machine where they're never going to fire.\n//!\n//! Hand-rolled IOKit bindings rather than `@cImport(...IOHIDManager.h)`\n//! because the C translator chokes on `iokit_common_err(return)` in\n//! IOReturn.h on Zig 0.14 (same reason the grabber hand-rolls).\n\nconst std = @import(\"std\");\n\nconst log = std.log.scoped(.device_check);\n\nconst CFAllocatorRef = ?*anyopaque;\nconst CFArrayRef = ?*anyopaque;\nconst CFArrayCallBacks = anyopaque;\nconst CFMutableArrayRef = ?*anyopaque;\nconst CFDictionaryRef = ?*anyopaque;\nconst CFDictionaryKeyCallBacks = anyopaque;\nconst CFDictionaryValueCallBacks = anyopaque;\nconst CFMutableDictionaryRef = ?*anyopaque;\nconst CFNumberRef = ?*anyopaque;\nconst CFStringRef = ?*anyopaque;\nconst CFTypeRef = ?*anyopaque;\nconst CFIndex = isize;\n\nconst CFNumberType = c_int;\nconst kCFNumberSInt32Type: CFNumberType = 3;\n\nconst CFStringEncoding = u32;\nconst kCFStringEncodingUTF8: CFStringEncoding = 0x08000100;\n\nconst IOOptionBits = u32;\nconst IOReturn = c_int;\nconst kIOReturnSuccess: IOReturn = 0;\n\nconst IOHIDManagerRef = ?*anyopaque;\n\nextern const kCFAllocatorDefault: CFAllocatorRef;\nextern const kCFTypeArrayCallBacks: CFArrayCallBacks;\nextern const kCFTypeDictionaryKeyCallBacks: CFDictionaryKeyCallBacks;\nextern const kCFTypeDictionaryValueCallBacks: CFDictionaryValueCallBacks;\n\nextern fn CFRelease(cf: CFTypeRef) void;\nextern fn CFArrayCreateMutable(allocator: CFAllocatorRef, capacity: CFIndex, callbacks: *const CFArrayCallBacks) CFMutableArrayRef;\nextern fn CFArrayAppendValue(array: CFMutableArrayRef, value: ?*const anyopaque) void;\nextern fn CFDictionaryCreateMutable(allocator: CFAllocatorRef, capacity: CFIndex, keyCallBacks: *const CFDictionaryKeyCallBacks, valueCallBacks: *const CFDictionaryValueCallBacks) CFMutableDictionaryRef;\nextern fn CFDictionarySetValue(dict: CFMutableDictionaryRef, key: ?*const anyopaque, value: ?*const anyopaque) void;\nextern fn CFNumberCreate(allocator: CFAllocatorRef, type_: CFNumberType, valuePtr: *const anyopaque) CFNumberRef;\nextern fn CFStringCreateWithCString(allocator: CFAllocatorRef, cstr: [*:0]const u8, encoding: CFStringEncoding) CFStringRef;\nextern fn CFSetGetCount(theSet: ?*anyopaque) CFIndex;\n\nextern fn IOHIDManagerCreate(allocator: CFAllocatorRef, options: IOOptionBits) IOHIDManagerRef;\nextern fn IOHIDManagerSetDeviceMatchingMultiple(manager: IOHIDManagerRef, multiple: CFArrayRef) void;\nextern fn IOHIDManagerCopyDevices(manager: IOHIDManagerRef) ?*anyopaque;\n\nconst kIOHIDVendorIDKey: [*:0]const u8 = \"VendorID\";\nconst kIOHIDProductIDKey: [*:0]const u8 = \"ProductID\";\n\n/// True when at least one HID device matching `(vendor, product)` is\n/// currently connected. False when the lookup failed too — callers\n/// treat \"unknown\" the same as \"absent\" since the only consequence\n/// is skipping a grabber dial that would warn anyway.\npub fn isPresent(vendor: u32, product: u32) bool {\n    const manager = IOHIDManagerCreate(kCFAllocatorDefault, 0);\n    if (manager == null) return false;\n    defer CFRelease(manager);\n\n    const dicts = CFArrayCreateMutable(kCFAllocatorDefault, 1, &kCFTypeArrayCallBacks);\n    if (dicts == null) return false;\n    defer CFRelease(dicts);\n\n    const dict = CFDictionaryCreateMutable(kCFAllocatorDefault, 2, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);\n    if (dict == null) return false;\n    defer CFRelease(dict);\n\n    const v_key = CFStringCreateWithCString(kCFAllocatorDefault, kIOHIDVendorIDKey, kCFStringEncodingUTF8);\n    if (v_key == null) return false;\n    defer CFRelease(v_key);\n    const p_key = CFStringCreateWithCString(kCFAllocatorDefault, kIOHIDProductIDKey, kCFStringEncodingUTF8);\n    if (p_key == null) return false;\n    defer CFRelease(p_key);\n\n    var v: i32 = @intCast(vendor);\n    var p: i32 = @intCast(product);\n    const v_num = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &v);\n    if (v_num == null) return false;\n    defer CFRelease(v_num);\n    const p_num = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &p);\n    if (p_num == null) return false;\n    defer CFRelease(p_num);\n\n    CFDictionarySetValue(dict, v_key, v_num);\n    CFDictionarySetValue(dict, p_key, p_num);\n    CFArrayAppendValue(dicts, dict);\n\n    IOHIDManagerSetDeviceMatchingMultiple(manager, dicts);\n\n    const matched = IOHIDManagerCopyDevices(manager) orelse return false;\n    defer CFRelease(matched);\n    const count = CFSetGetCount(matched);\n    log.debug(\"vendor=0x{X:0>4} product=0x{X:0>4} → {d} match(es)\", .{ vendor, product, count });\n    return count > 0;\n}\n"
  },
  {
    "path": "src/EventTap.zig",
    "content": "const std = @import(\"std\");\nconst c = @import(\"c.zig\");\n\nhandle: c.CFMachPortRef = null,\nrunloop_source: c.CFRunLoopSourceRef = null,\nmask: c.CGEventMask,\n\nconst EventTap = @This();\nconst log = std.log.scoped(.event_tap);\n\npub fn enabled(self: *EventTap) bool {\n    return self.handle != null and c.CGEventTapIsEnabled(self.handle);\n}\n\n// pub const CGEventTapCallBack = ?*const fn (CGEventTapProxy, CGEventType, CGEventRef, ?*anyopaque) callconv(.c) CGEventRef;\n\npub fn begin(self: *EventTap, callback: c.CGEventTapCallBack, user_info: ?*anyopaque) !void {\n    // CGEventTapCreate can transiently return NULL during early login on macOS\n    // (Tahoe especially) when WindowServer/TCC haven't finished coming up, even\n    // though accessibility permissions are granted. Retry briefly before giving\n    // up; a real permissions denial will fail every attempt and surface the\n    // same error after the retry budget is spent.\n    const max_attempts: u8 = 10;\n    const retry_delay_ns: u64 = 500 * std.time.ns_per_ms;\n\n    var attempt: u8 = 0;\n    while (attempt < max_attempts) : (attempt += 1) {\n        self.handle = c.CGEventTapCreate(c.kCGSessionEventTap, c.kCGHeadInsertEventTap, //\n            c.kCGEventTapOptionDefault, self.mask, callback, user_info);\n        if (self.enabled()) {\n            self.runloop_source = c.CFMachPortCreateRunLoopSource(c.kCFAllocatorDefault, self.handle, 0);\n            c.CFRunLoopAddSource(c.CFRunLoopGetMain(), self.runloop_source, c.kCFRunLoopCommonModes);\n            if (attempt > 0) log.info(\"Event tap created on attempt {d}/{d}\", .{ attempt + 1, max_attempts });\n            return;\n        }\n        if (attempt + 1 < max_attempts) {\n            log.warn(\"Event tap creation failed (attempt {d}/{d}), retrying in 500ms...\", .{ attempt + 1, max_attempts });\n            std.time.sleep(retry_delay_ns);\n        }\n    }\n    return error.AccessibilityPermissionDenied;\n}\n\npub fn deinit(self: *EventTap) void {\n    if (self.handle == null) return;\n    // Clean up regardless of enabled state — when the system disables the tap\n    // (e.g. accessibility revoked at runtime), `enabled()` is false but the\n    // CFMachPort and run loop source are still installed and must be torn\n    // down or the keyboard remains captured.\n    c.CGEventTapEnable(self.handle, false);\n    c.CFMachPortInvalidate(self.handle);\n    if (self.runloop_source != null) {\n        c.CFRunLoopRemoveSource(c.CFRunLoopGetMain(), self.runloop_source, c.kCFRunLoopCommonModes);\n        c.CFRelease(self.runloop_source);\n        self.runloop_source = null;\n    }\n    c.CFRelease(self.handle);\n    self.handle = null;\n}\n\n// This test would block forever as it runs an actual event tap\n// test \"EventTap\" {\n//     var event_tap = EventTap{ .mask = (1 << c.kCGEventKeyDown) | (1 << c.NX_SYSDEFINED) };\n//     defer event_tap.deinit();\n\n//     const callback = struct {\n//         fn f(proxy: c.CGEventTapProxy, typ: c.CGEventType, event: c.CGEventRef, _: ?*anyopaque) callconv(.c) c.CGEventRef {\n//             _ = proxy;\n//             if (typ == c.kCGEventKeyDown) {\n//                 const keycode = c.CGEventGetIntegerValueField(event, c.kCGKeyboardEventKeycode);\n//                 const flags = c.CGEventGetFlags(event);\n//                 // Control + C\n//                 if (keycode == c.kVK_ANSI_C and flags & c.kCGEventFlagMaskControl != 0) {\n//                     std.process.exit(0);\n//                 }\n//             }\n//             std.debug.print(\"Event: {any}\\n\", .{event.?});\n//             return event;\n//         }\n//     };\n//     try event_tap.run(callback.f, null);\n// }\n"
  },
  {
    "path": "src/HidKeyMap.zig",
    "content": "//! Mapping from skhd keysym names to HID Keyboard/Keypad usage codes\n//! (page 0x07). Used by `.remap` to translate config names like\n//! \"caps_lock\" / \"lctrl\" into the values `hidutil` expects.\n//!\n//! hidutil represents each remap entry as a 64-bit src/dst pair where\n//! the upper 32 bits are the usage page and the lower 32 bits are the\n//! usage. `fullUsage(usage)` packs the keyboard page (0x07) for callers.\n//!\n//! Names are HID-standard (physical-position, layout-independent) and\n//! deliberately differ from skhd's macOS-virtual-keycode names you see\n//! in `skhd -o` output. Examples:\n//!   - `grave` here = HID 0x35 = the top-left key. On US that's\n//!     `` ` /~ ``; on UK ISO it's `§/±`.\n//!   - `non_us_backslash` = HID 0x64 = the ISO-only key between left\n//!     shift and `Z`. UK ISO sends `` ` /~ `` from this key.\n//!\n//! When a `.remap` lookup fails, the parser error lists every name in\n//! this table so users can discover the right one without reading the\n//! source.\nconst std = @import(\"std\");\n\npub const HID_PAGE_KEYBOARD: u64 = 0x07;\n\n/// Pack a keyboard-page usage into the 64-bit value `hidutil` consumes:\n/// `(0x07 << 32) | usage`.\npub inline fn fullUsage(usage: u32) u64 {\n    return (HID_PAGE_KEYBOARD << 32) | usage;\n}\n\n/// Look up a skhd keysym name and return its HID usage byte (just the\n/// usage, page is implied 0x07). Returns null when the name has no HID\n/// equivalent we expose for `.remap`.\npub fn lookup(name: []const u8) ?u32 {\n    return Map.get(name);\n}\n\n/// All known names in declaration order. Used by the parser to list\n/// available names when a `.remap` source or destination doesn't\n/// resolve.\npub fn knownNames() []const []const u8 {\n    return Map.keys();\n}\n\n/// Static name → usage table. HID-standard physical-position names —\n/// see the file-level comment for how these differ from skhd's macOS\n/// virtual-keycode names.\nconst Map = std.StaticStringMap(u32).initComptime(&.{\n    // Modifiers\n    .{ \"lctrl\", 0xE0 },\n    .{ \"lshift\", 0xE1 },\n    .{ \"lalt\", 0xE2 },\n    .{ \"lcmd\", 0xE3 },\n    .{ \"rctrl\", 0xE4 },\n    .{ \"rshift\", 0xE5 },\n    .{ \"ralt\", 0xE6 },\n    .{ \"rcmd\", 0xE7 },\n\n    // Toggles + control keys\n    .{ \"caps_lock\", 0x39 },\n    .{ \"escape\", 0x29 },\n    .{ \"return\", 0x28 },\n    .{ \"tab\", 0x2B },\n    .{ \"space\", 0x2C },\n    .{ \"backspace\", 0x2A },\n    .{ \"delete\", 0x4C }, // Delete Forward\n    .{ \"insert\", 0x49 },\n    .{ \"home\", 0x4A },\n    .{ \"end\", 0x4D },\n    .{ \"pageup\", 0x4B },\n    .{ \"pagedown\", 0x4E },\n    .{ \"left\", 0x50 },\n    .{ \"right\", 0x4F },\n    .{ \"up\", 0x52 },\n    .{ \"down\", 0x51 },\n\n    // F-row (F1..F20)\n    .{ \"f1\", 0x3A },  .{ \"f2\", 0x3B },  .{ \"f3\", 0x3C },  .{ \"f4\", 0x3D },\n    .{ \"f5\", 0x3E },  .{ \"f6\", 0x3F },  .{ \"f7\", 0x40 },  .{ \"f8\", 0x41 },\n    .{ \"f9\", 0x42 },  .{ \"f10\", 0x43 }, .{ \"f11\", 0x44 }, .{ \"f12\", 0x45 },\n    .{ \"f13\", 0x68 }, .{ \"f14\", 0x69 }, .{ \"f15\", 0x6A }, .{ \"f16\", 0x6B },\n    .{ \"f17\", 0x6C }, .{ \"f18\", 0x6D }, .{ \"f19\", 0x6E }, .{ \"f20\", 0x6F },\n\n    // Letters\n    .{ \"a\", 0x04 }, .{ \"b\", 0x05 }, .{ \"c\", 0x06 }, .{ \"d\", 0x07 },\n    .{ \"e\", 0x08 }, .{ \"f\", 0x09 }, .{ \"g\", 0x0A }, .{ \"h\", 0x0B },\n    .{ \"i\", 0x0C }, .{ \"j\", 0x0D }, .{ \"k\", 0x0E }, .{ \"l\", 0x0F },\n    .{ \"m\", 0x10 }, .{ \"n\", 0x11 }, .{ \"o\", 0x12 }, .{ \"p\", 0x13 },\n    .{ \"q\", 0x14 }, .{ \"r\", 0x15 }, .{ \"s\", 0x16 }, .{ \"t\", 0x17 },\n    .{ \"u\", 0x18 }, .{ \"v\", 0x19 }, .{ \"w\", 0x1A }, .{ \"x\", 0x1B },\n    .{ \"y\", 0x1C }, .{ \"z\", 0x1D },\n\n    // Digits (top row)\n    .{ \"1\", 0x1E }, .{ \"2\", 0x1F }, .{ \"3\", 0x20 }, .{ \"4\", 0x21 },\n    .{ \"5\", 0x22 }, .{ \"6\", 0x23 }, .{ \"7\", 0x24 }, .{ \"8\", 0x25 },\n    .{ \"9\", 0x26 }, .{ \"0\", 0x27 },\n\n    // Punctuation (US layout positions; HID is layout-independent so\n    // these refer to physical keys, not character output).\n    .{ \"minus\",            0x2D }, // -/_  (right of 0)\n    .{ \"equal\",            0x2E }, // =/+\n    .{ \"lbracket\",         0x2F }, // [/{\n    .{ \"rbracket\",         0x30 }, // ]/}\n    .{ \"backslash\",        0x31 }, // \\/| (US, above return)\n    .{ \"non_us_hash\",      0x32 }, // # on some ISO layouts (rarely needed)\n    .{ \"semicolon\",        0x33 }, // ;/:\n    .{ \"quote\",            0x34 }, // '/\"\n    .{ \"grave\",            0x35 }, // top-left key — `/~ on US, §/± on UK ISO\n    .{ \"comma\",            0x36 }, // ,/<\n    .{ \"period\",           0x37 }, // ./>\n    .{ \"slash\",            0x38 }, // ///\n    .{ \"non_us_backslash\", 0x64 }, // ISO-only key between L-shift and Z\n});\n\ntest \"lookup returns expected HID usage codes\" {\n    try std.testing.expectEqual(@as(?u32, 0x39), lookup(\"caps_lock\"));\n    try std.testing.expectEqual(@as(?u32, 0xE0), lookup(\"lctrl\"));\n    try std.testing.expectEqual(@as(?u32, 0x6D), lookup(\"f18\"));\n    try std.testing.expectEqual(@as(?u32, null), lookup(\"does_not_exist\"));\n}\n\ntest \"fullUsage packs keyboard page\" {\n    try std.testing.expectEqual(@as(u64, 0x700000039), fullUsage(0x39));\n    try std.testing.expectEqual(@as(u64, 0x7000000E0), fullUsage(0xE0));\n}\n"
  },
  {
    "path": "src/Hidutil.zig",
    "content": "//! HID-level key remap management via the `hidutil` command-line tool.\n//!\n//! Used by the `.remap` feature: collect all `RemapDecl` entries from\n//! `Mappings`, group by device alias, and apply them as per-device\n//! `UserKeyMapping` properties through `hidutil property --matching ...\n//! --set ...`. On exit (signal handler or `deinit`) the same per-device\n//! property is cleared so the user's keyboard returns to default.\n//!\n//! Crash recovery: before each apply, write a state file at\n//! `~/.cache/skhd/hidutil_state.json` listing the touched\n//! (vendor, product) pairs and our pid. At startup, if the file exists\n//! with a dead pid, clear the listed devices first so a previous crashed\n//! instance doesn't leave the user with a broken caps_lock.\n//!\n//! V1 caveat: we don't preserve any pre-existing `UserKeyMapping`. If\n//! another tool (Hyperkey, manual hidutil invocations) has set one, we\n//! overwrite it on apply and clear to empty on restore. Document this\n//! limitation in user-facing docs.\n\nconst std = @import(\"std\");\nconst Mappings = @import(\"Mappings.zig\");\nconst HidKeyMap = @import(\"HidKeyMap.zig\");\nconst log = std.log.scoped(.hidutil);\n\nconst Hidutil = @This();\n\nallocator: std.mem.Allocator,\n/// Devices we currently have a UserKeyMapping applied on. Populated by\n/// applyRemaps; consulted by restoreAll. Owned strings.\napplied_devices: std.ArrayListUnmanaged(VendorProduct),\nstate_path: []const u8,\n\npub const VendorProduct = struct {\n    vendor: u32,\n    product: u32,\n};\n\npub fn init(allocator: std.mem.Allocator) !*Hidutil {\n    const state_path = try resolveStatePath(allocator);\n    errdefer allocator.free(state_path);\n\n    const self = try allocator.create(Hidutil);\n    self.* = .{\n        .allocator = allocator,\n        .applied_devices = .empty,\n        .state_path = state_path,\n    };\n    return self;\n}\n\npub fn deinit(self: *Hidutil) void {\n    self.applied_devices.deinit(self.allocator);\n    self.allocator.free(self.state_path);\n    self.allocator.destroy(self);\n}\n\n/// Apply every `.remap` declaration in the given Mappings. Groups by\n/// device alias, resolves alias → (vendor, product), and shell-outs to\n/// `hidutil property --matching '...' --set '...'` once per device.\n/// Records the set of touched devices to the state file before any\n/// invocation so a crash mid-apply still leaves recoverable state.\npub fn applyRemaps(self: *Hidutil, mappings: *const Mappings) !void {\n    if (mappings.remaps.items.len == 0) return;\n\n    // Group remaps by device alias. Most users have 1–2 devices, so a\n    // small ArrayList of (alias, ArrayList(RemapDecl)) is fine.\n    var groups = std.StringArrayHashMapUnmanaged(std.ArrayListUnmanaged(Mappings.RemapDecl)){};\n    defer {\n        var it = groups.iterator();\n        while (it.next()) |kv| kv.value_ptr.deinit(self.allocator);\n        groups.deinit(self.allocator);\n    }\n    for (mappings.remaps.items) |r| {\n        const gop = try groups.getOrPut(self.allocator, r.device_alias);\n        if (!gop.found_existing) gop.value_ptr.* = .empty;\n        try gop.value_ptr.append(self.allocator, r);\n    }\n\n    // Snapshot which (vendor, product) we'll touch — write state before\n    // executing so a crash mid-loop is recoverable.\n    self.applied_devices.clearRetainingCapacity();\n    var it = groups.iterator();\n    while (it.next()) |kv| {\n        const alias = mappings.device_aliases.get(kv.key_ptr.*) orelse {\n            log.err(\"Internal error: alias '{s}' missing from device_aliases at apply time\", .{kv.key_ptr.*});\n            return error.UnknownDeviceAlias;\n        };\n        try self.applied_devices.append(self.allocator, .{ .vendor = alias.vendor, .product = alias.product });\n    }\n    try self.writeState();\n\n    // Apply each device's mapping. Failure on one device doesn't abort\n    // the rest (we already recorded the device in state, so cleanup will\n    // clear them on exit).\n    var any_failure = false;\n    var grp_it = groups.iterator();\n    while (grp_it.next()) |kv| {\n        const alias = mappings.device_aliases.get(kv.key_ptr.*).?;\n        applyForDevice(self.allocator, alias.vendor, alias.product, kv.value_ptr.items) catch |err| {\n            log.err(\"Failed to apply remap for device '{s}' ({x:0>4}:{x:0>4}): {s}\", .{ kv.key_ptr.*, alias.vendor, alias.product, @errorName(err) });\n            any_failure = true;\n        };\n    }\n    if (any_failure) return error.PartialApply;\n}\n\n/// Restore all applied remaps by clearing UserKeyMapping on each touched\n/// device. Idempotent. Called on clean exit, signal handlers, and crash\n/// recovery (where `applied_devices` was populated from the state file).\npub fn restoreAll(self: *Hidutil) void {\n    for (self.applied_devices.items) |vp| {\n        clearForDevice(self.allocator, vp.vendor, vp.product) catch |err| {\n            log.err(\"Failed to clear UserKeyMapping on {x:0>4}:{x:0>4}: {s}\", .{ vp.vendor, vp.product, @errorName(err) });\n        };\n    }\n    self.applied_devices.clearRetainingCapacity();\n    self.deleteState() catch {};\n}\n\n/// Startup recovery. If the state file exists and the pid recorded in it\n/// is no longer running, populate `applied_devices` from the file and\n/// clear those devices via `restoreAll`. This unsticks a user whose\n/// previous skhd instance was killed via SIGKILL or panicked before it\n/// could restore.\npub fn recoverFromCrash(self: *Hidutil) !void {\n    const file = std.fs.cwd().openFile(self.state_path, .{}) catch |err| {\n        if (err == error.FileNotFound) return;\n        return err;\n    };\n    defer file.close();\n\n    const content = try file.readToEndAlloc(self.allocator, 64 * 1024);\n    defer self.allocator.free(content);\n\n    const parsed = std.json.parseFromSlice(StateFile, self.allocator, content, .{}) catch |err| {\n        log.warn(\"hidutil state file at '{s}' is malformed ({s}); ignoring.\", .{ self.state_path, @errorName(err) });\n        return;\n    };\n    defer parsed.deinit();\n\n    if (isProcessRunning(parsed.value.pid)) {\n        log.warn(\"hidutil state file's pid {d} is still running — assuming the other instance owns the remaps. Skipping crash recovery.\", .{parsed.value.pid});\n        return;\n    }\n\n    log.warn(\"Recovering from crashed pid {d}: clearing UserKeyMapping on {d} device(s)\", .{ parsed.value.pid, parsed.value.devices.len });\n    for (parsed.value.devices) |d| {\n        try self.applied_devices.append(self.allocator, .{ .vendor = d.vendor, .product = d.product });\n    }\n    self.restoreAll();\n}\n\nconst StateFile = struct {\n    pid: i32,\n    devices: []VendorProduct,\n};\n\nfn writeState(self: *Hidutil) !void {\n    // Ensure parent dir exists. Errors here are fatal — without the\n    // state file we can't recover from a future crash.\n    if (std.fs.path.dirname(self.state_path)) |dir| {\n        try std.fs.cwd().makePath(dir);\n    }\n\n    var file = try std.fs.cwd().createFile(self.state_path, .{ .truncate = true });\n    defer file.close();\n\n    const state = StateFile{\n        .pid = @intCast(std.c.getpid()),\n        .devices = self.applied_devices.items,\n    };\n    try std.json.stringify(state, .{}, file.writer());\n}\n\nfn deleteState(self: *Hidutil) !void {\n    std.fs.cwd().deleteFile(self.state_path) catch |err| {\n        if (err == error.FileNotFound) return;\n        return err;\n    };\n}\n\nfn resolveStatePath(allocator: std.mem.Allocator) ![]const u8 {\n    const home = std.posix.getenv(\"HOME\") orelse return error.HomeNotSet;\n    return try std.fmt.allocPrint(allocator, \"{s}/.cache/skhd/hidutil_state.json\", .{home});\n}\n\nfn isProcessRunning(pid: i32) bool {\n    // kill(pid, 0) returns 0 if process exists and we have permission.\n    // -1 with ESRCH means no such process.\n    return std.c.kill(pid, 0) == 0;\n}\n\nfn applyForDevice(allocator: std.mem.Allocator, vendor: u32, product: u32, remaps: []const Mappings.RemapDecl) !void {\n    var json_buf = std.ArrayList(u8).init(allocator);\n    defer json_buf.deinit();\n    const w = json_buf.writer();\n    try w.writeAll(\"{\\\"UserKeyMapping\\\":[\");\n    for (remaps, 0..) |r, i| {\n        if (i > 0) try w.writeAll(\",\");\n        try w.print(\"{{\\\"HIDKeyboardModifierMappingSrc\\\":{d},\\\"HIDKeyboardModifierMappingDst\\\":{d}}}\", .{ HidKeyMap.fullUsage(r.src_usage), HidKeyMap.fullUsage(r.dst_usage) });\n    }\n    try w.writeAll(\"]}\");\n\n    var matching_buf: [128]u8 = undefined;\n    const matching = try std.fmt.bufPrint(&matching_buf, \"{{\\\"VendorID\\\":{d},\\\"ProductID\\\":{d}}}\", .{ vendor, product });\n\n    try runHidutilSet(allocator, matching, json_buf.items);\n}\n\nfn clearForDevice(allocator: std.mem.Allocator, vendor: u32, product: u32) !void {\n    var matching_buf: [128]u8 = undefined;\n    const matching = try std.fmt.bufPrint(&matching_buf, \"{{\\\"VendorID\\\":{d},\\\"ProductID\\\":{d}}}\", .{ vendor, product });\n    try runHidutilSet(allocator, matching, \"{\\\"UserKeyMapping\\\":[]}\");\n}\n\nfn runHidutilSet(allocator: std.mem.Allocator, matching: []const u8, set_value: []const u8) !void {\n    var child = std.process.Child.init(&.{\n        \"/usr/bin/hidutil\",\n        \"property\",\n        \"--matching\",\n        matching,\n        \"--set\",\n        set_value,\n    }, allocator);\n    child.stdin_behavior = .Ignore;\n    child.stdout_behavior = .Ignore;\n    child.stderr_behavior = .Pipe;\n    try child.spawn();\n\n    var stderr_data = std.ArrayList(u8).init(allocator);\n    defer stderr_data.deinit();\n    if (child.stderr) |stderr| {\n        stderr.reader().readAllArrayList(&stderr_data, 4096) catch {};\n    }\n    const term = try child.wait();\n    if (term != .Exited or term.Exited != 0) {\n        log.err(\"hidutil failed (term={any}): {s}\", .{ term, std.mem.trim(u8, stderr_data.items, \" \\r\\n\\t\") });\n        return error.HidutilFailed;\n    }\n}\n\ntest \"VendorProduct round-trip via state file\" {\n    const alloc = std.testing.allocator;\n    var devices = [_]VendorProduct{\n        .{ .vendor = 0x05AC, .product = 0x0342 },\n        .{ .vendor = 0x04FE, .product = 0x0021 },\n    };\n    const state = StateFile{\n        .pid = 12345,\n        .devices = devices[0..],\n    };\n    var buf = std.ArrayList(u8).init(alloc);\n    defer buf.deinit();\n    try std.json.stringify(state, .{}, buf.writer());\n\n    const parsed = try std.json.parseFromSlice(StateFile, alloc, buf.items, .{});\n    defer parsed.deinit();\n    try std.testing.expectEqual(@as(i32, 12345), parsed.value.pid);\n    try std.testing.expectEqual(@as(usize, 2), parsed.value.devices.len);\n    try std.testing.expectEqual(@as(u32, 0x05AC), parsed.value.devices[0].vendor);\n}\n"
  },
  {
    "path": "src/Hotkey.zig",
    "content": "const std = @import(\"std\");\nconst testing = std.testing;\nconst Hotkey = @This();\nconst Mode = @import(\"Mode.zig\");\nconst utils = @import(\"utils.zig\");\nconst ModifierFlag = @import(\"Keycodes.zig\").ModifierFlag;\nconst log = std.log.scoped(.hotkey_array_hashmap);\n\n// Error sets for better type safety\npub const ProcessCommandError = error{\n    ProcessCommandAlreadyExists,\n    WildcardCommandAlreadyExists,\n    OutOfMemory,\n};\n\nallocator: std.mem.Allocator,\nflags: ModifierFlag = .{},\nkey: u32 = 0,\nwildcard_command: ?ProcessCommand = null,\n// Use ArrayHashMap for process name -> command mapping\nmappings: std.StringArrayHashMapUnmanaged(ProcessCommand) = .empty,\nmode_list: std.AutoArrayHashMapUnmanaged(*Mode, void) = .empty,\n\npub fn destroy(self: *Hotkey) void {\n    var it = self.mappings.iterator();\n    while (it.next()) |entry| {\n        self.allocator.free(entry.key_ptr.*);\n        entry.value_ptr.*.deinit(self.allocator);\n    }\n    self.mappings.deinit(self.allocator);\n\n    // Free wildcard command if any\n    if (self.wildcard_command) |cmd| {\n        cmd.deinit(self.allocator);\n    }\n\n    self.mode_list.deinit(self.allocator);\n    self.allocator.destroy(self);\n}\n\npub fn create(allocator: std.mem.Allocator) !*Hotkey {\n    const hotkey = try allocator.create(Hotkey);\n    hotkey.* = .{\n        .allocator = allocator,\n    };\n    return hotkey;\n}\n\npub const HotkeyMap = std.ArrayHashMapUnmanaged(*Hotkey, void, struct {\n    pub fn hash(self: @This(), key: *Hotkey) u32 {\n        _ = self;\n        // Like original skhd, only hash by key code to allow modifier matching during lookup\n        return key.key;\n    }\n    pub fn eql(self: @This(), a: *Hotkey, b: *Hotkey, _: anytype) bool {\n        _ = self;\n        return Hotkey.eql(a, b);\n    }\n}, false);\n\npub const KeyPress = struct {\n    flags: ModifierFlag,\n    key: u32,\n};\n\npub fn eql(a: *Hotkey, b: *Hotkey) bool {\n    // Implement left/right modifier comparison logic like original skhd\n    // Note: This is for HashMap equality check, both are from config\n    return compareLRMod(a.flags, b.flags, .alt) and\n        compareLRMod(a.flags, b.flags, .cmd) and\n        compareLRMod(a.flags, b.flags, .control) and\n        compareLRMod(a.flags, b.flags, .shift) and\n        a.flags.@\"fn\" == b.flags.@\"fn\" and\n        a.flags.nx == b.flags.nx and\n        a.key == b.key;\n}\n\npub fn triggersOverlap(a: *Hotkey, b: *Hotkey) bool {\n    if (a.key != b.key) return false;\n    return hotkeyFlagsMatch(a.flags, b.flags) or hotkeyFlagsMatch(b.flags, a.flags);\n}\n\nfn compareLRMod(a: ModifierFlag, b: ModifierFlag, comptime mod: enum { alt, cmd, control, shift }) bool {\n    const general_field = switch (mod) {\n        .alt => \"alt\",\n        .cmd => \"cmd\",\n        .control => \"control\",\n        .shift => \"shift\",\n    };\n    const left_field = switch (mod) {\n        .alt => \"lalt\",\n        .cmd => \"lcmd\",\n        .control => \"lcontrol\",\n        .shift => \"lshift\",\n    };\n    const right_field = switch (mod) {\n        .alt => \"ralt\",\n        .cmd => \"rcmd\",\n        .control => \"rcontrol\",\n        .shift => \"rshift\",\n    };\n\n    const a_general = @field(a, general_field);\n    const a_left = @field(a, left_field);\n    const a_right = @field(a, right_field);\n\n    const b_general = @field(b, general_field);\n    const b_left = @field(b, left_field);\n    const b_right = @field(b, right_field);\n\n    // For HashMap equality, we need exact match\n    // Both hotkeys are from config, so exact comparison is correct\n    return a_general == b_general and a_left == b_left and a_right == b_right;\n}\n\n// Context for looking up hotkeys from keyboard events\n// This uses our custom modifier matching logic\npub const KeyboardLookupContext = struct {\n    pub fn hash(_: @This(), key: Hotkey.KeyPress) u32 {\n        // Must match the hash function used by HotkeyMap for lookup to work\n        return key.key;\n    }\n\n    pub fn eql(_: @This(), keyboard: Hotkey.KeyPress, config: *Hotkey, _: usize) bool {\n        // Match keyboard event against config hotkey\n        return config.key == keyboard.key and hotkeyFlagsMatch(config.flags, keyboard.flags);\n    }\n};\n\n/// Wildcard-modifier lookup context for capture-mode layer rules.\n/// Matches a config hotkey by key code alone, ignoring the keyboard\n/// event's modifier flags — but ONLY if the config rule itself has\n/// no declared modifiers (so explicit-modifier rules still need an\n/// exact match elsewhere). The caller is expected to OR the user's\n/// modifiers into the forward target after a wildcard match.\npub const WildcardLookupContext = struct {\n    pub fn hash(_: @This(), key: Hotkey.KeyPress) u32 {\n        return key.key;\n    }\n\n    pub fn eql(_: @This(), keyboard: Hotkey.KeyPress, config: *Hotkey, _: usize) bool {\n        return config.key == keyboard.key and config.flags.isEmpty();\n    }\n};\n\n/// Compare hotkey flags, handling left/right modifier logic\n/// config = hotkey from config file, keyboard = event from keyboard\npub fn hotkeyFlagsMatch(config: ModifierFlag, keyboard: ModifierFlag) bool {\n    // Match logic from original skhd:\n    // If config has general modifier (alt), keyboard can have general, left, or right\n    // If config has specific modifier (lalt), keyboard must match exactly\n\n    const alt_match = if (config.alt)\n        (keyboard.alt or keyboard.lalt or keyboard.ralt)\n    else\n        (config.lalt == keyboard.lalt and config.ralt == keyboard.ralt and config.alt == keyboard.alt);\n\n    const cmd_match = if (config.cmd)\n        (keyboard.cmd or keyboard.lcmd or keyboard.rcmd)\n    else\n        (config.lcmd == keyboard.lcmd and config.rcmd == keyboard.rcmd and config.cmd == keyboard.cmd);\n\n    const ctrl_match = if (config.control)\n        (keyboard.control or keyboard.lcontrol or keyboard.rcontrol)\n    else\n        (config.lcontrol == keyboard.lcontrol and config.rcontrol == keyboard.rcontrol and config.control == keyboard.control);\n\n    const shift_match = if (config.shift)\n        (keyboard.shift or keyboard.lshift or keyboard.rshift)\n    else\n        (config.lshift == keyboard.lshift and config.rshift == keyboard.rshift and config.shift == keyboard.shift);\n\n    return alt_match and cmd_match and ctrl_match and shift_match and\n        config.@\"fn\" == keyboard.@\"fn\" and\n        config.nx == keyboard.nx;\n}\n\npub const ProcessCommand = union(enum) {\n    command: [:0]const u8,\n    forwarded: KeyPress,\n    unbound: void,\n    activation: Activation,\n\n    pub const Activation = struct {\n        mode_name: []const u8,\n        command: ?[:0]const u8 = null,\n\n        fn eql(self: Activation, other: Activation) bool {\n            if (!std.mem.eql(u8, self.mode_name, other.mode_name)) return false;\n            if (self.command == null and other.command == null) return true;\n            if (self.command != null and other.command != null) {\n                return std.mem.eql(u8, self.command.?, other.command.?);\n            }\n            return false;\n        }\n    };\n\n    /// Create a command variant with a duplicated null-terminated string\n    pub fn initCommand(allocator: std.mem.Allocator, cmd: []const u8) !ProcessCommand {\n        return ProcessCommand{ .command = try allocator.dupeZ(u8, cmd) };\n    }\n\n    /// Create a forwarded variant\n    pub fn initForwarded(key_press: KeyPress) ProcessCommand {\n        return ProcessCommand{ .forwarded = key_press };\n    }\n\n    /// Create an unbound variant\n    pub fn initUnbound() ProcessCommand {\n        return ProcessCommand{ .unbound = {} };\n    }\n\n    /// Create an activation variant with a duplicated string and optional command\n    pub fn initActivation(allocator: std.mem.Allocator, mode_name: []const u8, cmd: ?[]const u8) !ProcessCommand {\n        return ProcessCommand{ .activation = .{\n            .mode_name = try allocator.dupe(u8, mode_name),\n            .command = if (cmd) |c| try allocator.dupeZ(u8, c) else null,\n        } };\n    }\n\n    /// Free any owned memory\n    pub fn deinit(self: ProcessCommand, allocator: std.mem.Allocator) void {\n        switch (self) {\n            .command => |str| allocator.free(str),\n            .activation => |act| {\n                allocator.free(act.mode_name);\n                if (act.command) |cmd| allocator.free(cmd);\n            },\n            else => {},\n        }\n    }\n};\n\npub fn format(self: *const Hotkey, comptime fmt: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void {\n    _ = fmt;\n    try writer.print(\"Hotkey{{\", .{});\n    try writer.print(\"\\n  mode_list: {{\", .{});\n    {\n        var it = self.mode_list.iterator();\n        while (it.next()) |kv| {\n            try writer.print(\"{s},\", .{kv.key_ptr.*.name});\n        }\n    }\n    try writer.print(\"}}\", .{});\n    try writer.print(\"\\n  flags: {}\", .{self.flags});\n    try writer.print(\"\\n  key: {}\", .{self.key});\n    try writer.print(\"\\n  process_mappings: {} entries\", .{self.mappings.count()});\n    try writer.print(\"\\n}}\", .{});\n}\n\npub fn add_process_command(self: *Hotkey, process_name: []const u8, command: []const u8) ProcessCommandError!void {\n    const owned_cmd = try ProcessCommand.initCommand(self.allocator, command);\n    errdefer owned_cmd.deinit(self.allocator);\n\n    if (std.mem.eql(u8, process_name, \"*\")) {\n        if (self.wildcard_command) |_| {\n            return error.WildcardCommandAlreadyExists;\n        }\n\n        self.wildcard_command = owned_cmd;\n        return;\n    }\n\n    const owned_name = try self.toLowercaseOwned(process_name);\n    errdefer self.allocator.free(owned_name);\n\n    // Check if we're replacing an existing mapping\n    if (self.mappings.get(owned_name)) |existing_cmd| {\n        if (std.meta.activeTag(existing_cmd) == ProcessCommand.command and std.mem.eql(u8, existing_cmd.command, owned_cmd.command)) {\n            self.allocator.free(owned_name);\n            owned_cmd.deinit(self.allocator);\n            return;\n        }\n        return error.ProcessCommandAlreadyExists;\n    }\n\n    // Put into hashmap\n    try self.mappings.put(self.allocator, owned_name, owned_cmd);\n}\n\nfn toLowercaseOwned(self: *Hotkey, process_name: []const u8) ![]const u8 {\n    const owned_name = try self.allocator.dupe(u8, process_name);\n    for (owned_name, 0..) |c, i| {\n        owned_name[i] = std.ascii.toLower(c);\n    }\n    return owned_name;\n}\n\npub fn add_process_forward(self: *Hotkey, process_name: []const u8, key_press: KeyPress) ProcessCommandError!void {\n    const owned_cmd = ProcessCommand.initForwarded(key_press);\n\n    if (std.mem.eql(u8, process_name, \"*\")) {\n        if (self.wildcard_command) |_| {\n            return error.WildcardCommandAlreadyExists;\n        }\n\n        self.wildcard_command = owned_cmd;\n        return;\n    }\n\n    const owned_name = try self.toLowercaseOwned(process_name);\n    errdefer self.allocator.free(owned_name);\n\n    // Check if we're replacing an existing mapping\n    if (self.mappings.get(owned_name)) |existing_cmd| {\n        if (std.meta.activeTag(existing_cmd) == ProcessCommand.forwarded and\n            std.meta.eql(existing_cmd.forwarded, owned_cmd.forwarded))\n        {\n            self.allocator.free(owned_name);\n            return; // No need to replace if it's the same\n        }\n        return error.ProcessCommandAlreadyExists;\n    }\n\n    // Put into hashmap\n    try self.mappings.put(self.allocator, owned_name, owned_cmd);\n}\n\npub fn add_process_unbound(self: *Hotkey, process_name: []const u8) ProcessCommandError!void {\n    const owned_cmd = ProcessCommand.initUnbound();\n\n    if (std.mem.eql(u8, process_name, \"*\")) {\n        if (self.wildcard_command) |_| {\n            return error.WildcardCommandAlreadyExists;\n        }\n\n        self.wildcard_command = owned_cmd;\n        return;\n    }\n\n    const owned_name = try self.toLowercaseOwned(process_name);\n    errdefer self.allocator.free(owned_name);\n\n    // Check if we're replacing an existing mapping\n    if (self.mappings.get(owned_name)) |existing_cmd| {\n        if (std.meta.activeTag(existing_cmd) == ProcessCommand.unbound) {\n            self.allocator.free(owned_name);\n            return; // No need to replace if it's already unbound\n        }\n        return error.ProcessCommandAlreadyExists;\n    }\n\n    // Put into hashmap\n    try self.mappings.put(self.allocator, owned_name, owned_cmd);\n}\n\npub fn add_process_activation(self: *Hotkey, process_name: []const u8, mode_name: []const u8, cmd: ?[]const u8) ProcessCommandError!void {\n    const owned_cmd = try ProcessCommand.initActivation(self.allocator, mode_name, cmd);\n    errdefer owned_cmd.deinit(self.allocator);\n\n    if (std.mem.eql(u8, process_name, \"*\")) {\n        if (self.wildcard_command) |_| {\n            return error.WildcardCommandAlreadyExists;\n        }\n\n        self.wildcard_command = owned_cmd;\n        return;\n    }\n\n    const owned_name = try self.toLowercaseOwned(process_name);\n    errdefer self.allocator.free(owned_name);\n\n    // Check if we're replacing an existing mapping\n    if (self.mappings.get(owned_name)) |existing_cmd| {\n        if (std.meta.activeTag(existing_cmd) == ProcessCommand.activation and existing_cmd.activation.eql(owned_cmd.activation)) {\n            self.allocator.free(owned_name);\n            owned_cmd.deinit(self.allocator);\n            return; // No need to replace if it's the same\n        }\n        return error.ProcessCommandAlreadyExists;\n    }\n\n    // Put into hashmap\n    try self.mappings.put(self.allocator, owned_name, owned_cmd);\n}\n\npub fn find_command_for_process(self: *const Hotkey, process_name: []const u8) ?ProcessCommand {\n    if (process_name.len == 0 or std.mem.eql(u8, process_name, \"*\")) {\n        return self.wildcard_command;\n    }\n\n    // Create lowercase version for lookup\n    var name_buf: [256]u8 = undefined;\n    if (process_name.len > name_buf.len) return self.wildcard_command;\n\n    for (process_name, 0..) |c, i| {\n        name_buf[i] = std.ascii.toLower(c);\n    }\n    const lower_name = name_buf[0..process_name.len];\n\n    // First try to find exact match\n    if (self.mappings.get(lower_name)) |cmd| {\n        return cmd;\n    }\n\n    // If no exact match, return wildcard\n    return self.wildcard_command;\n}\n\npub fn add_mode(self: *Hotkey, mode: *Mode) !void {\n    if (self.mode_list.contains(mode)) {\n        return error.ModeAlreadyExistsInHotkey;\n    }\n    try self.mode_list.put(self.allocator, mode, {});\n}\n\n// Additional utility methods\npub fn getProcessCount(self: *const Hotkey) usize {\n    return self.mappings.count();\n}\n\ntest \"ArrayHashMap hotkey implementation\" {\n    const alloc = std.testing.allocator;\n    var hotkey = try Hotkey.create(alloc);\n    defer hotkey.destroy();\n\n    hotkey.flags = ModifierFlag{ .alt = true };\n    hotkey.key = 0x2;\n\n    // Test the API\n    try hotkey.add_process_command(\"firefox\", \"echo firefox\");\n    try hotkey.add_process_command(\"chrome\", \"echo chrome\");\n    try hotkey.add_process_forward(\"terminal\", KeyPress{ .flags = .{}, .key = 0x24 });\n\n    // Test lookup\n    const firefox_cmd = hotkey.find_command_for_process(\"Firefox\");\n    try std.testing.expect(firefox_cmd != null);\n    try std.testing.expectEqualStrings(\"echo firefox\", firefox_cmd.?.command);\n\n    // Test case insensitive\n    const chrome_cmd = hotkey.find_command_for_process(\"CHROME\");\n    try std.testing.expect(chrome_cmd != null);\n    try std.testing.expectEqualStrings(\"echo chrome\", chrome_cmd.?.command);\n\n    // Test wildcard\n    try hotkey.add_process_command(\"*\", \"echo default\");\n    const unknown_cmd = hotkey.find_command_for_process(\"unknown\");\n    try std.testing.expect(unknown_cmd != null);\n    try std.testing.expectEqualStrings(\"echo default\", unknown_cmd.?.command);\n\n    // Test count\n    try std.testing.expectEqual(@as(usize, 3), hotkey.getProcessCount()); // firefox, chrome, terminal (wildcard is separate)\n}\n\ntest \"hotkey initialization\" {\n    const alloc = std.testing.allocator;\n    var hotkey = try Hotkey.create(alloc);\n    defer hotkey.destroy();\n\n    // Test that flags are properly initialized to empty\n    try std.testing.expectEqual(@as(u32, 0), @as(u32, @bitCast(hotkey.flags)));\n\n    // Test that key is properly initialized to 0\n    try std.testing.expectEqual(@as(u32, 0), hotkey.key);\n\n    // Test that other fields are properly initialized\n    try std.testing.expectEqual(@as(?ProcessCommand, null), hotkey.wildcard_command);\n    try std.testing.expectEqual(@as(usize, 0), hotkey.mappings.count());\n    try std.testing.expectEqual(@as(usize, 0), hotkey.mode_list.count());\n}\n\ntest \"add_process returns error on duplicate\" {\n    const alloc = std.testing.allocator;\n    var hotkey = try Hotkey.create(alloc);\n    defer hotkey.destroy();\n\n    // First mapping should succeed\n    try hotkey.add_process_command(\"firefox\", \"echo firefox\");\n\n    // Duplicate mapping should fail\n    const result = hotkey.add_process_command(\"firefox\", \"echo firefox2\");\n    try std.testing.expectError(error.ProcessCommandAlreadyExists, result);\n\n    // Case insensitive duplicate should also fail\n    const result2 = hotkey.add_process_command(\"FIREFOX\", \"echo firefox3\");\n    try std.testing.expectError(error.ProcessCommandAlreadyExists, result2);\n\n    // Original command should still be there\n    const cmd = hotkey.find_command_for_process(\"firefox\");\n    try std.testing.expect(cmd != null);\n    try std.testing.expectEqualStrings(\"echo firefox\", cmd.?.command);\n\n    // Test wildcard duplicate\n    try hotkey.add_process_command(\"*\", \"echo wildcard\");\n    const wildcard_result = hotkey.add_process_command(\"*\", \"echo wildcard2\");\n    try std.testing.expectError(error.WildcardCommandAlreadyExists, wildcard_result);\n\n    // Original wildcard should still be there\n    const wildcard_cmd = hotkey.find_command_for_process(\"unknown_process\");\n    try std.testing.expect(wildcard_cmd != null);\n    try std.testing.expectEqualStrings(\"echo wildcard\", wildcard_cmd.?.command);\n}\n\ntest \"ArrayHashMap performance characteristics\" {\n    const alloc = std.testing.allocator;\n    var hotkey = try Hotkey.create(alloc);\n    defer hotkey.destroy();\n\n    // Add many mappings\n    for (0..100) |i| {\n        const name = try std.fmt.allocPrint(alloc, \"process_{}\", .{i});\n        defer alloc.free(name);\n        const cmd = try std.fmt.allocPrint(alloc, \"echo process_{}\", .{i});\n        defer alloc.free(cmd);\n\n        try hotkey.add_process_command(name, cmd);\n    }\n\n    // Test some lookups\n    const cmd_50 = hotkey.find_command_for_process(\"Process_50\");\n    try std.testing.expect(cmd_50 != null);\n    try std.testing.expectEqualStrings(\"echo process_50\", cmd_50.?.command);\n\n    const cmd_99 = hotkey.find_command_for_process(\"PROCESS_99\");\n    try std.testing.expect(cmd_99 != null);\n    try std.testing.expectEqualStrings(\"echo process_99\", cmd_99.?.command);\n\n    try std.testing.expectEqual(@as(usize, 100), hotkey.getProcessCount());\n}\n\ntest \"hotkeyFlagsMatch behavior\" {\n    // Test general modifier matching: config has general (alt), keyboard can have general, left, or right\n    {\n        const config = ModifierFlag{ .alt = true };\n        const kb_general = ModifierFlag{ .alt = true };\n        const kb_left = ModifierFlag{ .lalt = true };\n        const kb_right = ModifierFlag{ .ralt = true };\n\n        try testing.expect(hotkeyFlagsMatch(config, kb_general));\n        try testing.expect(hotkeyFlagsMatch(config, kb_left));\n        try testing.expect(hotkeyFlagsMatch(config, kb_right));\n    }\n\n    // Test specific modifier matching: config has specific (lalt), keyboard must match exactly\n    {\n        const config = ModifierFlag{ .lalt = true };\n        const kb_general = ModifierFlag{ .alt = true };\n        const kb_left = ModifierFlag{ .lalt = true };\n        const kb_right = ModifierFlag{ .ralt = true };\n\n        try testing.expect(!hotkeyFlagsMatch(config, kb_general));\n        try testing.expect(hotkeyFlagsMatch(config, kb_left));\n        try testing.expect(!hotkeyFlagsMatch(config, kb_right));\n    }\n\n    // Test multiple modifiers\n    {\n        const config = ModifierFlag{ .cmd = true, .shift = true };\n        const kb_match = ModifierFlag{ .lcmd = true, .shift = true };\n        const kb_no_match = ModifierFlag{ .lcmd = true }; // Missing shift\n\n        try testing.expect(hotkeyFlagsMatch(config, kb_match));\n        try testing.expect(!hotkeyFlagsMatch(config, kb_no_match));\n    }\n}\n\ntest \"duplicate commands allowed if identical\" {\n    const alloc = std.testing.allocator;\n    var hotkey = try Hotkey.create(alloc);\n    defer hotkey.destroy();\n\n    // Test duplicate command with same content is allowed\n    try hotkey.add_process_command(\"firefox\", \"echo firefox\");\n    // Adding the exact same command should succeed silently\n    try hotkey.add_process_command(\"firefox\", \"echo firefox\");\n\n    // Verify only one entry exists\n    try std.testing.expectEqual(@as(usize, 1), hotkey.getProcessCount());\n    const cmd = hotkey.find_command_for_process(\"firefox\");\n    try std.testing.expect(cmd != null);\n    try std.testing.expectEqualStrings(\"echo firefox\", cmd.?.command);\n\n    // Test with case-insensitive duplicate\n    try hotkey.add_process_command(\"FIREFOX\", \"echo firefox\");\n    try std.testing.expectEqual(@as(usize, 1), hotkey.getProcessCount());\n\n    // But different command should fail\n    const result = hotkey.add_process_command(\"firefox\", \"echo different\");\n    try std.testing.expectError(error.ProcessCommandAlreadyExists, result);\n}\n\ntest \"duplicate forwards allowed if identical\" {\n    const alloc = std.testing.allocator;\n    var hotkey = try Hotkey.create(alloc);\n    defer hotkey.destroy();\n\n    const key_press = KeyPress{ .flags = .{ .cmd = true }, .key = 0x24 };\n\n    // First forward should succeed\n    try hotkey.add_process_forward(\"terminal\", key_press);\n    // Adding the exact same forward should succeed silently\n    try hotkey.add_process_forward(\"terminal\", key_press);\n\n    // Verify only one entry exists\n    try std.testing.expectEqual(@as(usize, 1), hotkey.getProcessCount());\n    const cmd = hotkey.find_command_for_process(\"terminal\");\n    try std.testing.expect(cmd != null);\n    try std.testing.expect(cmd.? == .forwarded);\n    try std.testing.expect(std.meta.eql(cmd.?.forwarded, key_press));\n\n    // Different forward should fail\n    const different_key = KeyPress{ .flags = .{ .alt = true }, .key = 0x25 };\n    const result = hotkey.add_process_forward(\"terminal\", different_key);\n    try std.testing.expectError(error.ProcessCommandAlreadyExists, result);\n}\n\ntest \"duplicate unbound allowed if identical\" {\n    const alloc = std.testing.allocator;\n    var hotkey = try Hotkey.create(alloc);\n    defer hotkey.destroy();\n\n    // First unbound should succeed\n    try hotkey.add_process_unbound(\"notepad\");\n    // Adding the same unbound should succeed silently\n    try hotkey.add_process_unbound(\"notepad\");\n\n    // Verify only one entry exists\n    try std.testing.expectEqual(@as(usize, 1), hotkey.getProcessCount());\n    const cmd = hotkey.find_command_for_process(\"notepad\");\n    try std.testing.expect(cmd != null);\n    try std.testing.expect(cmd.? == .unbound);\n\n    // Case insensitive duplicate should also work\n    try hotkey.add_process_unbound(\"NOTEPAD\");\n    try std.testing.expectEqual(@as(usize, 1), hotkey.getProcessCount());\n\n    // But changing from unbound to command should fail\n    const result = hotkey.add_process_command(\"notepad\", \"echo notepad\");\n    try std.testing.expectError(error.ProcessCommandAlreadyExists, result);\n}\n\ntest \"duplicate activation allowed if identical\" {\n    const alloc = std.testing.allocator;\n    var hotkey = try Hotkey.create(alloc);\n    defer hotkey.destroy();\n\n    // Test activation without command\n    try hotkey.add_process_activation(\"vscode\", \"insert\", null);\n    try hotkey.add_process_activation(\"vscode\", \"insert\", null);\n\n    try std.testing.expectEqual(@as(usize, 1), hotkey.getProcessCount());\n    const cmd = hotkey.find_command_for_process(\"vscode\");\n    try std.testing.expect(cmd != null);\n    try std.testing.expect(cmd.? == .activation);\n    try std.testing.expectEqualStrings(\"insert\", cmd.?.activation.mode_name);\n    try std.testing.expect(cmd.?.activation.command == null);\n\n    // Test activation with command\n    try hotkey.add_process_activation(\"sublime\", \"visual\", \"echo visual mode\");\n    try hotkey.add_process_activation(\"sublime\", \"visual\", \"echo visual mode\");\n\n    try std.testing.expectEqual(@as(usize, 2), hotkey.getProcessCount());\n    const cmd2 = hotkey.find_command_for_process(\"sublime\");\n    try std.testing.expect(cmd2 != null);\n    try std.testing.expect(cmd2.? == .activation);\n    try std.testing.expectEqualStrings(\"visual\", cmd2.?.activation.mode_name);\n    try std.testing.expectEqualStrings(\"echo visual mode\", cmd2.?.activation.command.?);\n\n    // Different mode name should fail\n    const result = hotkey.add_process_activation(\"vscode\", \"normal\", null);\n    try std.testing.expectError(error.ProcessCommandAlreadyExists, result);\n\n    // Different command should fail\n    const result2 = hotkey.add_process_activation(\"sublime\", \"visual\", \"echo different\");\n    try std.testing.expectError(error.ProcessCommandAlreadyExists, result2);\n}\n\ntest \"wildcard duplicate handling\" {\n    const alloc = std.testing.allocator;\n    var hotkey = try Hotkey.create(alloc);\n    defer hotkey.destroy();\n\n    // Test wildcard command duplicate\n    try hotkey.add_process_command(\"*\", \"echo wildcard\");\n    const result = hotkey.add_process_command(\"*\", \"echo wildcard\");\n    // Wildcard doesn't allow duplicates even if identical\n    try std.testing.expectError(error.WildcardCommandAlreadyExists, result);\n\n    // Test wildcard forward\n    var hotkey2 = try Hotkey.create(alloc);\n    defer hotkey2.destroy();\n\n    const key_press = KeyPress{ .flags = .{}, .key = 0x24 };\n    try hotkey2.add_process_forward(\"*\", key_press);\n    const result2 = hotkey2.add_process_forward(\"*\", key_press);\n    try std.testing.expectError(error.WildcardCommandAlreadyExists, result2);\n\n    // Test wildcard unbound\n    var hotkey3 = try Hotkey.create(alloc);\n    defer hotkey3.destroy();\n\n    try hotkey3.add_process_unbound(\"*\");\n    const result3 = hotkey3.add_process_unbound(\"*\");\n    try std.testing.expectError(error.WildcardCommandAlreadyExists, result3);\n\n    // Test wildcard activation\n    var hotkey4 = try Hotkey.create(alloc);\n    defer hotkey4.destroy();\n\n    try hotkey4.add_process_activation(\"*\", \"mode\", \"cmd\");\n    const result4 = hotkey4.add_process_activation(\"*\", \"mode\", \"cmd\");\n    try std.testing.expectError(error.WildcardCommandAlreadyExists, result4);\n}\n\ntest \"mixed duplicate types should fail\" {\n    const alloc = std.testing.allocator;\n    var hotkey = try Hotkey.create(alloc);\n    defer hotkey.destroy();\n\n    // Add a command first\n    try hotkey.add_process_command(\"app\", \"echo app\");\n\n    // Try to add forward for same app - should fail\n    const key_press = KeyPress{ .flags = .{}, .key = 0x24 };\n    const result1 = hotkey.add_process_forward(\"app\", key_press);\n    try std.testing.expectError(error.ProcessCommandAlreadyExists, result1);\n\n    // Try to add unbound for same app - should fail\n    const result2 = hotkey.add_process_unbound(\"app\");\n    try std.testing.expectError(error.ProcessCommandAlreadyExists, result2);\n\n    // Try to add activation for same app - should fail\n    const result3 = hotkey.add_process_activation(\"app\", \"mode\", null);\n    try std.testing.expectError(error.ProcessCommandAlreadyExists, result3);\n\n    // Verify original command is still there\n    try std.testing.expectEqual(@as(usize, 1), hotkey.getProcessCount());\n    const cmd = hotkey.find_command_for_process(\"app\");\n    try std.testing.expect(cmd != null);\n    try std.testing.expect(cmd.? == .command);\n    try std.testing.expectEqualStrings(\"echo app\", cmd.?.command);\n}\n"
  },
  {
    "path": "src/Hotload.zig",
    "content": "const std = @import(\"std\");\nconst c = @import(\"c.zig\");\n\n/// File system event monitoring using macOS FSEvents API.\n///\n/// This struct is designed to be heap-allocated because FSEvents\n/// requires a stable pointer for its callbacks. Use create() to\n/// allocate and destroy() to clean up.\nconst Hotload = @This();\nconst log = std.log.scoped(.hotload);\n\n// Public callback type - simplified to just take the file path\npub const Callback = *const fn (path: []const u8) void;\n\n// Watched file entry - simplified\nconst WatchedFile = struct {\n    absolutepath: []u8,\n};\n\n// Core fields\nallocator: std.mem.Allocator,\ncallback: Callback,\nwatch_list: std.ArrayList(WatchedFile),\nenabled: bool = false,\n\n// FSEvents fields\nstream: ?c.FSEventStreamRef = null,\npaths: ?c.CFMutableArrayRef = null,\n\n/// Create a new Hotload instance on the heap\n/// Caller must call destroy() when done\npub fn create(allocator: std.mem.Allocator, callback: Callback) !*Hotload {\n    const self = try allocator.create(Hotload);\n    errdefer allocator.destroy(self);\n\n    self.* = .{\n        .allocator = allocator,\n        .callback = callback,\n        .watch_list = std.ArrayList(WatchedFile).init(allocator),\n        .enabled = false,\n        .stream = null,\n        .paths = null,\n    };\n\n    return self;\n}\n\n/// Destroy a Hotload instance, cleaning up all resources\npub fn destroy(self: *Hotload) void {\n    self.stop();\n\n    // Free all watched files\n    for (self.watch_list.items) |*entry| {\n        self.allocator.free(entry.absolutepath);\n    }\n    self.watch_list.deinit();\n\n    // Free self\n    const allocator = self.allocator;\n    allocator.destroy(self);\n}\n\npub fn addFile(self: *Hotload, file_path: []const u8) !void {\n    if (self.enabled) return error.AlreadyEnabled;\n\n    // Resolve symlinks and get real path\n    const real_path = try resolveSymlink(self.allocator, file_path);\n    errdefer self.allocator.free(real_path);\n\n    // Verify it's a file\n    const stat = try std.fs.cwd().statFile(real_path);\n    if (stat.kind != .file) {\n        return error.NotAFile;\n    }\n\n    // Add to watch list\n    try self.watch_list.append(.{\n        .absolutepath = real_path,\n    });\n}\n\npub fn start(self: *Hotload) !void {\n    if (self.enabled) return error.AlreadyEnabled;\n    if (self.watch_list.items.len == 0) return error.NoFilesToWatch;\n\n    // Create array of paths to watch\n    self.paths = c.CFArrayCreateMutable(c.kCFAllocatorDefault, 0, &c.kCFTypeArrayCallBacks);\n    if (self.paths == null) return error.CFArrayCreationFailed;\n    errdefer {\n        if (self.paths) |p| c.CFRelease(@ptrCast(p));\n        self.paths = null;\n    }\n\n    // Collect unique directories to watch\n    var seen_dirs = std.StringHashMap(void).init(self.allocator);\n    defer seen_dirs.deinit();\n\n    // Extract directories from file paths and add to FSEvents\n    for (self.watch_list.items) |entry| {\n        // Get directory from file path\n        const last_slash = std.mem.lastIndexOf(u8, entry.absolutepath, \"/\") orelse continue;\n        const directory = entry.absolutepath[0..last_slash];\n\n        // Only add each directory once\n        if (seen_dirs.contains(directory)) continue;\n        try seen_dirs.put(directory, {});\n\n        const cf_path = createCFString(directory) orelse return error.CFStringCreationFailed;\n        c.CFArrayAppendValue(self.paths.?, @ptrCast(cf_path));\n        c.CFRelease(@ptrCast(cf_path)); // Array retains it\n    }\n\n    // Create FSEventStream context\n    var context = c.FSEventStreamContext{\n        .version = 0,\n        .info = @ptrCast(self),\n        .retain = null,\n        .release = null,\n        .copyDescription = null,\n    };\n\n    // Create the event stream\n    const flags = c.kFSEventStreamCreateFlagNoDefer | c.kFSEventStreamCreateFlagFileEvents;\n    self.stream = c.FSEventStreamCreate(\n        c.kCFAllocatorDefault,\n        fseventsCallback,\n        &context,\n        self.paths.?,\n        c.kFSEventStreamEventIdSinceNow,\n        0.5, // latency in seconds\n        flags,\n    );\n\n    if (self.stream == null) return error.StreamCreationFailed;\n    errdefer {\n        if (self.stream) |s| c.FSEventStreamRelease(s);\n        self.stream = null;\n    }\n\n    // Schedule with run loop\n    c.FSEventStreamScheduleWithRunLoop(\n        self.stream.?,\n        c.CFRunLoopGetMain(),\n        c.kCFRunLoopDefaultMode,\n    );\n\n    // Start the stream\n    const started = c.FSEventStreamStart(self.stream.?);\n    if (started == 0) return error.StreamStartFailed;\n\n    self.enabled = true;\n}\n\npub fn stop(self: *Hotload) void {\n    if (!self.enabled) return;\n\n    if (self.stream) |stream| {\n        c.FSEventStreamStop(stream);\n        c.FSEventStreamInvalidate(stream);\n        c.FSEventStreamRelease(stream);\n        self.stream = null;\n    }\n\n    if (self.paths) |paths| {\n        c.CFRelease(@ptrCast(paths));\n        self.paths = null;\n    }\n\n    self.enabled = false;\n}\n\n// Helper functions\n\nfn resolveSymlink(allocator: std.mem.Allocator, path: []const u8) ![]u8 {\n    // Try to stat the file\n    const stat = std.fs.cwd().statFile(path) catch {\n        // If stat fails, just return a copy of the path\n        return allocator.dupe(u8, path);\n    };\n\n    // If it's not a symlink, return a copy\n    if (stat.kind != .sym_link) {\n        return allocator.dupe(u8, path);\n    }\n\n    // Resolve the symlink\n    var buffer: [std.fs.max_path_bytes]u8 = undefined;\n    const real_path = try std.fs.cwd().realpath(path, &buffer);\n    return allocator.dupe(u8, real_path);\n}\n\nfn createCFString(str: []const u8) ?c.CFStringRef {\n    return c.CFStringCreateWithBytes(\n        c.kCFAllocatorDefault,\n        str.ptr,\n        @intCast(str.len),\n        c.kCFStringEncodingUTF8,\n        0, // false\n    );\n}\n\n// FSEvents callback\nfn fseventsCallback(\n    stream: c.ConstFSEventStreamRef,\n    client_info: ?*anyopaque,\n    num_events: usize,\n    event_paths: ?*anyopaque,\n    event_flags: [*c]const c.FSEventStreamEventFlags,\n    event_ids: [*c]const c.FSEventStreamEventId,\n) callconv(.C) void {\n    _ = stream;\n    _ = event_flags;\n    _ = event_ids;\n\n    const self = @as(*Hotload, @ptrCast(@alignCast(client_info.?)));\n    // FSEvents passes paths as char**, not CFStringRef*\n    const paths = @as([*][*:0]const u8, @ptrCast(@alignCast(event_paths.?)));\n\n    for (0..num_events) |i| {\n        // Get the path that changed - it's already a C string!\n        const changed_path = std.mem.span(paths[i]);\n\n        // Check which watched file matches\n        for (self.watch_list.items) |entry| {\n            if (std.mem.eql(u8, changed_path, entry.absolutepath)) {\n                self.callback(entry.absolutepath);\n                break;\n            }\n        }\n    }\n}\n\n// Test support\nvar test_reload_count: u32 = 0;\n\nfn testCallback(path: []const u8) void {\n    _ = path;\n    test_reload_count += 1;\n}\n\ntest \"hotload file watching\" {\n    const testing = std.testing;\n    const allocator = testing.allocator;\n\n    // Reset test state\n    test_reload_count = 0;\n\n    // Create a test file with absolute path\n    var path_buf: [std.fs.max_path_bytes]u8 = undefined;\n    const cwd_path = try std.fs.cwd().realpath(\".\", &path_buf);\n    const test_file = try std.fmt.allocPrint(allocator, \"{s}/test_hotload_file.txt\", .{cwd_path});\n    defer allocator.free(test_file);\n\n    try std.fs.cwd().writeFile(.{ .sub_path = \"test_hotload_file.txt\", .data = \"Initial content\\n\" });\n\n    // Create hotloader\n    const hotloader = try create(allocator, testCallback);\n    defer hotloader.destroy();\n\n    // Add the test file\n    try hotloader.addFile(test_file);\n\n    // Start watching\n    try hotloader.start();\n\n    // Create a timer to modify the file and stop the run loop\n    const TimerContext = struct {\n        count: u32 = 0,\n\n        fn timerCallback(timer: c.CFRunLoopTimerRef, info: ?*anyopaque) callconv(.C) void {\n            _ = timer;\n            const self = @as(*@This(), @ptrCast(@alignCast(info.?)));\n            self.count += 1;\n\n            // Modify the file\n            const new_content = std.fmt.allocPrint(\n                std.heap.c_allocator,\n                \"Modified content {}\\n\",\n                .{self.count},\n            ) catch return;\n            defer std.heap.c_allocator.free(new_content);\n\n            std.fs.cwd().writeFile(.{ .sub_path = \"test_hotload_file.txt\", .data = new_content }) catch return;\n\n            if (self.count >= 3) {\n                // Stop after 3 modifications\n                c.CFRunLoopStop(c.CFRunLoopGetCurrent());\n            }\n        }\n    };\n\n    var timer_ctx = TimerContext{};\n    var timer_context = c.CFRunLoopTimerContext{\n        .version = 0,\n        .info = @ptrCast(&timer_ctx),\n        .retain = null,\n        .release = null,\n        .copyDescription = null,\n    };\n\n    const timer = c.CFRunLoopTimerCreate(\n        null,\n        c.CFAbsoluteTimeGetCurrent() + 0.5, // Start in 0.5 seconds\n        0.5, // Repeat every 0.5 seconds\n        0,\n        0,\n        TimerContext.timerCallback,\n        &timer_context,\n    );\n    defer c.CFRelease(timer);\n\n    c.CFRunLoopAddTimer(c.CFRunLoopGetCurrent(), timer, c.kCFRunLoopDefaultMode);\n\n    // Run the event loop for a limited time\n    _ = c.CFRunLoopRunInMode(c.kCFRunLoopDefaultMode, 3.0, 0); // Run for max 3 seconds\n\n    // Clean up test file before checking\n    std.fs.cwd().deleteFile(\"test_hotload_file.txt\") catch {};\n\n    // Verify we got at least 2 file change events (sometimes FSEvents coalesces)\n    try testing.expect(test_reload_count >= 2);\n}\n"
  },
  {
    "path": "src/Keycodes.zig",
    "content": "const std = @import(\"std\");\nconst c = @import(\"c.zig\");\nconst log = std.log.scoped(.keycodes);\n\nconst layout_dependent_keycodes = [_]u32{\n    c.kVK_ANSI_A,            c.kVK_ANSI_B,           c.kVK_ANSI_C,\n    c.kVK_ANSI_D,            c.kVK_ANSI_E,           c.kVK_ANSI_F,\n    c.kVK_ANSI_G,            c.kVK_ANSI_H,           c.kVK_ANSI_I,\n    c.kVK_ANSI_J,            c.kVK_ANSI_K,           c.kVK_ANSI_L,\n    c.kVK_ANSI_M,            c.kVK_ANSI_N,           c.kVK_ANSI_O,\n    c.kVK_ANSI_P,            c.kVK_ANSI_Q,           c.kVK_ANSI_R,\n    c.kVK_ANSI_S,            c.kVK_ANSI_T,           c.kVK_ANSI_U,\n    c.kVK_ANSI_V,            c.kVK_ANSI_W,           c.kVK_ANSI_X,\n    c.kVK_ANSI_Y,            c.kVK_ANSI_Z,           c.kVK_ANSI_0,\n    c.kVK_ANSI_1,            c.kVK_ANSI_2,           c.kVK_ANSI_3,\n    c.kVK_ANSI_4,            c.kVK_ANSI_5,           c.kVK_ANSI_6,\n    c.kVK_ANSI_7,            c.kVK_ANSI_8,           c.kVK_ANSI_9,\n    c.kVK_ANSI_Grave,        c.kVK_ANSI_Equal,       c.kVK_ANSI_Minus,\n    c.kVK_ANSI_RightBracket, c.kVK_ANSI_LeftBracket, c.kVK_ANSI_Quote,\n    c.kVK_ANSI_Semicolon,    c.kVK_ANSI_Backslash,   c.kVK_ANSI_Comma,\n    c.kVK_ANSI_Slash,        c.kVK_ANSI_Period,      c.kVK_ISO_Section,\n};\n\npub const ModifierFlag = packed struct(u32) {\n    alt: bool = false,\n    lalt: bool = false,\n    ralt: bool = false,\n    shift: bool = false,\n    lshift: bool = false,\n    rshift: bool = false,\n    cmd: bool = false,\n    lcmd: bool = false,\n    rcmd: bool = false,\n    control: bool = false,\n    lcontrol: bool = false,\n    rcontrol: bool = false,\n    @\"fn\": bool = false,\n    passthrough: bool = false,\n    nx: bool = false,\n    _: u17 = 0,\n    pub const hyper: ModifierFlag = .{\n        .cmd = true,\n        .alt = true,\n        .shift = true,\n        .control = true,\n    };\n    pub const meh: ModifierFlag = .{\n        .control = true,\n        .shift = true,\n        .alt = true,\n    };\n\n    pub fn get(text: []const u8) ?ModifierFlag {\n        return modifier_flags_map.get(text);\n    }\n\n    pub fn merge(self: ModifierFlag, other: ModifierFlag) ModifierFlag {\n        const m1: u32 = @bitCast(self);\n        const m2: u32 = @bitCast(other);\n        return @bitCast(m1 | m2);\n    }\n\n    /// True when no modifier bits are set (excluding the\n    /// `passthrough` flag, which is a routing marker rather than a\n    /// modifier and shouldn't gate wildcard matching). Used by\n    /// capture-mode layer lookup: a forward rule with no declared\n    /// modifiers acts as a \"transparent\" wildcard.\n    pub fn isEmpty(self: ModifierFlag) bool {\n        var copy = self;\n        copy.passthrough = false;\n        const m: u32 = @bitCast(copy);\n        return m == 0;\n    }\n    pub fn format(self: *const ModifierFlag, comptime fmt: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void {\n        _ = fmt;\n\n        var i: u32 = 0;\n        inline for (@typeInfo(@This()).@\"struct\".fields) |field| {\n            const name = field.name;\n            if (field.type != bool) continue;\n            const value = @field(self, name);\n            if (value) {\n                if (i != 0) try writer.print(\", \", .{});\n                try writer.print(\"{s}\", .{name});\n                i += 1;\n            }\n        }\n    }\n};\n\ntest \"format ModifierFlag\" {\n    const flag = ModifierFlag{ .alt = true, .shift = true };\n    // Verify formatting works without printing\n    const formatted = try std.fmt.allocPrint(std.testing.allocator, \"{s}\", .{flag});\n    defer std.testing.allocator.free(formatted);\n    try std.testing.expectEqualStrings(\"alt, shift\", formatted);\n}\n\nconst modifier_flags_map = std.StaticStringMap(ModifierFlag).initComptime(.{\n    .{ \"alt\", ModifierFlag{ .alt = true } },\n    .{ \"lalt\", ModifierFlag{ .lalt = true } },\n    .{ \"ralt\", ModifierFlag{ .ralt = true } },\n    .{ \"shift\", ModifierFlag{ .shift = true } },\n    .{ \"lshift\", ModifierFlag{ .lshift = true } },\n    .{ \"rshift\", ModifierFlag{ .rshift = true } },\n    .{ \"cmd\", ModifierFlag{ .cmd = true } },\n    .{ \"lcmd\", ModifierFlag{ .lcmd = true } },\n    .{ \"rcmd\", ModifierFlag{ .rcmd = true } },\n    .{ \"ctrl\", ModifierFlag{ .control = true } },\n    .{ \"lctrl\", ModifierFlag{ .lcontrol = true } },\n    .{ \"rctrl\", ModifierFlag{ .rcontrol = true } },\n    .{ \"fn\", ModifierFlag{ .@\"fn\" = true } },\n    .{ \"hyper\", ModifierFlag.hyper },\n    .{ \"meh\", ModifierFlag.meh },\n});\n\ntest \"is_modifier\" {\n    const flag = ModifierFlag.get(\"alt\");\n    try std.testing.expectEqual(flag, ModifierFlag{ .alt = true });\n    try std.testing.expectEqual(ModifierFlag.get(\"xx\"), null);\n}\n\npub const literal_keycode_str = [_][]const u8{\n    \"return\",          \"tab\",             \"space\",\n    \"backspace\",       \"escape\",          \"backtick\",\n\n    // zig fmt: off\n\n    // Fn mod\n    \"delete\",          \"home\",            \"end\",\n    \"pageup\",          \"pagedown\",        \"insert\",\n    \"left\",            \"right\",           \"up\",\n    \"down\",            \"f1\",              \"f2\",\n    \"f3\",              \"f4\",              \"f5\",\n    \"f6\",              \"f7\",              \"f8\",\n    \"f9\",              \"f10\",             \"f11\",\n    \"f12\",             \"f13\",             \"f14\",\n    \"f15\",             \"f16\",             \"f17\",\n    \"f18\",             \"f19\",             \"f20\",\n\n    // NX mod\n    \"sound_up\",        \"sound_down\",      \"mute\",\n    \"play\",            \"previous\",        \"next\",\n    \"rewind\",          \"fast\",            \"brightness_up\",\n    \"brightness_down\", \"illumination_up\", \"illumination_down\",\n\n    // Mouse buttons (no implicit modifier; synthetic keycodes above the\n    // u8 keyboard range so they can't collide with real kVK_ values)\n    \"mouse1\",          \"mouse2\",          \"mouse3\",\n    \"mouse4\",          \"mouse5\",\n    // zig fmt: on\n};\n\npub const KEY_HAS_IMPLICIT_FN_MOD = 4;\npub const KEY_HAS_IMPLICIT_NX_MOD = 35;\npub const KEY_FIRST_MOUSE = 48;\n\n/// Synthetic keycode base for mouse buttons. Keyboard kVK_* values\n/// fit in u8, so anything ≥ 0x10000 is unambiguously a mouse button.\npub const MOUSE_BUTTON_BASE: u32 = 0x10000;\n\npub inline fn mouseButtonCode(n: u8) u32 {\n    return MOUSE_BUTTON_BASE | @as(u32, n);\n}\n\npub inline fn isMouseButton(keycode: u32) bool {\n    return keycode >= MOUSE_BUTTON_BASE;\n}\n\npub const literal_keycode_value = [_]u32{\n    c.kVK_Return,                 c.kVK_Tab,                  c.kVK_Space,\n    c.kVK_Delete,                 c.kVK_Escape,               c.kVK_ANSI_Grave,\n\n    // zig fmt: off\n\n    // Fn mod\n    c.kVK_ForwardDelete, c.kVK_Home,       c.kVK_End,\n    c.kVK_PageUp,        c.kVK_PageDown,   c.kVK_Help,\n    c.kVK_LeftArrow,     c.kVK_RightArrow, c.kVK_UpArrow,\n    c.kVK_DownArrow,     c.kVK_F1,         c.kVK_F2,\n    c.kVK_F3,            c.kVK_F4,         c.kVK_F5,\n    c.kVK_F6,            c.kVK_F7,         c.kVK_F8,\n    c.kVK_F9,            c.kVK_F10,        c.kVK_F11,\n    c.kVK_F12,           c.kVK_F13,        c.kVK_F14,\n    c.kVK_F15,           c.kVK_F16,        c.kVK_F17,\n    c.kVK_F18,           c.kVK_F19,        c.kVK_F20,\n\n    // NX mod\n    c.NX_KEYTYPE_SOUND_UP,        c.NX_KEYTYPE_SOUND_DOWN,      c.NX_KEYTYPE_MUTE,\n    c.NX_KEYTYPE_PLAY,            c.NX_KEYTYPE_PREVIOUS,        c.NX_KEYTYPE_NEXT,\n    c.NX_KEYTYPE_REWIND,          c.NX_KEYTYPE_FAST,            c.NX_KEYTYPE_BRIGHTNESS_UP,\n    c.NX_KEYTYPE_BRIGHTNESS_DOWN, c.NX_KEYTYPE_ILLUMINATION_UP, c.NX_KEYTYPE_ILLUMINATION_DOWN,\n\n    // Mouse buttons\n    mouseButtonCode(1), mouseButtonCode(2), mouseButtonCode(3),\n    mouseButtonCode(4), mouseButtonCode(5),\n    // zig fmt: on\n};\n\nalloc: std.mem.Allocator,\nkeymap_table: std.StringArrayHashMapUnmanaged(u32) = .empty,\n\nconst Keycodes = @This();\n\npub fn init(alloc: std.mem.Allocator) !Keycodes {\n    var self =  Keycodes{ .alloc = alloc };\n\n    const keyboard = c.TISCopyCurrentASCIICapableKeyboardLayoutInputSource();\n    const uchr: c.CFDataRef = @ptrCast(c.TISGetInputSourceProperty(keyboard, c.kTISPropertyUnicodeKeyLayoutData));\n    defer c.CFRelease(keyboard);\n\n    const keyboard_layout: ?*c.UCKeyboardLayout = @constCast(@ptrCast(@alignCast(c.CFDataGetBytePtr(uchr))));\n    if (keyboard_layout == null) {\n        return error.@\"Failed to get keyboard layout\";\n    }\n\n    var len: c.UniCharCount = 0;\n    var chars = [_]c.UniChar{0} ** 255;\n    var state: c.UInt32 = 0;\n\n    for (layout_dependent_keycodes) |keycode| {\n        const ret = c.UCKeyTranslate(\n            keyboard_layout,\n            @intCast(keycode),\n            c.kUCKeyActionDisplay,\n            0,\n            c.LMGetKbdType(),\n            c.kUCKeyTranslateNoDeadKeysMask,\n            &state,\n            chars.len,\n            &len,\n            &chars,\n        );\n        if (ret == c.noErr and len > 0) {\n            const key_cfstring = c.CFStringCreateWithCharacters(c.kCFAllocatorDefault, &chars, @intCast(len));\n            if (key_cfstring == null) {\n                log.err(\"Failed to create CFString for keycode {}\", .{keycode});\n                continue;\n            }\n            defer c.CFRelease(key_cfstring);\n            const key_string = copy_cfstring(alloc, key_cfstring.?) catch |err| {\n                log.err(\"Failed to copy CFString for keycode {}: {}\", .{ keycode, err });\n                continue;\n            };\n            errdefer alloc.free(key_string);\n            if (self.keymap_table.get(key_string)) |existing_keycode| {\n                // EurKEY and other layouts may have duplicate mappings - keep the first one\n                log.debug(\"Duplicate keycode mapping for '{s}': keeping {}, ignoring {}\", .{ key_string, existing_keycode, keycode });\n                alloc.free(key_string);\n            } else {\n                try self.keymap_table.put(alloc, key_string, keycode);\n            }\n        }\n    }\n\n    return self;\n}\n\npub fn deinit(self: *Keycodes) void {\n    var it = self.keymap_table.iterator();\n    while (it.next()) |kv| {\n        self.alloc.free(kv.key_ptr.*);\n    }\n    self.keymap_table.deinit(self.alloc);\n}\n\npub fn get_keycode(self: *Keycodes, key: []const u8) !u32 {\n    const key_string = self.keymap_table.get(key) orelse return error.@\"Key not found\";\n    return key_string;\n}\n\nfn copy_cfstring(alloc: std.mem.Allocator, cfstring: c.CFStringRef) ![]u8 {\n    const len = c.CFStringGetLength(cfstring);\n    const num_bytes = c.CFStringGetMaximumSizeForEncoding(len, c.kCFStringEncodingUTF8);\n\n    if (num_bytes > 64) {\n        log.err(\"CFString requires {} bytes (> 64)\", .{num_bytes});\n        @panic(\"num_bytes for cfstring > 64\");\n    }\n\n    var buffer: [64]u8 = undefined;\n\n    // Pass the actual buffer size (buffer.len), not num_bytes\n    // CFStringGetCString needs the full buffer size to work correctly\n    if (c.CFStringGetCString(cfstring, &buffer, buffer.len, c.kCFStringEncodingUTF8) == c.false) {\n        return error.@\"Failed to copy CFString\";\n    }\n\n    const ret = try alloc.dupe(u8, std.mem.sliceTo(buffer[0..], 0));\n    return ret;\n}\n\ntest \"init_keycode_map\" {\n    const alloc = std.testing.allocator;\n    var self = try init(alloc);\n    defer self.deinit();\n    \n    // Just verify the keymap was initialized with some expected values\n    try std.testing.expect(self.keymap_table.contains(\"a\"));\n    try std.testing.expect(self.keymap_table.contains(\"1\"));\n    try std.testing.expect(self.keymap_table.count() > 20); // Should have many keys\n    \n    // Verify literal keycodes exist in the arrays\n    try std.testing.expect(literal_keycode_str.len > 0);\n    try std.testing.expect(literal_keycode_value.len == literal_keycode_str.len);\n}\n\ntest \"mouse button literals are present and have synthetic codes\" {\n    // mouse1..mouse5 must live at indices [KEY_FIRST_MOUSE, +5).\n    try std.testing.expectEqual(@as(usize, 5), literal_keycode_str.len - KEY_FIRST_MOUSE);\n    try std.testing.expectEqualStrings(\"mouse1\", literal_keycode_str[KEY_FIRST_MOUSE]);\n    try std.testing.expectEqualStrings(\"mouse5\", literal_keycode_str[KEY_FIRST_MOUSE + 4]);\n\n    // Synthetic codes are above the keyboard u8 range so they can never\n    // collide with a real kVK_* value.\n    for (literal_keycode_value[KEY_FIRST_MOUSE..]) |code| {\n        try std.testing.expect(isMouseButton(code));\n        try std.testing.expect(code > 0xFF);\n    }\n    try std.testing.expectEqual(mouseButtonCode(1), literal_keycode_value[KEY_FIRST_MOUSE]);\n    try std.testing.expectEqual(mouseButtonCode(5), literal_keycode_value[KEY_FIRST_MOUSE + 4]);\n\n    // Real keyboard keycodes must NOT register as mouse buttons.\n    try std.testing.expect(!isMouseButton(0x00)); // 'a'\n    try std.testing.expect(!isMouseButton(0x35)); // escape\n}\n\ntest \"duplicate keycode mapping returns error\" {\n    const alloc = std.testing.allocator;\n\n    var self = try init(alloc);\n    defer self.deinit();\n    try std.testing.expect(self.keymap_table.count() > 0);\n}\n\ntest \"copy_cfstring with Unicode characters\" {\n    // Regression test for issue #19\n    // The ergol keyboard layout (https://ergol.org/) uses Unicode characters\n    // like U+2019 (right single quotation mark) for some keys. The bug was that\n    // we passed num_bytes instead of buffer.len to CFStringGetCString, causing\n    // the conversion to fail.\n    const alloc = std.testing.allocator;\n\n    // Test with a simple ASCII character\n    {\n        const chars = [_]c.UniChar{'a'};\n        const cfstring = c.CFStringCreateWithCharacters(c.kCFAllocatorDefault, &chars, 1);\n        try std.testing.expect(cfstring != null);\n        defer c.CFRelease(cfstring);\n\n        const result = try copy_cfstring(alloc, cfstring.?);\n        defer alloc.free(result);\n        try std.testing.expectEqualStrings(\"a\", result);\n    }\n\n    // Test with Unicode character U+2019 (right single quotation mark)\n    // This is the character that caused the crash with ergol keyboard layout\n    {\n        const chars = [_]c.UniChar{0x2019};\n        const cfstring = c.CFStringCreateWithCharacters(c.kCFAllocatorDefault, &chars, 1);\n        try std.testing.expect(cfstring != null);\n        defer c.CFRelease(cfstring);\n\n        const result = try copy_cfstring(alloc, cfstring.?);\n        defer alloc.free(result);\n        // U+2019 in UTF-8 is 0xE2 0x80 0x99 which is the right single quotation mark\n        try std.testing.expect(result.len == 3); // UTF-8 encoding is 3 bytes\n        const expected = [_]u8{ 0xE2, 0x80, 0x99 }; // UTF-8 encoding of U+2019\n        try std.testing.expectEqualSlices(u8, &expected, result);\n    }\n\n    // Test with multi-character string including Unicode\n    {\n        const chars = [_]c.UniChar{ 'a', 0x2019, 'b' };\n        const cfstring = c.CFStringCreateWithCharacters(c.kCFAllocatorDefault, &chars, 3);\n        try std.testing.expect(cfstring != null);\n        defer c.CFRelease(cfstring);\n\n        const result = try copy_cfstring(alloc, cfstring.?);\n        defer alloc.free(result);\n        const expected = [_]u8{ 'a', 0xE2, 0x80, 0x99, 'b' };\n        try std.testing.expectEqualSlices(u8, &expected, result);\n    }\n}\n\n/// Format modifier flags and key into a human-readable string using a stack buffer\n/// Returns a slice pointing into the provided buffer\npub fn formatKeyPressBuffer(buf: []u8, flags: ModifierFlag, keyCode: u32) ![]const u8 {\n    var stream = std.io.fixedBufferStream(buf);\n    const writer = stream.writer();\n    \n    var has_modifiers = false;\n    \n    // Add modifiers in a consistent order\n    if (flags.lcmd or flags.cmd) {\n        try writer.print(\"lcmd\", .{});\n        has_modifiers = true;\n    }\n    if (flags.rcmd) {\n        if (has_modifiers) try writer.print(\" + \", .{});\n        try writer.print(\"rcmd\", .{});\n        has_modifiers = true;\n    }\n    if (flags.lalt or flags.alt) {\n        if (has_modifiers) try writer.print(\" + \", .{});\n        try writer.print(\"lalt\", .{});\n        has_modifiers = true;\n    }\n    if (flags.ralt) {\n        if (has_modifiers) try writer.print(\" + \", .{});\n        try writer.print(\"ralt\", .{});\n        has_modifiers = true;\n    }\n    if (flags.lshift or flags.shift) {\n        if (has_modifiers) try writer.print(\" + \", .{});\n        try writer.print(\"lshift\", .{});\n        has_modifiers = true;\n    }\n    if (flags.rshift) {\n        if (has_modifiers) try writer.print(\" + \", .{});\n        try writer.print(\"rshift\", .{});\n        has_modifiers = true;\n    }\n    if (flags.lcontrol or flags.control) {\n        if (has_modifiers) try writer.print(\" + \", .{});\n        try writer.print(\"lctrl\", .{});\n        has_modifiers = true;\n    }\n    if (flags.rcontrol) {\n        if (has_modifiers) try writer.print(\" + \", .{});\n        try writer.print(\"rctrl\", .{});\n        has_modifiers = true;\n    }\n    if (flags.@\"fn\") {\n        if (has_modifiers) try writer.print(\" + \", .{});\n        try writer.print(\"fn\", .{});\n        has_modifiers = true;\n    }\n    \n    // Get the key\n    const key = getKeyString(keyCode);\n    \n    // Add the key with proper separator\n    if (has_modifiers) {\n        try writer.print(\" - {s}\", .{key});\n    } else {\n        try writer.print(\"{s}\", .{key});\n    }\n    \n    return stream.getWritten();\n}\n\n/// Get a human-readable string representation of a keycode\npub fn getKeyString(keyCode: u32) []const u8 {\n    return switch (keyCode) {\n        0 => \"a\",\n        1 => \"s\",\n        2 => \"d\",\n        3 => \"f\",\n        4 => \"h\",\n        5 => \"g\",\n        6 => \"z\",\n        7 => \"x\",\n        8 => \"c\",\n        9 => \"v\",\n        10 => \"§\",\n        11 => \"b\",\n        12 => \"q\",\n        13 => \"w\",\n        14 => \"e\",\n        15 => \"r\",\n        16 => \"y\",\n        17 => \"t\",\n        18 => \"1\",\n        19 => \"2\",\n        20 => \"3\",\n        21 => \"4\",\n        22 => \"6\",\n        23 => \"5\",\n        24 => \"=\",\n        25 => \"9\",\n        26 => \"7\",\n        27 => \"-\",\n        28 => \"8\",\n        29 => \"0\",\n        30 => \"]\",\n        31 => \"o\",\n        32 => \"u\",\n        33 => \"[\",\n        34 => \"i\",\n        35 => \"p\",\n        36 => \"return\",\n        37 => \"l\",\n        38 => \"j\",\n        39 => \"'\",\n        40 => \"k\",\n        41 => \";\",\n        42 => \"\\\\\",\n        43 => \",\",\n        44 => \"/\",\n        45 => \"n\",\n        46 => \"m\",\n        47 => \".\",\n        48 => \"tab\",\n        49 => \"space\",\n        50 => \"`\",\n        51 => \"delete\",\n        52 => \"enter\",\n        53 => \"escape\",\n\n        64 => \"f17\",\n        65 => \".\",\n\n        67 => \"*\",\n\n        69 => \"+\",\n\n        71 => \"clear\",\n\n        75 => \"/\",\n        76 => \"enter\",\n\n        78 => \"-\",\n        79 => \"f18\",\n        80 => \"f19\",\n        81 => \"=\",\n        82 => \"0\",\n        83 => \"1\",\n        84 => \"2\",\n        85 => \"3\",\n        86 => \"4\",\n        87 => \"5\",\n        88 => \"6\",\n        89 => \"7\",\n        90 => \"f20\",\n        91 => \"8\",\n        92 => \"9\",\n\n        0xb0 => \"f5\",\n        96 => \"f5\",\n        97 => \"f6\",\n        98 => \"f7\",\n        99 => \"f3\",\n        100 => \"f8\",\n        101 => \"f9\",\n\n        103 => \"f11\",\n\n        105 => \"f13\",\n        106 => \"f16\",\n        107 => \"f14\",\n\n        109 => \"f10\",\n\n        111 => \"f12\",\n\n        113 => \"f15\",\n        114 => \"help\",\n        115 => \"home\",\n        116 => \"pgup\",\n        117 => \"delete\",\n        118 => \"f4\",\n        119 => \"end\",\n        120 => \"f2\",\n        121 => \"pgdn\",\n        122 => \"f1\",\n        123 => \"left\",\n        124 => \"right\",\n        125 => \"down\",\n        126 => \"up\",\n\n        else => \"unknown\",\n    };\n}\n\n/// Get a human-readable string for NX media key codes\npub fn getNXKeyString(keyCode: u8) []const u8 {\n    return switch (keyCode) {\n        c.NX_KEYTYPE_SOUND_UP => \"sound_up\",\n        c.NX_KEYTYPE_SOUND_DOWN => \"sound_down\",\n        c.NX_KEYTYPE_MUTE => \"mute\",\n        c.NX_KEYTYPE_BRIGHTNESS_UP => \"brightness_up\",\n        c.NX_KEYTYPE_BRIGHTNESS_DOWN => \"brightness_down\",\n        c.NX_KEYTYPE_PLAY => \"play\",\n        c.NX_KEYTYPE_PREVIOUS => \"previous\",\n        c.NX_KEYTYPE_NEXT => \"next\",\n        c.NX_KEYTYPE_REWIND => \"rewind\",\n        c.NX_KEYTYPE_FAST => \"fast\",\n        c.NX_KEYTYPE_ILLUMINATION_UP => \"illumination_up\",\n        c.NX_KEYTYPE_ILLUMINATION_DOWN => \"illumination_down\",\n        else => \"unknown_nx\",\n    };\n}\n\ntest \"ptrcast\" {\n    const alloc = std.testing.allocator;\n    var buf = try alloc.alloc(u8, 10);\n    defer alloc.free(buf);\n\n    buf[0] = 'a';\n    buf[1] = 'b';\n    buf[2] = 'c';\n    buf[3] = 0;\n\n    const ptr: [*:0]u8 = @ptrCast(buf.ptr);\n    const sentinalSlice: [:0]const u8 = @ptrCast(buf);\n\n    // Verify the cast worked correctly\n    try std.testing.expectEqualStrings(\"abc\", ptr[0..3]);\n    try std.testing.expectEqualStrings(\"abc\", sentinalSlice[0..3]);\n\n    const span = std.mem.sliceTo(buf, 0);\n    try std.testing.expectEqualStrings(\"abc\", span);\n    // alloc.free(span);\n    // alloc.free(ptr);\n}\n"
  },
  {
    "path": "src/Mappings.zig",
    "content": "const std = @import(\"std\");\nconst Mode = @import(\"Mode.zig\");\nconst Hotkey = @import(\"Hotkey.zig\");\nconst utils = @import(\"utils.zig\");\nconst log = std.log.scoped(.mappings);\n\nallocator: std.mem.Allocator,\nmode_map: std.StringHashMapUnmanaged(Mode) = .empty,\nblacklist: std.StringHashMapUnmanaged(void) = .empty,\nshell: [:0]const u8,\nloaded_files: std.ArrayListUnmanaged([]const u8) = .empty,\n// Extra PATH entries declared via `.path` directives. Prepended to PATH at\n// startup so commands launched by hotkeys can find user-installed tools that\n// aren't in the shell-inherited PATH (e.g. mise/asdf/nvm shims).\npaths: std.ArrayListUnmanaged([]const u8) = .empty,\n// Track all hotkeys for cleanup (hotkeys can belong to multiple modes)\nhotkeys: std.ArrayListUnmanaged(*Hotkey) = .empty,\n// Device aliases declared via `.device <name> <vendor> <product>`. Empty\n// when the user hasn't opted into per-device matching, in which case the\n// IOHIDManager monitor is never started.\ndevice_aliases: std.StringHashMapUnmanaged(DeviceAlias) = .empty,\n// HID-level remaps declared via `.remap <src> [device <alias>] : <dst>`.\n// Owned by Mappings — strings are duped on insert, freed in deinit.\nremaps: std.ArrayListUnmanaged(RemapDecl) = .empty,\n// Tap-hold declarations from the block form of `.remap`. Distinct from\n// `remaps` so the runtime knows which keys need a state machine vs a\n// pure HID-level remap.\ntapholds: std.ArrayListUnmanaged(TapHoldDecl) = .empty,\n\nconst Mappings = @This();\n\npub const DeviceAlias = struct {\n    vendor: u32,\n    product: u32,\n};\n\npub const RemapDecl = struct {\n    /// HID usage byte (page implied = 0x07 keyboard) of the source key.\n    src_usage: u32,\n    /// HID usage byte of the destination key.\n    dst_usage: u32,\n    /// Device alias name. Owned by Mappings (duped on insert, freed in\n    /// deinit). Required for v1 — global remaps are not supported.\n    device_alias: []const u8,\n};\n\npub const TapHoldDecl = struct {\n    /// HID usage byte of the physical key being intercepted (caps_lock,\n    /// space, etc.).\n    src_usage: u32,\n    /// HID usage byte of the action emitted on a quick tap (e.g.,\n    /// escape).\n    tap_usage: u32,\n    /// HID usage byte of the action committed on hold (e.g., lctrl).\n    /// Zero when `hold_layer` is set instead.\n    hold_usage: u32 = 0,\n    /// Mode name to push on hold (e.g. \"fn_layer\"). null when this\n    /// rule's hold action is a HID usage. Owned by Mappings (duped\n    /// on insert, freed in deinit).\n    hold_layer: ?[]const u8 = null,\n    /// Required device alias (same rationale as RemapDecl). Owned.\n    device_alias: []const u8,\n    /// Tap-vs-hold decision deadline in milliseconds. Default 200 if\n    /// unspecified by the user.\n    timeout_ms: u32 = 200,\n    /// QMK PERMISSIVE_HOLD: nested-tap (other key down + up) inside the\n    /// hold key's press commits to hold even before the timeout.\n    permissive_hold: bool = true,\n    /// QMK HOLD_ON_OTHER_KEY_PRESS: any other key down commits to hold\n    /// immediately. Stronger than permissive_hold; off by default.\n    hold_on_other_key_press: bool = false,\n    /// QMK RETRO_TAPPING: when held past timeout with no other key\n    /// pressed, emit the tap action on release anyway.\n    retro_tap: bool = false,\n};\n\npub fn init(alloc: std.mem.Allocator) !Mappings {\n    const default_shell = \"/bin/bash\";\n    const shell = if (std.posix.getenv(\"SHELL\")) |env|\n        try alloc.dupeZ(u8, env)\n    else\n        try alloc.dupeZ(u8, default_shell);\n\n    return Mappings{\n        .shell = shell,\n        .allocator = alloc,\n    };\n}\n\npub fn deinit(self: *Mappings) void {\n    // First destroy all hotkeys (must be done before destroying modes)\n    for (self.hotkeys.items) |hotkey| {\n        hotkey.destroy();\n    }\n    self.hotkeys.deinit(self.allocator);\n\n    {\n        var it = self.mode_map.iterator();\n        while (it.next()) |kv| {\n            self.allocator.free(kv.key_ptr.*);\n            kv.value_ptr.*.deinit();\n        }\n        self.mode_map.deinit(self.allocator);\n    }\n    {\n        var it = self.blacklist.keyIterator();\n        while (it.next()) |key| self.allocator.free(key.*);\n        self.blacklist.deinit(self.allocator);\n    }\n    {\n        var it = self.device_aliases.keyIterator();\n        while (it.next()) |key| self.allocator.free(key.*);\n        self.device_aliases.deinit(self.allocator);\n    }\n    for (self.remaps.items) |r| self.allocator.free(r.device_alias);\n    self.remaps.deinit(self.allocator);\n    for (self.tapholds.items) |t| {\n        self.allocator.free(t.device_alias);\n        if (t.hold_layer) |l| self.allocator.free(l);\n    }\n    self.tapholds.deinit(self.allocator);\n    self.allocator.free(self.shell);\n\n    // Free loaded file paths\n    for (self.loaded_files.items) |file_path| {\n        self.allocator.free(file_path);\n    }\n    self.loaded_files.deinit(self.allocator);\n\n    for (self.paths.items) |path| {\n        self.allocator.free(path);\n    }\n    self.paths.deinit(self.allocator);\n\n    self.* = undefined;\n}\n\npub fn add_hotkey(self: *Mappings, hotkey: *Hotkey) !void {\n    // First try to add to all modes\n    var it = hotkey.mode_list.iterator();\n    while (it.next()) |kv| {\n        const mode = kv.key_ptr.*;\n        try mode.add_hotkey(hotkey);\n    }\n\n    // Only track the hotkey after successful addition to all modes\n    try self.hotkeys.append(self.allocator, hotkey);\n}\n\npub fn set_shell(self: *Mappings, shell: []const u8) !void {\n    self.allocator.free(self.shell);\n    self.shell = try self.allocator.dupeZ(u8, shell);\n}\n\npub fn add_blacklist(self: *Mappings, key: []const u8) !void {\n    if (self.blacklist.contains(key)) {\n        return error.BlacklistEntryAlreadyExists;\n    }\n    const owned = try self.allocator.dupe(u8, key);\n    try self.blacklist.put(self.allocator, owned, void{});\n}\n\npub fn add_device_alias(self: *Mappings, name: []const u8, vendor: u32, product: u32) !void {\n    if (self.device_aliases.contains(name)) {\n        return error.DeviceAliasAlreadyExists;\n    }\n    const owned = try self.allocator.dupe(u8, name);\n    errdefer self.allocator.free(owned);\n    try self.device_aliases.put(self.allocator, owned, .{ .vendor = vendor, .product = product });\n}\n\npub fn add_remap(self: *Mappings, src_usage: u32, dst_usage: u32, device_alias: []const u8) !void {\n    // Same source key for the same device cannot be remapped twice.\n    if (self.findRemapOrTaphold(src_usage, device_alias)) {\n        return error.RemapConflict;\n    }\n    const owned_alias = try self.allocator.dupe(u8, device_alias);\n    errdefer self.allocator.free(owned_alias);\n    try self.remaps.append(self.allocator, .{\n        .src_usage = src_usage,\n        .dst_usage = dst_usage,\n        .device_alias = owned_alias,\n    });\n}\n\npub fn add_taphold(self: *Mappings, decl: TapHoldDecl) !void {\n    if (self.findRemapOrTaphold(decl.src_usage, decl.device_alias)) {\n        return error.RemapConflict;\n    }\n    const owned_alias = try self.allocator.dupe(u8, decl.device_alias);\n    errdefer self.allocator.free(owned_alias);\n    const owned_layer: ?[]const u8 = if (decl.hold_layer) |l|\n        try self.allocator.dupe(u8, l)\n    else\n        null;\n    errdefer if (owned_layer) |l| self.allocator.free(l);\n    var d = decl;\n    d.device_alias = owned_alias;\n    d.hold_layer = owned_layer;\n    try self.tapholds.append(self.allocator, d);\n}\n\n/// True if (src_usage, device_alias) is already claimed by a `.remap`\n/// or `.remap { ... }` block. Used by both add_remap and add_taphold to\n/// reject ambiguous configurations like a colon-form and a block-form\n/// targeting the same physical key on the same device.\nfn findRemapOrTaphold(self: *const Mappings, src_usage: u32, device_alias: []const u8) bool {\n    for (self.remaps.items) |existing| {\n        if (existing.src_usage == src_usage and std.mem.eql(u8, existing.device_alias, device_alias)) return true;\n    }\n    for (self.tapholds.items) |existing| {\n        if (existing.src_usage == src_usage and std.mem.eql(u8, existing.device_alias, device_alias)) return true;\n    }\n    return false;\n}\n\n/// Append an entry from a `.path` directive. Caller passes the\n/// already-expanded absolute path; expansion (e.g. `~` → `$HOME`) happens at\n/// parse time so the stored value is what setenv will use directly.\npub fn add_path(self: *Mappings, path: []const u8) !void {\n    const owned = try self.allocator.dupe(u8, path);\n    errdefer self.allocator.free(owned);\n    try self.paths.append(self.allocator, owned);\n}\n\n\npub fn format(self: *const Mappings, comptime fmt: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void {\n    // if (fmt.len != 0) {\n    //     std.fmt.invalidFmtError(fmt, self);\n    // }\n    _ = fmt;\n    try writer.print(\"Mappings {{\", .{});\n    try writer.print(\"\\n  mode_map: {{\", .{});\n    {\n        var it = self.mode_map.iterator();\n        while (it.next()) |kv| {\n            try utils.indentPrint(self.allocator, writer, \"    \", \"\\n{}\", kv.value_ptr.*);\n        }\n    }\n    try writer.print(\"\\n  }}\", .{});\n    try writer.print(\"\\n  blacklist: {{\", .{});\n    {\n        var it = self.blacklist.keyIterator();\n        while (it.next()) |key| {\n            try writer.print(\"\\n    {s}\", .{key.*});\n        }\n    }\n    try writer.print(\"\\n  }}\", .{});\n    try writer.print(\"\\n}}\", .{});\n}\n\npub fn get_mode_or_create_default(self: *Mappings, mode_name: []const u8) !?*Mode {\n    if (std.mem.eql(u8, mode_name, \"default\")) {\n        const key = try self.allocator.dupe(u8, mode_name);\n        errdefer self.allocator.free(key);\n        const mode_value = try self.mode_map.getOrPut(self.allocator, key);\n        if (mode_value.found_existing) {\n            defer self.allocator.free(key);\n            return mode_value.value_ptr;\n        }\n        const mode = try Mode.init(self.allocator, key);\n        mode_value.value_ptr.* = mode;\n        return mode_value.value_ptr;\n    }\n    return self.mode_map.getPtr(mode_name);\n}\n\npub fn get_or_create_mode(self: *Mappings, mode_name: []const u8) !*Mode {\n    const key = try self.allocator.dupe(u8, mode_name);\n    errdefer self.allocator.free(key);\n    const mode_value = try self.mode_map.getOrPut(self.allocator, key);\n    if (mode_value.found_existing) {\n        defer self.allocator.free(key);\n        return mode_value.value_ptr;\n    }\n    const mode = try Mode.init(self.allocator, key);\n    mode_value.value_ptr.* = mode;\n    return mode_value.value_ptr;\n}\n\npub fn put_mode(self: *Mappings, mode: Mode) !void {\n    if (self.mode_map.contains(mode.name)) {\n        return error.ModeAlreadyExists;\n    }\n    const key = try self.allocator.dupe(u8, mode.name);\n    try self.mode_map.put(self.allocator, key, mode);\n}\n\ntest \"get_mode default\" {\n    const alloc = std.testing.allocator;\n    var mappings = try Mappings.init(alloc);\n    _ = try mappings.get_mode_or_create_default(\"default\");\n    _ = try mappings.get_mode_or_create_default(\"default\");\n    _ = try mappings.get_mode_or_create_default(\"xxx\");\n    _ = try mappings.get_mode_or_create_default(\"yyy\");\n    try std.testing.expectEqual(mappings.mode_map.count(), 1);\n    defer mappings.deinit();\n}\n\ntest \"format\" {\n    const alloc = std.testing.allocator;\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n    const mode = try mappings.get_mode_or_create_default(\"default\");\n    // Just verify the formatting doesn't crash\n    const formatted = try std.fmt.allocPrint(alloc, \"{}\", .{mappings});\n    defer alloc.free(formatted);\n    try std.testing.expect(formatted.len > 0);\n    try std.testing.expect(mode != null);\n}\n\ntest \"add_taphold rejects collision with prior .remap on same src+device\" {\n    const alloc = std.testing.allocator;\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try mappings.add_remap(0x39, 0xE0, \"builtin\");\n    const result = mappings.add_taphold(.{\n        .src_usage = 0x39,\n        .tap_usage = 0x29,\n        .hold_usage = 0xE0,\n        .device_alias = \"builtin\",\n    });\n    try std.testing.expectError(error.RemapConflict, result);\n}\n\ntest \"add_taphold accepts distinct src or distinct device\" {\n    const alloc = std.testing.allocator;\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try mappings.add_taphold(.{\n        .src_usage = 0x39, // caps_lock\n        .tap_usage = 0x29, // escape\n        .hold_usage = 0xE0, // lctrl\n        .device_alias = \"builtin\",\n        .timeout_ms = 120,\n    });\n    // Same key, different device — fine.\n    try mappings.add_taphold(.{\n        .src_usage = 0x39,\n        .tap_usage = 0x29,\n        .hold_usage = 0xE0,\n        .device_alias = \"hhkb\",\n    });\n    // Different key, same device — fine.\n    try mappings.add_taphold(.{\n        .src_usage = 0x2C, // space\n        .tap_usage = 0x2C,\n        .hold_usage = 0xE2, // lalt\n        .device_alias = \"builtin\",\n        .timeout_ms = 300,\n        .retro_tap = true,\n    });\n    try std.testing.expectEqual(@as(usize, 3), mappings.tapholds.items.len);\n    try std.testing.expectEqual(@as(u32, 120), mappings.tapholds.items[0].timeout_ms);\n    try std.testing.expectEqual(true, mappings.tapholds.items[2].retro_tap);\n}\n\ntest \"add_remap returns error on duplicate src+device\" {\n    const alloc = std.testing.allocator;\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try mappings.add_remap(0x39, 0xE0, \"builtin\"); // caps_lock -> lctrl on builtin\n    // Same source on a different device is fine.\n    try mappings.add_remap(0x39, 0xE0, \"hhkb\");\n    try std.testing.expectEqual(@as(usize, 2), mappings.remaps.items.len);\n    // Same source + same device is a conflict.\n    const result = mappings.add_remap(0x39, 0xE1, \"builtin\");\n    try std.testing.expectError(error.RemapConflict, result);\n    try std.testing.expectEqual(@as(usize, 2), mappings.remaps.items.len);\n}\n\ntest \"add_device_alias returns error on duplicate\" {\n    const alloc = std.testing.allocator;\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try mappings.add_device_alias(\"builtin\", 0x05AC, 0x0342);\n\n    const result = mappings.add_device_alias(\"builtin\", 0x04FE, 0x0021);\n    try std.testing.expectError(error.DeviceAliasAlreadyExists, result);\n\n    const entry = mappings.device_aliases.get(\"builtin\").?;\n    try std.testing.expectEqual(@as(u32, 0x05AC), entry.vendor);\n    try std.testing.expectEqual(@as(u32, 0x0342), entry.product);\n    try std.testing.expectEqual(@as(usize, 1), mappings.device_aliases.count());\n}\n\ntest \"add_blacklist returns error on duplicate\" {\n    const alloc = std.testing.allocator;\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // First add should succeed\n    try mappings.add_blacklist(\"firefox\");\n\n    // Duplicate should fail\n    const result = mappings.add_blacklist(\"firefox\");\n    try std.testing.expectError(error.BlacklistEntryAlreadyExists, result);\n\n    // Verify the original entry is still there\n    try std.testing.expect(mappings.blacklist.contains(\"firefox\"));\n    try std.testing.expectEqual(@as(usize, 1), mappings.blacklist.count());\n}\n\ntest \"put_mode returns error on duplicate\" {\n    const alloc = std.testing.allocator;\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Create and add a mode\n    const mode1 = try Mode.init(alloc, \"test_mode\");\n    try mappings.put_mode(mode1);\n\n    // Try to add another mode with the same name\n    var mode2 = try Mode.init(alloc, \"test_mode\");\n    defer mode2.deinit(); // We need to clean this up since put_mode will fail\n\n    const result = mappings.put_mode(mode2);\n    try std.testing.expectError(error.ModeAlreadyExists, result);\n\n    // Verify the original mode is still there\n    try std.testing.expect(mappings.mode_map.contains(\"test_mode\"));\n    try std.testing.expectEqual(@as(usize, 1), mappings.mode_map.count());\n}\n"
  },
  {
    "path": "src/Mode.zig",
    "content": "// struct mode\n// {\n//     char *name;\n//     char *command;\n//     bool capture;\n//     bool initialized;\n//     struct table hotkey_map;\n// };\nconst std = @import(\"std\");\nconst Hotkey = @import(\"Hotkey.zig\");\nconst utils = @import(\"utils.zig\");\n\nconst Mode = @This();\nconst log = std.log.scoped(.mode);\n\nallocator: std.mem.Allocator,\nname: []const u8,\ncommand: ?[:0]const u8 = null,\ncapture: bool = false,\ninitialized: bool = false,\nhotkey_map: Hotkey.HotkeyMap = .empty,\n\npub fn init(allocator: std.mem.Allocator, name: []const u8) !Mode {\n    return Mode{\n        .allocator = allocator,\n        .name = try allocator.dupe(u8, name),\n        .capture = false,\n        .initialized = true,\n    };\n}\n\npub fn deinit(self: *Mode) void {\n    self.allocator.free(self.name);\n    if (self.command) |cmd| self.allocator.free(cmd);\n    self.hotkey_map.deinit(self.allocator);\n    self.* = undefined;\n}\n\npub fn set_command(self: *Mode, command: []const u8) !void {\n    if (self.command) |cmd| self.allocator.free(cmd);\n    self.command = try self.allocator.dupeZ(u8, command);\n}\n\npub fn format(self: *const Mode, comptime fmt: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void {\n    // if (fmt.len != 0) {\n    //     std.fmt.invalidFmtError(fmt, self);\n    // }\n    _ = fmt;\n    try writer.print(\"Mode{{\", .{});\n    try writer.print(\"\\n  name: {s}\", .{self.name});\n    try writer.print(\"\\n  command: {?s}\", .{self.command});\n    try writer.print(\"\\n  capture: {}\", .{self.capture});\n    try writer.print(\"\\n  initialized: {}\", .{self.initialized});\n    try writer.print(\"\\n  hotkey_map: {{\\n\", .{});\n    {\n        var it = self.hotkey_map.iterator();\n        while (it.next()) |kv| {\n            try utils.indentPrint(self.allocator, writer, \"    \", \"{}\", kv.key_ptr.*);\n        }\n    }\n    try writer.print(\"\\n  }}\", .{});\n    try writer.print(\"\\n}}\", .{});\n}\n\npub fn add_hotkey(self: *Mode, hotkey: *Hotkey) !void {\n    // Config-time duplicates are about overlapping triggers in the same\n    // mode. Commands/process mappings are payload, not part of the lookup\n    // key; users express process-specific variants inside one hotkey's\n    // process list.\n    var it = self.hotkey_map.iterator();\n    while (it.next()) |entry| {\n        if (Hotkey.triggersOverlap(entry.key_ptr.*, hotkey)) {\n            return error.DuplicateHotkeyInMode;\n        }\n    }\n\n    try self.hotkey_map.put(self.allocator, hotkey, {});\n}\n\ntest \"init\" {\n    const alloc = std.testing.allocator;\n    var mode = try Mode.init(alloc, \"default\");\n    defer mode.deinit();\n\n    var key = try Hotkey.create(alloc);\n    defer key.destroy();\n    try key.add_process_command(\"notepad.exe\", \"echo notepad\");\n    try key.add_mode(&mode);\n    try mode.add_hotkey(key);\n\n    // const string = try std.fmt.allocPrint(alloc, \"{}\", .{mode});\n    // defer alloc.free(string);\n    // std.debug.print(\"{s}\\n\", .{string});\n}\n"
  },
  {
    "path": "src/ParseError.zig",
    "content": "const std = @import(\"std\");\nconst Token = @import(\"Tokenizer.zig\").Token;\n\npub const ParseError = struct {\n    allocator: std.mem.Allocator,\n    message: []const u8,\n    line: usize,\n    column: usize,\n    file_path: ?[]const u8,\n    token_text: ?[]const u8,\n\n    pub fn format(self: ParseError, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void {\n        if (self.file_path) |path| {\n            try writer.print(\"{s}:\", .{path});\n        }\n        try writer.print(\"{d}:{d}: error: {s}\", .{ self.line, self.column, self.message });\n        if (self.token_text) |text| {\n            try writer.print(\" near '{s}'\", .{text});\n        }\n    }\n\n    pub fn deinit(self: *ParseError) void {\n        self.allocator.free(self.message);\n        if (self.token_text) |t| self.allocator.free(t);\n    }\n\n    pub fn fromToken(allocator: std.mem.Allocator, token: Token, message: []const u8, file_path: ?[]const u8) !ParseError {\n        const msg_copy = try allocator.dupe(u8, message);\n        errdefer allocator.free(msg_copy);\n        // Dupe the token text — `token.text` is a slice into the\n        // tokenizer's input buffer, which can be freed before the\n        // error gets logged (e.g. an error from a `.load`'d file\n        // whose content buffer goes out of scope before the top-\n        // level caller prints the error).\n        const text_copy = try allocator.dupe(u8, token.text);\n        return ParseError{\n            .allocator = allocator,\n            .message = msg_copy,\n            .line = token.line,\n            .column = token.cursor,\n            .file_path = file_path,\n            .token_text = text_copy,\n        };\n    }\n\n    pub fn fromPosition(allocator: std.mem.Allocator, line: usize, column: usize, message: []const u8, file_path: ?[]const u8) !ParseError {\n        const msg_copy = try allocator.dupe(u8, message);\n        return ParseError{\n            .allocator = allocator,\n            .message = msg_copy,\n            .line = line,\n            .column = column,\n            .file_path = file_path,\n            .token_text = null,\n        };\n    }\n};\n\npub const ParseErrorContext = struct {\n    allocator: std.mem.Allocator,\n    errors: std.ArrayList(ParseError),\n\n    pub fn init(allocator: std.mem.Allocator) ParseErrorContext {\n        return ParseErrorContext{\n            .allocator = allocator,\n            .errors = std.ArrayList(ParseError).init(allocator),\n        };\n    }\n\n    pub fn deinit(self: *ParseErrorContext) void {\n        self.errors.deinit();\n    }\n\n    pub fn addError(self: *ParseErrorContext, err: ParseError) !void {\n        try self.errors.append(err);\n    }\n\n    pub fn printErrors(self: *ParseErrorContext, writer: anytype) !void {\n        for (self.errors.items) |err| {\n            try writer.print(\"{}\\n\", .{err});\n        }\n    }\n};\n"
  },
  {
    "path": "src/Parser.zig",
    "content": "const std = @import(\"std\");\nconst c = @import(\"c.zig\");\nconst Tokenizer = @import(\"Tokenizer.zig\");\nconst Token = Tokenizer.Token;\nconst Hotkey = @import(\"Hotkey.zig\");\nconst assert = std.debug.assert;\nconst Mode = @import(\"Mode.zig\");\nconst Mappings = @import(\"Mappings.zig\");\nconst Keycodes = @import(\"Keycodes.zig\");\nconst HidKeyMap = @import(\"HidKeyMap.zig\");\nconst utils = @import(\"utils.zig\");\nconst ModifierFlag = @import(\"Keycodes.zig\").ModifierFlag;\nconst ParseError = @import(\"ParseError.zig\").ParseError;\n\nconst Parser = @This();\nconst log = std.log.scoped(.parser);\n\nconst LoadDirective = struct {\n    filename: []const u8,\n    token: Token,\n};\n\nallocator: std.mem.Allocator,\ntokenizer: Tokenizer = undefined,\ncontent: []const u8 = undefined,\nprevious_token: ?Token = undefined,\nnext_token: ?Token = undefined,\nkeycodes: Keycodes = undefined,\nload_directives: std.ArrayList(LoadDirective) = undefined,\ncurrent_file_path: ?[]const u8 = null,\nerror_info: ?ParseError = null,\nprocess_groups: std.StringHashMapUnmanaged([][]const u8) = .empty,\ncommand_defs: std.StringHashMapUnmanaged(CommandDef) = .empty,\naliases: std.StringHashMapUnmanaged(Alias) = .empty,\n\npub const CommandDef = struct {\n    pub const Part = union(enum) {\n        text: []const u8,\n        placeholder: u8, // placeholder number (1-based)\n\n        pub fn deinit(self: Part, allocator: std.mem.Allocator) void {\n            switch (self) {\n                .text => |text| allocator.free(text),\n                .placeholder => {},\n            }\n        }\n    };\n\n    parts: []Part,\n    max_placeholder: u8, // Highest placeholder number seen (0 if none)\n\n    pub fn deinit(self: *CommandDef, allocator: std.mem.Allocator) void {\n        for (self.parts) |part| part.deinit(allocator);\n        allocator.free(self.parts);\n        self.* = undefined;\n    }\n};\n\n/// `.alias $name <value>` resolves to either a modifier combination\n/// (used in modifier position) or a single key (used in key position).\n/// Disambiguation is positional: a modifier alias appears before `-`\n/// (or chained with `+`); a key alias appears after `-` or standalone.\npub const Alias = union(enum) {\n    modifier: ModifierFlag,\n    key: Hotkey.KeyPress, // KeyPress carries any implicit fn/nx flags from a literal\n};\n\npub fn deinit(self: *Parser) void {\n    self.keycodes.deinit();\n    for (self.load_directives.items) |directive| {\n        self.allocator.free(directive.filename);\n    }\n    self.load_directives.deinit();\n    if (self.error_info) |*error_info| {\n        error_info.deinit();\n    }\n\n    // Free process groups\n    {\n        var it = self.process_groups.iterator();\n        while (it.next()) |kv| {\n            self.allocator.free(kv.key_ptr.*);\n            for (kv.value_ptr.*) |process_name| {\n                self.allocator.free(process_name);\n            }\n            self.allocator.free(kv.value_ptr.*);\n        }\n        self.process_groups.deinit(self.allocator);\n    }\n\n    // Free command definitions\n    {\n        var it = self.command_defs.iterator();\n        while (it.next()) |kv| {\n            self.allocator.free(kv.key_ptr.*);\n            kv.value_ptr.*.deinit(self.allocator);\n        }\n        self.command_defs.deinit(self.allocator);\n    }\n\n    // Free aliases (values are POD; only the name strings own memory)\n    {\n        var it = self.aliases.iterator();\n        while (it.next()) |kv| {\n            self.allocator.free(kv.key_ptr.*);\n        }\n        self.aliases.deinit(self.allocator);\n    }\n\n    self.* = undefined;\n}\n\npub fn clearError(self: *Parser) void {\n    if (self.error_info) |*error_info| {\n        error_info.deinit();\n        self.error_info = null;\n    }\n}\n\npub fn init(allocator: std.mem.Allocator) !Parser {\n    // const f = try std.fs.cwd().openFile(filename, .{});\n    // defer f.close();\n    // const content = try f.readToEndAlloc(allocator, 1 << 24); // max size 16MB\n    return Parser{\n        .allocator = allocator,\n        .previous_token = null,\n        .next_token = null,\n        .keycodes = try Keycodes.init(allocator),\n        .load_directives = std.ArrayList(LoadDirective).init(allocator),\n    };\n}\n\npub fn parse(self: *Parser, mappings: *Mappings, content: []const u8) !void {\n    try self.parseWithPath(mappings, content, null);\n}\n\npub fn parseWithPath(self: *Parser, mappings: *Mappings, content: []const u8, file_path: ?[]const u8) !void {\n    self.content = content;\n    self.tokenizer = try Tokenizer.init(content);\n    self.current_file_path = file_path;\n\n    // Create default mode if it doesn't exist\n    if (!mappings.mode_map.contains(\"default\")) {\n        const default_mode = try Mode.init(mappings.allocator, \"default\");\n        const key = try mappings.allocator.dupe(u8, \"default\");\n        try mappings.mode_map.put(mappings.allocator, key, default_mode);\n    }\n\n    _ = self.advance();\n    while (self.peek()) |token| {\n        switch (token.type) {\n            .Token_Identifier, .Token_Modifier, .Token_Literal, .Token_Key_Hex, .Token_Key, .Token_Activate, .Token_Alias => {\n                self.parse_hotkey(mappings) catch |err| {\n                    if (self.error_info == null) {\n                        self.error_info = try ParseError.fromToken(self.allocator, token, \"Failed to parse hotkey\", self.current_file_path);\n                    }\n                    return err;\n                };\n            },\n            .Token_Decl => {\n                self.parse_mode_decl(mappings) catch |err| {\n                    if (self.error_info == null) {\n                        self.error_info = try ParseError.fromToken(self.allocator, token, \"Failed to parse mode declaration\", self.current_file_path);\n                    }\n                    return err;\n                };\n            },\n            .Token_Option => {\n                self.parse_option(mappings) catch |err| {\n                    if (self.error_info == null) {\n                        self.error_info = try ParseError.fromToken(self.allocator, token, \"Failed to parse option\", self.current_file_path);\n                    }\n                    return err;\n                };\n            },\n            else => {\n                const msg = try std.fmt.allocPrint(self.allocator, \"Unexpected token type: {s}, text: '{s}'\", .{ @tagName(token.type), token.text });\n                defer self.allocator.free(msg);\n                self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n                return error.ParseErrorOccurred;\n            },\n        }\n    }\n}\n\nfn peek(self: *Parser) ?Token {\n    return self.next_token;\n}\n\nfn previous(self: *Parser) Token {\n    return self.previous_token orelse @panic(\"No previous token\");\n}\n\n/// advance token stream\nfn advance(self: *Parser) void {\n    self.previous_token = self.next_token;\n    self.next_token = self.tokenizer.get_token();\n}\n\n/// peek next token and check if it's the expected type\nfn peek_check(self: *Parser, typ: Tokenizer.TokenType) bool {\n    const token = self.peek() orelse return false;\n    return token.type == typ;\n}\n\n/// match next token and move over it\nfn match(self: *Parser, typ: Tokenizer.TokenType) bool {\n    if (self.peek_check(typ)) {\n        self.advance();\n        return true;\n    }\n    return false;\n}\n\n/// Process escape sequences in a string, replacing \\\\ with \\ and \\\" with \"\nfn processStringOwned(self: *Parser, str: []const u8) ![]const u8 {\n    var result = std.ArrayList(u8).init(self.allocator);\n    errdefer result.deinit();\n\n    var i: usize = 0;\n    while (i < str.len) {\n        if (i + 1 < str.len and str[i] == '\\\\') {\n            if (str[i + 1] == '\\\\' or str[i + 1] == '\"') {\n                // Skip the backslash and append the next character\n                try result.append(str[i + 1]);\n                i += 2;\n                continue;\n            }\n        }\n        try result.append(str[i]);\n        i += 1;\n    }\n\n    // Always return owned slice\n    return try result.toOwnedSlice();\n}\n\n/// Helper function to handle errors from add_process_* methods with context\nfn handleProcessError(self: *Parser, err: anyerror, process_name: []const u8, operation: []const u8) !void {\n    const msg = switch (err) {\n        error.ProcessCommandAlreadyExists => blk: {\n            if (std.mem.eql(u8, process_name, \"*\")) {\n                break :blk try std.fmt.allocPrint(self.allocator, \"Wildcard binding already has a different {s}. Each hotkey can only have one wildcard action\", .{operation});\n            } else {\n                break :blk try std.fmt.allocPrint(self.allocator, \"Process '{s}' already has a different {s} for this hotkey. Each process can only have one action per hotkey\", .{ process_name, operation });\n            }\n        },\n        error.WildcardCommandAlreadyExists => try std.fmt.allocPrint(self.allocator, \"This hotkey already has a wildcard {s}. Only one wildcard action is allowed per hotkey\", .{operation}),\n        else => return err,\n    };\n    defer self.allocator.free(msg);\n\n    const token = self.previous();\n    self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n    return error.ParseErrorOccurred;\n}\n\nfn parse_hotkey(self: *Parser, mappings: *Mappings) !void {\n    var hotkey = try Hotkey.create(self.allocator);\n    errdefer hotkey.destroy();\n\n    if (self.match(.Token_Identifier)) {\n        try self.parse_mode(mappings, hotkey);\n    }\n\n    if (hotkey.mode_list.count() > 0) {\n        if (!self.match(.Token_Insert)) {\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected '<' after mode identifier\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n    } else {\n        const default_mode = mappings.get_mode_or_create_default(\"default\") catch |err| {\n            const msg = try std.fmt.allocPrint(self.allocator, \"Failed to get or create default mode: {s}\", .{@errorName(err)});\n            defer self.allocator.free(msg);\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        } orelse unreachable;\n        hotkey.add_mode(default_mode) catch |err| {\n            const msg = try std.fmt.allocPrint(self.allocator, \"Failed to add default mode to hotkey: {s}\", .{@errorName(err)});\n            defer self.allocator.free(msg);\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        };\n    }\n\n    var found_modifier = false;\n    var key_consumed = false;\n\n    if (self.peek_check(.Token_Alias)) {\n        const tok = self.peek().?;\n        const alias = try self.lookup_alias(tok);\n        switch (alias) {\n            .modifier => {\n                self.advance();\n                hotkey.flags = try self.parse_modifier();\n                found_modifier = true;\n            },\n            .key => |kp| {\n                self.advance();\n                try self.reject_key_alias_in_modifier_position(tok);\n                hotkey.flags = hotkey.flags.merge(kp.flags);\n                hotkey.key = kp.key;\n                key_consumed = true;\n            },\n        }\n    } else if (self.match(.Token_Modifier)) {\n        hotkey.flags = try self.parse_modifier();\n        found_modifier = true;\n    }\n\n    if (found_modifier) {\n        if (!self.match(.Token_Dash)) {\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected '-' after modifier\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n    }\n\n    if (!key_consumed) {\n        if (self.match(.Token_Key)) {\n            hotkey.key = try self.parse_key();\n        } else if (self.match(.Token_Key_Hex)) {\n            hotkey.key = try self.parse_key_hex();\n        } else if (self.match(.Token_Literal)) {\n            const keypress = try self.parse_key_literal();\n            hotkey.flags = hotkey.flags.merge(keypress.flags);\n            hotkey.key = keypress.key;\n        } else if (self.match(.Token_Alias)) {\n            const keypress = try self.resolve_key_alias(self.previous());\n            hotkey.flags = hotkey.flags.merge(keypress.flags);\n            hotkey.key = keypress.key;\n        } else {\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected key, key hex, or literal\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n    }\n\n    if (self.match(.Token_Arrow)) {\n        hotkey.flags = hotkey.flags.merge(.{ .passthrough = true });\n    }\n\n    if (self.match(.Token_Activate)) {\n        const mode_name = self.previous().text;\n\n        // Check if there's a command after the mode activation\n        if (self.match(.Token_Command)) {\n            const result = try self.parse_command();\n            defer if (result.owns_memory) self.allocator.free(result.command);\n            hotkey.add_process_activation(\"*\", mode_name, result.command) catch |err| {\n                try self.handleProcessError(err, \"*\", \"mode activation\");\n            };\n        } else {\n            hotkey.add_process_activation(\"*\", mode_name, null) catch |err| {\n                try self.handleProcessError(err, \"*\", \"mode activation\");\n            };\n        }\n    } else if (self.match(.Token_Forward)) {\n        const keypress = try self.parse_keypress();\n        hotkey.add_process_forward(\"*\", keypress) catch |err| {\n            try self.handleProcessError(err, \"*\", \"key forward\");\n        };\n    } else if (self.match(.Token_Command)) {\n        const result = try self.parse_command();\n        defer if (result.owns_memory) self.allocator.free(result.command);\n        hotkey.add_process_command(\"*\", result.command) catch |err| {\n            try self.handleProcessError(err, \"*\", \"command\");\n        };\n    } else if (self.match(.Token_Unbound)) {\n        // Simple unbound action: <keysym> ~\n        hotkey.add_process_unbound(\"*\") catch |err| {\n            try self.handleProcessError(err, \"*\", \"unbound action\");\n        };\n    } else if (self.match(.Token_BeginList)) {\n        try self.parse_proc_list(mappings, hotkey);\n    }\n\n    mappings.add_hotkey(hotkey) catch |err| {\n        if (err == error.DuplicateHotkeyInMode) {\n            // Format the hotkey for the error message\n            var buf: [256]u8 = undefined;\n            const key_str = try Keycodes.formatKeyPressBuffer(&buf, hotkey.flags, hotkey.key);\n\n            // Get the mode(s) where we're trying to add this hotkey\n            var mode_names = std.ArrayList(u8).init(self.allocator);\n            defer mode_names.deinit();\n\n            var it = hotkey.mode_list.iterator();\n            var first = true;\n            while (it.next()) |entry| {\n                if (!first) try mode_names.appendSlice(\", \");\n                try mode_names.appendSlice(entry.key_ptr.*.name);\n                first = false;\n            }\n\n            const msg = try std.fmt.allocPrint(self.allocator, \"Duplicate hotkey '{s}' already exists in mode '{s}'\", .{ key_str, mode_names.items });\n            defer self.allocator.free(msg);\n\n            // Use the key token for error location\n            const key_token = self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, key_token, msg, self.current_file_path);\n\n            return error.ParseErrorOccurred;\n        }\n        return err;\n    };\n}\n\nfn parse_mode(self: *Parser, mappings: *Mappings, hotkey: *Hotkey) !void {\n    const token: Token = self.previous();\n    assert(token.type == .Token_Identifier);\n\n    const name = token.text;\n    const mode = mappings.get_mode_or_create_default(name) catch |err| {\n        const msg = try std.fmt.allocPrint(self.allocator, \"Failed to get or create mode '{s}': {s}\", .{ name, @errorName(err) });\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    } orelse {\n        const msg = try std.fmt.allocPrint(self.allocator, \"Mode '{s}' not found. Did you forget to declare it with '::{s}'?\", .{ name, name });\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    };\n    hotkey.add_mode(mode) catch |err| {\n        const msg = try std.fmt.allocPrint(self.allocator, \"Failed to add mode '{s}' to hotkey: {s}\", .{ name, @errorName(err) });\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    };\n    if (self.match(.Token_Comma)) {\n        if (self.match(.Token_Identifier)) {\n            try self.parse_mode(mappings, hotkey);\n        } else {\n            const error_token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, error_token, \"Expected mode identifier after comma\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n    }\n}\n\nfn parse_modifier(self: *Parser) !ModifierFlag {\n    const token = self.previous();\n    var flags = ModifierFlag{};\n\n    if (token.type == .Token_Alias) {\n        const alias = try self.lookup_alias(token);\n        switch (alias) {\n            .modifier => |alias_flags| flags = flags.merge(alias_flags),\n            .key => {\n                const msg = try std.fmt.allocPrint(self.allocator, \"Alias '${s}' is a key alias, not a modifier. Use it after '-' (e.g., <mod> - ${s})\", .{ token.text, token.text });\n                defer self.allocator.free(msg);\n                self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n                return error.AliasTypeMismatch;\n            },\n        }\n    } else if (ModifierFlag.get(token.text)) |modifier_flags_value| {\n        flags = flags.merge(modifier_flags_value);\n    } else {\n        const msg = try std.fmt.allocPrint(self.allocator, \"Unknown modifier '{s}'\", .{token.text});\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n\n    if (self.match(.Token_Plus)) {\n        if (self.match(.Token_Modifier) or self.match(.Token_Alias)) {\n            flags = flags.merge(try self.parse_modifier());\n        } else {\n            const error_token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, error_token, \"Expected modifier or alias after '+'\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n    }\n    return flags;\n}\n\n/// Look up an alias by token text, emitting a parse error if undefined.\nfn lookup_alias(self: *Parser, token: Token) !Alias {\n    return self.aliases.get(token.text) orelse {\n        const msg = try std.fmt.allocPrint(self.allocator, \"Undefined alias '${s}'. Define it with '.alias ${s} <value>' before use\", .{ token.text, token.text });\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n        return error.UndefinedAlias;\n    };\n}\n\n/// Parse the right-hand side of `.alias $name <value>`. The value is\n/// either a modifier list (chained with `+`) or a single key. The kind\n/// is determined by the first token: a Token_Modifier (or Token_Alias\n/// resolving to a modifier) starts a modifier list; a Token_Key,\n/// Token_Key_Hex, or Token_Literal is a key; a Token_Alias may resolve\n/// to either kind and the new alias inherits that kind.\nfn parse_alias_value(self: *Parser) !Alias {\n    if (self.match(.Token_Modifier)) {\n        const flags = try self.parse_modifier();\n        return .{ .modifier = flags };\n    }\n    if (self.match(.Token_Alias)) {\n        const ref_token = self.previous();\n        const ref = try self.lookup_alias(ref_token);\n        switch (ref) {\n            .key => |kp| return .{ .key = kp },\n            .modifier => |alias_flags| {\n                var flags = alias_flags;\n                if (self.match(.Token_Plus)) {\n                    if (self.match(.Token_Modifier) or self.match(.Token_Alias)) {\n                        flags = flags.merge(try self.parse_modifier());\n                    } else {\n                        const tok = self.peek() orelse self.previous();\n                        self.error_info = try ParseError.fromToken(self.allocator, tok, \"Expected modifier or alias after '+'\", self.current_file_path);\n                        return error.ParseErrorOccurred;\n                    }\n                }\n                return .{ .modifier = flags };\n            },\n        }\n    }\n    if (self.match(.Token_Key)) {\n        return .{ .key = .{ .flags = .{}, .key = try self.parse_key() } };\n    }\n    if (self.match(.Token_Key_Hex)) {\n        return .{ .key = .{ .flags = .{}, .key = try self.parse_key_hex() } };\n    }\n    if (self.match(.Token_Literal)) {\n        return .{ .key = try self.parse_key_literal() };\n    }\n    const tok = self.peek() orelse self.previous();\n    self.error_info = try ParseError.fromToken(self.allocator, tok, \"Expected modifier, key, or alias after alias name (e.g., 'cmd + alt' or 'tab' or '0x32')\", self.current_file_path);\n    return error.ParseErrorOccurred;\n}\n\n/// After consuming a key alias in standalone position, fail loudly if the\n/// next token is `-` or `+` — that would mean the user wrote it as if it\n/// were a modifier alias, which is the most common confusion.\nfn reject_key_alias_in_modifier_position(self: *Parser, alias_token: Token) !void {\n    if (self.peek_check(.Token_Dash) or self.peek_check(.Token_Plus)) {\n        const msg = try std.fmt.allocPrint(self.allocator, \"Alias '${s}' is a key alias, not a modifier. Use it after '-' (e.g., <mod> - ${s})\", .{ alias_token.text, alias_token.text });\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, alias_token, msg, self.current_file_path);\n        return error.AliasTypeMismatch;\n    }\n}\n\n/// Resolve a Token_Alias used in key position. Errors if it's a modifier alias.\nfn resolve_key_alias(self: *Parser, token: Token) !Hotkey.KeyPress {\n    const alias = try self.lookup_alias(token);\n    switch (alias) {\n        .key => |kp| return kp,\n        .modifier => {\n            const msg = try std.fmt.allocPrint(self.allocator, \"Alias '${s}' is a modifier alias, not a key. Use it before '-' (e.g., ${s} - <key>)\", .{ token.text, token.text });\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n            return error.AliasTypeMismatch;\n        },\n    }\n}\n\nfn parse_key(self: *Parser) !u32 {\n    const token = self.previous();\n    const key = token.text;\n    const keycode = self.keycodes.get_keycode(key) catch |err| {\n        if (err == error.@\"Key not found\") {\n            const msg = try std.fmt.allocPrint(self.allocator, \"Unknown key '{s}'\", .{token.text});\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n        return err;\n    };\n    return keycode;\n}\n\nfn parse_key_hex(self: *Parser) !u32 {\n    const token = self.previous();\n    const key = token.text;\n\n    const code = std.fmt.parseInt(u32, key, 16) catch {\n        const msg = try std.fmt.allocPrint(self.allocator, \"Invalid hex keycode '0x{s}'. Expected a valid hexadecimal number (e.g., '0x24' for return key)\", .{token.text});\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    };\n    return code;\n}\n\nconst literal_keycode_str = @import(\"Keycodes.zig\").literal_keycode_str;\nconst literal_keycode_value = @import(\"Keycodes.zig\").literal_keycode_value;\n\nfn parse_key_literal(self: *Parser) !Hotkey.KeyPress {\n    const token = self.previous();\n    const key = token.text;\n    var flags = ModifierFlag{};\n    var keycode: u32 = 0;\n\n    for (literal_keycode_str, 0..) |literal_key, i| {\n        if (std.mem.eql(u8, key, literal_key)) {\n            if (i > Keycodes.KEY_HAS_IMPLICIT_FN_MOD and i < Keycodes.KEY_HAS_IMPLICIT_NX_MOD) {\n                // flags |= @intFromEnum(consts.hotkey_flag.Hotkey_Flag_Fn);\n                flags = flags.merge(.{ .@\"fn\" = true });\n            } else if (i >= Keycodes.KEY_HAS_IMPLICIT_NX_MOD and i < Keycodes.KEY_FIRST_MOUSE) {\n                // flags |= @intFromEnum(consts.hotkey_flag.Hotkey_Flag_NX);\n                flags = flags.merge(.{ .nx = true });\n            }\n            // Mouse buttons (i >= KEY_FIRST_MOUSE) carry no implicit modifier.\n            keycode = literal_keycode_value[i];\n            break;\n        }\n    } else {\n        const msg = try std.fmt.allocPrint(self.allocator, \"Unknown literal key '{s}'\", .{token.text});\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n\n    return Hotkey.KeyPress{ .flags = flags, .key = keycode };\n}\n\nfn parse_keypress(self: *Parser) !Hotkey.KeyPress {\n    var flags: ModifierFlag = .{};\n    var keycode: u32 = 0;\n    var found_modifier = false;\n    var key_consumed = false;\n\n    if (self.peek_check(.Token_Alias)) {\n        const tok = self.peek().?;\n        const alias = try self.lookup_alias(tok);\n        switch (alias) {\n            .modifier => {\n                self.advance();\n                flags = try self.parse_modifier();\n                found_modifier = true;\n            },\n            .key => |kp| {\n                self.advance();\n                try self.reject_key_alias_in_modifier_position(tok);\n                flags = flags.merge(kp.flags);\n                keycode = kp.key;\n                key_consumed = true;\n            },\n        }\n    } else if (self.match(.Token_Modifier)) {\n        flags = try self.parse_modifier();\n        found_modifier = true;\n    }\n\n    if (found_modifier and !self.match(.Token_Dash)) {\n        const token = self.peek() orelse self.previous();\n        self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected '-' after modifier\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n\n    if (!key_consumed) {\n        if (self.match(.Token_Key)) {\n            keycode = try self.parse_key();\n        } else if (self.match(.Token_Key_Hex)) {\n            keycode = try self.parse_key_hex();\n        } else if (self.match(.Token_Literal)) {\n            const keypress = try self.parse_key_literal();\n            flags = flags.merge(keypress.flags);\n            keycode = keypress.key;\n        } else if (self.match(.Token_Alias)) {\n            const keypress = try self.resolve_key_alias(self.previous());\n            flags = flags.merge(keypress.flags);\n            keycode = keypress.key;\n        } else {\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected key, key hex, or literal\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n    }\n\n    return Hotkey.KeyPress{ .flags = flags, .key = keycode };\n}\n\nfn parse_proc_list(self: *Parser, mappings: *Mappings, hotkey: *Hotkey) !void {\n    // std.debug.print(\"parse_proc_list: entering\\n\", .{});\n    if (self.match(.Token_String)) {\n        const name_token = self.previous();\n        const process_name = try self.processStringOwned(name_token.text);\n        defer self.allocator.free(process_name);\n        if (self.match(.Token_Command)) {\n            const result = try self.parse_command();\n            defer if (result.owns_memory) self.allocator.free(result.command);\n            hotkey.add_process_command(process_name, result.command) catch |err| {\n                try self.handleProcessError(err, process_name, \"command\");\n            };\n        } else if (self.match(.Token_Forward)) {\n            const keypress = try self.parse_keypress();\n            hotkey.add_process_forward(process_name, keypress) catch |err| {\n                try self.handleProcessError(err, process_name, \"key forward\");\n            };\n        } else if (self.match(.Token_Unbound)) {\n            hotkey.add_process_unbound(process_name) catch |err| {\n                try self.handleProcessError(err, process_name, \"unbound action\");\n            };\n        } else if (self.match(.Token_Activate)) {\n            // Process-specific mode activation\n            const mode_name = self.previous().text;\n            if (self.match(.Token_Command)) {\n                const result = try self.parse_command();\n                defer if (result.owns_memory) self.allocator.free(result.command);\n                hotkey.add_process_activation(process_name, mode_name, result.command) catch |err| {\n                    try self.handleProcessError(err, process_name, \"mode activation\");\n                };\n            } else {\n                hotkey.add_process_activation(process_name, mode_name, null) catch |err| {\n                    try self.handleProcessError(err, process_name, \"mode activation\");\n                };\n            }\n        } else {\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected command ':', forward '|', unbound '~', or mode activation ';' after process name\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n        try self.parse_proc_list(mappings, hotkey);\n    } else if (self.peek_check(.Token_Reference)) {\n        // Handle @group_name reference (command references aren't allowed here)\n        _ = self.advance();\n        const ref_token = self.previous();\n        const ref_name = ref_token.text;\n\n        // Check if it's incorrectly followed by parenthesis (command invocation syntax)\n        if (self.peek_check(.Token_BeginTuple)) {\n            // This is a syntax error - command invocations aren't allowed as process list entries\n            const msg = try std.fmt.allocPrint(self.allocator, \"Command invocation '@{s}(...)' not allowed here. Process list entries must be process names, wildcards, or process groups\", .{ref_name});\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, ref_token, msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n\n        if (self.process_groups.get(ref_name)) |processes| {\n            // It's a process group - now parse the action (command, forward, or unbound)\n            if (self.match(.Token_Command)) {\n                // Apply same command to all processes in the group\n                const result = try self.parse_command();\n                defer if (result.owns_memory) self.allocator.free(result.command);\n                for (processes) |process_name| {\n                    hotkey.add_process_command(process_name, result.command) catch |err| {\n                        try self.handleProcessError(err, process_name, \"command\");\n                    };\n                }\n            } else if (self.match(.Token_Forward)) {\n                const forward_key = try self.parse_keypress();\n                for (processes) |process_name| {\n                    hotkey.add_process_forward(process_name, forward_key) catch |err| {\n                        try self.handleProcessError(err, process_name, \"key forward\");\n                    };\n                }\n            } else if (self.match(.Token_Unbound)) {\n                for (processes) |process_name| {\n                    hotkey.add_process_unbound(process_name) catch |err| {\n                        try self.handleProcessError(err, process_name, \"unbound action\");\n                    };\n                }\n            } else if (self.match(.Token_Activate)) {\n                // Process group mode activation\n                const mode_name = self.previous().text;\n\n                // Check if there's a command after the mode activation\n                var activation_command: ?[]const u8 = null;\n                var did_own_command = false;\n                if (self.match(.Token_Command)) {\n                    const result = try self.parse_command();\n                    activation_command = result.command;\n                    did_own_command = result.owns_memory;\n                }\n                defer if (did_own_command and activation_command != null) {\n                    self.allocator.free(activation_command.?);\n                };\n\n                // Apply activation to all processes in the group\n                for (processes) |process_name| {\n                    hotkey.add_process_activation(process_name, mode_name, activation_command) catch |err| {\n                        try self.handleProcessError(err, process_name, \"mode activation\");\n                    };\n                }\n            } else {\n                const err_token = self.peek() orelse self.previous();\n                self.error_info = try ParseError.fromToken(self.allocator, err_token, \"Expected command ':', forward '|', unbound '~', or mode activation ';' after process group\", self.current_file_path);\n                return error.ParseErrorOccurred;\n            }\n        } else {\n            const msg = try std.fmt.allocPrint(self.allocator, \"Undefined process group '@{s}'\", .{ref_name});\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, ref_token, msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n        try self.parse_proc_list(mappings, hotkey);\n    } else if (self.match(.Token_Wildcard)) {\n        if (self.match(.Token_Command)) {\n            const result = try self.parse_command();\n            defer if (result.owns_memory) self.allocator.free(result.command);\n            hotkey.add_process_command(\"*\", result.command) catch |err| {\n                try self.handleProcessError(err, \"*\", \"command\");\n            };\n        } else if (self.match(.Token_Forward)) {\n            const keypress = try self.parse_keypress();\n            hotkey.add_process_forward(\"*\", keypress) catch |err| {\n                try self.handleProcessError(err, \"*\", \"key forward\");\n            };\n        } else if (self.match(.Token_Unbound)) {\n            hotkey.add_process_unbound(\"*\") catch |err| {\n                try self.handleProcessError(err, \"*\", \"unbound action\");\n            };\n        } else if (self.match(.Token_Activate)) {\n            // Wildcard mode activation\n            const mode_name = self.previous().text;\n            if (self.match(.Token_Command)) {\n                const result = try self.parse_command();\n                defer if (result.owns_memory) self.allocator.free(result.command);\n                hotkey.add_process_activation(\"*\", mode_name, result.command) catch |err| {\n                    try self.handleProcessError(err, \"*\", \"mode activation\");\n                };\n            } else {\n                hotkey.add_process_activation(\"*\", mode_name, null) catch |err| {\n                    try self.handleProcessError(err, \"*\", \"mode activation\");\n                };\n            }\n        } else {\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected command ':', forward '|', unbound '~', or mode activation ';' after wildcard\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n        try self.parse_proc_list(mappings, hotkey);\n    } else if (self.match(.Token_EndList)) {\n        if (hotkey.mappings.count() == 0) {\n            const token = self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Empty process list\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n    } else {\n        const token = self.peek() orelse self.previous();\n        self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected process name, wildcard '*' or ']'\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n}\n\nfn parse_mode_decl(self: *Parser, mappings: *Mappings) !void {\n    assert(self.match(.Token_Decl));\n    if (!self.match(.Token_Identifier)) {\n        const token = self.peek() orelse self.previous();\n        self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected mode name after '::'\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n    const token = self.previous();\n    const mode_name = token.text;\n    var mode = try Mode.init(self.allocator, mode_name);\n    errdefer mode.deinit();\n\n    if (self.match(.Token_Capture)) {\n        mode.capture = true;\n    }\n\n    if (self.match(.Token_Command)) {\n        const result = try self.parse_command();\n        defer if (result.owns_memory) self.allocator.free(result.command);\n        try mode.set_command(result.command);\n    }\n\n    if (mappings.get_mode_or_create_default(mode_name) catch |err| {\n        const msg = try std.fmt.allocPrint(self.allocator, \"Failed to get or create mode '{s}': {s}\", .{ mode_name, @errorName(err) });\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, self.previous(), msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    }) |existing_mode| {\n        if (std.mem.eql(u8, existing_mode.name, \"default\")) {\n            existing_mode.initialized = false;\n            existing_mode.capture = mode.capture;\n            if (mode.command) |cmd| try existing_mode.set_command(cmd);\n            mode.deinit(); // Clean up since we're not using this mode\n        } else if (std.mem.eql(u8, existing_mode.name, mode_name)) {\n            const msg = try std.fmt.allocPrint(self.allocator, \"Mode '{s}' already exists\", .{mode_name});\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, self.previous(), msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n    } else {\n        mappings.put_mode(mode) catch |err| {\n            const msg = try std.fmt.allocPrint(self.allocator, \"Failed to create mode '{s}': {s}\", .{ mode_name, @errorName(err) });\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, self.previous(), msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        };\n    }\n}\n\n/// Parse a command token, handling both regular commands and command references\n/// Returns the parsed command (caller owns memory if it's a reference)\n/// Throws error if command is empty without a reference\nfn parse_command(self: *Parser) !struct { command: []const u8, owns_memory: bool } {\n    const cmd_token = self.previous();\n    if (cmd_token.text.len == 0) {\n        if (self.peek_check(.Token_Reference)) {\n            // Empty command followed by reference\n            const command = try self.parse_command_reference();\n            return .{ .command = command, .owns_memory = true };\n        } else {\n            // Empty command with no reference is an error\n            const err_token = self.peek() orelse cmd_token;\n            self.error_info = try ParseError.fromToken(self.allocator, err_token, \"Expected command text or command reference after ':'\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n    } else {\n        // Regular command\n        return .{ .command = cmd_token.text, .owns_memory = false };\n    }\n}\n\nfn parse_command_reference(self: *Parser) ![]const u8 {\n    // We expect a Token_Reference\n    if (!self.match(.Token_Reference)) {\n        const token = self.peek() orelse self.previous();\n        self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected command reference\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n\n    const ref_token = self.previous();\n    const command_name = ref_token.text;\n\n    // Look up the command definition\n    const cmd_def = self.command_defs.get(command_name) orelse {\n        // Command not found - report error\n        const msg = try std.fmt.allocPrint(self.allocator, \"Command '@{s}' not found. Did you forget to define it with '.define {s} : ...'?\", .{ command_name, command_name });\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, ref_token, msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    };\n\n    // Check for opening parenthesis\n    if (self.match(.Token_BeginTuple)) {\n        // Parse arguments\n        var args = std.ArrayList([]const u8).init(self.allocator);\n        defer args.deinit();\n        defer for (args.items) |arg| {\n            self.allocator.free(arg);\n        };\n\n        while (true) {\n            if (self.match(.Token_EndTuple)) {\n                break;\n            }\n\n            if (self.match(.Token_String)) {\n                const arg_token = self.previous();\n                const processed_arg = try self.processStringOwned(arg_token.text);\n                try args.append(processed_arg);\n\n                // Check for comma or closing paren\n                if (self.peek_check(.Token_EndTuple)) {\n                    continue;\n                } else if (!self.match(.Token_Comma)) {\n                    const token = self.peek() orelse self.previous();\n                    const msg = try std.fmt.allocPrint(self.allocator, \"Expected ',' or ')' after argument in command '@{s}'\", .{command_name});\n                    defer self.allocator.free(msg);\n                    self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n                    return error.ParseErrorOccurred;\n                }\n            } else {\n                const token = self.peek() orelse self.previous();\n                const msg = try std.fmt.allocPrint(self.allocator, \"Command arguments must be enclosed in double quotes in '@{s}'\", .{command_name});\n                defer self.allocator.free(msg);\n                self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n                return error.ParseErrorOccurred;\n            }\n        }\n\n        // Validate argument count\n        if (args.items.len != cmd_def.max_placeholder) {\n            const msg = if (cmd_def.max_placeholder == 0)\n                try std.fmt.allocPrint(self.allocator, \"Command '@{s}' expects no arguments but {d} provided\", .{ command_name, args.items.len })\n            else if (args.items.len < cmd_def.max_placeholder)\n                try std.fmt.allocPrint(self.allocator, \"Command '@{s}' expects {d} arguments but only {d} provided\", .{ command_name, cmd_def.max_placeholder, args.items.len })\n            else\n                try std.fmt.allocPrint(self.allocator, \"Command '@{s}' expects {d} arguments but {d} provided\", .{ command_name, cmd_def.max_placeholder, args.items.len });\n            defer self.allocator.free(msg);\n\n            self.error_info = try ParseError.fromToken(self.allocator, ref_token, msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n\n        // Expand the template using pre-parsed parts\n        var result = std.ArrayList(u8).init(self.allocator);\n        errdefer result.deinit();\n\n        for (cmd_def.parts) |part| {\n            switch (part) {\n                .text => |text| try result.appendSlice(text),\n                .placeholder => |num| {\n                    if (num <= args.items.len) {\n                        try result.appendSlice(args.items[num - 1]);\n                    }\n                    // Note: placeholders > args.len are silently ignored\n                },\n            }\n        }\n\n        return try result.toOwnedSlice();\n    } else if (cmd_def.max_placeholder > 0) {\n        // Error: command expects arguments but none provided\n        const msg = try std.fmt.allocPrint(self.allocator, \"Command '@{s}' expects {d} arguments but none provided\", .{ command_name, cmd_def.max_placeholder });\n        defer self.allocator.free(msg);\n\n        self.error_info = try ParseError.fromToken(self.allocator, ref_token, msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    } else {\n        // No arguments needed, build from parts\n        var result = std.ArrayList(u8).init(self.allocator);\n        errdefer result.deinit();\n\n        for (cmd_def.parts) |part| {\n            switch (part) {\n                .text => |text| try result.appendSlice(text),\n                .placeholder => {}, // Should not have placeholders if max_placeholder is 0\n            }\n        }\n\n        return try result.toOwnedSlice();\n    }\n}\n\nfn parseCommandTemplate(self: *Parser, template: []const u8, token: Token) !CommandDef {\n    var parts = std.ArrayList(CommandDef.Part).init(self.allocator);\n    errdefer {\n        for (parts.items) |part| {\n            part.deinit(self.allocator);\n        }\n        parts.deinit();\n    }\n\n    var max_placeholder: u8 = 0;\n    var pos: usize = 0;\n\n    while (pos < template.len) {\n        // Look for placeholder start\n        if (pos + 3 < template.len and template[pos] == '{' and template[pos + 1] == '{') {\n            // Find the end\n            var j = pos + 2;\n            while (j < template.len and template[j] != '}') : (j += 1) {}\n\n            if (j + 1 < template.len and template[j] == '}' and template[j + 1] == '}') {\n                // Found a placeholder\n                const num_str = template[pos + 2 .. j];\n                if (num_str.len == 0) {\n                    const msg = try std.fmt.allocPrint(self.allocator, \"Invalid placeholder '{{{{}}}}' in command template. Placeholders must be numbers like {{{{1}}}}, {{{{2}}}}, etc.\", .{});\n                    defer self.allocator.free(msg);\n                    self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n                    return error.ParseErrorOccurred;\n                }\n\n                const num = std.fmt.parseInt(u8, num_str, 10) catch {\n                    const msg = try std.fmt.allocPrint(self.allocator, \"Invalid placeholder '{{{{{s}}}}}' in command template. Placeholders must be numbers like {{{{1}}}}, {{{{2}}}}, etc.\", .{num_str});\n                    defer self.allocator.free(msg);\n                    self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n                    return error.ParseErrorOccurred;\n                };\n\n                if (num == 0) {\n                    const msg = try std.fmt.allocPrint(self.allocator, \"Invalid placeholder '{{{{0}}}}' in command template. Placeholders must start from 1.\", .{});\n                    defer self.allocator.free(msg);\n                    self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n                    return error.ParseErrorOccurred;\n                }\n\n                try parts.append(.{ .placeholder = num });\n                if (num > max_placeholder) {\n                    max_placeholder = num;\n                }\n                pos = j + 2;\n                continue;\n            }\n        }\n\n        // Not a placeholder, find next placeholder or end\n        var end = pos + 1;\n        while (end < template.len) : (end += 1) {\n            if (end + 1 < template.len and template[end] == '{' and template[end + 1] == '{') {\n                break;\n            }\n        }\n\n        // Add text part\n        const text = try self.allocator.dupe(u8, template[pos..end]);\n        try parts.append(.{ .text = text });\n        pos = end;\n    }\n\n    return CommandDef{\n        .parts = try parts.toOwnedSlice(),\n        .max_placeholder = max_placeholder,\n    };\n}\n\nfn parse_option(self: *Parser, mappings: *Mappings) !void {\n    assert(self.match(.Token_Option));\n    const option = self.previous().text;\n\n    if (std.mem.eql(u8, option, \"alias\")) {\n        if (!self.match(.Token_Alias)) {\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected alias name with '$' prefix after 'alias' (e.g., '.alias $hyper cmd + alt')\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n        const name_token = self.previous();\n        const name = name_token.text;\n\n        if (self.aliases.contains(name)) {\n            const msg = try std.fmt.allocPrint(self.allocator, \"Alias '${s}' is already defined\", .{name});\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, name_token, msg, self.current_file_path);\n            return error.AliasAlreadyDefined;\n        }\n\n        const value = try self.parse_alias_value();\n\n        const owned_name = try self.allocator.dupe(u8, name);\n        errdefer self.allocator.free(owned_name);\n        try self.aliases.put(self.allocator, owned_name, value);\n    } else if (std.mem.eql(u8, option, \"define\")) {\n        // Parse name after define\n        if (!self.match(.Token_Identifier)) {\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected name after 'define'\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n\n        const name = self.previous().text;\n\n        // Check if this is a command definition or process group\n        if (self.match(.Token_Command)) {\n            // Command definition: define name : command\n            const command_token = self.previous();\n            const command_template = command_token.text;\n\n            // Parse template into parts\n            var parsed = try self.parseCommandTemplate(command_template, command_token);\n            errdefer parsed.deinit(self.allocator);\n\n            if (self.command_defs.contains(name)) {\n                self.error_info = try ParseError.fromToken(self.allocator, command_token, \"Command already defined\", self.current_file_path);\n                return error.CommandAlreadyDefined;\n            }\n\n            const owned_name = try self.allocator.dupe(u8, name);\n            try self.command_defs.put(self.allocator, owned_name, parsed);\n        } else if (self.match(.Token_BeginList)) {\n            // Process group definition: define name [\"app1\", \"app2\"]\n            var process_list = std.ArrayList([]const u8).init(self.allocator);\n            errdefer {\n                for (process_list.items) |process| self.allocator.free(process);\n                process_list.deinit();\n            }\n\n            while (self.match(.Token_String)) {\n                const process_name = try self.processStringOwned(self.previous().text);\n                try process_list.append(process_name);\n\n                // Skip optional comma\n                _ = self.match(.Token_Comma);\n            }\n\n            if (!self.match(.Token_EndList)) {\n                const token = self.peek() orelse self.previous();\n                self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected ']' to close process list\", self.current_file_path);\n                return error.ParseErrorOccurred;\n            }\n\n            if (self.process_groups.contains(name)) {\n                self.error_info = try ParseError.fromToken(self.allocator, self.previous(), \"Process group already defined\", self.current_file_path);\n                return error.ProcessGroupAlreadyDefined;\n            }\n\n            const owned_name = try self.allocator.dupe(u8, name);\n            const owned_processes = try process_list.toOwnedSlice();\n            try self.process_groups.put(self.allocator, owned_name, owned_processes);\n        } else {\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected ':' for command definition or '[' for process group after name\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n    } else if (std.mem.eql(u8, option, \"load\")) {\n        if (self.match(.Token_String)) {\n            const filename_token = self.previous();\n            const filename = try self.processStringOwned(filename_token.text);\n            try self.load_directives.append(LoadDirective{\n                .filename = filename,\n                .token = filename_token,\n            });\n        } else {\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected filename after 'load'\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n    } else if (std.mem.eql(u8, option, \"blacklist\")) {\n        if (self.match(.Token_BeginList)) {\n            while (self.match(.Token_String)) {\n                const token = self.previous();\n                const app_name = try self.processStringOwned(token.text);\n                defer self.allocator.free(app_name);\n                mappings.add_blacklist(app_name) catch |err| {\n                    const msg = try std.fmt.allocPrint(self.allocator, \"Failed to add '{s}' to blacklist: {s}\", .{ app_name, @errorName(err) });\n                    defer self.allocator.free(msg);\n                    self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n                    return error.ParseErrorOccurred;\n                };\n            }\n            if (!self.match(.Token_EndList)) {\n                const token = self.peek() orelse self.previous();\n                self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected ']' to close blacklist\", self.current_file_path);\n                return error.ParseErrorOccurred;\n            }\n        } else {\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected '[' after 'blacklist'\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n    } else if (std.mem.eql(u8, option, \"shell\") or std.mem.eql(u8, option, \"SHELL\")) {\n        if (self.match(.Token_String)) {\n            const shell_path = try self.processStringOwned(self.previous().text);\n            defer self.allocator.free(shell_path);\n            mappings.set_shell(shell_path) catch |err| {\n                const msg = try std.fmt.allocPrint(self.allocator, \"Failed to set shell to '{s}': {s}\", .{ shell_path, @errorName(err) });\n                defer self.allocator.free(msg);\n                self.error_info = try ParseError.fromToken(self.allocator, self.previous(), msg, self.current_file_path);\n                return error.ParseErrorOccurred;\n            };\n        } else {\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected shell path after 'shell'\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n    } else if (std.mem.eql(u8, option, \"device\")) {\n        try self.parse_device_decl(mappings);\n    } else if (std.mem.eql(u8, option, \"remap\")) {\n        try self.parse_remap_decl(mappings);\n    } else if (std.mem.eql(u8, option, \"path\")) {\n        // Two forms: single-entry `.path \"/opt/homebrew/bin\"` or list\n        // `.path [ \"/opt/homebrew/bin\", \"$HOME/.local/bin\" ]`. Tilde and\n        // $HOME are expanded at parse time so the stored entry is absolute.\n        if (self.match(.Token_BeginList)) {\n            while (self.match(.Token_String)) {\n                try self.addPathEntry(mappings, self.previous());\n            }\n            if (!self.match(.Token_EndList)) {\n                const token = self.peek() orelse self.previous();\n                self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected ']' to close path list\", self.current_file_path);\n                return error.ParseErrorOccurred;\n            }\n        } else if (self.match(.Token_String)) {\n            try self.addPathEntry(mappings, self.previous());\n        } else {\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected path string or '[' after 'path'\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n    } else {\n        const msg = try std.fmt.allocPrint(self.allocator, \"Unknown option '{s}'. Valid options are: alias, define, load, blacklist, shell, device, remap, path\", .{option});\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, self.previous(), msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n}\n\npub fn processLoadDirectives(self: *Parser, mappings: *Mappings) !void {\n    // Process each load directive\n    for (self.load_directives.items) |directive| {\n        const resolved_path = try self.resolveLoadPath(directive.filename);\n        defer self.allocator.free(resolved_path);\n\n        // Read the file content\n        const content = std.fs.cwd().readFileAlloc(self.allocator, resolved_path, 1 << 20) catch {\n            // Report error with line info from the .load directive\n            const msg = try std.fmt.allocPrint(self.allocator, \"Could not open included file '{s}'\", .{resolved_path});\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, directive.token, msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        };\n        defer self.allocator.free(content);\n\n        // Add the resolved path to the loaded files list\n        // Resolve to absolute path for hotloader\n        var path_buf: [std.fs.max_path_bytes]u8 = undefined;\n        const abs_path = std.fs.cwd().realpath(resolved_path, &path_buf) catch |err| {\n            const msg = try std.fmt.allocPrint(self.allocator, \"Failed to resolve path '{s}': {s}\", .{ resolved_path, @errorName(err) });\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, directive.token, msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        };\n        const duped_path = try self.allocator.dupe(u8, abs_path);\n        try mappings.loaded_files.append(self.allocator, duped_path);\n\n        // Create a new parser for the included file. We hand it the\n        // parent's parser-scoped definitions by value-move so included\n        // files can use aliases, commands, and process groups declared\n        // in the parent. After parsing, move the maps back so the\n        // parent owns any new entries the included file added.\n        var included_parser = try Parser.init(self.allocator);\n        included_parser.command_defs = self.command_defs;\n        included_parser.process_groups = self.process_groups;\n        included_parser.aliases = self.aliases;\n        self.command_defs = .empty;\n        self.process_groups = .empty;\n        self.aliases = .empty;\n        defer {\n            // Move maps back to the parent before the included\n            // parser's deinit so we don't double-free their entries.\n            self.command_defs = included_parser.command_defs;\n            self.process_groups = included_parser.process_groups;\n            self.aliases = included_parser.aliases;\n            included_parser.command_defs = .empty;\n            included_parser.process_groups = .empty;\n            included_parser.aliases = .empty;\n            included_parser.deinit();\n        }\n\n        // Parse the included file. Use `duped_path` (owned by\n        // mappings.loaded_files for the rest of the run) rather than\n        // `resolved_path` (freed at end of this iteration) so any\n        // ParseError.file_path slice points at long-lived memory.\n        // Hoist parse errors to our own error_info so the top-level\n        // caller (Skhd.init) logs the actual file:line:reason instead\n        // of a bare \"ParseErrorOccurred\".\n        included_parser.parseWithPath(mappings, content, duped_path) catch |err| {\n            if (included_parser.error_info) |info| {\n                self.error_info = info;\n                included_parser.error_info = null;\n            }\n            return err;\n        };\n\n        // Recursively process any load directives in the included file\n        included_parser.processLoadDirectives(mappings) catch |err| {\n            if (included_parser.error_info) |info| {\n                self.error_info = info;\n                included_parser.error_info = null;\n            }\n            return err;\n        };\n    }\n}\n\n/// Expand a `.path` entry: strip surrounding quotes (already handled by\n/// processStringOwned), resolve a leading `~` or `$HOME` to the user's home\n/// directory, and store the absolute path in mappings.paths. We deliberately\n/// don't support arbitrary `$VAR` — env at parse time can differ from env at\n/// command-exec time, and the user can write absolute paths for everything\n/// non-HOME.\nfn addPathEntry(self: *Parser, mappings: *Mappings, token: Token) !void {\n    const raw = try self.processStringOwned(token.text);\n    defer self.allocator.free(raw);\n\n    const expanded = expandHome(self.allocator, raw) catch |err| {\n        const msg = try std.fmt.allocPrint(self.allocator, \"Failed to expand path '{s}': {s}\", .{ raw, @errorName(err) });\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    };\n    defer self.allocator.free(expanded);\n\n    if (expanded.len == 0) {\n        self.error_info = try ParseError.fromToken(self.allocator, token, \"Empty path entry\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n\n    mappings.add_path(expanded) catch |err| {\n        const msg = try std.fmt.allocPrint(self.allocator, \"Failed to add path '{s}': {s}\", .{ expanded, @errorName(err) });\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    };\n}\n\nfn expandHome(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 {\n    // `~` alone, or `~/...` → $HOME / $HOME + suffix.\n    if (std.mem.eql(u8, raw, \"~\") or std.mem.startsWith(u8, raw, \"~/\")) {\n        const home = std.posix.getenv(\"HOME\") orelse return error.HomeNotSet;\n        if (raw.len == 1) return allocator.dupe(u8, home);\n        return std.fmt.allocPrint(allocator, \"{s}{s}\", .{ home, raw[1..] });\n    }\n    // `$HOME` or `$HOME/...` (no other $VAR forms supported).\n    if (std.mem.eql(u8, raw, \"$HOME\") or std.mem.startsWith(u8, raw, \"$HOME/\")) {\n        const home = std.posix.getenv(\"HOME\") orelse return error.HomeNotSet;\n        if (raw.len == 5) return allocator.dupe(u8, home);\n        return std.fmt.allocPrint(allocator, \"{s}{s}\", .{ home, raw[5..] });\n    }\n    return allocator.dupe(u8, raw);\n}\n\nfn resolveLoadPath(self: *Parser, filename: []const u8) ![]const u8 {\n    // If the path is absolute, return it as-is\n    if (std.fs.path.isAbsolute(filename)) {\n        return try self.allocator.dupe(u8, filename);\n    }\n\n    // If we have a current file path, resolve relative to its directory\n    if (self.current_file_path) |current_path| {\n        const dir_path = std.fs.path.dirname(current_path) orelse \".\";\n        return try std.fs.path.join(self.allocator, &[_][]const u8{ dir_path, filename });\n    }\n\n    // Otherwise, treat as relative to current working directory\n    return try self.allocator.dupe(u8, filename);\n}\n\n/// Build a comma-separated list of every HID name we accept in\n/// `.remap`. Used in error messages so the user can see the full set\n/// without reading source.\nfn formatHidKeyNames(allocator: std.mem.Allocator) ![]u8 {\n    var buf: std.ArrayListUnmanaged(u8) = .empty;\n    errdefer buf.deinit(allocator);\n    for (HidKeyMap.knownNames(), 0..) |n, i| {\n        if (i != 0) try buf.appendSlice(allocator, \", \");\n        try buf.appendSlice(allocator, n);\n    }\n    return buf.toOwnedSlice(allocator);\n}\n\n/// Parse `.remap <src> [device <alias>]` followed by either `:` (colon\n/// form, simple HID-level remap) or `{ ... }` (block form, tap-hold).\n/// Required device guard: global remaps would clobber other keyboards.\nfn parse_remap_decl(self: *Parser, mappings: *Mappings) !void {\n    const src_token = self.peek() orelse {\n        self.error_info = try ParseError.fromToken(self.allocator, self.previous(), \"Expected source key after '.remap'\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    };\n    switch (src_token.type) {\n        .Token_Literal, .Token_Modifier, .Token_Identifier, .Token_Key => {},\n        else => {\n            self.error_info = try ParseError.fromToken(self.allocator, src_token, \"Expected key name (e.g., caps_lock, lctrl, escape) as remap source\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        },\n    }\n    self.advance();\n\n    const src_usage = HidKeyMap.lookup(src_token.text) orelse {\n        const names = try formatHidKeyNames(self.allocator);\n        defer self.allocator.free(names);\n        const msg = try std.fmt.allocPrint(self.allocator, \"Source '{s}' has no HID-level mapping. Use HID-standard names (physical-position, layout-independent — different from skhd -o output). Available names: {s}\", .{ src_token.text, names });\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, src_token, msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    };\n\n    if (!self.match(.Token_BeginList)) {\n        const token = self.peek() orelse self.previous();\n        self.error_info = try ParseError.fromToken(self.allocator, token, \"'.remap' requires a [device <alias>] guard. Global remaps are not supported (would clobber other keyboards).\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n    if (!self.match(.Token_Identifier)) {\n        const token = self.peek() orelse self.previous();\n        self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected 'device' inside guard\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n    if (!std.mem.eql(u8, self.previous().text, \"device\")) {\n        self.error_info = try ParseError.fromToken(self.allocator, self.previous(), \"Expected 'device' keyword inside guard\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n    if (!self.match(.Token_Identifier)) {\n        const token = self.peek() orelse self.previous();\n        self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected device alias name after 'device'\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n    const alias_token = self.previous();\n    const alias_name = alias_token.text;\n    if (!mappings.device_aliases.contains(alias_name)) {\n        const msg = try std.fmt.allocPrint(self.allocator, \"Unknown device alias '{s}'. Declare it with '.device {s} {{ vendor: 0x..., product: 0x... }}' first.\", .{ alias_name, alias_name });\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, alias_token, msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n    if (!self.match(.Token_EndList)) {\n        const token = self.peek() orelse self.previous();\n        self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected ']' to close device guard\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n\n    // Branch: colon form (`.remap X [device d] : Y`) vs block form\n    // (`.remap X [device d] { tap: ..., hold: ..., ... }`).\n    if (self.peek_check(.Token_BeginBlock)) {\n        _ = self.advance();\n        try self.parse_remap_block_body(mappings, src_token, src_usage, alias_name);\n        return;\n    }\n\n    // Colon form (simple HID-level swap). Right-hand side is grabbed\n    // by Token_Command which runs to newline.\n    if (!self.match(.Token_Command)) {\n        const token = self.peek() orelse self.previous();\n        self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected ':' followed by destination key, or '{' for the tap-hold block form\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n    const dst_token = self.previous();\n    const dst_name = std.mem.trim(u8, dst_token.text, \" \\t\");\n    if (dst_name.len == 0) {\n        self.error_info = try ParseError.fromToken(self.allocator, dst_token, \"Empty destination — expected a key name (e.g., lctrl)\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n    const dst_usage = HidKeyMap.lookup(dst_name) orelse {\n        const names = try formatHidKeyNames(self.allocator);\n        defer self.allocator.free(names);\n        const msg = try std.fmt.allocPrint(self.allocator, \"Destination '{s}' has no HID-level mapping. Use HID-standard names (different from skhd -o output). Available names: {s}\", .{ dst_name, names });\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, dst_token, msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    };\n\n    mappings.add_remap(src_usage, dst_usage, alias_name) catch |err| {\n        if (err == error.RemapConflict) {\n            const msg = try std.fmt.allocPrint(self.allocator, \"Source key '{s}' is already claimed by another .remap or .remap{{}} on device '{s}'\", .{ src_token.text, alias_name });\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, src_token, msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n        return err;\n    };\n}\n\n/// Parse the body of `.remap X [device d] { ... }`. The opening `{`\n/// has already been consumed.\nfn parse_remap_block_body(self: *Parser, mappings: *Mappings, src_token: Token, src_usage: u32, alias_name: []const u8) !void {\n    var tap_usage: ?u32 = null;\n    var hold_usage: ?u32 = null;\n    var hold_target_text: []const u8 = \"\";\n    var timeout_ms: u32 = 200;\n    var permissive_hold: bool = true;\n    var hold_on_other_key_press: bool = false;\n    var retro_tap: bool = false;\n\n    while (!self.match(.Token_EndBlock)) {\n        if (!self.match(.Token_Identifier)) {\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected field name (tap, hold, timeout, permissive_hold, hold_on_other_key_press, retro_tap) or '}'\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n        const field_token = self.previous();\n        const field = field_token.text;\n\n        if (!self.match(.Token_Colon)) {\n            const token = self.peek() orelse self.previous();\n            const msg = try std.fmt.allocPrint(self.allocator, \"Expected ':' after field name '{s}'\", .{field});\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n\n        if (std.mem.eql(u8, field, \"tap\")) {\n            tap_usage = try self.parse_keysym_value();\n        } else if (std.mem.eql(u8, field, \"hold\")) {\n            // Capture the raw token text so we can emit a clear\n            // \"layer holds aren't wired up yet\" diagnostic for things\n            // like `hold : fn_layer` (a mode name, not a key).\n            const peeked = self.peek() orelse self.previous();\n            hold_target_text = peeked.text;\n            hold_usage = HidKeyMap.lookup(peeked.text) orelse blk: {\n                // Not a HID key — could be a layer name. Consume the\n                // token, leave hold_usage null, validate later.\n                self.advance();\n                break :blk null;\n            };\n            if (hold_usage != null) self.advance();\n        } else if (std.mem.eql(u8, field, \"timeout\")) {\n            timeout_ms = try self.parse_duration_ms();\n        } else if (std.mem.eql(u8, field, \"permissive_hold\")) {\n            permissive_hold = try self.parse_bool_on_off();\n        } else if (std.mem.eql(u8, field, \"hold_on_other_key_press\")) {\n            hold_on_other_key_press = try self.parse_bool_on_off();\n        } else if (std.mem.eql(u8, field, \"retro_tap\")) {\n            retro_tap = try self.parse_bool_on_off();\n        } else {\n            const msg = try std.fmt.allocPrint(self.allocator, \"Unknown field '{s}' in .remap block. Supported: tap, hold, timeout, permissive_hold, hold_on_other_key_press, retro_tap\", .{field});\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, field_token, msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n\n        _ = self.match(.Token_Comma);\n    }\n\n    if (tap_usage == null) {\n        self.error_info = try ParseError.fromToken(self.allocator, src_token, \"Missing required field 'tap' in .remap block\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n    var hold_layer: ?[]const u8 = null;\n    if (hold_usage == null) {\n        // Layer-hold case: `hold : <mode_name>` instead of a HID key.\n        // Look up the name in the mode_map; if it's not a known mode,\n        // surface a real error.\n        if (hold_target_text.len == 0 or !mappings.mode_map.contains(hold_target_text)) {\n            self.error_info = try ParseError.fromToken(self.allocator, src_token, \"Missing required field 'hold' in .remap block. (For a plain remap with no tap-vs-hold, use the colon form: .remap KEY [device D] : TARGET)\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n        hold_layer = hold_target_text;\n    }\n\n    mappings.add_taphold(.{\n        .src_usage = src_usage,\n        .tap_usage = tap_usage.?,\n        .hold_usage = hold_usage orelse 0,\n        .hold_layer = hold_layer,\n        .device_alias = alias_name,\n        .timeout_ms = timeout_ms,\n        .permissive_hold = permissive_hold,\n        .hold_on_other_key_press = hold_on_other_key_press,\n        .retro_tap = retro_tap,\n    }) catch |err| {\n        if (err == error.RemapConflict) {\n            const msg = try std.fmt.allocPrint(self.allocator, \"Source key '{s}' is already claimed by another .remap or .remap{{}} on device '{s}'\", .{ src_token.text, alias_name });\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, src_token, msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n        return err;\n    };\n}\n\nfn parse_keysym_value(self: *Parser) !u32 {\n    const token = self.peek() orelse {\n        self.error_info = try ParseError.fromToken(self.allocator, self.previous(), \"Expected key name\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    };\n    switch (token.type) {\n        .Token_Literal, .Token_Modifier, .Token_Identifier, .Token_Key => {},\n        else => {\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected key name (e.g., escape, lctrl, space)\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        },\n    }\n    self.advance();\n    return HidKeyMap.lookup(token.text) orelse {\n        const msg = try std.fmt.allocPrint(self.allocator, \"Unknown key '{s}'. Supported names live in src/HidKeyMap.zig.\", .{token.text});\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    };\n}\n\nfn parse_duration_ms(self: *Parser) !u32 {\n    if (!self.match(.Token_Key)) {\n        const token = self.peek() orelse self.previous();\n        self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected number for duration (e.g., 120 or 120ms)\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n    const num_token = self.previous();\n    const value = std.fmt.parseInt(u32, num_token.text, 10) catch {\n        const msg = try std.fmt.allocPrint(self.allocator, \"Invalid duration '{s}'\", .{num_token.text});\n        defer self.allocator.free(msg);\n        self.error_info = try ParseError.fromToken(self.allocator, num_token, msg, self.current_file_path);\n        return error.ParseErrorOccurred;\n    };\n    // Optional `ms` unit suffix. Accepted-and-ignored (timeouts already\n    // in ms). Reserved for future seconds support.\n    if (self.peek_check(.Token_Identifier)) {\n        if (self.peek()) |t| {\n            if (std.mem.eql(u8, t.text, \"ms\")) _ = self.advance();\n        }\n    }\n    return value;\n}\n\nfn parse_bool_on_off(self: *Parser) !bool {\n    if (!self.match(.Token_Identifier)) {\n        const token = self.peek() orelse self.previous();\n        self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected 'on' or 'off'\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n    const t = self.previous();\n    if (std.mem.eql(u8, t.text, \"on\") or std.mem.eql(u8, t.text, \"true\")) return true;\n    if (std.mem.eql(u8, t.text, \"off\") or std.mem.eql(u8, t.text, \"false\")) return false;\n    const msg = try std.fmt.allocPrint(self.allocator, \"Expected 'on' or 'off', got '{s}'\", .{t.text});\n    defer self.allocator.free(msg);\n    self.error_info = try ParseError.fromToken(self.allocator, t, msg, self.current_file_path);\n    return error.ParseErrorOccurred;\n}\n\n/// Parse `.device <name> { vendor: 0x..., product: 0x... }`.\nfn parse_device_decl(self: *Parser, mappings: *Mappings) !void {\n    if (!self.match(.Token_Identifier)) {\n        const token = self.peek() orelse self.previous();\n        self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected device alias name after '.device'\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n    const name_token = self.previous();\n    const name = name_token.text;\n\n    if (!self.match(.Token_BeginBlock)) {\n        const token = self.peek() orelse self.previous();\n        self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected '{' after device alias name (use form: .device <name> { vendor: 0x..., product: 0x... })\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n\n    var vendor: ?u32 = null;\n    var product: ?u32 = null;\n\n    while (!self.match(.Token_EndBlock)) {\n        if (!self.match(.Token_Identifier)) {\n            const token = self.peek() orelse self.previous();\n            self.error_info = try ParseError.fromToken(self.allocator, token, \"Expected field name (vendor / product) or '}'\", self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n        const field_token = self.previous();\n        const field_name = field_token.text;\n\n        if (!self.match(.Token_Colon)) {\n            const token = self.peek() orelse self.previous();\n            const msg = try std.fmt.allocPrint(self.allocator, \"Expected ':' after field name '{s}'\", .{field_name});\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n\n        if (!self.match(.Token_Key_Hex)) {\n            const token = self.peek() orelse self.previous();\n            const msg = try std.fmt.allocPrint(self.allocator, \"Expected hex value (e.g., 0x05AC) for field '{s}'\", .{field_name});\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n        const value = try self.parse_key_hex();\n\n        if (std.mem.eql(u8, field_name, \"vendor\")) {\n            if (vendor != null) {\n                self.error_info = try ParseError.fromToken(self.allocator, field_token, \"Duplicate field 'vendor'\", self.current_file_path);\n                return error.ParseErrorOccurred;\n            }\n            vendor = value;\n        } else if (std.mem.eql(u8, field_name, \"product\")) {\n            if (product != null) {\n                self.error_info = try ParseError.fromToken(self.allocator, field_token, \"Duplicate field 'product'\", self.current_file_path);\n                return error.ParseErrorOccurred;\n            }\n            product = value;\n        } else {\n            const msg = try std.fmt.allocPrint(self.allocator, \"Unknown field '{s}' in .device block. Supported fields: vendor, product\", .{field_name});\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, field_token, msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n\n        _ = self.match(.Token_Comma);\n    }\n\n    if (vendor == null) {\n        self.error_info = try ParseError.fromToken(self.allocator, name_token, \"Missing required field 'vendor' in .device block\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n    if (product == null) {\n        self.error_info = try ParseError.fromToken(self.allocator, name_token, \"Missing required field 'product' in .device block\", self.current_file_path);\n        return error.ParseErrorOccurred;\n    }\n\n    mappings.add_device_alias(name, vendor.?, product.?) catch |err| {\n        if (err == error.DeviceAliasAlreadyExists) {\n            const msg = try std.fmt.allocPrint(self.allocator, \"Device alias '{s}' already declared\", .{name});\n            defer self.allocator.free(msg);\n            self.error_info = try ParseError.fromToken(self.allocator, name_token, msg, self.current_file_path);\n            return error.ParseErrorOccurred;\n        }\n        return err;\n    };\n}\n\ntest \"init\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n}\n\ntest \"Parse\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings,\n        \\\\cmd + shift - h [\n        \\\\    \"notepad.exe\": echo \"notepad\"\n        \\\\    \"chrome.exe\": echo \"chrome\"\n        \\\\    \"firefox.exe\" | cmd + shift - h\n        \\\\    *: ~\n        \\\\]\n    );\n\n    // Verify the hotkey was created\n    try std.testing.expect(mappings.mode_map.get(\"default\").?.hotkey_map.count() == 1);\n}\n\ntest \"Parse mode decl\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings, \":: mode : command\");\n    // print(\"{s}\\n\", .{mappings});\n}\n\ntest \"Parse mode decl capture\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings, \":: mode @: command\");\n    // print(\"{s}\\n\", .{mappings});\n}\n\ntest \"double mode free\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings,\n        \\\\ ::game\n        \\\\ ::work\n        \\\\ game, work < ctrl + shift - h: echo\n    );\n    // print(\"{s}\\n\", .{mappings});\n}\n\ntest \"load directive\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Parse main content with .load directive\n    const main_content =\n        \\\\.load \"testdata/test_included.skhdrc\"\n        \\\\cmd - m : echo 'from main file'\n    ;\n\n    try parser.parse(&mappings, main_content);\n    try parser.processLoadDirectives(&mappings);\n\n    // Check that both hotkeys were loaded\n    try std.testing.expect(mappings.mode_map.get(\"default\").?.hotkey_map.count() >= 2);\n}\n\ntest \"load directive with cross-file mode reference\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Parse main content with mode definition and .load directive\n    const main_content =\n        \\\\:: mymode\n        \\\\.load \"testdata/test_included_mode.skhdrc\"\n        \\\\cmd - m : echo 'from main file'\n    ;\n\n    try parser.parse(&mappings, main_content);\n    try parser.processLoadDirectives(&mappings);\n\n    // Check that mode exists and has the hotkey from included file\n    try std.testing.expect(mappings.mode_map.contains(\"mymode\"));\n    const mode = mappings.mode_map.get(\"mymode\").?;\n    try std.testing.expect(mode.hotkey_map.count() > 0);\n}\n\ntest \"nested load directives\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Parse main content that loads nested1\n    const main_content =\n        \\\\.load \"testdata/test_nested1.skhdrc\"\n        \\\\cmd - m : echo 'from main'\n    ;\n\n    try parser.parse(&mappings, main_content);\n    try parser.processLoadDirectives(&mappings);\n\n    // Check that all three hotkeys were loaded\n    try std.testing.expect(mappings.mode_map.get(\"default\").?.hotkey_map.count() >= 3);\n}\n\ntest \"load directive with relative paths\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Parse main content that loads the loader from testdata directory\n    const main_content =\n        \\\\.load \"testdata/loader.skhdrc\"\n        \\\\cmd - m : echo 'from main'\n    ;\n\n    try parser.parseWithPath(&mappings, main_content, \"test.skhdrc\");\n    try parser.processLoadDirectives(&mappings);\n\n    // Check that all hotkeys were loaded (main + loader + sub)\n    try std.testing.expect(mappings.mode_map.get(\"default\").?.hotkey_map.count() >= 3);\n}\n\ntest \"load directive shares aliases with included files\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    const test_id = std.crypto.random.int(u32);\n    const include_path = try std.fmt.allocPrint(alloc, \"/tmp/skhd_alias_include_{d}.skhdrc\", .{test_id});\n    defer alloc.free(include_path);\n    defer std.fs.deleteFileAbsolute(include_path) catch {};\n\n    {\n        const file = try std.fs.createFileAbsolute(include_path, .{});\n        defer file.close();\n        try file.writeAll(\"$hyper - h : echo included\");\n    }\n\n    const main_content = try std.fmt.allocPrint(alloc,\n        \\\\.alias $hyper cmd + alt + ctrl + shift\n        \\\\.load \"{s}\"\n    , .{include_path});\n    defer alloc.free(main_content);\n\n    try parser.parse(&mappings, main_content);\n    try parser.processLoadDirectives(&mappings);\n\n    const default = mappings.mode_map.get(\"default\").?;\n    try std.testing.expectEqual(@as(usize, 1), default.hotkey_map.count());\n    var it = default.hotkey_map.iterator();\n    const hk = it.next().?.key_ptr.*;\n    const expected = ModifierFlag{ .cmd = true, .alt = true, .control = true, .shift = true };\n    try std.testing.expectEqual(@as(u32, @bitCast(expected)), @as(u32, @bitCast(hk.flags)));\n}\n\ntest \"config duplicate detection allows disjoint side-specific modifiers\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings,\n        \\\\lcmd - a : echo left\n        \\\\rcmd - a : echo right\n    );\n\n    const default = mappings.mode_map.get(\"default\").?;\n    try std.testing.expectEqual(@as(usize, 2), default.hotkey_map.count());\n}\n\ntest \"shell directive\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Test default shell (should be from $SHELL env or /bin/bash)\n    const default_shell = mappings.shell;\n    try std.testing.expect(default_shell.len > 0);\n\n    // Parse config with .shell directive\n    const content =\n        \\\\.shell \"/bin/zsh\"\n        \\\\cmd - t : echo \"test\"\n    ;\n    try parser.parse(&mappings, content);\n\n    // Verify shell was updated\n    try std.testing.expectEqualStrings(\"/bin/zsh\", mappings.shell);\n}\n\ntest \"shell directive with spaces\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Parse config with .shell directive containing spaces\n    const content =\n        \\\\.shell \"/usr/local/bin/fish\"\n        \\\\cmd - f : echo \"fish shell\"\n    ;\n    try parser.parse(&mappings, content);\n\n    // Verify shell was updated\n    try std.testing.expectEqualStrings(\"/usr/local/bin/fish\", mappings.shell);\n}\n\ntest \"shell directive error handling\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Test missing shell path\n    const content = \".shell\";\n    try std.testing.expectError(error.ParseErrorOccurred, parser.parse(&mappings, content));\n}\n\ntest \"path directive single entry\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings, \".path \\\"/opt/homebrew/bin\\\"\");\n    try std.testing.expectEqual(@as(usize, 1), mappings.paths.items.len);\n    try std.testing.expectEqualStrings(\"/opt/homebrew/bin\", mappings.paths.items[0]);\n}\n\ntest \"path directive list form\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    const content =\n        \\\\.path [\n        \\\\    \"/opt/homebrew/bin\"\n        \\\\    \"/usr/local/bin\"\n        \\\\]\n    ;\n    try parser.parse(&mappings, content);\n    try std.testing.expectEqual(@as(usize, 2), mappings.paths.items.len);\n    try std.testing.expectEqualStrings(\"/opt/homebrew/bin\", mappings.paths.items[0]);\n    try std.testing.expectEqualStrings(\"/usr/local/bin\", mappings.paths.items[1]);\n}\n\ntest \"path directive expands tilde and HOME\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    const home = std.posix.getenv(\"HOME\") orelse return error.SkipZigTest;\n\n    try parser.parse(&mappings,\n        \\\\.path \"~/.local/bin\"\n        \\\\.path \"$HOME/bin\"\n    );\n    try std.testing.expectEqual(@as(usize, 2), mappings.paths.items.len);\n\n    const tilde_expanded = try std.fmt.allocPrint(alloc, \"{s}/.local/bin\", .{home});\n    defer alloc.free(tilde_expanded);\n    try std.testing.expectEqualStrings(tilde_expanded, mappings.paths.items[0]);\n\n    const home_expanded = try std.fmt.allocPrint(alloc, \"{s}/bin\", .{home});\n    defer alloc.free(home_expanded);\n    try std.testing.expectEqualStrings(home_expanded, mappings.paths.items[1]);\n}\n\ntest \"path directive missing argument errors\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try std.testing.expectError(error.ParseErrorOccurred, parser.parse(&mappings, \".path\"));\n}\n\ntest \"command definition without placeholders\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Define a simple command\n    const content =\n        \\\\.define focus_recent : yabai -m window --focus recent || yabai -m space --focus recent\n        \\\\cmd - tab : @focus_recent\n    ;\n    try parser.parse(&mappings, content);\n\n    // Verify command was defined\n    try std.testing.expect(parser.command_defs.contains(\"focus_recent\"));\n\n    // Verify hotkey was created with expanded command\n    try std.testing.expect(mappings.mode_map.get(\"default\").?.hotkey_map.count() == 1);\n\n    // Verify command expanded correctly\n    const ctx = Hotkey.KeyboardLookupContext{};\n    const keypress = Hotkey.KeyPress{ .flags = .{ .cmd = true }, .key = 0x30 }; // tab key\n    const hotkey = mappings.mode_map.get(\"default\").?.hotkey_map.getKeyAdapted(keypress, ctx).?;\n    const cmd = hotkey.find_command_for_process(\"\").?;\n    try std.testing.expectEqualSlices(u8, \"yabai -m window --focus recent || yabai -m space --focus recent\", cmd.command);\n}\n\ntest \"command definition with single placeholder\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Define a command with one placeholder\n    const content =\n        \\\\.define yabai_focus : yabai -m window --focus {{1}} || yabai -m display --focus {{1}}\n        \\\\lcmd - h : @yabai_focus(\"west\")\n        \\\\lcmd - j : @yabai_focus(\"south\")\n    ;\n    try parser.parse(&mappings, content);\n\n    // Verify command was defined with correct max_placeholder\n    try std.testing.expect(parser.command_defs.contains(\"yabai_focus\"));\n    const cmd_def = parser.command_defs.get(\"yabai_focus\").?;\n    try std.testing.expectEqual(@as(u8, 1), cmd_def.max_placeholder);\n\n    // Verify hotkeys were created\n    try std.testing.expect(mappings.mode_map.get(\"default\").?.hotkey_map.count() == 2);\n\n    // Verify hotkeys expand to correct commands\n    const ctx = Hotkey.KeyboardLookupContext{};\n    const keypress1 = Hotkey.KeyPress{ .flags = .{ .lcmd = true }, .key = c.kVK_ANSI_H };\n    const hotkey1 = mappings.mode_map.get(\"default\").?.hotkey_map.getKeyAdapted(keypress1, ctx).?;\n    const cmd1 = hotkey1.find_command_for_process(\"\").?;\n    try std.testing.expectEqualSlices(u8, \"yabai -m window --focus west || yabai -m display --focus west\", cmd1.command);\n    const keypress2 = Hotkey.KeyPress{ .flags = .{ .lcmd = true }, .key = c.kVK_ANSI_J };\n    const hotkey2 = mappings.mode_map.get(\"default\").?.hotkey_map.getKeyAdapted(keypress2, ctx).?;\n    const cmd2 = hotkey2.find_command_for_process(\"\").?;\n    try std.testing.expectEqualSlices(u8, \"yabai -m window --focus south || yabai -m display --focus south\", cmd2.command);\n}\n\ntest \"command definition with multiple placeholders\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Define a command with multiple placeholders\n    const content =\n        \\\\.define window_action : yabai -m window --{{1}} {{2}} || yabai -m display --{{1}} {{2}}\n        \\\\cmd + shift - h : @window_action(\"swap\", \"west\")\n        \\\\cmd + shift - j : @window_action(\"swap\", \"south\")\n    ;\n    try parser.parse(&mappings, content);\n\n    // Verify command was defined with correct max_placeholder\n    try std.testing.expect(parser.command_defs.contains(\"window_action\"));\n    const cmd_def = parser.command_defs.get(\"window_action\").?;\n    try std.testing.expectEqual(@as(u8, 2), cmd_def.max_placeholder);\n\n    // Verify hotkeys were created\n    try std.testing.expect(mappings.mode_map.get(\"default\").?.hotkey_map.count() == 2);\n\n    // Verify commands expanded correctly\n    const ctx = Hotkey.KeyboardLookupContext{};\n    const keypress1 = Hotkey.KeyPress{ .flags = .{ .cmd = true, .shift = true }, .key = c.kVK_ANSI_H };\n    const hotkey1 = mappings.mode_map.get(\"default\").?.hotkey_map.getKeyAdapted(keypress1, ctx).?;\n    const cmd1 = hotkey1.find_command_for_process(\"\").?;\n    try std.testing.expectEqualSlices(u8, \"yabai -m window --swap west || yabai -m display --swap west\", cmd1.command);\n\n    const keypress2 = Hotkey.KeyPress{ .flags = .{ .cmd = true, .shift = true }, .key = c.kVK_ANSI_J };\n    const hotkey2 = mappings.mode_map.get(\"default\").?.hotkey_map.getKeyAdapted(keypress2, ctx).?;\n    const cmd2 = hotkey2.find_command_for_process(\"\").?;\n    try std.testing.expectEqualSlices(u8, \"yabai -m window --swap south || yabai -m display --swap south\", cmd2.command);\n}\n\ntest \"command definition with repeated placeholders\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Define a command where same placeholder appears multiple times\n    const content =\n        \\\\.define notify : osascript -e 'display notification \"{{1}}\" with title \"{{1}}\"'\n        \\\\cmd - n : @notify(\"Test Message\")\n    ;\n    try parser.parse(&mappings, content);\n\n    // Verify command was defined\n    try std.testing.expect(parser.command_defs.contains(\"notify\"));\n\n    // Verify hotkey was created\n    try std.testing.expect(mappings.mode_map.get(\"default\").?.hotkey_map.count() == 1);\n\n    // Verify placeholder replaced correctly in both locations\n    const ctx = Hotkey.KeyboardLookupContext{};\n    const keypress = Hotkey.KeyPress{ .flags = .{ .cmd = true }, .key = c.kVK_ANSI_N };\n    const hotkey = mappings.mode_map.get(\"default\").?.hotkey_map.getKeyAdapted(keypress, ctx).?;\n    const cmd = hotkey.find_command_for_process(\"\").?;\n    try std.testing.expectEqualSlices(u8, \"osascript -e 'display notification \\\"Test Message\\\" with title \\\"Test Message\\\"'\", cmd.command);\n}\n\ntest \"command definition error: wrong argument count\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Define a command expecting 2 arguments but provide only 1\n    const content =\n        \\\\.define window_action : yabai -m window --{{1}} {{2}}\n        \\\\cmd - h : @window_action(\"swap\")\n    ;\n    // Should fail with ParseError\n    try std.testing.expectError(error.ParseErrorOccurred, parser.parse(&mappings, content));\n\n    // Verify error message\n    const error_info = parser.error_info.?;\n    try std.testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, \"expects 2 arguments but only 1 provided\"));\n}\n\ntest \"command definition error: missing arguments\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Define a command expecting arguments but provide none\n    const content =\n        \\\\.define yabai_focus : yabai -m window --focus {{1}}\n        \\\\cmd - h : @yabai_focus\n    ;\n    // Should fail with ParseError\n    try std.testing.expectError(error.ParseErrorOccurred, parser.parse(&mappings, content));\n\n    // Verify error message\n    const error_info = parser.error_info.?;\n    try std.testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, \"expects 1 arguments but none provided\"));\n}\n\ntest \"command definition error: too many arguments\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Define a command expecting 1 argument but provide 2\n    const content =\n        \\\\.define yabai_focus : yabai -m window --focus {{1}}\n        \\\\cmd - h : @yabai_focus(\"west\", \"extra\")\n    ;\n\n    // Should fail with ParseError\n    try std.testing.expectError(error.ParseErrorOccurred, parser.parse(&mappings, content));\n\n    // Verify error message\n    const error_info = parser.error_info.?;\n    try std.testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, \"expects 1 arguments but 2 provided\"));\n}\n\ntest \"command definition error: undefined command\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Try to use undefined command\n    const content =\n        \\\\cmd - h : @undefined_command(\"arg\")\n    ;\n\n    // Should fail with ParseError\n    try std.testing.expectError(error.ParseErrorOccurred, parser.parse(&mappings, content));\n\n    // Verify error message\n    const error_info = parser.error_info.?;\n    try std.testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, \"Command '@undefined_command' not found\"));\n    try std.testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, \".define undefined_command\"));\n}\n\ntest \"command definition error: unquoted arguments\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Define a command and try to use with unquoted arguments\n    const content =\n        \\\\.define toggle : open -a \"{{1}}\"\n        \\\\cmd - h : @toggle(Firefox)\n    ;\n\n    // Should fail with ParseError\n    try std.testing.expectError(error.ParseErrorOccurred, parser.parse(&mappings, content));\n\n    // Verify error message\n    const error_info = parser.error_info.?;\n    try std.testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, \"must be enclosed in double quotes\"));\n}\n\ntest \"command definition with escape sequences\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Define command and use with escaped quotes\n    const content =\n        \\\\.define notify : osascript -e 'display notification \"{{2}}\" with title \"{{1}}\"'\n        \\\\cmd - n : @notify(\"Test\", \"Message with \\\"quotes\\\"\")\n    ;\n    try parser.parse(&mappings, content);\n\n    // Verify command was defined and hotkey created\n    try std.testing.expect(parser.command_defs.contains(\"notify\"));\n    try std.testing.expect(mappings.mode_map.get(\"default\").?.hotkey_map.count() == 1);\n\n    // Verify escape sequences are processed correctly\n    const ctx = Hotkey.KeyboardLookupContext{};\n    const keypress = Hotkey.KeyPress{ .flags = .{ .cmd = true }, .key = c.kVK_ANSI_N };\n    const hotkey = mappings.mode_map.get(\"default\").?.hotkey_map.getKeyAdapted(keypress, ctx).?;\n    const cmd = hotkey.find_command_for_process(\"\").?;\n    try std.testing.expectEqualSlices(u8, \"osascript -e 'display notification \\\"Message with \\\"quotes\\\"\\\" with title \\\"Test\\\"'\", cmd.command);\n}\n\ntest \"command definition with comma-separated arguments\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Test with optional commas between arguments\n    const content =\n        \\\\.define resize_win : yabai -m window --resize {{1}}:{{2}}:{{3}}\n        \\\\cmd + ctrl + shift - k : @resize_win(\"top\", \"0\", \"-10\")\n        \\\\cmd + ctrl + shift - j : @resize_win(\"bottom\",\"0\",\"10\")\n    ;\n    try parser.parse(&mappings, content);\n\n    // Verify command was defined and hotkeys created\n    try std.testing.expect(parser.command_defs.contains(\"resize_win\"));\n    try std.testing.expect(mappings.mode_map.get(\"default\").?.hotkey_map.count() == 2);\n\n    // Verify commands expanded correctly\n    const ctx = Hotkey.KeyboardLookupContext{};\n    const keypress1 = Hotkey.KeyPress{ .flags = .{ .cmd = true, .control = true, .shift = true }, .key = c.kVK_ANSI_K };\n    const hotkey1 = mappings.mode_map.get(\"default\").?.hotkey_map.getKeyAdapted(keypress1, ctx).?;\n    const cmd1 = hotkey1.find_command_for_process(\"\").?;\n    try std.testing.expectEqualSlices(u8, \"yabai -m window --resize top:0:-10\", cmd1.command);\n\n    const keypress2 = Hotkey.KeyPress{ .flags = .{ .cmd = true, .control = true, .shift = true }, .key = c.kVK_ANSI_J };\n    const hotkey2 = mappings.mode_map.get(\"default\").?.hotkey_map.getKeyAdapted(keypress2, ctx).?;\n    const cmd2 = hotkey2.find_command_for_process(\"\").?;\n    try std.testing.expectEqualSlices(u8, \"yabai -m window --resize bottom:0:10\", cmd2.command);\n}\n\ntest \"command definition with whitespace handling\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Test whitespace in arguments and around parentheses\n    const content =\n        \\\\.define toggle_app : yabai -m window --toggle {{1}} || open -a \"{{1}}\"\n        \\\\ralt - m : @toggle_app(  \"YT Music\"  )\n        \\\\ralt - n : @toggle_app(\"Notes\")\n    ;\n    try parser.parse(&mappings, content);\n\n    // Verify command was defined and hotkeys created\n    try std.testing.expect(parser.command_defs.contains(\"toggle_app\"));\n    try std.testing.expect(mappings.mode_map.get(\"default\").?.hotkey_map.count() == 2);\n\n    // Verify whitespace is trimmed from arguments\n    const ctx = Hotkey.KeyboardLookupContext{};\n    const keypress1 = Hotkey.KeyPress{ .flags = .{ .ralt = true }, .key = c.kVK_ANSI_M };\n    const hotkey1 = mappings.mode_map.get(\"default\").?.hotkey_map.getKeyAdapted(keypress1, ctx).?;\n    const cmd1 = hotkey1.find_command_for_process(\"\").?;\n    try std.testing.expectEqualSlices(u8, \"yabai -m window --toggle YT Music || open -a \\\"YT Music\\\"\", cmd1.command);\n}\n\ntest \"command definition complex placeholders\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Test non-sequential placeholders and highest placeholder detection\n    const content =\n        \\\\.define complex : echo {{3}} {{1}} {{3}} {{2}}\n        \\\\cmd - c : @complex(\"first\", \"second\", \"third\")\n    ;\n    try parser.parse(&mappings, content);\n\n    // Verify max_placeholder is correctly detected as 3\n    const cmd_def = parser.command_defs.get(\"complex\").?;\n    try std.testing.expectEqual(@as(u8, 3), cmd_def.max_placeholder);\n\n    // Verify placeholders expanded in correct order\n    const ctx = Hotkey.KeyboardLookupContext{};\n    const keypress = Hotkey.KeyPress{ .flags = .{ .cmd = true }, .key = c.kVK_ANSI_C };\n    const hotkey = mappings.mode_map.get(\"default\").?.hotkey_map.getKeyAdapted(keypress, ctx).?;\n    const cmd = hotkey.find_command_for_process(\"\").?;\n    try std.testing.expectEqualSlices(u8, \"echo third first third second\", cmd.command);\n}\n\ntest \"process group in hotkey with command expansion\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Test command expansion within process lists\n    const content =\n        \\\\.define toggle : open -a \"{{1}}\"\n        \\\\cmd - a [\n        \\\\    \"firefox\" : @toggle(\"Firefox\")\n        \\\\    \"chrome\" : @toggle(\"Google Chrome\")\n        \\\\]\n    ;\n    try parser.parse(&mappings, content);\n\n    // Verify command was defined and hotkey created\n    try std.testing.expect(parser.command_defs.contains(\"toggle\"));\n    try std.testing.expect(mappings.mode_map.get(\"default\").?.hotkey_map.count() == 1);\n\n    // Verify commands expanded for different processes\n    const ctx = Hotkey.KeyboardLookupContext{};\n    const keypress = Hotkey.KeyPress{ .flags = .{ .cmd = true }, .key = c.kVK_ANSI_A };\n    const hotkey = mappings.mode_map.get(\"default\").?.hotkey_map.getKeyAdapted(keypress, ctx).?;\n\n    const firefox_cmd = hotkey.find_command_for_process(\"firefox\").?;\n    try std.testing.expectEqualSlices(u8, \"open -a \\\"Firefox\\\"\", firefox_cmd.command);\n\n    const chrome_cmd = hotkey.find_command_for_process(\"chrome\").?;\n    try std.testing.expectEqualSlices(u8, \"open -a \\\"Google Chrome\\\"\", chrome_cmd.command);\n}\n\ntest \"error on command invocation in process list\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Try to use command invocation as process list entry - should fail\n    const content =\n        \\\\.define toggle : open -a \"{{1}}\"\n        \\\\cmd - a [\n        \\\\    @toggle(\"Firefox\") : echo \"This should fail\"\n        \\\\]\n    ;\n\n    // Should fail with ParseError\n    try std.testing.expectError(error.ParseErrorOccurred, parser.parse(&mappings, content));\n\n    // Verify error message\n    const error_info = parser.error_info.?;\n    try std.testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, \"Command invocation\"));\n    try std.testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, \"not allowed here\"));\n}\n\ntest \"error on undefined process group in process list\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Try to use undefined process group - should fail\n    const content =\n        \\\\.define toggle : open -a \"{{1}}\"\n        \\\\cmd - a [\n        \\\\    @toggle : echo \"This should fail\"\n        \\\\]\n    ;\n\n    // Should fail with ParseError\n    try std.testing.expectError(error.ParseErrorOccurred, parser.parse(&mappings, content));\n\n    // Verify error message\n    const error_info = parser.error_info.?;\n    try std.testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, \"Undefined process group\"));\n    try std.testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, \"@toggle\"));\n}\n\ntest \"valid process group in process list\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Define process group and use it correctly\n    const content =\n        \\\\.define browsers [\"Firefox\", \"Chrome\", \"Safari\"]\n        \\\\cmd - b [\n        \\\\    @browsers : echo \"Browser hotkey\"\n        \\\\    * : echo \"Default\"\n        \\\\]\n    ;\n\n    try parser.parse(&mappings, content);\n\n    // Should parse successfully\n    try std.testing.expect(mappings.mode_map.get(\"default\").?.hotkey_map.count() == 1);\n\n    // Verify commands are set for all browsers\n    const ctx = Hotkey.KeyboardLookupContext{};\n    const keypress = Hotkey.KeyPress{ .flags = .{ .cmd = true }, .key = c.kVK_ANSI_B };\n    const hotkey = mappings.mode_map.get(\"default\").?.hotkey_map.getKeyAdapted(keypress, ctx).?;\n\n    const firefox_cmd = hotkey.find_command_for_process(\"Firefox\").?;\n    try std.testing.expectEqualSlices(u8, \"echo \\\"Browser hotkey\\\"\", firefox_cmd.command);\n\n    const chrome_cmd = hotkey.find_command_for_process(\"Chrome\").?;\n    try std.testing.expectEqualSlices(u8, \"echo \\\"Browser hotkey\\\"\", chrome_cmd.command);\n\n    const safari_cmd = hotkey.find_command_for_process(\"Safari\").?;\n    try std.testing.expectEqualSlices(u8, \"echo \\\"Browser hotkey\\\"\", safari_cmd.command);\n\n    const default_cmd = hotkey.find_command_for_process(\"other_app\").?;\n    try std.testing.expectEqualSlices(u8, \"echo \\\"Default\\\"\", default_cmd.command);\n}\n\ntest \"invalid placeholder in command definition\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // Test various invalid placeholders\n    const test_cases = .{\n        .{ .content = \".define bad : echo {{a}}\", .expected_error = \"Invalid placeholder '{{a}}'\" },\n        .{ .content = \".define bad : echo {{}}\", .expected_error = \"Invalid placeholder '{{}}'\" },\n        .{ .content = \".define bad : echo {{0}}\", .expected_error = \"Invalid placeholder '{{0}}'\" },\n        .{ .content = \".define bad : echo {{1a}}\", .expected_error = \"Invalid placeholder '{{1a}}'\" },\n        .{ .content = \".define bad : echo {{-1}}\", .expected_error = \"Invalid placeholder '{{-1}}'\" },\n    };\n\n    inline for (test_cases) |test_case| {\n        parser.clearError();\n\n        // Should fail with ParseError\n        const result = parser.parse(&mappings, test_case.content);\n        try std.testing.expectError(error.ParseErrorOccurred, result);\n\n        // Verify error message contains expected text\n        const error_info = parser.error_info.?;\n        try std.testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, test_case.expected_error));\n    }\n}\n\ntest \"duplicate command definition returns error\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // First add a command definition\n    const content1 = \".define test_cmd : echo first\";\n    try parser.parse(&mappings, content1);\n\n    // Verify it was added\n    try std.testing.expect(parser.command_defs.contains(\"test_cmd\"));\n\n    // Now try to add a duplicate\n    const content2 = \".define test_cmd : echo second\";\n    const result = parser.parse(&mappings, content2);\n    try std.testing.expectError(error.CommandAlreadyDefined, result);\n\n    // Verify the original is still there\n    try std.testing.expect(parser.command_defs.contains(\"test_cmd\"));\n}\n\ntest \"duplicate process group definition returns error\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    // First add a process group\n    const content1 = \".define browsers [\\\"firefox\\\", \\\"chrome\\\"]\";\n    try parser.parse(&mappings, content1);\n\n    // Verify it was added\n    try std.testing.expect(parser.process_groups.contains(\"browsers\"));\n\n    // Now try to add a duplicate\n    const content2 = \".define browsers [\\\"safari\\\", \\\"edge\\\"]\";\n    const result = parser.parse(&mappings, content2);\n    try std.testing.expectError(error.ProcessGroupAlreadyDefined, result);\n\n    // Verify the original is still there\n    try std.testing.expect(parser.process_groups.contains(\"browsers\"));\n}\n\ntest \"modifier alias - basic definition and use\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings,\n        \\\\.alias $hyper cmd + alt + ctrl + shift\n        \\\\$hyper - h : echo hyper-h\n    );\n\n    const default = mappings.mode_map.get(\"default\").?;\n    try std.testing.expectEqual(@as(usize, 1), default.hotkey_map.count());\n\n    var it = default.hotkey_map.iterator();\n    const hk = it.next().?.key_ptr.*;\n    const expected = ModifierFlag{ .cmd = true, .alt = true, .control = true, .shift = true };\n    try std.testing.expectEqual(@as(u32, @bitCast(expected)), @as(u32, @bitCast(hk.flags)));\n}\n\ntest \"modifier alias - combined with extra modifiers\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings,\n        \\\\.alias $super cmd + alt\n        \\\\$super + shift - h : echo super-shift-h\n    );\n\n    const default = mappings.mode_map.get(\"default\").?;\n    var it = default.hotkey_map.iterator();\n    const hk = it.next().?.key_ptr.*;\n    const expected = ModifierFlag{ .cmd = true, .alt = true, .shift = true };\n    try std.testing.expectEqual(@as(u32, @bitCast(expected)), @as(u32, @bitCast(hk.flags)));\n}\n\ntest \"modifier alias - nested alias\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings,\n        \\\\.alias $super cmd + alt\n        \\\\.alias $mega $super + shift + ctrl\n        \\\\$mega - h : echo mega-h\n    );\n\n    const default = mappings.mode_map.get(\"default\").?;\n    var it = default.hotkey_map.iterator();\n    const hk = it.next().?.key_ptr.*;\n    const expected = ModifierFlag{ .cmd = true, .alt = true, .shift = true, .control = true };\n    try std.testing.expectEqual(@as(u32, @bitCast(expected)), @as(u32, @bitCast(hk.flags)));\n}\n\ntest \"modifier alias - undefined alias errors\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    const result = parser.parse(&mappings, \"$hyper - h : echo nope\");\n    try std.testing.expectError(error.UndefinedAlias, result);\n    try std.testing.expect(parser.error_info != null);\n    try std.testing.expect(std.mem.indexOf(u8, parser.error_info.?.message, \"Undefined alias\") != null);\n}\n\ntest \"modifier alias - redefinition errors\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    const result = parser.parse(&mappings,\n        \\\\.alias $hyper cmd + alt\n        \\\\.alias $hyper cmd + shift\n    );\n    try std.testing.expectError(error.AliasAlreadyDefined, result);\n}\n\ntest \"modifier alias - works in forward target\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings,\n        \\\\.alias $super cmd + alt\n        \\\\ctrl - 1 | $super - h\n    );\n\n    const default = mappings.mode_map.get(\"default\").?;\n    try std.testing.expectEqual(@as(usize, 1), default.hotkey_map.count());\n}\n\ntest \"key alias - hex code in key position\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings,\n        \\\\.alias $grave 0x32\n        \\\\ctrl - $grave : echo grave\n    );\n\n    const default = mappings.mode_map.get(\"default\").?;\n    var it = default.hotkey_map.iterator();\n    const hk = it.next().?.key_ptr.*;\n    try std.testing.expectEqual(@as(u32, 0x32), hk.key);\n    const expected = ModifierFlag{ .control = true };\n    try std.testing.expectEqual(@as(u32, @bitCast(expected)), @as(u32, @bitCast(hk.flags)));\n}\n\ntest \"key alias - literal carries implicit flags (e.g., delete -> fn)\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings,\n        \\\\.alias $del delete\n        \\\\cmd - $del : echo del\n    );\n\n    const default = mappings.mode_map.get(\"default\").?;\n    var it = default.hotkey_map.iterator();\n    const hk = it.next().?.key_ptr.*;\n    // delete is a fn-implicit literal; flags should include both cmd and fn\n    try std.testing.expect(hk.flags.cmd);\n    try std.testing.expect(hk.flags.@\"fn\");\n}\n\ntest \"key alias - standalone without modifiers\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings,\n        \\\\.alias $tab tab\n        \\\\$tab : echo tab\n    );\n\n    const default = mappings.mode_map.get(\"default\").?;\n    try std.testing.expectEqual(@as(usize, 1), default.hotkey_map.count());\n}\n\ntest \"alias - key alias used in modifier position errors\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings, \".alias $grave 0x32\");\n\n    const result = parser.parse(&mappings, \"$grave - h : echo wrong\");\n    try std.testing.expectError(error.AliasTypeMismatch, result);\n    try std.testing.expect(parser.error_info != null);\n    try std.testing.expect(std.mem.indexOf(u8, parser.error_info.?.message, \"key alias\") != null);\n}\n\ntest \"alias - modifier alias used in key position errors\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings, \".alias $hyper cmd + alt\");\n\n    const result = parser.parse(&mappings, \"ctrl - $hyper : echo wrong\");\n    try std.testing.expectError(error.AliasTypeMismatch, result);\n    try std.testing.expect(parser.error_info != null);\n    try std.testing.expect(std.mem.indexOf(u8, parser.error_info.?.message, \"modifier alias\") != null);\n}\n\ntest \"alias - key alias inherits kind from referenced alias\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings,\n        \\\\.alias $grave 0x32\n        \\\\.alias $tilde $grave\n        \\\\ctrl - $tilde : echo tilde\n    );\n\n    const default = mappings.mode_map.get(\"default\").?;\n    var it = default.hotkey_map.iterator();\n    const hk = it.next().?.key_ptr.*;\n    try std.testing.expectEqual(@as(u32, 0x32), hk.key);\n}\n\ntest \"mouse button - parses as a literal with synthetic keycode\" {\n    const alloc = std.testing.allocator;\n    var parser = try Parser.init(alloc);\n    defer parser.deinit();\n    var mappings = try Mappings.init(alloc);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings,\n        \\\\cmd - mouse1 : echo m1\n        \\\\mouse3 -> : echo m3\n    );\n\n    const default = mappings.mode_map.get(\"default\").?;\n    try std.testing.expectEqual(@as(usize, 2), default.hotkey_map.count());\n\n    var saw_m1 = false;\n    var saw_m3 = false;\n    var it = default.hotkey_map.iterator();\n    while (it.next()) |entry| {\n        const hk = entry.key_ptr.*;\n        if (hk.key == Keycodes.mouseButtonCode(1)) {\n            saw_m1 = true;\n            try std.testing.expect(hk.flags.cmd);\n            // Mouse buttons must NOT pick up the implicit nx flag.\n            try std.testing.expect(!hk.flags.nx);\n            try std.testing.expect(!hk.flags.@\"fn\");\n        } else if (hk.key == Keycodes.mouseButtonCode(3)) {\n            saw_m3 = true;\n            try std.testing.expect(hk.flags.passthrough);\n            try std.testing.expect(!hk.flags.nx);\n        }\n    }\n    try std.testing.expect(saw_m1);\n    try std.testing.expect(saw_m3);\n}\n"
  },
  {
    "path": "src/Tokenizer.zig",
    "content": "const std = @import(\"std\");\nconst print = std.debug.print;\nconst eql = std.mem.eql;\nconst unicode = std.unicode;\nconst ascii = std.ascii;\nconst ModifierFlag = @import(\"Keycodes.zig\").ModifierFlag;\n\n// const modifier_flags_str = @import(\"Keycodes.zig\").modifier_flags_str;\nconst literal_keycode_str = @import(\"Keycodes.zig\").literal_keycode_str;\nconst log = std.log.scoped(.tokenizer);\n\nconst identifier_chars = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_\";\nconst number_chars = \"0123456789\";\nconst hex_chars = \"0123456789abcdefABCDEF\";\n\npub const TokenType = enum {\n    Token_Identifier,\n    Token_Activate,\n\n    Token_Command,\n    Token_Modifier,\n    Token_Literal,\n    Token_Key_Hex,\n    Token_Key,\n\n    Token_Decl,\n    Token_Forward,\n    Token_Comma,\n    Token_Insert,\n    Token_Plus,\n    Token_Dash,\n    Token_Arrow,\n    Token_Capture,\n    Token_Unbound,\n    Token_Wildcard,\n    Token_String,\n    Token_Option,\n    Token_Reference,\n    Token_Alias,\n\n    Token_BeginList,\n    Token_EndList,\n    Token_BeginTuple,\n    Token_EndTuple,\n\n    /// `{` / `}` brace block. Used by structured declarations like\n    /// `.device builtin { vendor: 0x05AC, product: 0x0342 }`. While\n    /// `block_depth > 0`, a bare `:` lexes as Token_Colon (a plain\n    /// separator) rather than the command-grabbing `:` used at top\n    /// level. This keeps the existing colon-grabs-to-newline behavior\n    /// intact for hotkey rules outside of blocks.\n    Token_BeginBlock,\n    Token_EndBlock,\n    Token_Colon,\n\n    Token_Unknown,\n};\n\npub const Token = struct {\n    type: TokenType,\n    text: []const u8,\n\n    line: usize,\n    cursor: usize,\n\n    pub fn format(self: *const Token, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void {\n        try writer.print(\"Token{{\", .{});\n        try writer.print(\"\\n  line: {d}\", .{self.line});\n        try writer.print(\"\\n  cursor: {d}\", .{self.cursor});\n        try writer.print(\"\\n  type: {any}\", .{self.type});\n        try writer.print(\"\\n  text: {s}\", .{self.text});\n        try writer.print(\"\\n}}\", .{});\n    }\n};\n\nbuffer: []const u8,\npos: usize = 0,\nline: usize = 1,\ncursor: usize = 1,\n\n// last rune size\nrw: usize = 0,\n\n/// Number of currently-open `{` blocks. Inside a block, `:` lexes as\n/// Token_Colon instead of the command-grabbing top-level form.\nblock_depth: usize = 0,\n\nconst Tokenizer = @This();\n\npub fn init(buffer: []const u8) !Tokenizer {\n    if (!unicode.utf8ValidateSlice(buffer)) {\n        return error.@\"Invalid UTF-8\";\n    }\n    return Tokenizer{\n        .buffer = buffer,\n    };\n}\n\npub fn get_token(self: *Tokenizer) ?Token {\n    self.skipWhitespace();\n\n    var token = Token{\n        .line = self.line,\n        .cursor = self.cursor,\n        .type = .Token_Unknown,\n        .text = undefined,\n    };\n\n    const r = self.peekRune() orelse return null;\n    self.moveOver(r);\n    token.text = r;\n    switch (r[0]) {\n        '+' => token.type = .Token_Plus,\n        ',' => token.type = .Token_Comma,\n        '<' => token.type = .Token_Insert,\n        '@' => {\n            // Check if this is followed by an identifier\n            const next = self.peekRune();\n            if (next != null and ascii.isAlphabetic(next.?[0])) {\n                // It's a reference like @command_name or @group_name\n                token.type = .Token_Reference;\n                // Don't include the @ in the token text\n                token.text = self.acceptIdentifier();\n            } else {\n                // It's just @ (used for capture in mode declarations)\n                token.type = .Token_Capture;\n            }\n        },\n        '$' => {\n            const next = self.peekRune();\n            if (next != null and ascii.isAlphabetic(next.?[0])) {\n                token.type = .Token_Alias;\n                token.text = self.acceptIdentifier();\n            } else {\n                token.type = .Token_Unknown;\n            }\n        },\n        '~' => token.type = .Token_Unbound,\n        '*' => token.type = .Token_Wildcard,\n        '[' => token.type = .Token_BeginList,\n        ']' => token.type = .Token_EndList,\n        '(' => token.type = .Token_BeginTuple,\n        ')' => token.type = .Token_EndTuple,\n        '{' => {\n            self.block_depth += 1;\n            token.type = .Token_BeginBlock;\n        },\n        '}' => {\n            if (self.block_depth > 0) self.block_depth -= 1;\n            token.type = .Token_EndBlock;\n        },\n        '.' => {\n            token.type = .Token_Option;\n            // Don't include the . in the token text\n            token.text = self.acceptIdentifier();\n        },\n        '\"' => {\n            token.type = .Token_String;\n            token.text = self.acceptString();\n        },\n        '#' => {\n            _ = self.acceptUntil('\\n');\n            token = self.get_token() orelse return null;\n        },\n        '-' => {\n            if (self.accept(\">\") != null) {\n                token.type = .Token_Arrow;\n                token.text = \"->\";\n            } else {\n                token.type = .Token_Dash;\n            }\n        },\n        ';' => {\n            self.skipWhitespace();\n            token.type = .Token_Activate;\n            token.line = self.line;\n            token.cursor = self.cursor;\n            token.text = self.acceptIdentifier();\n        },\n        ':' => {\n            if (self.accept(\":\") != null) {\n                token.type = .Token_Decl;\n                token.text = \"::\";\n            } else if (self.block_depth > 0) {\n                // Inside a `{ ... }` block, `:` is a plain separator.\n                // Consume only the colon; the next call to get_token()\n                // will return whatever follows on its own.\n                token.type = .Token_Colon;\n                token.text = \":\";\n            } else {\n                self.skipWhitespace();\n                token.line = self.line;\n                token.cursor = self.cursor;\n                token.type = .Token_Command;\n\n                // in case of a command, it can be either empty (followed by reference) or a raw command\n                const next = self.peekRune() orelse return null;\n                if (next[0] != '@') {\n                    token.text = self.acceptCommand();\n                } else {\n                    token.text = \"\";\n                }\n            }\n        },\n        '|' => {\n            self.skipWhitespace();\n            token.line = self.line;\n            token.cursor = self.cursor;\n            token.type = .Token_Forward;\n        },\n        else => {\n            // Pre-existing latent bug: peekRune was unconditionally\n            // required even by branches that don't read `next`. That\n            // returned null for any digit or letter at end-of-input.\n            // Now `next` is only consulted by the hex-prefix check.\n            const next_opt = self.peekRune();\n            const has_hex_x = next_opt != null and next_opt.?[0] == 'x';\n            if (r[0] == '0' and has_hex_x) {\n                self.moveOver(next_opt.?);\n                token.text = self.acceptRun(hex_chars);\n                token.type = .Token_Key_Hex;\n            } else if (ascii.isDigit(r[0])) {\n                // Multi-digit numbers: a sequence of digits lexes as a\n                // single Token_Key. Existing single-digit keycodes\n                // (e.g., `cmd - 1`) are unaffected because skhd config\n                // never writes multi-digit unsuffixed numbers in key\n                // position — hex is `0x..`, key combos are one digit.\n                // Multi-digit support unblocks duration values like\n                // `timeout: 120` inside `{ ... }` blocks.\n                const start = self.pos - r.len;\n                _ = self.acceptRun(number_chars);\n                const end = self.pos;\n                token.text = self.buffer[start..end];\n                token.type = .Token_Key;\n            } else if (ascii.isAlphanumeric(r[0])) {\n                const start = self.pos - 1; // rewind\n                _ = self.acceptIdentifier();\n                const end = self.pos;\n                token.text = self.buffer[start..end];\n                token.type = resolveIdentifierType(token);\n            } else {\n                token.type = .Token_Unknown;\n            }\n        },\n    }\n    return token;\n}\n//\n// pub fn peek_token(self: *Self) Token {}\n\nfn peekRune(self: *Tokenizer) ?[]const u8 {\n    if (self.pos >= self.buffer.len) {\n        return null;\n    }\n    const l: usize = unicode.utf8ByteSequenceLength(self.buffer[self.pos]) catch unreachable;\n    return self.buffer[self.pos .. self.pos + l];\n}\n\nfn moveOver(self: *Tokenizer, r: []const u8) void {\n    if (r[0] == '\\n') {\n        self.line += 1;\n        self.cursor = 0;\n    }\n    self.cursor += r.len;\n    self.pos += r.len;\n}\n\n/// Accept consumes the next rune if it's in the valid set.\n/// valid only accepts ASCII characters.\nfn accept(self: *Tokenizer, validSet: []const u8) ?[]const u8 {\n    const r = self.peekRune() orelse return null;\n    for (validSet) |valid| {\n        if (valid == r[0]) {\n            self.moveOver(r);\n            return r;\n        }\n    }\n    return null;\n}\n\nfn acceptRun(self: *Tokenizer, cs: []const u8) []const u8 {\n    const start = self.pos;\n    while (self.accept(cs)) |_| {}\n    const end = self.pos;\n    return self.buffer[start..end];\n}\n\nfn acceptUntil(self: *Tokenizer, cs: u8) []const u8 {\n    const start = self.pos;\n    while (self.peekRune()) |r| {\n        if (r[0] == cs) {\n            break;\n        }\n        self.moveOver(r);\n    }\n    return self.buffer[start..self.pos];\n}\n\nfn acceptIdentifier(self: *Tokenizer) []const u8 {\n    const start = self.pos;\n    _ = self.acceptRun(identifier_chars);\n    _ = self.acceptRun(identifier_chars ++ number_chars);\n    const end = self.pos;\n    return self.buffer[start..end];\n}\n\nfn acceptCommand(self: *Tokenizer) []const u8 {\n    const start = self.pos;\n    while (self.peekRune()) |r| {\n        self.moveOver(r);\n        if (r[0] == '\\\\') {\n            _ = self.accept(\"\\n\");\n        }\n        if (self.accept(\"\\n\") != null) {\n            break;\n        }\n    }\n    return std.mem.trimRight(u8, self.buffer[start..self.pos], \"\\n\");\n}\n\nfn acceptString(self: *Tokenizer) []const u8 {\n    const start = self.pos;\n\n    while (self.peekRune()) |r| {\n        if (r[0] == '\"') {\n            // Check if this quote is escaped by counting preceding backslashes\n            var backslash_count: usize = 0;\n            var check_pos = self.pos;\n\n            // Count consecutive backslashes before the quote\n            while (check_pos > start and self.buffer[check_pos - 1] == '\\\\') {\n                backslash_count += 1;\n                check_pos -= 1;\n            }\n\n            // If odd number of backslashes, the quote is escaped, continue\n            if (backslash_count % 2 == 1) {\n                self.moveOver(r);\n                continue;\n            }\n\n            // Even number (or zero) backslashes, this is the closing quote\n            const end = self.pos;\n            self.moveOver(r); // Skip closing quote\n            return self.buffer[start..end];\n        }\n\n        self.moveOver(r);\n    }\n\n    // If we reach here, string wasn't closed properly\n    return self.buffer[start..self.pos];\n}\n\nfn skipWhitespace(self: *Tokenizer) void {\n    _ = self.acceptRun(\" \\t\\n\");\n}\n\nfn resolveIdentifierType(token: Token) TokenType {\n    if (token.text.len == 1) {\n        return .Token_Key;\n    }\n\n    if (ModifierFlag.get(token.text) != null) {\n        return .Token_Modifier;\n    }\n\n    for (literal_keycode_str) |keycode| {\n        if (eql(u8, keycode, token.text)) {\n            return .Token_Literal;\n        }\n    }\n\n    return .Token_Identifier;\n}\n\nconst assert = std.debug.assert;\nconst expect = std.testing.expect;\nconst expectEqual = std.testing.expectEqual;\nconst expectEqualStrings = std.testing.expectEqualStrings;\n\ntest \"nextRune\" {\n    const content =\n        \\\\hello\n        \\\\world\n        \\\\\n    ;\n    var tokenizer = try init(content);\n    var got = std.ArrayList(u8).init(std.testing.allocator);\n    defer got.deinit();\n    while (tokenizer.peekRune()) |rune| {\n        try got.appendSlice(rune);\n        tokenizer.pos += rune.len;\n    }\n    try std.testing.expectEqualStrings(got.items, content);\n}\n\ntest \"acceptRun\" {\n    const content =\n        \\\\   hello\n        \\\\world\n        \\\\\n    ;\n    var tokenizer = try init(content);\n    _ = tokenizer.acceptRun(\" \\t\");\n    try expectEqual(4, tokenizer.cursor);\n    try expectEqual(1, tokenizer.line);\n    _ = tokenizer.acceptRun(\"abcdefghijklmnopqrstuvwxyz\");\n    try expectEqual(9, tokenizer.cursor);\n    try expectEqual(1, tokenizer.line);\n    _ = tokenizer.acceptRun(\"\\n\");\n    try expectEqual(1, tokenizer.cursor);\n    try expectEqual(2, tokenizer.line);\n}\n\ntest \"acceptUntil\" {\n    const content =\n        \\\\hello \\\n        \\\\world\n        \\\\\n    ;\n    var tokenizer = try init(content);\n    const got = tokenizer.acceptUntil('\\n');\n    try expectEqual(\"hello..|\".len, tokenizer.cursor);\n    try expectEqual(1, tokenizer.line);\n    try expectEqualStrings(\"hello \\\\\", got);\n}\n\ntest \"tokenize\" {\n    const test_content = \"cmd - a : echo test\";\n\n    var tokenizer = try init(test_content);\n\n    // Just verify we can tokenize a simple hotkey\n    const token1 = tokenizer.get_token();\n    try std.testing.expect(token1 != null);\n    try std.testing.expectEqual(TokenType.Token_Modifier, token1.?.type);\n    try std.testing.expectEqualStrings(\"cmd\", token1.?.text);\n\n    const token2 = tokenizer.get_token();\n    try std.testing.expect(token2 != null);\n    try std.testing.expectEqual(TokenType.Token_Dash, token2.?.type);\n\n    const token3 = tokenizer.get_token();\n    try std.testing.expect(token3 != null);\n    try std.testing.expectEqual(TokenType.Token_Key, token3.?.type);\n    try std.testing.expectEqualStrings(\"a\", token3.?.text);\n}\n\ntest \"format token\" {\n    const token = Token{\n        .line = 1,\n        .cursor = 1,\n        .type = .Token_Identifier,\n        .text = \"hello\",\n    };\n    // Just verify the token was created correctly\n    try std.testing.expectEqual(@as(usize, 1), token.line);\n    try std.testing.expectEqual(@as(usize, 1), token.cursor);\n    try std.testing.expectEqual(TokenType.Token_Identifier, token.type);\n    try std.testing.expectEqualStrings(\"hello\", token.text);\n}\n\ntest \"tokenize brace block with colon separators\" {\n    const input =\n        \\\\.device builtin { vendor: 0x05AC, product: 0x0342 }\n    ;\n    var tokenizer = try init(input);\n\n    const expected = [_]struct { type: TokenType, text: []const u8 }{\n        .{ .type = .Token_Option, .text = \"device\" },\n        .{ .type = .Token_Identifier, .text = \"builtin\" },\n        .{ .type = .Token_BeginBlock, .text = \"{\" },\n        .{ .type = .Token_Identifier, .text = \"vendor\" },\n        .{ .type = .Token_Colon, .text = \":\" },\n        .{ .type = .Token_Key_Hex, .text = \"05AC\" },\n        .{ .type = .Token_Comma, .text = \",\" },\n        .{ .type = .Token_Identifier, .text = \"product\" },\n        .{ .type = .Token_Colon, .text = \":\" },\n        .{ .type = .Token_Key_Hex, .text = \"0342\" },\n        .{ .type = .Token_EndBlock, .text = \"}\" },\n    };\n    for (expected) |e| {\n        const tok = tokenizer.get_token();\n        try std.testing.expect(tok != null);\n        try std.testing.expectEqual(e.type, tok.?.type);\n        try std.testing.expectEqualStrings(e.text, tok.?.text);\n    }\n    try std.testing.expect(tokenizer.get_token() == null);\n}\n\ntest \"multi-digit number lexes as one Token_Key\" {\n    var tokenizer = try init(\"120 ms\");\n    const t1 = tokenizer.get_token().?;\n    try std.testing.expectEqual(TokenType.Token_Key, t1.type);\n    try std.testing.expectEqualStrings(\"120\", t1.text);\n    const t2 = tokenizer.get_token().?;\n    try std.testing.expectEqual(TokenType.Token_Identifier, t2.type);\n    try std.testing.expectEqualStrings(\"ms\", t2.text);\n}\n\ntest \"single-digit hotkey key still works\" {\n    // Regression: `cmd - 1` should still produce a single Token_Key(\"1\").\n    var tokenizer = try init(\"cmd - 1\");\n    _ = tokenizer.get_token().?; // cmd\n    _ = tokenizer.get_token().?; // -\n    const t = tokenizer.get_token().?;\n    try std.testing.expectEqual(TokenType.Token_Key, t.type);\n    try std.testing.expectEqualStrings(\"1\", t.text);\n}\n\ntest \"colon outside block still grabs command\" {\n    // Sanity: outside `{}`, the existing colon-grabs-to-newline behavior\n    // is preserved. `cmd - h : echo hi` continues to lex as in v0.\n    const input = \"cmd - h : echo hi\";\n    var tokenizer = try init(input);\n\n    const t1 = tokenizer.get_token().?;\n    try std.testing.expectEqual(TokenType.Token_Modifier, t1.type);\n    const t2 = tokenizer.get_token().?;\n    try std.testing.expectEqual(TokenType.Token_Dash, t2.type);\n    const t3 = tokenizer.get_token().?;\n    try std.testing.expectEqual(TokenType.Token_Key, t3.type);\n    const t4 = tokenizer.get_token().?;\n    try std.testing.expectEqual(TokenType.Token_Command, t4.type);\n    try std.testing.expectEqualStrings(\"echo hi\", t4.text);\n}\n\ntest \"tokenize option\" {\n    const test_content = \".shell \\\"/bin/zsh\\\"\";\n    var tokenizer = try init(test_content);\n\n    // First token should be .shell\n    const token1 = tokenizer.get_token();\n    try std.testing.expect(token1 != null);\n    try std.testing.expectEqual(TokenType.Token_Option, token1.?.type);\n    try std.testing.expectEqualStrings(\"shell\", token1.?.text);\n\n    // Second token should be the string\n    const token2 = tokenizer.get_token();\n    try std.testing.expect(token2 != null);\n    try std.testing.expectEqual(TokenType.Token_String, token2.?.type);\n    try std.testing.expectEqualStrings(\"/bin/zsh\", token2.?.text);\n}\n\ntest \"tokenize command invocations\" {\n    // Test tokenizing command invocations with the new approach\n    const test_cases = .{\n        .{\n            .input = \"@toggle(\\\"Firefox\\\")\",\n            .expected = &[_]struct { type: TokenType, text: []const u8 }{\n                .{ .type = .Token_Reference, .text = \"toggle\" },\n                .{ .type = .Token_BeginTuple, .text = \"(\" },\n                .{ .type = .Token_String, .text = \"Firefox\" },\n                .{ .type = .Token_EndTuple, .text = \")\" },\n            },\n        },\n        .{\n            .input = \"@yabai_focus(\\\"west\\\")\",\n            .expected = &[_]struct { type: TokenType, text: []const u8 }{\n                .{ .type = .Token_Reference, .text = \"yabai_focus\" },\n                .{ .type = .Token_BeginTuple, .text = \"(\" },\n                .{ .type = .Token_String, .text = \"west\" },\n                .{ .type = .Token_EndTuple, .text = \")\" },\n            },\n        },\n        .{\n            .input = \"@complex(\\\"arg1\\\", \\\"arg2\\\")\",\n            .expected = &[_]struct { type: TokenType, text: []const u8 }{\n                .{ .type = .Token_Reference, .text = \"complex\" },\n                .{ .type = .Token_BeginTuple, .text = \"(\" },\n                .{ .type = .Token_String, .text = \"arg1\" },\n                .{ .type = .Token_Comma, .text = \",\" },\n                .{ .type = .Token_String, .text = \"arg2\" },\n                .{ .type = .Token_EndTuple, .text = \")\" },\n            },\n        },\n    };\n\n    inline for (test_cases) |test_case| {\n        var tokenizer = try init(test_case.input);\n\n        inline for (test_case.expected) |expected| {\n            const token = tokenizer.get_token();\n            try std.testing.expect(token != null);\n            try std.testing.expectEqual(expected.type, token.?.type);\n            try std.testing.expectEqualStrings(expected.text, token.?.text);\n        }\n\n        // Ensure no more tokens\n        const final_token = tokenizer.get_token();\n        try std.testing.expect(final_token == null);\n    }\n}\n\ntest \"tokenize command with colon and reference\" {\n    // Test tokenizing : @command_name(args)\n    const test_cases = .{\n        .{\n            .input = \": @toggle(\\\"Firefox\\\")\",\n            .expected = &[_]struct { type: TokenType, text: []const u8 }{\n                .{ .type = .Token_Command, .text = \"\" },\n                .{ .type = .Token_Reference, .text = \"toggle\" },\n                .{ .type = .Token_BeginTuple, .text = \"(\" },\n                .{ .type = .Token_String, .text = \"Firefox\" },\n                .{ .type = .Token_EndTuple, .text = \")\" },\n            },\n        },\n        .{\n            .input = \": echo hello\",\n            .expected = &[_]struct { type: TokenType, text: []const u8 }{\n                .{ .type = .Token_Command, .text = \"echo hello\" },\n            },\n        },\n        .{\n            .input = \"cmd - h : @yabai_focus(\\\"west\\\")\",\n            .expected = &[_]struct { type: TokenType, text: []const u8 }{\n                .{ .type = .Token_Modifier, .text = \"cmd\" },\n                .{ .type = .Token_Dash, .text = \"-\" },\n                .{ .type = .Token_Key, .text = \"h\" },\n                .{ .type = .Token_Command, .text = \"\" },\n                .{ .type = .Token_Reference, .text = \"yabai_focus\" },\n                .{ .type = .Token_BeginTuple, .text = \"(\" },\n                .{ .type = .Token_String, .text = \"west\" },\n                .{ .type = .Token_EndTuple, .text = \")\" },\n            },\n        },\n    };\n\n    inline for (test_cases) |test_case| {\n        var tokenizer = try init(test_case.input);\n\n        inline for (test_case.expected) |expected| {\n            const token = tokenizer.get_token();\n            try std.testing.expect(token != null);\n            try std.testing.expectEqual(expected.type, token.?.type);\n            try std.testing.expectEqualStrings(expected.text, token.?.text);\n        }\n\n        // Ensure no more tokens\n        const final_token = tokenizer.get_token();\n        try std.testing.expect(final_token == null);\n    }\n}\n\ntest \"tokenize string with escape sequences\" {\n    const tests = .{\n        // Simple strings without escapes\n        .{\n            .input =\n            \\\\\"hello world\"\n            ,\n            .expected =\n            \\\\hello world\n            ,\n        },\n        // Escaped quotes - now we just return the raw string\n        .{\n            .input =\n            \\\\\"with \\\"quotes\\\"\"\n            ,\n            .expected =\n            \\\\with \\\"quotes\\\"\n            ,\n        },\n        .{\n            .input =\n            \\\\\"hello \\\\\\\"world\\\\\\\"\"\n            ,\n            .expected =\n            \\\\hello \\\\\\\"world\\\\\\\"\n            ,\n        },\n        // Backslashes - returned as-is\n        .{\n            .input =\n            \\\\\"back\\\\slash\"\n            ,\n            .expected =\n            \\\\back\\\\slash\n            ,\n        },\n        .{\n            .input =\n            \\\\\"path\\\\\\\\to\\\\\\\\file\"\n            ,\n            .expected =\n            \\\\path\\\\\\\\to\\\\\\\\file\n            ,\n        },\n        // Mixed escapes - all preserved\n        .{\n            .input =\n            \\\\\"mixed\\\\\\\\path with \\\\\\\"quotes\\\\\\\"\"\n            ,\n            .expected =\n            \\\\mixed\\\\\\\\path with \\\\\\\"quotes\\\\\\\"\n            ,\n        },\n        // Other sequences - preserved as-is\n        .{\n            .input =\n            \\\\\"hello \\\\ world\"\n            ,\n            .expected =\n            \\\\hello \\\\ world\n            ,\n        },\n        .{\n            .input =\n            \\\\\"new\\\\nline\"\n            ,\n            .expected =\n            \\\\new\\\\nline\n            ,\n        },\n        .{\n            .input =\n            \\\\\"tab\\\\there\"\n            ,\n            .expected =\n            \\\\tab\\\\there\n            ,\n        },\n    };\n\n    inline for (tests) |test_case| {\n        var tokenizer = try init(test_case.input);\n\n        const token = tokenizer.get_token();\n        try std.testing.expect(token != null);\n        try std.testing.expectEqual(TokenType.Token_String, token.?.type);\n        try std.testing.expectEqualStrings(test_case.expected, token.?.text);\n    }\n}\n"
  },
  {
    "path": "src/Tracer.zig",
    "content": "const std = @import(\"std\");\nconst builtin = @import(\"builtin\");\nconst Tracer = @This();\n\n// Simple execution tracer for profiling hot path\n// Tracks function calls and execution patterns\n// Only active in debug builds, compiled out in release builds\n\npub const TracerStats = struct {\n    // Event handling\n    total_key_events: u64 = 0,\n    key_down_events: u64 = 0,\n    system_key_events: u64 = 0,\n\n    // Process name lookups\n    process_name_lookups: u64 = 0,\n    process_name_cache_hits: u64 = 0,\n\n    // Hotkey lookups\n    hotkey_lookups: u64 = 0,\n    hotkey_found: u64 = 0,\n    hotkey_not_found: u64 = 0,\n    hotkey_comparisons: u64 = 0,\n\n    // Actions taken\n    keys_forwarded: u64 = 0,\n    commands_executed: u64 = 0,\n\n    // Early exits\n    no_mode_exits: u64 = 0,\n    blacklisted_exits: u64 = 0,\n    self_generated_exits: u64 = 0,\n\n    // Linear search details\n    linear_search_iterations: u64 = 0,\n    max_linear_search_depth: u64 = 0,\n};\n\nenabled: bool,\nstats: TracerStats,\nmutex: std.Thread.Mutex,\n\npub fn init(enabled: bool) Tracer {\n    return .{\n        .enabled = enabled,\n        .stats = .{},\n        .mutex = .{},\n    };\n}\n\n// Event tracking\npub fn traceKeyEvent(self: *Tracer) void {\n    if (comptime builtin.mode != .Debug and builtin.mode != .ReleaseSafe) return;\n    if (!self.enabled) return;\n    self.mutex.lock();\n    defer self.mutex.unlock();\n    self.stats.total_key_events += 1;\n}\n\npub fn traceKeyDown(self: *Tracer) void {\n    if (comptime builtin.mode != .Debug and builtin.mode != .ReleaseSafe) return;\n    if (!self.enabled) return;\n    self.mutex.lock();\n    defer self.mutex.unlock();\n    self.stats.key_down_events += 1;\n}\n\npub fn traceSystemKey(self: *Tracer) void {\n    if (comptime builtin.mode != .Debug and builtin.mode != .ReleaseSafe) return;\n    if (!self.enabled) return;\n    self.mutex.lock();\n    defer self.mutex.unlock();\n    self.stats.system_key_events += 1;\n}\n\n// Process name tracking\npub fn traceProcessNameLookup(self: *Tracer) void {\n    if (comptime builtin.mode != .Debug and builtin.mode != .ReleaseSafe) return;\n    if (!self.enabled) return;\n    self.mutex.lock();\n    defer self.mutex.unlock();\n    self.stats.process_name_lookups += 1;\n}\n\n// Hotkey lookup tracking\npub fn traceHotkeyLookup(self: *Tracer) void {\n    if (comptime builtin.mode != .Debug and builtin.mode != .ReleaseSafe) return;\n    if (!self.enabled) return;\n    self.mutex.lock();\n    defer self.mutex.unlock();\n    self.stats.hotkey_lookups += 1;\n}\n\npub fn traceHotkeyFound(self: *Tracer, found: bool) void {\n    if (comptime builtin.mode != .Debug and builtin.mode != .ReleaseSafe) return;\n    if (!self.enabled) return;\n    self.mutex.lock();\n    defer self.mutex.unlock();\n    if (found) {\n        self.stats.hotkey_found += 1;\n    } else {\n        self.stats.hotkey_not_found += 1;\n    }\n}\n\npub fn traceHotkeyComparison(self: *Tracer) void {\n    if (comptime builtin.mode != .Debug and builtin.mode != .ReleaseSafe) return;\n    if (!self.enabled) return;\n    self.mutex.lock();\n    defer self.mutex.unlock();\n    self.stats.hotkey_comparisons += 1;\n}\n\n// Linear search tracking\npub fn traceLinearSearchIterations(self: *Tracer, iterations: u64) void {\n    if (comptime builtin.mode != .Debug and builtin.mode != .ReleaseSafe) return;\n    if (!self.enabled) return;\n    self.mutex.lock();\n    defer self.mutex.unlock();\n    self.stats.linear_search_iterations += iterations;\n    if (iterations > self.stats.max_linear_search_depth) {\n        self.stats.max_linear_search_depth = iterations;\n    }\n}\n\n// Action tracking\npub fn traceKeyForwarded(self: *Tracer) void {\n    if (comptime builtin.mode != .Debug and builtin.mode != .ReleaseSafe) return;\n    if (!self.enabled) return;\n    self.mutex.lock();\n    defer self.mutex.unlock();\n    self.stats.keys_forwarded += 1;\n}\n\npub fn traceCommandExecuted(self: *Tracer) void {\n    if (comptime builtin.mode != .Debug and builtin.mode != .ReleaseSafe) return;\n    if (!self.enabled) return;\n    self.mutex.lock();\n    defer self.mutex.unlock();\n    self.stats.commands_executed += 1;\n}\n\n// Early exit tracking\npub fn traceNoModeExit(self: *Tracer) void {\n    if (comptime builtin.mode != .Debug and builtin.mode != .ReleaseSafe) return;\n    if (!self.enabled) return;\n    self.mutex.lock();\n    defer self.mutex.unlock();\n    self.stats.no_mode_exits += 1;\n}\n\npub fn traceBlacklistedExit(self: *Tracer) void {\n    if (comptime builtin.mode != .Debug and builtin.mode != .ReleaseSafe) return;\n    if (!self.enabled) return;\n    self.mutex.lock();\n    defer self.mutex.unlock();\n    self.stats.blacklisted_exits += 1;\n}\n\npub fn traceSelfGeneratedExit(self: *Tracer) void {\n    if (comptime builtin.mode != .Debug and builtin.mode != .ReleaseSafe) return;\n    if (!self.enabled) return;\n    self.mutex.lock();\n    defer self.mutex.unlock();\n    self.stats.self_generated_exits += 1;\n}\n\n// Print summary statistics\npub fn printSummary(self: *Tracer, writer: anytype) !void {\n    if (comptime builtin.mode != .Debug and builtin.mode != .ReleaseSafe) return;\n    if (!self.enabled) return;\n\n    self.mutex.lock();\n    defer self.mutex.unlock();\n\n    const s = &self.stats;\n\n    try writer.print(\"\\n=== SKHD Execution Trace Summary ===\\n\", .{});\n    try writer.print(\"\\nEvent Statistics:\\n\", .{});\n    try writer.print(\"  Total key events:     {d}\\n\", .{s.total_key_events});\n    try writer.print(\"  Key down events:      {d}\\n\", .{s.key_down_events});\n    try writer.print(\"  System key events:    {d}\\n\", .{s.system_key_events});\n\n    try writer.print(\"\\nEarly Exits:\\n\", .{});\n    try writer.print(\"  No mode exits:       {d}\\n\", .{s.no_mode_exits});\n    try writer.print(\"  Blacklisted exits:   {d}\\n\", .{s.blacklisted_exits});\n    try writer.print(\"  Self-generated exits: {d}\\n\", .{s.self_generated_exits});\n\n    try writer.print(\"\\nProcess Name Lookups:\\n\", .{});\n    try writer.print(\"  Total lookups:        {d}\\n\", .{s.process_name_lookups});\n    const lookups_per_event = if (s.total_key_events > 0)\n        @as(f64, @floatFromInt(s.process_name_lookups)) / @as(f64, @floatFromInt(s.total_key_events))\n    else\n        0.0;\n    try writer.print(\"  Lookups per event:    {d:.2}\\n\", .{lookups_per_event});\n\n    try writer.print(\"\\nHotkey Lookups:\\n\", .{});\n    try writer.print(\"  Total lookups:        {d}\\n\", .{s.hotkey_lookups});\n    try writer.print(\"  Hotkeys found:        {d}\\n\", .{s.hotkey_found});\n    try writer.print(\"  Hotkeys not found:    {d}\\n\", .{s.hotkey_not_found});\n    try writer.print(\"  Total comparisons:    {d}\\n\", .{s.hotkey_comparisons});\n\n    const avg_comparisons = if (s.hotkey_lookups > 0)\n        @as(f64, @floatFromInt(s.hotkey_comparisons)) / @as(f64, @floatFromInt(s.hotkey_lookups))\n    else\n        0.0;\n    try writer.print(\"  Avg comparisons/lookup: {d:.2}\\n\", .{avg_comparisons});\n\n    const avg_iterations = if (s.hotkey_lookups > 0)\n        @as(f64, @floatFromInt(s.linear_search_iterations)) / @as(f64, @floatFromInt(s.hotkey_lookups))\n    else\n        0.0;\n    try writer.print(\"  Avg linear iterations: {d:.2}\\n\", .{avg_iterations});\n    try writer.print(\"  Max linear depth:     {d}\\n\", .{s.max_linear_search_depth});\n\n    try writer.print(\"\\nActions:\\n\", .{});\n    try writer.print(\"  Keys forwarded:       {d}\\n\", .{s.keys_forwarded});\n    try writer.print(\"  Commands executed:    {d}\\n\", .{s.commands_executed});\n\n    // Performance insights\n    try writer.print(\"\\n=== Performance Insights ===\\n\", .{});\n\n    const hit_rate = if (s.hotkey_lookups > 0)\n        (@as(f64, @floatFromInt(s.hotkey_found)) / @as(f64, @floatFromInt(s.hotkey_lookups))) * 100.0\n    else\n        0.0;\n    try writer.print(\"Hotkey hit rate: {d:.1}%\\n\", .{hit_rate});\n\n    const wasted_lookups = s.process_name_lookups - (s.total_key_events - s.no_mode_exits - s.self_generated_exits);\n    if (wasted_lookups > 0) {\n        try writer.print(\"Potentially wasted process lookups: {d}\\n\", .{wasted_lookups});\n    }\n\n    try writer.print(\"\\n\", .{});\n}\n"
  },
  {
    "path": "src/TrackingAllocator.zig",
    "content": "const std = @import(\"std\");\nconst log = std.log.scoped(.tracking_allocator);\n\n/// TrackingAllocator - A debugging allocator that tracks all allocations\n///\n/// This implementation is based on common allocation tracking patterns found in:\n/// - Zig's standard library (formerly std.heap.LoggingAllocator, removed in 0.11)\n/// - The Zig Allocator interface design: https://ziglang.org/documentation/master/#Allocators\n/// - Similar implementations in other allocators like jemalloc's profiling mode\n///\n/// Key concepts:\n/// 1. Allocator Wrapping Pattern - wraps another allocator to intercept all operations\n/// 2. Metadata Tracking - uses a HashMap to store allocation metadata\n/// 3. Thread Safety - uses mutex for concurrent access\n///\n/// References for learning:\n/// - Zig's Allocator interface: https://ziglang.org/documentation/master/std/#std.mem.Allocator\n/// - \"Writing a Custom Allocator\" by Andrew Kelley: https://www.youtube.com/watch?v=vHWiDx_l4V0\n/// - The old LoggingAllocator source: https://github.com/ziglang/zig/blob/0.10.x/lib/std/heap/logging_allocator.zig\n/// - Memory debugging techniques: https://valgrind.org/docs/manual/mc-manual.html\nconst TrackingAllocator = @This();\n\n/// The underlying allocator that performs actual allocations\nchild_allocator: std.mem.Allocator,\n/// Map of allocation addresses to their metadata\nallocations: std.AutoHashMap(usize, AllocationInfo),\n/// Total bytes currently allocated\ntotal_allocated: usize = 0,\n/// Peak bytes allocated\npeak_allocated: usize = 0,\n/// Total number of allocations made\ntotal_allocations: u64 = 0,\n/// Total number of deallocations made\ntotal_deallocations: u64 = 0,\n/// Mutex for thread safety\nmutex: std.Thread.Mutex = .{},\n\npub const AllocationInfo = struct {\n    size: usize,\n    stack_trace: std.builtin.StackTrace,\n    timestamp: i64,\n};\n\npub fn init(child_allocator: std.mem.Allocator) !TrackingAllocator {\n    return TrackingAllocator{\n        .child_allocator = child_allocator,\n        .allocations = std.AutoHashMap(usize, AllocationInfo).init(child_allocator),\n    };\n}\n\npub fn deinit(self: *TrackingAllocator) void {\n    if (self.allocations.count() > 0) {\n        log.warn(\"Memory leaks detected! {} allocations not freed\", .{self.allocations.count()});\n        var it = self.allocations.iterator();\n        while (it.next()) |entry| {\n            log.warn(\"Leaked {} bytes at 0x{x}\", .{ entry.value_ptr.size, entry.key_ptr.* });\n            dumpStackTrace(entry.value_ptr.stack_trace);\n        }\n    }\n    self.allocations.deinit();\n}\n\n/// Returns a std.mem.Allocator interface that wraps this tracking allocator\n/// This follows Zig's allocator interface pattern where allocators return\n/// a fat pointer (ptr + vtable) that implements the Allocator interface\npub fn allocator(self: *TrackingAllocator) std.mem.Allocator {\n    return .{\n        .ptr = self,\n        .vtable = &.{\n            .alloc = alloc,\n            .resize = resize,\n            .free = free,\n            .remap = remap,\n        },\n    };\n}\n\n// VTable implementation functions\n// These follow the exact signatures required by std.mem.Allocator.VTable\n// See: https://github.com/ziglang/zig/blob/master/lib/std/mem/Allocator.zig\n\nfn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ret_addr: usize) ?[*]u8 {\n    const self: *TrackingAllocator = @ptrCast(@alignCast(ctx));\n\n    const result = self.child_allocator.rawAlloc(len, alignment, ret_addr) orelse return null;\n\n    self.mutex.lock();\n    defer self.mutex.unlock();\n\n    // Record allocation\n    const addr = @intFromPtr(result);\n    var stack_trace = std.builtin.StackTrace{\n        .instruction_addresses = &[_]usize{},\n        .index = 0,\n    };\n\n    // Capture stack trace if in debug mode\n    if (@import(\"builtin\").mode == .Debug) {\n        var addresses: [32]usize = undefined;\n        var trace = std.builtin.StackTrace{\n            .instruction_addresses = &addresses,\n            .index = 0,\n        };\n        std.debug.captureStackTrace(ret_addr, &trace);\n        stack_trace = trace;\n    }\n\n    self.allocations.put(addr, .{\n        .size = len,\n        .stack_trace = stack_trace,\n        .timestamp = std.time.milliTimestamp(),\n    }) catch {\n        // If we can't track it, still return the allocation\n        log.warn(\"Failed to track allocation of {} bytes\", .{len});\n    };\n\n    self.total_allocated += len;\n    self.total_allocations += 1;\n    if (self.total_allocated > self.peak_allocated) {\n        self.peak_allocated = self.total_allocated;\n    }\n\n    // Always log allocations\n    const stderr = std.io.getStdErr().writer();\n    stderr.print(\"[ALLOC] {} bytes at 0x{x} (total: {}, peak: {})\\n\", .{\n        len,\n        addr,\n        self.total_allocated,\n        self.peak_allocated,\n    }) catch {};\n\n    return result;\n}\n\nfn resize(ctx: *anyopaque, buf: []u8, buf_align: std.mem.Alignment, new_len: usize, ret_addr: usize) bool {\n    const self: *TrackingAllocator = @ptrCast(@alignCast(ctx));\n\n    if (!self.child_allocator.rawResize(buf, buf_align, new_len, ret_addr)) {\n        return false;\n    }\n\n    self.mutex.lock();\n    defer self.mutex.unlock();\n\n    const addr = @intFromPtr(buf.ptr);\n    if (self.allocations.get(addr)) |info| {\n        const old_size = info.size;\n        var new_info = info;\n        new_info.size = new_len;\n        self.allocations.put(addr, new_info) catch {};\n\n        if (new_len > old_size) {\n            self.total_allocated += new_len - old_size;\n        } else {\n            self.total_allocated -= old_size - new_len;\n        }\n\n        if (self.total_allocated > self.peak_allocated) {\n            self.peak_allocated = self.total_allocated;\n        }\n\n        // Always log resizes\n        const stderr = std.io.getStdErr().writer();\n        stderr.print(\"[RESIZE] {} -> {} bytes at 0x{x} (total: {})\\n\", .{\n            old_size,\n            new_len,\n            addr,\n            self.total_allocated,\n        }) catch {};\n    }\n\n    return true;\n}\n\nfn free(ctx: *anyopaque, buf: []u8, buf_align: std.mem.Alignment, ret_addr: usize) void {\n    const self: *TrackingAllocator = @ptrCast(@alignCast(ctx));\n\n    self.mutex.lock();\n    defer self.mutex.unlock();\n\n    const addr = @intFromPtr(buf.ptr);\n    if (self.allocations.fetchRemove(addr)) |entry| {\n        self.total_allocated -= entry.value.size;\n        self.total_deallocations += 1;\n\n        // Always log frees\n        const stderr = std.io.getStdErr().writer();\n        stderr.print(\"[FREE] {} bytes at 0x{x} (total: {})\\n\", .{\n            entry.value.size,\n            addr,\n            self.total_allocated,\n        }) catch {};\n    } else {\n        log.warn(\"Freeing untracked allocation at 0x{x}\", .{addr});\n    }\n\n    self.child_allocator.rawFree(buf, buf_align, ret_addr);\n}\n\nfn remap(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ret_addr: usize) ?[*]u8 {\n    const self: *TrackingAllocator = @ptrCast(@alignCast(ctx));\n\n    const result = self.child_allocator.vtable.remap(self.child_allocator.ptr, memory, alignment, new_len, ret_addr) orelse return null;\n\n    self.mutex.lock();\n    defer self.mutex.unlock();\n\n    const old_addr = @intFromPtr(memory.ptr);\n    const new_addr = @intFromPtr(result);\n\n    if (old_addr != new_addr) {\n        // Memory was moved to a new location\n        if (self.allocations.fetchRemove(old_addr)) |entry| {\n            // Track the new allocation\n            self.allocations.put(new_addr, .{\n                .size = new_len,\n                .stack_trace = entry.value.stack_trace,\n                .timestamp = std.time.milliTimestamp(),\n            }) catch {};\n\n            // Update total allocated\n            if (new_len > entry.value.size) {\n                self.total_allocated += new_len - entry.value.size;\n            } else {\n                self.total_allocated -= entry.value.size - new_len;\n            }\n\n            if (self.total_allocated > self.peak_allocated) {\n                self.peak_allocated = self.total_allocated;\n            }\n        }\n    } else {\n        // Memory was resized in place\n        if (self.allocations.getPtr(old_addr)) |info| {\n            const old_size = info.size;\n            info.size = new_len;\n\n            if (new_len > old_size) {\n                self.total_allocated += new_len - old_size;\n            } else {\n                self.total_allocated -= old_size - new_len;\n            }\n\n            if (self.total_allocated > self.peak_allocated) {\n                self.peak_allocated = self.total_allocated;\n            }\n        }\n    }\n\n    return result;\n}\n\npub fn printReport(self: *TrackingAllocator, writer: anytype) !void {\n    self.mutex.lock();\n    defer self.mutex.unlock();\n\n    try writer.print(\"\\n=== Memory Allocation Report ===\\n\", .{});\n    try writer.print(\"Total allocations: {}\\n\", .{self.total_allocations});\n    try writer.print(\"Total deallocations: {}\\n\", .{self.total_deallocations});\n    try writer.print(\"Current allocated: {} bytes\\n\", .{self.total_allocated});\n    try writer.print(\"Peak allocated: {} bytes\\n\", .{self.peak_allocated});\n    try writer.print(\"Active allocations: {}\\n\", .{self.allocations.count()});\n\n    if (self.allocations.count() > 0) {\n        try writer.print(\"\\nActive allocations:\\n\", .{});\n        var it = self.allocations.iterator();\n        var i: usize = 0;\n        while (it.next()) |entry| : (i += 1) {\n            if (i >= 10) {\n                try writer.print(\"... and {} more\\n\", .{self.allocations.count() - 10});\n                break;\n            }\n            try writer.print(\"  {} bytes at 0x{x}\\n\", .{ entry.value_ptr.size, entry.key_ptr.* });\n        }\n    }\n    try writer.print(\"================================\\n\", .{});\n}\n\nfn dumpStackTrace(trace: std.builtin.StackTrace) void {\n    if (trace.index == 0) return;\n\n    const stderr = std.io.getStdErr().writer();\n    // Simple stack trace dumping for now - just print the addresses\n    stderr.print(\"Stack trace:\\n\", .{}) catch {};\n    for (trace.instruction_addresses[0..trace.index]) |addr| {\n        stderr.print(\"  0x{x}\\n\", .{addr}) catch {};\n    }\n}\n\ntest \"TrackingAllocator basic functionality\" {\n    var tracker = try TrackingAllocator.init(std.testing.allocator);\n    defer tracker.deinit();\n\n    const tracking_alloc = tracker.allocator();\n\n    // Test allocation\n    const ptr1 = try tracking_alloc.alloc(u8, 100);\n    try std.testing.expectEqual(@as(usize, 100), tracker.total_allocated);\n    try std.testing.expectEqual(@as(u64, 1), tracker.total_allocations);\n\n    // Test another allocation\n    const ptr2 = try tracking_alloc.alloc(u8, 200);\n    try std.testing.expectEqual(@as(usize, 300), tracker.total_allocated);\n    try std.testing.expectEqual(@as(usize, 300), tracker.peak_allocated);\n\n    // Test deallocation\n    tracking_alloc.free(ptr1);\n    try std.testing.expectEqual(@as(usize, 200), tracker.total_allocated);\n    try std.testing.expectEqual(@as(u64, 1), tracker.total_deallocations);\n\n    // Peak should remain unchanged\n    try std.testing.expectEqual(@as(usize, 300), tracker.peak_allocated);\n\n    // Clean up\n    tracking_alloc.free(ptr2);\n    try std.testing.expectEqual(@as(usize, 0), tracker.total_allocated);\n}\n"
  },
  {
    "path": "src/agent_grabber_client.zig",
    "content": "//! Agent-side client for the system-grabber IPC.\n//!\n//! Used by the user-agent skhd to push the caps-class subset of its\n//! parsed rules to skhd-grabber. Synchronous: dial socket, hello,\n//! apply_rules, bye, close. The agent calls this once at startup and\n//! again after a config reload.\n\nconst std = @import(\"std\");\nconst c = @import(\"c.zig\");\nconst protocol = @import(\"grabber_protocol\");\n\nconst log = std.log.scoped(.agent_grabber);\n\npub const Client = struct {\n    allocator: std.mem.Allocator,\n    stream: std.net.Stream,\n\n    pub fn connect(allocator: std.mem.Allocator, socket_path: []const u8) !Client {\n        const stream = std.net.connectUnixSocket(socket_path) catch |err| {\n            switch (err) {\n                error.FileNotFound => log.warn(\n                    \"grabber socket not found at {s} — is skhd-grabber installed and running?\",\n                    .{socket_path},\n                ),\n                error.ConnectionRefused => log.warn(\n                    \"grabber socket {s} exists but nothing is listening (stale daemon?)\",\n                    .{socket_path},\n                ),\n                error.PermissionDenied => log.warn(\n                    \"permission denied connecting to {s}\",\n                    .{socket_path},\n                ),\n                else => log.warn(\"connect to {s} failed: {s}\", .{ socket_path, @errorName(err) }),\n            }\n            return err;\n        };\n        return .{ .allocator = allocator, .stream = stream };\n    }\n\n    pub fn close(self: *Client) void {\n        self.stream.close();\n        self.* = undefined;\n    }\n\n    /// Send `hello` and wait for the matching `ok`. Returns an error\n    /// if the grabber sends back `error` instead.\n    pub fn hello(self: *Client) !void {\n        try protocol.writeMessage(self.stream, self.allocator, .{\n            .@\"type\" = \"hello\",\n            .uid = currentUid(),\n            .version = protocol.protocol_version,\n        });\n        try expectOk(self);\n    }\n\n    /// Send the full set of caps-class rules and colon-form remaps in\n    /// one apply_rules call. Replaces whatever the grabber held for\n    /// this uid. `fkeys_as_standard` mirrors NSGlobalDomain\n    /// `com.apple.keyboard.fnState` so the grabber can flip its F-row\n    /// translation policy without doing a privileged prefs read.\n    pub fn applyRules(\n        self: *Client,\n        rules: []const protocol.Rule,\n        remaps: []const protocol.Remap,\n        fkeys_as_standard: bool,\n    ) !void {\n        try protocol.writeMessage(self.stream, self.allocator, .{\n            .@\"type\" = \"apply_rules\",\n            .rules = rules,\n            .remaps = remaps,\n            .fkeys_as_standard = fkeys_as_standard,\n        });\n        try expectOk(self);\n    }\n\n    pub fn bye(self: *Client) !void {\n        try protocol.writeMessage(self.stream, self.allocator, .{ .@\"type\" = \"bye\" });\n        // The grabber's response to bye is ok, but we also accept the\n        // peer simply closing the socket here.\n        expectOk(self) catch |err| switch (err) {\n            error.EndOfStream => {},\n            else => return err,\n        };\n    }\n};\n\nfn expectOk(client: *Client) !void {\n    var buf: [4096]u8 = undefined;\n    const n = try protocol.readFrame(client.stream, &buf);\n\n    var parsed = try std.json.parseFromSlice(std.json.Value, client.allocator, buf[0..n], .{});\n    defer parsed.deinit();\n\n    if (parsed.value != .object) return error.BadResponse;\n    const obj = parsed.value.object;\n    const t = obj.get(\"type\") orelse return error.BadResponse;\n    if (t != .string) return error.BadResponse;\n\n    if (std.mem.eql(u8, t.string, \"ok\")) return;\n\n    if (std.mem.eql(u8, t.string, \"error\")) {\n        const code = if (obj.get(\"code\")) |v| (if (v == .string) v.string else \"?\") else \"?\";\n        const msg = if (obj.get(\"message\")) |v| (if (v == .string) v.string else \"?\") else \"?\";\n        log.warn(\"grabber returned error: code={s} message={s}\", .{ code, msg });\n        return error.GrabberError;\n    }\n\n    log.warn(\"grabber returned unexpected type: {s}\", .{t.string});\n    return error.BadResponse;\n}\n\nfn currentUid() u32 {\n    return @intCast(c.getuid());\n}\n"
  },
  {
    "path": "src/agent_layer_listener.zig",
    "content": "//! Agent-side listener for `mode_change` push messages from the\n//! grabber.\n//!\n//! After the agent finishes apply_rules, it can keep the IPC socket\n//! open and register the fd as a CFFileDescriptor run loop source.\n//! When the grabber writes a `mode_change` frame (in response to a\n//! layer-hold rule committing or releasing), the run loop wakes us\n//! and we read + dispatch the message inline. Same thread as the\n//! CGEventTap callback, so updating `current_mode` is race-free.\n\nconst std = @import(\"std\");\nconst c = @import(\"c.zig\");\nconst protocol = @import(\"grabber_protocol\");\n\nconst log = std.log.scoped(.agent_layer_listener);\n\n/// Called when the grabber pushes a mode_change. `mode_name` is the\n/// owned-by-the-buffer slice from the parsed JSON; the listener\n/// borrows from a small static buffer, so handlers must copy if they\n/// need to retain the name beyond their own scope. Empty string (\"\")\n/// means \"exit current layer back to default\".\npub const ModeCallback = *const fn (ctx: ?*anyopaque, mode_name: []const u8) void;\n\n/// Called once when the grabber socket goes EndOfStream (grabber\n/// exited or restarted). Owner uses this to schedule a reconnect\n/// timer.\npub const DisconnectCallback = *const fn (ctx: ?*anyopaque) void;\n\npub const Listener = struct {\n    allocator: std.mem.Allocator,\n    fd: c_int,\n    cb: ModeCallback,\n    cb_ctx: ?*anyopaque,\n    on_disconnect: ?DisconnectCallback = null,\n    on_disconnect_ctx: ?*anyopaque = null,\n    /// Set after we fire the disconnect callback so we don't fire it\n    /// again on subsequent (no-op) callbacks. The owner is expected\n    /// to deinit this listener as part of the reconnect path.\n    disconnected: bool = false,\n    cf_fd: c.CFFileDescriptorRef,\n    runloop_source: c.CFRunLoopSourceRef,\n\n    pub fn init(\n        allocator: std.mem.Allocator,\n        socket_fd: c_int,\n        cb: ModeCallback,\n        cb_ctx: ?*anyopaque,\n    ) !*Listener {\n        const self = try allocator.create(Listener);\n        errdefer allocator.destroy(self);\n\n        var ctx: c.CFFileDescriptorContext = .{\n            .version = 0,\n            .info = self,\n            .retain = null,\n            .release = null,\n            .copyDescription = null,\n        };\n        const cf_fd = c.CFFileDescriptorCreate(\n            c.kCFAllocatorDefault,\n            socket_fd,\n            0, // closeOnInvalidate=false: skhd owns the underlying fd\n            cfFdCallback,\n            &ctx,\n        );\n        if (cf_fd == null) return error.CFFileDescriptorCreateFailed;\n        errdefer c.CFRelease(cf_fd);\n\n        const source = c.CFFileDescriptorCreateRunLoopSource(c.kCFAllocatorDefault, cf_fd, 0);\n        if (source == null) {\n            c.CFFileDescriptorInvalidate(cf_fd);\n            return error.RunLoopSourceCreateFailed;\n        }\n\n        self.* = .{\n            .allocator = allocator,\n            .fd = socket_fd,\n            .cb = cb,\n            .cb_ctx = cb_ctx,\n            .cf_fd = cf_fd,\n            .runloop_source = source,\n        };\n\n        c.CFRunLoopAddSource(c.CFRunLoopGetCurrent(), source, c.kCFRunLoopDefaultMode);\n        c.CFFileDescriptorEnableCallBacks(cf_fd, c.kCFFileDescriptorReadCallBack);\n\n        log.info(\"layer listener active on fd={d}\", .{socket_fd});\n        return self;\n    }\n\n    pub fn deinit(self: *Listener) void {\n        c.CFRunLoopRemoveSource(c.CFRunLoopGetCurrent(), self.runloop_source, c.kCFRunLoopDefaultMode);\n        c.CFRelease(self.runloop_source);\n        c.CFFileDescriptorInvalidate(self.cf_fd);\n        c.CFRelease(self.cf_fd);\n        self.allocator.destroy(self);\n    }\n};\n\nfn cfFdCallback(\n    cf_fd: c.CFFileDescriptorRef,\n    callback_types: c.CFOptionFlags,\n    info: ?*anyopaque,\n) callconv(.C) void {\n    _ = callback_types;\n    const self: *Listener = @ptrCast(@alignCast(info orelse return));\n\n    // Read one framed JSON message. Length prefix is 4 bytes; body\n    // typically tens of bytes for mode_change.\n    var buf: [4096]u8 = undefined;\n    const stream = std.net.Stream{ .handle = self.fd };\n    const n = protocol.readFrame(stream, &buf) catch |err| switch (err) {\n        error.EndOfStream => {\n            if (!self.disconnected) {\n                self.disconnected = true;\n                log.warn(\"grabber connection closed; layer pushes will not arrive\", .{});\n                if (self.on_disconnect) |cb| cb(self.on_disconnect_ctx);\n            }\n            return;\n        },\n        else => {\n            log.warn(\"layer push read error: {s}\", .{@errorName(err)});\n            // Re-arm in case it was a transient fault.\n            c.CFFileDescriptorEnableCallBacks(cf_fd, c.kCFFileDescriptorReadCallBack);\n            return;\n        },\n    };\n\n    var parsed = std.json.parseFromSlice(std.json.Value, self.allocator, buf[0..n], .{}) catch |err| {\n        log.warn(\"layer push parse error: {s}\", .{@errorName(err)});\n        c.CFFileDescriptorEnableCallBacks(cf_fd, c.kCFFileDescriptorReadCallBack);\n        return;\n    };\n    defer parsed.deinit();\n\n    if (parsed.value != .object) {\n        log.warn(\"layer push: not an object\", .{});\n        c.CFFileDescriptorEnableCallBacks(cf_fd, c.kCFFileDescriptorReadCallBack);\n        return;\n    }\n    const obj = parsed.value.object;\n    const type_val = obj.get(\"type\") orelse {\n        c.CFFileDescriptorEnableCallBacks(cf_fd, c.kCFFileDescriptorReadCallBack);\n        return;\n    };\n    if (type_val != .string) {\n        c.CFFileDescriptorEnableCallBacks(cf_fd, c.kCFFileDescriptorReadCallBack);\n        return;\n    }\n\n    if (std.mem.eql(u8, type_val.string, \"mode_change\")) {\n        const mode_val = obj.get(\"mode\") orelse {\n            log.warn(\"mode_change missing 'mode' field\", .{});\n            c.CFFileDescriptorEnableCallBacks(cf_fd, c.kCFFileDescriptorReadCallBack);\n            return;\n        };\n        const name = if (mode_val == .string) mode_val.string else \"\";\n        log.info(\"mode_change: '{s}'\", .{name});\n        self.cb(self.cb_ctx, name);\n    } else {\n        log.warn(\"ignoring unknown push type '{s}'\", .{type_val.string});\n    }\n\n    // CFFileDescriptor read-callbacks fire once per readable\n    // transition; re-arm so the next push wakes us.\n    c.CFFileDescriptorEnableCallBacks(cf_fd, c.kCFFileDescriptorReadCallBack);\n}\n"
  },
  {
    "path": "src/benchmark.zig",
    "content": "const std = @import(\"std\");\nconst zbench = @import(\"zbench\");\nconst Skhd = @import(\"skhd.zig\");\nconst Hotkey = @import(\"Hotkey.zig\");\nconst HotkeyOriginal = @import(\"Hotkey.zig\");\nconst c = @import(\"c.zig\");\nconst ModifierFlag = @import(\"Keycodes.zig\").ModifierFlag;\n\nvar gpa = std.heap.GeneralPurposeAllocator(.{}){};\n\n// Global context for benchmarks\nvar g_skhd: ?*Skhd = null;\nvar g_esc_key: Hotkey.KeyPress = undefined;\nvar g_hotkey_original: ?*HotkeyOriginal = null;\n\n// Process name lookup benchmark (system call)\nfn benchProcessNameLookup(allocator: std.mem.Allocator) void {\n    _ = allocator;\n    var buffer: [128]u8 = undefined;\n    _ = getCurrentProcessNameBuf(&buffer) catch {};\n}\n\n// Cached process name lookup benchmark\nfn benchCachedProcessName(allocator: std.mem.Allocator) void {\n    _ = allocator;\n    const skhd = g_skhd orelse return;\n    _ = skhd.carbon_event.getProcessName();\n}\n\n// Copy the getCurrentProcessNameBuf function for benchmarking\nfn getCurrentProcessNameBuf(buffer: []u8) ![]const u8 {\n    var psn: c.ProcessSerialNumber = undefined;\n\n    const status = c.GetFrontProcess(&psn);\n    if (status != c.noErr) {\n        const unknown = \"unknown\";\n        @memcpy(buffer[0..unknown.len], unknown);\n        return buffer[0..unknown.len];\n    }\n\n    var process_name_ref: c.CFStringRef = undefined;\n    const copy_status = c.CopyProcessName(&psn, &process_name_ref);\n    if (copy_status != c.noErr) {\n        const unknown = \"unknown\";\n        @memcpy(buffer[0..unknown.len], unknown);\n        return buffer[0..unknown.len];\n    }\n    defer c.CFRelease(process_name_ref);\n\n    const success = c.CFStringGetCString(process_name_ref, buffer.ptr, @intCast(buffer.len), c.kCFStringEncodingUTF8);\n    if (success == 0) {\n        const unknown = \"unknown\";\n        @memcpy(buffer[0..unknown.len], unknown);\n        return buffer[0..unknown.len];\n    }\n\n    const c_string_len = std.mem.len(@as([*:0]const u8, @ptrCast(buffer.ptr)));\n    const process_name = buffer[0..c_string_len];\n\n    for (process_name) |*char| {\n        char.* = std.ascii.toLower(char.*);\n    }\n\n    // Clean invisible Unicode characters that some apps (like WhatsApp) have\n    return cleanInvisibleChars(process_name);\n}\n\n/// Remove invisible Unicode characters from the beginning of a string\n/// This handles cases like WhatsApp which has U+200E (LEFT-TO-RIGHT MARK) in its process name\nfn cleanInvisibleChars(name: []const u8) []const u8 {\n    // Common invisible Unicode characters as UTF-8 byte sequences\n    const ltr_mark = \"\\u{200E}\"; // LEFT-TO-RIGHT MARK\n    const rtl_mark = \"\\u{200F}\"; // RIGHT-TO-LEFT MARK\n    const zwsp = \"\\u{200B}\"; // ZERO WIDTH SPACE\n    const zwnj = \"\\u{200C}\"; // ZERO WIDTH NON-JOINER\n    const zwj = \"\\u{200D}\"; // ZERO WIDTH JOINER\n    const bom = \"\\u{FEFF}\"; // ZERO WIDTH NO-BREAK SPACE (BOM)\n\n    var result = name;\n\n    // Keep removing invisible chars from the start until we find a visible char\n    while (result.len > 0) {\n        if (std.mem.startsWith(u8, result, ltr_mark)) {\n            result = result[ltr_mark.len..];\n        } else if (std.mem.startsWith(u8, result, rtl_mark)) {\n            result = result[rtl_mark.len..];\n        } else if (std.mem.startsWith(u8, result, zwsp)) {\n            result = result[zwsp.len..];\n        } else if (std.mem.startsWith(u8, result, zwnj)) {\n            result = result[zwnj.len..];\n        } else if (std.mem.startsWith(u8, result, zwj)) {\n            result = result[zwj.len..];\n        } else if (std.mem.startsWith(u8, result, bom)) {\n            result = result[bom.len..];\n        } else {\n            break;\n        }\n    }\n\n    return result;\n}\n\n// Process mapping benchmarks - Original implementation\nfn benchProcessMappingOriginal(allocator: std.mem.Allocator) void {\n    _ = allocator;\n    const hotkey = g_hotkey_original orelse return;\n\n    // Simulate process lookups\n    const test_processes = [_][]const u8{ \"firefox\", \"CHROME\", \"Visual Studio Code\", \"Unknown App\" };\n\n    for (test_processes) |proc_name| {\n        _ = hotkey.find_command_for_process(proc_name);\n    }\n}\n\npub fn main() !void {\n    const allocator = gpa.allocator();\n    defer _ = gpa.deinit();\n\n    std.debug.print(\"\\n=== Benchmarking skhd hot path ===\\n\\n\", .{});\n\n    // Load the actual user config\n    const config_path = \"/Users/jackieli/.config/skhd/skhdrc\";\n\n    // Initialize skhd with profiling disabled\n    var skhd = try Skhd.init(allocator, config_path, false, false);\n    defer skhd.deinit();\n\n    // Set global context\n    g_skhd = &skhd;\n    g_esc_key = Hotkey.KeyPress{\n        .key = 0x35, // ESC keycode\n        .flags = .{},\n    };\n\n    // Print configuration info\n    var hotkey_count: usize = 0;\n    if (skhd.current_mode) |mode| {\n        hotkey_count = mode.hotkey_map.count();\n    }\n    std.debug.print(\"Current mode has {} hotkeys\\n\\n\", .{hotkey_count});\n\n    // Initialize hotkeys for process mapping benchmarks\n    {\n        // Original implementation\n        var hotkey_original = try HotkeyOriginal.create(allocator);\n        g_hotkey_original = hotkey_original;\n\n        // Add common process mappings to both implementations\n        const common_processes = [_][]const u8{\n            \"Firefox\",            \"Google Chrome\",    \"Safari\",  \"Terminal\", \"iTerm2\",\n            \"Visual Studio Code\", \"Sublime Text\",     \"Slack\",   \"Discord\",  \"Spotify\",\n            \"Mail\",               \"Calendar\",         \"Notes\",   \"Preview\",  \"Finder\",\n            \"System Preferences\", \"Activity Monitor\", \"Console\", \"Xcode\",    \"IntelliJ IDEA\",\n        };\n\n        for (common_processes) |process| {\n            const cmd = try std.fmt.allocPrint(allocator, \"echo '{s}'\", .{process});\n            defer allocator.free(cmd);\n            try hotkey_original.add_process_command(process, cmd);\n        }\n\n        // Set wildcard commands using unified API\n        try hotkey_original.add_process_command(\"*\", \"echo 'default'\");\n\n        std.debug.print(\"Initialized hotkeys with {} process mappings\\n\\n\", .{common_processes.len});\n    }\n    defer if (g_hotkey_original) |h| h.destroy();\n\n    // Create benchmark suite\n    var bench = zbench.Benchmark.init(allocator, .{});\n    defer bench.deinit();\n\n    // Add benchmarks\n    try bench.add(\"Process Name Lookup (syscall)\", benchProcessNameLookup, .{});\n    try bench.add(\"Process Name Lookup (cached)\", benchCachedProcessName, .{});\n\n    // Add process mapping benchmarks\n    try bench.add(\"Process Mapping (Original)\", benchProcessMappingOriginal, .{});\n\n    // Run benchmarks\n    try bench.run(std.io.getStdOut().writer());\n}\n"
  },
  {
    "path": "src/c.zig",
    "content": "// Unified C imports for the project\npub usingnamespace @cImport({\n    @cInclude(\"Carbon/Carbon.h\");\n    @cInclude(\"CoreServices/CoreServices.h\");\n    @cInclude(\"objc/objc.h\");\n    @cInclude(\"objc/runtime.h\");\n    @cInclude(\"unistd.h\");\n    @cInclude(\"pwd.h\");\n    @cInclude(\"sys/types.h\");\n    @cInclude(\"sys/wait.h\");\n    @cInclude(\"fcntl.h\");\n    @cInclude(\"IOKit/hidsystem/ev_keymap.h\");\n});\n\n// Additional declarations\npub extern fn NSApplicationLoad() void;\n\n// IOHIDCheckAccess + enums from <IOKit/hidsystem/IOHIDLib.h>. translate-c\n// drops the function when @cInclude'd (likely the availability macro\n// surrounding the prototype), so we mirror it here. Available since macOS\n// 10.15; we target 13.0+ so it's always present. Linked via the IOKit\n// framework already pulled in for DeviceCheck.zig.\npub const IOHIDRequestType = u32;\npub const kIOHIDRequestTypePostEvent: IOHIDRequestType = 0;\npub const kIOHIDRequestTypeListenEvent: IOHIDRequestType = 1;\n\npub const IOHIDAccessType = u32;\npub const kIOHIDAccessTypeGranted: IOHIDAccessType = 0;\npub const kIOHIDAccessTypeDenied: IOHIDAccessType = 1;\npub const kIOHIDAccessTypeUnknown: IOHIDAccessType = 2;\n\npub extern fn IOHIDCheckAccess(requestType: IOHIDRequestType) IOHIDAccessType;\n\n// IOHIDRequestAccess is the *prompting* variant of IOHIDCheckAccess — calling\n// it triggers the macOS Input Monitoring approval dialog the first time the\n// bundle hits it, same way AXIsProcessTrustedWithOptions(prompt=true) does\n// for Accessibility. Returns true if access is currently granted; false\n// otherwise (whether the user denied, the prompt is pending, or this is a\n// first-launch unknown state). Available since macOS 10.15.\n//\n// IOKit returns Apple's `Boolean` typedef (CoreFoundation: unsigned char,\n// not C99 _Bool), so we declare the FFI return as `u8` for arch-portable\n// marshalling and treat any non-zero value as success at the call site.\npub extern fn IOHIDRequestAccess(requestType: IOHIDRequestType) u8;\n"
  },
  {
    "path": "src/echo.zig",
    "content": "const std = @import(\"std\");\nconst EventTap = @import(\"EventTap.zig\");\nconst Keycodes = @import(\"Keycodes.zig\");\n\nconst c = @import(\"c.zig\");\n\nextern fn NSApplicationLoad() void;\n\npub fn echo() !void {\n    // NSApplicationLoad();\n    const mask: u32 = (1 << c.kCGEventKeyDown) |\n        (1 << c.kCGEventFlagsChanged) |\n        (1 << c.kCGEventLeftMouseDown) |\n        (1 << c.kCGEventRightMouseDown) |\n        (1 << c.kCGEventOtherMouseDown) |\n        (1 << c.NX_SYSDEFINED);\n    var event_tap = EventTap{ .mask = mask };\n    defer event_tap.deinit();\n    std.debug.print(\"Ctrl+C to exit\\n\", .{});\n    try event_tap.begin(callback, null);\n    c.CFRunLoopRun();\n}\n\nfn callback(_: c.CGEventTapProxy, typ: c.CGEventType, event: c.CGEventRef, _: ?*anyopaque) callconv(.c) c.CGEventRef {\n    switch (typ) {\n        c.kCGEventKeyDown => return printKeydown(event) catch |err| {\n            std.debug.print(\"Error: {}\\n\", .{err});\n            return @ptrFromInt(0);\n        },\n        // c.kCGEventFlagsChanged => printFlagsChanged(event),\n        c.kCGEventLeftMouseDown, c.kCGEventRightMouseDown, c.kCGEventOtherMouseDown => {\n            const button = c.CGEventGetIntegerValueField(event, c.kCGMouseEventButtonNumber);\n            std.debug.print(\"Mouse button: {d}\\n\", .{button});\n            return @ptrFromInt(0);\n        },\n        c.NX_SYSDEFINED => {\n            printSystemKey(event);\n            return event;\n        },\n        else => {\n            // std.debug.print(\"Event type: {any}\\n\", .{typ});\n            return event;\n        },\n    }\n}\n\nfn printSystemKey(event: c.CGEventRef) void {\n    const event_data = c.CGEventCreateData(c.kCFAllocatorDefault, event);\n    if (event_data == null) return;\n    defer c.CFRelease(event_data);\n\n    const data = c.CFDataGetBytePtr(event_data);\n    const key_code = data[129];\n    const key_state = data[130];\n    const key_stype = data[123];\n\n    const NX_KEYDOWN: u8 = 0x0A;\n    const NX_SUBTYPE_AUX_CONTROL_BUTTONS: u8 = 8;\n\n    // Only print on key down for aux control buttons (media keys)\n    if (key_state != NX_KEYDOWN or key_stype != NX_SUBTYPE_AUX_CONTROL_BUTTONS) return;\n\n    // Get key name from our NX keycode table\n    const key_name = Keycodes.getNXKeyString(key_code);\n    std.debug.print(\"\\t{s}\\tkeycode: 0x{x:0>2} (NX media key)\\n\", .{ key_name, key_code });\n}\n\nfn printKeydown(event: c.CGEventRef) !c.CGEventRef {\n    const keycode = c.CGEventGetIntegerValueField(event, c.kCGKeyboardEventKeycode);\n\n    const flags: c.CGEventFlags = c.CGEventGetFlags(event);\n    if (keycode == c.kVK_ANSI_C and flags & c.kCGEventFlagMaskControl != 0) {\n        std.debug.print(\"Ctrl+C pressed\\n\", .{});\n        std.posix.exit(0);\n    }\n    if (flags & c.kCGEventFlagMaskShift != 0) {\n        std.debug.print(\"Shift \", .{});\n    }\n    if (flags & c.kCGEventFlagMaskControl != 0) {\n        std.debug.print(\"Ctrl \", .{});\n    }\n    if (flags & c.kCGEventFlagMaskAlternate != 0) {\n        std.debug.print(\"Alt \", .{});\n    }\n    if (flags & c.kCGEventFlagMaskCommand != 0) {\n        std.debug.print(\"Cmd \", .{});\n    }\n    if (flags & c.kCGEventFlagMaskSecondaryFn != 0) {\n        std.debug.print(\"Fn \", .{});\n    }\n    if (flags & c.kCGEventFlagMaskNumericPad != 0) {\n        std.debug.print(\"Num \", .{});\n    }\n    if (flags & c.kCGEventFlagMaskHelp != 0) {\n        std.debug.print(\"Help \", .{});\n    }\n    // if (flags & c.kCGEventFlagMaskNonCoalesced != 0) {\n    //     std.debug.print(\"NonCoalesced \", .{});\n    // }\n    if (flags & c.kCGEventFlagMaskAlphaShift != 0) {\n        std.debug.print(\"AlphaShift \", .{});\n    }\n\n    // const chars = createStringForKey(@intCast(u16, keycode), gpa_allocator) catch @panic(\"createStringForKey failed\");\n    // defer gpa_allocator.free(chars);\n    // std.debug.print(\"typeof chars: {}, length: {}\\n\", .{ @TypeOf(chars), chars.len });\n    // std.debug.print(\"key: {s}\", .{chars});\n    const chars = Keycodes.getKeyString(@intCast(keycode));\n    std.debug.print(\"\\t{s}\\tkeycode: 0x{x:0>2}\\n\", .{ chars, keycode });\n    // print(\"\\tkey: '{s}' (0x{x:0>2})\\n\", .{ key, keycode });\n\n    // var buffer: [255]u8 = undefined;\n    // try translateKey(&buffer, @intCast(keycode), @intCast(flags));\n    // const s = std.mem.sliceTo(buffer[0..], 0);\n    // std.debug.print(\"\\t{s}\\tkeycode: 0x{x:0<2}\\n\", .{ s, keycode });\n\n    return @ptrFromInt(0);\n}\n\nfn translateKey(buffer: *[255]u8, keyCode: u16, modifierState: u32) !void {\n    const keyboard = c.TISCopyCurrentASCIICapableKeyboardLayoutInputSource();\n    const uchr: c.CFDataRef = @ptrCast(c.TISGetInputSourceProperty(keyboard, c.kTISPropertyUnicodeKeyLayoutData));\n    defer c.CFRelease(keyboard);\n\n    const keyboard_layout: ?*c.UCKeyboardLayout = @constCast(@ptrCast(@alignCast(c.CFDataGetBytePtr(uchr))));\n    if (keyboard_layout == null) {\n        return error.@\"Failed to get keyboard layout\";\n    }\n\n    var len: c.UniCharCount = 0;\n    var chars: [255]u16 = undefined;\n    var state: c.UInt32 = 0;\n\n    const ret = c.UCKeyTranslate(\n        keyboard_layout,\n        keyCode,\n        c.kUCKeyActionDisplay,\n        modifierState & 0x0,\n        c.LMGetKbdType(),\n        c.kUCKeyTranslateNoDeadKeysMask,\n        &state,\n        chars.len,\n        &len,\n        &chars,\n    );\n\n    if (ret != c.noErr) {\n        std.debug.print(\"ret: {d}\\n\", .{ret});\n        return error.@\"Failed to translate key\";\n    }\n\n    const cfstring = c.CFStringCreateWithCharacters(c.kCFAllocatorDefault, &chars, @intCast(len));\n    defer c.CFRelease(cfstring);\n\n    const num_bytes = c.CFStringGetMaximumSizeForEncoding(c.CFStringGetLength(cfstring), c.kCFStringEncodingUTF8);\n    if (num_bytes > 64) {\n        @panic(\"num_bytes for cfstring > 64\");\n    }\n    if (c.CFStringGetCString(cfstring, buffer, num_bytes, c.kCFStringEncodingUTF8) == c.false) {\n        std.debug.print(\"str {?x} len: {d}\\n\", .{ cfstring.?, num_bytes });\n        std.debug.print(\"chars: {x}\\n\", .{chars});\n        return error.@\"Failed to get c string from CFString\";\n    }\n}\n"
  },
  {
    "path": "src/exec.zig",
    "content": "const c = @import(\"c.zig\");\nconst std = @import(\"std\");\n\n/// Fork and exec a command, detaching it from the parent process\n///\n/// This function uses the classic \"double fork\" technique to create a true daemon process\n/// that is completely detached from the parent. This prevents:\n/// 1. The child from becoming a zombie when it exits\n/// 2. The child from being affected by terminal hangups\n/// 3. Terminal output from child processes appearing in skhd's logs\n///\n/// References:\n/// - W. Richard Stevens, \"Advanced Programming in the UNIX Environment\", Chapter 13: Daemon Processes\n/// - Linux daemon(3) man page implementation\n/// - systemd source code: src/basic/process-util.c\n///\n/// The double fork works as follows:\n/// 1. First fork: Parent creates child1\n/// 2. Child1 calls setsid() to become session leader in new session\n/// 3. Second fork: Child1 creates child2\n/// 4. Child1 exits immediately, child2 continues\n/// 5. Parent waits for child1 to prevent zombie\n/// 6. Child2 is now orphaned and adopted by init (PID 1)\n/// 7. When child2 eventually exits, init automatically reaps it\npub inline fn forkAndExec(shell: [:0]const u8, command: [:0]const u8, verbose: bool) !void {\n    const cpid = c.fork();\n    if (cpid == -1) {\n        return error.ForkFailed;\n    }\n\n    if (cpid == 0) {\n        // Child process\n        // Create new session (detach from controlling terminal)\n        _ = c.setsid();\n\n        // Double fork to ensure we can't reacquire a controlling terminal\n        const cpid2 = c.fork();\n        if (cpid2 == -1) {\n            std.process.exit(1);\n        }\n        if (cpid2 > 0) {\n            // First child exits\n            std.process.exit(0);\n        }\n\n        // Second child continues\n        if (!verbose) {\n            const devnull = c.open(\"/dev/null\", c.O_WRONLY);\n            if (devnull != -1) {\n                _ = c.dup2(devnull, 1); // stdout\n                _ = c.dup2(devnull, 2); // stderr\n                _ = c.close(devnull);\n            }\n        }\n\n        // Prepare arguments for execvp\n        // No allocation needed - strings are already null-terminated\n        const arg_c = \"-c\";\n        const argv = [_:null]?[*:0]const u8{ shell.ptr, arg_c, command.ptr, null };\n\n        const status_code = c.execvp(shell.ptr, @ptrCast(&argv));\n        // If execvp returns, it failed\n        std.process.exit(@intCast(status_code));\n    }\n\n    // Parent waits for first child to exit\n    // This prevents the first child from becoming a zombie and ensures\n    // the double fork completes before we return. The wait is very brief\n    // since child1 exits immediately after forking child2.\n    var status: c_int = 0;\n    _ = c.waitpid(cpid, &status, 0);\n}\n"
  },
  {
    "path": "src/grabber/HidSeize.zig",
    "content": "//! IOHIDManager-based seize for the grabber.\n//!\n//! Opens a set of (vendor, product) keyboards with\n//! `kIOHIDOptionsTypeSeizeDevice`. While seized, those devices'\n//! input events bypass the normal HID stack — only this process's\n//! input value callback receives them. The grabber then either\n//! transforms (D4: tap-hold) or passes them straight through to the\n//! Karabiner virtual HID device (D3: this module).\n//!\n//! Requires root: `IOHIDDeviceOpen(seize)` returns\n//! `kIOReturnNotPrivileged` for non-root callers.\n//!\n//! Thread model: callbacks fire on the CFRunLoop thread we schedule\n//! against. The caller is expected to drive that run loop on the\n//! main thread.\n\nconst std = @import(\"std\");\nconst builtin = @import(\"builtin\");\n\nconst c = @import(\"c.zig\");\n\nconst log = std.log.scoped(.hid_seize);\n\npub const Match = struct {\n    vendor: u32,\n    product: u32,\n};\n\n/// One HID input value, decoded from `IOHIDValueRef`. Only what the\n/// pass-through path actually needs.\npub const Event = struct {\n    usage_page: u32,\n    usage: u32,\n    /// 1 for keydown / modifier set; 0 for keyup / modifier clear.\n    /// Booleans abstract over IOHIDValueGetIntegerValue's CFIndex.\n    pressed: bool,\n};\n\npub const Callback = *const fn (ctx: ?*anyopaque, event: Event) void;\n\n/// Singleton state. IOKit callbacks are C function pointers with no\n/// closure capture, so they need to find their way back to whatever\n/// owns the manager. We use one process-wide HidSeize anyway, so a\n/// global is the simplest representation.\nvar instance: ?*Self = null;\n\nallocator: std.mem.Allocator,\nmanager: c.IOHIDManagerRef,\ncallback: Callback,\ncallback_ctx: ?*anyopaque,\nrunning: bool = false,\n/// Open options actually applied to IOHIDManagerOpen. Tracked so\n/// stop() can mirror the same options on close.\nopen_options: u32 = 0,\n/// Owned copy of the matches passed to setMatches. Reused by\n/// disableCapsLockDelayOnMatches to filter event-system services\n/// to just the ones we seized.\nowned_matches: []Match = &.{},\n\nconst Self = @This();\n\npub fn init(allocator: std.mem.Allocator, callback: Callback, callback_ctx: ?*anyopaque) !*Self {\n    if (instance != null) return error.AlreadyInitialized;\n\n    const manager = c.IOHIDManagerCreate(c.kCFAllocatorDefault, c.kIOHIDOptionsTypeNone);\n    if (manager == null) return error.IOHIDManagerCreateFailed;\n    errdefer c.CFRelease(manager);\n\n    const self = try allocator.create(Self);\n    errdefer allocator.destroy(self);\n\n    self.* = .{\n        .allocator = allocator,\n        .manager = manager,\n        .callback = callback,\n        .callback_ctx = callback_ctx,\n    };\n    instance = self;\n    return self;\n}\n\npub fn deinit(self: *Self) void {\n    if (self.running) self.stop();\n    if (self.owned_matches.len > 0) self.allocator.free(self.owned_matches);\n    c.CFRelease(self.manager);\n    instance = null;\n    self.allocator.destroy(self);\n}\n\n/// Build a CFArray of dictionaries, one per match, and apply it as\n/// the manager's matching filter. Must be called before `start`.\n///\n/// We deliberately constrain to (Generic Desktop / Keyboard) only.\n/// Apple's built-in MacBook keyboard exposes other HID services on\n/// the same (vendor, product) — Apple Vendor at (0xFF00, 0x0B) for\n/// media keys, AppleMultitouchDevice at (0x0D, 0x0C) for the\n/// trackpad, etc. Seizing those is either disallowed by the kernel\n/// (`kIOReturnExclusiveAccess`) or breaks pointer/media input. By\n/// matching the keyboard service alone, the media-key service emits\n/// directly to the OS unchanged — F-row default actions keep working.\npub fn setMatches(self: *Self, matches: []const Match) !void {\n    if (self.running) return error.AlreadyRunning;\n\n    if (self.owned_matches.len > 0) self.allocator.free(self.owned_matches);\n    self.owned_matches = try self.allocator.dupe(Match, matches);\n    errdefer {\n        self.allocator.free(self.owned_matches);\n        self.owned_matches = &.{};\n    }\n\n    const dicts = c.CFArrayCreateMutable(c.kCFAllocatorDefault, @intCast(matches.len), &c.kCFTypeArrayCallBacks);\n    if (dicts == null) return error.CFArrayCreateFailed;\n    defer c.CFRelease(dicts);\n\n    for (matches) |m| {\n        const dict = c.CFDictionaryCreateMutable(\n            c.kCFAllocatorDefault,\n            4,\n            &c.kCFTypeDictionaryKeyCallBacks,\n            &c.kCFTypeDictionaryValueCallBacks,\n        );\n        if (dict == null) return error.CFDictionaryCreateFailed;\n        defer c.CFRelease(dict);\n\n        const vendor_key = c.CFStringCreateWithCString(c.kCFAllocatorDefault, c.kIOHIDVendorIDKey, c.kCFStringEncodingUTF8);\n        defer c.CFRelease(vendor_key);\n        const product_key = c.CFStringCreateWithCString(c.kCFAllocatorDefault, c.kIOHIDProductIDKey, c.kCFStringEncodingUTF8);\n        defer c.CFRelease(product_key);\n        const usage_page_key = c.CFStringCreateWithCString(c.kCFAllocatorDefault, c.kIOHIDPrimaryUsagePageKey, c.kCFStringEncodingUTF8);\n        defer c.CFRelease(usage_page_key);\n        const usage_key = c.CFStringCreateWithCString(c.kCFAllocatorDefault, c.kIOHIDPrimaryUsageKey, c.kCFStringEncodingUTF8);\n        defer c.CFRelease(usage_key);\n\n        var vendor: i32 = @intCast(m.vendor);\n        var product: i32 = @intCast(m.product);\n        var usage_page: i32 = c.kHIDPage_GenericDesktop;\n        var usage: i32 = c.kHIDUsage_GD_Keyboard;\n\n        const vendor_num = c.CFNumberCreate(c.kCFAllocatorDefault, c.kCFNumberSInt32Type, &vendor);\n        defer c.CFRelease(vendor_num);\n        const product_num = c.CFNumberCreate(c.kCFAllocatorDefault, c.kCFNumberSInt32Type, &product);\n        defer c.CFRelease(product_num);\n        const usage_page_num = c.CFNumberCreate(c.kCFAllocatorDefault, c.kCFNumberSInt32Type, &usage_page);\n        defer c.CFRelease(usage_page_num);\n        const usage_num = c.CFNumberCreate(c.kCFAllocatorDefault, c.kCFNumberSInt32Type, &usage);\n        defer c.CFRelease(usage_num);\n\n        c.CFDictionarySetValue(dict, vendor_key, vendor_num);\n        c.CFDictionarySetValue(dict, product_key, product_num);\n        c.CFDictionarySetValue(dict, usage_page_key, usage_page_num);\n        c.CFDictionarySetValue(dict, usage_key, usage_num);\n\n        c.CFArrayAppendValue(dicts, dict);\n    }\n\n    c.IOHIDManagerSetDeviceMatchingMultiple(self.manager, dicts);\n}\n\n/// Schedule the manager on the current run loop and open it.\n///\n/// `mode = .seize` opens with `kIOHIDOptionsTypeSeizeDevice` so the\n/// matched keyboards' events bypass the kernel HID stack and only\n/// flow into our `callback`.\n///\n/// `mode = .observe` opens passively — the kernel still sees the\n/// device's events and routes them to the foreground app; we get a\n/// copy via the callback. Used for diagnostics: confirms our matching\n/// + run-loop wiring before troubleshooting seize-specific issues.\npub const Mode = enum { seize, observe };\n\npub fn start(self: *Self, mode: Mode) !void {\n    if (self.running) return;\n\n    c.IOHIDManagerRegisterInputValueCallback(self.manager, valueCallback, self);\n    c.IOHIDManagerScheduleWithRunLoop(self.manager, c.CFRunLoopGetCurrent(), c.kCFRunLoopDefaultMode);\n\n    self.open_options = switch (mode) {\n        .seize => c.kIOHIDOptionsTypeSeizeDevice,\n        .observe => c.kIOHIDOptionsTypeNone,\n    };\n    const r = c.IOHIDManagerOpen(self.manager, self.open_options);\n    if (r != c.kIOReturnSuccess) {\n        c.IOHIDManagerUnscheduleFromRunLoop(self.manager, c.CFRunLoopGetCurrent(), c.kCFRunLoopDefaultMode);\n        log.err(\"IOHIDManagerOpen seize failed: 0x{X:0>8}\", .{@as(u32, @bitCast(r))});\n        return switch (r) {\n            c.kIOReturnNotPrivileged => error.NotPrivileged,\n            // kIOReturnNotPermitted: macOS TCC layer denied the\n            // operation — usually means the binary needs to be\n            // approved in System Settings → Privacy & Security →\n            // Input Monitoring. The first attempt triggers the\n            // approval dialog; subsequent runs are silent.\n            c.kIOReturnNotPermitted => error.NotPermitted,\n            c.kIOReturnExclusiveAccess => error.DeviceAlreadySeized,\n            else => error.IOHIDManagerOpenFailed,\n        };\n    }\n\n    self.running = true;\n\n    const matched = c.IOHIDManagerCopyDevices(self.manager);\n    const count: usize = if (matched) |s| @intCast(c.CFSetGetCount(s)) else 0;\n    log.info(\"seized matching devices (options=0x{x}, matched_count={d})\", .{ self.open_options, count });\n    if (count == 0) {\n        log.warn(\"matching dictionary captured 0 devices — vendor/product mismatch?\", .{});\n    }\n    if (matched) |s| c.CFRelease(s);\n\n    if (mode == .seize) {\n        self.disableCapsLockDelayOnMatches(self.owned_matches);\n    }\n}\n\n/// Set HIDKeyboardCapsLockDelayOverride=0 on every event-system\n/// service. Disables Apple's firmware-level \"hold caps_lock for ~150ms\n/// to toggle\" behavior — without this the toggle still fires through\n/// a side channel that IOHIDManager seize doesn't capture, so\n/// caps_lock-as-ctrl works at the FSM level but the OS's caps_lock\n/// state diverges (LED on, caps stuck).\n///\n/// Going through `IOHIDEventSystemClient` (private API) is what\n/// Karabiner does — `IOHIDDeviceSetProperty` returns success but the\n/// property is silently rejected, and `hidutil property --set`\n/// likewise doesn't persist. The event-system path takes effect.\n///\n/// We don't try to filter by vendor/product (event-system services\n/// don't expose those keys reliably) — setting the property on\n/// services that don't accept it is a no-op, and any keyboard where\n/// it does apply benefits from the same behavior we'd want.\nfn disableCapsLockDelayOnMatches(self: *Self, matches: []const Match) void {\n    _ = self;\n    _ = matches;\n    const evs = c.IOHIDEventSystemClientCreateSimpleClient(c.kCFAllocatorDefault);\n    if (evs == null) {\n        log.warn(\"IOHIDEventSystemClientCreateSimpleClient failed — caps_lock firmware toggle stays enabled\", .{});\n        return;\n    }\n    defer c.CFRelease(evs);\n\n    const services = c.IOHIDEventSystemClientCopyServices(evs);\n    if (services == null) {\n        log.warn(\"IOHIDEventSystemClientCopyServices returned null\", .{});\n        return;\n    }\n    defer c.CFRelease(services);\n\n    const delay_key = c.CFStringCreateWithCString(c.kCFAllocatorDefault, c.kIOHIDKeyboardCapsLockDelayOverrideKey, c.kCFStringEncodingUTF8);\n    if (delay_key == null) return;\n    defer c.CFRelease(delay_key);\n\n    var zero: i32 = 0;\n    const zero_cf = c.CFNumberCreate(c.kCFAllocatorDefault, c.kCFNumberSInt32Type, &zero);\n    if (zero_cf == null) return;\n    defer c.CFRelease(zero_cf);\n\n    var applied: usize = 0;\n    const total = c.CFArrayGetCount(services);\n    var i: c.CFIndex = 0;\n    while (i < total) : (i += 1) {\n        const raw = c.CFArrayGetValueAtIndex(services, i) orelse continue;\n        const svc: c.IOHIDServiceClientRef = @constCast(raw);\n        const ok = c.IOHIDServiceClientSetProperty(svc, delay_key, zero_cf);\n        if (ok != 0) applied += 1;\n    }\n    log.info(\"HIDKeyboardCapsLockDelayOverride=0 applied to {d}/{d} service(s)\", .{ applied, total });\n}\n\npub fn stop(self: *Self) void {\n    if (!self.running) return;\n    _ = c.IOHIDManagerClose(self.manager, self.open_options);\n    c.IOHIDManagerUnscheduleFromRunLoop(self.manager, c.CFRunLoopGetCurrent(), c.kCFRunLoopDefaultMode);\n    self.running = false;\n    log.info(\"released seize\", .{});\n}\n\nfn valueCallback(\n    ctx: ?*anyopaque,\n    result: c.IOReturn,\n    sender: ?*anyopaque,\n    value: c.IOHIDValueRef,\n) callconv(.C) void {\n    _ = sender;\n    if (result != c.kIOReturnSuccess) return;\n    const self: *Self = @ptrCast(@alignCast(ctx orelse return));\n\n    const element = c.IOHIDValueGetElement(value);\n    if (element == null) return;\n\n    const usage_page = c.IOHIDElementGetUsagePage(element);\n    const usage = c.IOHIDElementGetUsage(element);\n    const int_value = c.IOHIDValueGetIntegerValue(value);\n\n    self.callback(self.callback_ctx, .{\n        .usage_page = usage_page,\n        .usage = usage,\n        .pressed = int_value != 0,\n    });\n}\n"
  },
  {
    "path": "src/grabber/HidSystem.zig",
    "content": "//! Minimal IOHIDSystem client for forcing caps_lock state off.\n//!\n//! When IOHIDManager seize captures an Apple-built-in keyboard, the\n//! kernel's IOHIDSystem still receives caps_lock toggle through a\n//! firmware side channel — Apple keyboards do their own ~150ms\n//! hold-to-toggle, and the resulting state change reaches the OS\n//! independently of the HID events we get via seize. Result: the OS\n//! caps_lock state diverges, the menu-bar / LED show it on, and the\n//! user's tap-hold remap can't undo it (their tap maps to escape, not\n//! caps_lock).\n//!\n//! Same workaround Karabiner uses: open a connection to the\n//! IOHIDSystem service and call IOHIDSetModifierLockState(false)\n//! whenever a caps_lock event reaches us on a seized keyboard whose\n//! taphold rule remaps 0x39 — that resets the kernel's view of\n//! caps_lock state regardless of what the firmware just did.\n\nconst std = @import(\"std\");\nconst c = @import(\"c.zig\");\n\nconst log = std.log.scoped(.hid_system);\n\nconnect: c.io_connect_t,\n\nconst Self = @This();\n\npub fn init() !Self {\n    const matching = c.IOServiceMatching(\"IOHIDSystem\");\n    if (matching == null) return error.IOServiceMatchingFailed;\n    // IOServiceGetMatchingService consumes one reference on the\n    // matching dict, so we don't release it ourselves.\n    const service = c.IOServiceGetMatchingService(c.kIOMainPortDefault, matching);\n    if (service == 0) return error.IOHIDSystemNotFound;\n    defer _ = c.IOObjectRelease(service);\n\n    var connect: c.io_connect_t = 0;\n    const r = c.IOServiceOpen(service, c.mach_task_self_, c.kIOHIDParamConnectType, &connect);\n    if (r != c.kIOReturnSuccess) {\n        log.warn(\"IOServiceOpen IOHIDSystem failed: 0x{X:0>8}\", .{@as(u32, @bitCast(r))});\n        return error.IOHIDSystemOpenFailed;\n    }\n    return .{ .connect = connect };\n}\n\npub fn deinit(self: *Self) void {\n    _ = c.IOServiceClose(self.connect);\n    self.connect = 0;\n}\n\npub fn setCapsLockState(self: *Self, state: bool) void {\n    const r = c.IOHIDSetModifierLockState(self.connect, c.NX_MODIFIERKEY_ALPHALOCK, @intFromBool(state));\n    if (r != c.kIOReturnSuccess) {\n        log.warn(\"IOHIDSetModifierLockState failed: 0x{X:0>8}\", .{@as(u32, @bitCast(r))});\n    }\n}\n\npub fn getCapsLockState(self: *Self) ?bool {\n    var state: u8 = 0;\n    const r = c.IOHIDGetModifierLockState(self.connect, c.NX_MODIFIERKEY_ALPHALOCK, &state);\n    if (r != c.kIOReturnSuccess) return null;\n    return state != 0;\n}\n"
  },
  {
    "path": "src/grabber/Ipc.zig",
    "content": "//! Server-side handling of one IPC client session.\n//!\n//! Reads framed JSON messages, dispatches by `type`, writes back\n//! `ok`/`error`/`warn` responses. Synchronous, single-threaded — D5\n//! revisits this when per-uid lifecycle lands.\n\nconst std = @import(\"std\");\n\nconst protocol = @import(\"grabber_protocol\");\n\nconst log = std.log.scoped(.grabber_ipc);\n\n/// Owned (deep-copied) rules and remaps from one apply_rules\n/// message. Caller takes ownership and is responsible for freeing\n/// each Rule's hold_layer slice plus the rules and remaps slices\n/// themselves (see `freeApplied` below).\npub const AppliedRules = struct {\n    uid: u32,\n    rules: []protocol.Rule,\n    remaps: []protocol.Remap,\n    /// Mirror of macOS's \"Use F1, F2 … as standard function keys\" pref\n    /// (NSGlobalDomain `com.apple.keyboard.fnState`), read by the\n    /// per-user agent and forwarded so the grabber can flip its F-row\n    /// translation policy without doing a privileged prefs read itself.\n    /// false = bare F-row → media keys (the OS default).\n    fkeys_as_standard: bool = false,\n\n    pub fn free(self: AppliedRules, allocator: std.mem.Allocator) void {\n        for (self.rules) |r| {\n            if (r.hold_layer) |l| allocator.free(l);\n        }\n        allocator.free(self.rules);\n        allocator.free(self.remaps);\n    }\n};\n\n/// Result of one client session.\npub const ServeResult = union(enum) {\n    /// Client disconnected (sent bye, or closed) without leaving rules\n    /// active. Caller should drop the connection.\n    closed,\n    /// Client sent apply_rules with at least one rule. Caller takes\n    /// ownership of the parsed rules + remaps and the live socket;\n    /// keeping the socket open lets the grabber detect agent death\n    /// via EOS and tear down that subscription's rules.\n    rules_applied: AppliedRules,\n};\n\n/// Single typed envelope for everything the agent can send. All\n/// payload fields are optional so the same parse handles hello /\n/// apply_rules / bye in one pass — `type` selects which fields the\n/// handler consults. Slices borrow from the parse arena and must be\n/// deep-copied if they need to outlive the parse.\nconst Inbound = struct {\n    type: []const u8,\n    uid: ?u32 = null,\n    version: ?u32 = null,\n    rules: ?[]const protocol.Rule = null,\n    remaps: ?[]const protocol.Remap = null,\n    /// See AppliedRules.fkeys_as_standard. Optional so older agents\n    /// (without the field) still parse — they get the OS default.\n    fkeys_as_standard: ?bool = null,\n};\n\npub fn serve(allocator: std.mem.Allocator, stream: std.net.Stream) !ServeResult {\n    // Per-session state: the protocol expects hello first; record the\n    // client uid for subsequent messages and reject anything else\n    // before hello.\n    var client_uid: ?u32 = null;\n\n    // Match protocol.max_frame_bytes so the server accepts every frame\n    // the shared protocol declares valid.\n    var buf: [protocol.max_frame_bytes]u8 = undefined;\n\n    while (true) {\n        const n = protocol.readFrame(stream, &buf) catch |err| switch (err) {\n            error.EndOfStream => return .closed, // peer closed; end of session\n            else => return err,\n        };\n\n        var parsed = std.json.parseFromSlice(Inbound, allocator, buf[0..n], .{\n            .ignore_unknown_fields = true,\n        }) catch |err| {\n            try sendError(allocator, stream, \"bad_json\", @errorName(err));\n            return err;\n        };\n        defer parsed.deinit();\n\n        const msg = parsed.value;\n        const kind = msg.type;\n\n        if (std.mem.eql(u8, kind, \"hello\")) {\n            client_uid = try handleHello(allocator, stream, msg);\n        } else if (std.mem.eql(u8, kind, \"apply_rules\")) {\n            if (client_uid == null) {\n                try sendError(allocator, stream, \"no_hello\", \"send hello before apply_rules\");\n                return error.ProtocolViolation;\n            }\n            const applied = try handleApplyRules(allocator, stream, msg, client_uid.?);\n            if (applied) |a| {\n                // Hand the parsed rules back to the daemon along with\n                // ownership of the connection. The daemon turns this\n                // into a Subscription it can later GC when the socket\n                // goes EOS.\n                return .{ .rules_applied = a };\n            }\n            // Empty apply_rules clears the rule set. Continue\n            // handling further messages on the same connection.\n        } else if (std.mem.eql(u8, kind, \"bye\")) {\n            try sendOk(allocator, stream);\n            return .closed;\n        } else {\n            try sendError(allocator, stream, \"unknown_type\", kind);\n            return error.UnknownMessageType;\n        }\n    }\n}\n\nfn handleHello(allocator: std.mem.Allocator, stream: std.net.Stream, msg: Inbound) !u32 {\n    const uid = msg.uid orelse {\n        try sendError(allocator, stream, \"bad_hello\", \"missing 'uid'\");\n        return error.BadHello;\n    };\n    const version = msg.version orelse {\n        try sendError(allocator, stream, \"bad_hello\", \"missing 'version'\");\n        return error.BadHello;\n    };\n    if (version != protocol.protocol_version) {\n        try sendError(allocator, stream, \"version_mismatch\", \"agent and grabber differ on protocol version\");\n        return error.VersionMismatch;\n    }\n    log.info(\"hello from uid={d} version={d}\", .{ uid, version });\n    try sendOk(allocator, stream);\n    return uid;\n}\n\n/// Validate the apply_rules payload, deep-copy rules+remaps so they\n/// outlive the parse arena, and return them as an `AppliedRules`. On\n/// an empty payload (no rules, no remaps) returns null — the caller\n/// keeps the connection open and waits for the next message.\nfn handleApplyRules(\n    allocator: std.mem.Allocator,\n    stream: std.net.Stream,\n    msg: Inbound,\n    uid: u32,\n) !?AppliedRules {\n    const rules = msg.rules orelse {\n        try sendError(allocator, stream, \"bad_apply\", \"missing 'rules'\");\n        return error.BadApply;\n    };\n    const remaps = msg.remaps orelse &[_]protocol.Remap{};\n\n    const fkeys_as_standard = msg.fkeys_as_standard orelse false;\n\n    log.info(\"apply_rules uid={d} rules={d} remaps={d} fkeys_as_standard={}\", .{ uid, rules.len, remaps.len, fkeys_as_standard });\n    for (rules, 0..) |r, i| {\n        log.info(\n            \"  rule[{d}]: src=0x{X:0>2} tap=0x{X:0>2} hold=0x{X:0>2} timeout={d}ms perm={} hokp={} retro={}\",\n            .{ i, r.src_usage, r.tap_usage, r.hold_usage, r.timeout_ms, r.permissive_hold, r.hold_on_other_key_press, r.retro_tap },\n        );\n        if (r.device) |d| {\n            log.info(\"    device: vendor=0x{X:0>4} product=0x{X:0>4}\", .{ d.vendor, d.product });\n        }\n    }\n    for (remaps, 0..) |r, i| {\n        log.info(\n            \"  remap[{d}]: src=0x{X:0>2} → dst=0x{X:0>2} on vendor=0x{X:0>4} product=0x{X:0>4}\",\n            .{ i, r.src_usage, r.dst_usage, r.device.vendor, r.device.product },\n        );\n    }\n\n    try sendOk(allocator, stream);\n\n    if (rules.len == 0 and remaps.len == 0) return null;\n\n    // Deep-copy out of the JSON arena so the result outlives this\n    // function. Caller frees via AppliedRules.free.\n    const owned_rules = try allocator.alloc(protocol.Rule, rules.len);\n    errdefer allocator.free(owned_rules);\n    var i: usize = 0;\n    errdefer while (i > 0) : (i -= 1) {\n        if (owned_rules[i - 1].hold_layer) |l| allocator.free(l);\n    };\n    for (rules) |r| {\n        var copy = r;\n        if (r.hold_layer) |l| copy.hold_layer = try allocator.dupe(u8, l);\n        owned_rules[i] = copy;\n        i += 1;\n    }\n\n    const owned_remaps = try allocator.alloc(protocol.Remap, remaps.len);\n    errdefer allocator.free(owned_remaps);\n    @memcpy(owned_remaps, remaps);\n\n    return .{\n        .uid = uid,\n        .rules = owned_rules,\n        .remaps = owned_remaps,\n        .fkeys_as_standard = fkeys_as_standard,\n    };\n}\n\nfn sendOk(allocator: std.mem.Allocator, stream: std.net.Stream) !void {\n    try protocol.writeMessage(stream, allocator, .{ .type = \"ok\" });\n}\n\nfn sendError(allocator: std.mem.Allocator, stream: std.net.Stream, code: []const u8, message: []const u8) !void {\n    log.warn(\"error: code={s} message={s}\", .{ code, message });\n    try protocol.writeMessage(stream, allocator, .{\n        .type = \"error\",\n        .code = code,\n        .message = message,\n    });\n}\n"
  },
  {
    "path": "src/grabber/KbState.zig",
    "content": "//! HID keyboard state aggregator.\n//!\n//! HID input value events arrive one transition at a time (key X\n//! went down, key Y went up). The vhidd `post_keyboard_input_report`\n//! protocol takes a *snapshot* — the current modifier byte plus the\n//! list of all currently-held non-modifier keys.\n//!\n//! This struct keeps the state and emits the snapshot on demand.\n//!\n//! Caps-lock is treated like any other HID key; macOS's special\n//! caps-lock state-machine semantics are bypassed because we're\n//! injecting through the virtual HID keyboard, which is what\n//! \"consumes\" caps_lock at the hardware level. (D4 will remap\n//! caps_lock to escape/ctrl before it reaches this aggregator.)\n\nconst std = @import(\"std\");\n\nconst Vhidd = @import(\"Vhidd.zig\");\n\nconst log = std.log.scoped(.kbstate);\n\n/// HID Usage Page 0x07 modifier usages.\nconst usage_left_control = 0xE0;\nconst usage_left_shift = 0xE1;\nconst usage_left_option = 0xE2;\nconst usage_left_command = 0xE3;\nconst usage_right_control = 0xE4;\nconst usage_right_shift = 0xE5;\nconst usage_right_option = 0xE6;\nconst usage_right_command = 0xE7;\n\n/// A non-modifier key slot, kept in insertion order so reports are\n/// stable. Empty slot = 0.\nconst max_keys = 32;\n\nmodifiers: Vhidd.Modifier = .{},\nkeys: [max_keys]u16 = @splat(0),\n\nconst Self = @This();\n\n/// Apply one HID Usage Page 0x07 transition.\n///\n/// `usage` is the HID usage code (e.g. 0x04 = 'a', 0xE0 = left ctrl).\n/// `pressed` is true on keydown, false on keyup.\n///\n/// Returns true if the state changed; the caller should typically\n/// emit a fresh report whenever this returns true.\npub fn applyKeyboardEvent(self: *Self, usage: u16, pressed: bool) bool {\n    if (modifierFor(usage)) |bit| {\n        return self.applyModifier(bit, pressed);\n    }\n    return if (pressed) self.insertKey(usage) else self.eraseKey(usage);\n}\n\n/// Drop all held keys / modifiers (used at startup and when seize is\n/// released so we don't leave phantom keys held in the virtual HID).\npub fn clear(self: *Self) void {\n    self.* = .{};\n}\n\n/// Compact the keys array into a contiguous prefix and return it.\n/// Mutates `self.keys` to keep slots packed at the front so the\n/// returned slice is suitable for `Vhidd.Client.postKeyboardReport`.\npub fn compactedKeys(self: *Self) []const u16 {\n    var write: usize = 0;\n    for (self.keys) |k| {\n        if (k != 0) {\n            self.keys[write] = k;\n            write += 1;\n        }\n    }\n    while (write < self.keys.len) : (write += 1) {\n        self.keys[write] = 0;\n    }\n    var n: usize = 0;\n    for (self.keys) |k| {\n        if (k == 0) break;\n        n += 1;\n    }\n    return self.keys[0..n];\n}\n\nfn modifierFor(usage: u16) ?Modifier {\n    return switch (usage) {\n        usage_left_control => .left_control,\n        usage_left_shift => .left_shift,\n        usage_left_option => .left_option,\n        usage_left_command => .left_command,\n        usage_right_control => .right_control,\n        usage_right_shift => .right_shift,\n        usage_right_option => .right_option,\n        usage_right_command => .right_command,\n        else => null,\n    };\n}\n\nconst Modifier = enum {\n    left_control,\n    left_shift,\n    left_option,\n    left_command,\n    right_control,\n    right_shift,\n    right_option,\n    right_command,\n};\n\nfn applyModifier(self: *Self, m: Modifier, pressed: bool) bool {\n    const before = self.modifiers;\n    switch (m) {\n        .left_control => self.modifiers.left_control = pressed,\n        .left_shift => self.modifiers.left_shift = pressed,\n        .left_option => self.modifiers.left_option = pressed,\n        .left_command => self.modifiers.left_command = pressed,\n        .right_control => self.modifiers.right_control = pressed,\n        .right_shift => self.modifiers.right_shift = pressed,\n        .right_option => self.modifiers.right_option = pressed,\n        .right_command => self.modifiers.right_command = pressed,\n    }\n    return @as(u8, @bitCast(before)) != @as(u8, @bitCast(self.modifiers));\n}\n\nfn insertKey(self: *Self, key: u16) bool {\n    for (self.keys) |k| {\n        if (k == key) return false; // already held\n    }\n    for (&self.keys) |*k| {\n        if (k.* == 0) {\n            k.* = key;\n            return true;\n        }\n    }\n    log.warn(\"keys[] overflow — dropping insert of usage 0x{X:0>2}\", .{key});\n    return false;\n}\n\nfn eraseKey(self: *Self, key: u16) bool {\n    var changed = false;\n    for (&self.keys) |*k| {\n        if (k.* == key) {\n            k.* = 0;\n            changed = true;\n        }\n    }\n    return changed;\n}\n\n/// State for a HID page that has no modifiers — Consumer (0x0C),\n/// Apple Vendor Top Case (0xFF), Apple Vendor Keyboard (0xFF01).\n/// Same insert/erase/compact pattern as the keyboard page above, just\n/// without the modifier byte.\npub const PageState = struct {\n    keys: [max_keys]u16 = @splat(0),\n\n    pub fn apply(self: *PageState, usage: u16, pressed: bool) bool {\n        return if (pressed) insertInto(&self.keys, usage) else eraseFrom(&self.keys, usage);\n    }\n\n    pub fn compacted(self: *PageState) []const u16 {\n        var write: usize = 0;\n        for (self.keys) |k| {\n            if (k != 0) {\n                self.keys[write] = k;\n                write += 1;\n            }\n        }\n        while (write < self.keys.len) : (write += 1) {\n            self.keys[write] = 0;\n        }\n        var n: usize = 0;\n        for (self.keys) |k| {\n            if (k == 0) break;\n            n += 1;\n        }\n        return self.keys[0..n];\n    }\n\n    pub fn clear(self: *PageState) void {\n        self.* = .{};\n    }\n};\n\nfn insertInto(arr: *[max_keys]u16, key: u16) bool {\n    for (arr) |k| if (k == key) return false;\n    for (arr) |*k| {\n        if (k.* == 0) {\n            k.* = key;\n            return true;\n        }\n    }\n    log.warn(\"page-keys[] overflow — dropping insert of usage 0x{X:0>2}\", .{key});\n    return false;\n}\n\nfn eraseFrom(arr: *[max_keys]u16, key: u16) bool {\n    var changed = false;\n    for (arr) |*k| {\n        if (k.* == key) {\n            k.* = 0;\n            changed = true;\n        }\n    }\n    return changed;\n}\n\ntest \"modifier press/release flips the bit\" {\n    var s: Self = .{};\n    try std.testing.expect(s.applyKeyboardEvent(0xE0, true)); // lctrl down\n    try std.testing.expect(s.modifiers.left_control);\n\n    try std.testing.expect(s.applyKeyboardEvent(0xE0, false));\n    try std.testing.expect(!s.modifiers.left_control);\n}\n\ntest \"key insert and erase\" {\n    var s: Self = .{};\n    _ = s.applyKeyboardEvent(0x04, true); // 'a'\n    _ = s.applyKeyboardEvent(0x05, true); // 'b'\n    const held = s.compactedKeys();\n    try std.testing.expectEqual(@as(usize, 2), held.len);\n    try std.testing.expectEqual(@as(u16, 0x04), held[0]);\n    try std.testing.expectEqual(@as(u16, 0x05), held[1]);\n\n    _ = s.applyKeyboardEvent(0x04, false);\n    const held2 = s.compactedKeys();\n    try std.testing.expectEqual(@as(usize, 1), held2.len);\n    try std.testing.expectEqual(@as(u16, 0x05), held2[0]);\n}\n\ntest \"double-press is idempotent\" {\n    var s: Self = .{};\n    try std.testing.expect(s.applyKeyboardEvent(0x04, true));\n    try std.testing.expect(!s.applyKeyboardEvent(0x04, true));\n}\n"
  },
  {
    "path": "src/grabber/TapHold.zig",
    "content": "//! Tap-hold state machine for one rule.\n//!\n//! HID events come in one transition at a time (key X went up/down).\n//! The FSM watches a single \"source\" usage: while it's held, the\n//! engine swallows the source's normal output and waits to commit\n//! either the **tap** action (a different key emitted as a brief\n//! tap) or the **hold** action (a different key — typically a\n//! modifier — emitted continuously while the source is held).\n//!\n//! QMK semantics, with the same knobs Karabiner exposes:\n//!\n//! - `timeout_ms`: source held > this → commit hold. Default 200ms.\n//! - `hold_on_other_key_press`: any other key down before the timer\n//!   fires immediately commits the hold (and lets the other key\n//!   pass through under the held mod). Default off.\n//! - `permissive_hold`: another key fully tapped (down + up) while\n//!   the source is held commits the hold (the buffered other key\n//!   is then re-emitted under the held mod). Default off.\n//! - `retro_tap`: deferred to a follow-up phase.\n//!\n//! The owner runs the timer (CFRunLoopTimer in production); this\n//! file only manages the FSM. Each `feed()` / `timerFired()` returns\n//! a `TimerAction` describing what the owner should do with the\n//! pending timer (start/cancel/leave-alone) and emits any\n//! synthesized HID events through the sink callback.\n\nconst std = @import(\"std\");\n\nconst log = std.log.scoped(.taphold);\n\n/// One HID transition. Mirrors the shape used by the seize callback\n/// so the same struct flows through TapHold and KbState.\npub const Event = struct {\n    usage_page: u32,\n    usage: u32,\n    pressed: bool,\n};\n\n/// What to do when a tap-hold rule commits its hold action.\npub const HoldAction = union(enum) {\n    /// Emit a HID page-7 usage (modifier or any key). Used for\n    /// caps_lock → ctrl, etc.\n    hid_usage: u16,\n    /// Push a named mode onto the agent. Used for layer holds like\n    /// `space → fn_layer`. Lifetime: the engine borrows the slice;\n    /// caller keeps it valid until the engine is dropped.\n    layer: []const u8,\n};\n\npub const Rule = struct {\n    src_usage: u16,\n    tap_usage: u16,\n    hold: HoldAction,\n    timeout_ms: u32 = 200,\n    permissive_hold: bool = false,\n    hold_on_other_key_press: bool = false,\n    retro_tap: bool = false,\n};\n\n/// What the owner should do with its timer after the engine\n/// returned. Only one of `start_in_ms` / `cancel` is meaningful per\n/// call.\npub const TimerAction = union(enum) {\n    none,\n    start_in_ms: u32,\n    cancel,\n};\n\n/// Sink for synthesized HID events. Called synchronously from\n/// `feed`/`timerFired` for every emitted transition. The owner's\n/// implementation is expected to update its KbState and post the\n/// resulting report to vhidd.\npub const Sink = *const fn (ctx: ?*anyopaque, ev: Event) void;\n\n/// Sink for layer enter/exit events. Called when a layer-hold rule\n/// commits or releases. `entering = true` means push the named layer;\n/// `entering = false` means pop back to the previous mode (the owner\n/// implements that semantic — the engine doesn't track stack depth).\npub const LayerSink = *const fn (ctx: ?*anyopaque, layer: []const u8, entering: bool) void;\n\n/// Disposition of an incoming event: pass = the engine doesn't care\n/// about this usage, owner should forward; consumed = engine handled\n/// it (possibly by emitting through the sink).\npub const Disposition = enum { pass, consumed };\n\nconst State = enum {\n    idle,\n    /// Source is held, timer running, no decision yet. Other keys\n    /// may be pending in `buffer` if permissive_hold is on.\n    pending,\n    /// Hold action committed and emitted. Source still held.\n    decided_hold,\n};\n\n/// Buffer used in permissive_hold mode to hold \"other key\" events\n/// that arrived between source_down and source_up. We only need a\n/// few slots — most users don't pile up keys faster than the\n/// timeout.\nconst max_buffered_events = 16;\n\nrule: Rule,\nstate: State = .idle,\nsink: Sink,\nsink_ctx: ?*anyopaque,\n/// Optional layer sink — required if rule.hold is a layer; ignored\n/// otherwise. Owner's responsibility.\nlayer_sink: ?LayerSink = null,\nlayer_sink_ctx: ?*anyopaque = null,\nbuffer: std.BoundedArray(Event, max_buffered_events) = .{},\n/// HID usages currently parked in `buffer` as a still-pressed down\n/// (not yet matched by an up). Used to decide whether an arriving\n/// key-up should be buffered (its down was buffered, replay them\n/// together to preserve order) or passed through immediately (its\n/// down was emitted before the pending window — buffering the up\n/// would let the OS see the key as held for the entire pending\n/// window and autorepeat it).\nbuffered_downs: std.BoundedArray(u16, max_buffered_events) = .{},\n/// Whether any non-source key event has been seen since this rule\n/// last entered pending. Drives `retro_tap` (emit tap on release if\n/// nothing else was pressed during the hold).\nother_key_seen: bool = false,\n\nconst Self = @This();\n\npub fn init(rule: Rule, sink: Sink, sink_ctx: ?*anyopaque) Self {\n    return .{ .rule = rule, .sink = sink, .sink_ctx = sink_ctx };\n}\n\npub fn initWithLayerSink(\n    rule: Rule,\n    sink: Sink,\n    sink_ctx: ?*anyopaque,\n    layer_sink: LayerSink,\n    layer_sink_ctx: ?*anyopaque,\n) Self {\n    return .{\n        .rule = rule,\n        .sink = sink,\n        .sink_ctx = sink_ctx,\n        .layer_sink = layer_sink,\n        .layer_sink_ctx = layer_sink_ctx,\n    };\n}\n\npub fn feed(self: *Self, ev: Event) struct { disposition: Disposition, timer: TimerAction } {\n    const is_source = ev.usage_page == 0x07 and ev.usage == self.rule.src_usage;\n\n    switch (self.state) {\n        .idle => {\n            if (is_source and ev.pressed) {\n                self.state = .pending;\n                self.other_key_seen = false;\n                return .{ .disposition = .consumed, .timer = .{ .start_in_ms = self.rule.timeout_ms } };\n            }\n            // Source key-up while idle (e.g. stuck-key recovery): nothing\n            // for us to do; let the owner pass it through.\n            return .{ .disposition = .pass, .timer = .none };\n        },\n\n        .pending => {\n            if (is_source) {\n                if (!ev.pressed) {\n                    // Source released before timeout / before any\n                    // permissive-hold trigger → it was a tap.\n                    self.commitTap();\n                    return .{ .disposition = .consumed, .timer = .cancel };\n                }\n                // Source-down repeated (key repeat at the OS level\n                // rarely fires here since seize is per-event, but\n                // be defensive). Stay in pending.\n                return .{ .disposition = .consumed, .timer = .none };\n            }\n\n            // Track for retro_tap.\n            self.other_key_seen = true;\n\n            // Other key arrived while pending.\n            if (self.rule.hold_on_other_key_press and ev.pressed) {\n                // Eager commit: the moment another key is pressed,\n                // we know the user is using the source as a hold.\n                self.emitHoldDown();\n                self.state = .decided_hold;\n                self.flushBuffer();\n                return .{ .disposition = .pass, .timer = .cancel };\n            }\n\n            // Layer rules always buffer non-source events through the\n            // pending window so the typing order is preserved when we\n            // commit. permissive_hold buffers too, but additionally\n            // treats a nested tap as proof of intent-to-hold (used by\n            // modifier-style rules; doesn't fit layer holds well).\n            if (self.isLayer() or self.rule.permissive_hold) {\n                const ev_usage16: u16 = std.math.cast(u16, ev.usage) orelse 0;\n                if (!ev.pressed) {\n                    // For an UP event, only buffer if the matching\n                    // DOWN was also buffered. Otherwise the down is\n                    // already at the OS and delaying the up would\n                    // let the OS see the key as held for the entire\n                    // pending window — at which point autorepeat\n                    // fires and a single physical press shows up as\n                    // many characters.\n                    var down_was_buffered = false;\n                    for (self.buffered_downs.constSlice(), 0..) |u, i| {\n                        if (u == ev_usage16) {\n                            _ = self.buffered_downs.swapRemove(i);\n                            down_was_buffered = true;\n                            break;\n                        }\n                    }\n                    if (!down_was_buffered) {\n                        log.info(\"pass-through up: src=0x{X:0>2} usage=0x{X:0>2} (down was external)\", .{ self.rule.src_usage, ev.usage });\n                        return .{ .disposition = .pass, .timer = .none };\n                    }\n                }\n                self.buffer.append(ev) catch {\n                    log.warn(\"buffer overflow — flushing as tap\", .{});\n                    self.commitTap();\n                    return .{ .disposition = .pass, .timer = .cancel };\n                };\n                if (ev.pressed) {\n                    self.buffered_downs.append(ev_usage16) catch {};\n                }\n                log.info(\"buffer: slot src=0x{X:0>2} +usage=0x{X:0>2} pressed={} (depth={d})\", .{ self.rule.src_usage, ev.usage, ev.pressed, self.buffer.len });\n                if (self.rule.permissive_hold and !self.isLayer() and !ev.pressed) {\n                    // Modifier-style permissive_hold: nested down+up\n                    // commits hold and replays the inner key under\n                    // the modifier. Layer holds skip this — it\n                    // misclassifies natural typing roll-overs (where\n                    // user presses next letter before releasing the\n                    // layer key) as deliberate layer use.\n                    self.emitHoldDown();\n                    self.state = .decided_hold;\n                    self.flushBuffer();\n                    return .{ .disposition = .consumed, .timer = .cancel };\n                }\n                return .{ .disposition = .consumed, .timer = .none };\n            }\n\n            // Default behaviour: other keys pass through immediately\n            // and we keep waiting for source_up or timeout.\n            return .{ .disposition = .pass, .timer = .none };\n        },\n\n        .decided_hold => {\n            if (is_source) {\n                if (!ev.pressed) {\n                    // Hold finished.\n                    self.emitHoldUp();\n                    // QMK retro_tap: if no other key was pressed\n                    // during the entire hold window, fall back to\n                    // emitting the tap action on release. Lets a\n                    // user who accidentally over-held the source key\n                    // still get the character (or whatever the tap\n                    // action is) without losing it.\n                    if (self.rule.retro_tap and !self.other_key_seen) {\n                        log.info(\"retro_tap: no other key seen — emit tap on release\", .{});\n                        self.emit(self.rule.tap_usage, true);\n                        self.emit(self.rule.tap_usage, false);\n                    }\n                    self.state = .idle;\n                    return .{ .disposition = .consumed, .timer = .none };\n                }\n                // Source-down repeated while already held — same\n                // story as in pending; stay put.\n                return .{ .disposition = .consumed, .timer = .none };\n            }\n            // Anything else just flows through under the held mod.\n            self.other_key_seen = true;\n            return .{ .disposition = .pass, .timer = .none };\n        },\n    }\n}\n\npub fn timerFired(self: *Self) TimerAction {\n    if (self.state != .pending) return .none;\n    self.emitHoldDown();\n    self.state = .decided_hold;\n    self.flushBuffer();\n    return .none;\n}\n\nfn isLayer(self: *const Self) bool {\n    return std.meta.activeTag(self.rule.hold) == .layer;\n}\n\nfn commitTap(self: *Self) void {\n    log.info(\"commit tap: src=0x{X:0>2} → tap=0x{X:0>2}\", .{ self.rule.src_usage, self.rule.tap_usage });\n    self.emit(self.rule.tap_usage, true);\n    self.emit(self.rule.tap_usage, false);\n    // After tap, replay any buffered events in their original order.\n    self.flushBuffer();\n    self.state = .idle;\n}\n\nfn emitHoldDown(self: *Self) void {\n    switch (self.rule.hold) {\n        .hid_usage => |u| {\n            log.info(\"commit hold: src=0x{X:0>2} → hold=0x{X:0>2} down\", .{ self.rule.src_usage, u });\n            self.emit(u, true);\n        },\n        .layer => |name| {\n            log.info(\"commit hold: src=0x{X:0>2} → enter layer '{s}'\", .{ self.rule.src_usage, name });\n            if (self.layer_sink) |ls| {\n                ls(self.layer_sink_ctx, name, true);\n            } else {\n                log.warn(\"layer hold for '{s}' has no layer sink — drop\", .{name});\n            }\n        },\n    }\n}\n\nfn emitHoldUp(self: *Self) void {\n    switch (self.rule.hold) {\n        .hid_usage => |u| {\n            log.info(\"commit hold: src=0x{X:0>2} → hold=0x{X:0>2} up\", .{ self.rule.src_usage, u });\n            self.emit(u, false);\n        },\n        .layer => |name| {\n            log.info(\"commit hold: src=0x{X:0>2} → exit layer '{s}'\", .{ self.rule.src_usage, name });\n            if (self.layer_sink) |ls| {\n                ls(self.layer_sink_ctx, name, false);\n            }\n        },\n    }\n}\n\nfn emit(self: *Self, usage: u16, pressed: bool) void {\n    self.sink(self.sink_ctx, .{\n        .usage_page = 0x07,\n        .usage = usage,\n        .pressed = pressed,\n    });\n}\n\nfn flushBuffer(self: *Self) void {\n    if (self.buffer.len > 0) {\n        log.info(\"flush: slot src=0x{X:0>2} replaying {d} buffered event(s)\", .{ self.rule.src_usage, self.buffer.len });\n    }\n    for (self.buffer.constSlice()) |ev| {\n        self.sink(self.sink_ctx, ev);\n    }\n    self.buffer.clear();\n    self.buffered_downs.clear();\n}\n\n// ─── tests ───────────────────────────────────────────────────────\n\nconst TestSink = struct {\n    out: std.ArrayList(Event),\n\n    fn init(allocator: std.mem.Allocator) TestSink {\n        return .{ .out = std.ArrayList(Event).init(allocator) };\n    }\n\n    fn deinit(self: *TestSink) void {\n        self.out.deinit();\n    }\n\n    fn callback(ctx: ?*anyopaque, ev: Event) void {\n        const self: *TestSink = @ptrCast(@alignCast(ctx.?));\n        self.out.append(ev) catch unreachable;\n    }\n};\n\nfn kbev(usage: u16, pressed: bool) Event {\n    return .{ .usage_page = 0x07, .usage = usage, .pressed = pressed };\n}\n\ntest \"tap path: quick source up emits tap_down + tap_up\" {\n    var sink = TestSink.init(std.testing.allocator);\n    defer sink.deinit();\n    var eng = init(.{ .src_usage = 0x39, .tap_usage = 0x29, .hold = .{ .hid_usage = 0xE0 } }, TestSink.callback, &sink);\n\n    const r1 = eng.feed(kbev(0x39, true));\n    try std.testing.expectEqual(Disposition.consumed, r1.disposition);\n    try std.testing.expectEqual(TimerAction{ .start_in_ms = 200 }, r1.timer);\n    try std.testing.expectEqual(@as(usize, 0), sink.out.items.len);\n\n    const r2 = eng.feed(kbev(0x39, false));\n    try std.testing.expectEqual(Disposition.consumed, r2.disposition);\n    try std.testing.expectEqual(TimerAction.cancel, r2.timer);\n    try std.testing.expectEqual(@as(usize, 2), sink.out.items.len);\n    try std.testing.expectEqual(@as(u32, 0x29), sink.out.items[0].usage);\n    try std.testing.expect(sink.out.items[0].pressed);\n    try std.testing.expectEqual(@as(u32, 0x29), sink.out.items[1].usage);\n    try std.testing.expect(!sink.out.items[1].pressed);\n}\n\ntest \"hold path: timer fires emits hold_down, source up emits hold_up\" {\n    var sink = TestSink.init(std.testing.allocator);\n    defer sink.deinit();\n    var eng = init(.{ .src_usage = 0x39, .tap_usage = 0x29, .hold = .{ .hid_usage = 0xE0 } }, TestSink.callback, &sink);\n\n    _ = eng.feed(kbev(0x39, true));\n    _ = eng.timerFired();\n    try std.testing.expectEqual(@as(usize, 1), sink.out.items.len);\n    try std.testing.expectEqual(@as(u32, 0xE0), sink.out.items[0].usage);\n    try std.testing.expect(sink.out.items[0].pressed);\n\n    const r = eng.feed(kbev(0x39, false));\n    try std.testing.expectEqual(Disposition.consumed, r.disposition);\n    try std.testing.expectEqual(@as(usize, 2), sink.out.items.len);\n    try std.testing.expectEqual(@as(u32, 0xE0), sink.out.items[1].usage);\n    try std.testing.expect(!sink.out.items[1].pressed);\n}\n\ntest \"default: other key passes through during pending without committing\" {\n    var sink = TestSink.init(std.testing.allocator);\n    defer sink.deinit();\n    var eng = init(.{ .src_usage = 0x39, .tap_usage = 0x29, .hold = .{ .hid_usage = 0xE0 } }, TestSink.callback, &sink);\n\n    _ = eng.feed(kbev(0x39, true));\n    const r = eng.feed(kbev(0x04, true)); // 'a' down\n    try std.testing.expectEqual(Disposition.pass, r.disposition);\n    try std.testing.expectEqual(TimerAction.none, r.timer);\n    try std.testing.expectEqual(@as(usize, 0), sink.out.items.len);\n\n    // Source release within timeout still commits a tap (default\n    // behaviour — other-key-down does not flip the decision).\n    _ = eng.feed(kbev(0x39, false));\n    try std.testing.expectEqual(@as(usize, 2), sink.out.items.len);\n    try std.testing.expectEqual(@as(u32, 0x29), sink.out.items[0].usage);\n}\n\ntest \"hold_on_other_key_press: other-key-down immediately commits hold\" {\n    var sink = TestSink.init(std.testing.allocator);\n    defer sink.deinit();\n    var eng = init(.{\n        .src_usage = 0x39,\n        .tap_usage = 0x29,\n        .hold = .{ .hid_usage = 0xE0 },\n        .hold_on_other_key_press = true,\n    }, TestSink.callback, &sink);\n\n    _ = eng.feed(kbev(0x39, true));\n    const r = eng.feed(kbev(0x04, true));\n    try std.testing.expectEqual(Disposition.pass, r.disposition);\n    try std.testing.expectEqual(TimerAction.cancel, r.timer);\n    try std.testing.expectEqual(@as(usize, 1), sink.out.items.len);\n    try std.testing.expectEqual(@as(u32, 0xE0), sink.out.items[0].usage);\n    try std.testing.expect(sink.out.items[0].pressed);\n}\n\ntest \"permissive_hold: other-key tap during source-held commits hold + replays\" {\n    var sink = TestSink.init(std.testing.allocator);\n    defer sink.deinit();\n    var eng = init(.{\n        .src_usage = 0x39,\n        .tap_usage = 0x29,\n        .hold = .{ .hid_usage = 0xE0 },\n        .permissive_hold = true,\n    }, TestSink.callback, &sink);\n\n    _ = eng.feed(kbev(0x39, true));\n    const r1 = eng.feed(kbev(0x04, true)); // 'a' down — buffered\n    try std.testing.expectEqual(Disposition.consumed, r1.disposition);\n    try std.testing.expectEqual(@as(usize, 0), sink.out.items.len);\n\n    const r2 = eng.feed(kbev(0x04, false)); // 'a' up — commits hold + replays\n    try std.testing.expectEqual(Disposition.consumed, r2.disposition);\n    try std.testing.expectEqual(TimerAction.cancel, r2.timer);\n    // Output: hold_down (0xE0 down), then buffered a-down, then a-up.\n    try std.testing.expectEqual(@as(usize, 3), sink.out.items.len);\n    try std.testing.expectEqual(@as(u32, 0xE0), sink.out.items[0].usage);\n    try std.testing.expect(sink.out.items[0].pressed);\n    try std.testing.expectEqual(@as(u32, 0x04), sink.out.items[1].usage);\n    try std.testing.expect(sink.out.items[1].pressed);\n    try std.testing.expectEqual(@as(u32, 0x04), sink.out.items[2].usage);\n    try std.testing.expect(!sink.out.items[2].pressed);\n}\n\ntest \"permissive_hold: source up before other-key up emits tap then replays\" {\n    var sink = TestSink.init(std.testing.allocator);\n    defer sink.deinit();\n    var eng = init(.{\n        .src_usage = 0x39,\n        .tap_usage = 0x29,\n        .hold = .{ .hid_usage = 0xE0 },\n        .permissive_hold = true,\n    }, TestSink.callback, &sink);\n\n    _ = eng.feed(kbev(0x39, true));\n    _ = eng.feed(kbev(0x04, true));\n    const r = eng.feed(kbev(0x39, false));\n    try std.testing.expectEqual(Disposition.consumed, r.disposition);\n    // Output: tap_down, tap_up, then replayed a-down.\n    try std.testing.expectEqual(@as(usize, 3), sink.out.items.len);\n    try std.testing.expectEqual(@as(u32, 0x29), sink.out.items[0].usage);\n    try std.testing.expectEqual(@as(u32, 0x29), sink.out.items[1].usage);\n    try std.testing.expectEqual(@as(u32, 0x04), sink.out.items[2].usage);\n}\n\ntest \"decided_hold: source up after timer emits hold_up exactly once\" {\n    var sink = TestSink.init(std.testing.allocator);\n    defer sink.deinit();\n    var eng = init(.{ .src_usage = 0x39, .tap_usage = 0x29, .hold = .{ .hid_usage = 0xE0 } }, TestSink.callback, &sink);\n\n    _ = eng.feed(kbev(0x39, true));\n    _ = eng.timerFired();\n    _ = eng.feed(kbev(0x04, true)); // 'a' down — passes through\n    _ = eng.feed(kbev(0x04, false));\n    _ = eng.feed(kbev(0x39, false));\n\n    // Output: hold_down (timer), hold_up (source-up). The 'a' events\n    // are pass-through and not in our sink.\n    try std.testing.expectEqual(@as(usize, 2), sink.out.items.len);\n    try std.testing.expect(sink.out.items[0].pressed);\n    try std.testing.expect(!sink.out.items[1].pressed);\n}\n\ntest \"layer hold: hold_on_other_key_press triggers layer enter+exit through layer sink\" {\n    const LayerEvent = struct { layer: []const u8, entering: bool };\n    const LayerLog = struct {\n        events: std.ArrayList(LayerEvent),\n\n        fn cb(ctx: ?*anyopaque, layer: []const u8, entering: bool) void {\n            const s: *@This() = @ptrCast(@alignCast(ctx.?));\n            s.events.append(.{ .layer = layer, .entering = entering }) catch unreachable;\n        }\n    };\n    var sink = TestSink.init(std.testing.allocator);\n    defer sink.deinit();\n    var ll = LayerLog{ .events = std.ArrayList(LayerEvent).init(std.testing.allocator) };\n    defer ll.events.deinit();\n\n    var eng = initWithLayerSink(.{\n        .src_usage = 0x2C, // space\n        .tap_usage = 0x2C,\n        .hold = .{ .layer = \"fn_layer\" },\n        .hold_on_other_key_press = true,\n    }, TestSink.callback, &sink, LayerLog.cb, &ll);\n\n    // space down → pending\n    _ = eng.feed(kbev(0x2C, true));\n    try std.testing.expectEqual(@as(usize, 0), ll.events.items.len);\n\n    // 'h' down → hold_on_other_key_press commits the layer hold\n    _ = eng.feed(kbev(0x0B, true));\n    try std.testing.expectEqual(@as(usize, 1), ll.events.items.len);\n    try std.testing.expectEqualStrings(\"fn_layer\", ll.events.items[0].layer);\n    try std.testing.expect(ll.events.items[0].entering);\n\n    // 'h' up — passes through, no further layer events\n    _ = eng.feed(kbev(0x0B, false));\n    try std.testing.expectEqual(@as(usize, 1), ll.events.items.len);\n\n    // space up → exit layer\n    _ = eng.feed(kbev(0x2C, false));\n    try std.testing.expectEqual(@as(usize, 2), ll.events.items.len);\n    try std.testing.expectEqualStrings(\"fn_layer\", ll.events.items[1].layer);\n    try std.testing.expect(!ll.events.items[1].entering);\n\n    // No HID emits at all for the layer rule's hold path.\n    try std.testing.expectEqual(@as(usize, 0), sink.out.items.len);\n}\n\ntest \"layer hold: timer-only commit, buffered events replayed in order on tap\" {\n    const LayerEvent = struct { layer: []const u8, entering: bool };\n    const LayerLog = struct {\n        events: std.ArrayList(LayerEvent),\n\n        fn cb(ctx: ?*anyopaque, layer: []const u8, entering: bool) void {\n            const s: *@This() = @ptrCast(@alignCast(ctx.?));\n            s.events.append(.{ .layer = layer, .entering = entering }) catch unreachable;\n        }\n    };\n    var sink = TestSink.init(std.testing.allocator);\n    defer sink.deinit();\n    var ll = LayerLog{ .events = std.ArrayList(LayerEvent).init(std.testing.allocator) };\n    defer ll.events.deinit();\n\n    // Layer rule with neither permissive_hold nor hold_on_other_key_press\n    // explicitly set — should still buffer non-source events because\n    // layer rules always do, and replay on commit.\n    var eng = initWithLayerSink(.{\n        .src_usage = 0x2C, // space\n        .tap_usage = 0x2C,\n        .hold = .{ .layer = \"fn_layer\" },\n        .timeout_ms = 200,\n    }, TestSink.callback, &sink, LayerLog.cb, &ll);\n\n    _ = eng.feed(kbev(0x2C, true)); // space down → pending\n\n    // Quick prose-typing: 'h' down + 'h' up while space is briefly held.\n    _ = eng.feed(kbev(0x0B, true));\n    _ = eng.feed(kbev(0x0B, false));\n    // No layer transition yet — just buffered.\n    try std.testing.expectEqual(@as(usize, 0), ll.events.items.len);\n    try std.testing.expectEqual(@as(usize, 0), sink.out.items.len);\n\n    // Space released before timeout → tap path. Buffer replays after\n    // tap_up so the kernel sees: space, h.\n    _ = eng.feed(kbev(0x2C, false));\n    try std.testing.expectEqual(@as(usize, 0), ll.events.items.len); // never entered layer\n    try std.testing.expectEqual(@as(usize, 4), sink.out.items.len);\n    try std.testing.expectEqual(@as(u32, 0x2C), sink.out.items[0].usage); // tap down (space)\n    try std.testing.expect(sink.out.items[0].pressed);\n    try std.testing.expectEqual(@as(u32, 0x2C), sink.out.items[1].usage); // tap up\n    try std.testing.expect(!sink.out.items[1].pressed);\n    try std.testing.expectEqual(@as(u32, 0x0B), sink.out.items[2].usage); // 'h' down replay\n    try std.testing.expectEqual(@as(u32, 0x0B), sink.out.items[3].usage); // 'h' up replay\n}\n\ntest \"layer hold: timer fires after buffered events → enter layer + replay\" {\n    const LayerEvent = struct { layer: []const u8, entering: bool };\n    const LayerLog = struct {\n        events: std.ArrayList(LayerEvent),\n\n        fn cb(ctx: ?*anyopaque, layer: []const u8, entering: bool) void {\n            const s: *@This() = @ptrCast(@alignCast(ctx.?));\n            s.events.append(.{ .layer = layer, .entering = entering }) catch unreachable;\n        }\n    };\n    var sink = TestSink.init(std.testing.allocator);\n    defer sink.deinit();\n    var ll = LayerLog{ .events = std.ArrayList(LayerEvent).init(std.testing.allocator) };\n    defer ll.events.deinit();\n\n    var eng = initWithLayerSink(.{\n        .src_usage = 0x2C,\n        .tap_usage = 0x2C,\n        .hold = .{ .layer = \"fn_layer\" },\n    }, TestSink.callback, &sink, LayerLog.cb, &ll);\n\n    _ = eng.feed(kbev(0x2C, true));\n    _ = eng.feed(kbev(0x0B, true)); // 'h' buffered\n    _ = eng.timerFired();\n    // After timer: layer entered, buffer replayed.\n    try std.testing.expectEqual(@as(usize, 1), ll.events.items.len);\n    try std.testing.expect(ll.events.items[0].entering);\n    try std.testing.expectEqual(@as(usize, 1), sink.out.items.len);\n    try std.testing.expectEqual(@as(u32, 0x0B), sink.out.items[0].usage);\n}\n\ntest \"retro_tap: source held past timeout with no other key, on release emits tap\" {\n    var sink = TestSink.init(std.testing.allocator);\n    defer sink.deinit();\n    var eng = init(.{\n        .src_usage = 0x39,\n        .tap_usage = 0x29,\n        .hold = .{ .hid_usage = 0xE0 },\n        .retro_tap = true,\n    }, TestSink.callback, &sink);\n\n    _ = eng.feed(kbev(0x39, true)); // source down → pending\n    _ = eng.timerFired();             // → decided_hold, hold_down emitted\n    try std.testing.expectEqual(@as(usize, 1), sink.out.items.len);\n    try std.testing.expectEqual(@as(u32, 0xE0), sink.out.items[0].usage);\n    try std.testing.expect(sink.out.items[0].pressed);\n\n    _ = eng.feed(kbev(0x39, false)); // source up\n    // Output: hold_up, then retro tap_down + tap_up.\n    try std.testing.expectEqual(@as(usize, 4), sink.out.items.len);\n    try std.testing.expectEqual(@as(u32, 0xE0), sink.out.items[1].usage); // hold up\n    try std.testing.expect(!sink.out.items[1].pressed);\n    try std.testing.expectEqual(@as(u32, 0x29), sink.out.items[2].usage); // tap down\n    try std.testing.expect(sink.out.items[2].pressed);\n    try std.testing.expectEqual(@as(u32, 0x29), sink.out.items[3].usage); // tap up\n    try std.testing.expect(!sink.out.items[3].pressed);\n}\n\ntest \"retro_tap: other key seen during hold suppresses retro tap on release\" {\n    var sink = TestSink.init(std.testing.allocator);\n    defer sink.deinit();\n    var eng = init(.{\n        .src_usage = 0x39,\n        .tap_usage = 0x29,\n        .hold = .{ .hid_usage = 0xE0 },\n        .retro_tap = true,\n    }, TestSink.callback, &sink);\n\n    _ = eng.feed(kbev(0x39, true));\n    _ = eng.timerFired();\n    _ = eng.feed(kbev(0x04, true));  // 'a' down — observed during hold\n    _ = eng.feed(kbev(0x04, false));\n    _ = eng.feed(kbev(0x39, false));\n\n    // Output: hold_down, hold_up. No retro tap because 'a' was pressed.\n    try std.testing.expectEqual(@as(usize, 2), sink.out.items.len);\n    try std.testing.expectEqual(@as(u32, 0xE0), sink.out.items[0].usage);\n    try std.testing.expect(sink.out.items[0].pressed);\n    try std.testing.expectEqual(@as(u32, 0xE0), sink.out.items[1].usage);\n    try std.testing.expect(!sink.out.items[1].pressed);\n}\n\ntest \"non-source events while idle: pass through, FSM untouched\" {\n    var sink = TestSink.init(std.testing.allocator);\n    defer sink.deinit();\n    var eng = init(.{ .src_usage = 0x39, .tap_usage = 0x29, .hold = .{ .hid_usage = 0xE0 } }, TestSink.callback, &sink);\n\n    const r = eng.feed(kbev(0x04, true));\n    try std.testing.expectEqual(Disposition.pass, r.disposition);\n    try std.testing.expectEqual(TimerAction.none, r.timer);\n    try std.testing.expectEqual(@as(usize, 0), sink.out.items.len);\n    try std.testing.expectEqual(State.idle, eng.state);\n}\n\ntest \"key released during pending: pass-through if its down was already at OS\" {\n    // Regression for the \"single press fires N times\" bug. User\n    // presses 's' (slot idle, emitted directly). Then presses\n    // source (slot enters pending). Then releases 's'. The old code\n    // buffered the s-up — delaying it until source-up — and the OS\n    // saw 's' as held for the entire pending window, autorepeating\n    // it. Fix: pass-through nested-up if its matching down wasn't\n    // also buffered.\n    var sink = TestSink.init(std.testing.allocator);\n    defer sink.deinit();\n    var eng = init(.{\n        .src_usage = 0x2C,\n        .tap_usage = 0x2C,\n        .hold = .{ .layer = \"fn_layer\" },\n        .permissive_hold = true,\n    }, TestSink.callback, &sink);\n\n    // s-down arrives idle → pass-through (caller's responsibility\n    // to emit; we just verify disposition).\n    const r0 = eng.feed(kbev(0x16, true));\n    try std.testing.expectEqual(Disposition.pass, r0.disposition);\n\n    // Source down → pending.\n    _ = eng.feed(kbev(0x2C, true));\n\n    // s-up arrives during pending. s-down was external (not\n    // buffered) so the up must pass-through, NOT buffer.\n    const r1 = eng.feed(kbev(0x16, false));\n    try std.testing.expectEqual(Disposition.pass, r1.disposition);\n\n    // Source-up before timeout → tap path. Buffer is empty.\n    _ = eng.feed(kbev(0x2C, false));\n    // Output: just the source tap pair. No replay of the s.\n    try std.testing.expectEqual(@as(usize, 2), sink.out.items.len);\n    try std.testing.expectEqual(@as(u32, 0x2C), sink.out.items[0].usage);\n    try std.testing.expectEqual(@as(u32, 0x2C), sink.out.items[1].usage);\n}\n\n// ─── benchmarks ──────────────────────────────────────────────────\n//\n// These tests time tight loops through `feed()` to track per-event\n// FSM cost. They print results via `std.debug.print` so the numbers\n// land in `zig build test` output without needing extra tooling.\n// `feed()` is allocation-free (BoundedArray storage is inline), so a\n// counter-only sink is enough to isolate FSM time.\n\nconst CountSink = struct {\n    count: usize = 0,\n    fn cb(ctx: ?*anyopaque, _: Event) void {\n        const self: *CountSink = @ptrCast(@alignCast(ctx.?));\n        self.count += 1;\n    }\n};\n\nfn benchReport(name: []const u8, total_ns: u64, events: usize) void {\n    const ns_per_ev = if (events == 0) 0 else total_ns / events;\n    std.debug.print(\n        \"[bench] {s:<48} events={d:>9} total={d:>8}us ns/event={d:>5}\\n\",\n        .{ name, events, total_ns / std.time.ns_per_us, ns_per_ev },\n    );\n}\n\ntest \"bench: pure-tap loop (modifier rule, no buffering)\" {\n    // Source down → source up → repeat. The hot path for a user\n    // typing through caps_lock-as-ctrl when caps is briefly tapped\n    // (no other key in flight). Exercises the .idle ⇄ .pending\n    // transitions and the tap-emit path.\n    var sink: CountSink = .{};\n    var eng = init(\n        .{ .src_usage = 0x39, .tap_usage = 0x29, .hold = .{ .hid_usage = 0xE0 } },\n        CountSink.cb,\n        &sink,\n    );\n\n    const iterations = 200_000;\n    var timer = try std.time.Timer.start();\n    var i: usize = 0;\n    while (i < iterations) : (i += 1) {\n        _ = eng.feed(kbev(0x39, true));\n        _ = eng.feed(kbev(0x39, false));\n    }\n    const total_ns = timer.read();\n    const events = iterations * 2;\n    benchReport(\"pure-tap\", total_ns, events);\n    // Each iteration produces 2 emitted events (tap_down + tap_up).\n    try std.testing.expectEqual(iterations * 2, sink.count);\n}\n\ntest \"bench: layer rule, fast typing burst (5 keys per hold)\" {\n    // Realistic fast-typing scenario for layer-hold: source down,\n    // a few nested key down/up pairs (buffered), then source up\n    // before timeout → commits as tap, replays the buffer. This is\n    // the path a fast typist hits when rolling over a layer key\n    // without actually intending to enter the layer.\n    var sink: CountSink = .{};\n    var eng = init(\n        .{\n            .src_usage = 0x2C,\n            .tap_usage = 0x2C,\n            .hold = .{ .layer = \"fn_layer\" },\n            .permissive_hold = false,\n        },\n        CountSink.cb,\n        &sink,\n    );\n\n    const iterations = 50_000;\n    const inner_keys = 5;\n    var timer = try std.time.Timer.start();\n    var i: usize = 0;\n    while (i < iterations) : (i += 1) {\n        _ = eng.feed(kbev(0x2C, true));\n        var k: u16 = 0;\n        while (k < inner_keys) : (k += 1) {\n            _ = eng.feed(kbev(0x04 + k, true));\n            _ = eng.feed(kbev(0x04 + k, false));\n        }\n        _ = eng.feed(kbev(0x2C, false));\n    }\n    const total_ns = timer.read();\n    const events = iterations * (2 + inner_keys * 2);\n    benchReport(\"layer-roll-5keys\", total_ns, events);\n}\n\ntest \"bench: hold-on-other-key-press eager commit\" {\n    // Source down → first other key down → eager hold commit; rest\n    // of the burst flows through the .decided_hold pass-through\n    // path. Source up emits hold_up. This is the cheapest realistic\n    // hold path (no buffering, no timer fire from inside the test).\n    var sink: CountSink = .{};\n    var eng = init(\n        .{\n            .src_usage = 0x39,\n            .tap_usage = 0x29,\n            .hold = .{ .hid_usage = 0xE0 },\n            .hold_on_other_key_press = true,\n        },\n        CountSink.cb,\n        &sink,\n    );\n\n    const iterations = 50_000;\n    const inner_keys = 5;\n    var timer = try std.time.Timer.start();\n    var i: usize = 0;\n    while (i < iterations) : (i += 1) {\n        _ = eng.feed(kbev(0x39, true));\n        var k: u16 = 0;\n        while (k < inner_keys) : (k += 1) {\n            _ = eng.feed(kbev(0x04 + k, true));\n            _ = eng.feed(kbev(0x04 + k, false));\n        }\n        _ = eng.feed(kbev(0x39, false));\n    }\n    const total_ns = timer.read();\n    const events = iterations * (2 + inner_keys * 2);\n    benchReport(\"hold-on-other-key-press\", total_ns, events);\n}\n\ntest \"key pressed AND released during pending: both buffered, replayed in order\" {\n    // The well-behaved case (and the one we mustn't break with the\n    // pass-through fix above): a nested key fully tapped inside the\n    // pending window should still be buffered, so the source-tap\n    // replay preserves \"source-then-key\" ordering.\n    var sink = TestSink.init(std.testing.allocator);\n    defer sink.deinit();\n    var eng = init(.{\n        .src_usage = 0x2C,\n        .tap_usage = 0x2C,\n        .hold = .{ .layer = \"fn_layer\" },\n        .permissive_hold = true,\n    }, TestSink.callback, &sink);\n\n    _ = eng.feed(kbev(0x2C, true)); // source down\n    const rd = eng.feed(kbev(0x16, true)); // s-down during pending\n    try std.testing.expectEqual(Disposition.consumed, rd.disposition);\n    const ru = eng.feed(kbev(0x16, false)); // s-up during pending — should buffer\n    try std.testing.expectEqual(Disposition.consumed, ru.disposition);\n    _ = eng.feed(kbev(0x2C, false)); // source up → tap\n\n    // Output: source tap, then s pair.\n    try std.testing.expectEqual(@as(usize, 4), sink.out.items.len);\n    try std.testing.expectEqual(@as(u32, 0x2C), sink.out.items[0].usage);\n    try std.testing.expect(sink.out.items[0].pressed);\n    try std.testing.expectEqual(@as(u32, 0x2C), sink.out.items[1].usage);\n    try std.testing.expect(!sink.out.items[1].pressed);\n    try std.testing.expectEqual(@as(u32, 0x16), sink.out.items[2].usage);\n    try std.testing.expect(sink.out.items[2].pressed);\n    try std.testing.expectEqual(@as(u32, 0x16), sink.out.items[3].usage);\n    try std.testing.expect(!sink.out.items[3].pressed);\n}\n"
  },
  {
    "path": "src/grabber/Vhidd.zig",
    "content": "//! Karabiner-DriverKit-VirtualHIDDevice client.\n//!\n//! Talks to the `vhidd_server` daemon shipped by pqrs.org's signed\n//! DriverKit extension. Used by the grabber to inject HID events\n//! (keyboard reports specifically — pointing/consumer reports are\n//! defined for completeness but not yet wired up).\n//!\n//! Protocol summary (verified against\n//! Karabiner-DriverKit-VirtualHIDDevice 533b4b6, July 2025):\n//!\n//! Transport\n//!     SOCK_DGRAM Unix domain. Server listens on the lexically-last\n//!     `*.sock` under `/Library/Application Support/org.pqrs/tmp/\n//!     rootonly/vhidd_server/`. Clients bind their own ephemeral\n//!     socket under `vhidd_client/<ns>.sock` so the server can use\n//!     the sender address as the per-client key (and so responses\n//!     come back via recvfrom).\n//!\n//! Outer envelope (pqrs::local_datagram::send_entry)\n//!     [type:u8] [body…]\n//!     type=0x00 → heartbeat: body = `[next_heartbeat_deadline:u32 LE]`\n//!     type=0x01 → user_data:  body = vhidd protocol bytes\n//!\n//!     The leading type byte is stripped before the application sees\n//!     a received frame.\n//!\n//! Inner protocol (virtual_hid_device_service)\n//!     Send: [magic 'c''p'] [version:u16 LE = 5] [request:u8] [payload]\n//!     Recv: [response:u8] [body…]\n//!\n//! Root-only: the server / client socket directories live under\n//! /Library/Application Support/org.pqrs/tmp/rootonly/, which is\n//! mode 0700 owned by root.\n\nconst std = @import(\"std\");\nconst builtin = @import(\"builtin\");\nconst posix = std.posix;\n\nconst log = std.log.scoped(.vhidd);\n\nconst protocol_version: u16 = 5;\nconst magic = [_]u8{ 'c', 'p' };\n\n/// `pqrs::local_datagram::send_entry::type`.\nconst FrameType = enum(u8) {\n    heartbeat = 0,\n    user_data = 1,\n};\n\n/// `pqrs::karabiner::driverkit::virtual_hid_device_service::request`.\npub const Request = enum(u8) {\n    none = 0,\n    virtual_hid_keyboard_initialize = 1,\n    virtual_hid_keyboard_terminate = 2,\n    virtual_hid_keyboard_reset = 3,\n    virtual_hid_pointing_initialize = 4,\n    virtual_hid_pointing_terminate = 5,\n    virtual_hid_pointing_reset = 6,\n    post_keyboard_input_report = 7,\n    post_consumer_input_report = 8,\n    post_apple_vendor_keyboard_input_report = 9,\n    post_apple_vendor_top_case_input_report = 10,\n    post_generic_desktop_input_report = 11,\n    post_pointing_input_report = 12,\n};\n\n/// `pqrs::karabiner::driverkit::virtual_hid_device_service::response`.\npub const Response = enum(u8) {\n    none = 0,\n    driver_activated = 1,\n    driver_connected = 2,\n    driver_version_mismatched = 3,\n    virtual_hid_keyboard_ready = 4,\n    virtual_hid_pointing_ready = 5,\n    _,\n};\n\n/// Modifier byte layout for `keyboard_input.modifiers`. Bit values\n/// match HID Usage Page 0x07 modifier semantics.\npub const Modifier = packed struct(u8) {\n    left_control: bool = false,\n    left_shift: bool = false,\n    left_option: bool = false,\n    left_command: bool = false,\n    right_control: bool = false,\n    right_shift: bool = false,\n    right_option: bool = false,\n    right_command: bool = false,\n};\n\n/// vhidd `virtual_hid_keyboard_parameters`. Byte-level layout matches\n/// `__attribute__((packed))` in C++ (3 × u64 LE = 24 bytes).\npub const KeyboardParameters = struct {\n    vendor_id: u64 = 0x16c0,\n    product_id: u64 = 0x27db,\n    /// HID Usage Page 0x07 country code; 0 = \"not supported\".\n    country_code: u64 = 0,\n};\n\nconst root_dir = \"/Library/Application Support/org.pqrs/tmp/rootonly\";\npub const server_socket_dir = root_dir ++ \"/vhidd_server\";\npub const client_socket_dir = root_dir ++ \"/vhidd_client\";\n\npub const Client = struct {\n    allocator: std.mem.Allocator,\n    fd: posix.fd_t,\n    /// Ephemeral path we bound — needs unlink on close.\n    bound_path: []u8,\n\n    pub fn connect(allocator: std.mem.Allocator) !Client {\n        const server_path = try findServerSocket(allocator);\n        defer allocator.free(server_path);\n\n        try ensureClientDir();\n\n        const client_path = try ephemeralClientPath(allocator);\n        errdefer allocator.free(client_path);\n\n        // Stale file from a prior crash would make bind() fail with EADDRINUSE.\n        posix.unlink(client_path) catch |err| switch (err) {\n            error.FileNotFound => {},\n            else => return error.ClientSocketBindFailed,\n        };\n\n        const fd = try posix.socket(posix.AF.UNIX, posix.SOCK.DGRAM | posix.SOCK.CLOEXEC, 0);\n        errdefer posix.close(fd);\n\n        try bindUnix(fd, client_path);\n        errdefer posix.unlink(client_path) catch {};\n\n        try connectUnix(fd, server_path);\n\n        log.info(\"connected: server={s} client={s}\", .{ server_path, client_path });\n\n        return .{\n            .allocator = allocator,\n            .fd = fd,\n            .bound_path = client_path,\n        };\n    }\n\n    pub fn close(self: *Client) void {\n        posix.close(self.fd);\n        posix.unlink(self.bound_path) catch {};\n        self.allocator.free(self.bound_path);\n        self.* = undefined;\n    }\n\n    /// Send a vhidd request with no payload.\n    pub fn sendRequest(self: *Client, req: Request) !void {\n        var buf: [16]u8 = undefined;\n        const n = encodeHeader(&buf, req);\n        try self.sendUserData(buf[0..n]);\n    }\n\n    /// Send a vhidd request with a fixed-size payload appended.\n    pub fn sendRequestWithPayload(self: *Client, req: Request, payload: []const u8) !void {\n        var buf: [1024]u8 = undefined;\n        const hdr_len = encodeHeader(&buf, req);\n        if (hdr_len + payload.len > buf.len) return error.PayloadTooLarge;\n        @memcpy(buf[hdr_len..][0..payload.len], payload);\n        try self.sendUserData(buf[0 .. hdr_len + payload.len]);\n    }\n\n    /// Send a `virtual_hid_keyboard_initialize` request with the given\n    /// (vendor, product, country) tuple.\n    pub fn initializeKeyboard(self: *Client, params: KeyboardParameters) !void {\n        var payload: [24]u8 = undefined;\n        std.mem.writeInt(u64, payload[0..8], params.vendor_id, .little);\n        std.mem.writeInt(u64, payload[8..16], params.product_id, .little);\n        std.mem.writeInt(u64, payload[16..24], params.country_code, .little);\n        try self.sendRequestWithPayload(.virtual_hid_keyboard_initialize, &payload);\n    }\n\n    /// Send a `post_keyboard_input_report` with the 67-byte report.\n    pub fn postKeyboardReport(self: *Client, modifiers: Modifier, keys: []const u16) !void {\n        if (keys.len > 32) return error.TooManyKeys;\n        var report: [67]u8 = @splat(0);\n        report[0] = 1; // report_id\n        report[1] = @bitCast(modifiers);\n        report[2] = 0; // reserved\n        for (keys, 0..) |k, i| {\n            std.mem.writeInt(u16, report[3 + i * 2 ..][0..2], k, .little);\n        }\n        try self.sendRequestWithPayload(.post_keyboard_input_report, &report);\n    }\n\n    /// Reports for non-keyboard pages share an identical shape:\n    ///   [u8 report_id][32 × u16 le keys] = 65 bytes\n    /// Differs from the keyboard report only by report_id and the\n    /// driver-side request that carries it.\n    fn postKeysReport(\n        self: *Client,\n        request: Request,\n        report_id: u8,\n        keys: []const u16,\n    ) !void {\n        if (keys.len > 32) return error.TooManyKeys;\n        var report: [65]u8 = @splat(0);\n        report[0] = report_id;\n        for (keys, 0..) |k, i| {\n            std.mem.writeInt(u16, report[1 + i * 2 ..][0..2], k, .little);\n        }\n        try self.sendRequestWithPayload(request, &report);\n    }\n\n    /// Consumer page (HID 0x0C) report — volume, play/pause, mute, etc.\n    /// On Apple keyboards in the default F-row mode (the \"Use F1, F2…\n    /// as standard function keys\" setting OFF), the F-row keys emit on\n    /// this page.\n    pub fn postConsumerReport(self: *Client, keys: []const u16) !void {\n        try self.postKeysReport(.post_consumer_input_report, 2, keys);\n    }\n\n    /// Apple Vendor Top Case page (HID 0xFF). Brightness up/down, the\n    /// fn key state, and a few other Apple-specific keys.\n    pub fn postAppleVendorTopCaseReport(self: *Client, keys: []const u16) !void {\n        try self.postKeysReport(.post_apple_vendor_top_case_input_report, 3, keys);\n    }\n\n    /// Apple Vendor Keyboard page (HID 0xFF01). Spotlight, mission\n    /// control, dictation, etc. on modern MacBooks.\n    pub fn postAppleVendorKeyboardReport(self: *Client, keys: []const u16) !void {\n        try self.postKeysReport(.post_apple_vendor_keyboard_input_report, 4, keys);\n    }\n\n    /// Generic Desktop page (HID 0x01). Used for `do_not_disturb`\n    /// (usage 0x9B) — F6 on modern MacBooks.\n    pub fn postGenericDesktopReport(self: *Client, keys: []const u16) !void {\n        try self.postKeysReport(.post_generic_desktop_input_report, 7, keys);\n    }\n\n    /// Block until the given vhidd response arrives with body byte\n    /// non-zero (`true`), or the timeout expires.\n    ///\n    /// The DriverKit boot is async: after `virtual_hid_keyboard_initialize`,\n    /// the server sends `virtual_hid_keyboard_ready=false` once, then\n    /// keeps sending status updates as the kernel finishes wiring up\n    /// the virtual device. We're only interested in the eventual\n    /// `true` arrival. Heartbeats and non-matching responses are\n    /// silently consumed.\n    pub fn waitForBoolTrue(self: *Client, want: Response, timeout_ms: u32) !void {\n        const deadline_ms = std.time.milliTimestamp() + @as(i64, @intCast(timeout_ms));\n        while (true) {\n            const remaining = deadline_ms - std.time.milliTimestamp();\n            if (remaining <= 0) return error.Timeout;\n\n            try setRecvTimeout(self.fd, @intCast(remaining));\n\n            var buf: [1024]u8 = undefined;\n            const n = posix.recv(self.fd, &buf, 0) catch |err| switch (err) {\n                error.WouldBlock => return error.Timeout,\n                else => return err,\n            };\n            if (n == 0) continue;\n\n            // Wire frame: [type:u8] [body…].\n            // type=0 (heartbeat) → body is `[next_heartbeat_deadline:u32 LE]`.\n            // type=1 (user_data) → body is `[response:u8] [response_body…]`.\n            const frame_type: FrameType = @enumFromInt(buf[0]);\n            switch (frame_type) {\n                .heartbeat => {\n                    log.debug(\"recv heartbeat ({d} body bytes)\", .{n - 1});\n                    continue;\n                },\n                .user_data => {\n                    if (n < 2) continue;\n                    const resp: Response = @enumFromInt(buf[1]);\n                    const resp_body_len = n - 2;\n                    log.debug(\"recv response={d} body_len={d} body[0]={any}\", .{\n                        buf[1],\n                        resp_body_len,\n                        if (resp_body_len > 0) @as(?u8, buf[2]) else null,\n                    });\n                    if (resp == want and resp_body_len >= 1 and buf[2] != 0) {\n                        return;\n                    }\n                    // Non-matching response (or matching but false) —\n                    // loop and read again.\n                },\n            }\n        }\n    }\n\n    fn sendUserData(self: *Client, body: []const u8) !void {\n        var buf: [1024]u8 = undefined;\n        if (body.len + 1 > buf.len) return error.PayloadTooLarge;\n        buf[0] = @intFromEnum(FrameType.user_data);\n        @memcpy(buf[1..][0..body.len], body);\n        const sent = try posix.send(self.fd, buf[0 .. body.len + 1], 0);\n        if (sent != body.len + 1) return error.ShortWrite;\n    }\n};\n\nfn encodeHeader(buf: []u8, req: Request) usize {\n    buf[0] = magic[0];\n    buf[1] = magic[1];\n    std.mem.writeInt(u16, buf[2..4], protocol_version, .little);\n    buf[4] = @intFromEnum(req);\n    return 5;\n}\n\nfn ensureClientDir() !void {\n    std.fs.makeDirAbsolute(client_socket_dir) catch |err| switch (err) {\n        error.PathAlreadyExists => {},\n        error.AccessDenied => return error.PermissionDenied,\n        else => return error.ClientSocketDirCreate,\n    };\n}\n\nfn ephemeralClientPath(allocator: std.mem.Allocator) ![]u8 {\n    // Karabiner uses hex-formatted nanoseconds since epoch; we match\n    // that since the server doesn't care about the format, only that\n    // it's unique.\n    const ns = std.time.nanoTimestamp();\n    return std.fmt.allocPrint(allocator, \"{s}/{x}.sock\", .{ client_socket_dir, @as(u128, @bitCast(ns)) });\n}\n\n/// Find the server's listening socket. Karabiner restarts produce\n/// a new file (epoch-named in hex), so we glob and pick the lexically\n/// last one — same algorithm Karabiner's own client uses.\nfn findServerSocket(allocator: std.mem.Allocator) ![]u8 {\n    var dir = std.fs.openDirAbsolute(server_socket_dir, .{ .iterate = true }) catch |err| switch (err) {\n        error.FileNotFound => return error.ServerSocketDirMissing,\n        error.AccessDenied => return error.PermissionDenied,\n        else => return err,\n    };\n    defer dir.close();\n\n    var best: ?[]u8 = null;\n    errdefer if (best) |p| allocator.free(p);\n\n    var it = dir.iterate();\n    while (try it.next()) |entry| {\n        if (entry.kind != .unix_domain_socket and entry.kind != .file) continue;\n        if (!std.mem.endsWith(u8, entry.name, \".sock\")) continue;\n        if (best) |existing| {\n            if (std.mem.lessThan(u8, existing, entry.name)) {\n                allocator.free(existing);\n                best = try allocator.dupe(u8, entry.name);\n            }\n        } else {\n            best = try allocator.dupe(u8, entry.name);\n        }\n    }\n\n    const name = best orelse return error.ServerSocketAbsent;\n    defer allocator.free(name);\n    return std.fmt.allocPrint(allocator, \"{s}/{s}\", .{ server_socket_dir, name });\n}\n\nfn bindUnix(fd: posix.fd_t, path: []const u8) !void {\n    var addr: posix.sockaddr.un = .{\n        .family = posix.AF.UNIX,\n        .path = @splat(0),\n    };\n    if (path.len >= addr.path.len) return error.PathTooLong;\n    @memcpy(addr.path[0..path.len], path);\n    posix.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) catch |err| {\n        log.warn(\"bind {s} failed: {s}\", .{ path, @errorName(err) });\n        return error.ClientSocketBindFailed;\n    };\n}\n\nfn connectUnix(fd: posix.fd_t, path: []const u8) !void {\n    var addr: posix.sockaddr.un = .{\n        .family = posix.AF.UNIX,\n        .path = @splat(0),\n    };\n    if (path.len >= addr.path.len) return error.PathTooLong;\n    @memcpy(addr.path[0..path.len], path);\n    try posix.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr)));\n}\n\nfn setRecvTimeout(fd: posix.fd_t, ms: u32) !void {\n    const tv: posix.timeval = .{\n        .sec = @intCast(ms / 1000),\n        .usec = @intCast((ms % 1000) * 1000),\n    };\n    try posix.setsockopt(fd, posix.SOL.SOCKET, posix.SO.RCVTIMEO, std.mem.asBytes(&tv));\n}\n\ntest \"encodeHeader writes magic + version + request\" {\n    var buf: [16]u8 = undefined;\n    const n = encodeHeader(&buf, .virtual_hid_keyboard_initialize);\n    try std.testing.expectEqual(@as(usize, 5), n);\n    try std.testing.expectEqual(@as(u8, 'c'), buf[0]);\n    try std.testing.expectEqual(@as(u8, 'p'), buf[1]);\n    try std.testing.expectEqual(@as(u16, 5), std.mem.readInt(u16, buf[2..4], .little));\n    try std.testing.expectEqual(@as(u8, 1), buf[4]);\n}\n\ntest \"Modifier packs to 8 bits matching Karabiner enum\" {\n    const m = Modifier{ .left_control = true, .right_command = true };\n    const byte: u8 = @bitCast(m);\n    // bit 0 (left_control) | bit 7 (right_command) = 0b10000001 = 0x81\n    try std.testing.expectEqual(@as(u8, 0x81), byte);\n}\n"
  },
  {
    "path": "src/grabber/c.zig",
    "content": "//! Minimal C bindings for the grabber binary.\n//!\n//! We hand-declare the IOKit / CoreFoundation symbols we need\n//! instead of using `@cImport`. Zig 0.14's C translator chokes on\n//! `iokit_common_err(return)` (a macro that uses `return` as a\n//! parameter name) deep in `IOReturn.h`, and the resulting\n//! `@compileError` sentinel is reachable through the module's\n//! semantic analysis even when we don't name the macro ourselves.\n//!\n//! The ABI of these symbols is stable across macOS versions, so\n//! mirroring the signatures here is straightforward and keeps the\n//! grabber's surface narrow (no Cocoa, no Carbon, no ObjC runtime).\n\nconst std = @import(\"std\");\n\n// CoreFoundation opaque types — pointers to \"Ref\" types are how\n// Apple frameworks model handles. We use anyopaque so we don't have\n// to mirror the underlying structs.\npub const CFTypeRef = ?*anyopaque;\npub const CFAllocatorRef = ?*anyopaque;\npub const CFArrayRef = ?*anyopaque;\npub const CFArrayCallBacks = anyopaque;\npub const CFMutableArrayRef = ?*anyopaque;\npub const CFDictionaryRef = ?*anyopaque;\npub const CFDictionaryKeyCallBacks = anyopaque;\npub const CFDictionaryValueCallBacks = anyopaque;\npub const CFMutableDictionaryRef = ?*anyopaque;\npub const CFNumberRef = ?*anyopaque;\npub const CFStringRef = ?*anyopaque;\npub const CFRunLoopRef = ?*anyopaque;\npub const CFRunLoopTimerRef = ?*anyopaque;\npub const CFRunLoopSourceRef = ?*anyopaque;\npub const CFFileDescriptorRef = ?*anyopaque;\npub const CFFileDescriptorNativeDescriptor = c_int;\npub const CFOptionFlags = u64;\n\npub const CFIndex = isize;\npub const CFTimeInterval = f64;\npub const CFAbsoluteTime = f64;\npub const Boolean = u8;\n\npub const CFNumberType = c_int;\npub const kCFNumberSInt32Type: CFNumberType = 3;\n\npub const CFStringEncoding = u32;\npub const kCFStringEncodingUTF8: CFStringEncoding = 0x08000100;\n\npub const CFRunLoopRunResult = c_int;\npub const kCFRunLoopRunFinished: CFRunLoopRunResult = 1;\npub const kCFRunLoopRunStopped: CFRunLoopRunResult = 2;\npub const kCFRunLoopRunTimedOut: CFRunLoopRunResult = 3;\npub const kCFRunLoopRunHandledSource: CFRunLoopRunResult = 4;\n\npub const CFRunLoopTimerCallBack = ?*const fn (CFRunLoopTimerRef, ?*anyopaque) callconv(.C) void;\n\npub const CFRunLoopTimerContext = extern struct {\n    version: CFIndex = 0,\n    info: ?*anyopaque = null,\n    retain: ?*const fn (?*const anyopaque) callconv(.C) ?*const anyopaque = null,\n    release: ?*const fn (?*const anyopaque) callconv(.C) void = null,\n    copyDescription: ?*const fn (?*const anyopaque) callconv(.C) CFStringRef = null,\n};\n\npub extern const kCFAllocatorDefault: CFAllocatorRef;\npub extern const kCFTypeArrayCallBacks: CFArrayCallBacks;\npub extern const kCFTypeDictionaryKeyCallBacks: CFDictionaryKeyCallBacks;\npub extern const kCFTypeDictionaryValueCallBacks: CFDictionaryValueCallBacks;\npub extern const kCFRunLoopDefaultMode: CFStringRef;\n\npub extern fn CFRelease(cf: CFTypeRef) void;\npub extern fn CFArrayCreateMutable(allocator: CFAllocatorRef, capacity: CFIndex, callbacks: *const CFArrayCallBacks) CFMutableArrayRef;\npub extern fn CFArrayAppendValue(array: CFMutableArrayRef, value: ?*const anyopaque) void;\npub extern fn CFDictionaryCreateMutable(\n    allocator: CFAllocatorRef,\n    capacity: CFIndex,\n    keyCallBacks: *const CFDictionaryKeyCallBacks,\n    valueCallBacks: *const CFDictionaryValueCallBacks,\n) CFMutableDictionaryRef;\npub extern fn CFDictionarySetValue(dict: CFMutableDictionaryRef, key: ?*const anyopaque, value: ?*const anyopaque) void;\npub extern fn CFNumberCreate(allocator: CFAllocatorRef, type_: CFNumberType, valuePtr: *const anyopaque) CFNumberRef;\npub extern fn CFStringCreateWithCString(allocator: CFAllocatorRef, cstr: [*:0]const u8, encoding: CFStringEncoding) CFStringRef;\npub extern fn CFAbsoluteTimeGetCurrent() CFAbsoluteTime;\n\npub extern fn CFRunLoopGetCurrent() CFRunLoopRef;\npub extern fn CFRunLoopRunInMode(mode: CFStringRef, seconds: CFTimeInterval, returnAfterSourceHandled: Boolean) CFRunLoopRunResult;\npub extern fn CFRunLoopStop(rl: CFRunLoopRef) void;\npub extern fn CFRunLoopAddTimer(rl: CFRunLoopRef, timer: CFRunLoopTimerRef, mode: CFStringRef) void;\npub extern fn CFRunLoopTimerInvalidate(timer: CFRunLoopTimerRef) void;\npub extern fn CFRunLoopAddSource(rl: CFRunLoopRef, source: CFRunLoopSourceRef, mode: CFStringRef) void;\npub extern fn CFRunLoopRemoveSource(rl: CFRunLoopRef, source: CFRunLoopSourceRef, mode: CFStringRef) void;\n\npub const CFFileDescriptorCallBack = ?*const fn (CFFileDescriptorRef, CFOptionFlags, ?*anyopaque) callconv(.C) void;\n\npub const CFFileDescriptorContext = extern struct {\n    version: CFIndex = 0,\n    info: ?*anyopaque = null,\n    retain: ?*const fn (?*const anyopaque) callconv(.C) ?*const anyopaque = null,\n    release: ?*const fn (?*const anyopaque) callconv(.C) void = null,\n    copyDescription: ?*const fn (?*const anyopaque) callconv(.C) CFStringRef = null,\n};\n\npub const kCFFileDescriptorReadCallBack: CFOptionFlags = 1 << 0;\npub const kCFFileDescriptorWriteCallBack: CFOptionFlags = 1 << 1;\n\npub extern fn CFFileDescriptorCreate(\n    allocator: CFAllocatorRef,\n    fd: CFFileDescriptorNativeDescriptor,\n    closeOnInvalidate: Boolean,\n    callout: CFFileDescriptorCallBack,\n    context: *CFFileDescriptorContext,\n) CFFileDescriptorRef;\npub extern fn CFFileDescriptorEnableCallBacks(f: CFFileDescriptorRef, callBackTypes: CFOptionFlags) void;\npub extern fn CFFileDescriptorCreateRunLoopSource(allocator: CFAllocatorRef, f: CFFileDescriptorRef, order: CFIndex) CFRunLoopSourceRef;\npub extern fn CFFileDescriptorInvalidate(f: CFFileDescriptorRef) void;\npub extern fn CFRunLoopTimerCreate(\n    allocator: CFAllocatorRef,\n    fireDate: CFAbsoluteTime,\n    interval: CFTimeInterval,\n    flags: u32,\n    order: CFIndex,\n    callout: CFRunLoopTimerCallBack,\n    context: *CFRunLoopTimerContext,\n) CFRunLoopTimerRef;\n\n// IOKit / HID. IOHID*Ref are also opaque.\npub const IOReturn = c_int;\npub const kIOReturnSuccess: IOReturn = 0;\n// Constants follow IOReturn.h: sys_iokit | sub_iokit_common (0xE0000000)\n// + per-error sub-code. iokit_common_err(0x2c1), etc.\npub const kIOReturnNotPrivileged: IOReturn = @bitCast(@as(u32, 0xE00002C1));\npub const kIOReturnNotPermitted: IOReturn = @bitCast(@as(u32, 0xE00002E2));\npub const kIOReturnExclusiveAccess: IOReturn = @bitCast(@as(u32, 0xE00002C5));\n\npub const IOOptionBits = u32;\npub const kIOHIDOptionsTypeNone: IOOptionBits = 0x0;\npub const kIOHIDOptionsTypeSeizeDevice: IOOptionBits = 0x1;\n\npub const IOHIDManagerRef = ?*anyopaque;\npub const IOHIDDeviceRef = ?*anyopaque;\npub const IOHIDElementRef = ?*anyopaque;\npub const IOHIDValueRef = ?*anyopaque;\n\npub const IOHIDValueCallback = ?*const fn (\n    context: ?*anyopaque,\n    result: IOReturn,\n    sender: ?*anyopaque,\n    value: IOHIDValueRef,\n) callconv(.C) void;\n\n// Matching dictionary keys (string constants in IOHIDKeys.h).\npub const kIOHIDVendorIDKey: [*:0]const u8 = \"VendorID\";\npub const kIOHIDProductIDKey: [*:0]const u8 = \"ProductID\";\npub const kIOHIDPrimaryUsagePageKey: [*:0]const u8 = \"PrimaryUsagePage\";\npub const kIOHIDPrimaryUsageKey: [*:0]const u8 = \"PrimaryUsage\";\n/// Per-device property: how long (in ms) Apple's keyboard firmware\n/// waits before treating a caps_lock press as a toggle. Default ~150.\n/// Setting it to 0 disables the firmware-level toggle entirely so\n/// caps_lock acts like any other key under our seize. Same trick\n/// Karabiner-Elements uses to suppress caps_lock-on-hold on built-in\n/// MacBook keyboards.\npub const kIOHIDKeyboardCapsLockDelayOverrideKey: [*:0]const u8 = \"HIDKeyboardCapsLockDelayOverride\";\n\n// HID usage pages / usages relevant to seize matching.\npub const kHIDPage_GenericDesktop: i32 = 0x01;\npub const kHIDUsage_GD_Keyboard: i32 = 0x06;\n\npub extern fn IOHIDManagerCreate(allocator: CFAllocatorRef, options: IOOptionBits) IOHIDManagerRef;\npub extern fn IOHIDManagerOpen(manager: IOHIDManagerRef, options: IOOptionBits) IOReturn;\npub extern fn IOHIDManagerClose(manager: IOHIDManagerRef, options: IOOptionBits) IOReturn;\npub extern fn IOHIDManagerSetDeviceMatchingMultiple(manager: IOHIDManagerRef, multiple: CFArrayRef) void;\npub extern fn IOHIDManagerRegisterInputValueCallback(manager: IOHIDManagerRef, callback: IOHIDValueCallback, context: ?*anyopaque) void;\npub extern fn IOHIDManagerScheduleWithRunLoop(manager: IOHIDManagerRef, runLoop: CFRunLoopRef, mode: CFStringRef) void;\npub extern fn IOHIDManagerUnscheduleFromRunLoop(manager: IOHIDManagerRef, runLoop: CFRunLoopRef, mode: CFStringRef) void;\npub extern fn IOHIDManagerCopyDevices(manager: IOHIDManagerRef) ?*anyopaque; // CFSetRef\npub extern fn CFSetGetCount(theSet: ?*anyopaque) CFIndex;\npub extern fn CFSetGetValues(theSet: ?*anyopaque, values: [*]?*const anyopaque) void;\npub extern fn IOHIDDeviceSetProperty(device: IOHIDDeviceRef, key: CFStringRef, property: CFTypeRef) Boolean;\n\n// Private IOHIDEventSystemClient API. These symbols are in\n// IOKit.framework but live in the private header\n// <IOKit/hidsystem/IOHIDEventSystemClient.h>. Karabiner-Elements uses\n// them — without this path the standard `IOHIDDeviceSetProperty(...,\n// HIDKeyboardCapsLockDelayOverride, 0)` returns success but the\n// property doesn't persist (firmware caps_lock toggle keeps firing).\npub const IOHIDEventSystemClientRef = ?*anyopaque;\npub const IOHIDServiceClientRef = ?*anyopaque;\n\npub extern fn IOHIDEventSystemClientCreateSimpleClient(allocator: CFAllocatorRef) IOHIDEventSystemClientRef;\npub extern fn IOHIDEventSystemClientCopyServices(client: IOHIDEventSystemClientRef) CFArrayRef;\npub extern fn IOHIDServiceClientGetRegistryID(service: IOHIDServiceClientRef) u64;\npub extern fn IOHIDServiceClientSetProperty(service: IOHIDServiceClientRef, key: CFStringRef, property: CFTypeRef) Boolean;\npub extern fn IOHIDServiceClientCopyProperty(service: IOHIDServiceClientRef, key: CFStringRef) CFTypeRef;\npub extern fn CFArrayGetCount(theArray: CFArrayRef) CFIndex;\npub extern fn CFArrayGetValueAtIndex(theArray: CFArrayRef, idx: CFIndex) ?*const anyopaque;\npub extern fn CFNumberGetValue(number: CFNumberRef, type_: CFNumberType, valuePtr: *anyopaque) Boolean;\n\npub extern fn IOHIDValueGetElement(value: IOHIDValueRef) IOHIDElementRef;\npub extern fn IOHIDValueGetIntegerValue(value: IOHIDValueRef) CFIndex;\npub extern fn IOHIDElementGetUsagePage(element: IOHIDElementRef) u32;\npub extern fn IOHIDElementGetUsage(element: IOHIDElementRef) u32;\n\n// IOService / IOHIDSystem client. Used by HidSystem.zig to force\n// caps_lock state off after Apple's MacBook keyboard firmware toggles\n// it through a side channel that IOHIDManager seize doesn't capture.\npub const mach_port_t = u32;\npub const task_port_t = mach_port_t;\npub const io_object_t = mach_port_t;\npub const io_service_t = io_object_t;\npub const io_connect_t = io_object_t;\n\npub const kIOMainPortDefault: mach_port_t = 0;\n// IOHIDSystem's user-client connect type for parameter access (the\n// type used to call IOHIDSet/GetModifierLockState). Defined in\n// <IOKit/hidsystem/IOHIDShared.h>.\npub const kIOHIDParamConnectType: u32 = 1;\n// Selector for IOHIDSet/GetModifierLockState. From <IOKit/hidsystem/IOLLEvent.h>.\npub const NX_MODIFIERKEY_ALPHALOCK: c_int = 0;\n\n// `mach_task_self()` is a macro expanding to this global; export it\n// directly so we don't need a C wrapper.\npub extern var mach_task_self_: mach_port_t;\n\npub extern fn IOServiceMatching(name: [*:0]const u8) CFMutableDictionaryRef;\npub extern fn IOServiceGetMatchingService(masterPort: mach_port_t, matching: CFDictionaryRef) io_service_t;\npub extern fn IOServiceOpen(service: io_service_t, owningTask: task_port_t, type_: u32, connect: *io_connect_t) IOReturn;\npub extern fn IOServiceClose(connect: io_connect_t) IOReturn;\npub extern fn IOObjectRelease(object: io_object_t) IOReturn;\npub extern fn IOHIDSetModifierLockState(handle: io_connect_t, selector: c_int, state: u8) IOReturn;\npub extern fn IOHIDGetModifierLockState(handle: io_connect_t, selector: c_int, state: *u8) IOReturn;\n\n// SystemConfiguration — read the active console user uid. D5 uses\n// this to apply rules only from the foreground user's agent (so\n// fast-user-switching doesn't get caps_lock-as-ctrl set up by a\n// background user's stored rules).\n//\n// We poll on a CFRunLoopTimer rather than subscribing to change\n// notifications — one syscall every few seconds is cheaper than the\n// SC notification dance and the responsiveness is fine for\n// user-switch (humans don't notice 3s).\npub const SCDynamicStoreRef = ?*anyopaque;\npub const uid_t = u32;\npub const gid_t = u32;\n\npub extern fn SCDynamicStoreCopyConsoleUser(\n    store: SCDynamicStoreRef,\n    uid_out: ?*uid_t,\n    gid_out: ?*gid_t,\n) CFStringRef;\n\n// CoreGraphics — read-only access to the system's modifier state.\n// Used to detect when Apple's firmware-level caps_lock toggle has\n// fired against our intent (so we can flip it back via a vhidd-\n// injected caps_lock toggle). Read-only call works for any process;\n// the matching IOHIDSetModifierLockState write does not.\npub const CGEventFlags = u64;\npub const CGEventSourceStateID = c_int;\npub const kCGEventSourceStateHIDSystemState: CGEventSourceStateID = 1;\npub const kCGEventFlagMaskAlphaShift: CGEventFlags = 0x10000;\n/// fn (the \"function\" / globe key on Apple keyboards). Set in\n/// CGEventFlags when fn is held. We use this to bypass the F-row\n/// media-key translation: `fn+F12` should produce F12 on the\n/// keyboard like default Apple behavior, not volume_up.\npub const kCGEventFlagMaskSecondaryFn: CGEventFlags = 0x800000;\npub extern fn CGEventSourceFlagsState(stateID: CGEventSourceStateID) CGEventFlags;\n\n// libc bits (geteuid for the seize permission check).\npub extern fn geteuid() c_uint;\n\n// stdio handle for setvbuf — when launchd redirects our stdout/stderr\n// to a file, they go block-buffered; per-event log lines then aren't\n// visible until the buffer fills. We force line-buffered (or\n// unbuffered) at startup so debug logs land immediately.\npub const FILE = anyopaque;\npub extern var __stderrp: *FILE;\npub extern var __stdoutp: *FILE;\npub const _IONBF: c_int = 2;\npub extern fn setvbuf(stream: *FILE, buf: ?[*]u8, mode: c_int, size: usize) c_int;\n"
  },
  {
    "path": "src/grabber/main.zig",
    "content": "//! `skhd-grabber` — system daemon, root-only.\n//!\n//! D1 scope: socket plumbing only. The daemon binds a Unix domain\n//! socket, accepts one client connection at a time, and processes the\n//! `hello` / `apply_rules` / `bye` IPC protocol. No HID seizing, no\n//! virtual HID injection, no rule execution yet — those land in D2–D4.\n//!\n//! Run modes:\n//!   skhd-grabber                            (listens on default socket)\n//!   skhd-grabber --socket-path /tmp/x.sock  (dev override, no root needed)\n//!   skhd-grabber --foreground               (log to stderr; otherwise\n//!                                            launchd captures stdout/stderr\n//!                                            via the plist)\n\nconst std = @import(\"std\");\nconst builtin = @import(\"builtin\");\nconst posix = std.posix;\n\nconst c = @import(\"c.zig\");\nconst protocol = @import(\"grabber_protocol\");\nconst HidSeize = @import(\"HidSeize.zig\");\nconst HidSystem = @import(\"HidSystem.zig\");\nconst Ipc = @import(\"Ipc.zig\");\nconst KbState = @import(\"KbState.zig\");\nconst TapHold = @import(\"TapHold.zig\");\nconst Vhidd = @import(\"Vhidd.zig\");\n\nconst log = std.log.scoped(.grabber);\n\n/// `-P/--profile` instrumentation is compiled in for Debug and\n/// ReleaseSafe only — matching `Tracer.zig` in the user-agent. In\n/// ReleaseFast/ReleaseSmall every profile branch folds away at\n/// comptime so the seize hot path pays nothing for it.\nconst profile_supported = builtin.mode == .Debug or builtin.mode == .ReleaseSafe;\n\npub const std_options: std.Options = .{\n    .log_level = switch (builtin.mode) {\n        .Debug => .debug,\n        .ReleaseSafe => .info,\n        .ReleaseFast, .ReleaseSmall => .warn,\n    },\n};\n\n/// Set by SIGTERM/SIGINT/SIGHUP so the accept() loop tears down on\n/// next iteration. async-signal-safe by being volatile primitives only.\nvar should_exit: std.atomic.Value(bool) = .init(false);\n\n/// Path of the bound socket; the SIGTERM handler uses it to unlink\n/// before exit so a stale file doesn't block respawn.\nvar bound_socket_path: ?[]const u8 = null;\n\npub fn main() !void {\n    // stderr → file is block-buffered by libc default. Our SIGTERM\n    // handler exits via _exit() which skips fflush, so block-buffered\n    // logs from the seize loop never reach the log file. Switching\n    // stderr (and stdout for symmetry) to unbuffered fixes that —\n    // each log line goes to the fd immediately.\n    _ = c.setvbuf(c.__stderrp, null, c._IONBF, 0);\n    _ = c.setvbuf(c.__stdoutp, null, c._IONBF, 0);\n\n    var debug_allocator: std.heap.DebugAllocator(.{}) = .init;\n    defer if (builtin.mode == .Debug or builtin.mode == .ReleaseSafe) {\n        _ = debug_allocator.deinit();\n    };\n    const gpa = switch (builtin.mode) {\n        .Debug, .ReleaseSafe => debug_allocator.allocator(),\n        .ReleaseFast, .ReleaseSmall => std.heap.smp_allocator,\n    };\n\n    const args = try std.process.argsAlloc(gpa);\n    defer std.process.argsFree(gpa, args);\n\n    var socket_path: []const u8 = protocol.default_socket_path;\n    var seize_test_vendor: ?u32 = null;\n    var seize_test_product: ?u32 = null;\n    var seize_test_duration_ms: u32 = 30_000;\n    var seize_test_observe: bool = false;\n    // Single inline tap-hold rule for the --seize-test debug harness.\n    // The daemon path takes rules from the IPC RuleSet instead; this\n    // slot only feeds seizeTest() for standalone HID-pipeline bring-up.\n    var seize_test_rule: ?TapHold.Rule = null;\n    // -P/--profile: emit one stderr line per HID-in / timer-sched /\n    // timer-fire / vhidd-post boundary so cold-start lag and steady-\n    // state cost can be measured. No effect on the no-profile path.\n    var profile = false;\n\n    var i: usize = 1;\n    while (i < args.len) : (i += 1) {\n        const a = args[i];\n        if (std.mem.eql(u8, a, \"--socket-path\")) {\n            i += 1;\n            if (i >= args.len) {\n                std.debug.print(\"error: --socket-path requires a path\\n\", .{});\n                std.process.exit(2);\n            }\n            socket_path = args[i];\n        } else if (std.mem.eql(u8, a, \"--foreground\")) {\n            // launchd's plist handles redirection in production; flag\n            // accepted for symmetry with other daemons but unused at\n            // D1. D6 will hook it up to a stderr redirect.\n        } else if (std.mem.eql(u8, a, \"--version\") or std.mem.eql(u8, a, \"-v\")) {\n            std.debug.print(\"skhd-grabber (D1 skeleton)\\n\", .{});\n            return;\n        } else if (std.mem.eql(u8, a, \"--help\") or std.mem.eql(u8, a, \"-h\")) {\n            printHelp();\n            return;\n        } else if (std.mem.eql(u8, a, \"--inject-test-key\")) {\n            injectTestKey(gpa) catch |err| {\n                log.err(\"inject-test-key failed: {s}\", .{@errorName(err)});\n                std.process.exit(1);\n            };\n            return;\n        } else if (std.mem.eql(u8, a, \"--seize-test\")) {\n            i += 1;\n            if (i >= args.len) {\n                std.debug.print(\"error: --seize-test requires <vendor>:<product> (hex)\\n\", .{});\n                std.process.exit(2);\n            }\n            const pair = parseVendorProduct(args[i]) catch {\n                std.debug.print(\"error: --seize-test arg must be VEND:PROD (e.g. 0x05AC:0x0342)\\n\", .{});\n                std.process.exit(2);\n            };\n            seize_test_vendor = pair[0];\n            seize_test_product = pair[1];\n        } else if (std.mem.eql(u8, a, \"--seize-test-observe\")) {\n            seize_test_observe = true;\n        } else if (std.mem.eql(u8, a, \"--rule\")) {\n            i += 1;\n            if (i >= args.len) {\n                std.debug.print(\"error: --rule requires SRC:TAP:HOLD[@TIMEOUT_MS]\\n\", .{});\n                std.process.exit(2);\n            }\n            seize_test_rule = parseRule(args[i]) catch {\n                std.debug.print(\"error: --rule must be VEND:PROD form like 0x39:0x29:0xE0@200\\n\", .{});\n                std.process.exit(2);\n            };\n        } else if (std.mem.eql(u8, a, \"--permissive-hold\")) {\n            if (seize_test_rule) |*r| r.permissive_hold = true;\n        } else if (std.mem.eql(u8, a, \"--hold-on-other-key-press\")) {\n            if (seize_test_rule) |*r| r.hold_on_other_key_press = true;\n        } else if (std.mem.eql(u8, a, \"-P\") or std.mem.eql(u8, a, \"--profile\")) {\n            if (comptime profile_supported) {\n                profile = true;\n            } else {\n                log.warn(\"--profile ignored: this binary was built in {s} mode (compiled out for zero overhead)\", .{@tagName(builtin.mode)});\n            }\n        } else if (std.mem.eql(u8, a, \"--seize-test-duration\")) {\n            i += 1;\n            if (i >= args.len) {\n                std.debug.print(\"error: --seize-test-duration requires seconds\\n\", .{});\n                std.process.exit(2);\n            }\n            seize_test_duration_ms = (std.fmt.parseInt(u32, args[i], 10) catch {\n                std.debug.print(\"error: invalid seconds: {s}\\n\", .{args[i]});\n                std.process.exit(2);\n            }) * 1000;\n        } else {\n            std.debug.print(\"error: unknown argument: {s}\\n\", .{a});\n            std.process.exit(2);\n        }\n    }\n\n    if (seize_test_vendor) |vendor| {\n        const product = seize_test_product.?;\n        const mode: HidSeize.Mode = if (seize_test_observe) .observe else .seize;\n        seizeTest(gpa, .{ .vendor = vendor, .product = product }, seize_test_duration_ms, mode, seize_test_rule, profile) catch |err| {\n            log.err(\"seize-test failed: {s}\", .{@errorName(err)});\n            std.process.exit(1);\n        };\n        return;\n    }\n\n    log.info(\"skhd-grabber starting (socket={s}, pid={d})\", .{ socket_path, std.c.getpid() });\n\n    var daemon = Daemon.init(gpa, socket_path, profile) catch |err| {\n        log.err(\"daemon init failed: {s}\", .{@errorName(err)});\n        return err;\n    };\n    defer daemon.deinit();\n\n    installSignalHandlers();\n    daemon.run();\n\n    log.info(\"shutting down\", .{});\n}\n\n/// Long-lived daemon state. The IPC listener stays alive for the\n/// process's whole life so the agent can re-apply rules without\n/// restarting the grabber. vhidd connection / HID seize / engine\n/// slots are all rebuilt from scratch on each apply_rules — that's\n/// the simple-and-correct policy until rule churn is high enough\n/// that a diff-and-patch path would be a meaningful win.\n/// One agent-grabber connection. The grabber keeps the socket open\n/// for the lifetime of the agent, both for `mode_change` push and\n/// for EOS detection: when the agent dies, our CFFileDescriptor on\n/// `fd` fires and the daemon drops this subscription, falling back\n/// to whatever earlier subscription was sitting underneath.\n///\n/// Heap-allocated so callbacks have a stable address.\nconst Subscription = struct {\n    daemon: *Daemon,\n    fd: c_int,\n    uid: u32,\n    stream: std.net.Stream,\n    rules: []protocol.Rule,\n    remaps: []protocol.Remap,\n    /// Mirror of NSGlobalDomain `com.apple.keyboard.fnState` as read by\n    /// the agent. Drives whether bare F-row should translate to media\n    /// (false, OS default) or stay literal (true).\n    fkeys_as_standard: bool,\n    cf_fd: c.CFFileDescriptorRef,\n    cf_source: c.CFRunLoopSourceRef,\n};\n\nconst Daemon = struct {\n    allocator: std.mem.Allocator,\n    socket_path: []const u8,\n    server: std.net.Server,\n\n    /// One per live agent connection. Most-recently-pushed apply_rules\n    /// from the active console uid wins. When a subscription's socket\n    /// goes EOS we drop it and fall back to the next-most-recent.\n    /// Heap-allocated entries so callbacks have stable addresses.\n    subscriptions: std.ArrayListUnmanaged(*Subscription) = .empty,\n\n    /// CFFileDescriptor wrapping `server.stream.handle`. Drives the\n    /// listener callback off the same CFRunLoop that handles HID\n    /// seize events, so accept() and seize callbacks don't compete.\n    cf_listener: c.CFFileDescriptorRef = null,\n    cf_listener_source: c.CFRunLoopSourceRef = null,\n\n    /// Lazy: connected on the first apply_rules so a daemon with no\n    /// rules pending doesn't spend 3s probing vhidd at startup.\n    vhidd: ?*Vhidd.Client = null,\n    /// Lazy: created on first apply_rules, recreated each rebuild.\n    seize: ?*HidSeize = null,\n    /// Slot array owned by the Daemon, sized to the current rule list.\n    slots: []EngineSlot = &.{},\n\n    /// Stable-address state for callbacks. Kept on the Daemon so the\n    /// CFFileDescriptor / CFRunLoopTimer / IOHIDManager callbacks have\n    /// a fixed `info` pointer across rebuilds.\n    seize_ctx: SeizeCtx,\n    layer_ctx: LayerPushCtx,\n\n    /// Active console user (foreground session). Subscriptions from\n    /// agents running in *other* sessions get stored but not applied\n    /// — only this uid's most-recent subscription drives seize/vhidd.\n    /// Null means \"no console user\" (login window). D5.\n    active_uid: ?u32 = null,\n    /// CFRunLoopTimer that re-queries the console user every few\n    /// seconds. On change, applyLatestRules re-evaluates which\n    /// subscription should be active.\n    console_user_timer: c.CFRunLoopTimerRef = null,\n\n    pub fn init(allocator: std.mem.Allocator, socket_path: []const u8, profile: bool) !Daemon {\n        try ensureSocketParentDir(socket_path);\n        // Stale socket from a crashed previous run: bind() would EADDRINUSE.\n        posix.unlink(socket_path) catch |err| switch (err) {\n            error.FileNotFound => {},\n            else => return err,\n        };\n\n        var addr = try std.net.Address.initUnix(socket_path);\n        var server = try addr.listen(.{ .reuse_address = false });\n        errdefer server.deinit();\n\n        bound_socket_path = socket_path;\n        // Mode 0666 so any logged-in user's agent can connect. Per-uid\n        // auth lands in D5 (uid is already carried in `hello`).\n        chmodPath(socket_path, 0o666) catch |err| {\n            log.warn(\"chmod {s} failed: {s}\", .{ socket_path, @errorName(err) });\n        };\n\n        // Best-effort open of IOHIDSystem. If this fails (unlikely as\n        // root) we keep running — only the caps_lock force-off\n        // behaviour is unavailable.\n        const hidsystem: ?HidSystem = HidSystem.init() catch |err| blk: {\n            log.warn(\"IOHIDSystem connect failed ({s}); caps_lock state won't be forced off\", .{@errorName(err)});\n            break :blk null;\n        };\n\n        const initial_uid = currentConsoleUid();\n        if (initial_uid) |u| {\n            log.info(\"active console user: uid={d}\", .{u});\n        } else {\n            log.info(\"no active console user at startup (login window?)\", .{});\n        }\n\n        return .{\n            .allocator = allocator,\n            .socket_path = socket_path,\n            .server = server,\n            .seize_ctx = .{\n                .state = .{},\n                // vhidd pointer set on lazy connect.\n                .vhidd = undefined,\n                .hidsystem = hidsystem,\n                .profile = profile,\n                .profile_timer = if (comptime profile_supported)\n                    (if (profile) try std.time.Timer.start() else undefined)\n                else\n                    undefined,\n            },\n            .layer_ctx = .{ .stream = null, .allocator = allocator },\n            .active_uid = initial_uid,\n        };\n    }\n\n    pub fn deinit(self: *Daemon) void {\n        self.stopConsoleUserTimer();\n        self.teardownSeize();\n        if (self.vhidd) |v| {\n            v.close();\n            self.allocator.destroy(v);\n            self.vhidd = null;\n        }\n        // Drop every live subscription — closes their sockets, frees\n        // their owned rules, releases CFFileDescriptors.\n        while (self.subscriptions.items.len > 0) {\n            const s = self.subscriptions.pop().?;\n            self.freeSubscription(s);\n        }\n        self.subscriptions.deinit(self.allocator);\n        if (self.cf_listener_source) |src| {\n            c.CFRunLoopRemoveSource(c.CFRunLoopGetCurrent(), src, c.kCFRunLoopDefaultMode);\n            c.CFRelease(src);\n            self.cf_listener_source = null;\n        }\n        if (self.cf_listener) |fd| {\n            c.CFFileDescriptorInvalidate(fd);\n            c.CFRelease(fd);\n            self.cf_listener = null;\n        }\n        if (self.seize_ctx.hidsystem) |*h| h.deinit();\n        self.server.deinit();\n        posix.unlink(self.socket_path) catch {};\n        bound_socket_path = null;\n    }\n\n    /// Release everything a Subscription owns. Called from\n    /// handleConnectionClose and from deinit. Safe even if the\n    /// CFFileDescriptor already invalidated itself.\n    fn freeSubscription(self: *Daemon, s: *Subscription) void {\n        c.CFRunLoopRemoveSource(c.CFRunLoopGetCurrent(), s.cf_source, c.kCFRunLoopDefaultMode);\n        c.CFRelease(s.cf_source);\n        c.CFFileDescriptorInvalidate(s.cf_fd);\n        c.CFRelease(s.cf_fd);\n        s.stream.close();\n        for (s.rules) |r| if (r.hold_layer) |l| self.allocator.free(l);\n        self.allocator.free(s.rules);\n        self.allocator.free(s.remaps);\n        self.allocator.destroy(s);\n    }\n\n    pub fn run(self: *Daemon) void {\n        self.startListenerSource() catch |err| {\n            log.err(\"failed to start IPC listener: {s}\", .{@errorName(err)});\n            return;\n        };\n        self.startConsoleUserTimer();\n\n        log.info(\"listening on {s}\", .{self.socket_path});\n\n        while (!should_exit.load(.acquire)) {\n            const rc = c.CFRunLoopRunInMode(c.kCFRunLoopDefaultMode, 60.0, 0);\n            switch (rc) {\n                c.kCFRunLoopRunStopped, c.kCFRunLoopRunFinished => break,\n                else => {},\n            }\n        }\n    }\n\n    fn startListenerSource(self: *Daemon) !void {\n        var ctx: c.CFFileDescriptorContext = .{\n            .version = 0,\n            .info = self,\n            .retain = null,\n            .release = null,\n            .copyDescription = null,\n        };\n        const cf_fd = c.CFFileDescriptorCreate(\n            c.kCFAllocatorDefault,\n            self.server.stream.handle,\n            0, // closeOnInvalidate=false: server owns the fd\n            listenerCallback,\n            &ctx,\n        );\n        if (cf_fd == null) return error.CFFileDescriptorCreateFailed;\n        errdefer c.CFRelease(cf_fd);\n        self.cf_listener = cf_fd;\n\n        const src = c.CFFileDescriptorCreateRunLoopSource(c.kCFAllocatorDefault, cf_fd, 0);\n        if (src == null) return error.RunLoopSourceCreateFailed;\n        self.cf_listener_source = src;\n\n        c.CFRunLoopAddSource(c.CFRunLoopGetCurrent(), src, c.kCFRunLoopDefaultMode);\n        c.CFFileDescriptorEnableCallBacks(cf_fd, c.kCFFileDescriptorReadCallBack);\n    }\n\n    fn handleListener(self: *Daemon) void {\n        const conn = self.server.accept() catch |err| {\n            log.warn(\"accept failed: {s}\", .{@errorName(err)});\n            return;\n        };\n\n        const result = Ipc.serve(self.allocator, conn.stream) catch |err| blk: {\n            log.warn(\"client session ended: {s}\", .{@errorName(err)});\n            break :blk Ipc.ServeResult.closed;\n        };\n\n        switch (result) {\n            .closed => conn.stream.close(),\n            .rules_applied => |applied| {\n                self.addSubscription(conn.stream, applied) catch |err| {\n                    log.err(\"addSubscription failed: {s}\", .{@errorName(err)});\n                    applied.free(self.allocator);\n                    conn.stream.close();\n                    return;\n                };\n                self.applyLatestRules() catch |err| {\n                    log.err(\"applyLatestRules failed: {s}\", .{@errorName(err)});\n                };\n            },\n        }\n    }\n\n    /// Take ownership of a fresh apply_rules: wrap in a Subscription\n    /// (with a CFFileDescriptor watching the socket for EOS) and add\n    /// to the stack. The most recent subscription wins for active\n    /// rules; on EOS its entry is dropped and the next-most-recent\n    /// takes over. Subscriptions from non-active uids stay parked in\n    /// the list — silenced now, candidate for \"active\" if the\n    /// console user switches.\n    fn addSubscription(self: *Daemon, stream: std.net.Stream, applied: Ipc.AppliedRules) !void {\n        const sub = try self.allocator.create(Subscription);\n        errdefer self.allocator.destroy(sub);\n        sub.* = .{\n            .daemon = self,\n            .fd = stream.handle,\n            .uid = applied.uid,\n            .stream = stream,\n            .rules = applied.rules,\n            .remaps = applied.remaps,\n            .fkeys_as_standard = applied.fkeys_as_standard,\n            .cf_fd = undefined,\n            .cf_source = undefined,\n        };\n\n        var ctx: c.CFFileDescriptorContext = .{\n            .version = 0,\n            .info = sub,\n            .retain = null,\n            .release = null,\n            .copyDescription = null,\n        };\n        const cf_fd = c.CFFileDescriptorCreate(\n            c.kCFAllocatorDefault,\n            stream.handle,\n            0,\n            subscriptionCallback,\n            &ctx,\n        );\n        if (cf_fd == null) return error.CFFileDescriptorCreateFailed;\n        errdefer c.CFRelease(cf_fd);\n\n        const src = c.CFFileDescriptorCreateRunLoopSource(c.kCFAllocatorDefault, cf_fd, 0);\n        if (src == null) {\n            c.CFFileDescriptorInvalidate(cf_fd);\n            return error.RunLoopSourceCreateFailed;\n        }\n        errdefer c.CFRelease(src);\n\n        sub.cf_fd = cf_fd;\n        sub.cf_source = src;\n\n        c.CFRunLoopAddSource(c.CFRunLoopGetCurrent(), src, c.kCFRunLoopDefaultMode);\n        c.CFFileDescriptorEnableCallBacks(cf_fd, c.kCFFileDescriptorReadCallBack);\n\n        try self.subscriptions.append(self.allocator, sub);\n        log.info(\"subscription added: uid={d} rules={d} remaps={d} (total subs={d})\", .{ applied.uid, applied.rules.len, applied.remaps.len, self.subscriptions.items.len });\n    }\n\n    /// Called from subscriptionCallback when an agent's socket goes\n    /// EOS or returns an unrecoverable error. Removes the entry and\n    /// re-evaluates active rules (which falls back to the prior\n    /// most-recent subscription, or tears down if none remain for\n    /// the active uid).\n    fn handleConnectionClose(self: *Daemon, sub: *Subscription) void {\n        var idx: ?usize = null;\n        for (self.subscriptions.items, 0..) |s, i| {\n            if (s == sub) {\n                idx = i;\n                break;\n            }\n        }\n        if (idx) |i| _ = self.subscriptions.orderedRemove(i);\n        log.info(\"subscription closed: uid={d} (total subs={d})\", .{ sub.uid, self.subscriptions.items.len });\n        self.freeSubscription(sub);\n        self.applyLatestRules() catch |err| {\n            log.warn(\"apply after close failed: {s}\", .{@errorName(err)});\n        };\n    }\n\n    /// Most recent subscription owned by the active console user, or\n    /// null if no subscription matches (login window, or only\n    /// background-user agents are connected). D5: lets fast-user-\n    /// switching toggle who drives seize without dropping anyone's\n    /// stored rules.\n    fn activeSubscription(self: *Daemon) ?*Subscription {\n        const uid = self.active_uid orelse return null;\n        var i = self.subscriptions.items.len;\n        while (i > 0) : (i -= 1) {\n            const s = self.subscriptions.items[i - 1];\n            if (s.uid == uid) return s;\n        }\n        return null;\n    }\n\n    /// (Re)build vhidd / seize / engine slots from the active\n    /// subscription's rules. Called whenever the active state\n    /// changes: a new agent connected, an existing one disconnected,\n    /// or the console user switched. The active subscription's\n    /// stream becomes the layer-push target.\n    fn applyLatestRules(self: *Daemon) !void {\n        const sub = self.activeSubscription() orelse {\n            log.info(\"no active subscription — keeping seize torn down\", .{});\n            self.teardownSeize();\n            self.layer_ctx.stream = null;\n            return;\n        };\n        const rules = sub.rules;\n        const remaps = sub.remaps;\n\n        var has_layer_rule = false;\n        var matches = std.ArrayList(HidSeize.Match).init(self.allocator);\n        defer matches.deinit();\n\n        const addMatch = struct {\n            fn call(list: *std.ArrayList(HidSeize.Match), dev: protocol.Device) !void {\n                for (list.items) |m| {\n                    if (m.vendor == dev.vendor and m.product == dev.product) return;\n                }\n                try list.append(.{ .vendor = dev.vendor, .product = dev.product });\n            }\n        }.call;\n\n        for (rules) |rule| {\n            const dev = rule.device orelse {\n                log.err(\"rule src=0x{X:0>2} has no device match — global seize not supported yet\", .{rule.src_usage});\n                return error.MissingDevice;\n            };\n            if (rule.hold_layer != null) has_layer_rule = true;\n            try addMatch(&matches, dev);\n        }\n        for (remaps) |rm| {\n            try addMatch(&matches, rm.device);\n        }\n\n        log.info(\"apply_rules: {d} rule(s), {d} remap(s) across {d} device(s) layer_push={}\", .{ rules.len, remaps.len, matches.items.len, has_layer_rule });\n        for (rules, 0..) |rule, i| {\n            const hold_str: []const u8 = if (rule.hold_layer) |l| l else \"<hid_usage>\";\n            log.info(\n                \"  rule[{d}]: src=0x{X:0>2} tap=0x{X:0>2} hold={s} timeout={d}ms perm={} hokp={}\",\n                .{ i, rule.src_usage, rule.tap_usage, hold_str, rule.timeout_ms, rule.permissive_hold, rule.hold_on_other_key_press },\n            );\n        }\n        for (remaps, 0..) |rm, i| {\n            log.info(\"  remap[{d}]: src=0x{X:0>2} → dst=0x{X:0>2}\", .{ i, rm.src_usage, rm.dst_usage });\n        }\n\n        // Lazy vhidd init on first apply.\n        if (self.vhidd == null) {\n            log.info(\"connecting to vhidd_server\", .{});\n            const v = try self.allocator.create(Vhidd.Client);\n            errdefer self.allocator.destroy(v);\n            v.* = try Vhidd.Client.connect(self.allocator);\n            errdefer v.close();\n            log.info(\"initializing virtual keyboard\", .{});\n            try v.initializeKeyboard(.{});\n            try v.waitForBoolTrue(.virtual_hid_keyboard_ready, 5000);\n            log.info(\"virtual keyboard ready\", .{});\n            self.vhidd = v;\n            self.seize_ctx.vhidd = v;\n        }\n\n        // Layer push target = the active subscription's stream\n        // (always live now thanks to per-connection tracking — the\n        // grabber doesn't close subscriptions out from under their\n        // owners any more).\n        self.layer_ctx.stream = if (has_layer_rule) sub.stream else null;\n\n        // Tear down old seize + slots before allocating new ones.\n        // HidSeize is the singleton (one-process IOHIDManager); we\n        // must release it before calling HidSeize.init again.\n        self.teardownSeize();\n\n        // Build slots and seize as locals first. Don't expose to\n        // `self` until everything's wired up — otherwise a failure\n        // partway through (e.g. seize.start returning NotPermitted\n        // because TCC denied us) leaves self.slots pointing at memory\n        // that errdefer just freed, and the next teardownSeize crashes\n        // iterating it.\n        const slots = try self.allocator.alloc(EngineSlot, rules.len);\n        errdefer self.allocator.free(slots);\n\n        for (rules, 0..) |rule, i| {\n            const hold_action: TapHold.HoldAction = if (rule.hold_layer) |layer_name| .{ .layer = layer_name } else .{\n                .hid_usage = std.math.cast(u16, rule.hold_usage) orelse return error.HoldUsageOverflow,\n            };\n            const th_rule: TapHold.Rule = .{\n                .src_usage = std.math.cast(u16, rule.src_usage) orelse return error.SourceUsageOverflow,\n                .tap_usage = std.math.cast(u16, rule.tap_usage) orelse return error.TapUsageOverflow,\n                .hold = hold_action,\n                .timeout_ms = rule.timeout_ms,\n                .permissive_hold = rule.permissive_hold,\n                .hold_on_other_key_press = rule.hold_on_other_key_press,\n                .retro_tap = rule.retro_tap,\n            };\n            slots[i] = .{\n                .seize_ctx = &self.seize_ctx,\n                .engine = TapHold.initWithLayerSink(\n                    th_rule,\n                    emitToVhidd,\n                    &self.seize_ctx,\n                    layerPushSink,\n                    &self.layer_ctx,\n                ),\n            };\n        }\n\n        const seize = try HidSeize.init(self.allocator, seizeInputCallback, &self.seize_ctx);\n        errdefer seize.deinit();\n        try seize.setMatches(matches.items);\n        try seize.start(.seize);\n\n        // Past the failure boundary: commit ownership atomically.\n        self.slots = slots;\n        self.seize_ctx.slots = slots;\n        self.seize = seize;\n\n        // Cache: do we have any caps_lock remap? Drives the per-event\n        // force-off in seizeInputCallback.\n        var caps_active = false;\n        for (slots) |*s| {\n            if (s.engine.rule.src_usage == 0x39) {\n                caps_active = true;\n                break;\n            }\n        }\n        self.seize_ctx.caps_remap_active = caps_active;\n        self.seize_ctx.fkeys_as_standard = sub.fkeys_as_standard;\n\n        // Rebuild the colon-form remap table. Reset to all-zero first\n        // so a previously-installed rule that's no longer present is\n        // forgotten.\n        self.seize_ctx.remap_table = @splat(0);\n        for (remaps) |rm| {\n            if (rm.src_usage >= self.seize_ctx.remap_table.len) {\n                log.warn(\"remap src=0x{X} out of HID page-7 range — skipping\", .{rm.src_usage});\n                continue;\n            }\n            const dst16 = std.math.cast(u16, rm.dst_usage) orelse {\n                log.warn(\"remap dst=0x{X} out of u16 range — skipping\", .{rm.dst_usage});\n                continue;\n            };\n            self.seize_ctx.remap_table[rm.src_usage] = dst16;\n        }\n\n        log.info(\"seize active — re-apply by sending another apply_rules over the IPC socket\", .{});\n    }\n\n    fn teardownSeize(self: *Daemon) void {\n        if (self.seize) |s| {\n            s.stop();\n            s.deinit();\n            self.seize = null;\n        }\n        for (self.slots) |*slot| cancelTapHoldTimer(slot);\n        if (self.slots.len > 0) self.allocator.free(self.slots);\n        self.slots = &.{};\n        self.seize_ctx.slots = &.{};\n        self.seize_ctx.caps_remap_active = false;\n        self.seize_ctx.remap_table = @splat(0);\n        // Drop any virtual keys we left held so a re-apply starts clean.\n        self.seize_ctx.consumer_state.clear();\n        self.seize_ctx.apple_top_case_state.clear();\n        self.seize_ctx.apple_keyboard_state.clear();\n        self.seize_ctx.generic_desktop_state.clear();\n        if (self.vhidd) |v| {\n            v.postKeyboardReport(.{}, &.{}) catch {};\n            v.postConsumerReport(&.{}) catch {};\n            v.postAppleVendorTopCaseReport(&.{}) catch {};\n            v.postAppleVendorKeyboardReport(&.{}) catch {};\n            v.postGenericDesktopReport(&.{}) catch {};\n        }\n    }\n\n    /// Poll the active console user every 3 seconds. On change, log\n    /// the transition and rebuild seize from the new uid's stored\n    /// rules (or tear down if they have none). Polling is much\n    /// simpler than SCDynamicStore notification subscription and the\n    /// 3s latency is well within human-noticeable limits for a\n    /// fast-user-switch.\n    fn startConsoleUserTimer(self: *Daemon) void {\n        if (self.console_user_timer != null) return;\n        var ctx: c.CFRunLoopTimerContext = .{\n            .version = 0,\n            .info = self,\n            .retain = null,\n            .release = null,\n            .copyDescription = null,\n        };\n        const interval: f64 = 3.0;\n        const fire_at = c.CFAbsoluteTimeGetCurrent() + interval;\n        self.console_user_timer = c.CFRunLoopTimerCreate(\n            c.kCFAllocatorDefault,\n            fire_at,\n            interval,\n            0,\n            0,\n            consoleUserTimerCallback,\n            &ctx,\n        );\n        if (self.console_user_timer == null) {\n            log.warn(\"could not create console_user timer; fast-user-switching may not be picked up\", .{});\n            return;\n        }\n        c.CFRunLoopAddTimer(c.CFRunLoopGetCurrent(), self.console_user_timer, c.kCFRunLoopDefaultMode);\n    }\n\n    fn stopConsoleUserTimer(self: *Daemon) void {\n        if (self.console_user_timer) |t| {\n            c.CFRunLoopTimerInvalidate(t);\n            c.CFRelease(t);\n            self.console_user_timer = null;\n        }\n    }\n};\n\nfn consoleUserTimerCallback(_: c.CFRunLoopTimerRef, info: ?*anyopaque) callconv(.C) void {\n    const d: *Daemon = @ptrCast(@alignCast(info orelse return));\n    const new_uid = currentConsoleUid();\n    if (new_uid == d.active_uid) return; // no change\n\n    log.info(\"active console user changed: {?d} → {?d}\", .{ d.active_uid, new_uid });\n    d.active_uid = new_uid;\n    // Rebuild from the (possibly new) uid's rules. No agent_conn\n    // because this rebuild isn't triggered by an agent message —\n    // when the new user's agent next sends apply_rules, that path\n    // wires up layer push.\n    d.applyLatestRules() catch |err| {\n        log.warn(\"rebuild after console user change failed: {s}\", .{@errorName(err)});\n    };\n}\n\nfn listenerCallback(\n    cf_fd: c.CFFileDescriptorRef,\n    callback_types: c.CFOptionFlags,\n    info: ?*anyopaque,\n) callconv(.C) void {\n    _ = callback_types;\n    const d: *Daemon = @ptrCast(@alignCast(info orelse return));\n    d.handleListener();\n    // CFFileDescriptor is one-shot: re-arm so the next pending\n    // accept fires another callback.\n    c.CFFileDescriptorEnableCallBacks(cf_fd, c.kCFFileDescriptorReadCallBack);\n}\n\n/// CFFileDescriptor callback for an active Subscription's socket.\n/// Fires either when the agent writes to us (which it shouldn't —\n/// the post-apply_rules direction is server → client only) or when\n/// the OS marks the fd readable due to EOS (peer closed). Either\n/// way we attempt a 1-byte read; 0-byte recv → EOS → drop the sub.\n/// A successful read of unexpected bytes is logged but kept alive.\nfn subscriptionCallback(\n    cf_fd: c.CFFileDescriptorRef,\n    callback_types: c.CFOptionFlags,\n    info: ?*anyopaque,\n) callconv(.C) void {\n    _ = callback_types;\n    const sub: *Subscription = @ptrCast(@alignCast(info orelse return));\n\n    // macOS MSG flags: MSG_PEEK=0x2, MSG_DONTWAIT=0x80. Hand-rolled\n    // because std.posix.MSG isn't exposed on Zig 0.14 darwin.\n    const MSG_PEEK: u32 = 0x2;\n    const MSG_DONTWAIT: u32 = 0x80;\n    var byte: [1]u8 = undefined;\n    const n = posix.recv(sub.fd, &byte, MSG_PEEK | MSG_DONTWAIT) catch |err| switch (err) {\n        error.WouldBlock => {\n            c.CFFileDescriptorEnableCallBacks(cf_fd, c.kCFFileDescriptorReadCallBack);\n            return;\n        },\n        else => {\n            log.info(\"subscription recv error ({s}) — dropping\", .{@errorName(err)});\n            sub.daemon.handleConnectionClose(sub);\n            return;\n        },\n    };\n    if (n == 0) {\n        // Peer closed cleanly.\n        sub.daemon.handleConnectionClose(sub);\n        return;\n    }\n    // Unexpected stray data from agent. Drain it and stay armed —\n    // the agent shouldn't send anything after apply_rules but we\n    // tolerate it rather than tear down.\n    log.warn(\"subscription uid={d}: unexpected {d} byte(s) from agent — discarding\", .{ sub.uid, n });\n    var drain: [256]u8 = undefined;\n    _ = posix.recv(sub.fd, &drain, MSG_DONTWAIT) catch {};\n    c.CFFileDescriptorEnableCallBacks(cf_fd, c.kCFFileDescriptorReadCallBack);\n}\n\n/// Read the current foreground console user uid, or null at the\n/// login window (no user logged in graphically). Calls\n/// SCDynamicStoreCopyConsoleUser with a null store, the canonical\n/// \"just give me the current value, no subscription\" form.\nfn currentConsoleUid() ?u32 {\n    var uid: c.uid_t = 0;\n    const name = c.SCDynamicStoreCopyConsoleUser(null, &uid, null);\n    if (name == null) return null;\n    c.CFRelease(name);\n    // SCDynamicStoreCopyConsoleUser returns \"loginwindow\" with uid=0\n    // at the login screen. Treat any uid < 500 (system uids) as\n    // \"no console user\" — real users on macOS start at 501.\n    if (uid < 500) return null;\n    return @intCast(uid);\n}\n\nfn ensureSocketParentDir(socket_path: []const u8) !void {\n    const dir = std.fs.path.dirname(socket_path) orelse return;\n    std.fs.makeDirAbsolute(dir) catch |err| switch (err) {\n        error.PathAlreadyExists => {},\n        else => return err,\n    };\n}\n\nfn chmodPath(path: []const u8, mode: u32) !void {\n    var path_buf: [std.fs.max_path_bytes:0]u8 = undefined;\n    if (path.len >= path_buf.len) return error.NameTooLong;\n    @memcpy(path_buf[0..path.len], path);\n    path_buf[path.len] = 0;\n    const rc = std.c.chmod(&path_buf, @intCast(mode));\n    if (rc != 0) return error.ChmodFailed;\n}\n\nfn installSignalHandlers() void {\n    var act: posix.Sigaction = .{\n        .handler = .{ .handler = handleSignal },\n        .mask = posix.empty_sigset,\n        .flags = 0,\n    };\n    posix.sigaction(posix.SIG.TERM, &act, null);\n    posix.sigaction(posix.SIG.INT, &act, null);\n    posix.sigaction(posix.SIG.HUP, &act, null);\n    // SIGPIPE: client dropped mid-write; we want EPIPE from write() and\n    // graceful continue, not whole-process termination.\n    var ignore: posix.Sigaction = .{\n        .handler = .{ .handler = posix.SIG.IGN },\n        .mask = posix.empty_sigset,\n        .flags = 0,\n    };\n    posix.sigaction(posix.SIG.PIPE, &ignore, null);\n}\n\nfn handleSignal(_: c_int) callconv(.C) void {\n    should_exit.store(true, .release);\n    // Best-effort unlink so the next launchd respawn binds cleanly.\n    // Path access is safe here because we set bound_socket_path once\n    // at startup before installing this handler.\n    if (bound_socket_path) |p| {\n        var path_buf: [std.fs.max_path_bytes:0]u8 = undefined;\n        if (p.len < path_buf.len) {\n            @memcpy(path_buf[0..p.len], p);\n            path_buf[p.len] = 0;\n            _ = std.c.unlink(&path_buf);\n        }\n    }\n    // accept() in the main loop retries internally on EINTR, so just\n    // setting a flag isn't enough to unblock it. _exit forces a clean\n    // shutdown without running atexit/destructors — fine here because\n    // the only stateful resource is the socket file we just unlinked.\n    // D5 will replace this with a self-pipe / kqueue wake-up so we can\n    // run normal teardown.\n    std.c._exit(0);\n}\n\n/// Connect to vhidd_server, initialize the virtual keyboard, wait for\n/// the ready signal, then send Escape keydown + keyup. Used to verify\n/// the Karabiner DriverKit injection path end-to-end (D2 phase).\nfn injectTestKey(allocator: std.mem.Allocator) !void {\n    log.info(\"connecting to vhidd_server…\", .{});\n    var client = try Vhidd.Client.connect(allocator);\n    defer client.close();\n\n    log.info(\"initializing virtual keyboard…\", .{});\n    try client.initializeKeyboard(.{});\n\n    log.info(\"waiting for virtual_hid_keyboard_ready=true…\", .{});\n    try client.waitForBoolTrue(.virtual_hid_keyboard_ready, 5000);\n    log.info(\"keyboard ready\", .{});\n\n    // Brief settle; in practice the ready signal is enough but Apple\n    // Silicon DriverKit sometimes needs a beat before injection lands\n    // reliably (matches the example client's 100ms post-ready sleep).\n    std.time.sleep(100 * std.time.ns_per_ms);\n\n    // 'a' (HID 0x04). Picked over Escape because Escape is invisible in\n    // most terminals — 'a' shows up on screen so injection success is\n    // self-evident. The test only proves the wire path; the choice of\n    // key is irrelevant.\n    const test_usage: u16 = 0x04;\n    log.info(\"posting keydown (a, HID 0x{X:0>2})\", .{test_usage});\n    try client.postKeyboardReport(.{}, &.{test_usage});\n\n    std.time.sleep(50 * std.time.ns_per_ms);\n\n    log.info(\"posting keyup (empty)\", .{});\n    try client.postKeyboardReport(.{}, &.{});\n\n    // Small post-write grace period before close so the kernel has\n    // time to deliver our final keyup before we tear the socket down.\n    std.time.sleep(50 * std.time.ns_per_ms);\n    log.info(\"done\", .{});\n}\n\n/// One TapHold engine + its currently-active CFRunLoopTimer. The\n/// timer's `info` points back at the slot so the timer callback can\n/// drive `engine.timerFired()` and apply the next action without\n/// scanning a list.\nconst EngineSlot = struct {\n    seize_ctx: *SeizeCtx,\n    engine: TapHold,\n    timer: c.CFRunLoopTimerRef = null,\n    /// Profile-only: ns-since-Timer-start when this slot's hold timer\n    /// is supposed to fire. Set in applyTapHoldTimer when scheduling\n    /// `start_in_ms`; read in tapHoldTimerCallback to compute drift\n    /// (actual fire − requested fire). Unread when profile is off.\n    profile_pending_fire_ns: u64 = 0,\n};\n\n/// State carried into the HidSeize input value callback. We can't\n/// closure-capture, so callers stash whatever the callback needs into\n/// a struct and pass its address as the void* context.\nconst SeizeCtx = struct {\n    state: KbState,\n    /// Per-page snapshot state for the non-keyboard HID pages we\n    /// forward through vhidd. Apple's built-in keyboard reports\n    /// media keys (volume, brightness, play/pause, …) on these pages\n    /// when the \"Use F1, F2… as standard function keys\" setting is\n    /// off, and seize captures them alongside the keyboard page —\n    /// so we have to forward them ourselves or the user loses every\n    /// F-row default action.\n    consumer_state: KbState.PageState = .{},\n    apple_top_case_state: KbState.PageState = .{},\n    apple_keyboard_state: KbState.PageState = .{},\n    generic_desktop_state: KbState.PageState = .{},\n    vhidd: *Vhidd.Client,\n    /// One slot per active rule. Empty in --inject-test-key /\n    /// --seize-test pass-through paths; populated in the daemon\n    /// loop after apply_rules.\n    slots: []EngineSlot = &.{},\n    forwarded: u64 = 0,\n    skipped_other_pages: u64 = 0,\n    /// IOHIDSystem connection used to force caps_lock state off after\n    /// Apple firmware would otherwise toggle it. Null when the open\n    /// failed (we still process events; only the caps_lock-neutralize\n    /// behaviour is skipped).\n    hidsystem: ?HidSystem = null,\n    /// Cached: any active rule has src_usage = 0x39. When false, we\n    /// skip the per-event caps_lock force-off so we don't interfere\n    /// with users who haven't remapped caps_lock.\n    caps_remap_active: bool = false,\n    /// Mirror of NSGlobalDomain `com.apple.keyboard.fnState` from the\n    /// active subscription. Decides whether bare F-row should run\n    /// `translateFRow` (false → media keys) or pass through as a\n    /// literal F<i> keyboard-page event (true → F-keys are the OS\n    /// default, fn-modifier flips back to media). Read on each\n    /// `applyLatestRules`.\n    fkeys_as_standard: bool = false,\n    /// Colon-form `.remap` table. Indexed by HID usage (page 0x07\n    /// only). Zero means \"no remap\"; any nonzero value rewrites the\n    /// source usage on its way in. Sized to 0x100 — covers every\n    /// keyboard usage. Allocated once and reused on each rule\n    /// rebuild. We don't device-key this in v1 — typical user has\n    /// one seized device and remaps target it by alias anyway.\n    remap_table: [0x100]u16 = @splat(0),\n    /// -P/--profile: emit timeline traces. Off-path is unaffected.\n    profile: bool = false,\n    /// Monotonic clock anchor used by profile traces. Only valid when\n    /// `profile` is true; left undefined otherwise so the no-profile\n    /// path doesn't pay for the syscall.\n    profile_timer: std.time.Timer = undefined,\n};\n\n/// Profile-trace helper: ns-since-`profile_timer.start()` cast to\n/// microseconds for compact stderr output. Caller is responsible for\n/// gating with `cx.profile` so the cost is paid only when enabled.\ninline fn profUs(cx: *SeizeCtx) u64 {\n    return cx.profile_timer.read() / std.time.ns_per_us;\n}\n\n/// State for the layer-hold sink: a borrowed agent IPC stream + the\n/// allocator we use to build outbound JSON payloads.\nconst LayerPushCtx = struct {\n    stream: ?std.net.Stream,\n    allocator: std.mem.Allocator,\n};\n\n/// TapHold layer sink: write a `mode_change` message back to the\n/// agent over the live IPC connection. Failures are logged but\n/// don't propagate — the seize loop must keep running even if the\n/// agent has disconnected. (D6 will detect that and reset state.)\nfn layerPushSink(ctx_ptr: ?*anyopaque, layer: []const u8, entering: bool) void {\n    const lctx: *LayerPushCtx = @ptrCast(@alignCast(ctx_ptr orelse return));\n    const stream = lctx.stream orelse {\n        log.warn(\"layer transition '{s}' entering={} dropped — no agent connection\", .{ layer, entering });\n        return;\n    };\n\n    // On exit, an empty mode name tells the agent \"fall back to\n    // default\". Push the layer name on enter so multi-layer setups\n    // can target named modes individually.\n    const target: []const u8 = if (entering) layer else \"\";\n    protocol.writeMessage(stream, lctx.allocator, .{\n        .@\"type\" = \"mode_change\",\n        .mode = target,\n    }) catch |err| {\n        log.warn(\"mode_change push failed: {s}\", .{@errorName(err)});\n    };\n}\n\n/// Sink for both real HID events and TapHold-synthesized events.\n/// Aggregates the transition into KbState and posts a vhidd report.\nfn emitToVhidd(ctx_ptr: ?*anyopaque, ev: TapHold.Event) void {\n    const cx: *SeizeCtx = @ptrCast(@alignCast(ctx_ptr orelse return));\n    if (ev.usage_page != 0x07) return;\n    const usage16 = std.math.cast(u16, ev.usage) orelse return;\n    if (usage16 < 0x04) return;\n    if (!cx.state.applyKeyboardEvent(usage16, ev.pressed)) return;\n\n    log.info(\"emit: usage=0x{X:0>2} pressed={}\", .{ usage16, ev.pressed });\n\n    const held = cx.state.compactedKeys();\n    const t_pre: u64 = if (comptime profile_supported) (if (cx.profile) cx.profile_timer.read() else 0) else 0;\n    cx.vhidd.postKeyboardReport(cx.state.modifiers, held) catch |err| {\n        log.warn(\"vhidd post failed: {s}\", .{@errorName(err)});\n        return;\n    };\n    if (comptime profile_supported) {\n        if (cx.profile) {\n            const t_post = cx.profile_timer.read();\n            std.debug.print(\"[prof] vhidd-post t={d}us cost={d}us usage=0x{X:0>2} pressed={}\\n\", .{\n                t_post / std.time.ns_per_us, (t_post - t_pre) / std.time.ns_per_us, usage16, ev.pressed,\n            });\n        }\n    }\n    cx.forwarded += 1;\n}\n\nfn applyTapHoldTimer(slot: *EngineSlot, action: TapHold.TimerAction) void {\n    switch (action) {\n        .none => {},\n        .cancel => cancelTapHoldTimer(slot),\n        .start_in_ms => |ms| {\n            cancelTapHoldTimer(slot);\n            if (comptime profile_supported) {\n                if (slot.seize_ctx.profile) {\n                    const now_ns = slot.seize_ctx.profile_timer.read();\n                    slot.profile_pending_fire_ns = now_ns + @as(u64, ms) * std.time.ns_per_ms;\n                    std.debug.print(\"[prof] timer-sched t={d}us src=0x{X:0>2} fire-after={d}ms\\n\", .{\n                        now_ns / std.time.ns_per_us, slot.engine.rule.src_usage, ms,\n                    });\n                }\n            }\n            slot.timer = makeTapHoldTimer(ms, slot);\n            c.CFRunLoopAddTimer(c.CFRunLoopGetCurrent(), slot.timer, c.kCFRunLoopDefaultMode);\n        },\n    }\n}\n\nfn cancelTapHoldTimer(slot: *EngineSlot) void {\n    if (slot.timer != null) {\n        // Invalidate first so the run loop drops its strong ref AND\n        // so the timer never fires (otherwise CFRelease alone would\n        // keep the timer scheduled — the run loop still owns its own\n        // retain via CFRunLoopAddTimer, and the timer would later fire\n        // a stale callback that could mis-commit a hold during the\n        // next tap's pending state).\n        c.CFRunLoopTimerInvalidate(slot.timer);\n        c.CFRelease(slot.timer);\n        slot.timer = null;\n    }\n}\n\nfn tapHoldTimerCallback(_: c.CFRunLoopTimerRef, info: ?*anyopaque) callconv(.C) void {\n    const slot: *EngineSlot = @ptrCast(@alignCast(info orelse return));\n    if (comptime profile_supported) {\n        if (slot.seize_ctx.profile) {\n            const now_ns = slot.seize_ctx.profile_timer.read();\n            // Drift = actual fire − requested fire, in microseconds.\n            // Positive means the runloop fired late; large positive on\n            // the first hold after startup would be the cold-start\n            // signal we're hunting.\n            const now_us: i128 = @intCast(now_ns / std.time.ns_per_us);\n            const want_us: i128 = @intCast(slot.profile_pending_fire_ns / std.time.ns_per_us);\n            std.debug.print(\"[prof] timer-fire t={d}us src=0x{X:0>2} drift={d}us\\n\", .{\n                now_ns / std.time.ns_per_us, slot.engine.rule.src_usage, now_us - want_us,\n            });\n        }\n    }\n    const action = slot.engine.timerFired();\n    // The timer that just fired is now expired — drop our handle\n    // before applying any new timer action so we don't try to\n    // CFRelease a freed-by-runloop reference if the engine asks\n    // for cancel.\n    if (slot.timer != null) {\n        c.CFRunLoopTimerInvalidate(slot.timer);\n        c.CFRelease(slot.timer);\n        slot.timer = null;\n    }\n    applyTapHoldTimer(slot, action);\n}\n\nfn makeTapHoldTimer(after_ms: u32, slot: *EngineSlot) c.CFRunLoopTimerRef {\n    const fire_date = c.CFAbsoluteTimeGetCurrent() + @as(f64, @floatFromInt(after_ms)) / 1000.0;\n    var context: c.CFRunLoopTimerContext = .{\n        .version = 0,\n        .info = slot,\n        .retain = null,\n        .release = null,\n        .copyDescription = null,\n    };\n    return c.CFRunLoopTimerCreate(\n        c.kCFAllocatorDefault,\n        fire_date,\n        0,\n        0,\n        0,\n        tapHoldTimerCallback,\n        &context,\n    );\n}\n\n/// Where to route a translated F-row press. Indexes into the\n/// non-keyboard-page states owned by SeizeCtx.\nconst FRowTarget = union(enum) {\n    consumer: u16,\n    apple_vendor_keyboard: u16,\n    generic_desktop: u16,\n};\n\n/// F1..F12 → media-key translation, matches Karabiner's defaults\n/// for Apple built-in keyboards (see fn_function_keys_manipulator).\n/// Indexed by `usage - 0x3A`.\nconst f_row_translation = [12]FRowTarget{\n    .{ .consumer = 0x70 }, // F1  display_brightness_decrement\n    .{ .consumer = 0x6F }, // F2  display_brightness_increment\n    .{ .apple_vendor_keyboard = 0x10 }, // F3  mission_control\n    .{ .apple_vendor_keyboard = 0x01 }, // F4  spotlight\n    .{ .consumer = 0xCF }, // F5  voice_command (dictation)\n    .{ .generic_desktop = 0x9B }, // F6  do_not_disturb\n    .{ .consumer = 0xB4 }, // F7  rewind\n    .{ .consumer = 0xCD }, // F8  play_or_pause\n    .{ .consumer = 0xB3 }, // F9  fast_forward\n    .{ .consumer = 0xE2 }, // F10 mute\n    .{ .consumer = 0xEA }, // F11 volume_decrement\n    .{ .consumer = 0xE9 }, // F12 volume_increment\n};\n\nfn translateFRow(cx: *SeizeCtx, raw_usage16: u16, pressed: bool) void {\n    const idx: usize = raw_usage16 - 0x3A;\n    const target = f_row_translation[idx];\n    switch (target) {\n        .consumer => |u| {\n            if (!cx.consumer_state.apply(u, pressed)) return;\n            cx.vhidd.postConsumerReport(cx.consumer_state.compacted()) catch |err| {\n                log.warn(\"vhidd consumer post failed: {s}\", .{@errorName(err)});\n            };\n        },\n        .apple_vendor_keyboard => |u| {\n            if (!cx.apple_keyboard_state.apply(u, pressed)) return;\n            cx.vhidd.postAppleVendorKeyboardReport(cx.apple_keyboard_state.compacted()) catch |err| {\n                log.warn(\"vhidd apple-keyboard post failed: {s}\", .{@errorName(err)});\n            };\n        },\n        .generic_desktop => |u| {\n            if (!cx.generic_desktop_state.apply(u, pressed)) return;\n            cx.vhidd.postGenericDesktopReport(cx.generic_desktop_state.compacted()) catch |err| {\n                log.warn(\"vhidd generic-desktop post failed: {s}\", .{@errorName(err)});\n            };\n        },\n    }\n}\n\nfn seizeInputCallback(ctx: ?*anyopaque, ev: HidSeize.Event) void {\n    const cx: *SeizeCtx = @ptrCast(@alignCast(ctx orelse return));\n\n    if (comptime profile_supported) {\n        if (cx.profile) {\n            std.debug.print(\"[prof] hid-in t={d}us page=0x{X:0>2} usage=0x{X:0>4} pressed={}\\n\", .{\n                profUs(cx), ev.usage_page, ev.usage, ev.pressed,\n            });\n        }\n    }\n\n    log.debug(\"hid event: page=0x{X:0>2} usage=0x{X:0>4} pressed={}\", .{\n        ev.usage_page,\n        ev.usage,\n        ev.pressed,\n    });\n\n    // Forward non-keyboard pages straight through vhidd so the F-row\n    // default media actions (volume, brightness, play/pause, …) keep\n    // working on the seized device. These pages don't run through\n    // tap-hold or the colon-form remap table — they're pass-through\n    // only.\n    if (ev.usage_page != 0x07) {\n        cx.skipped_other_pages += 1;\n        const usage16 = std.math.cast(u16, ev.usage) orelse return;\n        switch (ev.usage_page) {\n            0x0C => {\n                if (!cx.consumer_state.apply(usage16, ev.pressed)) return;\n                cx.vhidd.postConsumerReport(cx.consumer_state.compacted()) catch |err| {\n                    log.warn(\"vhidd consumer post failed: {s}\", .{@errorName(err)});\n                };\n            },\n            0xFF => {\n                if (!cx.apple_top_case_state.apply(usage16, ev.pressed)) return;\n                cx.vhidd.postAppleVendorTopCaseReport(cx.apple_top_case_state.compacted()) catch |err| {\n                    log.warn(\"vhidd apple-top-case post failed: {s}\", .{@errorName(err)});\n                };\n            },\n            0xFF01 => {\n                if (!cx.apple_keyboard_state.apply(usage16, ev.pressed)) return;\n                cx.vhidd.postAppleVendorKeyboardReport(cx.apple_keyboard_state.compacted()) catch |err| {\n                    log.warn(\"vhidd apple-keyboard post failed: {s}\", .{@errorName(err)});\n                };\n            },\n            else => {}, // unknown page; ignore\n        }\n        return;\n    }\n\n    // Guard the truncation: HID 0x07 usages in normal use are\n    // 0x04..0xE7, but the kernel emits the keys[] array element\n    // itself with a sentinel usage (0xFFFFFFFF) carrying the\n    // per-slot value, plus status codes 0x00..0x03 (no event /\n    // ErrorRollOver / POSTFail / ErrorUndefined). None of those\n    // should drive our state machine.\n    const raw_usage16 = std.math.cast(u16, ev.usage) orelse return;\n    if (raw_usage16 < 0x04) return;\n\n    // F1..F12 on Apple's built-in keyboard need translation. Seizing\n    // the keyboard service silences the OS's apple-vendor media-key\n    // path for that device, so we have to do the F-row → media\n    // translation ourselves and post through vhidd's consumer /\n    // apple-vendor / generic-desktop reports. Mirrors Karabiner's\n    // `fn_function_keys_manipulator` default mapping.\n    //\n    // Policy follows NSGlobalDomain `com.apple.keyboard.fnState`\n    // (\"Use F1, F2 … as standard function keys\"), forwarded to us by\n    // the agent so we don't have to do a per-uid prefs read from a\n    // root daemon:\n    //   pref OFF (default): bare F<i> → media, fn+F<i> → literal F<i>\n    //   pref ON:            bare F<i> → literal F<i>, fn+F<i> → media\n    //\n    // We read fn-state from our own `apple_top_case_state` rather than\n    // `CGEventSourceFlagsState(kCGEventSourceStateHIDSystemState)`\n    // because the latter is polluted by our own vhidd forwards: every\n    // fn we round-trip through vhidd is reflected back into the OS HID\n    // flags, so the query stops being a clean \"is the user holding\n    // fn?\" signal. The internal page-state, by contrast, only tracks\n    // events from the seized device — same source the rest of seize\n    // uses, so the F-row decision stays self-consistent.\n    if (raw_usage16 >= 0x3A and raw_usage16 <= 0x45) {\n        const fn_held = blk: for (cx.apple_top_case_state.keys) |k| {\n            if (k == 0x03) break :blk true;\n        } else false;\n        const want_media = if (cx.fkeys_as_standard) fn_held else !fn_held;\n        if (want_media) {\n            translateFRow(cx, raw_usage16, ev.pressed);\n            return;\n        }\n        // else fall through to keyboard-page emit (literal F-key).\n    }\n\n    // Apply colon-form `.remap` rewrites BEFORE the slots see the\n    // event — hidutil's UserKeyMapping doesn't reach seized devices\n    // (kIOHIDOptionsTypeSeizeDevice bypasses the IOHIDLib filter\n    // chain), so the grabber has to do the substitution itself.\n    const usage16: u16 = blk: {\n        if (raw_usage16 < cx.remap_table.len) {\n            const dst = cx.remap_table[raw_usage16];\n            if (dst != 0) break :blk dst;\n        }\n        break :blk raw_usage16;\n    };\n\n    const taphold_event: TapHold.Event = .{\n        .usage_page = 0x07,\n        .usage = usage16,\n        .pressed = ev.pressed,\n    };\n\n    // If this event is the source of some slot's tap-hold rule,\n    // only deliver it to that slot — never to other slots. Letting\n    // a foreign slot buffer a fellow source leads to double-emit\n    // pathologies: user presses caps+space, caps_slot (still\n    // pending) snapshots space-down into its buffer, and on caps's\n    // hold-commit replays space-down to the OS, which then sees\n    // space held under ctrl and autorepeats.\n    //\n    // The check has to be \"is this any slot's source?\", not \"is\n    // this a *currently-pending* slot's source?\": at the moment\n    // space-down arrives, space_slot hasn't yet transitioned to\n    // pending (this event is what triggers it), so the pending-\n    // state-filtered version of the check would miss this case.\n    const event_is_some_slot_source = blk: for (cx.slots) |*slot| {\n        if (slot.engine.rule.src_usage == usage16) break :blk true;\n    } else false;\n\n    var any_consumed = false;\n    for (cx.slots) |*slot| {\n        if (event_is_some_slot_source and slot.engine.rule.src_usage != usage16) {\n            continue;\n        }\n        const r = slot.engine.feed(taphold_event);\n        applyTapHoldTimer(slot, r.timer);\n        if (r.disposition == .consumed) any_consumed = true;\n    }\n\n    // Apple firmware caps_lock neutralization. The proper fix\n    // (HIDKeyboardCapsLockDelayOverride=0 via IOHIDServiceClient or\n    // IOHIDSetModifierLockState) is gated on a real Apple Developer\n    // ID signature — both fail silently on self-signed binaries.\n    // Fallback: read the OS-level caps_lock state via the still-open\n    // CGEventSource API and, if Apple's firmware has toggled it,\n    // inject a vhidd caps_lock toggle to flip it back. Visible as a\n    // brief LED flash on long holds; clean otherwise.\n    if (usage16 == 0x39 and cx.caps_remap_active) {\n        if (cx.hidsystem) |*hs| hs.setCapsLockState(false); // no-op when unsigned\n        const flags = c.CGEventSourceFlagsState(c.kCGEventSourceStateHIDSystemState);\n        if ((flags & c.kCGEventFlagMaskAlphaShift) != 0) {\n            log.info(\"caps_lock toggled by firmware — injecting vhidd toggle to flip off\", .{});\n            // Apply the toggle to KbState so the next vhidd report\n            // reflects it correctly, then post.\n            _ = cx.state.applyKeyboardEvent(0x39, true);\n            cx.vhidd.postKeyboardReport(cx.state.modifiers, cx.state.compactedKeys()) catch {};\n            _ = cx.state.applyKeyboardEvent(0x39, false);\n            cx.vhidd.postKeyboardReport(cx.state.modifiers, cx.state.compactedKeys()) catch {};\n        }\n    }\n\n    if (any_consumed) return;\n\n    emitToVhidd(@ptrCast(cx), taphold_event);\n}\n\n/// Open the vhidd virtual keyboard, seize the matched physical\n/// keyboard, run the CFRunLoop with everything-passes-through for\n/// `duration_ms`, then release. Used to verify D3 end-to-end before\n/// any rules are wired up.\nfn seizeTest(\n    allocator: std.mem.Allocator,\n    match: HidSeize.Match,\n    duration_ms: u32,\n    mode: HidSeize.Mode,\n    rule: ?TapHold.Rule,\n    profile: bool,\n) !void {\n    log.info(\"seize-test: device 0x{X:0>4}:0x{X:0>4} for {d}ms (mode={s})\", .{\n        match.vendor,\n        match.product,\n        duration_ms,\n        @tagName(mode),\n    });\n    if (rule) |r| {\n        const hold_str: []const u8 = switch (r.hold) {\n            .hid_usage => \"<hid_usage>\",\n            .layer => |n| n,\n        };\n        log.info(\n            \"  taphold rule: src=0x{X:0>2} tap=0x{X:0>2} hold={s} timeout={d}ms perm={} hokp={}\",\n            .{ r.src_usage, r.tap_usage, hold_str, r.timeout_ms, r.permissive_hold, r.hold_on_other_key_press },\n        );\n    }\n\n    if (c.geteuid() != 0) {\n        log.err(\"seize-test needs root (sudo)\", .{});\n        return error.NotPrivileged;\n    }\n\n    log.info(\"connecting to vhidd_server\", .{});\n    var vhidd = try Vhidd.Client.connect(allocator);\n    defer vhidd.close();\n\n    log.info(\"initializing virtual keyboard\", .{});\n    try vhidd.initializeKeyboard(.{});\n    try vhidd.waitForBoolTrue(.virtual_hid_keyboard_ready, 5000);\n    log.info(\"virtual keyboard ready\", .{});\n\n    var ctx = SeizeCtx{\n        .state = .{},\n        .vhidd = &vhidd,\n        .hidsystem = HidSystem.init() catch |err| blk: {\n            log.warn(\"IOHIDSystem connect failed ({s}); caps_lock force-off skipped\", .{@errorName(err)});\n            break :blk null;\n        },\n        .profile = profile,\n        .profile_timer = if (comptime profile_supported)\n            (if (profile) try std.time.Timer.start() else undefined)\n        else\n            undefined,\n    };\n    defer if (ctx.hidsystem) |*h| h.deinit();\n\n    // Single inline rule for --seize-test. The slot must outlive the\n    // run loop, so we keep it on the stack and slice into it.\n    var slot_storage: [1]EngineSlot = undefined;\n    if (rule) |r| {\n        slot_storage[0] = .{\n            .seize_ctx = &ctx,\n            .engine = TapHold.init(r, emitToVhidd, &ctx),\n        };\n        ctx.slots = slot_storage[0..1];\n        if (r.src_usage == 0x39) ctx.caps_remap_active = true;\n    }\n    defer for (ctx.slots) |*s| cancelTapHoldTimer(s);\n\n    var seize = try HidSeize.init(allocator, seizeInputCallback, &ctx);\n    defer seize.deinit();\n    try seize.setMatches(&.{match});\n    try seize.start(mode);\n\n    // Schedule a timer on the same run loop so we exit on duration.\n    var timer_ctx = TimerCtx{};\n    const timer = makeTimer(duration_ms, &timer_ctx);\n    defer c.CFRelease(timer);\n    c.CFRunLoopAddTimer(c.CFRunLoopGetCurrent(), timer, c.kCFRunLoopDefaultMode);\n\n    log.info(\"seize active — typing on the seized keyboard should still work via vhidd pass-through\", .{});\n\n    // CFRunLoopRunInMode returns kCFRunLoopRunStopped when the timer\n    // calls CFRunLoopStop. We loop in case of spurious wake-ups.\n    while (!timer_ctx.stop) {\n        const r = c.CFRunLoopRunInMode(c.kCFRunLoopDefaultMode, 60.0, 0);\n        switch (r) {\n            c.kCFRunLoopRunStopped, c.kCFRunLoopRunFinished => break,\n            else => {},\n        }\n    }\n\n    seize.stop();\n\n    // Belt-and-braces: post an empty report to drop any virtual keys\n    // we left held when the test ended (e.g. user kept the source\n    // key pressed through the timeout, which committed hold but\n    // never saw the corresponding source-up to emit hold-up).\n    vhidd.postKeyboardReport(.{}, &.{}) catch {};\n\n    log.info(\"seize-test done — events forwarded={d} other-pages-skipped={d}\", .{\n        ctx.forwarded,\n        ctx.skipped_other_pages,\n    });\n}\n\nconst TimerCtx = struct {\n    stop: bool = false,\n};\n\nfn timerCallback(_: c.CFRunLoopTimerRef, info: ?*anyopaque) callconv(.C) void {\n    const ctx: *TimerCtx = @ptrCast(@alignCast(info orelse return));\n    ctx.stop = true;\n    c.CFRunLoopStop(c.CFRunLoopGetCurrent());\n}\n\nfn makeTimer(after_ms: u32, ctx: *TimerCtx) c.CFRunLoopTimerRef {\n    const fire_date = c.CFAbsoluteTimeGetCurrent() + @as(f64, @floatFromInt(after_ms)) / 1000.0;\n    var context: c.CFRunLoopTimerContext = .{\n        .version = 0,\n        .info = ctx,\n        .retain = null,\n        .release = null,\n        .copyDescription = null,\n    };\n    return c.CFRunLoopTimerCreate(\n        c.kCFAllocatorDefault,\n        fire_date,\n        0,\n        0,\n        0,\n        timerCallback,\n        &context,\n    );\n}\n\n/// Parse a tap-hold rule of the form `SRC:TAP:HOLD[@TIMEOUT_MS]`.\n/// Each usage may be hex (0x...) or decimal. Timeout defaults to\n/// 200ms if omitted. Modifier flags (--permissive-hold etc.) are\n/// applied separately by the CLI parser after this returns.\nfn parseRule(s: []const u8) !TapHold.Rule {\n    const at_pos = std.mem.indexOfScalar(u8, s, '@');\n    const usages_part = if (at_pos) |p| s[0..p] else s;\n    const timeout_part = if (at_pos) |p| s[p + 1 ..] else \"\";\n\n    var it = std.mem.splitScalar(u8, usages_part, ':');\n    const src_s = it.next() orelse return error.MissingSource;\n    const tap_s = it.next() orelse return error.MissingTap;\n    const hold_s = it.next() orelse return error.MissingHold;\n    if (it.next() != null) return error.TooManyParts;\n\n    const src = try parseHexOrDec(src_s);\n    const tap = try parseHexOrDec(tap_s);\n    const hold = try parseHexOrDec(hold_s);\n\n    const timeout: u32 = if (timeout_part.len > 0) try parseHexOrDec(timeout_part) else 200;\n\n    return .{\n        .src_usage = std.math.cast(u16, src) orelse return error.SourceUsageOverflow,\n        .tap_usage = std.math.cast(u16, tap) orelse return error.TapUsageOverflow,\n        .hold = .{ .hid_usage = std.math.cast(u16, hold) orelse return error.HoldUsageOverflow },\n        .timeout_ms = timeout,\n    };\n}\n\nfn parseVendorProduct(s: []const u8) ![2]u32 {\n    const colon = std.mem.indexOfScalar(u8, s, ':') orelse return error.MissingColon;\n    const vendor = try parseHexOrDec(s[0..colon]);\n    const product = try parseHexOrDec(s[colon + 1 ..]);\n    return .{ vendor, product };\n}\n\nfn parseHexOrDec(s: []const u8) !u32 {\n    if (std.mem.startsWith(u8, s, \"0x\") or std.mem.startsWith(u8, s, \"0X\")) {\n        return std.fmt.parseInt(u32, s[2..], 16);\n    }\n    return std.fmt.parseInt(u32, s, 10);\n}\n\nfn printHelp() void {\n    std.debug.print(\n        \\\\skhd-grabber - system daemon for caps-class tap-hold remaps\n        \\\\\n        \\\\Usage: skhd-grabber [options]\n        \\\\\n        \\\\Options:\n        \\\\  --socket-path <path>   Override IPC socket path\n        \\\\                         (default: /var/run/skhd/grabber.sock)\n        \\\\  --foreground           Run in foreground (logs to stderr)\n        \\\\  -v, --version          Print version\n        \\\\  -h, --help             Show this help\n        \\\\\n        \\\\Debug:\n        \\\\  --inject-test-key      Connect to Karabiner vhidd_server, init the\n        \\\\                         virtual keyboard, send a single Escape\n        \\\\                         keydown/up. Verifies the D2 injection path.\n        \\\\  --seize-test V:P       Seize keyboard with vendor V product P (hex\n        \\\\                         like 0x05AC:0x0342) and pass every keyboard\n        \\\\                         event through vhidd unchanged. Auto-releases\n        \\\\                         after --seize-test-duration seconds (default\n        \\\\                         30). Verifies the D3 seize+pass-through path.\n        \\\\  --seize-test-observe   Run --seize-test in passive observe mode\n        \\\\                         (kernel still receives the events too) for\n        \\\\                         diagnostics.\n        \\\\  --seize-test-duration N\n        \\\\                         Seconds before --seize-test auto-releases.\n        \\\\  --rule SRC:TAP:HOLD[@TIMEOUT_MS]\n        \\\\                         Add one tap-hold rule active during\n        \\\\                         --seize-test. Usages are HID page-7 codes\n        \\\\                         (e.g. 0x39 caps_lock, 0x29 escape, 0xE0\n        \\\\                         lctrl). Timeout defaults to 200ms.\n        \\\\  --permissive-hold      Tweak --rule's tap-hold semantics (QMK\n        \\\\                         permissive_hold).\n        \\\\  --hold-on-other-key-press\n        \\\\                         Tweak --rule's tap-hold semantics (QMK\n        \\\\                         hold_on_other_key_press).\n        \\\\  -P, --profile          Emit one stderr line per HID-in /\n        \\\\                         timer-sched / timer-fire / vhidd-post\n        \\\\                         boundary, with monotonic timestamps in\n        \\\\                         microseconds and timer drift. Use it\n        \\\\                         to investigate cold-start lag.\n        \\\\                         Debug + ReleaseSafe builds only;\n        \\\\                         compiled out of ReleaseFast / Small.\n        \\\\\n        \\\\This daemon is normally started by launchd via\n        \\\\  /Library/LaunchDaemons/com.jackielii.skhd.grabber.plist\n        \\\\Install it with: skhd --install-grabber\n        \\\\\n    , .{});\n}\n"
  },
  {
    "path": "src/grabber_cli.zig",
    "content": "//! CLI subcommands that touch the system-grabber daemon.\n//!\n//! Implementations of `--install-grabber`, `--uninstall-grabber`,\n//! `--grabber-status`, and `--grabber-test-rule`. Kept in their own\n//! file so main.zig stays thin.\n\nconst std = @import(\"std\");\nconst builtin = @import(\"builtin\");\nconst build_options = @import(\"build_options\");\n\nconst c = @import(\"c.zig\");\nconst protocol = @import(\"grabber_protocol\");\nconst Client = @import(\"agent_grabber_client.zig\").Client;\n\n/// The Karabiner-DriverKit-VirtualHIDDevice version skhd-grabber's IPC has\n/// been validated against. Set in build.zig (`karabiner_dext_version`),\n/// passed through `build_options`. Used as the source of truth for both\n/// `zig build install-dext` and runtime compatibility checks.\npub const pinned_dext_version = build_options.karabiner_dext_version;\n\n/// Result of comparing the installed dext version (`readHidDaemonVersion`)\n/// against `pinned_dext_version`. The pqrs project follows SemVer and uses\n/// the major as their compat boundary, so we treat any non-major-matching\n/// install as incompatible — wire format may differ, IPC may break.\npub const Compatibility = enum {\n    /// Same major version (or same exact version). IPC contract holds.\n    ok,\n    /// Installed major is older than pinned. User likely has an older\n    /// Karabiner-Elements bundled DriverKit; IPC is not guaranteed.\n    older,\n    /// Installed major is newer than pinned. User upgraded the dext; we\n    /// haven't validated against this version.\n    newer,\n    /// Either string failed to parse.\n    parse_error,\n};\n\nfn parseMajor(version: []const u8) ?u32 {\n    const dot = std.mem.indexOfScalar(u8, version, '.') orelse version.len;\n    return std.fmt.parseInt(u32, version[0..dot], 10) catch null;\n}\n\npub fn compareVersions(installed: []const u8, pinned: []const u8) Compatibility {\n    const installed_major = parseMajor(installed) orelse return .parse_error;\n    const pinned_major = parseMajor(pinned) orelse return .parse_error;\n    if (installed_major == pinned_major) return .ok;\n    if (installed_major < pinned_major) return .older;\n    return .newer;\n}\n\ntest \"compareVersions: same major\" {\n    try std.testing.expectEqual(Compatibility.ok, compareVersions(\"6.14.0\", \"6.0.0\"));\n    try std.testing.expectEqual(Compatibility.ok, compareVersions(\"6.0.0\", \"6.0.0\"));\n    try std.testing.expectEqual(Compatibility.older, compareVersions(\"5.9.9\", \"6.0.0\"));\n    try std.testing.expectEqual(Compatibility.newer, compareVersions(\"7.0.0\", \"6.0.0\"));\n    try std.testing.expectEqual(Compatibility.parse_error, compareVersions(\"garbage\", \"6.0.0\"));\n}\n\nconst log = std.log.scoped(.grabber_cli);\n\nconst grabber_binary_rel = \"zig-out/bin/skhd-grabber\";\n\nconst grabber_launchd_label = \"com.jackielii.skhd.grabber\";\nconst grabber_plist_path = \"/Library/LaunchDaemons/\" ++ grabber_launchd_label ++ \".plist\";\n\nconst grabber_socket_dir = \"/var/run/skhd\";\nconst grabber_socket_default = grabber_socket_dir ++ \"/grabber.sock\";\n\n/// Path to the VHIDD daemon's launchd plist. installDext writes this so the\n/// userland half of Karabiner-DriverKit-VirtualHIDDevice gets registered —\n/// the standalone pqrs .pkg's postinstall is a no-op `killall` that assumes\n/// some other component (Karabiner-Elements, historically) provides the\n/// launchd entry. Without Karabiner-Elements, that entry never lands.\nconst vhidd_plist_path = \"/Library/LaunchDaemons/\" ++ vhidd_launchd_label ++ \".plist\";\n\n/// Embedded LaunchDaemon plist for `skhd-grabber`. Single source of truth\n/// for the launchd config, baked into the skhd binary so `--install-grabber`\n/// works from any cwd (and on a brew install where scripts/ isn't on disk).\n/// Wired up as an anonymous import in build.zig (`addGrabberPlistImports`).\nconst grabber_plist_template = @embedFile(\"grabber_plist\");\n\n/// Embedded LaunchDaemon plist for `Karabiner-VirtualHIDDevice-Daemon`.\n/// See the file's comment block for why we ship it.\nconst vhidd_plist_template = @embedFile(\"vhidd_plist\");\n\n/// Path to the installed grabber binary, used by --install-grabber to\n/// know what to copy. We resolve it relative to the running skhd\n/// binary's directory so a Homebrew install picks up the bundled copy\n/// in libexec/, while a `zig build run` picks up zig-out/bin/.\nfn resolveGrabberBinary(allocator: std.mem.Allocator) ![]const u8 {\n    const self_path = try std.fs.selfExePathAlloc(allocator);\n    defer allocator.free(self_path);\n\n    // Try a sibling `skhd-grabber` next to ourselves first; that\n    // covers both bundled installs (libexec/) and dev runs (zig-out/bin/).\n    const dir = std.fs.path.dirname(self_path) orelse \".\";\n    const sibling = try std.fs.path.join(allocator, &.{ dir, \"skhd-grabber\" });\n    if (fileExists(sibling)) return sibling;\n    allocator.free(sibling);\n\n    // Fallback: cwd-relative dev path so a `--install-grabber` invoked\n    // from the repo root works even if the agent itself was launched\n    // from elsewhere.\n    const dev = try allocator.dupe(u8, grabber_binary_rel);\n    if (fileExists(dev)) return dev;\n    allocator.free(dev);\n\n    return error.GrabberBinaryNotFound;\n}\n\nfn fileExists(path: []const u8) bool {\n    std.fs.cwd().access(path, .{}) catch return false;\n    return true;\n}\n\n/// Run `/bin/launchctl <args...>`. Stdio is inherited so launchctl's own\n/// error messages reach the user. Returns error on non-zero exit.\nfn runLaunchctl(allocator: std.mem.Allocator, args: []const []const u8) !void {\n    var argv = std.ArrayList([]const u8).init(allocator);\n    defer argv.deinit();\n    try argv.append(\"/bin/launchctl\");\n    try argv.appendSlice(args);\n\n    var child = std.process.Child.init(argv.items, allocator);\n    child.stdin_behavior = .Ignore;\n    child.stdout_behavior = .Inherit;\n    child.stderr_behavior = .Inherit;\n    const term = try child.spawnAndWait();\n    if (term != .Exited or term.Exited != 0) return error.LaunchctlFailed;\n}\n\n/// Create or overwrite an absolute path with `content`, mode 0644. Used\n/// for system plists.\nfn writePlistAbsolute(path: []const u8, content: []const u8) !void {\n    var file = try std.fs.createFileAbsolute(path, .{ .mode = 0o644 });\n    defer file.close();\n    try file.writeAll(content);\n}\n\npub fn installGrabber(allocator: std.mem.Allocator) !void {\n    if (c.geteuid() != 0) {\n        std.debug.print(\n            \\\\skhd --install-grabber needs root.\n            \\\\Re-run with: sudo skhd --install-grabber\n            \\\\\n        , .{});\n        return error.NotRoot;\n    }\n\n    // Pre-check: the dext is what makes vhidd injection possible.\n    // Without it, the grabber starts but its first connect attempt to the\n    // vhidd_server fails. installDext now handles both `not_installed`\n    // (no dext at all) and `plist_unregistered` (dext loaded but the\n    // VHIDD launchd entry is missing — common after a partial Karabiner\n    // uninstall) by re-running the .pkg + writing our shipped VHIDD plist.\n    var hid_state = try checkHidDaemonState(allocator);\n    if (hid_state == .not_installed or hid_state == .plist_unregistered) {\n        std.debug.print(\n            \"\\nKarabiner-DriverKit-VirtualHIDDevice setup needed (state: {s}). Installing pinned v{s}...\\n\",\n            .{ @tagName(hid_state), pinned_dext_version },\n        );\n        try installDext(allocator);\n        hid_state = try checkHidDaemonState(allocator);\n    }\n    if (hid_state != .running) {\n        printHidDaemonRemediation(hid_state);\n        return switch (hid_state) {\n            .not_installed => error.DextMissing,\n            .dext_disabled => error.DextDisabled,\n            .plist_unregistered, .stopped => error.VhiddDaemonMissing,\n            .running => unreachable,\n        };\n    }\n    // Daemon is running — check the major version matches our pinned one.\n    // Refuse on `.older` (IPC likely broken). `.newer` is allowed with a\n    // warning; the user opted into a newer version explicitly.\n    if (readHidDaemonVersion(allocator)) |installed_dext| {\n        defer allocator.free(installed_dext);\n        const compat = compareVersions(installed_dext, pinned_dext_version);\n        if (compat == .older) {\n            printVersionMismatchRemediation(installed_dext, compat);\n            return error.DextVersionIncompatible;\n        }\n        if (compat == .newer) printVersionMismatchRemediation(installed_dext, compat);\n    }\n    if (isKarabinerElementsActive(allocator)) {\n        std.debug.print(\n            \\\\warning: Karabiner-Elements is running and will conflict with\n            \\\\skhd-grabber for HID seize. Disable Karabiner-Elements (or\n            \\\\uninstall it) before relying on skhd-grabber's tap-hold/remap\n            \\\\rules; otherwise both will fight for keyboard control.\n            \\\\\n        , .{});\n    }\n\n    const binary = resolveGrabberBinary(allocator) catch {\n        std.debug.print(\n            \\\\error: skhd-grabber binary not found.\n            \\\\Looked next to skhd at <bundle>/Contents/MacOS/skhd-grabber and\n            \\\\at zig-out/bin/skhd-grabber. On a brew install, this means the\n            \\\\bundled grabber is missing — try `brew reinstall skhd-zig`.\n            \\\\\n        , .{});\n        return error.GrabberBinaryNotFound;\n    };\n    defer allocator.free(binary);\n\n    const binary_abs = std.fs.realpathAlloc(allocator, binary) catch try allocator.dupe(u8, binary);\n    defer allocator.free(binary_abs);\n\n    // The plist's ProgramArguments path is critical for bundle-keyed TCC:\n    // when the grabber runs from inside skhd.app, TCC walks up to the\n    // bundle and uses the bundle ID (com.jackielii.skhd) — same client\n    // identifier as the agent. A single Input Monitoring grant on\n    // skhd.app then covers both processes. If the path resolves to a bare\n    // binary outside any .app, TCC keys the grant by path+cdHash and the\n    // user has to approve the daemon binary separately.\n    const grabber_path_for_plist = grabber_path_for_plist: {\n        if (std.mem.indexOf(u8, binary_abs, \".app/Contents/MacOS/\") != null) {\n            break :grabber_path_for_plist try allocator.dupe(u8, binary_abs);\n        }\n        std.debug.print(\n            \\\\warning: grabber binary at {s} is not inside a .app bundle.\n            \\\\TCC will key its Input Monitoring grant by path+cdHash, so the\n            \\\\user has to add this path to System Settings manually and\n            \\\\re-grant after every rebuild. For production installs run\n            \\\\--install-grabber from a brew-installed bundle, or use\n            \\\\`zig build install-local` to overlay into the bundle.\n            \\\\\n        , .{binary_abs});\n        break :grabber_path_for_plist try allocator.dupe(u8, binary_abs);\n    };\n    defer allocator.free(grabber_path_for_plist);\n\n    // 1. Render plist with the resolved grabber path.\n    const rendered_plist = try renderGrabberPlist(allocator, grabber_path_for_plist);\n    defer allocator.free(rendered_plist);\n\n    // 2. Write the LaunchDaemon plist (no binary copy — we run the grabber\n    //    in place from inside the bundle so TCC bundle-shares the grant).\n    std.debug.print(\"Installing plist → {s} (program={s})\\n\", .{ grabber_plist_path, grabber_path_for_plist });\n    try writePlistAbsolute(grabber_plist_path, rendered_plist);\n\n    // 3. bootout-then-bootstrap so re-runs are idempotent. enable +\n    //    kickstart in case launchd has the service disabled or stopped\n    //    (e.g. after a previous uninstall).\n    const target = \"system/\" ++ grabber_launchd_label;\n    runLaunchctl(allocator, &.{ \"bootout\", target }) catch {};\n    try runLaunchctl(allocator, &.{ \"bootstrap\", \"system\", grabber_plist_path });\n    runLaunchctl(allocator, &.{ \"enable\", target }) catch {};\n    runLaunchctl(allocator, &.{ \"kickstart\", \"-k\", target }) catch {};\n\n    // Brief pause for the daemon to bind its socket so a follow-up\n    // --grabber-status reports \"running\" instead of \"socket absent\".\n    std.time.sleep(400 * std.time.ns_per_ms);\n\n    std.debug.print(\n        \\\\\n        \\\\Done. Daemon should now be running.\n        \\\\Logs:    /var/log/skhd-grabber.log\n        \\\\Socket:  {s}\n        \\\\\n        \\\\Input Monitoring permission: the agent (skhd) prompts for this on\n        \\\\next launch. Granting it to skhd.app covers the grabber too —\n        \\\\both binaries are signed with the same bundle ID and run from\n        \\\\inside the bundle, so TCC bundle-shares the grant.\n        \\\\\n        \\\\Verify with:  skhd --grabber-status\n        \\\\\n    , .{grabber_socket_default});\n}\n\n/// Substitute every `__GRABBER_PATH__` placeholder in the embedded plist\n/// template with the absolute path to the running bundle's grabber. This\n/// is the path launchd will exec — picking the bundle path is what\n/// enables bundle-keyed TCC. replaceAll (not first-match) so a stray\n/// reference in the template's comment block doesn't shadow the one in\n/// `<string>`, which would give launchd a literal `__GRABBER_PATH__`\n/// program path and a cryptic EX_CONFIG (78) crash on bootstrap.\nfn renderGrabberPlist(allocator: std.mem.Allocator, grabber_path: []const u8) ![]const u8 {\n    const placeholder = \"__GRABBER_PATH__\";\n    if (std.mem.indexOf(u8, grabber_plist_template, placeholder) == null) {\n        std.debug.print(\n            \"internal error: grabber plist template missing __GRABBER_PATH__ placeholder\\n\",\n            .{},\n        );\n        return error.PlistTemplateMalformed;\n    }\n    const out_size = std.mem.replacementSize(u8, grabber_plist_template, placeholder, grabber_path);\n    const out = try allocator.alloc(u8, out_size);\n    _ = std.mem.replace(u8, grabber_plist_template, placeholder, grabber_path, out);\n    return out;\n}\n\n/// True when the grabber LaunchDaemon plist is installed. Used by\n/// the smart `--install-service` flow to skip the sudo prompt for\n/// users who already installed the grabber separately.\npub fn isGrabberInstalled() bool {\n    std.fs.accessAbsolute(grabber_plist_path, .{}) catch return false;\n    return true;\n}\n\n/// Re-exec ourselves under sudo with `--install-grabber`. Sudo\n/// prompts the user for their password inline (stdio is inherited),\n/// so this only works in an interactive terminal context. Returns an\n/// error if sudo or the install fails — caller logs and tells the\n/// user how to retry by hand.\npub fn installGrabberViaSudo(allocator: std.mem.Allocator) !void {\n    const self_path = try std.fs.selfExePathAlloc(allocator);\n    defer allocator.free(self_path);\n\n    var child = std.process.Child.init(&.{ \"/usr/bin/sudo\", self_path, \"--install-grabber\" }, allocator);\n    child.stdin_behavior = .Inherit;\n    child.stdout_behavior = .Inherit;\n    child.stderr_behavior = .Inherit;\n    const term = try child.spawnAndWait();\n    if (term != .Exited or term.Exited != 0) return error.GrabberInstallFailed;\n}\n\npub fn uninstallGrabber(allocator: std.mem.Allocator) !void {\n    if (c.geteuid() != 0) {\n        std.debug.print(\n            \\\\skhd --uninstall-grabber needs root.\n            \\\\Re-run with: sudo skhd --uninstall-grabber\n            \\\\\n        , .{});\n        return error.NotRoot;\n    }\n\n    // 1. skhd-grabber LaunchDaemon (always ours).\n    const grabber_target = \"system/\" ++ grabber_launchd_label;\n    if (fileExists(grabber_plist_path)) {\n        std.debug.print(\"Stopping skhd-grabber...\\n\", .{});\n        runLaunchctl(allocator, &.{ \"bootout\", grabber_target }) catch {};\n        std.debug.print(\"Removing {s}\\n\", .{grabber_plist_path});\n        std.fs.deleteFileAbsolute(grabber_plist_path) catch |err| switch (err) {\n            error.FileNotFound => {},\n            else => return err,\n        };\n    }\n\n    // 2. VHIDD daemon LaunchDaemon — only ours if a file exists at this\n    //    path. Karabiner-Elements registers via SMAppService and never\n    //    writes to /Library/LaunchDaemons/, so the file's presence is a\n    //    reliable signal that --install-dext put it there. Bootout first\n    //    so a Karabiner install that follows can SMAppService-register\n    //    against the same label without colliding.\n    if (fileExists(vhidd_plist_path)) {\n        const vhidd_target = \"system/\" ++ vhidd_launchd_label;\n        std.debug.print(\"Stopping VHIDD daemon...\\n\", .{});\n        runLaunchctl(allocator, &.{ \"bootout\", vhidd_target }) catch {};\n        std.debug.print(\"Removing {s}\\n\", .{vhidd_plist_path});\n        std.fs.deleteFileAbsolute(vhidd_plist_path) catch |err| switch (err) {\n            error.FileNotFound => {},\n            else => return err,\n        };\n    }\n\n    // 3. Best-effort socket dir cleanup. Harmless if empty/missing.\n    std.fs.deleteFileAbsolute(grabber_socket_default) catch {};\n    std.fs.deleteDirAbsolute(grabber_socket_dir) catch {};\n\n    std.debug.print(\n        \\\\\n        \\\\Done. Removed:\n        \\\\  - skhd-grabber LaunchDaemon\n        \\\\  - VHIDD daemon LaunchDaemon (if installed by --install-dext)\n        \\\\\n        \\\\Left in place:\n        \\\\  - Karabiner-DriverKit-VirtualHIDDevice .pkg payload at\n        \\\\    /Library/Application Support/org.pqrs/. Run pqrs's uninstall\n        \\\\    scripts under .../scripts/uninstall/ to fully remove.\n        \\\\  - The kernel-loaded dext (system extension; SIP gates removal\n        \\\\    via systemextensionsctl — toggle off in System Settings →\n        \\\\    Login Items & Extensions → Driver Extensions if you want it\n        \\\\    gone).\n        \\\\\n    , .{});\n}\n\n/// Walk every prerequisite for caps_lock-class tap-hold and report\n/// where the chain breaks. One command users can run when something\n/// isn't working — gives a clear \"this is where it's broken, this is\n/// how to fix it\" without them having to know the layered design.\npub fn grabberStatus(allocator: std.mem.Allocator, socket_path: []const u8) !void {\n    std.debug.print(\"skhd-grabber status\\n\", .{});\n    std.debug.print(\"===================\\n\\n\", .{});\n\n    var ok_count: u32 = 0;\n    var fail_count: u32 = 0;\n\n    // 1. Karabiner DriverKit dext (provides the virtual HID device\n    //    that the grabber injects through). Loaded dext shows up as\n    //    a running process under _driverkit owned by launchd.\n    //    Loaded-but-disabled (System Settings → Login Items & Extensions\n    //    toggled off) is also a fail — the dext process keeps running so\n    //    pgrep matches, but the kernel detaches it from HID dispatch.\n    if (try processRunning(allocator, \"org.pqrs.Karabiner-DriverKit-VirtualHIDDevice\")) {\n        if (isDextEnabled(allocator)) |enabled| {\n            if (enabled) {\n                std.debug.print(\"  [OK]      Karabiner-DriverKit-VirtualHIDDevice (dext) loaded and enabled\\n\", .{});\n                ok_count += 1;\n            } else {\n                std.debug.print(\n                    \\\\  [FAIL]    Karabiner-DriverKit-VirtualHIDDevice (dext) loaded but DISABLED in System Settings\n                    \\\\            Re-enable: System Settings → General → Login Items & Extensions →\n                    \\\\            Driver Extensions → toggle Karabiner-VirtualHIDDevice Manager Extensions on.\n                    \\\\\n                , .{});\n                fail_count += 1;\n            }\n        } else {\n            // Probe failed (systemextensionsctl unavailable / output\n            // unparseable). Don't false-alarm; trust the pgrep signal.\n            std.debug.print(\"  [OK]      Karabiner-DriverKit-VirtualHIDDevice (dext) loaded (enabled-state probe inconclusive)\\n\", .{});\n            ok_count += 1;\n        }\n    } else {\n        std.debug.print(\n            \\\\  [MISSING] Karabiner-DriverKit-VirtualHIDDevice (dext) not loaded\n            \\\\            Required for HID injection. Install from\n            \\\\              https://github.com/pqrs-org/Karabiner-DriverKit-VirtualHIDDevice\n            \\\\            Then approve it in System Settings > Privacy & Security.\n            \\\\\n        , .{});\n        fail_count += 1;\n    }\n\n    // 2. Karabiner-VirtualHIDDevice-Daemon (userland helper that\n    //    bridges our IPC to the dext). It's the process we connect\n    //    to via vhidd_server socket.\n    if (try processRunning(allocator, \"Karabiner-VirtualHIDDevice-Daemon\")) {\n        std.debug.print(\"  [OK]      Karabiner-VirtualHIDDevice-Daemon running\\n\", .{});\n        ok_count += 1;\n    } else {\n        std.debug.print(\n            \\\\  [MISSING] Karabiner-VirtualHIDDevice-Daemon not running\n            \\\\            Comes with the dext install. Try:\n            \\\\              sudo launchctl kickstart -k system/org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon\n            \\\\\n        , .{});\n        fail_count += 1;\n    }\n\n    // 3. skhd-grabber LaunchDaemon plist (we installed it via\n    //    --install-grabber).\n    if (isGrabberInstalled()) {\n        std.debug.print(\"  [OK]      skhd-grabber LaunchDaemon plist installed\\n\", .{});\n        ok_count += 1;\n    } else {\n        std.debug.print(\n            \\\\  [MISSING] skhd-grabber LaunchDaemon plist not found\n            \\\\            Install with:\n            \\\\              sudo skhd --install-grabber\n            \\\\\n        , .{});\n        fail_count += 1;\n    }\n\n    // 4. skhd-grabber process running.\n    if (try processRunning(allocator, \"skhd-grabber\")) {\n        std.debug.print(\"  [OK]      skhd-grabber process running\\n\", .{});\n        ok_count += 1;\n    } else {\n        std.debug.print(\n            \\\\  [MISSING] skhd-grabber not running\n            \\\\            Try:\n            \\\\              sudo launchctl kickstart -k system/com.jackielii.skhd.grabber\n            \\\\\n        , .{});\n        fail_count += 1;\n    }\n\n    // 5. IPC socket reachable + protocol version match.\n    var client = Client.connect(allocator, socket_path) catch |err| {\n        std.debug.print(\n            \\\\  [FAIL]    IPC socket not reachable at {s} ({s})\n            \\\\\n            \\\\Summary: {d} OK, {d} failing — fix the [MISSING]/[FAIL] items above.\n            \\\\\n        , .{ socket_path, @errorName(err), ok_count, fail_count + 1 });\n        return err;\n    };\n    defer client.close();\n    client.hello() catch |err| {\n        std.debug.print(\n            \\\\  [FAIL]    IPC handshake failed ({s}) — protocol version mismatch?\n            \\\\            agent expects v{d}; grabber may be older.\n            \\\\\n        , .{ @errorName(err), protocol.protocol_version });\n        return err;\n    };\n    client.bye() catch {};\n    std.debug.print(\"  [OK]      IPC socket reachable at {s} (protocol v{d})\\n\", .{ socket_path, protocol.protocol_version });\n    ok_count += 1;\n\n    if (fail_count == 0) {\n        std.debug.print(\"\\nSummary: {d} OK, 0 failing — everything looks good.\\n\", .{ok_count});\n    } else {\n        std.debug.print(\"\\nSummary: {d} OK, {d} failing — fix the [MISSING]/[FAIL] items above.\\n\", .{ ok_count, fail_count });\n    }\n}\n\n/// True if at least one running process matches `needle` in its\n/// argv. Uses pgrep so we don't need root for system-domain queries.\nfn processRunning(allocator: std.mem.Allocator, needle: []const u8) !bool {\n    var child = std.process.Child.init(&.{ \"/usr/bin/pgrep\", \"-f\", needle }, allocator);\n    child.stdin_behavior = .Ignore;\n    child.stdout_behavior = .Ignore;\n    child.stderr_behavior = .Ignore;\n    try child.spawn();\n    const term = try child.wait();\n    return term == .Exited and term.Exited == 0;\n}\n\n/// Path to the daemon binary's Info.plist — single source of truth for the\n/// installed Karabiner DriverKit version (matches what's in the dext).\nconst vhidd_info_plist = \"/Library/Application Support/org.pqrs/Karabiner-DriverKit-VirtualHIDDevice/Applications/Karabiner-VirtualHIDDevice-Daemon.app/Contents/Info.plist\";\n\n/// HID daemon dependency state, in priority order: missing dext →\n/// dext-disabled → broken launchd registration → just-stopped →\n/// running. Each state has a distinct remediation so callers\n/// (--install-grabber, --status) can give specific guidance instead of\n/// a generic \"something's wrong\".\npub const HidDaemonState = enum {\n    /// The DriverKit dext isn't loaded. User needs to install the\n    /// Karabiner-DriverKit-VirtualHIDDevice .pkg from pqrs releases.\n    not_installed,\n    /// Dext is loaded as a system extension but the user has toggled it\n    /// off in System Settings → Login Items & Extensions → Driver\n    /// Extensions. The dext process keeps running so a pgrep-only check\n    /// reports it as loaded, but the kernel won't dispatch HID requests\n    /// through it — vhidd injection silently no-ops and seize calls\n    /// fail. Recovery is UI-only: SIP gates `systemextensionsctl\n    /// activate` from the command line.\n    dext_disabled,\n    /// Dext is loaded but the daemon binary's launchd plist isn't\n    /// registered. Happens after a partial uninstall or when only the\n    /// dext was installed without its companion plist. `kickstart` will\n    /// fail with \"could not find service\"; need to re-run the .pkg\n    /// installer.\n    plist_unregistered,\n    /// Dext loaded, daemon plist registered, daemon just isn't running.\n    /// Recoverable via `launchctl kickstart`.\n    stopped,\n    /// All good.\n    running,\n};\n\nconst vhidd_launchd_label = \"org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon\";\n\n/// Probe the HID daemon dependency chain and return the first failure\n/// found, or `running` if every layer is up. The caller is expected to\n/// branch on the result for state-specific messaging.\npub fn checkHidDaemonState(allocator: std.mem.Allocator) !HidDaemonState {\n    const dext_loaded = try processRunning(allocator, \"org.pqrs.Karabiner-DriverKit-VirtualHIDDevice\");\n    if (!dext_loaded) return .not_installed;\n\n    // The dext can be loaded-but-disabled — user toggled it off in\n    // System Settings → Login Items & Extensions → Driver Extensions.\n    // The dext process keeps running (so the pgrep above reports\n    // \"loaded\") but the kernel detaches it from HID dispatch. Without\n    // this check the status would lie: `--grabber-status` and\n    // `--status` reported 5/5 OK while seize / injection silently\n    // failed. `systemextensionsctl list` is the only programmatic\n    // signal we have for this state.\n    if (isDextEnabled(allocator)) |enabled| {\n        if (!enabled) return .dext_disabled;\n    }\n\n    const daemon_running = try processRunning(allocator, \"Karabiner-VirtualHIDDevice-Daemon\");\n    if (daemon_running) return .running;\n\n    // Daemon process not running. Distinguish \"launchd doesn't know about\n    // it at all\" (plist_unregistered, kickstart will fail) from \"launchd\n    // knows about it, just not running right now\" (stopped, kickstart\n    // works). `launchctl print system/<label>` returns non-zero with\n    // \"Could not find service\" for the former.\n    const registered = try launchdServiceRegistered(allocator);\n    return if (registered) .stopped else .plist_unregistered;\n}\n\n/// Tri-state probe of the dext's enabled status via `systemextensionsctl\n/// list`. Returns `true` only if the bundle's state line shows\n/// `[activated enabled]`. Returns `false` for `[activated disabled]` or\n/// any other recognized non-enabled state. Returns `null` when we can't\n/// tell (command failed, output unparseable, bundle not in the list at\n/// all) — caller treats that as \"give the user the benefit of the doubt\"\n/// to avoid false alarms in unusual environments.\nfn isDextEnabled(allocator: std.mem.Allocator) ?bool {\n    var child = std.process.Child.init(\n        &.{ \"/usr/bin/systemextensionsctl\", \"list\" },\n        allocator,\n    );\n    child.stdin_behavior = .Ignore;\n    child.stdout_behavior = .Pipe;\n    child.stderr_behavior = .Ignore;\n    child.spawn() catch return null;\n\n    var buf: [16 * 1024]u8 = undefined;\n    var n: usize = 0;\n    if (child.stdout) |stdout| {\n        n = stdout.reader().read(&buf) catch 0;\n    }\n    const term = child.wait() catch return null;\n    if (term != .Exited or term.Exited != 0) return null;\n\n    const dext_id = \"org.pqrs.Karabiner-DriverKit-VirtualHIDDevice\";\n    var lines = std.mem.splitScalar(u8, buf[0..n], '\\n');\n    while (lines.next()) |line| {\n        if (std.mem.indexOf(u8, line, dext_id) == null) continue;\n        if (std.mem.indexOf(u8, line, \"[activated enabled]\") != null) return true;\n        if (std.mem.indexOf(u8, line, \"[activated disabled]\") != null) return false;\n        // Other states (`[deactivated]`, `[uninstalling]`, `[activated\n        // waiting for user]`, …) — anything that isn't explicitly\n        // `enabled` won't dispatch HID, so report as not-enabled.\n        return false;\n    }\n    return null;\n}\n\n\nfn launchdServiceRegistered(allocator: std.mem.Allocator) !bool {\n    const target = \"system/\" ++ vhidd_launchd_label;\n    var child = std.process.Child.init(&.{ \"/bin/launchctl\", \"print\", target }, allocator);\n    child.stdin_behavior = .Ignore;\n    child.stdout_behavior = .Ignore;\n    child.stderr_behavior = .Ignore;\n    try child.spawn();\n    const term = try child.wait();\n    return term == .Exited and term.Exited == 0;\n}\n\n/// Read CFBundleShortVersionString from the daemon's Info.plist (which\n/// matches the dext's version — they ship together). Returns null if the\n/// file is absent or PlistBuddy fails. Caller frees.\npub fn readHidDaemonVersion(allocator: std.mem.Allocator) ?[]const u8 {\n    var child = std.process.Child.init(\n        &.{ \"/usr/libexec/PlistBuddy\", \"-c\", \"Print :CFBundleShortVersionString\", vhidd_info_plist },\n        allocator,\n    );\n    child.stdin_behavior = .Ignore;\n    child.stdout_behavior = .Pipe;\n    child.stderr_behavior = .Ignore;\n    child.spawn() catch return null;\n\n    var buf: [128]u8 = undefined;\n    var n: usize = 0;\n    if (child.stdout) |stdout| {\n        n = stdout.reader().read(&buf) catch 0;\n    }\n    const term = child.wait() catch return null;\n    if (term != .Exited or term.Exited != 0) return null;\n\n    const trimmed = std.mem.trim(u8, buf[0..n], \" \\r\\n\\t\");\n    if (trimmed.len == 0) return null;\n    return allocator.dupe(u8, trimmed) catch null;\n}\n\n/// True iff Karabiner-Elements' userland grabber is currently running.\n/// Coexists badly with skhd-grabber (both seize keyboards via the same\n/// dext), so we surface this as a warning in --install-grabber and --status.\npub fn isKarabinerElementsActive(allocator: std.mem.Allocator) bool {\n    return processRunning(allocator, \"karabiner_grabber\") catch false;\n}\n\n/// URL + SHA-256 for the pinned .pkg, exposed via build_options so the\n/// same values flow to here and to `zig build install-dext`. Bumping\n/// happens in build.zig — single source of truth, see the comment block\n/// there.\nconst karabiner_dext_url = build_options.karabiner_dext_url;\nconst karabiner_dext_sha256 = build_options.karabiner_dext_sha256;\n\n/// Resolve the cache path for the pinned .pkg. Per-version filename so\n/// multiple versions don't collide; transient location (/tmp under root,\n/// $XDG_CACHE_HOME or $HOME/.cache otherwise) since the .pkg is purely a\n/// download cache, not configuration.\nfn dextCachePath(allocator: std.mem.Allocator) ![]u8 {\n    const filename = try std.fmt.allocPrint(allocator, \"skhd-Karabiner-DriverKit-VirtualHIDDevice-{s}.pkg\", .{pinned_dext_version});\n    defer allocator.free(filename);\n\n    if (c.geteuid() == 0) {\n        // Running as root (typical when reached via `sudo skhd\n        // --install-grabber`): /tmp is the safest writable location\n        // that doesn't depend on a particular user's home.\n        return std.fmt.allocPrint(allocator, \"/tmp/{s}\", .{filename});\n    }\n\n    const home = std.posix.getenv(\"HOME\") orelse return error.NoHome;\n    const dir = try std.fmt.allocPrint(allocator, \"{s}/.cache/skhd\", .{home});\n    defer allocator.free(dir);\n    std.fs.makeDirAbsolute(dir) catch |err| switch (err) {\n        error.PathAlreadyExists => {},\n        else => return err,\n    };\n    return std.fmt.allocPrint(allocator, \"{s}/{s}\", .{ dir, filename });\n}\n\nfn fileSha256(allocator: std.mem.Allocator, path: []const u8) ![]u8 {\n    var file = try std.fs.openFileAbsolute(path, .{});\n    defer file.close();\n\n    var hasher = std.crypto.hash.sha2.Sha256.init(.{});\n    var buf: [64 * 1024]u8 = undefined;\n    while (true) {\n        const n = try file.read(&buf);\n        if (n == 0) break;\n        hasher.update(buf[0..n]);\n    }\n    var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;\n    hasher.final(&digest);\n\n    var hex_buf: [digest.len * 2]u8 = undefined;\n    return allocator.dupe(u8, std.fmt.bufPrint(&hex_buf, \"{}\", .{std.fmt.fmtSliceHexLower(&digest)}) catch unreachable);\n}\n\n/// Download + verify + install the pinned Karabiner-DriverKit-VirtualHIDDevice\n/// .pkg. Idempotent: re-runs reuse the cached .pkg, and pqrs's installer is\n/// a no-op when the same version is already installed. Re-execs via sudo\n/// when not already root.\npub fn installDext(allocator: std.mem.Allocator) !void {\n    const pkg_path = try dextCachePath(allocator);\n    defer allocator.free(pkg_path);\n\n    // 1. Download to cache if missing.\n    if (std.fs.accessAbsolute(pkg_path, .{})) |_| {\n        std.debug.print(\"Using cached pkg at {s}\\n\", .{pkg_path});\n    } else |_| {\n        std.debug.print(\"Downloading Karabiner-DriverKit-VirtualHIDDevice {s}...\\n\", .{pinned_dext_version});\n        std.debug.print(\"  {s}\\n\", .{karabiner_dext_url});\n        var dl = std.process.Child.init(&.{ \"/usr/bin/curl\", \"-fsSL\", \"-o\", pkg_path, karabiner_dext_url }, allocator);\n        dl.stdin_behavior = .Inherit;\n        dl.stdout_behavior = .Inherit;\n        dl.stderr_behavior = .Inherit;\n        const term = try dl.spawnAndWait();\n        if (term != .Exited or term.Exited != 0) {\n            std.debug.print(\"error: curl failed downloading {s}\\n\", .{karabiner_dext_url});\n            return error.DownloadFailed;\n        }\n    }\n\n    // 2. Verify sha256 against the pinned hash. On mismatch, drop the\n    // cached file so the next attempt re-downloads — this protects\n    // against a partial/corrupt download but won't auto-retry on what\n    // could be a legitimate upstream re-tag (the user has to bump\n    // build.zig in that case).\n    const actual_hex = try fileSha256(allocator, pkg_path);\n    defer allocator.free(actual_hex);\n    if (!std.mem.eql(u8, actual_hex, karabiner_dext_sha256)) {\n        std.debug.print(\n            \\\\error: sha256 mismatch for {s}\n            \\\\  expected: {s}\n            \\\\  got:      {s}\n            \\\\\n        , .{ pkg_path, karabiner_dext_sha256, actual_hex });\n        std.fs.deleteFileAbsolute(pkg_path) catch {};\n        return error.Sha256Mismatch;\n    }\n\n    // 3. Install. /usr/sbin/installer needs root. If we're not, re-exec\n    // ourselves under sudo with --install-dext (the cached .pkg gets\n    // re-validated by the elevated invocation, so the only change vs.\n    // running `installer` directly is that the user sees one sudo\n    // password prompt instead of being told to run a separate command).\n    if (c.geteuid() != 0) {\n        const self_path = try std.fs.selfExePathAlloc(allocator);\n        defer allocator.free(self_path);\n        std.debug.print(\"Installing Karabiner-DriverKit-VirtualHIDDevice {s} (sudo will prompt for your password)...\\n\", .{pinned_dext_version});\n        var elev = std.process.Child.init(&.{ \"/usr/bin/sudo\", self_path, \"--install-dext\" }, allocator);\n        elev.stdin_behavior = .Inherit;\n        elev.stdout_behavior = .Inherit;\n        elev.stderr_behavior = .Inherit;\n        const term = try elev.spawnAndWait();\n        if (term != .Exited or term.Exited != 0) return error.SudoFailed;\n        return;\n    }\n\n    var inst = std.process.Child.init(&.{ \"/usr/sbin/installer\", \"-pkg\", pkg_path, \"-target\", \"/\" }, allocator);\n    inst.stdin_behavior = .Inherit;\n    inst.stdout_behavior = .Inherit;\n    inst.stderr_behavior = .Inherit;\n    const term = try inst.spawnAndWait();\n    if (term != .Exited or term.Exited != 0) return error.InstallerFailed;\n\n    // 4. Register the VHIDD daemon with launchd. The pqrs .pkg's\n    //    postinstall is a no-op `killall` — the launchd plist that\n    //    historically registered the daemon ships with Karabiner-Elements,\n    //    not the standalone DriverKit pkg, so without our help the daemon\n    //    never gets a launchd entry on a fresh box.\n    try installVhiddDaemon(allocator);\n\n    std.debug.print(\n        \\\\\n        \\\\Karabiner-DriverKit-VirtualHIDDevice {s} installed. macOS may\n        \\\\prompt to approve the system extension in System Settings →\n        \\\\Privacy & Security; approve it before running skhd-grabber.\n        \\\\\n    , .{pinned_dext_version});\n}\n\n/// Install + bootstrap the VHIDD daemon's launchd entry. Coexists with\n/// Karabiner-Elements: KE registers the same launchd label\n/// (`org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon`) via\n/// `SMAppService.daemon(plistName:)` from inside its app bundle, NOT via\n/// `/Library/LaunchDaemons/`. Two registrations under the same label\n/// would conflict, so we check whether launchd already has it (regardless\n/// of source) and either:\n///   - existing registration → skip plist write + bootstrap, just kickstart\n///   - no registration → write our plist to /Library/LaunchDaemons/,\n///     bootstrap, kickstart\n///\n/// Side effect on uninstall: we deliberately do NOT remove our VHIDD plist\n/// in `uninstallGrabber` — leaving it behind keeps the daemon working for\n/// any other consumer (e.g. user installs Karabiner-Elements after us).\nfn installVhiddDaemon(allocator: std.mem.Allocator) !void {\n    const target = \"system/\" ++ vhidd_launchd_label;\n\n    if (try launchdServiceRegistered(allocator)) {\n        std.debug.print(\n            \\\\VHIDD launchd entry already registered (Karabiner-Elements or a\n            \\\\prior --install-dext run). Skipping plist install to avoid a\n            \\\\duplicate registration; will just (re)kick the daemon.\n            \\\\\n        , .{});\n        runLaunchctl(allocator, &.{ \"kickstart\", \"-k\", target }) catch {};\n        std.time.sleep(400 * std.time.ns_per_ms);\n        return;\n    }\n\n    // Stale plist on disk but launchd doesn't know about it: bootout is a\n    // no-op (will fail silently), then bootstrap from the path on disk.\n    if (fileExists(vhidd_plist_path)) {\n        runLaunchctl(allocator, &.{ \"bootout\", target }) catch {};\n    } else {\n        std.debug.print(\"Installing VHIDD launchd plist → {s}\\n\", .{vhidd_plist_path});\n        try writePlistAbsolute(vhidd_plist_path, vhidd_plist_template);\n    }\n\n    try runLaunchctl(allocator, &.{ \"bootstrap\", \"system\", vhidd_plist_path });\n    runLaunchctl(allocator, &.{ \"enable\", target }) catch {};\n    runLaunchctl(allocator, &.{ \"kickstart\", \"-k\", target }) catch {};\n\n    // Pause so checkHidDaemonState() right after this reports running\n    // instead of the in-flight bootstrapped-but-not-yet-spawned state.\n    std.time.sleep(400 * std.time.ns_per_ms);\n}\n\n/// Print the one-line `--status` summary for the HID daemon. Returns the\n/// detected state so the caller can decide whether to print the\n/// remediation block below the existing remediation chain. Errors during\n/// the probe are reported inline and treated as `.running` so we don't\n/// spam an irrelevant remediation in unusual environments.\npub fn printHidDaemonStatus(allocator: std.mem.Allocator) HidDaemonState {\n    const state = checkHidDaemonState(allocator) catch |err| {\n        std.debug.print(\"  HID daemon:           Unknown ({s})\\n\", .{@errorName(err)});\n        return .running;\n    };\n\n    const version = readHidDaemonVersion(allocator);\n    defer if (version) |v| allocator.free(v);\n\n    switch (state) {\n        .running => {\n            if (version) |v| {\n                const compat = compareVersions(v, pinned_dext_version);\n                const tag = switch (compat) {\n                    .ok => \"✓\",\n                    .older => \"INCOMPATIBLE — older major\",\n                    .newer => \"untested — newer major\",\n                    .parse_error => \"version parse failed\",\n                };\n                std.debug.print(\"  HID daemon:           Running (Karabiner DriverKit v{s}, pinned v{s} {s})\\n\", .{ v, pinned_dext_version, tag });\n            } else {\n                std.debug.print(\"  HID daemon:           Running (version unknown, pinned v{s})\\n\", .{pinned_dext_version});\n            }\n        },\n        .not_installed => std.debug.print(\"  HID daemon:           Not installed (required for .remap / .taphold rules; pinned v{s})\\n\", .{pinned_dext_version}),\n        .dext_disabled => {\n            const v_str = version orelse \"?\";\n            std.debug.print(\"  HID daemon:           DEXT v{s} loaded but DISABLED in System Settings (re-enable in Login Items & Extensions → Driver Extensions)\\n\", .{v_str});\n        },\n        .plist_unregistered => {\n            const v_str = version orelse \"?\";\n            std.debug.print(\"  HID daemon:           DEXT v{s} loaded but launchd entry missing\\n\", .{v_str});\n        },\n        .stopped => {\n            const v_str = version orelse \"?\";\n            std.debug.print(\"  HID daemon:           Stopped (Karabiner DriverKit v{s} installed)\\n\", .{v_str});\n        },\n    }\n\n    if (isKarabinerElementsActive(allocator)) {\n        std.debug.print(\"  Karabiner-Elements:   Active — conflicts with skhd-grabber for HID seize\\n\", .{});\n    }\n\n    return state;\n}\n\n/// Print state-specific remediation for a non-running HID daemon. Called\n/// from the bottom of --status and from --install-grabber's preflight.\n/// `--install-service` is the user-facing entry point; `--install-grabber`\n/// is the privileged subcommand it invokes once prereqs are in place.\npub fn printHidDaemonRemediation(state: HidDaemonState) void {\n    switch (state) {\n        .running => {}, // version-mismatch case handled by printVersionMismatchRemediation\n        .not_installed => std.debug.print(\n            \\\\\n            \\\\HID daemon (Karabiner-DriverKit-VirtualHIDDevice) is not installed.\n            \\\\skhd-grabber injects HID events through this dext. Install it from:\n            \\\\  https://github.com/pqrs-org/Karabiner-DriverKit-VirtualHIDDevice/releases\n            \\\\Approve the system extension in System Settings → Privacy &\n            \\\\Security, then re-run:\n            \\\\  skhd --install-service\n            \\\\\n        , .{}),\n        .dext_disabled => std.debug.print(\n            \\\\\n            \\\\Karabiner-DriverKit-VirtualHIDDevice is loaded but currently\n            \\\\DISABLED in System Settings. The dext process keeps running so\n            \\\\pgrep finds it, but the kernel won't dispatch HID requests\n            \\\\through it — seize fails and vhidd injection silently no-ops.\n            \\\\\n            \\\\Re-enable: System Settings → General → Login Items & Extensions\n            \\\\→ Driver Extensions → toggle Karabiner-VirtualHIDDevice\n            \\\\Manager Extensions on.\n            \\\\\n            \\\\(SIP gates `systemextensionsctl activate` from the command line\n            \\\\so this can only be done via the System Settings UI.)\n            \\\\\n        , .{}),\n        .plist_unregistered => std.debug.print(\n            \\\\\n            \\\\HID daemon's launchd entry is missing — the dext loaded but the\n            \\\\companion userland helper (Karabiner-VirtualHIDDevice-Daemon)\n            \\\\never got registered with launchd. `launchctl kickstart` will\n            \\\\fail with \"could not find service\" in this state.\n            \\\\\n            \\\\Reinstall Karabiner-DriverKit-VirtualHIDDevice (idempotent — the\n            \\\\.pkg redoes the launchd registration cleanly):\n            \\\\  https://github.com/pqrs-org/Karabiner-DriverKit-VirtualHIDDevice/releases\n            \\\\\n        , .{}),\n        .stopped => std.debug.print(\n            \\\\\n            \\\\HID daemon is stopped. Restart it with:\n            \\\\  sudo launchctl kickstart -k system/org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon\n            \\\\(skhd-grabber will reconnect automatically once it's back up.)\n            \\\\\n        , .{}),\n    }\n}\n\n/// Print remediation when the HID daemon is running but its major version\n/// doesn't match the pinned one. Only worth surfacing for `.older` (IPC\n/// likely broken); `.newer` is just an \"untested\" advisory, not blocking.\npub fn printVersionMismatchRemediation(installed: []const u8, compat: Compatibility) void {\n    switch (compat) {\n        .ok, .parse_error => {},\n        .older => std.debug.print(\n            \\\\\n            \\\\HID daemon is running v{s}, older than the pinned major v{s}.\n            \\\\skhd-grabber's IPC is validated against the pinned version; this\n            \\\\older install may not match the wire format. Likely cause: an\n            \\\\older Karabiner-Elements bundled an earlier DriverKit. Resolve\n            \\\\by either upgrading Karabiner-Elements OR by installing our\n            \\\\pinned version (which will replace the installed one):\n            \\\\  zig build install-dext     # downloads + installs v{s}\n            \\\\\n        , .{ installed, pinned_dext_version, pinned_dext_version }),\n        .newer => std.debug.print(\n            \\\\\n            \\\\HID daemon is running v{s}, newer than the pinned major v{s}.\n            \\\\skhd-grabber hasn't been validated against this version. If\n            \\\\.remap / .taphold rules misbehave, downgrade to the pinned\n            \\\\version with:\n            \\\\  zig build install-dext     # installs v{s}\n            \\\\\n        , .{ installed, pinned_dext_version, pinned_dext_version }),\n    }\n}\n\n/// Send a hard-coded sample rule to the grabber so we can validate the\n/// IPC plumbing end-to-end before parser integration lands. Logs at\n/// the grabber side print the parsed rule.\npub fn grabberTestRule(allocator: std.mem.Allocator, socket_path: []const u8) !void {\n    const sample_rules = [_]protocol.Rule{\n        .{\n            .src_usage = 0x39, // caps_lock\n            .tap_usage = 0x29, // escape\n            .hold_usage = 0xE0, // left ctrl\n            .device = .{ .vendor = 0x05AC, .product = 0x0342 },\n            .timeout_ms = 200,\n            .permissive_hold = true,\n            .hold_on_other_key_press = false,\n            .retro_tap = false,\n        },\n    };\n\n    var client = try Client.connect(allocator, socket_path);\n    defer client.close();\n\n    try client.hello();\n    try client.applyRules(&sample_rules, &.{}, false);\n    try client.bye();\n\n    std.debug.print(\n        \"sent test rule (caps_lock → tap escape / hold lctrl) to {s}\\n\",\n        .{socket_path},\n    );\n}\n"
  },
  {
    "path": "src/grabber_protocol.zig",
    "content": "//! Shared types and framing for the user-agent ↔ system-grabber IPC.\n//!\n//! Wire format: 4-byte big-endian length prefix, then a JSON object body.\n//! Each message has a `type` field; payload fields depend on the type.\n\nconst std = @import(\"std\");\n\n/// Default socket path the grabber listens on. Override with --socket-path\n/// for development runs without root.\npub const default_socket_path = \"/var/run/skhd/grabber.sock\";\n\n/// Protocol version exchanged in `hello`. Bump when wire format changes\n/// in a non-backwards-compatible way.\npub const protocol_version: u32 = 2;\n\n/// Maximum size of a single framed message body (1 MiB). Guards against\n/// runaway frames on a misbehaving peer.\npub const max_frame_bytes: usize = 1 * 1024 * 1024;\n\n/// One device matcher. Both fields must match to apply the rule. If\n/// omitted (null), the rule applies to all keyboards.\npub const Device = struct {\n    vendor: u32,\n    product: u32,\n};\n\n/// HID-level colon-form remap (`.remap X [device d] : Y`). Forwarded\n/// to the grabber so it can rewrite the source usage before tap-hold\n/// processing — `kIOHIDOptionsTypeSeizeDevice` bypasses the IOHIDLib\n/// UserKeyMapping that hidutil sets, so colon-form rules can't reach\n/// seized devices through hidutil alone.\npub const Remap = struct {\n    src_usage: u32,\n    dst_usage: u32,\n    /// Device filter; required (the agent's parser rejects global\n    /// remaps).\n    device: Device,\n};\n\n/// A single tap-hold remap rule. Wire-stable: don't reorder/rename\n/// without bumping protocol_version.\n///\n/// Hold action is one of:\n/// - `hold_usage > 0`: emit that HID usage on hold (modifier-style),\n/// - `hold_layer != null`: switch the agent into that mode while held.\n///\n/// Exactly one must be set; both forms are mutually exclusive. This\n/// matches `.remap … { hold: <hid-key> | <mode_name> }` in the config.\npub const Rule = struct {\n    /// HID usage of the source key on usage page 0x07 (e.g. 0x39 for\n    /// caps_lock).\n    src_usage: u32,\n    /// HID usage emitted on tap.\n    tap_usage: u32,\n    /// HID usage emitted on hold. Zero when `hold_layer` is set.\n    hold_usage: u32 = 0,\n    /// Mode name to push on hold; null when `hold_usage` is set.\n    /// Owned by the wire payload's arena (parsed-from-JSON lifetime).\n    hold_layer: ?[]const u8 = null,\n    /// Optional device filter; null means \"all keyboards\".\n    device: ?Device = null,\n    /// Tap-hold timeout in milliseconds.\n    timeout_ms: u32 = 200,\n    /// QMK permissive_hold semantics.\n    permissive_hold: bool = false,\n    /// QMK hold_on_other_key_press semantics.\n    hold_on_other_key_press: bool = false,\n    /// QMK retro_tap semantics.\n    retro_tap: bool = false,\n};\n\n/// Read one length-prefixed frame into `buf`. Returns the body length on\n/// success. Errors if peer closed cleanly (EndOfStream) or sent a frame\n/// larger than the caller-supplied buffer / `max_frame_bytes`.\npub fn readFrame(stream: anytype, buf: []u8) !usize {\n    var len_bytes: [4]u8 = undefined;\n    try stream.reader().readNoEof(&len_bytes);\n    const len = std.mem.readInt(u32, &len_bytes, .big);\n    if (len > max_frame_bytes) return error.FrameTooLarge;\n    if (len > buf.len) return error.BufferTooSmall;\n    try stream.reader().readNoEof(buf[0..len]);\n    return @intCast(len);\n}\n\n/// Write one length-prefixed frame.\npub fn writeFrame(stream: anytype, body: []const u8) !void {\n    if (body.len > max_frame_bytes) return error.FrameTooLarge;\n    var len_bytes: [4]u8 = undefined;\n    std.mem.writeInt(u32, &len_bytes, @intCast(body.len), .big);\n    try stream.writer().writeAll(&len_bytes);\n    try stream.writer().writeAll(body);\n}\n\n/// Serialize an arbitrary value to JSON and send it as one framed\n/// message. Caller passes an anonymous struct literal that includes a\n/// `type` field — the value is serialized verbatim so callers control\n/// the wire shape.\npub fn writeMessage(stream: anytype, allocator: std.mem.Allocator, value: anytype) !void {\n    var buf = std.ArrayList(u8).init(allocator);\n    defer buf.deinit();\n    try std.json.stringify(value, .{}, buf.writer());\n    try writeFrame(stream, buf.items);\n}\n\ntest \"frame round-trip\" {\n    var pipe_buf: [256]u8 = undefined;\n    var fbs = std.io.fixedBufferStream(&pipe_buf);\n\n    try writeFrame(&fbs, \"hello world\");\n\n    fbs.reset();\n    var read_buf: [256]u8 = undefined;\n    const n = try readFrame(&fbs, &read_buf);\n    try std.testing.expectEqualStrings(\"hello world\", read_buf[0..n]);\n}\n\ntest \"writeMessage produces parseable JSON\" {\n    var pipe_buf: [256]u8 = undefined;\n    var fbs = std.io.fixedBufferStream(&pipe_buf);\n\n    try writeMessage(&fbs, std.testing.allocator, .{\n        .@\"type\" = \"hello\",\n        .uid = @as(u32, 501),\n        .version = protocol_version,\n    });\n\n    fbs.reset();\n    var read_buf: [256]u8 = undefined;\n    const n = try readFrame(&fbs, &read_buf);\n\n    var parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, read_buf[0..n], .{});\n    defer parsed.deinit();\n\n    const obj = parsed.value.object;\n    try std.testing.expectEqualStrings(\"hello\", obj.get(\"type\").?.string);\n    try std.testing.expectEqual(@as(i64, 501), obj.get(\"uid\").?.integer);\n}\n"
  },
  {
    "path": "src/main.zig",
    "content": "const std = @import(\"std\");\nconst builtin = @import(\"builtin\");\nconst track_alloc = @import(\"build_options\").track_alloc;\n\nconst c = @import(\"c.zig\");\nconst DeviceCheck = @import(\"DeviceCheck.zig\");\nconst grabber_cli = @import(\"grabber_cli.zig\");\nconst grabber_protocol = @import(\"grabber_protocol\");\nconst Mappings = @import(\"Mappings.zig\");\nconst Parser = @import(\"Parser.zig\");\nconst service = @import(\"service.zig\");\nconst Skhd = @import(\"skhd.zig\");\nconst synthesize = @import(\"synthesize.zig\");\nconst TrackingAllocator = @import(\"TrackingAllocator.zig\");\n\nconst version = std.mem.trimRight(u8, @embedFile(\"VERSION\"), \"\\n\\r\\t \");\nconst log = std.log.scoped(.main);\n\n/// Build-mode-aware log level: full debug locally, info in safe\n/// release builds (so production daemons keep their session-start\n/// marker, watchdog notices, etc.), warn-only in fast/small release\n/// builds where the noise floor matters and we don't want info\n/// chatter in user terminals. Mirrors the policy used by skhd-grabber.\npub const std_options: std.Options = .{\n    .log_level = switch (builtin.mode) {\n        .Debug => .debug,\n        .ReleaseSafe => .info,\n        .ReleaseFast, .ReleaseSmall => .warn,\n    },\n};\n\nvar debug_allocator: std.heap.DebugAllocator(.{}) = .init;\n\npub fn main() !void {\n    // Get base allocator\n    const base_gpa, const is_debug = switch (builtin.mode) {\n        .Debug, .ReleaseSafe => .{ debug_allocator.allocator(), true },\n        .ReleaseFast, .ReleaseSmall => .{ std.heap.smp_allocator, false },\n    };\n    defer if (is_debug) {\n        switch (debug_allocator.deinit()) {\n            .ok => {},\n            .leak => std.debug.print(\"memory leak detected\\n\", .{}),\n        }\n    };\n\n    // Set up tracking allocator if enabled at compile time\n    var tracker: if (track_alloc) TrackingAllocator else void = undefined;\n    const gpa = if (comptime track_alloc) blk: {\n        tracker = try TrackingAllocator.init(base_gpa);\n\n        std.debug.print(\"=== Allocation Logging Enabled ===\\n\", .{});\n        std.debug.print(\"All allocations and deallocations will be logged.\\n\\n\", .{});\n\n        break :blk tracker.allocator();\n    } else base_gpa;\n\n    defer if (comptime track_alloc) {\n        std.debug.print(\"\\n=== Final Allocation Report ===\\n\", .{});\n        tracker.printReport(std.io.getStdErr().writer()) catch {};\n        tracker.deinit();\n    };\n\n    // Parse command line arguments\n    const args = try std.process.argsAlloc(gpa);\n    defer std.process.argsFree(gpa, args);\n\n    var config_file: ?[]const u8 = null;\n    var verbose = false;\n    var observe_mode = false;\n    var no_hotload = false;\n    var profile = false;\n\n    var i: usize = 1;\n    while (i < args.len) : (i += 1) {\n        if (std.mem.eql(u8, args[i], \"-c\") or std.mem.eql(u8, args[i], \"--config\")) {\n            if (i + 1 < args.len) {\n                i += 1;\n                config_file = args[i];\n            } else {\n                std.debug.print(\"Error: --config requires a file path\\n\", .{});\n                return;\n            }\n        } else if (std.mem.eql(u8, args[i], \"-V\") or std.mem.eql(u8, args[i], \"--verbose\")) {\n            verbose = true;\n        } else if (std.mem.eql(u8, args[i], \"-o\") or std.mem.eql(u8, args[i], \"--observe\")) {\n            observe_mode = true;\n        } else if (std.mem.eql(u8, args[i], \"-v\") or std.mem.eql(u8, args[i], \"--version\")) {\n            std.debug.print(\"skhd.zig v{s}\\n\", .{version});\n            return;\n        } else if (std.mem.eql(u8, args[i], \"-k\") or std.mem.eql(u8, args[i], \"--key\")) {\n            if (i + 1 < args.len) {\n                i += 1;\n                try synthesize.synthesizeKey(gpa, args[i]);\n                return;\n            } else {\n                std.debug.print(\"Error: --key requires a key string\\n\", .{});\n                return;\n            }\n        } else if (std.mem.eql(u8, args[i], \"-t\") or std.mem.eql(u8, args[i], \"--text\")) {\n            if (i + 1 < args.len) {\n                i += 1;\n                try synthesize.synthesizeText(gpa, args[i]);\n                return;\n            } else {\n                std.debug.print(\"Error: --text requires a text string\\n\", .{});\n                return;\n            }\n        } else if (std.mem.eql(u8, args[i], \"--help\")) {\n            printHelp();\n            return;\n        } else if (std.mem.eql(u8, args[i], \"--install-service\")) {\n            try service.installService(gpa);\n            try maybeInstallGrabber(gpa);\n            return;\n        } else if (std.mem.eql(u8, args[i], \"--uninstall-service\")) {\n            try service.uninstallService(gpa);\n            return;\n        } else if (std.mem.eql(u8, args[i], \"--start-service\")) {\n            try service.startService(gpa);\n            return;\n        } else if (std.mem.eql(u8, args[i], \"--stop-service\")) {\n            try service.stopService(gpa);\n            return;\n        } else if (std.mem.eql(u8, args[i], \"--restart-service\")) {\n            try service.restartService(gpa);\n            return;\n        } else if (std.mem.eql(u8, args[i], \"--status\")) {\n            try service.checkServiceStatus(gpa);\n            return;\n        } else if (std.mem.eql(u8, args[i], \"-r\") or std.mem.eql(u8, args[i], \"--reload\")) {\n            try service.reloadConfig(gpa);\n            return;\n        } else if (std.mem.eql(u8, args[i], \"-h\") or std.mem.eql(u8, args[i], \"--no-hotload\")) {\n            no_hotload = true;\n        } else if (std.mem.eql(u8, args[i], \"-P\") or std.mem.eql(u8, args[i], \"--profile\")) {\n            profile = true;\n        } else if (std.mem.eql(u8, args[i], \"--install-grabber\")) {\n            grabber_cli.installGrabber(gpa) catch std.process.exit(1);\n            return;\n        } else if (std.mem.eql(u8, args[i], \"--install-dext\")) {\n            grabber_cli.installDext(gpa) catch std.process.exit(1);\n            return;\n        } else if (std.mem.eql(u8, args[i], \"--uninstall-grabber\")) {\n            grabber_cli.uninstallGrabber(gpa) catch std.process.exit(1);\n            return;\n        } else if (std.mem.eql(u8, args[i], \"--grabber-status\")) {\n            const path = consumeOptionalPath(args, &i) orelse grabber_protocol.default_socket_path;\n            grabber_cli.grabberStatus(gpa, path) catch std.process.exit(1);\n            return;\n        } else if (std.mem.eql(u8, args[i], \"--grabber-test-rule\")) {\n            const path = consumeOptionalPath(args, &i) orelse grabber_protocol.default_socket_path;\n            grabber_cli.grabberTestRule(gpa, path) catch std.process.exit(1);\n            return;\n        }\n    }\n\n    if (observe_mode) {\n        const echo = @import(\"echo.zig\").echo;\n        try echo();\n        return;\n    }\n\n    // Resolve config file path\n    const resolved_config_file = if (config_file) |cf|\n        try gpa.dupe(u8, cf)\n    else\n        try getConfigFile(gpa, \"skhdrc\");\n    defer gpa.free(resolved_config_file);\n\n    // Check if another instance is already running\n    if (!verbose) { // Only check in service mode\n        if (try service.readPidFile(gpa)) |pid| {\n            if (service.isProcessRunning(pid)) {\n                std.debug.print(\"skhd is already running (PID {d})\\n\", .{pid});\n                return;\n            } else {\n                // Clean up stale PID file\n                service.removePidFile(gpa);\n            }\n        }\n    }\n\n    // Write PID file\n    try service.writePidFile(gpa);\n    defer service.removePidFile(gpa);\n\n    // Capture stderr to ~/Library/Logs/skhd.log when launched as a daemon\n    // (SMAppService wires stderr to /dev/null). Skipped for `-V` so verbose\n    // runs always print to the invoking terminal/pipe, even if launchd\n    // somehow set XPC_SERVICE_NAME.\n    if (!verbose) {\n        redirectDaemonStderr(gpa);\n    }\n    logSessionStart();\n    inheritUserPath(gpa);\n\n    // Initialize and run skhd\n    var skhd = try Skhd.init(gpa, resolved_config_file, verbose, profile);\n    defer skhd.deinit();\n\n    applyConfigPaths(gpa, skhd.mappings.paths.items);\n\n    if (verbose) {\n        log.info(\"Using config file: {s}\", .{resolved_config_file});\n        if (no_hotload) {\n            log.info(\"Hot reload disabled\", .{});\n        } else {\n            log.info(\"Hot reload enabled\", .{});\n        }\n        if (profile) {\n            log.info(\"Profiling enabled\", .{});\n        }\n    }\n\n    // Pass the hotload flag to run\n    skhd.run(!no_hotload) catch {};\n}\n\n/// Consume the next CLI arg as an optional value if it doesn't look\n/// like a new flag. Returns null and leaves the index alone otherwise.\n/// Used by --grabber-status / --grabber-test-rule which take an\n/// optional `<socket-path>` after the flag.\nfn consumeOptionalPath(args: []const [:0]u8, i: *usize) ?[]const u8 {\n    if (i.* + 1 >= args.len) return null;\n    const next = args[i.* + 1];\n    if (next.len > 0 and next[0] == '-') return null;\n    i.* += 1;\n    return next;\n}\n\n/// True iff this process was spawned by launchd as an XPC service /\n/// LaunchAgent. The XPC framework sets `XPC_SERVICE_NAME` to the placeholder\n/// \"0\" for normal user-shell processes (so it's almost always *set* — the\n/// classic null-check is too loose); launchd overrides it with the real\n/// service label (e.g. `com.jackielii.skhd`) only for actual services.\npub fn isLaunchdManaged() bool {\n    const name = std.posix.getenv(\"XPC_SERVICE_NAME\") orelse return false;\n    return !std.mem.eql(u8, name, \"0\");\n}\n\n/// Redirect stderr to ~/Library/Logs/skhd.log when running under\n/// SMAppService — the LaunchAgent.plist doesn't set StandardErrorPath, so\n/// the daemon's stderr is /dev/null and every log.err / log.info is\n/// silently dropped. Foreground runs (terminal or `zig build` subprocess)\n/// keep stderr untouched so logs reach the user's terminal. `-V` always\n/// forces no-redirect — verbose mode is for humans watching the output\n/// live, never for log-file capture.\n///\n/// Detection signal: `XPC_SERVICE_NAME` is injected by launchd into every\n/// service it spawns. It's absent for direct CLI invocations and for\n/// processes started through `zig build`'s subprocess pipe — so it's a\n/// stricter \"am I really a daemon\" test than isatty(2), which gets fooled\n/// by the build system's stderr pipe.\nfn redirectDaemonStderr(allocator: std.mem.Allocator) void {\n    if (!isLaunchdManaged()) return;\n\n    const home = std.posix.getenv(\"HOME\") orelse return;\n    const path = std.fmt.allocPrintZ(allocator, \"{s}/Library/Logs/skhd.log\", .{home}) catch return;\n    defer allocator.free(path);\n\n    const fd = c.open(path.ptr, c.O_WRONLY | c.O_CREAT | c.O_APPEND, @as(c_int, 0o644));\n    if (fd < 0) return;\n    defer _ = c.close(fd);\n\n    _ = c.dup2(fd, 2);\n}\n\n/// Mark the start of a new session in the log so it's easy to find where the\n/// current run begins after a respawn. Single line, ISO-8601 UTC timestamp,\n/// version, and PID.\nfn logSessionStart() void {\n    const ts = std.time.timestamp();\n    if (ts < 0) return;\n\n    const epoch_secs = std.time.epoch.EpochSeconds{ .secs = @intCast(ts) };\n    const epoch_day = epoch_secs.getEpochDay();\n    const year_day = epoch_day.calculateYearDay();\n    const month_day = year_day.calculateMonthDay();\n    const day_secs = epoch_secs.getDaySeconds();\n\n    log.warn(\"=== skhd {s} started at {d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z (PID {d}) ===\", .{\n        version,\n        @as(u32, year_day.year),\n        @intFromEnum(month_day.month),\n        @as(u32, month_day.day_index) + 1,\n        day_secs.getHoursIntoDay(),\n        day_secs.getMinutesIntoHour(),\n        day_secs.getSecondsIntoMinute(),\n        @as(i32, @intCast(std.c.getpid())),\n    });\n}\n\n/// Resolve the user's login shell. Prefers `SHELL` env (the shell the user is\n/// actively using — terminal apps may override pw_shell), then falls back to\n/// `getpwuid(getuid()).pw_shell` from Open Directory. The pw_shell fallback is\n/// what fixes #36: under SMAppService, SHELL can be unset, so the previous\n/// implementation silently bailed out. pw_shell is the same source `login(1)`\n/// uses and is reliable under launchd.\nfn detectLoginShell(allocator: std.mem.Allocator) ?[:0]const u8 {\n    if (std.posix.getenv(\"SHELL\")) |shell| {\n        if (shell.len > 0) return allocator.dupeZ(u8, shell) catch null;\n    }\n    if (c.getpwuid(c.getuid())) |pw| {\n        if (pw.*.pw_shell) |shell_ptr| {\n            const slice = std.mem.sliceTo(shell_ptr, 0);\n            if (slice.len > 0) return allocator.dupeZ(u8, slice) catch null;\n        }\n    }\n    return null;\n}\n\n/// Capture PATH using a shell-specific invocation. `-i` is dropped on every\n/// shell because interactive init under launchd's no-tty environment is the\n/// main source of failures: zsh's `compinit` writes warnings to stdout, fish\n/// prompts that probe terminal capabilities can hang, and rc files commonly\n/// assume `read`/colorized output works. PATH belongs in profile/login files\n/// anyway, which `-l` (or fish's always-sourced `config.fish`) covers.\n///\n/// Returns the trimmed colon-joined PATH on success, null on any failure\n/// (with the failure logged at warn level so it survives in the daemon log).\nfn capturePath(allocator: std.mem.Allocator, shell_path: []const u8) ?[]u8 {\n    const shell_name = std.fs.path.basename(shell_path);\n\n    // fish stores PATH as a list and `printenv PATH` prints it\n    // space-separated. `string join : $PATH` gives the colon-joined form\n    // every other tool expects. fish always sources `config.fish` and\n    // `conf.d/*.fish`, so no `-l` flag is needed.\n    const argv: []const []const u8 = if (std.mem.eql(u8, shell_name, \"fish\"))\n        &.{ shell_path, \"-c\", \"string join : $PATH\" }\n    else\n        &.{ shell_path, \"-lc\", \"printenv PATH\" };\n\n    var child = std.process.Child.init(argv, allocator);\n    child.stdout_behavior = .Pipe;\n    child.stderr_behavior = .Ignore;\n    child.spawn() catch |err| {\n        log.warn(\"PATH capture: spawn {s} failed: {s}\", .{ shell_path, @errorName(err) });\n        return null;\n    };\n\n    var stdout_data = std.ArrayList(u8).init(allocator);\n    defer stdout_data.deinit();\n    if (child.stdout) |stdout| {\n        stdout.reader().readAllArrayList(&stdout_data, 64 * 1024) catch |err| {\n            _ = child.wait() catch {};\n            log.warn(\"PATH capture: read stdout failed: {s}\", .{@errorName(err)});\n            return null;\n        };\n    }\n    const term = child.wait() catch |err| {\n        log.warn(\"PATH capture: wait failed: {s}\", .{@errorName(err)});\n        return null;\n    };\n    if (term != .Exited or term.Exited != 0) {\n        log.warn(\"PATH capture: {s} exited abnormally: {any}\", .{ shell_path, term });\n        return null;\n    }\n\n    const trimmed = std.mem.trim(u8, stdout_data.items, \" \\r\\n\\t\");\n    if (trimmed.len == 0) {\n        log.warn(\"PATH capture: {s} returned empty PATH\", .{shell_path});\n        return null;\n    }\n\n    return allocator.dupe(u8, trimmed) catch null;\n}\n\n/// Augment PATH from the user's login shell so commands launched by hotkeys\n/// resolve the same as they do in a terminal. launchd starts services with a\n/// minimal `PATH=/usr/bin:/bin:/usr/sbin:/sbin` that excludes Homebrew\n/// (`/opt/homebrew/bin`, `/usr/local/bin`), `~/.local/bin`, and similar — so\n/// commands like `yabai` or `jq` referenced bare in skhdrc fail to exec.\n/// This is the same problem (and same fix) GUI editors like VS Code solve.\nfn inheritUserPath(allocator: std.mem.Allocator) void {\n    const shell = detectLoginShell(allocator) orelse {\n        log.warn(\"PATH inheritance: no login shell (SHELL unset and getpwuid failed)\", .{});\n        return;\n    };\n    defer allocator.free(shell);\n\n    const captured = capturePath(allocator, shell) orelse return;\n    defer allocator.free(captured);\n\n    const path_z = allocator.dupeZ(u8, captured) catch return;\n    defer allocator.free(path_z);\n\n    if (c.setenv(\"PATH\", path_z.ptr, 1) != 0) {\n        log.warn(\"PATH inheritance: setenv failed\", .{});\n        return;\n    }\n    log.info(\"PATH inherited from {s}: {s}\", .{ shell, captured });\n}\n\n/// Prepend `.path` directive entries to PATH. Called after inheritUserPath so\n/// the layering is:\n///   `<.path entries, in declaration order> : <inherited PATH>`\n/// Explicit user entries take precedence over what shell inheritance found,\n/// which matters for tool-version-managers (mise/asdf shims) where the user\n/// wants the shim dir resolved before any system tool of the same name.\nfn applyConfigPaths(allocator: std.mem.Allocator, entries: []const []const u8) void {\n    if (entries.len == 0) return;\n\n    const current = std.posix.getenv(\"PATH\") orelse \"\";\n\n    var buf = std.ArrayList(u8).init(allocator);\n    defer buf.deinit();\n\n    for (entries) |entry| {\n        buf.appendSlice(entry) catch return;\n        buf.append(':') catch return;\n    }\n    buf.appendSlice(current) catch return;\n    buf.append(0) catch return;\n\n    if (c.setenv(\"PATH\", @ptrCast(buf.items.ptr), 1) != 0) {\n        log.warn(\"PATH apply: setenv failed\", .{});\n        return;\n    }\n    log.warn(\"PATH after .path directives: {s}\", .{buf.items[0 .. buf.items.len - 1]});\n}\n\n/// Resolve config file path following XDG spec\n/// Tries in order:\n/// 1. $XDG_CONFIG_HOME/skhd/<filename>\n/// 2. $HOME/.config/skhd/<filename>\n/// 3. $HOME/.<filename>\npub fn getConfigFile(allocator: std.mem.Allocator, filename: []const u8) ![]const u8 {\n    // Try XDG_CONFIG_HOME first\n    if (std.posix.getenv(\"XDG_CONFIG_HOME\")) |xdg_home| {\n        const path = try std.fmt.allocPrint(allocator, \"{s}/skhd/{s}\", .{ xdg_home, filename });\n        defer allocator.free(path);\n\n        if (fileExists(path)) {\n            return try allocator.dupe(u8, path);\n        }\n    }\n\n    // Try HOME/.config/skhd\n    if (std.posix.getenv(\"HOME\")) |home| {\n        const config_path = try std.fmt.allocPrint(allocator, \"{s}/.config/skhd/{s}\", .{ home, filename });\n        defer allocator.free(config_path);\n\n        if (fileExists(config_path)) {\n            return try allocator.dupe(u8, config_path);\n        }\n\n        // Try HOME/.skhdrc (dotfile in home)\n        const dotfile_path = try std.fmt.allocPrint(allocator, \"{s}/.{s}\", .{ home, filename });\n        defer allocator.free(dotfile_path);\n\n        if (fileExists(dotfile_path)) {\n            return try allocator.dupe(u8, dotfile_path);\n        }\n    }\n\n    // Default to filename in current directory\n    return try allocator.dupe(u8, filename);\n}\n\nfn fileExists(path: []const u8) bool {\n    std.fs.cwd().access(path, .{}) catch return false;\n    return true;\n}\n\n/// Run after a successful `--install-service`. Checks whether the\n/// user's config has any caps_lock-class `.remap` block-form rules\n/// targeting a currently-connected device — those need the system\n/// grabber. If so and the grabber isn't already installed, prints\n/// the situation and offers to install it now via sudo. The user\n/// always has the choice to decline and run `sudo skhd\n/// --install-grabber` themselves.\n///\n/// Skips silently on Mac Studio / external-keyboard-only setups\n/// where no caps-class rule's target device is connected — the\n/// agent's runtime path (DeviceCheck in forwardTapholdsToGrabber)\n/// also handles this, so installing the grabber there would just be\n/// dead weight.\nfn maybeInstallGrabber(allocator: std.mem.Allocator) !void {\n    if (grabber_cli.isGrabberInstalled()) {\n        std.debug.print(\"\\nskhd-grabber is already installed.\\n\", .{});\n        return;\n    }\n\n    // Parse the user's config to find caps-class rules.\n    const config_path = getConfigFile(allocator, \"skhdrc\") catch |err| {\n        std.debug.print(\"\\n(could not resolve config file: {s})\\n\", .{@errorName(err)});\n        return;\n    };\n    defer allocator.free(config_path);\n\n    var mappings = Mappings.init(allocator) catch return;\n    defer mappings.deinit();\n\n    var parser = Parser.init(allocator) catch return;\n    defer parser.deinit();\n\n    const content = std.fs.cwd().readFileAlloc(allocator, config_path, 1 << 20) catch |err| {\n        std.debug.print(\"\\n(could not read config {s}: {s} — skipping grabber check)\\n\", .{ config_path, @errorName(err) });\n        return;\n    };\n    defer allocator.free(content);\n\n    parser.parseWithPath(&mappings, content, config_path) catch return;\n    parser.processLoadDirectives(&mappings) catch return;\n\n    if (mappings.tapholds.items.len == 0) {\n        std.debug.print(\"\\nNo caps_lock-class rules in config — skhd-grabber not needed.\\n\", .{});\n        return;\n    }\n\n    // Filter by device presence so a config shared between a laptop\n    // and a Mac Studio doesn't force grabber install on the Studio.\n    var any_present = false;\n    var first_alias: ?[]const u8 = null;\n    for (mappings.tapholds.items) |th| {\n        const alias = mappings.device_aliases.get(th.device_alias) orelse continue;\n        if (DeviceCheck.isPresent(alias.vendor, alias.product)) {\n            any_present = true;\n            if (first_alias == null) first_alias = th.device_alias;\n            break;\n        }\n    }\n    if (!any_present) {\n        std.debug.print(\"\\nConfig has caps_lock-class rules, but none of the targeted devices are connected — skhd-grabber not needed on this machine.\\n\", .{});\n        return;\n    }\n\n    std.debug.print(\n        \\\\\n        \\\\Config has caps_lock-class rules for connected device '{s}'.\n        \\\\skhd-grabber is required to handle them — it runs as a system\n        \\\\daemon (root) and seizes the keyboard for tap-hold processing.\n        \\\\\n        \\\\Install it now? (you'll be prompted for your sudo password) [Y/n]\n    , .{first_alias.?});\n\n    const answer = readLine(allocator) catch {\n        std.debug.print(\"\\n(could not read answer; run `sudo skhd --install-grabber` to install manually)\\n\", .{});\n        return;\n    };\n    defer allocator.free(answer);\n    const trimmed = std.mem.trim(u8, answer, \" \\t\\r\");\n    if (trimmed.len > 0 and (trimmed[0] == 'n' or trimmed[0] == 'N')) {\n        std.debug.print(\n            \\\\Skipping. To install later:\n            \\\\  sudo skhd --install-grabber\n            \\\\\n        , .{});\n        return;\n    }\n\n    grabber_cli.installGrabberViaSudo(allocator) catch |err| {\n        std.debug.print(\n            \\\\\n            \\\\Grabber install via sudo failed ({s}). Try running it directly:\n            \\\\  sudo skhd --install-grabber\n            \\\\\n        , .{@errorName(err)});\n        return;\n    };\n    std.debug.print(\"\\nskhd-grabber installed.\\n\", .{});\n\n    // Trigger the Input Monitoring approval dialog now, while the user is\n    // at an interactive terminal and expects system prompts. Granting IM\n    // to skhd.app covers the grabber via bundle-shared TCC (both binaries\n    // are signed with `-i com.jackielii.skhd` and the grabber runs from\n    // inside the bundle). IOHIDRequestAccess blocks until the user\n    // clicks Allow/Deny — fine here, broken if called at agent startup.\n    std.debug.print(\n        \\\\\n        \\\\Requesting Input Monitoring permission (a System Settings dialog\n        \\\\will appear; approving it covers both skhd and skhd-grabber)...\n        \\\\\n    , .{});\n    const im_granted = service.promptForInputMonitoring();\n    if (im_granted) {\n        std.debug.print(\"Input Monitoring granted.\\n\", .{});\n    } else {\n        std.debug.print(\n            \\\\Input Monitoring not granted yet. If you missed the dialog or\n            \\\\denied by accident, open System Settings → Privacy & Security\n            \\\\→ Input Monitoring and toggle skhd on, then run:\n            \\\\  sudo launchctl kickstart -k system/com.jackielii.skhd.grabber\n            \\\\\n        , .{});\n    }\n}\n\n/// Read one line from stdin (up to newline). Returns owned slice\n/// including any trailing carriage return; caller is responsible for\n/// trimming.\nfn readLine(allocator: std.mem.Allocator) ![]u8 {\n    const stdin = std.io.getStdIn().reader();\n    return try stdin.readUntilDelimiterAlloc(allocator, '\\n', 64);\n}\n\nfn printHelp() void {\n    std.debug.print(\n        \\\\skhd - Simple Hotkey Daemon for macOS\n        \\\\\n        \\\\Usage: skhd [options]\n        \\\\\n        \\\\Options:\n        \\\\  -c, --config <file>    Specify config file (default: skhdrc)\n        \\\\  -V, --verbose          Enable verbose output (interactive mode)\n        \\\\  -P, --profile          Enable profiling/tracing mode\n        \\\\  -o, --observe          Observe mode - print key events\n        \\\\  -h, --no-hotload       Disable system for hotloading config file\n        \\\\  -k, --key <keyspec>    Synthesize a keypress\n        \\\\  -t, --text <text>      Synthesize text input\n        \\\\  -r, --reload           Reload config on running instance\n        \\\\  -v, --version          Print version\n        \\\\      --help             Show this help message\n        \\\\\n        \\\\Service Management:\n        \\\\      --install-service   Register the bundled LaunchAgent with macOS\n        \\\\                          via SMAppService (BTM-tracked, auto-starts\n        \\\\                          at login)\n        \\\\      --uninstall-service Unregister and remove\n        \\\\      --start-service     Start the service\n        \\\\      --stop-service      Stop the service (transient — relaunches\n        \\\\                          on next login)\n        \\\\      --restart-service   Restart the service\n        \\\\      --status            Check service status\n        \\\\\n        \\\\System Grabber (caps_lock-class tap-hold, opt-in):\n        \\\\      --install-grabber       Install skhd-grabber LaunchDaemon (sudo)\n        \\\\      --uninstall-grabber     Remove skhd-grabber LaunchDaemon (sudo)\n        \\\\      --grabber-status [path] Ping the grabber's IPC socket\n        \\\\      --grabber-test-rule [path]\n        \\\\                              Send a sample tap-hold rule to the grabber\n        \\\\                              for IPC plumbing verification\n        \\\\\n    , .{});\n}\n"
  },
  {
    "path": "src/service.zig",
    "content": "const std = @import(\"std\");\nconst builtin = @import(\"builtin\");\nconst c = @import(\"c.zig\");\nconst sm = @import(\"sm_app_service.zig\");\nconst grabber_cli = @import(\"grabber_cli.zig\");\nconst log = std.log.scoped(.service);\n\n// Import C functions\nextern \"c\" fn getpid() c_int;\nextern \"c\" fn getuid() c_uint;\n\n/// Bundle ID of the agent the daemon registers itself as. Must match the\n/// CFBundleIdentifier embedded in skhd.app/Contents/Info.plist *and* the\n/// filename of the bundled launchd plist\n/// (skhd.app/Contents/Library/LaunchAgents/<this>.plist).\nconst BUNDLE_ID = \"com.jackielii.skhd\";\nconst LAUNCH_AGENT_PLIST_NAME: [*:0]const u8 = BUNDLE_ID ++ \".plist\";\n\n/// Check if the current process has accessibility permissions\npub fn hasAccessibilityPermissions() bool {\n    // Use the official macOS API to check accessibility permissions\n    // AXIsProcessTrusted() is the recommended way to check if the current\n    // process has been granted accessibility permissions by the user\n    return c.AXIsProcessTrusted() != 0;\n}\n\n/// Three-valued result of `IOHIDCheckAccess(kIOHIDRequestTypeListenEvent)`.\n/// `unknown` means the user has never been prompted (first run); `denied`\n/// can mean either explicitly denied OR — the case worth surfacing — that\n/// TCC's stored csreq is anchored on a stale cdHash (every brew upgrade\n/// or rebuild produces a new cdHash, silently invalidating the grant\n/// without losing the System Settings check mark).\npub const InputMonitoringAccess = enum { granted, denied, unknown };\n\n/// Query whether the running process has Input Monitoring\n/// (kTCCServiceListenEvent) granted. CGEvent taps that listen for keyDown\n/// require this in addition to Accessibility — without it, the tap is\n/// created (Accessibility-only check) but key-down events are silently\n/// dropped before reaching the callback. This catches the cdHash-mismatch\n/// case that the existing Accessibility / log-tail signals miss entirely.\npub fn checkInputMonitoringAccess() InputMonitoringAccess {\n    return switch (c.IOHIDCheckAccess(c.kIOHIDRequestTypeListenEvent)) {\n        c.kIOHIDAccessTypeGranted => .granted,\n        c.kIOHIDAccessTypeDenied => .denied,\n        else => .unknown,\n    };\n}\n\n/// Trigger the Input Monitoring approval dialog for this bundle. Same\n/// auto-pop UX as `promptForAccessibility` — first call shows the system\n/// prompt; subsequent calls just return the current grant state. Used to\n/// extend the bundle-keyed IM grant to skhd-grabber: when the daemon and\n/// the agent are both signed with `com.jackielii.skhd` and the daemon\n/// runs from inside skhd.app, granting the dialog covers both processes.\npub fn promptForInputMonitoring() bool {\n    return c.IOHIDRequestAccess(c.kIOHIDRequestTypeListenEvent) != 0;\n}\n\n/// Like hasAccessibilityPermissions() but uses the prompting variant. The\n/// first time an unknown bundle calls this, macOS pops the \"X would like to\n/// control this computer\" dialog and opens System Settings → Accessibility.\n/// Subsequent calls (granted or denied) just return without prompting.\n/// CGEventTap creation itself never prompts, so we have to call this\n/// explicitly to surface the popup.\npub fn promptForAccessibility() bool {\n    const keys = [_]?*const anyopaque{@ptrCast(c.kAXTrustedCheckOptionPrompt)};\n    const values = [_]?*const anyopaque{@ptrCast(c.kCFBooleanTrue)};\n    const opts = c.CFDictionaryCreate(\n        null,\n        @ptrCast(@constCast(&keys)),\n        @ptrCast(@constCast(&values)),\n        1,\n        &c.kCFCopyStringDictionaryKeyCallBacks,\n        &c.kCFTypeDictionaryValueCallBacks,\n    );\n    defer if (opts != null) c.CFRelease(opts);\n    return c.AXIsProcessTrustedWithOptions(opts) != 0;\n}\n\n/// PID file management\npub fn writePidFile(allocator: std.mem.Allocator) !void {\n    const username = std.posix.getenv(\"USER\") orelse \"unknown\";\n    const pid_path = try std.fmt.allocPrint(allocator, \"/tmp/skhd_{s}.pid\", .{username});\n    defer allocator.free(pid_path);\n\n    const pid = @as(i32, @intCast(getpid()));\n    const pid_str = try std.fmt.allocPrint(allocator, \"{d}\\n\", .{pid});\n    defer allocator.free(pid_str);\n\n    const file = try std.fs.createFileAbsolute(pid_path, .{ .truncate = true });\n    defer file.close();\n\n    try file.writeAll(pid_str);\n}\n\npub fn removePidFile(allocator: std.mem.Allocator) void {\n    const username = std.posix.getenv(\"USER\") orelse \"unknown\";\n    const pid_path = std.fmt.allocPrint(allocator, \"/tmp/skhd_{s}.pid\", .{username}) catch return;\n    defer allocator.free(pid_path);\n\n    std.fs.deleteFileAbsolute(pid_path) catch {};\n}\n\npub fn readPidFile(allocator: std.mem.Allocator) !?i32 {\n    const username = std.posix.getenv(\"USER\") orelse \"unknown\";\n    const pid_path = try std.fmt.allocPrint(allocator, \"/tmp/skhd_{s}.pid\", .{username});\n    defer allocator.free(pid_path);\n\n    const file = std.fs.openFileAbsolute(pid_path, .{}) catch |err| {\n        if (err == error.FileNotFound) return null;\n        return err;\n    };\n    defer file.close();\n\n    const content = try file.readToEndAlloc(allocator, 256);\n    defer allocator.free(content);\n\n    const trimmed = std.mem.trim(u8, content, \" \\n\\r\\t\");\n    return std.fmt.parseInt(i32, trimmed, 10) catch null;\n}\n\n/// Check if a process with given PID is running\npub fn isProcessRunning(pid: i32) bool {\n    // On macOS/Unix, we can check if a process exists by sending signal 0\n    std.posix.kill(pid, 0) catch |err| {\n        // If we get permission denied, the process exists but we can't signal it\n        return err == error.PermissionDenied;\n    };\n    return true;\n}\n\n/// Pick a path to recommend in error messages for the System Settings →\n/// Accessibility picker. Tahoe's picker only accepts `.app` bundles, and TCC\n/// keys entries by the running process's signature — so we prefer the `.app`\n/// that actually contains the running binary, since that's the one a grant\n/// would apply to. Only fall back to `/Applications/skhd.app` when the running\n/// process is bare (e.g. cellar binary), in which case adding the prod bundle\n/// is the right pointer for the install.\npub fn resolveBundlePath(allocator: std.mem.Allocator, exe_path: []const u8) ![]const u8 {\n    const marker = \".app/Contents/MacOS/\";\n    if (std.mem.indexOf(u8, exe_path, marker)) |idx| {\n        return allocator.dupe(u8, exe_path[0 .. idx + 4]); // keep \".app\"\n    }\n\n    const apps_path = \"/Applications/skhd.app\";\n    if (std.fs.accessAbsolute(apps_path, .{})) |_| {\n        return allocator.dupe(u8, apps_path);\n    } else |_| {}\n\n    return allocator.dupe(u8, exe_path);\n}\n\n/// Resolve a Homebrew Cellar path (e.g. /opt/homebrew/Cellar/skhd-zig/0.0.15/bin/skhd)\n/// to its stable opt symlink (/opt/homebrew/opt/skhd-zig/bin/skhd) so the plist\n/// keeps working across `brew upgrade` + `brew cleanup`. Returns the input\n/// unchanged when no stable equivalent exists.\npub fn resolveStableExePath(allocator: std.mem.Allocator, exe_path: []const u8) ![]const u8 {\n    const cellar_marker = \"/Cellar/\";\n    const idx = std.mem.indexOf(u8, exe_path, cellar_marker) orelse {\n        return allocator.dupe(u8, exe_path);\n    };\n\n    const prefix = exe_path[0..idx];\n    const after_cellar = exe_path[idx + cellar_marker.len ..];\n\n    const formula_end = std.mem.indexOfScalar(u8, after_cellar, '/') orelse {\n        return allocator.dupe(u8, exe_path);\n    };\n    const formula = after_cellar[0..formula_end];\n\n    const after_formula = after_cellar[formula_end + 1 ..];\n    const version_end = std.mem.indexOfScalar(u8, after_formula, '/') orelse {\n        return allocator.dupe(u8, exe_path);\n    };\n    const rest = after_formula[version_end + 1 ..];\n\n    const candidate = try std.fmt.allocPrint(allocator, \"{s}/opt/{s}/{s}\", .{ prefix, formula, rest });\n    errdefer allocator.free(candidate);\n\n    std.fs.accessAbsolute(candidate, .{}) catch {\n        allocator.free(candidate);\n        return allocator.dupe(u8, exe_path);\n    };\n    return candidate;\n}\n\npub fn getServicePath(allocator: std.mem.Allocator) ![]const u8 {\n    const home = std.posix.getenv(\"HOME\") orelse return error.NoHomeDirectory;\n    return std.fmt.allocPrint(allocator, \"{s}/Library/LaunchAgents/com.jackielii.skhd.plist\", .{home});\n}\n\n\n/// Register the bundled LaunchAgent with macOS Background Tasks Manager via\n/// SMAppService and clean up any pre-0.0.21 plist installed at\n/// ~/Library/LaunchAgents/. With the SMAppService flow, BTM auto-tracks the\n/// agent so it actually auto-starts at login (the legacy hand-installed\n/// plist is silently disallowed by BTM on Sequoia/Tahoe — that's the\n/// \"skhd doesn't always start after reboot\" bug at its root).\npub fn installService(allocator: std.mem.Allocator) !void {\n    cleanupLegacyInstall(allocator);\n    try registerWithBTM();\n}\n\npub fn uninstallService(allocator: std.mem.Allocator) !void {\n    cleanupLegacyInstall(allocator);\n\n    const service = sm.agentService(LAUNCH_AGENT_PLIST_NAME) orelse {\n        std.debug.print(\"SMAppService unavailable; nothing to unregister.\\n\", .{});\n        return;\n    };\n    sm.unregister(service) catch |err| {\n        // unregister fails if the service was never registered — treat as success.\n        log.info(\"Unregister returned: {}\", .{err});\n    };\n    std.debug.print(\"Service unregistered.\\n\", .{});\n\n    // The agent uninstall is what users reach for first; the grabber and\n    // the Karabiner DriverKit pieces don't get auto-removed because\n    // they're root-owned and need a separate sudo step. Surface them\n    // here so a user reading the terminal output knows what's still on\n    // disk and how to finish the cleanup.\n    printPostUninstallHints();\n}\n\n/// Print follow-up cleanup instructions if the grabber or VHIDD daemon\n/// LaunchDaemons are still on disk after `--uninstall-service`. Silent\n/// when nothing else is installed (the agent-only path doesn't need\n/// extra noise). Mirrors the tail message from `--uninstall-grabber` but\n/// scoped to whatever's actually present.\nfn printPostUninstallHints() void {\n    const grabber_plist = \"/Library/LaunchDaemons/com.jackielii.skhd.grabber.plist\";\n    const vhidd_plist = \"/Library/LaunchDaemons/org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon.plist\";\n    const pqrs_payload = \"/Library/Application Support/org.pqrs/Karabiner-DriverKit-VirtualHIDDevice\";\n\n    const has_grabber = fileExistsAbsolute(grabber_plist);\n    const has_vhidd = fileExistsAbsolute(vhidd_plist);\n    const has_pqrs = fileExistsAbsolute(pqrs_payload);\n\n    if (!has_grabber and !has_vhidd and !has_pqrs) return;\n\n    std.debug.print(\"\\nStill installed (run these to fully clean up):\\n\", .{});\n\n    if (has_grabber or has_vhidd) {\n        std.debug.print(\n            \\\\  sudo skhd --uninstall-grabber\n            \\\\    Removes:\n        , .{});\n        if (has_grabber) std.debug.print(\"\\n      - skhd-grabber LaunchDaemon ({s})\", .{grabber_plist});\n        if (has_vhidd) std.debug.print(\"\\n      - VHIDD daemon LaunchDaemon ({s})\", .{vhidd_plist});\n        std.debug.print(\"\\n\", .{});\n    }\n\n    if (has_pqrs) {\n        std.debug.print(\n            \\\\\n            \\\\  Karabiner-DriverKit-VirtualHIDDevice .pkg payload + dext\n            \\\\    pqrs's domain — skhd doesn't ship its own uninstaller for these.\n            \\\\    Run pqrs's scripts:\n            \\\\      sudo bash \"/Library/Application Support/org.pqrs/Karabiner-DriverKit-VirtualHIDDevice/scripts/uninstall/remove_files.sh\"\n            \\\\      sudo bash \"/Library/Application Support/org.pqrs/Karabiner-DriverKit-VirtualHIDDevice/scripts/uninstall/deactivate_driver.sh\"\n            \\\\    The dext (kernel-side) needs SIP-aware removal — toggle it\n            \\\\    off in System Settings → Login Items & Extensions →\n            \\\\    Driver Extensions if you want it gone.\n            \\\\\n        , .{});\n    } else {\n        std.debug.print(\"\\n\", .{});\n    }\n}\n\nfn fileExistsAbsolute(path: []const u8) bool {\n    std.fs.accessAbsolute(path, .{}) catch return false;\n    return true;\n}\n\npub fn startService(allocator: std.mem.Allocator) !void {\n    _ = allocator;\n    try registerWithBTM();\n}\n\n/// Register (or re-register) the bundled LaunchAgent and print the\n/// resulting BTM status. Idempotent — calling on an already-registered\n/// agent re-loads it (useful as the implementation of both\n/// --install-service and --start-service).\nfn registerWithBTM() !void {\n    const service = sm.agentService(LAUNCH_AGENT_PLIST_NAME) orelse {\n        std.debug.print(\"Failed to obtain SMAppService instance.\\n\", .{});\n        std.debug.print(\"Verify the bundled plist exists at:\\n\", .{});\n        std.debug.print(\"  <skhd.app>/Contents/Library/LaunchAgents/{s}\\n\", .{std.mem.span(LAUNCH_AGENT_PLIST_NAME)});\n        return error.SMAppServiceUnavailable;\n    };\n\n    sm.register(service) catch |err| {\n        std.debug.print(\"Failed to register service: {}\\n\", .{err});\n        std.debug.print(\"\\nMake sure you're running skhd from inside its .app bundle.\\n\", .{});\n        std.debug.print(\"Try: /opt/homebrew/opt/skhd-zig/skhd.app/Contents/MacOS/skhd --install-service\\n\", .{});\n        return err;\n    };\n\n    const st = sm.status(service);\n    std.debug.print(\"Service registered with macOS.\\n\", .{});\n    std.debug.print(\"Status: {s}\\n\", .{st.describe()});\n    std.debug.print(\"Logs: {s}/Library/Logs/skhd.log\\n\", .{std.posix.getenv(\"HOME\") orelse \"~\"});\n\n    if (st == .requires_approval) {\n        std.debug.print(\"\\nMacOS requires approval before the agent can run:\\n\", .{});\n        std.debug.print(\"1. Open System Settings → General → Login Items & Extensions\\n\", .{});\n        std.debug.print(\"2. Find 'skhd' under 'Allow in the Background' and toggle it on\\n\", .{});\n        std.debug.print(\"3. Then run: skhd --restart-service\\n\", .{});\n    }\n}\n\n/// Best-effort cleanup of the pre-0.0.21 install layout: bootout the\n/// hand-installed launchd job and delete the plist at\n/// ~/Library/LaunchAgents/com.jackielii.skhd.plist. Silent if the legacy\n/// state isn't present. Run on every install/uninstall to make sure we\n/// never have both a legacy agent and a BTM-registered agent racing for\n/// the event tap.\nfn cleanupLegacyInstall(allocator: std.mem.Allocator) void {\n    const service_path = getServicePath(allocator) catch return;\n    defer allocator.free(service_path);\n\n    std.fs.accessAbsolute(service_path, .{}) catch return;\n\n    log.info(\"Found legacy plist at {s}, cleaning up.\", .{service_path});\n\n    // Bootout from launchd (no-op if not loaded).\n    const uid = getuid();\n    const target = std.fmt.allocPrint(allocator, \"gui/{d}/{s}\", .{ uid, BUNDLE_ID }) catch return;\n    defer allocator.free(target);\n    {\n        const argv = [_][]const u8{ \"launchctl\", \"bootout\", target };\n        var child = std.process.Child.init(&argv, allocator);\n        child.stdout_behavior = .Ignore;\n        child.stderr_behavior = .Ignore;\n        child.spawn() catch {};\n        _ = child.wait() catch {};\n    }\n\n    // Older code wrote a persistent `disable` flag via `unload -w` — clear\n    // it so a future register isn't silently blocked.\n    {\n        const argv = [_][]const u8{ \"launchctl\", \"enable\", target };\n        var child = std.process.Child.init(&argv, allocator);\n        child.stdout_behavior = .Ignore;\n        child.stderr_behavior = .Ignore;\n        child.spawn() catch {};\n        _ = child.wait() catch {};\n    }\n\n    std.fs.deleteFileAbsolute(service_path) catch {};\n}\n\npub fn stopService(allocator: std.mem.Allocator) !void {\n    const uid = getuid();\n    const target = try std.fmt.allocPrint(allocator, \"gui/{d}/com.jackielii.skhd\", .{uid});\n    defer allocator.free(target);\n\n    // bootout unloads the agent without touching the disable list, so the\n    // agent can still auto-load on next login (unlike legacy `unload -w`).\n    const argv = [_][]const u8{ \"launchctl\", \"bootout\", target };\n    var child = std.process.Child.init(&argv, allocator);\n    child.stdout_behavior = .Ignore;\n    child.stderr_behavior = .Ignore;\n\n    try child.spawn();\n    const term = try child.wait();\n\n    if (term != .Exited or term.Exited != 0) {\n        // Service might not be running, which is okay\n        return;\n    }\n\n    std.debug.print(\"Service stopped\\n\", .{});\n}\n\npub fn restartService(allocator: std.mem.Allocator) !void {\n    try stopService(allocator);\n    // Small delay to ensure service is fully stopped\n    std.time.sleep(1 * std.time.ns_per_s);\n    try startService(allocator);\n}\n\npub fn reloadConfig(allocator: std.mem.Allocator) !void {\n    // Read PID file to find running instance\n    const pid = try readPidFile(allocator) orelse {\n        std.debug.print(\"skhd is not running (no PID file found)\\n\", .{});\n        return error.NotRunning;\n    };\n\n    // Check if process is actually running\n    if (!isProcessRunning(pid)) {\n        std.debug.print(\"skhd is not running (PID {d} not found)\\n\", .{pid});\n        removePidFile(allocator);\n        return error.NotRunning;\n    }\n\n    // Send SIGUSR1 to reload config\n    std.posix.kill(pid, std.posix.SIG.USR1) catch |err| {\n        std.debug.print(\"Failed to send reload signal to PID {d}: {}\\n\", .{ pid, err });\n        return error.SignalFailed;\n    };\n\n    std.debug.print(\"Sent reload signal to skhd (PID {d})\\n\", .{pid});\n}\n\n/// State of the LaunchAgent as known to launchd, from `launchctl list`.\nconst DaemonState = union(enum) {\n    /// Agent isn't loaded into launchd at all (nobody ran --start-service or\n    /// the plist isn't installed).\n    not_loaded,\n    /// Agent is loaded but currently not running — usually means it just\n    /// exited and launchd is waiting for `ThrottleInterval` before respawning.\n    loaded_idle,\n    /// Agent is loaded and the daemon process is alive.\n    running: i32,\n};\n\nfn getDaemonState(allocator: std.mem.Allocator) DaemonState {\n    const argv = [_][]const u8{ \"launchctl\", \"list\" };\n    var child = std.process.Child.init(&argv, allocator);\n    child.stdout_behavior = .Pipe;\n    child.stderr_behavior = .Ignore;\n    child.spawn() catch return .not_loaded;\n\n    var stdout_data = std.ArrayList(u8).init(allocator);\n    defer stdout_data.deinit();\n    if (child.stdout) |stdout| {\n        stdout.reader().readAllArrayList(&stdout_data, 1 << 20) catch return .not_loaded;\n    }\n    _ = child.wait() catch return .not_loaded;\n\n    var lines = std.mem.splitScalar(u8, stdout_data.items, '\\n');\n    while (lines.next()) |line| {\n        if (!std.mem.endsWith(u8, line, \"\\tcom.jackielii.skhd\")) continue;\n        // Format: \"<PID-or-->\\t<exit>\\t<label>\"\n        var fields = std.mem.splitScalar(u8, line, '\\t');\n        const pid_str = fields.next() orelse return .loaded_idle;\n        if (std.mem.eql(u8, pid_str, \"-\")) return .loaded_idle;\n        const pid = std.fmt.parseInt(i32, pid_str, 10) catch return .loaded_idle;\n        if (pid > 0) return .{ .running = pid };\n        return .loaded_idle;\n    }\n    return .not_loaded;\n}\n\nconst EventTapHealth = enum { unknown, working, denied };\n\n/// Darwin sysctl MIB to fetch a single process's kinfo_proc.\n/// Equivalent to: `int mib[] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, pid };`\nconst CTL_KERN: c_int = 1;\nconst KERN_PROC: c_int = 14;\nconst KERN_PROC_PID: c_int = 1;\n\n/// Get the running process's uptime in seconds via the darwin sysctl\n/// `kern.proc.pid.<pid>` interface. The first 8 bytes of `struct\n/// kinfo_proc` are `kp_proc.p_un.__p_starttime.tv_sec` (this layout has\n/// been stable across macOS versions). Avoids spawning `ps`. Returns\n/// null if the process isn't reachable or the call fails.\nfn getProcessUptimeSeconds(pid: i32) ?u64 {\n    var mib = [_]c_int{ CTL_KERN, KERN_PROC, KERN_PROC_PID, @intCast(pid) };\n\n    // kinfo_proc is ~656 bytes on macOS — 1 KiB stack buffer is plenty.\n    var buf: [1024]u8 = undefined;\n    var size: usize = buf.len;\n    if (std.c.sysctl(&mib, mib.len, &buf, &size, null, 0) != 0) return null;\n    if (size < @sizeOf(i64)) return null;\n\n    const tv_sec = std.mem.readInt(i64, buf[0..@sizeOf(i64)], .little);\n    if (tv_sec <= 0) return null;\n\n    const now = std.time.timestamp();\n    if (now < tv_sec) return null;\n    return @intCast(now - tv_sec);\n}\n\n/// Determine whether the daemon's event tap is currently active. Two signals\n/// in priority order:\n///\n/// 1. **Process uptime.** With `KeepAlive=true` and `ThrottleInterval=10`,\n///    a daemon that fails event-tap creation exits within ~5s (10 retries\n///    × 500ms) and is respawned 10s later — so a daemon that has been\n///    alive for >30s necessarily has a working event tap. This is the\n///    primary signal: it works for any build mode without relying on the\n///    daemon emitting success messages (we deliberately keep the log\n///    quiet on the happy path).\n/// 2. **Log tail fallback** for daemons too young for #1 to be conclusive,\n///    or when the daemon is loaded but currently in the throttle window.\n///    Anchored on the *current* daemon's start marker so a stale\n///    \"ACCESSIBILITY PERMISSIONS REQUIRED\" block from a previously\n///    crashed instance doesn't poison the read. Recent \"ACCESSIBILITY\n///    PERMISSIONS REQUIRED\" / \"Event tap creation failed\" entries after\n///    that marker point to denial; success-side patterns are matched for\n///    Debug / ReleaseSafe builds where `log.info` reaches the file.\nfn getEventTapHealth(allocator: std.mem.Allocator, daemon_state: DaemonState) EventTapHealth {\n    if (daemon_state == .running) {\n        if (getProcessUptimeSeconds(daemon_state.running)) |uptime| {\n            if (uptime >= 30) return .working;\n        }\n    }\n\n    const home = std.posix.getenv(\"HOME\") orelse return .unknown;\n    const log_path = std.fmt.allocPrint(allocator, \"{s}/Library/Logs/skhd.log\", .{home}) catch return .unknown;\n    defer allocator.free(log_path);\n\n    const file = std.fs.openFileAbsolute(log_path, .{}) catch return .unknown;\n    defer file.close();\n\n    const stat = file.stat() catch return .unknown;\n    if (stat.size == 0) return .unknown;\n\n    // Read enough of the tail to almost certainly contain the current\n    // daemon's startup marker. 64 KiB covers tens of restart cycles even\n    // with PATH dumps; if the daemon is so young its marker isn't here\n    // yet, we'll just bail out as unknown rather than risk a stale read.\n    const tail_size: u64 = 64 * 1024;\n    const start: u64 = if (stat.size > tail_size) stat.size - tail_size else 0;\n    file.seekTo(start) catch return .unknown;\n\n    const content = file.readToEndAlloc(allocator, tail_size) catch return .unknown;\n    defer allocator.free(content);\n\n    // Find the last `=== skhd … (PID <daemon_pid>) ===` start marker.\n    // Without anchoring on the current daemon's PID, log entries from a\n    // previous crashed run (the cdHash-mismatch case in particular) keep\n    // showing as \"denied\" forever. Scanning only after the marker is the\n    // simplest way to make the status reflect the *current* daemon.\n    const scan_start: usize = scan_start: {\n        if (daemon_state == .running) {\n            var pid_buf: [32]u8 = undefined;\n            const pid_marker = std.fmt.bufPrint(&pid_buf, \"(PID {d})\", .{daemon_state.running}) catch break :scan_start 0;\n            if (std.mem.lastIndexOf(u8, content, pid_marker)) |idx| break :scan_start idx;\n        }\n        // No running daemon to anchor on, or its marker isn't in the tail\n        // window — caller hasn't established whether *this* run is broken,\n        // so don't make claims either way.\n        return .unknown;\n    };\n\n    var lines = std.mem.splitScalar(u8, content[scan_start..], '\\n');\n    var last: EventTapHealth = .unknown;\n    while (lines.next()) |line| {\n        if (std.mem.indexOf(u8, line, \"Event tap created successfully\") != null or\n            std.mem.indexOf(u8, line, \"Event tap created on attempt\") != null)\n        {\n            last = .working;\n        } else if (std.mem.indexOf(u8, line, \"ACCESSIBILITY PERMISSIONS REQUIRED\") != null or\n            std.mem.indexOf(u8, line, \"Event tap creation failed\") != null)\n        {\n            last = .denied;\n        }\n    }\n    return last;\n}\n\npub fn checkServiceStatus(allocator: std.mem.Allocator) !void {\n    const daemon_state = getDaemonState(allocator);\n    const tap_health = getEventTapHealth(allocator, daemon_state);\n\n    // Determine SMAppService registration status. Don't use the legacy\n    // ~/Library/LaunchAgents/<id>.plist file as a marker any more — that\n    // path is empty for SMAppService-managed installs (our 0.0.21+ flow).\n    const sm_service = sm.agentService(LAUNCH_AGENT_PLIST_NAME);\n    const sm_status = if (sm_service) |svc| sm.status(svc) else sm.Status.not_found;\n    const installed = sm_status != .not_registered and sm_status != .not_found;\n\n    std.debug.print(\"skhd service status:\\n\", .{});\n    std.debug.print(\"  Service installed:    {s}\\n\", .{if (installed) \"Yes\" else \"No\"});\n    std.debug.print(\"  Registration status:  {s}\\n\", .{sm_status.describe()});\n\n    switch (daemon_state) {\n        .running => |pid| std.debug.print(\"  Daemon running:       Yes (PID {d})\\n\", .{pid}),\n        .loaded_idle => std.debug.print(\"  Daemon running:       No (loaded, waiting for respawn — see log)\\n\", .{}),\n        .not_loaded => std.debug.print(\"  Daemon running:       No (LaunchAgent not loaded)\\n\", .{}),\n    }\n\n    const tap_label = switch (tap_health) {\n        .working => \"Yes (event tap active)\",\n        .denied => \"No (accessibility denied — see remediation below)\",\n        .unknown => \"Unknown (no recent event-tap activity in log)\",\n    };\n    std.debug.print(\"  Hotkeys functional:   {s}\\n\", .{tap_label});\n\n    // Input Monitoring is the smoking gun for the silent cdHash-mismatch\n    // case: tap_health says working, daemon log shows no errors, but no\n    // events flow because the kTCCServiceListenEvent grant's csreq is\n    // anchored on a stale cdHash. IOHIDCheckAccess returns Denied for that\n    // case. Surface it as a separate status line.\n    const im_access = checkInputMonitoringAccess();\n    const im_label = switch (im_access) {\n        .granted => \"Granted\",\n        .denied => \"Denied (events suppressed — see remediation below)\",\n        .unknown => \"Unknown (will prompt on first key event)\",\n    };\n    std.debug.print(\"  Input Monitoring:     {s}\\n\", .{im_label});\n\n    // HID daemon (Karabiner-DriverKit-VirtualHIDDevice). Required by\n    // skhd-grabber for .remap / .taphold rules. printHidDaemonStatus\n    // emits its own line (and a Karabiner-Elements conflict warning when\n    // detected) and returns the state so we can print remediation below.\n    const hid_state = grabber_cli.printHidDaemonStatus(allocator);\n\n    std.debug.print(\"  Log file:             {s}/Library/Logs/skhd.log\\n\", .{std.posix.getenv(\"HOME\") orelse \"~\"});\n\n    if (!installed) {\n        std.debug.print(\"\\nTo install the service, run: skhd --install-service\\n\", .{});\n        std.debug.print(\"(must be invoked from inside the .app — typically via\\n\", .{});\n        std.debug.print(\" /Applications/skhd.app/Contents/MacOS/skhd --install-service)\\n\", .{});\n    } else if (sm_status == .requires_approval) {\n        std.debug.print(\"\\nMacOS requires approval before the agent can run:\\n\", .{});\n        std.debug.print(\"1. Open System Settings → General → Login Items & Extensions\\n\", .{});\n        std.debug.print(\"2. Find 'skhd' under 'Allow in the Background' and toggle it on\\n\", .{});\n        std.debug.print(\"3. Then run: skhd --restart-service\\n\", .{});\n    } else if (daemon_state == .not_loaded) {\n        std.debug.print(\"\\nTo start the service, run: skhd --start-service\\n\", .{});\n    } else if (tap_health == .denied) {\n        std.debug.print(\"\\nTo grant accessibility permissions:\\n\", .{});\n        std.debug.print(\"1. Open System Settings → Privacy & Security → Accessibility\\n\", .{});\n        std.debug.print(\"2. Click '+' and add /Applications/skhd.app (the .app shows\\n\", .{});\n        std.debug.print(\"   up in the list, unlike a bare binary)\\n\", .{});\n        std.debug.print(\"3. Toggle the entry on\\n\", .{});\n        std.debug.print(\"4. Run: skhd --restart-service\\n\", .{});\n        std.debug.print(\n            \\\\\n            \\\\If the entry already shows as granted in System Settings but\n            \\\\events still don't flow (typical after `brew upgrade` on macOS\n            \\\\Tahoe — the cached csreq is anchored to the previous binary's\n            \\\\cdHash), drop the stale grant and re-grant from scratch:\n            \\\\\n            \\\\  tccutil reset ListenEvent com.jackielii.skhd\n            \\\\  tccutil reset Accessibility com.jackielii.skhd\n            \\\\  skhd --restart-service     # then re-grant via System Settings\n            \\\\\n            \\\\For more troubleshooting, see docs/CODE_SIGNING.md.\n            \\\\\n        , .{});\n    } else if (im_access == .denied) {\n        // Reached when Accessibility is fine and the daemon looks healthy\n        // — but the IM grant's csreq is still stale (the most common\n        // post-`brew upgrade` failure mode, and the one tap_health can't\n        // see because the tap creates successfully).\n        std.debug.print(\n            \\\\\n            \\\\Input Monitoring is denied for com.jackielii.skhd, so key-down\n            \\\\events are silently dropped before reaching skhd's event tap.\n            \\\\This usually means TCC's stored grant is anchored on a stale\n            \\\\cdHash from a previous build (every brew upgrade / rebuild\n            \\\\changes the cdHash). System Settings still shows it granted.\n            \\\\\n            \\\\Reset and re-grant:\n            \\\\  tccutil reset ListenEvent com.jackielii.skhd\n            \\\\  skhd --restart-service\n            \\\\  # press any hotkey — macOS prompts for Input Monitoring; approve\n            \\\\\n            \\\\The fresh grant anchors on the cert root and survives future\n            \\\\upgrades.\n            \\\\\n        , .{});\n    }\n\n    // HID daemon remediation runs after the agent-side chain so it\n    // doesn't bury an Accessibility / IM problem the user has to fix\n    // first. Only print it when the grabber is actually installed —\n    // for users who never set up `.remap` / `.taphold`, \"Not installed\"\n    // is an informational line, not an actionable problem.\n    if (grabber_cli.isGrabberInstalled()) {\n        if (hid_state != .running) {\n            grabber_cli.printHidDaemonRemediation(hid_state);\n        } else if (grabber_cli.readHidDaemonVersion(allocator)) |installed_dext| {\n            defer allocator.free(installed_dext);\n            const compat = grabber_cli.compareVersions(installed_dext, grabber_cli.pinned_dext_version);\n            grabber_cli.printVersionMismatchRemediation(installed_dext, compat);\n        }\n    }\n}\n\n"
  },
  {
    "path": "src/skhd.zig",
    "content": "const std = @import(\"std\");\nconst builtin = @import(\"builtin\");\n\nconst c = @import(\"c.zig\");\nconst agent_grabber_client = @import(\"agent_grabber_client.zig\");\nconst agent_layer_listener = @import(\"agent_layer_listener.zig\");\nconst CarbonEvent = @import(\"CarbonEvent.zig\");\nconst EventTap = @import(\"EventTap.zig\");\nconst forkAndExec = @import(\"exec.zig\").forkAndExec;\nconst grabber_protocol = @import(\"grabber_protocol\");\nconst DeviceCheck = @import(\"DeviceCheck.zig\");\nconst Hidutil = @import(\"Hidutil.zig\");\nconst Hotkey = @import(\"Hotkey.zig\");\nconst Hotload = @import(\"Hotload.zig\");\nconst Keycodes = @import(\"Keycodes.zig\");\nconst ModifierFlag = Keycodes.ModifierFlag;\nconst Mappings = @import(\"Mappings.zig\");\nconst Mode = @import(\"Mode.zig\");\nconst Parser = @import(\"Parser.zig\");\nconst service = @import(\"service.zig\");\nconst Tracer = @import(\"Tracer.zig\");\n\n// Use scoped logging for skhd module\nconst log = std.log.scoped(.skhd);\nconst Skhd = @This();\n\n// Global reference for signal handler\nvar global_skhd: ?*Skhd = null;\nvar reload_requested: std.atomic.Value(bool) = .init(false);\nvar stop_requested: std.atomic.Value(bool) = .init(false);\nvar hotload_refresh_pending: std.atomic.Value(bool) = .init(false);\n\n// Result of processing a hotkey\nconst HotkeyResult = enum {\n    consumed, // Hotkey handled, consume the event\n    passthrough, // Hotkey found but marked as passthrough/unbound\n    not_found, // No matching hotkey\n};\n\nallocator: std.mem.Allocator,\nmappings: Mappings,\ncurrent_mode: ?*Mode = null,\nevent_tap: EventTap,\nconfig_file: []const u8,\nverbose: bool,\nhotloader: ?*Hotload = null,\nhotload_enabled: bool = false,\ntracer: Tracer,\ncarbon_event: *CarbonEvent,\nwatchdog_timer: c.CFRunLoopTimerRef = null,\n/// Persistent IPC connection to skhd-grabber (for layer-hold pushes).\n/// null when there are no caps-class rules, or when we couldn't dial\n/// the grabber. The Client owns the socket fd; Listener wraps it as a\n/// CFRunLoop source. Both freed on deinit.\ngrabber_client: ?*agent_grabber_client.Client = null,\nlayer_listener: ?*agent_layer_listener.Listener = null,\n/// Periodic retry timer for re-dialing the grabber after a\n/// disconnect. Null while connected (or when no rules need\n/// forwarding); created on disconnect, cancelled on successful\n/// forward.\ngrabber_reconnect_timer: c.CFRunLoopTimerRef = null,\n/// Per-device `hidutil` UserKeyMapping owner. Allocated only when the\n/// config has at least one colon-form `.remap`. On deinit (graceful or\n/// signal), restoreAll() clears the OS-level mapping so the keyboard\n/// returns to default.\nhidutil: ?*Hidutil = null,\n\npub fn init(gpa: std.mem.Allocator, config_file: []const u8, verbose: bool, profile: bool) !Skhd {\n    log.info(\"Initializing skhd with config: {s}\", .{config_file});\n\n    var mappings = try Mappings.init(gpa);\n    errdefer mappings.deinit();\n\n    // Parse configuration file\n    var parser = try Parser.init(gpa);\n    defer parser.deinit();\n\n    const content = try std.fs.cwd().readFileAlloc(gpa, config_file, 1 << 20); // 1MB max\n    defer gpa.free(content);\n\n    parser.parseWithPath(&mappings, content, config_file) catch |err| {\n        if (parser.error_info) |parse_err| {\n            log.err(\"skhd: {}\", .{parse_err});\n        }\n        return err;\n    };\n\n    // Process any .load directives. Surface include-file parse\n    // errors with their file:line, same as the top-level file.\n    parser.processLoadDirectives(&mappings) catch |err| {\n        if (parser.error_info) |parse_err| {\n            log.err(\"skhd: {}\", .{parse_err});\n        }\n        return err;\n    };\n\n    // Initialize with default mode if exists\n    var current_mode: ?*Mode = null;\n    if (mappings.mode_map.getPtr(\"default\")) |default_mode| {\n        current_mode = default_mode;\n    }\n\n    // Log loaded modes\n    var mode_iter = mappings.mode_map.iterator();\n    var modes_list = std.ArrayList(u8).init(gpa);\n    defer modes_list.deinit();\n    while (mode_iter.next()) |entry| {\n        try modes_list.writer().print(\"'{s}' \", .{entry.key_ptr.*});\n    }\n    log.info(\"Loaded modes: {s}\", .{modes_list.items});\n\n    // Log shell configuration\n    log.info(\"Using shell: {s}\", .{mappings.shell});\n\n    // Initialize Carbon event handler for app switching\n    var carbon_event = try CarbonEvent.init(gpa);\n    errdefer carbon_event.deinit();\n\n    log.info(\"Initial process: {s}\", .{carbon_event.getProcessName()});\n\n    // Lazy-init Hidutil only if any colon-form `.remap` declarations\n    // exist. Crash recovery runs first so a previous instance's stale\n    // UserKeyMapping is cleared before we apply our own.\n    var hidutil: ?*Hidutil = null;\n    if (mappings.remaps.items.len > 0) {\n        hidutil = Hidutil.init(gpa) catch |err| blk: {\n            log.warn(\"Hidutil init failed: {s}. .remap colon-form ignored.\", .{@errorName(err)});\n            break :blk null;\n        };\n        if (hidutil) |h| {\n            h.recoverFromCrash() catch |err| {\n                log.warn(\"Hidutil crash recovery failed: {s}. Continuing.\", .{@errorName(err)});\n            };\n            log.info(\"Hidutil initialized for {d} remap declaration(s)\", .{mappings.remaps.items.len});\n        }\n    }\n    errdefer if (hidutil) |h| h.deinit();\n\n    // Create event tap with keyboard, system-defined, and mouse-down events.\n    // Mouse-down is opt-in only via `mouse1`–`mouse5` bindings, but the tap\n    // mask is set unconditionally — `processHotkey` returns `.not_found` for\n    // un-bound mouse events and we pass them through, so an unused mask bit\n    // costs only a couple of dispatches per click.\n    const mask: u32 = (1 << c.kCGEventKeyDown) | (1 << c.NX_SYSDEFINED) //\n    | (1 << c.kCGEventLeftMouseDown) //\n    | (1 << c.kCGEventRightMouseDown) //\n    | (1 << c.kCGEventOtherMouseDown);\n\n    return Skhd{\n        .allocator = gpa,\n        .mappings = mappings,\n        .current_mode = current_mode,\n        .event_tap = EventTap{ .mask = mask },\n        .config_file = try gpa.dupe(u8, config_file),\n        .verbose = verbose,\n        .tracer = Tracer.init(profile),\n        .carbon_event = carbon_event,\n        .hidutil = hidutil,\n    };\n}\n\npub fn deinit(self: *Skhd) void {\n    // Print tracer summary before cleanup\n    if (self.tracer.enabled) {\n        const stderr = std.io.getStdErr().writer();\n        self.tracer.printSummary(stderr) catch {};\n    }\n\n    // Clear hidutil UserKeyMapping FIRST so the user's keyboard isn't\n    // left remapped if anything below errors. Idempotent — no-op when\n    // applyRemaps was never called.\n    if (self.hidutil) |h| {\n        h.restoreAll();\n        h.deinit();\n        self.hidutil = null;\n    }\n\n    if (self.hotloader) |hotloader| {\n        hotloader.destroy();\n    }\n    self.stopWatchdog();\n    self.cancelGrabberReconnect();\n    if (self.layer_listener) |ll| ll.deinit();\n    if (self.grabber_client) |gc| {\n        gc.close();\n        self.allocator.destroy(gc);\n    }\n    self.carbon_event.deinit();\n    self.event_tap.deinit();\n    self.mappings.deinit();\n    self.allocator.free(self.config_file);\n}\n\n/// Translate parsed `.remap { tap, hold, ... }` rules into the\n/// IPC schema and push them to skhd-grabber. Looks up each rule's\n/// device alias to attach the (vendor, product) match the grabber\n/// uses for IOHIDManager. Layer-hold rules carry `hold_layer` set\n/// to a mode name; HID-key holds carry `hold_usage` set instead.\n///\n/// If any rule is a layer rule, the agent keeps the IPC connection\n/// open and registers a CFFileDescriptor source so the grabber can\n/// push `mode_change` messages back when the layer hold commits or\n/// releases. Otherwise the connection is closed after `bye`.\n///\n/// \"Cannot reach grabber\" is downgraded to a warning by the caller —\n/// users without `skhd --install-grabber` still get the rest of\n/// their config running.\n/// Read NSGlobalDomain `com.apple.keyboard.fnState` (the \"Use F1, F2 …\n/// as standard function keys\" toggle in System Settings → Keyboard).\n/// false = bare F-row keys send media actions (Apple's default), true\n/// = bare F-row keys send literal F<i>. Forwarded to the grabber so\n/// its F-row translation policy matches the user's setting.\n///\n/// Read from the agent because the agent runs as the logged-in user;\n/// the root grabber would otherwise need to attach to a per-uid\n/// preference domain. Defaults to false (the OS default) on any\n/// failure — a missing/unreadable pref is exactly what an unset toggle\n/// looks like.\nfn readFkeysAsStandardPref() bool {\n    const key = c.CFStringCreateWithCString(\n        c.kCFAllocatorDefault,\n        \"com.apple.keyboard.fnState\",\n        c.kCFStringEncodingUTF8,\n    );\n    if (key == null) return false;\n    defer c.CFRelease(key);\n\n    const value = c.CFPreferencesCopyAppValue(key, c.kCFPreferencesAnyApplication);\n    if (value == null) return false;\n    defer c.CFRelease(value);\n\n    if (c.CFGetTypeID(value) != c.CFBooleanGetTypeID()) return false;\n    return c.CFBooleanGetValue(@ptrCast(value)) != 0;\n}\n\nfn forwardTapholdsToGrabber(self: *Skhd) !void {\n    if (self.mappings.tapholds.items.len == 0 and self.mappings.remaps.items.len == 0) return;\n\n    // Build a presence cache keyed by device alias so we don't enumerate\n    // HID twice for the same alias. A rule whose device isn't connected\n    // is silently dropped — the grabber would log \"matched 0 devices\"\n    // and the user-facing UX would be a \"grabber not running\" warning\n    // on machines (e.g. a Mac Studio) that share the config but lack\n    // the targeted built-in keyboard.\n    var present = std.StringHashMap(bool).init(self.allocator);\n    defer present.deinit();\n\n    const aliasPresent = struct {\n        fn check(\n            mappings: *const Mappings,\n            cache: *std.StringHashMap(bool),\n            alias_name: []const u8,\n        ) bool {\n            if (cache.get(alias_name)) |v| return v;\n            const alias = mappings.device_aliases.get(alias_name) orelse return false;\n            const ok = DeviceCheck.isPresent(alias.vendor, alias.product);\n            cache.put(alias_name, ok) catch {};\n            return ok;\n        }\n    }.check;\n\n    var rules = try std.ArrayList(grabber_protocol.Rule).initCapacity(self.allocator, self.mappings.tapholds.items.len);\n    defer rules.deinit();\n    var remaps = try std.ArrayList(grabber_protocol.Remap).initCapacity(self.allocator, self.mappings.remaps.items.len);\n    defer remaps.deinit();\n\n    var has_layer_rule = false;\n    var skipped_absent: usize = 0;\n\n    for (self.mappings.tapholds.items) |th| {\n        const alias = self.mappings.device_aliases.get(th.device_alias) orelse {\n            log.warn(\"taphold for src=0x{X:0>2}: device alias '{s}' not in alias map (skip)\", .{ th.src_usage, th.device_alias });\n            continue;\n        };\n        if (!aliasPresent(&self.mappings, &present, th.device_alias)) {\n            skipped_absent += 1;\n            continue;\n        }\n        if (th.hold_layer != null) has_layer_rule = true;\n        try rules.append(.{\n            .src_usage = th.src_usage,\n            .tap_usage = th.tap_usage,\n            .hold_usage = th.hold_usage,\n            .hold_layer = th.hold_layer,\n            .device = .{ .vendor = alias.vendor, .product = alias.product },\n            .timeout_ms = th.timeout_ms,\n            .permissive_hold = th.permissive_hold,\n            .hold_on_other_key_press = th.hold_on_other_key_press,\n            .retro_tap = th.retro_tap,\n        });\n    }\n\n    for (self.mappings.remaps.items) |rm| {\n        const alias = self.mappings.device_aliases.get(rm.device_alias) orelse {\n            log.warn(\"remap for src=0x{X:0>2}: device alias '{s}' not in alias map (skip)\", .{ rm.src_usage, rm.device_alias });\n            continue;\n        };\n        if (!aliasPresent(&self.mappings, &present, rm.device_alias)) {\n            skipped_absent += 1;\n            continue;\n        }\n        try remaps.append(.{\n            .src_usage = rm.src_usage,\n            .dst_usage = rm.dst_usage,\n            .device = .{ .vendor = alias.vendor, .product = alias.product },\n        });\n    }\n\n    if (skipped_absent > 0) {\n        log.info(\"skipped {d} grabber rule(s) — target device not connected\", .{skipped_absent});\n    }\n    if (rules.items.len == 0 and remaps.items.len == 0) return;\n\n    const fkeys_as_standard = readFkeysAsStandardPref();\n\n    log.info(\n        \"forwarding {d} tap-hold rule(s) and {d} remap(s) to skhd-grabber at {s} (layer_listen={} fkeys_as_standard={})\",\n        .{ rules.items.len, remaps.items.len, grabber_protocol.default_socket_path, has_layer_rule, fkeys_as_standard },\n    );\n\n    const client = try self.allocator.create(agent_grabber_client.Client);\n    errdefer self.allocator.destroy(client);\n    client.* = try agent_grabber_client.Client.connect(self.allocator, grabber_protocol.default_socket_path);\n    errdefer client.close();\n\n    try client.hello();\n    try client.applyRules(rules.items, remaps.items, fkeys_as_standard);\n\n    // Always keep the connection open + watch it for EOS, regardless\n    // of whether this config has layer rules. The grabber's per-\n    // connection rule tracking relies on EOS detection to drop a\n    // dead agent's rules; if we close immediately when there are no\n    // layer rules, the grabber would assume we're alive forever and\n    // never fall back when this agent dies.\n    self.grabber_client = client;\n    self.layer_listener = try agent_layer_listener.Listener.init(\n        self.allocator,\n        client.stream.handle,\n        modeChangePushed,\n        self,\n    );\n    self.layer_listener.?.on_disconnect = grabberDisconnected;\n    self.layer_listener.?.on_disconnect_ctx = self;\n    if (has_layer_rule) {\n        log.info(\"grabber acknowledged {d} rule(s); layer listener installed\", .{rules.items.len});\n    } else {\n        log.info(\"grabber acknowledged {d} rule(s); listener active for EOS\", .{rules.items.len});\n    }\n    // Successful forward: cancel any pending reconnect timer from a\n    // prior outage.\n    self.cancelGrabberReconnect();\n}\n\n/// Run-loop callback fired by `agent_layer_listener` whenever the\n/// grabber pushes a mode_change. Empty `mode_name` means \"exit current\n/// layer back to default\".\nfn modeChangePushed(ctx: ?*anyopaque, mode_name: []const u8) void {\n    const self: *Skhd = @ptrCast(@alignCast(ctx orelse return));\n    if (mode_name.len == 0) {\n        if (self.mappings.mode_map.getPtr(\"default\")) |m| {\n            self.current_mode = m;\n            log.info(\"layer push: exited to default\", .{});\n        } else {\n            self.current_mode = null;\n        }\n        return;\n    }\n    if (self.mappings.mode_map.getPtr(mode_name)) |m| {\n        self.current_mode = m;\n        log.info(\"layer push: entered '{s}'\", .{mode_name});\n    } else {\n        log.warn(\"layer push: unknown mode '{s}'\", .{mode_name});\n    }\n}\n\n/// Poll AXIsProcessTrusted on a 1s timer and reconcile the event tap with\n/// what TCC currently allows. The OS doesn't fire kCGEventTapDisabledBy*\n/// when accessibility is revoked at runtime, so the disabled-branch alone\n/// can't catch a revoke — the tap stays in the event chain as an active\n/// filter that swallows keystrokes. This watchdog is the only reliable\n/// signal: detect revoke and tear the tap down, detect re-grant and\n/// recreate it. AXIsProcessTrusted is cached and ~µs, so 1s polling is\n/// negligible overhead and never touches the per-event hot path.\nfn startWatchdog(self: *Skhd) void {\n    if (self.watchdog_timer != null) return;\n    var ctx = c.CFRunLoopTimerContext{\n        .version = 0,\n        .info = self,\n        .retain = null,\n        .release = null,\n        .copyDescription = null,\n    };\n    const interval: f64 = 1.0;\n    const fire_at = c.CFAbsoluteTimeGetCurrent() + interval;\n    self.watchdog_timer = c.CFRunLoopTimerCreate(\n        c.kCFAllocatorDefault,\n        fire_at,\n        interval,\n        0,\n        0,\n        watchdogCallback,\n        &ctx,\n    );\n    if (self.watchdog_timer == null) {\n        log.err(\"Failed to create accessibility watchdog timer\", .{});\n        return;\n    }\n    c.CFRunLoopAddTimer(c.CFRunLoopGetMain(), self.watchdog_timer, c.kCFRunLoopCommonModes);\n}\n\nfn stopWatchdog(self: *Skhd) void {\n    if (self.watchdog_timer) |t| {\n        c.CFRunLoopTimerInvalidate(t);\n        c.CFRelease(t);\n        self.watchdog_timer = null;\n    }\n}\n\n/// Listener-side disconnect callback. Tear down the dead client +\n/// listener so the next reconnect attempt starts clean, then schedule\n/// a retry timer.\nfn grabberDisconnected(ctx: ?*anyopaque) void {\n    const self = @as(*Skhd, @ptrCast(@alignCast(ctx orelse return)));\n    if (self.layer_listener) |ll| {\n        ll.deinit();\n        self.layer_listener = null;\n    }\n    if (self.grabber_client) |gc| {\n        gc.close();\n        self.allocator.destroy(gc);\n        self.grabber_client = null;\n    }\n    self.scheduleGrabberReconnect();\n}\n\nfn scheduleGrabberReconnect(self: *Skhd) void {\n    if (self.grabber_reconnect_timer != null) return;\n    var ctx = c.CFRunLoopTimerContext{\n        .version = 0,\n        .info = self,\n        .retain = null,\n        .release = null,\n        .copyDescription = null,\n    };\n    // 2-second cadence — grabber respawn under launchd is typically\n    // <1s, manual restart (`zig build run-grabber` Ctrl+C cycle)\n    // a few seconds. Repeats until forward succeeds.\n    const interval: f64 = 2.0;\n    const fire_at = c.CFAbsoluteTimeGetCurrent() + interval;\n    self.grabber_reconnect_timer = c.CFRunLoopTimerCreate(\n        c.kCFAllocatorDefault,\n        fire_at,\n        interval,\n        0,\n        0,\n        grabberReconnectCallback,\n        &ctx,\n    );\n    if (self.grabber_reconnect_timer == null) {\n        log.warn(\"could not create grabber reconnect timer; rules will only be forwarded on next reload\", .{});\n        return;\n    }\n    c.CFRunLoopAddTimer(c.CFRunLoopGetMain(), self.grabber_reconnect_timer, c.kCFRunLoopCommonModes);\n    log.info(\"grabber connection lost — retrying every {d}s\", .{@as(u32, @intFromFloat(interval))});\n}\n\nfn cancelGrabberReconnect(self: *Skhd) void {\n    if (self.grabber_reconnect_timer) |t| {\n        c.CFRunLoopTimerInvalidate(t);\n        c.CFRelease(t);\n        self.grabber_reconnect_timer = null;\n    }\n}\n\nfn grabberReconnectCallback(_: c.CFRunLoopTimerRef, info: ?*anyopaque) callconv(.c) void {\n    const self = @as(*Skhd, @ptrCast(@alignCast(info orelse return)));\n    self.forwardTapholdsToGrabber() catch {\n        // Forward failed (likely \"socket not found\" or\n        // \"connection refused\"). Timer stays armed, will fire again.\n        return;\n    };\n    // forwardTaphold... already calls cancelGrabberReconnect on success.\n}\n\nfn watchdogCallback(_: c.CFRunLoopTimerRef, info: ?*anyopaque) callconv(.c) void {\n    const self = @as(*Skhd, @ptrCast(@alignCast(info)));\n\n    if (stop_requested.swap(false, .acq_rel)) {\n        log.info(\"Received stop request, stopping run loop\", .{});\n        c.CFRunLoopStop(c.CFRunLoopGetCurrent());\n        return;\n    }\n\n    if (reload_requested.swap(false, .acq_rel)) {\n        log.info(\"Received SIGUSR1, reloading configuration\", .{});\n        self.reloadConfig() catch |err| {\n            log.err(\"Failed to reload config: {}\", .{err});\n        };\n    }\n    self.processPendingHotReloadRefresh();\n\n    const trusted = service.hasAccessibilityPermissions();\n    const have_tap = self.event_tap.handle != null;\n\n    if (!trusted and have_tap) {\n        log.err(\"Accessibility revoked — detaching event tap so the keyboard stays responsive.\", .{});\n        self.event_tap.deinit();\n        if (self.event_tap.handle != null) {\n            log.err(\"Event tap detach left handle non-null; keyboard may still be captured.\", .{});\n        } else {\n            log.info(\"Event tap detached. Watchdog will recreate it when accessibility is re-granted.\", .{});\n        }\n        return;\n    }\n    if (trusted and !have_tap) {\n        log.info(\"Accessibility re-granted — recreating event tap.\", .{});\n        self.event_tap.begin(keyHandler, self) catch |err| {\n            log.err(\"Failed to recreate event tap after re-grant: {} — will retry on next watchdog tick.\", .{err});\n            return;\n        };\n        log.info(\"Event tap reactivated. Hotkeys restored.\", .{});\n    }\n}\n\npub fn run(self: *Skhd, enable_hotload: bool) !void {\n    // Set up signal handler for config reload\n    const usr1_act = std.posix.Sigaction{\n        .handler = .{ .handler = handleSigusr1 },\n        .mask = std.posix.empty_sigset,\n        .flags = 0,\n    };\n    std.posix.sigaction(std.posix.SIG.USR1, &usr1_act, null);\n\n    // Set up signal handler for SIGINT (Ctrl+C) to print trace summary\n    const int_act = std.posix.Sigaction{\n        .handler = .{ .handler = handleSigint },\n        .mask = std.posix.empty_sigset,\n        .flags = 0,\n    };\n    std.posix.sigaction(std.posix.SIG.INT, &int_act, null);\n\n    // Store a global reference for the signal handler\n    global_skhd = self;\n\n    // Now that we hold a stable address for `self`, dial skhd-grabber\n    // and forward any caps-class rules. We defer this from init() to\n    // here so:\n    //  (1) the layer listener can use `self` as its callback context,\n    //  (2) the listener registers on the same CFRunLoop the event\n    //      tap is about to attach to.\n    //\n    // Propagate the error: forwardTapholdsToGrabber returns early when\n    // the config has no caps-class rules (or none whose target devices\n    // are connected), so an error here means the config genuinely needs\n    // the grabber and we couldn't reach it. Better to exit and let\n    // launchd respawn us — by then the grabber daemon should be up —\n    // than silently run with the caps-class rules disabled.\n    self.forwardTapholdsToGrabber() catch |err| {\n        log.err(\"config requires skhd-grabber but forward failed: {s} — exiting so launchd can retry\", .{@errorName(err)});\n        return err;\n    };\n\n    // Check if config file is a regular file\n    const stat = std.fs.cwd().statFile(self.config_file) catch |err| {\n        log.err(\"Cannot stat config file {s}: {}\", .{ self.config_file, err });\n        return err;\n    };\n\n    if (stat.kind != .file) {\n        log.err(\"Config file {s} is not a regular file\", .{self.config_file});\n        return error.InvalidConfigFile;\n    }\n\n    // Enable hot reload if requested (must be done before run loop starts)\n    if (enable_hotload) {\n        try self.enableHotReload();\n    }\n\n    // Set up event tap (but don't start run loop yet). Only ask the OS to\n    // show the Accessibility prompt when running as a launchd-managed daemon\n    // (SMAppService). Foreground runs (`zig build run`, `zig build alloc\n    // -- -V`, etc.) just check silently — popping a system dialog every\n    // time you iterate on a debug build is noise, and Tahoe's TCC\n    // mis-displays the path anyway when self-signed dev/prod bundles share\n    // a `com.jackielii.skhd*` identifier prefix.\n    const main = @import(\"main.zig\");\n    const is_daemon = main.isLaunchdManaged();\n    const trusted = if (is_daemon)\n        service.promptForAccessibility()\n    else\n        service.hasAccessibilityPermissions();\n    if (!trusted) {\n        log.warn(\"Accessibility not granted for this bundle. {s}\", .{\n            if (is_daemon)\n                \"System Settings should now show the prompt.\"\n            else\n                \"Foreground run — grant manually in System Settings → Privacy & Security → Accessibility, or run the daemon (install-local) to get the prompt.\",\n        });\n    }\n    // Note: Input Monitoring prompt deliberately NOT triggered here.\n    // IOHIDRequestAccess blocks the calling thread until the user clicks\n    // Allow/Deny (\"The user response is required before this function\n    // returns\" — Apple docs). Calling it from agent startup hangs the\n    // daemon's main thread, which in turn hangs `launchctl bootstrap`\n    // (and thus `zig build install-local`). The IM prompt fires from the\n    // interactive `--install-service` flow instead, where the user is at\n    // a terminal and expects dialogs.\n    log.info(\"Starting event tap\", .{});\n    self.event_tap.begin(keyHandler, self) catch |err| {\n        if (err == error.AccessibilityPermissionDenied) {\n            const raw_path: ?[]u8 = std.fs.selfExePathAlloc(self.allocator) catch null;\n            defer if (raw_path) |p| self.allocator.free(p);\n            const stable_path: ?[]const u8 = if (raw_path) |p|\n                service.resolveStableExePath(self.allocator, p) catch null\n            else\n                null;\n            defer if (stable_path) |p| self.allocator.free(p);\n            const bundle_path: ?[]const u8 = if (stable_path) |p|\n                service.resolveBundlePath(self.allocator, p) catch null\n            else\n                null;\n            defer if (bundle_path) |p| self.allocator.free(p);\n            const display_path = bundle_path orelse stable_path orelse raw_path orelse \"/Applications/skhd.app\";\n\n            log.err(\n                \\\\\n                \\\\=====================================================\n                \\\\ACCESSIBILITY PERMISSIONS REQUIRED\n                \\\\=====================================================\n                \\\\skhd needs accessibility permissions to capture hotkeys.\n                \\\\\n                \\\\1. Open System Settings → Privacy & Security → Accessibility\n                \\\\2. Click '+' and add: {s}\n                \\\\3. Toggle the entry on\n                \\\\4. Run: skhd --restart-service\n                \\\\\n                \\\\Troubleshooting:\n                \\\\- macOS Tahoe hides bare-binary entries from the Accessibility\n                \\\\  list. If the path above is not a .app, install the app\n                \\\\  bundle (`brew upgrade skhd-zig`) so the entry is visible\n                \\\\  and toggleable, then re-run --install-service.\n                \\\\- If skhd was working before and stopped after a `brew\n                \\\\  upgrade` (or any binary swap), the TCC entry likely shows\n                \\\\  as granted but its csreq is anchored to the previous\n                \\\\  cdHash. Reset and re-grant:\n                \\\\    tccutil reset ListenEvent com.jackielii.skhd\n                \\\\    tccutil reset Accessibility com.jackielii.skhd\n                \\\\    skhd --restart-service   # then re-grant in Settings\n                \\\\  See docs/CODE_SIGNING.md for the full troubleshooting\n                \\\\  section.\n                \\\\=====================================================\n                \\\\\n            , .{display_path});\n        }\n        return err;\n    };\n\n    // Call NSApplicationLoad() like the original skhd\n    c.NSApplicationLoad();\n\n    log.info(\"Event tap created successfully. skhd is now running.\", .{});\n\n    // Apply hidutil remaps AFTER the event tap is up — the OS starts\n    // delivering remapped keys immediately, and we want our tap\n    // intercepting the translated stream.\n    if (self.hidutil) |h| {\n        h.applyRemaps(&self.mappings) catch |err| {\n            log.err(\"Failed to apply hidutil remaps: {s}. Colon-form .remap rules will not take effect.\", .{@errorName(err)});\n        };\n    }\n\n    // Watchdog reconciles the tap with TCC state every 1s. Catches runtime\n    // accessibility revoke (which the OS doesn't surface via the disabled\n    // callback) and re-grant.\n    self.startWatchdog();\n\n    // Now start the run loop - this will handle both event tap and FSEvents\n    c.CFRunLoopRun();\n\n    // If we get here, the run loop has exited\n    log.info(\"Run loop exited\", .{});\n}\n\nfn keyHandler(proxy: c.CGEventTapProxy, typ: c.CGEventType, event: c.CGEventRef, user_info: ?*anyopaque) callconv(.c) c.CGEventRef {\n    _ = proxy;\n\n    const self = @as(*Skhd, @ptrCast(@alignCast(user_info)));\n    self.tracer.traceKeyEvent();\n\n    switch (typ) {\n        c.kCGEventTapDisabledByTimeout, c.kCGEventTapDisabledByUserInput => {\n            // Legitimate timeout / user-input throttle: re-enable. Runtime\n            // accessibility revoke does not reach this branch (the OS stops\n            // delivering callbacks instead of sending the disabled event) —\n            // the watchdog timer catches that case.\n            log.info(\"Restarting event-tap (typ={d})\", .{typ});\n            c.CGEventTapEnable(self.event_tap.handle, true);\n            if (!c.CGEventTapIsEnabled(self.event_tap.handle)) {\n                log.err(\"CGEventTapEnable did not bring the tap back; watchdog will detach it on the next tick.\", .{});\n            }\n            return event;\n        },\n        c.kCGEventKeyDown => {\n            self.tracer.traceKeyDown();\n            return self.handleKeyDown(event) catch |err| {\n                log.err(\"Error handling key down: {}\", .{err});\n                return event;\n            };\n        },\n        c.NX_SYSDEFINED => {\n            self.tracer.traceSystemKey();\n            return self.handleSystemKey(event) catch |err| {\n                log.err(\"Error handling system key: {}\", .{err});\n                return event;\n            };\n        },\n        c.kCGEventLeftMouseDown => return self.handleMouseDown(event, 1) catch |err| {\n            log.err(\"Error handling mouse down: {}\", .{err});\n            return event;\n        },\n        c.kCGEventRightMouseDown => return self.handleMouseDown(event, 2) catch |err| {\n            log.err(\"Error handling mouse down: {}\", .{err});\n            return event;\n        },\n        c.kCGEventOtherMouseDown => {\n            // CGMouseEventButtonNumber is 0-based: 0=left, 1=right, 2=middle,\n            // 3=back, 4=forward. Left/right come through their own event\n            // types, so here we expect button >= 2 → mouse3..mouse5+.\n            const btn_raw = c.CGEventGetIntegerValueField(event, c.kCGMouseEventButtonNumber);\n            if (btn_raw < 2 or btn_raw > 4) return event;\n            const mouse_n: u8 = @intCast(btn_raw + 1);\n            return self.handleMouseDown(event, mouse_n) catch |err| {\n                log.err(\"Error handling mouse down: {}\", .{err});\n                return event;\n            };\n        },\n        else => return event,\n    }\n}\n\ninline fn handleKeyDown(self: *Skhd, event: c.CGEventRef) !c.CGEventRef {\n    if (self.current_mode == null) {\n        self.tracer.traceNoModeExit();\n        return event;\n    }\n\n    // Skip events that we generated ourselves to avoid loops\n    const marker = c.CGEventGetIntegerValueField(event, c.kCGEventSourceUserData);\n    if (marker == SKHD_EVENT_MARKER) {\n        self.tracer.traceSelfGeneratedExit();\n        return event;\n    }\n\n    // Check if current application is blacklisted (using cached name)\n    self.tracer.traceProcessNameLookup();\n    const process_name = self.carbon_event.getProcessName();\n\n    if (self.mappings.blacklist.contains(process_name)) {\n        self.tracer.traceBlacklistedExit();\n        return event;\n    }\n\n    const eventkey = createEventKey(event);\n    const result = try self.processHotkey(&eventkey, event, process_name);\n    return try self.handleHotkeyResult(result, event, eventkey, process_name);\n}\n\ninline fn handleMouseDown(self: *Skhd, event: c.CGEventRef, mouse_n: u8) !c.CGEventRef {\n    if (self.current_mode == null) return event;\n\n    // Skip self-generated events.\n    const marker = c.CGEventGetIntegerValueField(event, c.kCGEventSourceUserData);\n    if (marker == SKHD_EVENT_MARKER) return event;\n\n    const process_name = self.carbon_event.getProcessName();\n    if (self.mappings.blacklist.contains(process_name)) return event;\n\n    const eventkey = Hotkey.KeyPress{\n        .key = Keycodes.mouseButtonCode(mouse_n),\n        .flags = cgeventFlagsToHotkeyFlags(c.CGEventGetFlags(event)),\n    };\n    const result = try self.processHotkey(&eventkey, event, process_name);\n    return try self.handleHotkeyResult(result, event, eventkey, process_name);\n}\n\n/// Process the result of a hotkey lookup and determine what to do with the event\ninline fn handleHotkeyResult(self: *Skhd, result: HotkeyResult, event: c.CGEventRef, eventkey: Hotkey.KeyPress, process_name: []const u8) !c.CGEventRef {\n    switch (result) {\n        .consumed => return @ptrFromInt(0),\n        .passthrough => return event,\n        .not_found => {\n            // Check if current mode has capture enabled\n            if (self.current_mode) |mode| {\n                if (mode.capture) {\n                    // Mode has capture enabled, consume all unmatched keypresses\n                    try self.logKeyPress(\"Capture mode consuming unmatched key: {s}, process name: {s}\", eventkey, .{process_name});\n                    return @ptrFromInt(0);\n                }\n            }\n\n            try self.logKeyPress(\"No matching hotkey found for key: {s}, process name: {s}\", eventkey, .{process_name});\n            return event;\n        },\n    }\n}\n\ninline fn handleSystemKey(self: *Skhd, event: c.CGEventRef) !c.CGEventRef {\n    if (self.current_mode == null) {\n        self.tracer.traceNoModeExit();\n        return event;\n    }\n\n    // Skip events that we generated ourselves to avoid loops\n    const marker = c.CGEventGetIntegerValueField(event, c.kCGEventSourceUserData);\n    if (marker == SKHD_EVENT_MARKER) {\n        self.tracer.traceSelfGeneratedExit();\n        return event;\n    }\n\n    // Check if current application is blacklisted (using cached name)\n    self.tracer.traceProcessNameLookup();\n    const process_name = self.carbon_event.getProcessName();\n\n    if (self.mappings.blacklist.contains(process_name)) {\n        self.tracer.traceBlacklistedExit();\n        return event;\n    }\n\n    var eventkey: Hotkey.KeyPress = undefined;\n    if (interceptSystemKey(event, &eventkey)) {\n        const result = try self.processHotkey(&eventkey, event, process_name);\n        return try self.handleHotkeyResult(result, event, eventkey, process_name);\n    }\n\n    return event;\n}\n\ninline fn createEventKey(event: c.CGEventRef) Hotkey.KeyPress {\n    return .{\n        .key = @intCast(c.CGEventGetIntegerValueField(event, c.kCGKeyboardEventKeycode)),\n        .flags = cgeventFlagsToHotkeyFlags(c.CGEventGetFlags(event)),\n    };\n}\n\ninline fn cgeventFlagsToHotkeyFlags(event_flags: c.CGEventFlags) ModifierFlag {\n    var flags = ModifierFlag{};\n\n    // Implement left/right modifier distinction like original skhd\n    // Alt/Option modifiers\n    if (event_flags & c.kCGEventFlagMaskAlternate != 0) {\n        const left_alt = (event_flags & 0x00000020) != 0; // Event_Mask_LAlt\n        const right_alt = (event_flags & 0x00000040) != 0; // Event_Mask_RAlt\n\n        if (left_alt) {\n            flags.lalt = true;\n        }\n        if (right_alt) {\n            flags.ralt = true;\n        }\n        if (!left_alt and !right_alt) {\n            flags.alt = true;\n        }\n    }\n\n    // Shift modifiers\n    if (event_flags & c.kCGEventFlagMaskShift != 0) {\n        const left_shift = (event_flags & 0x00000002) != 0; // Event_Mask_LShift\n        const right_shift = (event_flags & 0x00000004) != 0; // Event_Mask_RShift\n\n        if (left_shift) {\n            flags.lshift = true;\n        }\n        if (right_shift) {\n            flags.rshift = true;\n        }\n        if (!left_shift and !right_shift) {\n            flags.shift = true;\n        }\n    }\n\n    // Command modifiers\n    if (event_flags & c.kCGEventFlagMaskCommand != 0) {\n        const left_cmd = (event_flags & 0x00000008) != 0; // Event_Mask_LCmd\n        const right_cmd = (event_flags & 0x00000010) != 0; // Event_Mask_RCmd\n\n        if (left_cmd) {\n            flags.lcmd = true;\n        }\n        if (right_cmd) {\n            flags.rcmd = true;\n        }\n        if (!left_cmd and !right_cmd) {\n            flags.cmd = true;\n        }\n    }\n\n    // Control modifiers\n    if (event_flags & c.kCGEventFlagMaskControl != 0) {\n        const left_ctrl = (event_flags & 0x00000001) != 0; // Event_Mask_LControl\n        const right_ctrl = (event_flags & 0x00002000) != 0; // Event_Mask_RControl\n\n        if (left_ctrl) {\n            flags.lcontrol = true;\n        }\n        if (right_ctrl) {\n            flags.rcontrol = true;\n        }\n        if (!left_ctrl and !right_ctrl) {\n            flags.control = true;\n        }\n    }\n\n    // Function key modifier\n    if (event_flags & c.kCGEventFlagMaskSecondaryFn != 0) {\n        flags.@\"fn\" = true;\n    }\n\n    return flags;\n}\n\ninline fn interceptSystemKey(event: c.CGEventRef, eventkey: *Hotkey.KeyPress) bool {\n    const event_data = c.CGEventCreateData(c.kCFAllocatorDefault, event);\n    defer c.CFRelease(event_data);\n\n    const data = c.CFDataGetBytePtr(event_data);\n    const key_code = data[129];\n    const key_state = data[130];\n    const key_stype = data[123];\n\n    const NX_KEYDOWN: u8 = 0x0A;\n    const NX_SUBTYPE_AUX_CONTROL_BUTTONS: u8 = 8;\n\n    const result = (key_state == NX_KEYDOWN) and (key_stype == NX_SUBTYPE_AUX_CONTROL_BUTTONS);\n\n    if (result) {\n        eventkey.key = key_code;\n        eventkey.flags = cgeventFlagsToHotkeyFlags(c.CGEventGetFlags(event));\n        eventkey.flags.nx = true;\n    }\n\n    return result;\n}\n\n// Magic number to mark events generated by us\nconst SKHD_EVENT_MARKER: i64 = 0x736B6864; // \"skhd\" in hex\n\ninline fn forwardKey(target_key: Hotkey.KeyPress, _: c.CGEventRef) !void {\n    // Mouse buttons live in a separate keycode space (≥ 0x10000) and need\n    // CGEventCreateMouseEvent rather than CGEventCreateKeyboardEvent.\n    if (Keycodes.isMouseButton(target_key.key)) {\n        try postMouseClick(target_key);\n        return;\n    }\n    // Check if this is an NX media key (requires different event type)\n    if (target_key.flags.nx) {\n        try postMediaKeyEvent(target_key.key);\n        return;\n    }\n\n    // Create a proper event source for the new event\n    const event_source = c.CGEventSourceCreate(c.kCGEventSourceStateHIDSystemState);\n    if (event_source == null) return error.FailedToCreateEventSource;\n    defer c.CFRelease(event_source);\n\n    // Create key down event\n    const key_down = c.CGEventCreateKeyboardEvent(event_source, @intCast(target_key.key), true);\n    if (key_down == null) return error.FailedToCreateKeyboardEvent;\n    defer c.CFRelease(key_down);\n\n    // Create key up event\n    const key_up = c.CGEventCreateKeyboardEvent(event_source, @intCast(target_key.key), false);\n    if (key_up == null) return error.FailedToCreateKeyboardEvent;\n    defer c.CFRelease(key_up);\n\n    // Set the modifier flags for both events\n    const target_flags = hotkeyFlagsToCGEventFlags(target_key.flags);\n    c.CGEventSetFlags(key_down, target_flags);\n    c.CGEventSetFlags(key_up, target_flags);\n\n    // Mark these events as generated by us to avoid processing them again\n    c.CGEventSetIntegerValueField(key_down, c.kCGEventSourceUserData, SKHD_EVENT_MARKER);\n    c.CGEventSetIntegerValueField(key_up, c.kCGEventSourceUserData, SKHD_EVENT_MARKER);\n\n    // Post both key down and key up events\n    c.CGEventPost(c.kCGSessionEventTap, key_down);\n    c.CGEventPost(c.kCGSessionEventTap, key_up);\n}\n\n/// Synthesize a mouse-down + mouse-up at the current cursor position.\n/// Used for `... | mouse1` forwards. The cursor doesn't move; we just\n/// fire a click in place.\ninline fn postMouseClick(target_key: Hotkey.KeyPress) !void {\n    const button: u32 = target_key.key & 0xFF; // 1..5\n    const down_type: c.CGEventType, const up_type: c.CGEventType, const cg_button: c.CGMouseButton = switch (button) {\n        1 => .{ c.kCGEventLeftMouseDown, c.kCGEventLeftMouseUp, c.kCGMouseButtonLeft },\n        2 => .{ c.kCGEventRightMouseDown, c.kCGEventRightMouseUp, c.kCGMouseButtonRight },\n        else => .{ c.kCGEventOtherMouseDown, c.kCGEventOtherMouseUp, @intCast(button - 1) },\n    };\n\n    // CGEventCreate(NULL) returns an empty event whose location field is\n    // populated with the current cursor position — cheaper than a Cocoa\n    // round-trip via NSEvent.mouseLocation.\n    const probe = c.CGEventCreate(null);\n    if (probe == null) return error.FailedToProbeMouse;\n    defer c.CFRelease(probe);\n    const cursor = c.CGEventGetLocation(probe);\n\n    const source = c.CGEventSourceCreate(c.kCGEventSourceStateHIDSystemState);\n    if (source == null) return error.FailedToCreateEventSource;\n    defer c.CFRelease(source);\n\n    const down = c.CGEventCreateMouseEvent(source, down_type, cursor, cg_button);\n    if (down == null) return error.FailedToCreateMouseEvent;\n    defer c.CFRelease(down);\n\n    const up = c.CGEventCreateMouseEvent(source, up_type, cursor, cg_button);\n    if (up == null) return error.FailedToCreateMouseEvent;\n    defer c.CFRelease(up);\n\n    // Carry any modifier flags from the forward target so syntax like\n    // `key | cmd - mouse1` synthesizes a cmd-click rather than a plain click.\n    const target_flags = hotkeyFlagsToCGEventFlags(target_key.flags);\n    c.CGEventSetFlags(down, target_flags);\n    c.CGEventSetFlags(up, target_flags);\n\n    // Mark as self-generated so handleMouseDown re-entry skips them.\n    c.CGEventSetIntegerValueField(down, c.kCGEventSourceUserData, SKHD_EVENT_MARKER);\n    c.CGEventSetIntegerValueField(up, c.kCGEventSourceUserData, SKHD_EVENT_MARKER);\n\n    c.CGEventPost(c.kCGSessionEventTap, down);\n    c.CGEventPost(c.kCGSessionEventTap, up);\n}\n\n/// Synthesize and post a media key event (play, next, previous, etc.)\n/// Media keys use NX_SYSDEFINED events with special data encoding, not regular keyboard events.\n/// Reference: https://stackoverflow.com/questions/11045814/emulate-media-key-press-on-mac\ninline fn postMediaKeyEvent(key_code: u32) !void {\n    try postMediaKeyPress(key_code, true); // key down\n    try postMediaKeyPress(key_code, false); // key up\n}\n\n/// Post a single media key press (down or up) using NSEvent.otherEvent\n/// The data1 field encodes: (key_code << 16) | (state_flags << 8)\n/// where state_flags is 0x0a for down, 0x0b for up\nfn postMediaKeyPress(key_code: u32, key_down: bool) !void {\n    const NSEventClass = c.objc_getClass(\"NSEvent\");\n    if (NSEventClass == null) return error.FailedToGetNSEventClass;\n\n    const state_flags: c_long = if (key_down) 0x0a00 else 0x0b00;\n    const data1: c_long = (@as(c_long, @intCast(key_code)) << 16) | state_flags;\n\n    const ev = nsEventOtherEvent(\n        @ptrCast(@alignCast(NSEventClass)),\n        14, // NSEventTypeSystemDefined\n        8, // NX_SUBTYPE_AUX_CONTROL_BUTTONS\n        data1,\n    );\n\n    if (ev != null) {\n        const cg_event = nsEventToCGEvent(ev);\n        if (cg_event != null) {\n            c.CGEventPost(c.kCGHIDEventTap, cg_event);\n        }\n    }\n}\n\n/// Create an NSEvent using otherEventWithType:location:modifierFlags:timestamp:windowNumber:context:subtype:data1:data2:\n/// Reference: https://stackoverflow.com/questions/11045814/emulate-media-key-press-on-mac\nfn nsEventOtherEvent(ns_event_class: c.id, event_type: c_ulong, subtype: c_short, data1: c_long) c.id {\n    const sel = c.sel_registerName(\"otherEventWithType:location:modifierFlags:timestamp:windowNumber:context:subtype:data1:data2:\");\n    const msgSend = @extern(*const fn (\n        c.id,\n        c.SEL,\n        c_ulong,\n        f64,\n        f64,\n        c_ulong,\n        f64,\n        c_long,\n        ?*anyopaque,\n        c_short,\n        c_long,\n        c_long,\n    ) callconv(.C) c.id, .{ .name = \"objc_msgSend\" });\n\n    return msgSend(\n        ns_event_class,\n        sel,\n        event_type,\n        0.0,\n        0.0, // location\n        @as(c_ulong, @bitCast(data1)) & 0xff00, // modifierFlags from data1\n        0.0, // timestamp\n        0, // windowNumber\n        null, // context\n        subtype,\n        data1,\n        -1, // data2\n    );\n}\n\n/// Get CGEvent from NSEvent by calling [event CGEvent]\nfn nsEventToCGEvent(ns_event: c.id) c.CGEventRef {\n    const sel = c.sel_registerName(\"CGEvent\");\n    const msgSend = @extern(*const fn (c.id, c.SEL) callconv(.C) ?*anyopaque, .{ .name = \"objc_msgSend\" });\n    return @ptrCast(msgSend(ns_event, sel));\n}\n\ninline fn hotkeyFlagsToCGEventFlags(hotkey_flags: ModifierFlag) c.CGEventFlags {\n    var flags: c.CGEventFlags = 0;\n\n    // Handle command modifiers (general, left, right)\n    if (hotkey_flags.cmd or hotkey_flags.lcmd or hotkey_flags.rcmd) {\n        flags |= c.kCGEventFlagMaskCommand;\n        if (hotkey_flags.lcmd) {\n            flags |= 0x00000008; // Event_Mask_LCmd\n        }\n        if (hotkey_flags.rcmd) {\n            flags |= 0x00000010; // Event_Mask_RCmd\n        }\n    }\n\n    // Handle alt modifiers (general, left, right)\n    if (hotkey_flags.alt or hotkey_flags.lalt or hotkey_flags.ralt) {\n        flags |= c.kCGEventFlagMaskAlternate;\n        if (hotkey_flags.lalt) {\n            flags |= 0x00000020; // Event_Mask_LAlt\n        }\n        if (hotkey_flags.ralt) {\n            flags |= 0x00000040; // Event_Mask_RAlt\n        }\n    }\n\n    // Handle control modifiers (general, left, right)\n    if (hotkey_flags.control or hotkey_flags.lcontrol or hotkey_flags.rcontrol) {\n        flags |= c.kCGEventFlagMaskControl;\n        if (hotkey_flags.lcontrol) {\n            flags |= 0x00000001; // Event_Mask_LControl\n        }\n        if (hotkey_flags.rcontrol) {\n            flags |= 0x00002000; // Event_Mask_RControl\n        }\n    }\n\n    // Handle shift modifiers (general, left, right)\n    if (hotkey_flags.shift or hotkey_flags.lshift or hotkey_flags.rshift) {\n        flags |= c.kCGEventFlagMaskShift;\n        if (hotkey_flags.lshift) {\n            flags |= 0x00000002; // Event_Mask_LShift\n        }\n        if (hotkey_flags.rshift) {\n            flags |= 0x00000004; // Event_Mask_RShift\n        }\n    }\n\n    // Function key modifier\n    if (hotkey_flags.@\"fn\") {\n        flags |= c.kCGEventFlagMaskSecondaryFn;\n    }\n\n    return flags;\n}\n\n/// Find a hotkey in the mode that matches the keyboard event\n/// Returns the hotkey pointer if found, null otherwise\npub inline fn findHotkeyInMode(self: *Skhd, mode: *const Mode, eventkey: Hotkey.KeyPress) ?*Hotkey {\n    // Method 1: HashMap lookup with adapted context (O(1) average case)\n    return self.findHotkeyHashMap(mode, eventkey);\n\n    // Method 2: Linear array search (O(n) but potentially faster for small sets)\n    // return self.findHotkeyLinear(mode, eventkey);\n}\n\n/// HashMap-based lookup using adapted context\npub inline fn findHotkeyHashMap(self: *Skhd, mode: *const Mode, eventkey: Hotkey.KeyPress) ?*Hotkey {\n    self.tracer.traceHotkeyLookup();\n    const ctx = Hotkey.KeyboardLookupContext{};\n    const result = mode.hotkey_map.getKeyAdapted(eventkey, ctx);\n    self.tracer.traceHotkeyFound(result != null);\n    return result;\n}\n\n/// Wildcard fallback for capture (layer) modes: same key, but the\n/// lookup ignores the keyboard's modifiers and only matches a config\n/// hotkey that itself was declared without explicit modifiers. Used\n/// to get QMK-style layer transparency: `fn_layer < h | left` should\n/// also fire for `shift+h`, `ctrl+h`, etc., with the user's actual\n/// modifiers carried through to the forwarded keystroke.\npub inline fn findWildcardHotkey(_: *Skhd, mode: *const Mode, eventkey: Hotkey.KeyPress) ?*Hotkey {\n    const ctx = Hotkey.WildcardLookupContext{};\n    return mode.hotkey_map.getKeyAdapted(eventkey, ctx);\n}\n\n/// Process a hotkey - single lookup that handles both forwarding and execution\ninline fn processHotkey(self: *Skhd, eventkey: *const Hotkey.KeyPress, event: c.CGEventRef, process_name: []const u8) !HotkeyResult {\n    const mode = self.current_mode orelse return .not_found;\n\n    self.tracer.traceHotkeyLookup();\n    var found_hotkey = self.findHotkeyInMode(mode, eventkey.*);\n\n    // Capture-mode layer transparency: if no exact-modifier match\n    // exists, try the wildcard lookup (matches the same key code\n    // against any rule with no declared modifiers). When this hits,\n    // we OR the user's actual modifiers into the forwarded output\n    // below so e.g. `fn_layer < h | left` also handles `lctrl+h`\n    // → `lctrl+left`.\n    var via_wildcard = false;\n    if (found_hotkey == null and mode.capture) {\n        found_hotkey = self.findWildcardHotkey(mode, eventkey.*);\n        via_wildcard = (found_hotkey != null);\n    }\n\n    if (found_hotkey == null) {\n        self.tracer.traceHotkeyFound(false);\n        return .not_found;\n    }\n\n    // Format the matched hotkey to mirror the config-file syntax —\n    // `mode < key` so the log line reads the same way the binding\n    // is written. Default mode prints just the key (no `default <`\n    // prefix in user configs). Compile out entirely in release\n    // builds (log.debug is filtered there anyway).\n    if (comptime builtin.mode == .Debug) {\n        if (self.verbose) {\n            var key_buf: [256]u8 = undefined;\n            const key_str = try Keycodes.formatKeyPressBuffer(&key_buf, eventkey.flags, eventkey.key);\n            if (std.mem.eql(u8, mode.name, \"default\")) {\n                log.debug(\"Found hotkey: '{s}' for process: '{s}'\", .{ key_str, process_name });\n            } else {\n                log.debug(\"Found hotkey: '{s} < {s}' for process: '{s}'\", .{ mode.name, key_str, process_name });\n            }\n        }\n    }\n    self.tracer.traceHotkeyFound(true);\n    const hotkey = found_hotkey.?;\n\n    // Check for process-specific command/forward (includes wildcard fallback)\n    if (hotkey.find_command_for_process(process_name)) |process_cmd| {\n        switch (process_cmd) {\n            .forwarded => |target_key| {\n                // QMK-style layer transparency: when a wildcard\n                // (no-modifier) layer rule fires, OR the user's\n                // actual modifiers into the forward target so\n                // shift+h → shift+left, ctrl+h → ctrl+left, etc.\n                // Without the wildcard match (i.e. an explicit\n                // modifier rule fired), use the rule's target as-is.\n                const effective_target: Hotkey.KeyPress = if (via_wildcard) .{\n                    .flags = target_key.flags.merge(eventkey.flags),\n                    .key = target_key.key,\n                } else target_key;\n                try self.logKeyPress(\"Forwarding key '{s}' for process {s}\", effective_target, .{process_name});\n                self.tracer.traceKeyForwarded();\n                try forwardKey(effective_target, event);\n                return .consumed;\n            },\n            .command => |cmd| {\n                log.debug(\"Executing command '{s}' for process {s}\", .{ cmd, process_name });\n                self.tracer.traceCommandExecuted();\n                try forkAndExec(self.mappings.shell, cmd, self.verbose);\n                return if (hotkey.flags.passthrough) .passthrough else .consumed;\n            },\n            .unbound => {\n                log.debug(\"Unbound key for process {s}\", .{process_name});\n                return .passthrough;\n            },\n            .activation => |act| {\n                // Execute activation command if provided\n                if (act.command) |activation_cmd| {\n                    log.debug(\"Executing activation command: {s}\", .{activation_cmd});\n                    try forkAndExec(self.mappings.shell, activation_cmd, self.verbose);\n                }\n                log.debug(\"Activating mode '{s}'\", .{act.mode_name});\n                self.current_mode = self.mappings.mode_map.getPtr(act.mode_name);\n                if (self.current_mode) |_mode| {\n                    if (_mode.command) |mode_cmd| {\n                        log.debug(\"Executing mode command: {s}\", .{mode_cmd});\n                        try forkAndExec(self.mappings.shell, mode_cmd, self.verbose);\n                    }\n                } else {\n                    log.err(\"Failed to activate mode '{s}': mode not found\", .{act.mode_name});\n                    log.debug(\"Resetting to default mode\", .{});\n                    self.current_mode = self.mappings.mode_map.getPtr(\"default\");\n                }\n\n                return .consumed;\n            },\n        }\n    }\n\n    return .not_found;\n}\n\n/// Signal handler for SIGUSR1 - reload configuration\nfn handleSigusr1(_: c_int) callconv(.C) void {\n    reload_requested.store(true, .release);\n}\n\n/// Signal handler for SIGINT - stop the run loop to allow graceful shutdown\nfn handleSigint(_: c_int) callconv(.C) void {\n    stop_requested.store(true, .release);\n}\n\n/// Reload configuration from file\npub fn reloadConfig(self: *Skhd) !void {\n    log.info(\"Reloading configuration from: {s}\", .{self.config_file});\n\n    // Parse new configuration\n    var new_mappings = try Mappings.init(self.allocator);\n    errdefer new_mappings.deinit();\n\n    var parser = try Parser.init(self.allocator);\n    defer parser.deinit();\n\n    const content = try std.fs.cwd().readFileAlloc(self.allocator, self.config_file, 1 << 20);\n    defer self.allocator.free(content);\n\n    parser.parseWithPath(&new_mappings, content, self.config_file) catch |err| {\n        // Log the parse error with proper formatting\n        if (parser.error_info) |parse_err| {\n            log.err(\"skhd: {}\", .{parse_err});\n        }\n        return err;\n    };\n    parser.processLoadDirectives(&new_mappings) catch |err| {\n        if (parser.error_info) |parse_err| {\n            log.err(\"skhd: {}\", .{parse_err});\n        }\n        return err;\n    };\n\n    // Swap old mappings with new ones\n    self.mappings.deinit();\n    self.mappings = new_mappings;\n\n    // Reset to default mode\n    if (self.mappings.mode_map.getPtr(\"default\")) |default_mode| {\n        self.current_mode = default_mode;\n    } else {\n        self.current_mode = null;\n    }\n\n    // Re-apply hidutil for any colon-form `.remap` rules in the new\n    // config. Without this, edits to .remap directives wouldn't take\n    // effect on hot reload — the OS-level UserKeyMapping would still\n    // reflect the previous parse. Lazy-init the Hidutil owner if the\n    // previous config had no remaps but the new one does.\n    if (self.hidutil == null and self.mappings.remaps.items.len > 0) {\n        self.hidutil = Hidutil.init(self.allocator) catch |err| blk: {\n            log.warn(\"Hidutil init on reload failed: {s}. .remap colon-form ignored.\", .{@errorName(err)});\n            break :blk null;\n        };\n    }\n    if (self.hidutil) |h| {\n        // Clear whatever's installed before re-applying; this also\n        // covers the case where the new config removed every remap\n        // (then we just clear and stay quiescent).\n        h.restoreAll();\n        if (self.mappings.remaps.items.len > 0) {\n            h.applyRemaps(&self.mappings) catch |err| {\n                log.err(\"Failed to re-apply hidutil remaps on reload: {s}\", .{@errorName(err)});\n            };\n        }\n    }\n\n    // Tear down the previous grabber connection so forwardTapholds...\n    // can dial fresh with the updated rules. Do this even when the\n    // new config has no caps-class rules — closing the old socket\n    // is how the grabber learns we don't want our previous rules\n    // applied any more.\n    //\n    // No `bye` here: once apply_rules has succeeded, the grabber moves\n    // this socket out of `Ipc.serve` and into its subscriptionCallback,\n    // which only PEEKs for EOS and discards any frame the agent writes\n    // as \"stray bytes\". A bye on a subscription connection therefore\n    // never gets a reply — and worse, an `expectOk` read after it can\n    // pick up a queued `mode_change` push (logged as \"unexpected type:\n    // mode_change\") or block indefinitely. EOS-on-close is the only\n    // teardown signal the subscription path actually honors.\n    if (self.layer_listener) |ll| {\n        ll.deinit();\n        self.layer_listener = null;\n    }\n    if (self.grabber_client) |gc| {\n        gc.close();\n        self.allocator.destroy(gc);\n        self.grabber_client = null;\n    }\n    self.forwardTapholdsToGrabber() catch |err| {\n        log.warn(\"hot reload: could not forward updated rules to skhd-grabber: {s}\", .{@errorName(err)});\n    };\n\n    self.requestHotReloadRefresh();\n\n    log.info(\"Configuration reloaded successfully\", .{});\n}\n\npub fn enableHotReload(self: *Skhd) !void {\n    if (self.hotload_enabled) return;\n\n    log.info(\"Enabling hot reload...\", .{});\n\n    // Store self reference for callback\n    global_skhd = self;\n\n    // Create hotloader (already heap-allocated by create())\n    const hotloader = try Hotload.create(self.allocator, hotloadCallback);\n\n    // Resolve main config file to absolute path for FSEvents\n    var path_buf: [std.fs.max_path_bytes]u8 = undefined;\n    const abs_path = try std.fs.cwd().realpath(self.config_file, &path_buf);\n\n    log.info(\"Watching main config: {s} (resolved to: {s})\", .{ self.config_file, abs_path });\n    try hotloader.addFile(abs_path);\n\n    // Also watch all loaded files\n    for (self.mappings.loaded_files.items) |loaded_file| {\n        log.info(\"Watching loaded file: {s}\", .{loaded_file});\n        hotloader.addFile(loaded_file) catch |err| {\n            log.info(\"Failed to watch loaded file {s}: {}\", .{ loaded_file, err });\n        };\n    }\n\n    // Start the hotloader\n    try hotloader.start();\n\n    self.hotloader = hotloader;\n    self.hotload_enabled = true;\n\n    log.info(\"Hot reload enabled successfully\", .{});\n}\n\npub fn disableHotReload(self: *Skhd) void {\n    if (!self.hotload_enabled) return;\n\n    if (self.hotloader) |hotloader| {\n        hotloader.destroy();\n    }\n\n    self.hotloader = null;\n    self.hotload_enabled = false;\n\n    log.info(\"Hot reload disabled\", .{});\n}\n\nfn refreshHotReload(self: *Skhd) !void {\n    if (!self.hotload_enabled) return;\n    if (self.hotloader) |hotloader| {\n        hotloader.destroy();\n    }\n    self.hotloader = null;\n    self.hotload_enabled = false;\n    try self.enableHotReload();\n}\n\nfn requestHotReloadRefresh(self: *Skhd) void {\n    if (!self.hotload_enabled) return;\n    hotload_refresh_pending.store(true, .release);\n}\n\nfn processPendingHotReloadRefresh(self: *Skhd) void {\n    if (!hotload_refresh_pending.swap(false, .acq_rel)) return;\n    self.refreshHotReload() catch |err| {\n        log.warn(\"hot reload: failed to refresh watched files after reload: {s}\", .{@errorName(err)});\n    };\n}\n\nfn hotloadCallback(path: []const u8) void {\n    // Follow the original skhd approach - directly reload the config\n    if (global_skhd) |skhd| {\n        log.info(\"Config file has been modified: {s} .. reloading config\", .{path});\n        skhd.reloadConfig() catch |err| {\n            log.err(\"Failed to reload config: {}\", .{err});\n        };\n    } else {\n        std.debug.print(\"ERROR: global_skhd is null in hotloadCallback\\n\", .{});\n    }\n}\n\n/// Log a keypress with formatted key string. Compile out in any\n/// non-Debug build — log.debug is filtered above ReleaseSafe and\n/// even ReleaseSafe sets `log_level = .info`, so the format work\n/// would be wasted there.\ninline fn logKeyPress(self: *Skhd, comptime fmt: []const u8, key: Hotkey.KeyPress, rest: anytype) !void {\n    if (comptime builtin.mode != .Debug) return;\n    if (!self.verbose) return;\n\n    var buf: [256]u8 = undefined;\n    const key_str = try Keycodes.formatKeyPressBuffer(&buf, key.flags, key.key);\n    log.debug(fmt, .{key_str} ++ rest);\n}\n\n// Test helper to create a Skhd instance from a config string\nfn createTestSkhdFromConfig(allocator: std.mem.Allocator, config: []const u8) !Skhd {\n    // Parse the config\n    var mappings = try Mappings.init(allocator);\n    errdefer mappings.deinit();\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    try parser.parseWithPath(&mappings, config, \"test.conf\");\n\n    // Set up default mode if it exists\n    var current_mode: ?*Mode = null;\n    if (mappings.mode_map.getPtr(\"default\")) |default_mode| {\n        current_mode = default_mode;\n    }\n\n    // Create carbon event mock\n    const carbon_event = try CarbonEvent.init(allocator);\n    errdefer carbon_event.deinit();\n\n    return Skhd{\n        .allocator = allocator,\n        .mappings = mappings,\n        .current_mode = current_mode,\n        .event_tap = EventTap{ .mask = 0 },\n        .config_file = try allocator.dupe(u8, \"test.conf\"),\n        .verbose = false,\n        .tracer = Tracer.init(false),\n        .carbon_event = carbon_event,\n    };\n}\n\ntest \"hot reload refresh is deferred until maintenance turn\" {\n    const allocator = std.testing.allocator;\n    hotload_refresh_pending.store(false, .release);\n    defer hotload_refresh_pending.store(false, .release);\n\n    const test_id = std.crypto.random.int(u32);\n    const config_path = try std.fmt.allocPrint(allocator, \"/tmp/skhd_test_hotload_refresh_{d}.skhdrc\", .{test_id});\n    defer allocator.free(config_path);\n    const include_path = try std.fmt.allocPrint(allocator, \"/tmp/skhd_test_hotload_refresh_include_{d}.skhdrc\", .{test_id});\n    defer allocator.free(include_path);\n\n    {\n        const file = try std.fs.createFileAbsolute(config_path, .{});\n        defer file.close();\n        try file.writeAll(\"cmd - a : echo initial\");\n    }\n    {\n        const file = try std.fs.createFileAbsolute(include_path, .{});\n        defer file.close();\n        try file.writeAll(\"cmd - b : echo included\");\n    }\n    defer std.fs.deleteFileAbsolute(config_path) catch {};\n    defer std.fs.deleteFileAbsolute(include_path) catch {};\n\n    var skhd = try Skhd.init(allocator, config_path, false, false);\n    defer skhd.deinit();\n\n    try skhd.enableHotReload();\n    try std.testing.expect(skhd.hotloader != null);\n    try std.testing.expectEqual(@as(usize, 1), skhd.hotloader.?.watch_list.items.len);\n\n    {\n        const updated = try std.fmt.allocPrint(allocator,\n            \\\\.load \"{s}\"\n            \\\\cmd - a : echo reloaded\n        , .{include_path});\n        defer allocator.free(updated);\n        const file = try std.fs.createFileAbsolute(config_path, .{ .truncate = true });\n        defer file.close();\n        try file.writeAll(updated);\n    }\n\n    try skhd.reloadConfig();\n    try std.testing.expect(skhd.hotloader != null);\n    try std.testing.expectEqual(@as(usize, 1), skhd.hotloader.?.watch_list.items.len);\n\n    skhd.processPendingHotReloadRefresh();\n    try std.testing.expect(skhd.hotloader != null);\n    try std.testing.expectEqual(@as(usize, 2), skhd.hotloader.?.watch_list.items.len);\n}\n\ntest \"processHotkey respects passthrough in capture mode\" {\n    const alloc = std.testing.allocator;\n\n    const config =\n        \\\\:: capture @\n        \\\\capture < cmd - a -> : echo passthrough\n        \\\\capture < cmd - b : echo normal\n        \\\\capture < cmd - c ~\n    ;\n\n    var skhd = try createTestSkhdFromConfig(alloc, config);\n    defer skhd.deinit();\n\n    // Switch to capture mode\n    skhd.current_mode = skhd.mappings.mode_map.getPtr(\"capture\");\n\n    // Create mock event\n    const mock_event: c.CGEventRef = @ptrFromInt(0x1234);\n\n    // Test passthrough command\n    {\n        const keypress = Hotkey.KeyPress{ .key = 0x00, .flags = ModifierFlag{ .cmd = true } }; // Cmd+A\n        const result = try skhd.processHotkey(&keypress, mock_event, \"test\");\n        try std.testing.expectEqual(HotkeyResult.passthrough, result);\n    }\n\n    // Test normal command\n    {\n        const keypress = Hotkey.KeyPress{ .key = 0x0B, .flags = ModifierFlag{ .cmd = true } }; // Cmd+B\n        const result = try skhd.processHotkey(&keypress, mock_event, \"test\");\n        try std.testing.expectEqual(HotkeyResult.consumed, result);\n    }\n\n    // Test unbound action\n    {\n        const keypress = Hotkey.KeyPress{ .key = 0x08, .flags = ModifierFlag{ .cmd = true } }; // Cmd+C\n        const result = try skhd.processHotkey(&keypress, mock_event, \"test\");\n        try std.testing.expectEqual(HotkeyResult.passthrough, result);\n    }\n\n    // Test unmatched key (should return not_found, allowing capture mode to consume it)\n    {\n        const keypress = Hotkey.KeyPress{ .key = 0x02, .flags = ModifierFlag{ .cmd = true } }; // Cmd+D (not defined)\n        const result = try skhd.processHotkey(&keypress, mock_event, \"test\");\n        try std.testing.expectEqual(HotkeyResult.not_found, result);\n    }\n}\n\ntest \"capture-mode wildcard: no-modifier rule matches any-modifier press\" {\n    // QMK-style layer transparency. `fn_layer < h | left` (no\n    // modifier) should also fire when shift, ctrl, etc. are held —\n    // and the user's modifiers should be carried through to the\n    // forwarded keystroke.\n    const alloc = std.testing.allocator;\n\n    const config =\n        \\\\:: fn_layer @\n        \\\\fn_layer < 0x04 | 0x7B\n    ; // 0x04 = 'h' (macOS keycode), 0x7B = left arrow\n\n    var skhd = try createTestSkhdFromConfig(alloc, config);\n    defer skhd.deinit();\n\n    skhd.current_mode = skhd.mappings.mode_map.getPtr(\"fn_layer\");\n    const mock_event: c.CGEventRef = @ptrFromInt(0x1234);\n\n    // No modifier — exact match (also wildcard, but exact wins).\n    {\n        const kp = Hotkey.KeyPress{ .key = 0x04, .flags = .{} };\n        const result = try skhd.processHotkey(&kp, mock_event, \"test\");\n        try std.testing.expectEqual(HotkeyResult.consumed, result);\n    }\n    // shift held — exact lookup misses, wildcard hits.\n    {\n        const kp = Hotkey.KeyPress{ .key = 0x04, .flags = .{ .shift = true } };\n        const result = try skhd.processHotkey(&kp, mock_event, \"test\");\n        try std.testing.expectEqual(HotkeyResult.consumed, result);\n    }\n    // ctrl held — same.\n    {\n        const kp = Hotkey.KeyPress{ .key = 0x04, .flags = .{ .control = true } };\n        const result = try skhd.processHotkey(&kp, mock_event, \"test\");\n        try std.testing.expectEqual(HotkeyResult.consumed, result);\n    }\n}\n\ntest \"capture-mode wildcard: explicit-modifier rule wins over wildcard\" {\n    // If both a wildcard and an explicit-modifier rule exist for\n    // the same key, the explicit one matches its exact modifier\n    // combo and the wildcard handles everything else.\n    const alloc = std.testing.allocator;\n\n    const config =\n        \\\\:: fn_layer @\n        \\\\fn_layer < 0x04 | 0x7B\n        \\\\fn_layer < shift - 0x04 | 0x7C\n    ; // 0x7B = left, 0x7C = right\n\n    var skhd = try createTestSkhdFromConfig(alloc, config);\n    defer skhd.deinit();\n\n    skhd.current_mode = skhd.mappings.mode_map.getPtr(\"fn_layer\");\n    const mock_event: c.CGEventRef = @ptrFromInt(0x1234);\n\n    // shift+0x04 hits the explicit rule (→ right, no shift).\n    {\n        const kp = Hotkey.KeyPress{ .key = 0x04, .flags = .{ .shift = true } };\n        const result = try skhd.processHotkey(&kp, mock_event, \"test\");\n        try std.testing.expectEqual(HotkeyResult.consumed, result);\n    }\n    // ctrl+0x04 falls through to wildcard.\n    {\n        const kp = Hotkey.KeyPress{ .key = 0x04, .flags = .{ .control = true } };\n        const result = try skhd.processHotkey(&kp, mock_event, \"test\");\n        try std.testing.expectEqual(HotkeyResult.consumed, result);\n    }\n}\n\ntest \"non-capture mode: wildcard does NOT fire\" {\n    // Default mode should keep strict-match semantics so users who\n    // wrote `q : something` don't suddenly get matches on shift+q.\n    const alloc = std.testing.allocator;\n\n    const config =\n        \\\\0x04 | 0x7B\n    ;\n\n    var skhd = try createTestSkhdFromConfig(alloc, config);\n    defer skhd.deinit();\n    const mock_event: c.CGEventRef = @ptrFromInt(0x1234);\n\n    // Exact match: fires.\n    {\n        const kp = Hotkey.KeyPress{ .key = 0x04, .flags = .{} };\n        const result = try skhd.processHotkey(&kp, mock_event, \"test\");\n        try std.testing.expectEqual(HotkeyResult.consumed, result);\n    }\n    // shift held: default mode (non-capture), wildcard does NOT fire.\n    {\n        const kp = Hotkey.KeyPress{ .key = 0x04, .flags = .{ .shift = true } };\n        const result = try skhd.processHotkey(&kp, mock_event, \"test\");\n        try std.testing.expectEqual(HotkeyResult.not_found, result);\n    }\n}\n"
  },
  {
    "path": "src/sm_app_service.zig",
    "content": "/// Thin Zig bridge to Apple's `SMAppService` Obj-C class (ServiceManagement\n/// framework, macOS 13+). Required for registering the bundled LaunchAgent\n/// with the Background Tasks Manager (BTM) on Sequoia/Tahoe — the legacy\n/// `~/Library/LaunchAgents/` flow is silently `disallowed` by BTM until the\n/// user manually approves it in System Settings → Login Items & Extensions,\n/// which manifests as \"skhd doesn't always start after reboot\".\n///\n/// SMAppService binds to the *calling* app bundle. The plist must live at\n/// `<App>.app/Contents/Library/LaunchAgents/<plistName>` — see make-app.sh.\nconst std = @import(\"std\");\nconst c = @import(\"c.zig\");\nconst log = std.log.scoped(.sm_app_service);\n\n/// `SMAppServiceStatus` enum (NSInteger). See <ServiceManagement/SMAppService.h>.\npub const Status = enum(c_long) {\n    not_registered = 0,\n    enabled = 1,\n    requires_approval = 2,\n    not_found = 3,\n    _,\n\n    pub fn describe(self: Status) []const u8 {\n        return switch (self) {\n            .not_registered => \"not registered\",\n            .enabled => \"enabled\",\n            .requires_approval => \"requires user approval in System Settings → Login Items & Extensions\",\n            .not_found => \"bundled plist not found\",\n            _ => \"unknown\",\n        };\n    }\n};\n\n/// Opaque Obj-C reference to an SMAppService instance.\npub const Service = c.id;\n\nconst NullService = error.SMAppServiceUnavailable;\n\n/// Build an NSString from a null-terminated UTF-8 C string. Returned object\n/// is autoreleased; we don't manage its lifetime explicitly because each\n/// call site does a single `+stringWithUTF8String:` that's only used as an\n/// argument to one subsequent message-send.\nfn nsString(utf8: [*:0]const u8) c.id {\n    const NSStringClass = c.objc_getClass(\"NSString\") orelse return null;\n    const sel = c.sel_registerName(\"stringWithUTF8String:\");\n    const msg = @extern(\n        *const fn (c.id, c.SEL, [*:0]const u8) callconv(.C) c.id,\n        .{ .name = \"objc_msgSend\" },\n    );\n    return msg(@as(c.id, @ptrCast(@alignCast(NSStringClass))), sel, utf8);\n}\n\n/// Equivalent to `[SMAppService agentServiceWithPlistName:plistName]`.\n/// Returns null when the SMAppService class isn't available (i.e. we're\n/// running on macOS < 13, which we don't support but failing soft beats\n/// crashing).\npub fn agentService(plist_name: [*:0]const u8) ?Service {\n    const SMAppServiceClass = c.objc_getClass(\"SMAppService\") orelse {\n        log.err(\"SMAppService class is not available (macOS 13+ required)\", .{});\n        return null;\n    };\n    const sel = c.sel_registerName(\"agentServiceWithPlistName:\");\n    const msg = @extern(\n        *const fn (c.id, c.SEL, c.id) callconv(.C) c.id,\n        .{ .name = \"objc_msgSend\" },\n    );\n    const plist_ns = nsString(plist_name);\n    return msg(@as(c.id, @ptrCast(@alignCast(SMAppServiceClass))), sel, plist_ns);\n}\n\n/// Equivalent to `service.status` — see `Status` for values.\npub fn status(service: Service) Status {\n    const sel = c.sel_registerName(\"status\");\n    const msg = @extern(\n        *const fn (c.id, c.SEL) callconv(.C) c_long,\n        .{ .name = \"objc_msgSend\" },\n    );\n    return @enumFromInt(msg(service, sel));\n}\n\n/// Equivalent to `try service.register()` — returns RegisterFailed on\n/// failure with the localized error message logged.\npub fn register(service: Service) !void {\n    const sel = c.sel_registerName(\"registerAndReturnError:\");\n    // Use u8 instead of c.BOOL: on arm64-darwin BOOL translates to bool\n    // (Zig type), on x86_64-darwin it translates to i8 (signed char). Both\n    // are 1-byte on the Darwin ABI, so u8 marshals correctly on either.\n    const msg = @extern(\n        *const fn (c.id, c.SEL, *c.id) callconv(.C) u8,\n        .{ .name = \"objc_msgSend\" },\n    );\n    var err: c.id = null;\n    const ok = msg(service, sel, &err);\n    if (ok == 0) {\n        if (err) |e| logNSError(\"SMAppService.register\", e) else log.err(\"SMAppService.register failed (no error info)\", .{});\n        return error.RegisterFailed;\n    }\n}\n\n/// Equivalent to `try service.unregister()`.\npub fn unregister(service: Service) !void {\n    const sel = c.sel_registerName(\"unregisterAndReturnError:\");\n    const msg = @extern(\n        *const fn (c.id, c.SEL, *c.id) callconv(.C) u8,\n        .{ .name = \"objc_msgSend\" },\n    );\n    var err: c.id = null;\n    const ok = msg(service, sel, &err);\n    if (ok == 0) {\n        if (err) |e| logNSError(\"SMAppService.unregister\", e) else log.err(\"SMAppService.unregister failed (no error info)\", .{});\n        return error.UnregisterFailed;\n    }\n}\n\n/// Read `error.localizedDescription.UTF8String` and forward to our log.\nfn logNSError(prefix: []const u8, err: c.id) void {\n    const sel_desc = c.sel_registerName(\"localizedDescription\");\n    const desc_msg = @extern(\n        *const fn (c.id, c.SEL) callconv(.C) c.id,\n        .{ .name = \"objc_msgSend\" },\n    );\n    const desc = desc_msg(err, sel_desc);\n    if (desc == null) {\n        log.err(\"{s} failed (no localized description)\", .{prefix});\n        return;\n    }\n\n    const sel_utf8 = c.sel_registerName(\"UTF8String\");\n    const utf8_msg = @extern(\n        *const fn (c.id, c.SEL) callconv(.C) ?[*:0]const u8,\n        .{ .name = \"objc_msgSend\" },\n    );\n    const utf8 = utf8_msg(desc, sel_utf8) orelse {\n        log.err(\"{s} failed (UTF8String returned null)\", .{prefix});\n        return;\n    };\n    log.err(\"{s} failed: {s}\", .{ prefix, std.mem.span(utf8) });\n}\n"
  },
  {
    "path": "src/synthesize.zig",
    "content": "const std = @import(\"std\");\nconst c = @import(\"c.zig\");\nconst Parser = @import(\"Parser.zig\");\nconst Mappings = @import(\"Mappings.zig\");\nconst Hotkey = @import(\"Hotkey.zig\");\nconst Keycodes = @import(\"Keycodes.zig\");\nconst ModifierFlag = Keycodes.ModifierFlag;\nconst log = std.log.scoped(.synthesize);\n\n// Modifier keycodes from original skhd\nconst Modifier_Keycode_Alt = 0x3A;\nconst Modifier_Keycode_Shift = 0x38;\nconst Modifier_Keycode_Cmd = 0x37;\nconst Modifier_Keycode_Ctrl = 0x3B;\nconst Modifier_Keycode_Fn = 0x3F;\n\n/// Synthesize a keypress from a key specification string (e.g., \"cmd - space\")\npub fn synthesizeKey(allocator: std.mem.Allocator, key_string: []const u8) !void {\n    // Parse the key string\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    // Create temporary mappings just for parsing\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    // For synthesis, we need to add a dummy command since the parser expects a complete hotkey\n    // The command won't be executed, we just need it for parsing\n    const key_with_command = try std.fmt.allocPrint(allocator, \"{s} : __dummy__\", .{key_string});\n    defer allocator.free(key_with_command);\n\n    // Parse the key specification with dummy command\n    try parser.parse(&mappings, key_with_command);\n\n    // Find the first hotkey that was parsed\n    var mode_iter = mappings.mode_map.iterator();\n    if (mode_iter.next()) |mode_entry| {\n        const mode = mode_entry.value_ptr.*;\n        var hotkey_iter = mode.hotkey_map.iterator();\n        if (hotkey_iter.next()) |hotkey_entry| {\n            const hotkey = hotkey_entry.key_ptr.*;\n\n            // Disable local event suppression and state combining for clean synthesis\n            _ = c.CGSetLocalEventsSuppressionInterval(0.0);\n            _ = c.CGEnableEventStateCombining(0);\n\n            // Press modifiers down\n            synthesizeModifiers(hotkey.flags, true);\n\n            // Press the main key down\n            createAndPostKeyEvent(@intCast(hotkey.key), true);\n\n            // Release the main key\n            createAndPostKeyEvent(@intCast(hotkey.key), false);\n\n            // Release modifiers\n            synthesizeModifiers(hotkey.flags, false);\n\n            std.log.scoped(.synthesize).debug(\"Synthesized key: {any} + {s}\", .{ hotkey.flags, Keycodes.getKeyString(hotkey.key) });\n        } else {\n            std.debug.print(\"Error: Failed to parse key specification: {s}\\n\", .{key_string});\n        }\n    } else {\n        std.debug.print(\"Error: Failed to parse key specification: {s}\\n\", .{key_string});\n    }\n}\n\n/// Synthesize text input\npub fn synthesizeText(allocator: std.mem.Allocator, text: []const u8) !void {\n    _ = allocator;\n\n    // Convert text to CFString\n    const text_ref = c.CFStringCreateWithCString(null, text.ptr, c.kCFStringEncodingUTF8);\n    defer c.CFRelease(text_ref);\n\n    const text_length = c.CFStringGetLength(text_ref);\n\n    // Create key down and key up events\n    const down_event = c.CGEventCreateKeyboardEvent(null, 0, true);\n    defer c.CFRelease(down_event);\n\n    const up_event = c.CGEventCreateKeyboardEvent(null, 0, false);\n    defer c.CFRelease(up_event);\n\n    // Clear any flags\n    c.CGEventSetFlags(down_event, 0);\n    c.CGEventSetFlags(up_event, 0);\n\n    // Send each character\n    var i: c.CFIndex = 0;\n    while (i < text_length) : (i += 1) {\n        const char = c.CFStringGetCharacterAtIndex(text_ref, i);\n\n        // Set the unicode character for both events\n        c.CGEventKeyboardSetUnicodeString(down_event, 1, &char);\n        c.CGEventPost(c.kCGAnnotatedSessionEventTap, down_event);\n\n        // Small delay between key down and up\n        std.time.sleep(1000 * 1000); // 1ms in nanoseconds\n\n        c.CGEventKeyboardSetUnicodeString(up_event, 1, &char);\n        c.CGEventPost(c.kCGAnnotatedSessionEventTap, up_event);\n    }\n\n    std.log.scoped(.synthesize).debug(\"Synthesized text: {s}\", .{text});\n}\n\nfn createAndPostKeyEvent(keycode: u16, pressed: bool) void {\n    // Use the deprecated but working CGPostKeyboardEvent for now\n    // This matches the original skhd implementation\n    _ = c.CGPostKeyboardEvent(0, keycode, if (pressed) 1 else 0);\n}\n\nfn synthesizeModifiers(flags: ModifierFlag, pressed: bool) void {\n    if (flags.alt or flags.lalt or flags.ralt) {\n        createAndPostKeyEvent(Modifier_Keycode_Alt, pressed);\n    }\n\n    if (flags.shift or flags.lshift or flags.rshift) {\n        createAndPostKeyEvent(Modifier_Keycode_Shift, pressed);\n    }\n\n    if (flags.cmd or flags.lcmd or flags.rcmd) {\n        createAndPostKeyEvent(Modifier_Keycode_Cmd, pressed);\n    }\n\n    if (flags.control or flags.lcontrol or flags.rcontrol) {\n        createAndPostKeyEvent(Modifier_Keycode_Ctrl, pressed);\n    }\n\n    if (flags.@\"fn\") {\n        createAndPostKeyEvent(Modifier_Keycode_Fn, pressed);\n    }\n}\n\ntest \"synthesize key parsing\" {\n    const testing = std.testing;\n    const allocator = testing.allocator;\n\n    // Test that we can parse a simple key specification\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    try parser.parse(&mappings, \"cmd - space : echo test\");\n\n    // Should have a default mode with one hotkey\n    const default_mode = mappings.mode_map.get(\"default\");\n    try testing.expect(default_mode != null);\n\n    const hotkey_count = default_mode.?.hotkey_map.count();\n    try testing.expect(hotkey_count == 1);\n}\n"
  },
  {
    "path": "src/tests.zig",
    "content": "const std = @import(\"std\");\nconst testing = std.testing;\n\n// Import our modules\nconst Hotkey = @import(\"Hotkey.zig\");\nconst ModifierFlag = @import(\"Keycodes.zig\").ModifierFlag;\nconst Parser = @import(\"Parser.zig\");\nconst Mappings = @import(\"Mappings.zig\");\nconst Mode = @import(\"Mode.zig\");\nconst Skhd = @import(\"skhd.zig\");\nconst ParseError = @import(\"ParseError.zig\").ParseError;\nconst print = std.debug.print;\nconst log = std.log.scoped(.tests);\n\ntest \"ModifierFlag basic operations\" {\n    // Test basic flag creation\n    const flag1 = ModifierFlag{ .cmd = true, .shift = true };\n    const flag2 = ModifierFlag{ .alt = true };\n\n    // Test merging\n    const merged = flag1.merge(flag2);\n    try testing.expect(merged.cmd);\n    try testing.expect(merged.shift);\n    try testing.expect(merged.alt);\n    try testing.expect(!merged.control);\n}\n\ntest \"ModifierFlag left/right distinction\" {\n    const lcmd = ModifierFlag{ .lcmd = true };\n    const rcmd = ModifierFlag{ .rcmd = true };\n\n    try testing.expect(lcmd.lcmd);\n    try testing.expect(!lcmd.rcmd);\n    try testing.expect(!lcmd.cmd);\n\n    try testing.expect(rcmd.rcmd);\n    try testing.expect(!rcmd.lcmd);\n    try testing.expect(!rcmd.cmd);\n}\n\ntest \"ModifierFlag parsing\" {\n    // Test that we can get modifiers by string\n    const alt_flag = ModifierFlag.get(\"alt\");\n    try testing.expect(alt_flag != null);\n    try testing.expect(alt_flag.?.alt);\n\n    const lalt_flag = ModifierFlag.get(\"lalt\");\n    try testing.expect(lalt_flag != null);\n    try testing.expect(lalt_flag.?.lalt);\n    try testing.expect(!lalt_flag.?.alt);\n\n    const invalid_flag = ModifierFlag.get(\"invalid\");\n    try testing.expect(invalid_flag == null);\n}\n\ntest \"Hotkey creation and equality\" {\n    const allocator = testing.allocator;\n\n    // Create two identical hotkeys\n    var hotkey1 = try Hotkey.create(allocator);\n    defer hotkey1.destroy();\n    hotkey1.key = 0x31; // '1' key\n    hotkey1.flags = ModifierFlag{ .cmd = true };\n\n    var hotkey2 = try Hotkey.create(allocator);\n    defer hotkey2.destroy();\n    hotkey2.key = 0x31; // '1' key\n    hotkey2.flags = ModifierFlag{ .cmd = true };\n\n    // Test equality\n    try testing.expect(Hotkey.eql(hotkey1, hotkey2));\n\n    // Change one and test inequality\n    hotkey2.key = 0x32; // '2' key\n    try testing.expect(!Hotkey.eql(hotkey1, hotkey2));\n}\n\ntest \"Hotkey left/right modifier comparison\" {\n    const allocator = testing.allocator;\n\n    // Test that general modifier matches specific modifiers\n    var general_cmd = try Hotkey.create(allocator);\n    defer general_cmd.destroy();\n    general_cmd.key = 0x31;\n    general_cmd.flags = ModifierFlag{ .cmd = true };\n\n    var left_cmd = try Hotkey.create(allocator);\n    defer left_cmd.destroy();\n    left_cmd.key = 0x31;\n    left_cmd.flags = ModifierFlag{ .lcmd = true };\n\n    var right_cmd = try Hotkey.create(allocator);\n    defer right_cmd.destroy();\n    right_cmd.key = 0x31;\n    right_cmd.flags = ModifierFlag{ .rcmd = true };\n\n    // General modifier should NOT match specific modifiers in config comparison\n    // (This is different from keyboard event matching)\n    try testing.expect(!Hotkey.eql(general_cmd, left_cmd));\n    try testing.expect(!Hotkey.eql(general_cmd, right_cmd));\n\n    // But specific modifiers should not match each other\n    try testing.expect(!Hotkey.eql(left_cmd, right_cmd));\n}\n\ntest \"Mode creation and management\" {\n    const allocator = testing.allocator;\n\n    var mode = try Mode.init(allocator, \"test\");\n    defer mode.deinit();\n\n    try testing.expectEqualStrings(\"test\", mode.name);\n    try testing.expect(!mode.capture);\n    try testing.expect(mode.command == null);\n}\n\ntest \"Basic parsing\" {\n    const allocator = testing.allocator;\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    // Parse a simple hotkey\n    try parser.parse(&mappings, \"cmd - a : echo test\");\n\n    // Should have a default mode\n    const default_mode = mappings.mode_map.get(\"default\");\n    try testing.expect(default_mode != null);\n\n    // Should have one hotkey\n    const hotkey_count = default_mode.?.hotkey_map.count();\n    try testing.expect(hotkey_count == 1);\n}\n\ntest \"Mode declaration parsing\" {\n    const allocator = testing.allocator;\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    // Parse mode declaration\n    try parser.parse(&mappings, \":: test : echo \\\"test mode\\\"\");\n\n    // Should have both default and test modes\n    try testing.expect(mappings.mode_map.count() == 2);\n\n    const test_mode = mappings.mode_map.get(\"test\");\n    try testing.expect(test_mode != null);\n    try testing.expectEqualStrings(\"test\", test_mode.?.name);\n}\n\ntest \"Mode switching hotkey\" {\n    const allocator = testing.allocator;\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    // Parse mode declaration and mode switching hotkey\n    try parser.parse(&mappings,\n        \\\\:: test\n        \\\\cmd - t ; test\n    );\n\n    // Should have default and test modes\n    try testing.expect(mappings.mode_map.count() == 2);\n\n    // Find the mode switching hotkey in default mode\n    const default_mode = mappings.mode_map.get(\"default\");\n    try testing.expect(default_mode != null);\n\n    const hotkey_count = default_mode.?.hotkey_map.count();\n    try testing.expect(hotkey_count == 1);\n\n    // Check if the hotkey has activation for wildcard process\n    var hotkey_iter = default_mode.?.hotkey_map.iterator();\n    if (hotkey_iter.next()) |entry| {\n        const hotkey = entry.key_ptr.*;\n        const process_cmd = hotkey.find_command_for_process(\"*\");\n        try testing.expect(process_cmd != null);\n        try testing.expect(process_cmd.? == .activation);\n    }\n}\n\ntest \"Left/right modifier parsing\" {\n    const allocator = testing.allocator;\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    // Parse hotkeys with left/right modifiers\n    try parser.parse(&mappings,\n        \\\\lcmd - a : echo \"left command\"\n        \\\\rcmd - b : echo \"right command\"\n        \\\\lalt + rshift - c : echo \"left alt + right shift\"\n    );\n\n    const default_mode = mappings.mode_map.get(\"default\");\n    try testing.expect(default_mode != null);\n    try testing.expect(default_mode.?.hotkey_map.count() == 3);\n\n    // Check that the flags are parsed correctly\n    var hotkey_iter = default_mode.?.hotkey_map.iterator();\n    var found_lcmd = false;\n    var found_rcmd = false;\n    var found_mixed = false;\n\n    while (hotkey_iter.next()) |entry| {\n        const hotkey = entry.key_ptr.*;\n        if (hotkey.flags.lcmd and !hotkey.flags.rcmd and !hotkey.flags.cmd) {\n            found_lcmd = true;\n        }\n        if (hotkey.flags.rcmd and !hotkey.flags.lcmd and !hotkey.flags.cmd) {\n            found_rcmd = true;\n        }\n        if (hotkey.flags.lalt and hotkey.flags.rshift) {\n            found_mixed = true;\n        }\n    }\n\n    try testing.expect(found_lcmd);\n    try testing.expect(found_rcmd);\n    try testing.expect(found_mixed);\n}\n\ntest \"Blacklist parsing\" {\n    const allocator = testing.allocator;\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    // Parse blacklist\n    try parser.parse(&mappings,\n        \\\\.blacklist [\n        \\\\    \"terminal\"\n        \\\\    \"finder\"\n        \\\\]\n    );\n\n    try testing.expect(mappings.blacklist.contains(\"terminal\"));\n    try testing.expect(mappings.blacklist.contains(\"finder\"));\n    try testing.expect(!mappings.blacklist.contains(\"safari\"));\n}\n\ntest \"Shell option parsing\" {\n    const allocator = testing.allocator;\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    // Default shell should be from environment or /bin/bash\n    const initial_shell = mappings.shell;\n    try testing.expect(initial_shell.len > 0);\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    // Test parsing shell option\n    const config =\n        \\\\.shell \"/usr/bin/env zsh\"\n        \\\\cmd - a : echo \"test\"\n    ;\n\n    try parser.parse(&mappings, config);\n\n    // Shell should be updated\n    try testing.expectEqualStrings(\"/usr/bin/env zsh\", mappings.shell);\n}\n\ntest \"Shell from environment\" {\n    const allocator = testing.allocator;\n\n    // Test that SHELL env var is respected on init\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    // Should use SHELL env var if set, otherwise /bin/bash\n    if (std.posix.getenv(\"SHELL\")) |env_shell| {\n        try testing.expectEqualStrings(env_shell, mappings.shell);\n    } else {\n        try testing.expectEqualStrings(\"/bin/bash\", mappings.shell);\n    }\n}\n\ntest \"Config file resolution\" {\n    const allocator = testing.allocator;\n\n    // Test getting config file\n    const getConfigFile = @import(\"main.zig\").getConfigFile;\n\n    // This should resolve to a path based on environment\n    const config_path = try getConfigFile(allocator, \"skhdrc\");\n    defer allocator.free(config_path);\n\n    // Should be one of:\n    // - $XDG_CONFIG_HOME/skhd/skhdrc\n    // - $HOME/.config/skhd/skhdrc\n    // - $HOME/.skhdrc\n    // - skhdrc (in current dir)\n    try testing.expect(config_path.len > 0);\n\n    // Test that the function returns a valid path\n    if (std.posix.getenv(\"HOME\")) |home| {\n        // If we have HOME, the path should contain it or be the fallback\n        const has_home = std.mem.indexOf(u8, config_path, home) != null;\n        const is_fallback = std.mem.eql(u8, config_path, \"skhdrc\");\n        try testing.expect(has_home or is_fallback);\n    }\n}\n\ntest \"Config reload memory leak test\" {\n    const allocator = testing.allocator;\n\n    // Create temporary config files\n    const test_id = std.crypto.random.int(u32);\n    const config_path = try std.fmt.allocPrint(allocator, \"/tmp/skhd_test_reload_{d}.skhdrc\", .{test_id});\n    defer allocator.free(config_path);\n\n    // Write initial config\n    {\n        const initial_config =\n            \\\\# Initial test config\n            \\\\cmd - a : echo \"initial A\"\n            \\\\cmd - b : echo \"initial B\"\n            \\\\:: test_mode\n            \\\\test_mode < cmd - x : echo \"test mode X\"\n        ;\n\n        const file = try std.fs.createFileAbsolute(config_path, .{});\n        defer file.close();\n        try file.writeAll(initial_config);\n    }\n\n    // Clean up config file after test\n    defer std.fs.deleteFileAbsolute(config_path) catch {};\n\n    // Initialize skhd with initial config\n    var skhd = try Skhd.init(allocator, config_path, false, false);\n    defer skhd.deinit();\n\n    // Verify initial state\n    try testing.expect(skhd.mappings.mode_map.count() == 2); // default and test_mode\n    const default_mode = skhd.mappings.mode_map.get(\"default\");\n    try testing.expect(default_mode != null);\n    try testing.expect(default_mode.?.hotkey_map.count() == 2); // a and b keys\n\n    // Write modified config\n    {\n        const modified_config =\n            \\\\# Modified test config\n            \\\\cmd - a : echo \"modified A\"\n            \\\\cmd - c : echo \"new C\"\n            \\\\cmd - d : echo \"new D\"\n            \\\\:: another_mode\n            \\\\another_mode < cmd - y : echo \"another mode Y\"\n            \\\\.blacklist [\n            \\\\    \"terminal\"\n            \\\\]\n        ;\n\n        const file = std.fs.openFileAbsolute(config_path, .{ .mode = .write_only }) catch unreachable;\n        defer file.close();\n        try file.setEndPos(0); // Truncate file\n        try file.writeAll(modified_config);\n    }\n\n    // Reload config\n    try skhd.reloadConfig();\n\n    // Verify reloaded state\n    try testing.expect(skhd.mappings.mode_map.count() == 2); // default and another_mode\n    const reloaded_default = skhd.mappings.mode_map.get(\"default\");\n    try testing.expect(reloaded_default != null);\n    try testing.expect(reloaded_default.?.hotkey_map.count() == 3); // a, c, and d keys\n\n    // Check that test_mode is gone and another_mode exists\n    try testing.expect(skhd.mappings.mode_map.get(\"test_mode\") == null);\n    try testing.expect(skhd.mappings.mode_map.get(\"another_mode\") != null);\n\n    // Check blacklist was loaded\n    try testing.expect(skhd.mappings.blacklist.contains(\"terminal\"));\n\n    // Test multiple reloads to ensure no memory leaks\n    for (0..5) |i| {\n        // Modify config again\n        const multi_config = try std.fmt.allocPrint(allocator,\n            \\\\# Reload test {d}\n            \\\\cmd - {c} : echo \"reload {d}\"\n        , .{ i, 'a' + @as(u8, @intCast(i)), i });\n        defer allocator.free(multi_config);\n\n        const file = std.fs.openFileAbsolute(config_path, .{ .mode = .write_only }) catch unreachable;\n        defer file.close();\n        try file.setEndPos(0);\n        try file.writeAll(multi_config);\n\n        // Reload\n        try skhd.reloadConfig();\n\n        // Verify state after each reload\n        const mode = skhd.mappings.mode_map.get(\"default\");\n        try testing.expect(mode != null);\n        try testing.expect(mode.?.hotkey_map.count() == 1);\n    }\n\n    // The testing allocator will detect any memory leaks when skhd.deinit() is called\n}\n\ntest \"Config reload preserves current mode\" {\n    const allocator = testing.allocator;\n\n    // Create temporary config file\n    const test_id = std.crypto.random.int(u32);\n    const config_path = try std.fmt.allocPrint(allocator, \"/tmp/skhd_test_mode_{d}.skhdrc\", .{test_id});\n    defer allocator.free(config_path);\n\n    // Write config with modes\n    {\n        const config =\n            \\\\:: default\n            \\\\cmd - a : echo \"default A\"\n            \\\\\n            \\\\:: special @ : echo \"entered special mode\"\n            \\\\cmd - t ; special\n            \\\\special < cmd - b : echo \"special B\"\n            \\\\special < escape ; default\n        ;\n\n        const file = try std.fs.createFileAbsolute(config_path, .{});\n        defer file.close();\n        try file.writeAll(config);\n    }\n\n    defer std.fs.deleteFileAbsolute(config_path) catch {};\n\n    // Initialize skhd\n    var skhd = try Skhd.init(allocator, config_path, false, false);\n    defer skhd.deinit();\n\n    // Switch to special mode\n    skhd.current_mode = skhd.mappings.mode_map.getPtr(\"special\");\n    try testing.expect(skhd.current_mode != null);\n    try testing.expectEqualStrings(\"special\", skhd.current_mode.?.name);\n\n    // Reload config\n    try skhd.reloadConfig();\n\n    // Should be back in default mode after reload\n    try testing.expect(skhd.current_mode != null);\n    try testing.expectEqualStrings(\"default\", skhd.current_mode.?.name);\n}\n\ntest \"Parser error messages\" {\n    const allocator = testing.allocator;\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    // Test missing '<' after mode\n    parser.clearError();\n    const err1 = parser.parse(&mappings, \"mymode cmd - a : echo test\");\n    try testing.expectError(error.ParseErrorOccurred, err1);\n    try testing.expect(parser.error_info != null);\n    const parse_err1 = parser.error_info.?;\n    try testing.expect(std.mem.containsAtLeast(u8, parse_err1.message, 1, \"Mode 'mymode' not found\"));\n    try testing.expect(parse_err1.line == 1);\n\n    // Create the mode first, then test missing '<'\n    try mappings.put_mode(try Mode.init(allocator, \"mymode\"));\n    parser.clearError();\n    const err1b = parser.parse(&mappings, \"mymode cmd - a : echo test\");\n    try testing.expectError(error.ParseErrorOccurred, err1b);\n    const parse_err1b = parser.error_info.?;\n    try testing.expectEqualStrings(\"Expected '<' after mode identifier\", parse_err1b.message);\n\n    // Test unknown mode\n    parser.clearError();\n    const err2 = parser.parse(&mappings, \"foo - b : echo test\");\n    try testing.expectError(error.ParseErrorOccurred, err2);\n    try testing.expect(parser.error_info != null);\n    const parse_err2 = parser.error_info.?;\n    try testing.expect(std.mem.containsAtLeast(u8, parse_err2.message, 1, \"Mode 'foo' not found\"));\n\n    // Test missing '-' after modifier\n    parser.clearError();\n    const err3 = parser.parse(&mappings, \"cmd b : echo test\");\n    try testing.expectError(error.ParseErrorOccurred, err3);\n    try testing.expect(parser.error_info != null);\n    const parse_err3 = parser.error_info.?;\n    try testing.expectEqualStrings(\"Expected '-' after modifier\", parse_err3.message);\n\n    // Test unknown key\n    parser.clearError();\n    const err4 = parser.parse(&mappings, \"cmd - unknown_key : echo test\");\n    try testing.expectError(error.ParseErrorOccurred, err4);\n    try testing.expect(parser.error_info != null);\n    const parse_err4 = parser.error_info.?;\n    try testing.expectEqualStrings(\"Expected key, key hex, or literal\", parse_err4.message);\n\n    // Test empty process list\n    parser.clearError();\n    const err5 = parser.parse(&mappings, \"cmd - d []\");\n    try testing.expectError(error.ParseErrorOccurred, err5);\n    try testing.expect(parser.error_info != null);\n    const parse_err5 = parser.error_info.?;\n    try testing.expectEqualStrings(\"Empty process list\", parse_err5.message);\n\n    // Test duplicate mode declaration\n    parser.clearError();\n    const err6 = parser.parse(&mappings, \":: test_mode\\n:: test_mode\");\n    try testing.expectError(error.ParseErrorOccurred, err6);\n    try testing.expect(parser.error_info != null);\n    const parse_err6 = parser.error_info.?;\n    try testing.expect(std.mem.containsAtLeast(u8, parse_err6.message, 1, \"Mode 'test_mode' already exists\"));\n    try testing.expect(parse_err6.line == 2);\n\n    // Test unknown option\n    parser.clearError();\n    const err7 = parser.parse(&mappings, \".unknown_option\");\n    try testing.expectError(error.ParseErrorOccurred, err7);\n    try testing.expect(parser.error_info != null);\n    const parse_err7 = parser.error_info.?;\n    try testing.expect(std.mem.containsAtLeast(u8, parse_err7.message, 1, \"Unknown option 'unknown_option'\"));\n}\n\ntest \"Parser error message formatting\" {\n\n    // Test error formatting without file path\n    var err1 = try ParseError.fromPosition(testing.allocator, 5, 10, \"Test error message\", null);\n    defer err1.deinit();\n    var buf: [256]u8 = undefined;\n    const result1 = try std.fmt.bufPrint(&buf, \"{}\", .{err1});\n    try testing.expectEqualStrings(\"5:10: error: Test error message\", result1);\n\n    // Test error formatting with file path\n    var err2 = try ParseError.fromPosition(testing.allocator, 3, 7, \"Another error\", \"test.skhdrc\");\n    defer err2.deinit();\n    const result2 = try std.fmt.bufPrint(&buf, \"{}\", .{err2});\n    try testing.expectEqualStrings(\"test.skhdrc:3:7: error: Another error\", result2);\n\n    // Test error with token text\n    const token = @import(\"Tokenizer.zig\").Token{\n        .type = .Token_Key,\n        .text = \"badkey\",\n        .line = 2,\n        .cursor = 15,\n    };\n    var err3 = try ParseError.fromToken(testing.allocator, token, \"Unknown key\", \"config.skhdrc\");\n    defer err3.deinit();\n    const result3 = try std.fmt.bufPrint(&buf, \"{}\", .{err3});\n    try testing.expectEqualStrings(\"config.skhdrc:2:15: error: Unknown key near 'badkey'\", result3);\n}\n\ntest \"Parser error with multiline input\" {\n    const allocator = testing.allocator;\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    // Test error on line 3\n    const multiline_config =\n        \\\\# Comment line\n        \\\\cmd - a : echo \"valid\"\n        \\\\bad - x : echo \"error\"\n        \\\\cmd - b : echo \"another\"\n    ;\n\n    parser.clearError();\n    const err = parser.parse(&mappings, multiline_config);\n    try testing.expectError(error.ParseErrorOccurred, err);\n\n    const parse_err = parser.error_info.?;\n    try testing.expect(std.mem.containsAtLeast(u8, parse_err.message, 1, \"Mode 'bad' not found\"));\n    try testing.expect(parse_err.line == 3);\n    try testing.expectEqualStrings(\"bad\", parse_err.token_text.?);\n}\n\ntest \"Hot reload enable/disable\" {\n    const allocator = testing.allocator;\n\n    // Create temporary config file\n    const test_id = std.crypto.random.int(u32);\n    const config_path = try std.fmt.allocPrint(allocator, \"/tmp/skhd_test_hotload_{d}.skhdrc\", .{test_id});\n    defer allocator.free(config_path);\n\n    // Write initial config\n    {\n        const config = \"cmd - a : echo \\\"test A\\\"\";\n        const file = try std.fs.createFileAbsolute(config_path, .{});\n        defer file.close();\n        try file.writeAll(config);\n    }\n\n    defer std.fs.deleteFileAbsolute(config_path) catch {};\n\n    // Initialize skhd\n    var skhd = try Skhd.init(allocator, config_path, false, false);\n    defer skhd.deinit();\n\n    // Test enabling hot reload\n    try testing.expect(!skhd.hotload_enabled);\n    try skhd.enableHotReload();\n    try testing.expect(skhd.hotload_enabled);\n    try testing.expect(skhd.hotloader != null);\n\n    // Test disabling hot reload\n    skhd.disableHotReload();\n    try testing.expect(!skhd.hotload_enabled);\n    try testing.expect(skhd.hotloader == null);\n\n    // Test re-enabling\n    try skhd.enableHotReload();\n    try testing.expect(skhd.hotload_enabled);\n\n    // Double enable should be safe\n    try skhd.enableHotReload();\n    try testing.expect(skhd.hotload_enabled);\n}\n\ntest \"modifier matching - general modifiers match specific ones\" {\n    const allocator = std.testing.allocator;\n\n    // Create test config\n    const config_path = \"/tmp/skhd_test_modifier_matching.txt\";\n    {\n        const config =\n            \\\\# Test modifier matching\n            \\\\alt - a : echo \"alt - a\"\n            \\\\lalt - b : echo \"lalt - b\"\n            \\\\cmd + shift - c : echo \"cmd + shift - c\"\n            \\\\lcmd + lshift - d : echo \"lcmd + lshift - d\"\n        ;\n        const file = try std.fs.createFileAbsolute(config_path, .{});\n        defer file.close();\n        try file.writeAll(config);\n    }\n    defer std.fs.deleteFileAbsolute(config_path) catch {};\n\n    var skhd = try Skhd.init(allocator, config_path, false, false);\n    defer skhd.deinit();\n\n    // Get default mode\n    const mode = skhd.mappings.mode_map.get(\"default\").?;\n\n    // Test 1: Config \"alt - a\" should be found with keyboard \"lalt - a\"\n    {\n        const keyboard_key = Hotkey.KeyPress{\n            .flags = ModifierFlag{ .lalt = true },\n            .key = 0, // 'a' key\n        };\n\n        // Find matching hotkey using our lookup abstraction\n        const found = skhd.findHotkeyInMode(&mode, keyboard_key);\n\n        try testing.expect(found != null);\n        try testing.expect(found.?.flags.alt);\n        try testing.expect(!found.?.flags.lalt);\n    }\n\n    // Test 2: Config \"lalt - b\" should NOT be found with keyboard \"ralt - b\"\n    {\n        const keyboard_key = Hotkey.KeyPress{\n            .flags = ModifierFlag{ .ralt = true },\n            .key = 11, // 'b' key\n        };\n\n        // Find matching hotkey using our lookup abstraction\n        const found = skhd.findHotkeyInMode(&mode, keyboard_key);\n\n        try testing.expect(found == null);\n    }\n\n    // Test 3: Config \"cmd + shift - c\" should match \"lcmd + lshift - c\"\n    {\n        const keyboard_key = Hotkey.KeyPress{\n            .flags = ModifierFlag{ .lcmd = true, .lshift = true },\n            .key = 8, // 'c' key\n        };\n\n        // Find matching hotkey using our lookup abstraction\n        const found = skhd.findHotkeyInMode(&mode, keyboard_key);\n\n        try testing.expect(found != null);\n        try testing.expect(found.?.flags.cmd);\n        try testing.expect(found.?.flags.shift);\n    }\n}\n\ntest \"keyboard lalt should match config alt\" {\n    const allocator = std.testing.allocator;\n\n    // Create test config with just \"alt - a\"\n    const config_path = \"/tmp/skhd_test_lalt_matches_alt.txt\";\n    {\n        const config = \"alt - a : echo \\\"alt - a pressed\\\"\";\n        const file = try std.fs.createFileAbsolute(config_path, .{});\n        defer file.close();\n        try file.writeAll(config);\n    }\n    defer std.fs.deleteFileAbsolute(config_path) catch {};\n\n    var skhd = try Skhd.init(allocator, config_path, false, false);\n    defer skhd.deinit();\n\n    // Get default mode\n    const mode = skhd.mappings.mode_map.get(\"default\").?;\n\n    // Test: Keyboard \"lalt - a\" should match config \"alt - a\"\n    {\n        const keyboard_key = Hotkey.KeyPress{\n            .flags = ModifierFlag{ .lalt = true },\n            .key = 0, // 'a' key\n        };\n\n        // Find matching hotkey using our lookup abstraction\n        const found = skhd.findHotkeyInMode(&mode, keyboard_key);\n\n        if (found == null) {\n            std.debug.print(\"Test failed: Could not find hotkey for lalt - a\\n\", .{});\n            std.debug.print(\"Looking for keyboard_key: flags={any}, key={d}\\n\", .{ keyboard_key.flags, keyboard_key.key });\n            std.debug.print(\"Available hotkeys in map:\\n\", .{});\n            var it = mode.hotkey_map.iterator();\n            while (it.next()) |entry| {\n                const hotkey = entry.key_ptr.*;\n                std.debug.print(\"  config hotkey: flags={any}, key={d}\\n\", .{ hotkey.flags, hotkey.key });\n            }\n        }\n        try testing.expect(found != null);\n        try testing.expect(found.?.flags.alt);\n        try testing.expect(!found.?.flags.lalt);\n    }\n\n    // Also test ralt should match\n    {\n        const keyboard_key = Hotkey.KeyPress{\n            .flags = ModifierFlag{ .ralt = true },\n            .key = 0, // 'a' key\n        };\n\n        const ctx = Hotkey.KeyboardLookupContext{};\n        const found = mode.hotkey_map.getKeyAdapted(keyboard_key, ctx);\n\n        try testing.expect(found != null);\n        try testing.expect(found.?.flags.alt);\n        try testing.expect(!found.?.flags.ralt);\n    }\n\n    // And general alt should also match\n    {\n        const keyboard_key = Hotkey.KeyPress{\n            .flags = ModifierFlag{ .alt = true },\n            .key = 0, // 'a' key\n        };\n\n        const ctx = Hotkey.KeyboardLookupContext{};\n        const found = mode.hotkey_map.getKeyAdapted(keyboard_key, ctx);\n\n        try testing.expect(found != null);\n        try testing.expect(found.?.flags.alt);\n    }\n}\n\ntest \"find_command_for_process function with process matching\" {\n    const allocator = std.testing.allocator;\n\n    // Create a hotkey with some process names\n    var hotkey = try Hotkey.create(allocator);\n    defer hotkey.destroy();\n\n    try hotkey.add_process_command(\"chrome\", \"echo chrome\");\n    try hotkey.add_process_command(\"firefox\", \"echo firefox\");\n    try hotkey.add_process_command(\"whatsapp\", \"echo whatsapp\");\n    try hotkey.add_process_command(\"*\", \"echo wildcard\");\n\n    // Test finding existing processes (case insensitive)\n    const chrome_cmd = hotkey.find_command_for_process(\"Chrome\");\n    try testing.expect(chrome_cmd != null);\n    try testing.expectEqualStrings(\"echo chrome\", chrome_cmd.?.command);\n\n    const firefox_cmd = hotkey.find_command_for_process(\"FIREFOX\");\n    try testing.expect(firefox_cmd != null);\n    try testing.expectEqualStrings(\"echo firefox\", firefox_cmd.?.command);\n\n    // Test wildcard fallback\n    const notepad_cmd = hotkey.find_command_for_process(\"notepad\");\n    try testing.expect(notepad_cmd != null);\n    try testing.expectEqualStrings(\"echo wildcard\", notepad_cmd.?.command);\n}\n\ntest \"multiple process groups and reuse\" {\n    const allocator = std.testing.allocator;\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    // Test multiple groups and reusing them\n    const content =\n        \\\\.define terminal_apps [\"kitty\", \"wezterm\", \"terminal\"]\n        \\\\.define browser_apps [\"chrome\", \"safari\", \"firefox\"]\n        \\\\.define native_apps [\"kitty\", \"wezterm\", \"chrome\", \"whatsapp\"]\n        \\\\\n        \\\\# Delete word\n        \\\\ctrl - backspace [\n        \\\\    @terminal_apps ~\n        \\\\    *              | alt - backspace\n        \\\\]\n        \\\\\n        \\\\# Move word\n        \\\\ctrl - left [\n        \\\\    @terminal_apps ~\n        \\\\    *              | alt - left\n        \\\\]\n        \\\\\n        \\\\# Home key\n        \\\\home [\n        \\\\    @native_apps ~\n        \\\\    *            | cmd - left\n        \\\\]\n    ;\n\n    try parser.parse(&mappings, content);\n\n    // Check that all process groups were created\n    try testing.expect(parser.process_groups.contains(\"terminal_apps\"));\n    try testing.expect(parser.process_groups.contains(\"browser_apps\"));\n    try testing.expect(parser.process_groups.contains(\"native_apps\"));\n\n    // Check group contents\n    const terminal_group = parser.process_groups.get(\"terminal_apps\").?;\n    try testing.expectEqual(@as(usize, 3), terminal_group.len);\n\n    const browser_group = parser.process_groups.get(\"browser_apps\").?;\n    try testing.expectEqual(@as(usize, 3), browser_group.len);\n\n    const native_group = parser.process_groups.get(\"native_apps\").?;\n    try testing.expectEqual(@as(usize, 4), native_group.len);\n\n    // Check that hotkeys were created\n    const default_mode = mappings.mode_map.get(\"default\").?;\n    try testing.expectEqual(@as(usize, 3), default_mode.hotkey_map.count());\n\n    // Verify that terminal apps are properly set as unbound\n    var it = default_mode.hotkey_map.iterator();\n    while (it.next()) |entry| {\n        const hotkey = entry.key_ptr.*;\n\n        // Check if terminal apps have unbound action\n        if (hotkey.find_command_for_process(\"kitty\")) |cmd| {\n            if (hotkey.key == 0x33 or hotkey.key == 0x7B) { // backspace or left\n                try testing.expect(cmd == .unbound);\n            }\n        }\n\n        // Check if chrome has unbound action for home key\n        if (hotkey.key == 0x73) { // home\n            if (hotkey.find_command_for_process(\"chrome\")) |cmd| {\n                try testing.expect(cmd == .unbound);\n            }\n        }\n    }\n}\n\ntest \"Command definitions - single placeholder\" {\n    const allocator = testing.allocator;\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    const config =\n        \\\\.define yabai_focus : yabai -m window --focus {{1}}\n        \\\\cmd - h : @yabai_focus(\"west\")\n        \\\\cmd - l : @yabai_focus(\"east\")\n    ;\n\n    try parser.parse(&mappings, config);\n\n    // Check command definition\n    const cmd_def = parser.command_defs.get(\"yabai_focus\").?;\n    // Should have two parts: text and placeholder\n    try testing.expectEqual(@as(usize, 2), cmd_def.parts.len);\n    try testing.expect(cmd_def.parts[0] == .text);\n    try testing.expectEqualStrings(\"yabai -m window --focus \", cmd_def.parts[0].text);\n    try testing.expect(cmd_def.parts[1] == .placeholder);\n    try testing.expectEqual(@as(u8, 1), cmd_def.parts[1].placeholder);\n    try testing.expectEqual(@as(u8, 1), cmd_def.max_placeholder);\n\n    // Find hotkeys and check their commands\n    var hotkey_count: usize = 0;\n    var it = mappings.mode_map.get(\"default\").?.hotkey_map.iterator();\n    while (it.next()) |entry| {\n        const hotkey = entry.key_ptr.*;\n        // Since these hotkeys don't have process-specific mappings, they should have wildcard commands\n        const cmd = hotkey.find_command_for_process(\"*\");\n\n        if (hotkey.key == 4) { // 'h' key\n            try testing.expect(cmd != null);\n            try testing.expectEqualStrings(\"yabai -m window --focus west\", cmd.?.command);\n            hotkey_count += 1;\n        } else if (hotkey.key == 37) { // 'l' key\n            try testing.expect(cmd != null);\n            try testing.expectEqualStrings(\"yabai -m window --focus east\", cmd.?.command);\n            hotkey_count += 1;\n        }\n    }\n    try testing.expectEqual(@as(usize, 2), hotkey_count);\n}\n\ntest \"Command definitions - multiple placeholders\" {\n    const allocator = testing.allocator;\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    const config =\n        \\\\.define window_action : yabai -m window --{{1}} {{2}}\n        \\\\cmd - h : @window_action(\"focus\", \"west\")\n    ;\n\n    try parser.parse(&mappings, config);\n\n    // Check command definition\n    const cmd_def = parser.command_defs.get(\"window_action\").?;\n    try testing.expectEqual(@as(u8, 2), cmd_def.max_placeholder);\n\n    // Check expanded command\n    var it = mappings.mode_map.get(\"default\").?.hotkey_map.iterator();\n    const entry = it.next().?;\n    const hotkey = entry.key_ptr.*;\n    const cmd = hotkey.find_command_for_process(\"*\");\n    try testing.expect(cmd != null);\n    try testing.expectEqualStrings(\"yabai -m window --focus west\", cmd.?.command);\n}\n\ntest \"Command definitions - repeated placeholder\" {\n    const allocator = testing.allocator;\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    const config =\n        \\\\.define toggle_app : yabai -m window --toggle {{1}} || open -a \"{{1}}\"\n        \\\\cmd - m : @toggle_app(\"Music\")\n    ;\n\n    try parser.parse(&mappings, config);\n\n    // Check expanded command\n    var it = mappings.mode_map.get(\"default\").?.hotkey_map.iterator();\n    const entry = it.next().?;\n    const hotkey = entry.key_ptr.*;\n    const cmd = hotkey.find_command_for_process(\"*\");\n    try testing.expect(cmd != null);\n    try testing.expectEqualStrings(\"yabai -m window --toggle Music || open -a \\\"Music\\\"\", cmd.?.command);\n}\n\ntest \"Command definitions - in process list\" {\n    const allocator = testing.allocator;\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    const config =\n        \\\\.define echo_test : echo \"{{1}}\"\n        \\\\cmd - a [\n        \\\\    \"terminal\" : @echo_test(\"terminal app\")\n        \\\\    * : @echo_test(\"other app\")\n        \\\\]\n    ;\n\n    try parser.parse(&mappings, config);\n\n    // Check hotkey has correct commands\n    var it = mappings.mode_map.get(\"default\").?.hotkey_map.iterator();\n    const entry = it.next().?;\n    const hotkey = entry.key_ptr.*;\n\n    // Test process-specific commands\n    const terminal_cmd = hotkey.find_command_for_process(\"terminal\");\n    try testing.expect(terminal_cmd != null);\n    try testing.expectEqualStrings(\"echo \\\"terminal app\\\"\", terminal_cmd.?.command);\n\n    // Test wildcard command\n    const other_cmd = hotkey.find_command_for_process(\"some_other_app\");\n    try testing.expect(other_cmd != null);\n    try testing.expectEqualStrings(\"echo \\\"other app\\\"\", other_cmd.?.command);\n\n    // Verify the process count (terminal + wildcard)\n    try testing.expectEqual(@as(usize, 1), hotkey.getProcessCount()); // Only \"terminal\" is in mappings, wildcard is separate\n}\n\ntest \"Command definitions - mode declaration\" {\n    const allocator = testing.allocator;\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    const config =\n        \\\\.define mode_cmd : echo \"Entering {{1}} mode\"\n        \\\\:: test_mode : @mode_cmd(\"test\")\n    ;\n\n    try parser.parse(&mappings, config);\n\n    // Check mode has expanded command\n    const mode = mappings.mode_map.get(\"test_mode\").?;\n    try testing.expectEqualStrings(\"echo \\\"Entering test mode\\\"\", mode.command.?);\n}\n\ntest \"Command definitions - with escaped quotes\" {\n    const allocator = testing.allocator;\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    const config =\n        \\\\.define notify : osascript -e 'display notification \"{{1}}\" with title \"{{2}}\"'\n        \\\\cmd - n : @notify(\"Hello \\\"World\\\"\", \"Test \\\"Message\\\"\")\n    ;\n\n    try parser.parse(&mappings, config);\n\n    // Check expanded command has properly escaped quotes\n    var it = mappings.mode_map.get(\"default\").?.hotkey_map.iterator();\n    const entry = it.next().?;\n    const hotkey = entry.key_ptr.*;\n    const cmd = hotkey.find_command_for_process(\"*\");\n    try testing.expect(cmd != null);\n    try testing.expectEqualStrings(\"osascript -e 'display notification \\\"Hello \\\"World\\\"\\\" with title \\\"Test \\\"Message\\\"\\\"'\", cmd.?.command);\n}\n\ntest \"Duplicate hotkey detection - same mode same hotkey\" {\n    const allocator = std.testing.allocator;\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    // Define the same hotkey twice in default mode\n    const config =\n        \\\\cmd - a : echo \"first\"\n        \\\\cmd - a : echo \"second\"\n    ;\n\n    // This should report an error\n    const result = parser.parse(&mappings, config);\n    try testing.expectError(error.ParseErrorOccurred, result);\n\n    // Check error message\n    const error_info = parser.error_info.?;\n    try testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, \"Duplicate hotkey\"));\n    try testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, \"cmd - a\"));\n    try testing.expect(error_info.line == 2);\n}\n\ntest \"Duplicate hotkey detection - specific modifiers\" {\n    const allocator = std.testing.allocator;\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    // Test exact duplicates with specific modifiers\n    const config =\n        \\\\lcmd - a : echo \"first\"\n        \\\\lcmd - a : echo \"second\"\n    ;\n\n    const result = parser.parse(&mappings, config);\n    try testing.expectError(error.ParseErrorOccurred, result);\n\n    const error_info = parser.error_info.?;\n    try testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, \"Duplicate hotkey\"));\n    try testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, \"lcmd - a\"));\n}\n\ntest \"Duplicate hotkey detection - different modes allowed\" {\n    const allocator = std.testing.allocator;\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    // Same hotkey in different modes should be allowed\n    const config =\n        \\\\:: test_mode\n        \\\\cmd - a : echo \"default\"\n        \\\\test_mode < cmd - a : echo \"test mode\"\n    ;\n\n    // This should parse successfully\n    try parser.parse(&mappings, config);\n\n    // Verify both hotkeys exist\n    const default_mode = mappings.mode_map.get(\"default\").?;\n    const test_mode = mappings.mode_map.get(\"test_mode\").?;\n    try testing.expectEqual(@as(usize, 1), default_mode.hotkey_map.count());\n    try testing.expectEqual(@as(usize, 1), test_mode.hotkey_map.count());\n}\n\ntest \"Duplicate hotkey detection - left/right modifiers are different\" {\n    const allocator = std.testing.allocator;\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    // Different left/right modifiers should be allowed (not duplicates)\n    const config =\n        \\\\lcmd - a : echo \"left cmd\"\n        \\\\rcmd - a : echo \"right cmd\"\n    ;\n\n    try parser.parse(&mappings, config);\n\n    const default_mode = mappings.mode_map.get(\"default\").?;\n    try testing.expectEqual(@as(usize, 2), default_mode.hotkey_map.count());\n}\n\ntest \"Duplicate hotkey detection - general modifier conflicts with specific modifiers\" {\n    const allocator = std.testing.allocator;\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    const config =\n        \\\\cmd - a : echo \"general cmd\"\n        \\\\lcmd - a : echo \"left cmd\"\n    ;\n\n    const result = parser.parse(&mappings, config);\n    try testing.expectError(error.ParseErrorOccurred, result);\n    try testing.expect(std.mem.containsAtLeast(u8, parser.error_info.?.message, 1, \"Duplicate hotkey\"));\n}\n\ntest \"Duplicate hotkey detection - specific modifier conflicts with later general modifier\" {\n    const allocator = std.testing.allocator;\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    const config =\n        \\\\lcmd - a : echo \"left cmd\"\n        \\\\cmd - a : echo \"general cmd\"\n    ;\n\n    const result = parser.parse(&mappings, config);\n    try testing.expectError(error.ParseErrorOccurred, result);\n    try testing.expect(std.mem.containsAtLeast(u8, parser.error_info.?.message, 1, \"Duplicate hotkey\"));\n}\n\ntest \"Duplicate hotkey detection - multi mode assignment\" {\n    const allocator = std.testing.allocator;\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    // Test duplicate detection with multi-mode hotkey\n    const config =\n        \\\\:: mode1\n        \\\\:: mode2\n        \\\\mode1, mode2 < cmd - a : echo \"multi mode\"\n        \\\\mode1 < cmd - a : echo \"duplicate in mode1\"\n    ;\n\n    const result = parser.parse(&mappings, config);\n    try testing.expectError(error.ParseErrorOccurred, result);\n\n    const error_info = parser.error_info.?;\n    try testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, \"Duplicate hotkey\"));\n    try testing.expect(std.mem.containsAtLeast(u8, error_info.message, 1, \"mode1\"));\n    try testing.expect(error_info.line == 4);\n}\n\ntest \"Unbound action - simple syntax\" {\n    const allocator = std.testing.allocator;\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    const config =\n        \\\\cmd - a ~\n        \\\\cmd - b : echo \"normal command\"\n    ;\n\n    try parser.parse(&mappings, config);\n\n    // Check that unbound hotkey was created\n    const default_mode = mappings.mode_map.get(\"default\").?;\n    try testing.expectEqual(@as(usize, 2), default_mode.hotkey_map.count());\n\n    // Find the unbound hotkey\n    var it = default_mode.hotkey_map.iterator();\n    var found_unbound = false;\n    while (it.next()) |entry| {\n        const hotkey = entry.key_ptr.*;\n        if (hotkey.key == 0x00) { // a key\n            found_unbound = true;\n            // Check it has unbound command\n            if (hotkey.find_command_for_process(\"*\")) |cmd| {\n                try testing.expect(cmd == .unbound);\n            } else {\n                return error.TestExpectUnboundCommand;\n            }\n        }\n    }\n    try testing.expect(found_unbound);\n}\n\ntest \"Unbound action - with modes\" {\n    const allocator = std.testing.allocator;\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    const config =\n        \\\\:: test_mode\n        \\\\test_mode < cmd - x ~\n        \\\\test_mode < cmd - y : echo \"in test mode\"\n        \\\\cmd - z ~\n    ;\n\n    try parser.parse(&mappings, config);\n\n    // Check test_mode has unbound hotkey\n    const test_mode = mappings.mode_map.get(\"test_mode\").?;\n    var found_mode_unbound = false;\n    var it = test_mode.hotkey_map.iterator();\n    while (it.next()) |entry| {\n        const hotkey = entry.key_ptr.*;\n        if (hotkey.key == 0x07) { // x key\n            found_mode_unbound = true;\n            if (hotkey.find_command_for_process(\"*\")) |cmd| {\n                try testing.expect(cmd == .unbound);\n            }\n        }\n    }\n    try testing.expect(found_mode_unbound);\n\n    // Check default mode has unbound hotkey\n    const default_mode = mappings.mode_map.get(\"default\").?;\n    var found_default_unbound = false;\n    it = default_mode.hotkey_map.iterator();\n    while (it.next()) |entry| {\n        const hotkey = entry.key_ptr.*;\n        if (hotkey.key == 0x06) { // z key\n            found_default_unbound = true;\n            if (hotkey.find_command_for_process(\"*\")) |cmd| {\n                try testing.expect(cmd == .unbound);\n            }\n        }\n    }\n    try testing.expect(found_default_unbound);\n}\n\ntest \"Unbound action - with passthrough\" {\n    const allocator = std.testing.allocator;\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    const config =\n        \\\\cmd - a -> ~\n    ;\n\n    try parser.parse(&mappings, config);\n\n    // Check that hotkey has both passthrough flag and unbound command\n    const default_mode = mappings.mode_map.get(\"default\").?;\n    var it = default_mode.hotkey_map.iterator();\n    const entry = it.next().?;\n    const hotkey = entry.key_ptr.*;\n\n    try testing.expect(hotkey.flags.passthrough);\n    if (hotkey.find_command_for_process(\"*\")) |cmd| {\n        try testing.expect(cmd == .unbound);\n    }\n}\n\ntest \"Unbound action - mixed with process lists\" {\n    const allocator = std.testing.allocator;\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    const config =\n        \\\\cmd - space ~  # Simple unbound\n        \\\\cmd - tab [     # Process list with unbound\n        \\\\    \"terminal\" ~\n        \\\\    \"firefox\" : echo \"firefox tab\"\n        \\\\    * | ctrl - tab\n        \\\\]\n    ;\n\n    try parser.parse(&mappings, config);\n\n    const default_mode = mappings.mode_map.get(\"default\").?;\n    try testing.expectEqual(@as(usize, 2), default_mode.hotkey_map.count());\n\n    // Check cmd-space is fully unbound\n    var it = default_mode.hotkey_map.iterator();\n    while (it.next()) |entry| {\n        const hotkey = entry.key_ptr.*;\n        if (hotkey.key == 0x31) { // space key\n            if (hotkey.find_command_for_process(\"*\")) |cmd| {\n                try testing.expect(cmd == .unbound);\n            }\n        } else if (hotkey.key == 0x30) { // tab key\n            // Check terminal is unbound\n            if (hotkey.find_command_for_process(\"terminal\")) |cmd| {\n                try testing.expect(cmd == .unbound);\n            }\n            // Check firefox has command\n            if (hotkey.find_command_for_process(\"firefox\")) |cmd| {\n                try testing.expect(cmd == .command);\n                try testing.expectEqualStrings(\"echo \\\"firefox tab\\\"\", cmd.command);\n            }\n            // Check others forward\n            if (hotkey.find_command_for_process(\"other\")) |cmd| {\n                try testing.expect(cmd == .forwarded);\n            }\n        }\n    }\n}\n\ntest \"mode activation uses activation variant\" {\n    const allocator = std.testing.allocator;\n\n    const config =\n        \\\\:: window\n        \\\\cmd - w ; window\n    ;\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    try parser.parseWithPath(&mappings, config, \"test.conf\");\n\n    // Get the default mode and find the hotkey\n    const default_mode = mappings.mode_map.getPtr(\"default\").?;\n    var it = default_mode.hotkey_map.iterator();\n\n    var found_activation = false;\n    while (it.next()) |entry| {\n        const hotkey = entry.key_ptr.*;\n        if (hotkey.key == 0x0D and hotkey.flags.cmd) { // cmd - w\n            const process_cmd = hotkey.find_command_for_process(\"*\");\n            try std.testing.expect(process_cmd != null);\n            switch (process_cmd.?) {\n                .activation => |act| {\n                    try std.testing.expectEqualStrings(\"window\", act.mode_name);\n                    try std.testing.expect(act.command == null);\n                    found_activation = true;\n                },\n                else => return error.WrongCommandType,\n            }\n        }\n    }\n\n    try std.testing.expect(found_activation);\n}\n\ntest \"mode activation with command\" {\n    const allocator = std.testing.allocator;\n\n    const config =\n        \\\\:: window\n        \\\\cmd - w ; window : echo \"Entering window mode\"\n        \\\\escape ; default : echo \"Back to default\"\n    ;\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    try parser.parseWithPath(&mappings, config, \"test.conf\");\n\n    // Get the default mode and find the hotkey\n    const default_mode = mappings.mode_map.getPtr(\"default\").?;\n    var it = default_mode.hotkey_map.iterator();\n\n    var found_window_activation = false;\n    var found_default_activation = false;\n\n    while (it.next()) |entry| {\n        const hotkey = entry.key_ptr.*;\n        if (hotkey.key == 0x0D and hotkey.flags.cmd) { // cmd - w\n            const process_cmd = hotkey.find_command_for_process(\"*\");\n            try std.testing.expect(process_cmd != null);\n            switch (process_cmd.?) {\n                .activation => |act| {\n                    try std.testing.expectEqualStrings(\"window\", act.mode_name);\n                    try std.testing.expect(act.command != null);\n                    try std.testing.expectEqualStrings(\"echo \\\"Entering window mode\\\"\", act.command.?);\n                    found_window_activation = true;\n                },\n                else => return error.WrongCommandType,\n            }\n        } else if (hotkey.key == 0x35) { // escape\n            const process_cmd = hotkey.find_command_for_process(\"*\");\n            try std.testing.expect(process_cmd != null);\n            switch (process_cmd.?) {\n                .activation => |act| {\n                    try std.testing.expectEqualStrings(\"default\", act.mode_name);\n                    try std.testing.expect(act.command != null);\n                    try std.testing.expectEqualStrings(\"echo \\\"Back to default\\\"\", act.command.?);\n                    found_default_activation = true;\n                },\n                else => return error.WrongCommandType,\n            }\n        }\n    }\n\n    try std.testing.expect(found_window_activation);\n    try std.testing.expect(found_default_activation);\n}\n\ntest \"mode activation with command reference\" {\n    const allocator = std.testing.allocator;\n\n    const config =\n        \\\\.define notify : osascript -e 'display notification \"{{1}}\" with title \"skhd\"'\n        \\\\:: window\n        \\\\cmd - w ; window : @notify(\"Window mode active\")\n    ;\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    try parser.parseWithPath(&mappings, config, \"test.conf\");\n\n    // Get the default mode and find the hotkey\n    const default_mode = mappings.mode_map.getPtr(\"default\").?;\n    var it = default_mode.hotkey_map.iterator();\n\n    var found_activation = false;\n    while (it.next()) |entry| {\n        const hotkey = entry.key_ptr.*;\n        if (hotkey.key == 0x0D and hotkey.flags.cmd) { // cmd - w\n            const process_cmd = hotkey.find_command_for_process(\"*\");\n            try std.testing.expect(process_cmd != null);\n            switch (process_cmd.?) {\n                .activation => |act| {\n                    try std.testing.expectEqualStrings(\"window\", act.mode_name);\n                    try std.testing.expect(act.command != null);\n                    try std.testing.expectEqualStrings(\"osascript -e 'display notification \\\"Window mode active\\\" with title \\\"skhd\\\"'\", act.command.?);\n                    found_activation = true;\n                },\n                else => return error.WrongCommandType,\n            }\n        }\n    }\n\n    try std.testing.expect(found_activation);\n}\n\ntest \"mode activation in process list\" {\n    const allocator = std.testing.allocator;\n\n    const config =\n        \\\\:: vim_mode\n        \\\\:: browser_mode\n        \\\\cmd - m [\n        \\\\    \"terminal\" ; vim_mode : echo \"Vim mode for terminal\"\n        \\\\    \"chrome\" ; browser_mode\n        \\\\    * ; default : echo \"Back to default\"\n        \\\\]\n    ;\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    try parser.parseWithPath(&mappings, config, \"test.conf\");\n\n    // Get the default mode and find the hotkey\n    const default_mode = mappings.mode_map.getPtr(\"default\").?;\n    var it = default_mode.hotkey_map.iterator();\n\n    var hotkey: ?*Hotkey = null;\n    while (it.next()) |entry| {\n        const hk = entry.key_ptr.*;\n        if (hk.key == 0x2E) { // m key\n            hotkey = hk;\n            break;\n        }\n    }\n\n    try std.testing.expect(hotkey != null);\n\n    // Check terminal process has vim_mode activation with command\n    const terminal_cmd = hotkey.?.find_command_for_process(\"terminal\");\n    try std.testing.expect(terminal_cmd != null);\n    switch (terminal_cmd.?) {\n        .activation => |act| {\n            try std.testing.expectEqualStrings(\"vim_mode\", act.mode_name);\n            try std.testing.expect(act.command != null);\n            try std.testing.expectEqualStrings(\"echo \\\"Vim mode for terminal\\\"\", act.command.?);\n        },\n        else => return error.WrongCommandType,\n    }\n\n    // Check chrome process has browser_mode activation without command\n    const chrome_cmd = hotkey.?.find_command_for_process(\"chrome\");\n    try std.testing.expect(chrome_cmd != null);\n    switch (chrome_cmd.?) {\n        .activation => |act| {\n            try std.testing.expectEqualStrings(\"browser_mode\", act.mode_name);\n            try std.testing.expect(act.command == null);\n        },\n        else => return error.WrongCommandType,\n    }\n\n    // Check wildcard has default mode activation with command\n    const wildcard_cmd = hotkey.?.find_command_for_process(\"*\");\n    try std.testing.expect(wildcard_cmd != null);\n    switch (wildcard_cmd.?) {\n        .activation => |act| {\n            try std.testing.expectEqualStrings(\"default\", act.mode_name);\n            try std.testing.expect(act.command != null);\n            try std.testing.expectEqualStrings(\"echo \\\"Back to default\\\"\", act.command.?);\n        },\n        else => return error.WrongCommandType,\n    }\n}\n\ntest \"process group command reference parsing\" {\n    const allocator = std.testing.allocator;\n\n    // Test specifically: process groups with command references vs regular commands containing @\n    const config =\n        \\\\.define browsers [\"firefox\", \"chrome\"]\n        \\\\.define echo_cmd : echo \"{{1}}\"\n        \\\\\n        \\\\# Process group with command reference (empty token + reference)\n        \\\\cmd - a [\n        \\\\    @browsers : @echo_cmd(\"hello\")\n        \\\\]\n        \\\\\n        \\\\# Process group with regular command containing @\n        \\\\cmd - b [\n        \\\\    @browsers : echo @not_a_reference\n        \\\\]\n        \\\\\n        \\\\# Mix of both in same list\n        \\\\cmd - c [\n        \\\\    \"terminal\" : @echo_cmd(\"term\")\n        \\\\    @browsers : echo @symbol\n        \\\\    * : @echo_cmd(\"wildcard\")\n        \\\\]\n    ;\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    try parser.parseWithPath(&mappings, config, \"test.conf\");\n\n    const default_mode = mappings.mode_map.getPtr(\"default\").?;\n\n    // Find cmd-a and verify browsers have expanded command\n    var it = default_mode.hotkey_map.iterator();\n    while (it.next()) |entry| {\n        const hk = entry.key_ptr.*;\n        if (hk.key == 0x00 and hk.flags.cmd) { // A key\n            // Check firefox mapping\n            const firefox_cmd = hk.find_command_for_process(\"firefox\").?.command;\n            try std.testing.expectEqualStrings(\"echo \\\"hello\\\"\", firefox_cmd);\n            // Check chrome mapping\n            const chrome_cmd = hk.find_command_for_process(\"chrome\").?.command;\n            try std.testing.expectEqualStrings(\"echo \\\"hello\\\"\", chrome_cmd);\n        } else if (hk.key == 0x0B and hk.flags.cmd) { // B key\n            // Check that @ symbol is preserved in regular command\n            const firefox_cmd = hk.find_command_for_process(\"firefox\").?.command;\n            try std.testing.expectEqualStrings(\"echo @not_a_reference\", firefox_cmd);\n        }\n    }\n}\n\ntest \"empty command token with reference\" {\n    const allocator = std.testing.allocator;\n\n    // Test the specific case: \": @ref\" should parse as command reference\n    // But \": echo @ref\" should parse as literal command\n    const config =\n        \\\\.define test_cmd : echo \"test {{1}}\"\n        \\\\# Empty command token followed by reference\n        \\\\cmd - a : @test_cmd(\"hello\")\n        \\\\# Non-empty command token with @ symbol  \n        \\\\cmd - b : echo @not_a_reference\n    ;\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    try parser.parseWithPath(&mappings, config, \"test.conf\");\n\n    const default_mode = mappings.mode_map.getPtr(\"default\").?;\n\n    // Find both hotkeys\n    var cmd_a: ?[:0]const u8 = null;\n    var cmd_b: ?[:0]const u8 = null;\n\n    var it = default_mode.hotkey_map.iterator();\n    while (it.next()) |entry| {\n        const hk = entry.key_ptr.*;\n        if (hk.key == 0x00 and hk.flags.cmd) { // A key\n            cmd_a = hk.wildcard_command.?.command;\n        } else if (hk.key == 0x0B and hk.flags.cmd) { // B key\n            cmd_b = hk.wildcard_command.?.command;\n        }\n    }\n\n    // cmd-a should have expanded command reference\n    try std.testing.expect(cmd_a != null);\n    try std.testing.expectEqualStrings(\"echo \\\"test hello\\\"\", cmd_a.?);\n\n    // cmd-b should have literal command with @\n    try std.testing.expect(cmd_b != null);\n    try std.testing.expectEqualStrings(\"echo @not_a_reference\", cmd_b.?);\n}\n\ntest \"mode declaration command references\" {\n    const allocator = std.testing.allocator;\n\n    // Test mode declarations with command references vs regular commands\n    const config =\n        \\\\.define mode_cmd : echo \"entering {{1}}\"\n        \\\\\n        \\\\# Mode with command reference\n        \\\\:: mode1 : @mode_cmd(\"mode1\")\n        \\\\\n        \\\\# Mode with regular command containing @\n        \\\\:: mode2 : echo @not_a_reference\n    ;\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    try parser.parseWithPath(&mappings, config, \"test.conf\");\n\n    // Verify mode1 has expanded command\n    const mode1 = mappings.mode_map.getPtr(\"mode1\").?;\n    try std.testing.expect(mode1.command != null);\n    try std.testing.expectEqualStrings(\"echo \\\"entering mode1\\\"\", mode1.command.?);\n\n    // Verify mode2 has literal command with @\n    const mode2 = mappings.mode_map.getPtr(\"mode2\").?;\n    try std.testing.expect(mode2.command != null);\n    try std.testing.expectEqualStrings(\"echo @not_a_reference\", mode2.command.?);\n}\n\ntest \"empty command followed by process group\" {\n    const allocator = std.testing.allocator;\n\n    // Test case: empty command token followed by process group on next line\n    const config =\n        \\\\.define browsers [\"firefox\", \"chrome\"]\n        \\\\cmd - a [\n        \\\\    \"firefox\" : \n        \\\\    @browsers : echo \"browser command\"\n        \\\\]\n    ;\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    // This should produce an error because \"firefox\" : with nothing after colon is invalid\n    const result = parser.parseWithPath(&mappings, config, \"test.conf\");\n    try std.testing.expectError(error.ParseErrorOccurred, result);\n\n    // TODO: our syntax design doesn't make this easy: the error is about command, but the @browsers is a process group reference\n    // I don't see a easy way around this besides look ahead infinitely to make sure the next token is indeed a process group\n    const parse_err = parser.error_info.?;\n    try std.testing.expectEqualStrings(\"Command '@browsers' not found. Did you forget to define it with '.define browsers : ...'?\", parse_err.message);\n}\n\ntest \"mode activation with process groups\" {\n    const allocator = std.testing.allocator;\n\n    const config =\n        \\\\.define terminal_apps [\"kitty\", \"wezterm\", \"terminal\"]\n        \\\\.define browser_apps [\"chrome\", \"safari\", \"firefox\"]\n        \\\\:: vim_mode\n        \\\\:: browser_mode\n        \\\\cmd - m [\n        \\\\    @terminal_apps ; vim_mode : echo \"Vim mode for terminals\"\n        \\\\    @browser_apps ; browser_mode\n        \\\\    * ; default\n        \\\\]\n    ;\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    try parser.parseWithPath(&mappings, config, \"test.conf\");\n\n    // Get the default mode and find the hotkey\n    const default_mode = mappings.mode_map.getPtr(\"default\").?;\n    var it = default_mode.hotkey_map.iterator();\n\n    var hotkey: ?*Hotkey = null;\n    while (it.next()) |entry| {\n        const hk = entry.key_ptr.*;\n        if (hk.key == 0x2E) { // m key\n            hotkey = hk;\n            break;\n        }\n    }\n\n    try std.testing.expect(hotkey != null);\n\n    // Check that all terminal apps have vim_mode activation\n    const terminal_apps = [_][]const u8{ \"kitty\", \"wezterm\", \"terminal\" };\n    for (terminal_apps) |app| {\n        const cmd = hotkey.?.find_command_for_process(app);\n        try std.testing.expect(cmd != null);\n        switch (cmd.?) {\n            .activation => |act| {\n                try std.testing.expectEqualStrings(\"vim_mode\", act.mode_name);\n                try std.testing.expect(act.command != null);\n                try std.testing.expectEqualStrings(\"echo \\\"Vim mode for terminals\\\"\", act.command.?);\n            },\n            else => return error.WrongCommandType,\n        }\n    }\n\n    // Check that all browser apps have browser_mode activation\n    const browser_apps = [_][]const u8{ \"chrome\", \"safari\", \"firefox\" };\n    for (browser_apps) |app| {\n        const cmd = hotkey.?.find_command_for_process(app);\n        try std.testing.expect(cmd != null);\n        switch (cmd.?) {\n            .activation => |act| {\n                try std.testing.expectEqualStrings(\"browser_mode\", act.mode_name);\n                try std.testing.expect(act.command == null);\n            },\n            else => return error.WrongCommandType,\n        }\n    }\n}\n\ntest \"NX media key forwarding\" {\n    const allocator = std.testing.allocator;\n    const c = @import(\"c.zig\");\n\n    // Test forwarding regular key to NX media key\n    const config =\n        \\\\# Forward delete to next media key\n        \\\\delete | next\n        \\\\# Forward backslash to previous media key\n        \\\\0x2A | previous\n        \\\\# Forward with modifiers to play\n        \\\\cmd - p | play\n    ;\n\n    var mappings = try Mappings.init(allocator);\n    defer mappings.deinit();\n\n    var parser = try Parser.init(allocator);\n    defer parser.deinit();\n\n    try parser.parseWithPath(&mappings, config, \"test.conf\");\n\n    const default_mode = mappings.mode_map.getPtr(\"default\").?;\n    var it = default_mode.hotkey_map.iterator();\n\n    var found_delete = false;\n    var found_backslash = false;\n    var found_cmd_p = false;\n\n    while (it.next()) |entry| {\n        const hotkey = entry.key_ptr.*;\n\n        // delete in skhd config maps to kVK_ForwardDelete = 117 (0x75)\n        if (hotkey.key == 0x75) {\n            // delete | next\n            const cmd = hotkey.find_command_for_process(\"*\");\n            try std.testing.expect(cmd != null);\n            try std.testing.expect(cmd.? == .forwarded);\n            // next is NX_KEYTYPE_NEXT = 17\n            try std.testing.expect(cmd.?.forwarded.key == c.NX_KEYTYPE_NEXT);\n            try std.testing.expect(cmd.?.forwarded.flags.nx == true);\n            found_delete = true;\n        } else if (hotkey.key == 0x2A) {\n            // backslash | previous\n            const cmd = hotkey.find_command_for_process(\"*\");\n            try std.testing.expect(cmd != null);\n            try std.testing.expect(cmd.? == .forwarded);\n            // previous is NX_KEYTYPE_PREVIOUS = 18\n            try std.testing.expect(cmd.?.forwarded.key == c.NX_KEYTYPE_PREVIOUS);\n            try std.testing.expect(cmd.?.forwarded.flags.nx == true);\n            found_backslash = true;\n        } else if (hotkey.key == 0x23 and hotkey.flags.cmd) {\n            // cmd - p | play\n            const cmd = hotkey.find_command_for_process(\"*\");\n            try std.testing.expect(cmd != null);\n            try std.testing.expect(cmd.? == .forwarded);\n            // play is NX_KEYTYPE_PLAY = 16\n            try std.testing.expect(cmd.?.forwarded.key == c.NX_KEYTYPE_PLAY);\n            try std.testing.expect(cmd.?.forwarded.flags.nx == true);\n            found_cmd_p = true;\n        }\n    }\n\n    try std.testing.expect(found_delete);\n    try std.testing.expect(found_backslash);\n    try std.testing.expect(found_cmd_p);\n}\n"
  },
  {
    "path": "src/utils.zig",
    "content": "const std = @import(\"std\");\n\npub fn indentPrint(alloc: std.mem.Allocator, writer: anytype, padding: []const u8, comptime fmt: []const u8, value: anytype) !void {\n    const string = try std.fmt.allocPrint(alloc, fmt, .{value});\n    defer alloc.free(string);\n\n    var parts = std.mem.splitScalar(u8, string, '\\n');\n    while (parts.next()) |part| {\n        // var i: i32 = 0;\n        // while (i < indent) {\n        //     try writer.print(\" \", .{});\n        //     i += 1;\n        // }\n        try writer.print(\"{s}\", .{padding});\n        try writer.print(\"{s}\", .{part});\n        if (parts.peek() != null) {\n            try writer.print(\"\\n\", .{});\n        }\n    }\n}\n\ntest {\n    const alloc = std.testing.allocator;\n    var list = std.ArrayList(u8).init(alloc);\n    defer list.deinit();\n    const writer = list.writer();\n\n    try indentPrint(alloc, writer, \" \" ** 2, \"{s}\", \"Hello, World!\");\n\n    const expected = \"  Hello, World!\";\n    const actual = list.items;\n    try std.testing.expectEqualStrings(expected, actual);\n}\n"
  },
  {
    "path": "taphold_test.skhdrc",
    "content": "# skhd.zig built-in keyboard test config\n#\n# Goal: make the MacBook built-in keyboard feel like the convolution QMK\n# board (keyboards/keebio/convolution/keymaps/jackie). Type with this active,\n# compare to typing on the convolution. Should feel identical.\n#\n# STATUS: target syntax for the upcoming .remap + .device features.\n#         Will not load on current skhd.zig. Uses syntax under design.\n#\n# Built-in caps_lock interception is handled automatically via hidutil\n# remap (caps_lock -> F18, per-device, restored on exit). User does not\n# need to touch System Settings.\n\n.SHELL \"/bin/dash\"\n\n.define anybar_color : echo -n \"{{1}}\" | nc -4u -w0 localhost 1738\n\n# ── Device alias ──────────────────────────────────────────────────────────\n# Discover the vendor/product values with: skhd --list-devices\n.device builtin { vendor: 0x05AC, product: 0x0342 }\n\n# ── Caps Lock -> tap=Esc, hold=LCtrl ──────────────────────────────────────\n# Mirrors JL_CTL_ESC. QMK term=120ms (PREFER_HOLD), PERMISSIVE_HOLD on,\n# retro off.\n.remap caps_lock [device builtin] {\n    tap             : escape\n    hold            : lctrl\n    timeout         : 120ms\n    permissive_hold : on\n    retro_tap       : off\n}\n\n# ── Space -> tap=Space, hold=fn_layer ─────────────────────────────────────\n# Mirrors JL_LSPC. QMK term=300ms (PREFER_TAP+50), PERMISSIVE_HOLD on,\n# RETRO_TAPPING on.\n:: fn_layer @ : @anybar_color(\"green\")\n:: default : @anybar_color(\"hollow\")\n\n.remap space [device builtin] {\n    tap             : space\n    hold            : fn_layer\n    timeout         : 300ms\n    permissive_hold : on\n    retro_tap       : on\n}\n\n# ── fn_layer (= QMK convolution layer 2) ──────────────────────────────────\n# Layer keys are NOT device-guarded: once fn_layer is active (entered by\n# holding built-in space), all keystrokes are intercepted regardless of\n# source. This matches QMK layer behavior.\n\n# Number row -> F-row\nfn_layer < 1 | f1\nfn_layer < 2 | f2\nfn_layer < 3 | f3\nfn_layer < 4 | f4\nfn_layer < 5 | f5\nfn_layer < 6 | f6\nfn_layer < 7 | f7\nfn_layer < 8 | f8\nfn_layer < 9 | f9\nfn_layer < 0 | f10\n\n# QWERTY row -> nav cluster\nfn_layer < w | alt - q\nfn_layer < r | end\nfn_layer < t | pageup\nfn_layer < u | home\nfn_layer < i | pagedown\nfn_layer < o | pageup\nfn_layer < p | end\n\n# Home row -> arrows + page nav\nfn_layer < f | pagedown\nfn_layer < h | left\nfn_layer < j | down\nfn_layer < k | up\nfn_layer < l | right\n\n# Selection variants\nfn_layer < shift - h | shift - left\nfn_layer < shift - j | shift - down\nfn_layer < shift - k | shift - up\nfn_layer < shift - l | shift - right\n\n# Bottom row -> clipboard ops + nav\nfn_layer < z | cmd - z\nfn_layer < x | cmd - x\nfn_layer < c | cmd - c\nfn_layer < v | cmd - v\nfn_layer < b | pageup\nfn_layer < n | down\n\n# Defensive escape: release of space exits automatically; this is the manual\n# fallback in case state gets stuck.\nfn_layer < escape ; default\n"
  },
  {
    "path": "testdata/example_process_groups.skhdrc",
    "content": "# Example configuration demonstrating process group variables\n# This feature is new in skhd.zig and helps reduce configuration duplication\n\n# Define process groups at the top of your config\n.define terminal_apps [\"kitty\", \"wezterm\", \"terminal\", \"iterm2\", \"alacritty\"]\n.define browser_apps [\"chrome\", \"safari\", \"firefox\", \"edge\", \"brave\"]\n.define code_editors [\"code\", \"sublime text\", \"atom\", \"vim\", \"emacs\"]\n.define native_apps [\"kitty\", \"wezterm\", \"chrome\", \"whatsapp\", \"notes\"]\n\n# Use process groups to simplify your hotkey definitions\n\n# Terminal apps handle Ctrl+Backspace natively, others get Alt+Backspace\nctrl - backspace [\n    @terminal_apps ~\n    *              | alt - backspace\n]\n\n# Terminal apps handle Ctrl+Left/Right natively, others get Alt+Left/Right\nctrl - left [\n    @terminal_apps ~\n    *              | alt - left\n]\n\nctrl - right [\n    @terminal_apps ~  \n    *              | alt - right\n]\n\n# Home/End key remapping for apps that don't handle them properly\nhome [\n    @native_apps ~\n    *            | cmd - left\n]\n\nend [\n    @native_apps ~\n    *            | cmd - right\n]\n\n# Shift+Home/End for text selection\nshift - home [\n    @native_apps ~\n    @browser_apps ~\n    *             | cmd + shift - left\n]\n\nshift - end [\n    @native_apps ~\n    @browser_apps ~\n    *             | cmd + shift - right\n]\n\n# Code editor specific bindings\ncmd - d [\n    @code_editors : echo \"Duplicate line in code editor\"\n    *             : echo \"Default cmd+d behavior\"\n]\n\n# You can mix process groups with individual apps\nctrl - s [\n    @terminal_apps ~              # Terminal apps ignore\n    \"preview\"      | cmd - s      # Preview gets Cmd+S\n    @browser_apps  | cmd - s      # Browsers get Cmd+S\n    *              : echo \"Ctrl+S in other apps\"\n]\n\n# Process groups work with all hotkey features including modes\n:: coding\ncmd - c ; coding\n\ncoding < h [\n    @code_editors : echo \"Show help in editor\"\n    *             : echo \"No editor active\"\n]\n\ncoding < escape ; default"
  },
  {
    "path": "testdata/hotload_test.skhdrc",
    "content": "# Initial content\n# Modified content\n"
  },
  {
    "path": "testdata/loader.skhdrc",
    "content": ".load \"sub.skhdrc\"\ncmd - l : echo 'from loader'"
  },
  {
    "path": "testdata/parse_errors.skhdrc",
    "content": "# Test file for parser error messages\n\n# Missing '<' after mode\nmymode cmd - a : echo test\n\n# Unknown modifier\nfoo - b : echo test\n\n# Missing '-' after modifier\ncmd b : echo test  \n\n# Unknown key\ncmd - unknown_key : echo test\n\n# Missing command after ':'\ncmd - c :\n\n# Unknown option\n.unknown_option\n\n# Empty process list\ncmd - d []\n\n# Missing ']' in process list\ncmd - e [ \"app\" : echo test\n\n# Mode already exists\n:: test_mode\n:: test_mode\n\n# Missing mode name after '::'\n::\n\n# Unknown literal key\ncmd - unknown_literal : echo\n"
  },
  {
    "path": "testdata/reload_test.skhdrc",
    "content": "# Modified config for reload test\ncmd - a : echo \" A key now does something different!\"\ncmd - b : echo \" B key also changed!\"\ncmd - c : echo \" C key remains unchanged!\"\n"
  },
  {
    "path": "testdata/sub.skhdrc",
    "content": "cmd - s : echo 'from subdir'"
  },
  {
    "path": "testdata/test-forward.skhdrc",
    "content": "# Test key forwarding/remapping\n\n# Simple key forwarding - remap Ctrl+H to Left Arrow\nctrl - h | left\n\n# Process-specific forwarding - only in Terminal\nctrl - j [\n    \"terminal\" | down\n    *          : echo \"Ctrl+J in other apps\"\n]\n\n# Test multiple forwarding options\nctrl - k [\n    \"terminal\" | up\n    \"safari\"   | cmd - up\n    *          | shift - up\n]"
  },
  {
    "path": "testdata/test-lr-modifiers.skhdrc",
    "content": "# Test left/right modifier distinction\n\n# General modifiers (should match any side)\ncmd - a : echo \"CMD+A: General command key (matches lcmd or rcmd)\"\nalt - b : echo \"ALT+B: General alt key (matches lalt or ralt)\"\nshift - c : echo \"SHIFT+C: General shift key (matches lshift or rshift)\"\nctrl - d : echo \"CTRL+D: General control key (matches lctrl or rctrl)\"\n\n# Left-specific modifiers\nlcmd - e : echo \"LCMD+E: Left command key only\"\nlalt - f : echo \"LALT+F: Left alt key only\"\nlshift - g : echo \"LSHIFT+G: Left shift key only\"\nlctrl - h : echo \"LCTRL+H: Left control key only\"\n\n# Right-specific modifiers\nrcmd - i : echo \"RCMD+I: Right command key only\"\nralt - j : echo \"RALT+J: Right alt key only\"\nrshift - k : echo \"RSHIFT+K: Right shift key only\"\nrctrl - l : echo \"RCTRL+L: Right control key only\"\n\n# Mixed combinations\nlcmd + ralt - m : echo \"LCMD+RALT+M: Left command + right alt\"\nlshift + rcmd - n : echo \"LSHIFT+RCMD+N: Left shift + right command\"\n\n# Test hyper and meh\nhyper - space : echo \"HYPER+SPACE: Hyper key (cmd+alt+shift+ctrl)\"\nmeh - return : echo \"MEH+RETURN: Meh key (alt+shift+ctrl)\""
  },
  {
    "path": "testdata/test-process.skhdrc",
    "content": "# Test process-specific hotkeys\n\n# Process-specific hotkey example\n# Different commands for different applications\ncmd - n [\n    \"terminal\" : echo \"Terminal: New window\"\n    \"safari\"   : echo \"Safari: New window\"\n    \"finder\"   : echo \"Finder: New window\"\n    *          : echo \"Other app: New window\"\n]\n\n# Another example with unbound keys\ncmd - q [\n    \"terminal\" ~ # Unbind Cmd+Q in Terminal\n    *          : echo \"Quit command\"\n]\n\n# Test forwarding in specific apps\nctrl - d [\n    \"terminal\" | cmd - left\n    \"kitty\"   | ctrl - l\n    *          : echo \"Ctrl+H in other apps\"\n]\n"
  },
  {
    "path": "testdata/test-shell.skhdrc",
    "content": "# Test shell option\n.SHELL \"/usr/bin/env zsh\"\n\n# Simple test command\ncmd - t : echo \"Test from custom shell: $SHELL\" > /tmp/skhd-test.txt"
  },
  {
    "path": "testdata/test-skhdrc",
    "content": "# Test configuration with simple echo commands\n# Run with: ./zig-out/bin/skhd.zig -V -c test-skhdrc\n\n# Basic test hotkeys\ncmd - a : echo \"CMD+A pressed!\"\ncmd - b : echo \"CMD+B pressed!\"\ncmd - c : echo \"CMD+C pressed!\"\n\n# Test different modifiers\nctrl - x : echo \"CTRL+X pressed!\"\nalt - z : echo \"ALT+Z pressed!\"\nshift - s : echo \"SHIFT+S pressed!\"\n\n# Test modifier combinations\nshift + cmd - d : echo \"SHIFT+CMD+D pressed!\"\nctrl + alt - e : echo \"CTRL+ALT+E pressed!\"\ncmd + alt - f : echo \"CMD+ALT+F pressed!\"\n\n# Test numbers and special keys\ncmd - 1 : echo \"CMD+1 pressed!\"\ncmd - 2 : echo \"CMD+2 pressed!\"\ncmd - space : echo \"CMD+SPACE pressed!\"\ncmd - return : echo \"CMD+RETURN pressed!\"\n\n# Mode switching test\n:: test @ : echo \"TEST MODE ACTIVATED!\"\ncmd - t ; test\ntest < q : echo \"In TEST mode: Q pressed!\"\ntest < w : echo \"In TEST mode: W pressed!\"\ntest < escape ; default\n\n# Another mode for demonstration\n:: edit : echo \"EDIT MODE ACTIVATED!\"\ncmd - e ; edit\nedit < h : echo \"EDIT mode: Move left\"\nedit < j : echo \"EDIT mode: Move down\"\nedit < k : echo \"EDIT mode: Move up\"\nedit < l : echo \"EDIT mode: Move right\"\nedit < i : echo \"EDIT mode: Insert\"; skhd -k \"escape\"\nedit < escape ; default"
  },
  {
    "path": "testdata/test-synthesis.skhdrc",
    "content": "# Test configuration for synthesis testing\n\n# Test basic hotkey that we can trigger with synthesis\ncmd - f1 : echo \"SUCCESS: Basic hotkey triggered!\"\n\n# Test left/right modifier distinction\nlcmd - f2 : echo \"SUCCESS: Left command key triggered!\"\nrcmd - f3 : echo \"SUCCESS: Right command key triggered!\"\n\n# Test process-specific hotkey\ncmd - f4 [\n    \"terminal\" : echo \"SUCCESS: Terminal-specific hotkey!\"\n    *          : echo \"SUCCESS: Default hotkey!\"\n]\n\n# Test mode switching\n:: testmode : echo \"SUCCESS: Entered test mode!\"\ncmd - f5 ; testmode\ntestmode < escape ; default"
  },
  {
    "path": "testdata/test.skhdrc",
    "content": "# Test configuration for skhd.zig\n# This file tests various features of the hotkey daemon\n\n# Blacklist some applications where hotkeys should not work\n.blacklist [\n    \"screencapture\"\n    \"loginwindow\"\n]\n\n#############################################\n# Basic Hotkeys\n#############################################\n\n# Single modifier hotkeys\ncmd - a : echo \"CMD+A: Testing single modifier\"\nctrl - b : echo \"CTRL+B: Control key test\"\nalt - c : echo \"ALT+C: Alt/Option key test\"\nshift - d : echo \"SHIFT+D: Shift key test\"\n\n# Multiple modifier combinations\nshift + cmd - e : echo \"SHIFT+CMD+E: Multiple modifiers\"\nctrl + alt - f : echo \"CTRL+ALT+F: Control+Alt combo\"\ncmd + alt - g : echo \"CMD+ALT+G: Command+Alt combo\"\nctrl + shift - h : echo \"CTRL+SHIFT+H: Control+Shift combo\"\n\n# Special keys\ncmd - space : echo \"CMD+SPACE: Space key\"\ncmd - return : echo \"CMD+RETURN: Return/Enter key\"\ncmd - tab : echo \"CMD+TAB: Tab key\"\ncmd - escape : echo \"CMD+ESCAPE: Escape key\"\ncmd - delete : echo \"CMD+DELETE: Delete/Backspace key\"\n\n# Arrow keys\ncmd - left : echo \"CMD+LEFT: Left arrow\"\ncmd - right : echo \"CMD+RIGHT: Right arrow\"\ncmd - up : echo \"CMD+UP: Up arrow\"\ncmd - down : echo \"CMD+DOWN: Down arrow\"\n\n# Function keys\ncmd - f1 : echo \"CMD+F1: Function key 1\"\ncmd - f2 : echo \"CMD+F2: Function key 2\"\ncmd - f12 : echo \"CMD+F12: Function key 12\"\n\n# Number keys\ncmd - 1 : echo \"CMD+1: Number 1\"\ncmd - 2 : echo \"CMD+2: Number 2\"\ncmd - 0 : echo \"CMD+0: Number 0\"\n\n#############################################\n# Modal System Tests\n#############################################\n\n# Test mode with capture (@)\n:: test @ : kitten notify \"TEST MODE ACTIVATED\"\ncmd - t ; test\n\n# In test mode - basic keys work without modifiers due to @ capture\ntest < q : echo \"TEST mode: Q key (captured)\"\ntest < w : echo \"TEST mode: W key (captured)\"\ntest < e : echo \"TEST mode: E key (captured)\"\ntest < escape ; default\n\n# Window management mode\n:: window : kitten notify \">>> Entering WINDOW mode\"\ncmd - w ; window\n\nwindow < h : echo \"WINDOW: Focus left\"\nwindow < j : echo \"WINDOW: Focus down\"\nwindow < k : echo \"WINDOW: Focus up\"\nwindow < l : echo \"WINDOW: Focus right\"\n\n# Resize submode\nwindow < r : echo \"WINDOW: Resize submode\"\nwindow < m : echo \"WINDOW: Move submode\"\nwindow < f : echo \"WINDOW: Fullscreen toggle\"\n\nwindow < escape ; default\n\n# Launch mode for applications\n:: launch : echo \">>> Entering LAUNCH mode\"\ncmd - o ; launch\n\nlaunch < t : echo \"LAUNCH: Terminal\"\nlaunch < b : echo \"LAUNCH: Browser\"\nlaunch < e : echo \"LAUNCH: Editor\"\nlaunch < escape ; default\n\n#############################################\n# Passthrough Mode Tests\n#############################################\n\n# These will pass the key through after executing command\ncmd - p -> : echo \"CMD+P: Passthrough test - key will still be sent\"\ncmd + shift - p -> : echo \"CMD+SHIFT+P: Passthrough with modifiers\"\n\n#############################################\n# Key Forwarding Tests (TODO)\n#############################################\n\n# Remap keys to other keys\n# ctrl - h | cmd - left  # Remap Ctrl+H to Cmd+Left\n# ctrl - l | cmd - right # Remap Ctrl+L to Cmd+Right\n\n#############################################\n# Process-Specific Hotkeys (TODO)\n#############################################\n\n# Different commands for different applications\n# cmd - n [\n#     \"Terminal\" : echo \"Terminal: New window\"\n#     \"Safari\"   : echo \"Safari: New window\"\n#     \"Finder\"   : echo \"Finder: New window\"\n#     *          : echo \"Other app: New window\"\n# ]\n\n# Unbind keys in specific apps\n# cmd - q [\n#     \"Terminal\" ~ # Unbind Cmd+Q in Terminal\n#     *          : echo \"Quit command\"\n# ]\n\n#############################################\n# Complex Examples\n#############################################\n\n# Vim-like mode\n:: vim : echo \">>> VIM mode activated\"\ncmd - v ; vim\n\nvim < h : echo \"VIM: Move left\"\nvim < j : echo \"VIM: Move down\"\nvim < k : echo \"VIM: Move up\"\nvim < l : echo \"VIM: Move right\"\n\nvim < i : echo \"VIM: Insert mode\"; skhd -k \"escape\"\nvim < a : echo \"VIM: Append mode\"; skhd -k \"escape\"\nvim < o : echo \"VIM: Open line below\"; skhd -k \"escape\"\n\nvim < shift - g : echo \"VIM: Go to end\"\nvim < g ; vim_g\nvim < escape ; default\n\n# Vim g-prefix commands\n:: vim_g : echo \">>> VIM G-prefix mode\"\nvim_g < g : echo \"VIM: Go to beginning\"; skhd -k \"escape\"\nvim_g < escape ; vim\n\n#############################################\n# Testing Edge Cases\n#############################################\n\n# Very long command\ncmd - x : echo \"This is a very long command that tests whether the command parsing and execution can handle longer strings without any issues\"\n\n# Command with special characters\ncmd - y : echo \"Special chars: $HOME | grep test && echo 'done' || echo 'failed'\"\n\n# Multiple commands (using semicolon in shell)\ncmd - z : echo \"First command\"; echo \"Second command\"; echo \"Third command\"\n"
  },
  {
    "path": "testdata/test_debug_match.skhdrc",
    "content": ".define focus_direction : yabai -m window --focus {{1}} || yabai -m display --focus {{1}} || yabai -m display --focus {{2}}\nlcmd - h : @focus_direction(\"west\", \"recent\")\n\n:: winmode @ : echo \"Window mode: enabled\"\n:: default : echo \"Default mode: enabled\"\n\n# Enter window mode with meh + m\nctrl - m ; winmode\nwinmode < escape ; default\nwinmode < ctrl - m ; default\nwinmode < h : echo \"Window mode: focus west\"\nwinmode < l : echo \"Window mode: focus east\"\n"
  },
  {
    "path": "testdata/test_forward_logging.skhdrc",
    "content": "# Test key forwarding with logging\n# This should log the forwarded key details\n\n# Forward cmd+h to cmd+m for Terminal\ncmd - h [\n    \"Terminal\" | cmd - m\n    * : echo \"cmd-h pressed\"\n]\n\n# Forward a complex key combo\nctrl + shift - x | ctrl + alt - z"
  },
  {
    "path": "testdata/test_home_key.skhdrc",
    "content": "# Test home key handling\n# This tests process-specific unbound vs wildcard forwarding\n\nhome [\n    \"kitty\" ~\n    \"Terminal\" ~\n    * | cmd - left\n]\n\n# Also test without any modifiers\nend [\n    \"kitty\" : echo \"End key in kitty\"\n    * | cmd - right\n]"
  },
  {
    "path": "testdata/test_included.skhdrc",
    "content": "# Test included file\ncmd - i : echo 'from included file'"
  },
  {
    "path": "testdata/test_included_mode.skhdrc",
    "content": "mymode < cmd - t : echo 'cross-file mode reference'"
  },
  {
    "path": "testdata/test_logging.skhdrc",
    "content": "# Test config to verify logging\ncmd - l : echo \"Logging test successful!\""
  },
  {
    "path": "testdata/test_media_key_forward.skhdrc",
    "content": "# Forward delete to next media key\ndelete | next\n\n# Forward backslash to previous\n0x2A | previous\n\n# Forward with modifiers to play\ncmd - p | play\n\n# For M3 Macs, also test fast/rewind\nshift - delete | fast\n"
  },
  {
    "path": "testdata/test_modifier_matching.skhdrc",
    "content": "# Test modifier matching behavior\n# General modifiers in config should match any specific modifier from keyboard\n\n# General alt should match alt, lalt, or ralt\nalt - a : echo \"alt - a pressed\"\n\n# Specific lalt should only match lalt\nlalt - b : echo \"lalt - b pressed\"\n\n# Test with multiple modifiers\ncmd + shift - c : echo \"cmd + shift - c pressed\"\nlcmd + lshift - d : echo \"lcmd + lshift - d pressed\""
  },
  {
    "path": "testdata/test_nested1.skhdrc",
    "content": ".load \"test_nested2.skhdrc\"\ncmd - o : echo 'from nested1'"
  },
  {
    "path": "testdata/test_nested2.skhdrc",
    "content": "cmd - n : echo 'from nested2'"
  }
]