diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ca2900..451b387 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,48 +6,186 @@ on: push: branches: [main] +permissions: + contents: read + env: CARGO_TERM_COLOR: always jobs: fmt: name: Format - runs-on: macos-latest + runs-on: macos-14 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 with: components: rustfmt + toolchain: 1.95.0 - run: cargo fmt --check clippy: name: Clippy - runs-on: macos-latest + runs-on: macos-14 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 with: components: clippy - - uses: actions/cache@v4 + toolchain: 1.95.0 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | ~/.cargo/registry ~/.cargo/git target key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }} - - run: cargo clippy -- -D warnings + - run: cargo clippy --locked --all-targets --all-features -- -D warnings test: name: Test - runs-on: macos-latest + runs-on: macos-14 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: actions/cache@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 + with: + toolchain: 1.95.0 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | ~/.cargo/registry ~/.cargo/git target key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} - - run: cargo test + - run: cargo test --locked + + build-all-targets: + name: Build (all targets) + runs-on: macos-14 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 + with: + toolchain: 1.95.0 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-build-all-targets-${{ hashFiles('**/Cargo.lock') }} + - run: cargo build --locked --all-targets + + build-all-features: + name: Build (all features) + runs-on: macos-14 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 + with: + toolchain: 1.95.0 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-build-all-features-${{ hashFiles('**/Cargo.lock') }} + - run: cargo build --locked --all-features + + shellcheck: + name: Shellcheck + runs-on: macos-14 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Use reviewdog/action-shellcheck SHA-pinned to v1. + # This action downloads a specific shellcheck binary with checksum + # verification, avoiding the moving Homebrew formula. + - uses: reviewdog/action-shellcheck@9fc50a796ddeef37619513aa2aa8063132ee117f # v1 + with: + level: warning + reporter: github-check + pattern: | + scripts/package.sh + scripts/lib.sh + scripts/version-sync.sh + install.sh + install-binary.sh + uninstall.sh + packaging/homebrew/render_formula.sh + + test-lib: + name: Shell lib tests + runs-on: macos-14 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: ./scripts/test_lib.sh + + package-dry-run: + name: Package dry-run (rehearsal) + runs-on: macos-14 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin + toolchain: 1.95.0 + - run: ./scripts/package.sh --dry-run --out-parent ./dist-parent + + version-sync: + name: Version sync + runs-on: macos-14 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: ./scripts/version-sync.sh + + cargo-deny: + name: Rust advisory scan + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: EmbarkStudios/cargo-deny-action@b66acf5e9fe20f8aba065be86778a8a4c846f902 # v2 + with: + arguments: --all-features + command: check advisories + + raycast: + name: Raycast extension + runs-on: macos-14 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "22" + - run: | + cd raycast-extension + npm ci + npm run lint:ci + npx tsc --noEmit + npm run build + npm audit + node --test src/lib/service.test.mjs \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1dfc80f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,245 @@ +name: Release + +# ── Read-only tagged-release BUILD pipeline ─────────────────────────── +# +# This workflow builds and validates release artifacts but does NOT publish +# a GitHub Release. There is no `contents: write` job or token anywhere. +# +# Job graph: assert-trusted-dispatch → validate-tag → [fmt, clippy, test] +# → build (calls scripts/package.sh, uploads artifacts) +# +# The validated 40-hex commit SHA is passed through ALL jobs via +# `tag_commit` output. Every job checks out that exact SHA. The build +# job passes it to package.sh which asserts HEAD == SHA and archives +# from that SHA, never the mutable tag name. +# +# Manual GitHub Release publication is BLOCKED until these external +# prerequisites are configured and verified (see RELEASE_SETUP.md): +# 1. Protected `release` environment with ≥1 independent required reviewer. +# 2. An environment-scoped `RELEASE_AUTHORIZATION` sentinel secret. +# 3. Strict `main` branch protection. +# 4. A mandatory immutable `v*` tag ruleset. +# 5. Actions policy: allow only reviewed actions, require SHA pins. +# 6. The exposed API credential incident is resolved. + +on: + workflow_dispatch: + inputs: + tag: + description: "Tag to build, e.g. v0.1.0 (must be a real v* tag whose commit is merged to main)" + required: true + type: string + +permissions: + contents: read # workflow-wide: no write token anywhere + +concurrency: + group: release-${{ inputs.tag }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + +jobs: + assert-trusted-dispatch: + name: Assert trusted dispatch (main) + runs-on: ubuntu-24.04 + steps: + - name: Reject unless dispatched from main + env: + GITHUB_REF: ${{ github.ref }} + run: | + set -euo pipefail + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "FAIL: release workflow must be dispatched from refs/heads/main (got $GITHUB_REF)" + exit 1 + fi + echo "OK: dispatched from main" + + validate-tag: + name: Validate tag (trusted & version-matched) + needs: assert-trusted-dispatch + runs-on: ubuntu-24.04 + outputs: + tag: ${{ steps.validate.outputs.tag }} + version: ${{ steps.validate.outputs.version }} + tag_commit: ${{ steps.validate.outputs.tag_commit }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + ref: main + + - name: Validate tag is v*, merged to main, and version-matched + id: validate + env: + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + TAG="$INPUT_TAG" + if ! printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "FAIL: tag '$TAG' is not a semver 'vX.Y.Z' form" + exit 1 + fi + if ! git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "FAIL: tag '$TAG' does not exist in this repository" + exit 1 + fi + TAG_COMMIT="$(git rev-parse "refs/tags/${TAG}^{commit}")" + printf 'tag_commit=%s\n' "$TAG_COMMIT" >> "$GITHUB_OUTPUT" + if ! git merge-base --is-ancestor "$TAG_COMMIT" origin/main; then + echo "FAIL: tag '$TAG' commit $TAG_COMMIT is NOT an ancestor of main" + exit 1 + fi + TAG_VERSION="${TAG#v}" + CARGO_VERSION="$(git show "${TAG_COMMIT}:Cargo.toml" | sed -n '/^\[package\]/,/^\[/p' | grep -m1 '^version' | sed 's/version = "\(.*\)"/\1/')" + if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then + echo "FAIL: tag '$TAG' (version '$TAG_VERSION') != Cargo.toml version '$CARGO_VERSION' at tag commit" + exit 1 + fi + PLIST_SHORT="$(git show "${TAG_COMMIT}:bundle/Info.plist" | grep -A1 'CFBundleShortVersionString' | grep '' | sed 's/.*\(.*\)<\/string>.*/\1/' || true)" + PLIST_VER="$(git show "${TAG_COMMIT}:bundle/Info.plist" | grep -A1 'CFBundleVersion' | grep '' | sed 's/.*\(.*\)<\/string>.*/\1/' || true)" + if [ "$PLIST_SHORT" != "$TAG_VERSION" ]; then + echo "FAIL: bundle/Info.plist CFBundleShortVersionString='$PLIST_SHORT' != tag version '$TAG_VERSION'" + exit 1 + fi + if [ "$PLIST_VER" != "$TAG_VERSION" ]; then + echo "FAIL: bundle/Info.plist CFBundleVersion='$PLIST_VER' != tag version '$TAG_VERSION'" + exit 1 + fi + CHANGELOG_VER="$(git show "${TAG_COMMIT}:CHANGELOG.md" | grep -m1 -E '^## \[' | sed -E 's/^## \[([0-9.]+)\].*/\1/' || true)" + if [ -z "$CHANGELOG_VER" ]; then + echo "FAIL: CHANGELOG.md has no version entry at tag commit" + exit 1 + fi + if [ "$CHANGELOG_VER" != "$TAG_VERSION" ]; then + echo "FAIL: CHANGELOG.md top entry version='$CHANGELOG_VER' != tag version '$TAG_VERSION'" + exit 1 + fi + # Validate rust-toolchain.toml exists at tag commit + if ! git show "${TAG_COMMIT}:rust-toolchain.toml" >/dev/null 2>&1; then + echo "FAIL: rust-toolchain.toml not found at tag commit — toolchain must be pinned" + exit 1 + fi + echo "OK: tag $TAG ($TAG_VERSION) @ $TAG_COMMIT is merged to main and version-matched" + echo "OK: Info.plist=$PLIST_SHORT, CHANGELOG=$CHANGELOG_VER, rust-toolchain.toml present" + printf 'tag=%s\n' "$TAG" >> "$GITHUB_OUTPUT" + printf 'version=%s\n' "$TAG_VERSION" >> "$GITHUB_OUTPUT" + + fmt: + name: Format + needs: validate-tag + runs-on: macos-14 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ needs.validate-tag.outputs.tag_commit }} + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 + with: + components: rustfmt + toolchain: 1.95.0 + - run: cargo fmt --check + + clippy: + name: Clippy + needs: validate-tag + runs-on: macos-14 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ needs.validate-tag.outputs.tag_commit }} + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 + with: + components: clippy + toolchain: 1.95.0 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }} + - run: cargo clippy --locked -- -D warnings + + test: + name: Test + needs: validate-tag + runs-on: macos-14 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ needs.validate-tag.outputs.tag_commit }} + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 + with: + toolchain: 1.95.0 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} + - run: cargo test --locked + + build: + name: Build + package + checksum + needs: [validate-tag, fmt, clippy, test] + runs-on: macos-14 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ needs.validate-tag.outputs.tag_commit }} + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin + toolchain: 1.95.0 + + - name: Build + package via scripts/package.sh (release mode) + env: + INPUT_TAG: ${{ inputs.tag }} + TAG_COMMIT: ${{ needs.validate-tag.outputs.tag_commit }} + run: | + set -euo pipefail + # Release mode: pass tag + commit-sha. package.sh will: + # - assert HEAD == TAG_COMMIT + # - export commit into a private source tree + # - build hermetically under env -i + # - archive from git archive (not the exported tree) + # - require both arch builds (no native fallback) + # - create a fresh-private mktemp output directory + # The unique output dir is printed to stdout; capture it. + OUTPUT_DIR=$(./scripts/package.sh --tag "$INPUT_TAG" --commit-sha "$TAG_COMMIT" --out-parent ./dist-parent 2>&1 | grep '^ Out-dir:' | sed 's/^ Out-dir: //') + echo "OUTPUT_DIR=$OUTPUT_DIR" >> "$GITHUB_ENV" + + - name: Verify binary --version (exact match) + env: + TAG_VERSION: ${{ needs.validate-tag.outputs.version }} + run: | + set -euo pipefail + OUT="$("${OUTPUT_DIR}/switcheroo-universal" --version)" + EXPECTED="switcheroo ${TAG_VERSION}" + if [ "$OUT" != "$EXPECTED" ]; then + echo "FAIL: binary --version '$OUT' != expected '$EXPECTED'" + exit 1 + fi + echo "OK: binary reports $OUT" + + - name: Upload validation artifacts (PRIVATE — not for public release) + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: private-validation-artifacts + path: | + ${{ env.OUTPUT_DIR }}/switcheroo-${{ inputs.tag }}-source.tar.gz + ${{ env.OUTPUT_DIR }}/switcheroo-${{ inputs.tag }}-macos-universal.tar.gz + ${{ env.OUTPUT_DIR }}/checksums-sha256.txt + ${{ env.OUTPUT_DIR }}/release-manifest.txt + retention-days: 7 \ No newline at end of file diff --git a/.gitignore b/.gitignore index dffe989..1680ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,10 @@ raycast-extension/build/ # Internal plans plans/ + +# Packaging build output +/dist/ +/dist-parent/ + +# Rendered Homebrew formula (template is tracked; rendered rb is a local artifact) +/packaging/homebrew/switcheroo.rb diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e9a2b80 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,99 @@ +# Changelog + +All notable changes to Switcheroo are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-09-02 + +### Added + +- First public tagged release. +- CGEventTap-based keyboard remapper daemon for macOS. +- Kernel-level modifier remaps via `hidutil` (applied on startup and + re-applied ~2 seconds after wake from sleep via an IOKit power + notification; debounced to avoid race conditions with panic/signal + cleanup). +- Simple key remaps (`[[remap]]`), conditional remaps + (`[[conditional_remap]]`), tap-hold (`[[tap_hold]]`), and chords + (`[[chord]]`). +- Standalone installer (`install.sh`) with transactional swap, + backup/rollback, and safe migration from the old + `com.local.switcheroo` label. +- Binary installer (`install-binary.sh`) for prebuilt universal `.app` + bundles with bundle-id, version, and signature verification. +- Idempotent uninstaller (`uninstall.sh`) that preserves unrelated + `hidutil` mappings and user config. +- Raycast extension for managing config via UI (View Remaps, Add Remap, + Restart Switcheroo, View Logs, Edit Config). +- Homebrew tap install path (`brew tap mitchelljphayes/switcheroo && + brew install switcheroo`) with a source-building Formula template. +- Ad-hoc universal binary archive for prebuilt distribution + (secondary path; Gatekeeper quarantine applies). +- Deterministic packaging script (`scripts/package.sh`) producing + source + binary archives with SHA-256 checksums. +- Version synchronization validation across `Cargo.toml`, + `bundle/Info.plist`, `CHANGELOG.md`, and built binary. +- Read-only GitHub Actions release build pipeline with immutable tag + validation, version-matched checks, and SHA-pinned actions. +- CI hardening with `--locked` clippy/test, all-targets/all-features + build gates, shellcheck, and packaging dry-run. + +### Security + +- All system tools in packaging scripts invoked by absolute path with + fixed minimal `PATH`. +- Safe directory validation rejects symlinks, group/world-writable + paths, and wrong-owner directories in installer scripts. +- Transactional app/plist swap with rollback on failure. +- Foreign job collision refusal: bootout/bootstrap only proceeds when + the loaded job's `program =` exactly matches the expected Switcheroo + executable. +- `hidutil` mappings snapshotted and restored around legacy daemon + shutdown to preserve foreign (non-Switcheroo) mappings. +- Raycast extension uses absolute `execFileSync` tool arrays, `lstat` + (not `stat`) to detect symlinked plists, exact Label + + ProgramArguments[0] + owner validation, and exact (not prefix-based) + Homebrew executable path matching against two official paths only. + `launchctl kickstart -k` for Homebrew is guarded by a re-verification + of the loaded job identity immediately before invocation. +- Release artifacts are bound to a validated 40-hex commit SHA: the + workflow passes the SHA through all jobs, `package.sh` asserts + HEAD == SHA, requires a completely clean worktree (tracked + untracked), + exports the commit into a fresh private source tree, and builds + hermetically under `env -i` with a validated toolchain. The source + archive is created via `git archive ` (the exact validated + commit SHA), not the exported build tree (which is used for compilation + only). The manifest is deterministic + (commit timestamp, cargo/rustc/toolchain/script SHA — no wall clock). + Rehearsal artifacts are clearly named `-rehearsal` and never look like + release artifacts. +- Formula renderer constructs the production URL internally from a + strict-semver version — no arbitrary URL input accepted. SHA-256 + must be exactly 64 lowercase hex. Rehearsal mode copies the local + archive to a script-created safe filename and derives the file:// URL + and checksum from that copy — the caller-supplied path never enters + the sed expression directly. +- Packaging output uses fresh-private `mktemp` directories with `umask 077`. + Ancestor paths are validated (no symlinks, correct owner, not + group/world-writable). Payload inputs are validated as regular + non-symlink files. No caller-controlled paths are overwritten or removed. +- GitHub Actions workflows use `contents: read` only — no write scopes, + no publication, no attestations in this pass. + +### Known Limitations + +- Ad-hoc signing only (no Apple Developer ID, no notarization). + Ad-hoc signing provides self-consistency only — it does NOT + authenticate the publisher. Checksums provide integrity (corruption + detection), not authenticity (publisher proof). The prebuilt binary + archive is a CI/rehearsal output, not a public distribution option, + until a signed manifest or trusted attestation is added. The primary + public path is the Homebrew source-build (Option 0). +- Accessibility permission may need re-granting after each rebuild + (ad-hoc signature changes). The "Switcheroo Dev" self-signed cert + path in `install.sh` mitigates this on the developer's own machine. +- No automated GitHub Release publication — maintainers download + build artifacts and create releases manually until + `RELEASE_SETUP.md` prerequisites are met. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 58e43e4..b6f1221 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,6 +239,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jiff" version = "0.2.23" @@ -405,6 +411,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -442,8 +461,10 @@ dependencies = [ "core-graphics", "dirs", "env_logger", + "libc", "log", "serde", + "serde_json", "signal-hook", "toml", ] @@ -561,3 +582,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 187b9c8..65dda1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,9 @@ license = "MIT" [dependencies] core-foundation = "0.10" core-graphics = "0.24" +libc = "0.2" serde = { version = "1", features = ["derive"] } +serde_json = "1" toml = "0.8" log = "0.4" env_logger = "0.11" diff --git a/README.md b/README.md index 75a1d10..3e54c91 100644 --- a/README.md +++ b/README.md @@ -51,21 +51,67 @@ window_ms = 100 ## Install +### Option 0 — Homebrew (recommended) + +```bash +brew tap mitchelljphayes/switcheroo +brew install switcheroo +brew services start switcheroo +``` + +Homebrew builds from source via `cargo --release --locked`, so the +binary is compiled locally and ad-hoc signed on your machine — no +Developer ID, no notarization, no Gatekeeper friction. Grant +Accessibility permission after first install (see below). + +> **Note:** ad-hoc signing means Accessibility permission may need +> re-granting after each `brew upgrade` (the binary is recompiled with +> a new signature). A sample config is installed at +> `$(brew --prefix)/etc/switcheroo/config.toml`; create or symlink your +> active config at `~/.config/switcheroo/config.toml`. + +### Option A — Prebuilt binary archive (not yet a public distribution path) + +> **Not yet available for public distribution.** Prebuilt binary archives +> are produced by CI for testing and rehearsal only. They are **unnotarized** +> and signed with an **ad-hoc signature** (self-consistency only — this does +> NOT authenticate the publisher). A co-hosted checksum file provides +> **integrity** (detection of accidental corruption), not **authenticity** +> (proof of publisher identity). Until a signed manifest or trusted +> attestation is added, the **Homebrew source-build path (Option 0) is the +> only public distribution method**. The binary archive will become a public +> option only when artifact authenticity is implemented (signed tag + +> attested manifest bound to the immutable commit). + +### Option B — Build from source (fallback) + ```bash ./install.sh ``` This will: -1. Build the release binary -2. Install app bundle to `~/.local/bin/Switcheroo.app` -3. Code sign with your local certificate (preserves Accessibility permission across rebuilds) -4. Copy config to `~/.config/switcheroo/config.toml` -5. Install and start a LaunchAgent +1. Build the release binary with `cargo` +2. Stage + ad-hoc sign the `.app` bundle and atomically swap it into `~/.local/bin/Switcheroo.app` +3. Copy config to `~/.config/switcheroo/config.toml` +4. Install and start a LaunchAgent, migrating from the old `com.local.switcheroo` label if present + +Both installers: +- Stop any existing Switcheroo agent before overwriting the bundle +- Validate `~`, paths, and plist ownership/permissions (rejecting hostile symlinks) +- Migrate the old `com.local.switcheroo` label safely (only if its plist points at Switcheroo) +- Verify the agent is registered after bootstrap, rolling back on failure **Important**: Grant Accessibility access after first install: - System Settings → Privacy & Security → Accessibility - Add `~/.local/bin/Switcheroo.app` +> **Bundle-id migration (v0.1.x):** The LaunchAgent identity changed from +> `com.local.switcheroo` to `com.mitchelljphayes.switcheroo`. An existing +> Accessibility grant is tied to the old bundle id and must be **re-issued +> once** after upgrading. `install.sh` detects and cleanly stops the old +> label; `uninstall.sh` cleans up both. Unrelated `hidutil` mappings are +> preserved across the migration. + ## Usage ```bash @@ -77,9 +123,9 @@ switcheroo /path/to/config.toml # explicit config path RUST_LOG=debug switcheroo # As a service (managed by install.sh) -launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.local.switcheroo.plist -launchctl bootout gui/$(id -u)/com.local.switcheroo -tail -f /tmp/switcheroo.err +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.mitchelljphayes.switcheroo.plist +launchctl bootout gui/$(id -u)/com.mitchelljphayes.switcheroo +tail -f ~/Library/Logs/com.mitchelljphayes.switcheroo/daemon.err ``` ## Raycast Extension @@ -102,7 +148,7 @@ Commands: View Remaps, Add Remap, Restart Switcheroo, View Logs, Edit Config. ### `[[modifier_remap]]` -Kernel-level key remap applied via `hidutil` on startup. Equivalent to System Settings → Keyboard → Modifier Keys, but persistent. These are applied at the HID driver level (before any event tap sees them) and survive app restarts. +Kernel-level key remap applied via `hidutil` on startup. Equivalent to System Settings → Keyboard → Modifier Keys, but persistent. These are applied at the HID driver level (before any event tap sees them) and survive app restarts. Mappings are automatically re-applied ~2 seconds after the system wakes from sleep (via an IOKit power notification); if reapplication fails, a warning is logged and the daemon keeps running. | Field | Values | |-------|--------| @@ -178,7 +224,7 @@ Function: `f1`-`f12` ## How it works -1. Applies `[[modifier_remap]]` rules via `hidutil` (kernel-level, instant) +1. Applies `[[modifier_remap]]` rules via `hidutil` (kernel-level, instant) — on startup **and on wake from sleep** (via an IOKit power notification; debounced ~2 s) 2. Registers a `CGEventTap` at `kCGHIDEventTap` (earliest interception point in userspace) 3. Receives `keyDown`, `keyUp`, and `flagsChanged` events 4. Runs them through the remap engine (tap-hold, conditional remaps, chords) @@ -196,6 +242,15 @@ Both depend on `Karabiner-DriverKit-VirtualHIDDevice`, which: Switcheroo uses `CGEventTap`, which has been stable since macOS 10.4 (2005) and is Apple's supported userspace event interception API. For kernel-level modifier remaps, it uses `hidutil`, which has been stable since macOS 10.12. +## Icons + +The app bundle icon (`AppIcon.icns`) is generated from the tracked +master `bundle/AppIcon-1024.png` (1024×1024). The packaging script +(`scripts/package.sh`) produces all required sizes via `sips` + +`iconutil` at build time. The Raycast extension icon +(`raycast-extension/assets/command-icon.png`) is a 512×512 derivative +for Raycast Store requirements. + ## License MIT diff --git a/RELEASE_SETUP.md b/RELEASE_SETUP.md new file mode 100644 index 0000000..1a73ab9 --- /dev/null +++ b/RELEASE_SETUP.md @@ -0,0 +1,64 @@ +# Release Setup — Required GitHub Configuration and External Controls + +## Current workflow state + +The `.github/workflows/release.yml` is a **read-only build pipeline**. It +validates an immutable tag, runs fmt/clippy/test, builds universal binary + +source archives, generates SHA-256 checksums, and uploads artifacts via +`actions/upload-artifact`. There is **no `contents: write` job, token, or +GitHub Release creation** anywhere in the workflow. + +Manual GitHub Release publication (downloading artifacts from the Actions +run and creating the release by hand after review) is the current process. + +## Prerequisites for automated publication + +Before adding any automated publish job (which would need `contents: write`), +**all** of the following must be configured and independently verified: + +### 1. Protected `release` environment +- Create: Settings → Environments → New environment → `release` +- Required reviewers: ≥1 independent reviewer (no self-approval) +- Deployment branches: restrict to `main` + +### 2. Environment-scoped sentinel secret +- Add secret `RELEASE_AUTHORIZATION` to the `release` environment (any + non-empty value). +- **Critical:** this must be an **environment** secret, NOT a repository or + organization secret. A same-named repo/org secret would resolve regardless + of environment protection, defeating the fail-closed gate. +- Verify: `gh api repos///environments` lists `release`; + confirm the secret exists only at the environment level. + +### 3. Strict `main` branch protection +- Require pull requests: yes, ≥1 approval +- Require status checks: Format, Clippy, Test — strict + up-to-date +- Require conversation resolution +- Enforce admins, restrict bypass + +### 4. Mandatory immutable `v*` tag ruleset +- Restrict tag creation, update, and deletion to admins (or a dedicated role) +- Preferably require signed tags +- This is the **strong control** that prevents tag movement between build + and publish; the workflow revalidates the SHA but only tag immutability + eliminates the race entirely + +### 5. Actions policy +- Allow only reviewed actions +- Require full commit SHA pins + +### 6. Credential incident resolution +- The exposed API credential must be revoked/rotated +- Removed from inherited launchd/shell state +- Incident owner attests completion before any release dispatch + +## Verification + +```bash +gh api repos///environments # must list "release" +gh api repos///branches/main/protection +gh api repos///rulesets # must list the v* tag ruleset +``` + +Until all six prerequisites are verified, **do not add a publish job**. +The current read-only build pipeline is safe to run without them. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..a628201 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,249 @@ +# Releasing Switcheroo + +This is the maintainer release runbook. Follow it step by step. + +## Prerequisites + +Before any release, verify the external prerequisites documented in +[`RELEASE_SETUP.md`](RELEASE_SETUP.md). The release workflow remains +read-only (no `contents: write`, no automated publication) until all +six prerequisites are verified. + +**You do NOT need all six prerequisites to build artifacts.** The +current workflow builds and validates artifacts with `contents: read` +only. The prerequisites are needed only for automated publication. + +## Version Bump Checklist + +The canonical version source is `Cargo.toml` `[package] version`. All +other version surfaces are validated against it: + +1. **`Cargo.toml`** — set `[package] version = "X.Y.Z"` (canonical). +2. **`bundle/Info.plist`** — update `CFBundleShortVersionString` and + `CFBundleVersion` to `X.Y.Z`. (The release workflow overrides these + via `plutil -replace`, but the committed values must match for the + `validate-tag` check to pass. `install.sh` now stamps them from the + built binary's `--version` output for source builds.) +3. **`CHANGELOG.md`** — add a `## [X.Y.Z] - YYYY-MM-DD` entry at the + top. The `validate-tag` check refuses tags whose top changelog entry + doesn't match. +4. **Tag** — `git tag -a vX.Y.Z -m "Release vX.Y.Z"` (after the commit + is merged to `main`; the tag must point at a main-ancestor commit). +5. **Archives** — archive names embed the tag: + `switcheroo-vX.Y.Z-source.tar.gz`, + `switcheroo-vX.Y.Z-macos-universal.tar.gz`. +6. **Formula checksum** — computed from the actual source archive after + the build; rendered into the Formula via + `packaging/homebrew/render_formula.sh`. + +### Version sync validation + +Run this locally before tagging: + +```bash +./scripts/version-sync.sh +# With a binary and tag: +./scripts/version-sync.sh --tag vX.Y.Z --binary ./dist/switcheroo-universal +``` + +All surfaces must report OK. + +## Acceptance Commands + +Run locally from the repo root (no commit, no push needed): + +### Rust gates +```bash +cargo fmt --check +cargo clippy --locked -- -D warnings +cargo test --locked +cargo build --locked --all-targets +cargo build --locked --all-features +``` + +### Shell gates +```bash +./scripts/test_lib.sh +shellcheck scripts/package.sh scripts/lib.sh scripts/version-sync.sh \ + install.sh install-binary.sh uninstall.sh \ + packaging/homebrew/render_formula.sh +``` + +### Workflow gates (if installed locally) +```bash +actionlint +zizmor . +``` + +### Packaging dry run + full run +```bash +# Dry run (no archive write): +./scripts/package.sh --dry-run --out-parent ./dist-parent + +# Full run (writes archives + checksums to a fresh-private mktemp dir): +./scripts/package.sh --out-parent ./dist-parent +# The unique output directory is printed as "Out-dir:" in the output. + +# Reproducibility check (two-run hash comparison): +./scripts/package.sh --verify-reproducibility --out-parent ./dist-parent --allow-native-fallback + +# Verify checksums (from the unique output dir printed by package.sh): +# OUT_DIR=./dist-parent/switcheroo-out.XXXXXX # from package.sh output +# shasum -a 256 -c "$OUT_DIR/checksums-sha256-rehearsal.txt" + +# Extract and verify binary archive: +# mkdir -p /tmp/sw-accept && tar -xzf "$OUT_DIR/switcheroo-v0.1.0-macos-universal-rehearsal.tar.gz" -C /tmp/sw-accept +# /usr/bin/plutil -lint /tmp/sw-accept/Switcheroo.app/Contents/Info.plist +# /usr/bin/codesign --verify --strict /tmp/sw-accept/Switcheroo.app +# /tmp/sw-accept/Switcheroo.app/Contents/MacOS/switcheroo --version +# ls /tmp/sw-accept/Switcheroo.app/Contents/Resources/AppIcon.icns +``` + +### Version sync gate +```bash +CARGO_VER=$(/usr/bin/sed -n '/^\[package\]/,/^\[/p' Cargo.toml | /usr/bin/grep -m1 '^version' | /usr/bin/sed 's/version = "\(.*\)"/\1/') +PLIST_VER=$(/usr/bin/plutil -extract CFBundleShortVersionString raw -o - bundle/Info.plist) +CHANGELOG_VER=$(/usr/bin/grep -m1 -E '^## \[' CHANGELOG.md | /usr/bin/sed -E 's/^## \[([0-9.]+)\].*/\1/') +test "$CARGO_VER" = "$PLIST_VER" && test "$CARGO_VER" = "$CHANGELOG_VER" && echo "version sync OK" +# Binary version can be checked with: +# ./scripts/version-sync.sh --binary +``` + +### Homebrew formula rehearsal (if `brew` is available) + +**REHEARSAL ONLY** — these artifacts are not for publication. The +rehearsal uses a local `file://` archive with a checksum computed from +that exact file. The production formula must use the public GitHub +archive URL with a checksum downloaded from the actual public archive +bytes (see step 9 in "Later Authorized Actions" below). + +```bash +# Build the source archive (rehearsal mode): +./scripts/package.sh --out-parent ./dist-parent --allow-native-fallback +# Find the unique output directory: +OUT_DIR=$(ls -d ./dist-parent/switcheroo-out.* | tail -1) +# Render the formula using the local archive (rehearsal mode): +packaging/homebrew/render_formula.sh \ + --version 0.1.0 \ + --local-archive "$OUT_DIR/switcheroo-v0.1.0-source-rehearsal.tar.gz" \ + --rehearsal +brew style packaging/homebrew/switcheroo.rb + +# For a full local install/test rehearsal, create a temporary tap: +TAP_DIR=$(mktemp -d) +mkdir -p "$TAP_DIR/Formula" +cp packaging/homebrew/switcheroo.rb "$TAP_DIR/Formula/switcheroo.rb" +cd "$TAP_DIR" && git init --quiet && git add -A && git commit -m test --quiet +cd - +brew tap local/sw-test "$TAP_DIR" +brew audit --strict --online local/sw-test/switcheroo +brew install --build-from-source local/sw-test/switcheroo +brew test local/sw-test/switcheroo +brew uninstall local/sw-test/switcheroo +brew untap local/sw-test +rm -rf "$TAP_DIR" packaging/homebrew/switcheroo.rb +``` + +### Raycast extension gates +```bash +cd raycast-extension +npm ci +npm run lint +npx tsc --noEmit +npm run build +node --test src/lib/service.test.mjs +npm audit +cd .. +``` + +## Release Workflow (GitHub Actions) + +Once the version bump commit is merged to `main` and the tag exists: + +```bash +gh workflow run release.yml -f tag=vX.Y.Z --ref main +``` + +The workflow: +1. Asserts dispatch from `main`. +2. Validates the tag (semver, exists, merged to main, Cargo.toml + + Info.plist + CHANGELOG.md + rust-toolchain.toml version match). +3. Passes the validated 40-hex commit SHA to all downstream jobs. +4. Runs `fmt`, `clippy`, `test` at the tag commit SHA. +5. Calls `scripts/package.sh --tag vX.Y.Z --commit-sha ` in release + mode: asserts HEAD == SHA, requires completely clean worktree, exports + commit into a private source tree, builds hermetically under `env -i` + with private CARGO_HOME and `--frozen`, archives from the exact SHA. +6. Uploads `private-validation-artifacts` (7-day retention). + +**No GitHub Release is created.** Maintainers download validation artifacts +from the Actions run for verification only. + +### Publication Allowlist + +**The following may be attached to a public GitHub Release:** +- Release notes (text) +- The git tag itself (immutable, signed if tag ruleset configured) + +**The following must NOT be attached to a public GitHub Release:** +- `switcheroo-vX.Y.Z-macos-universal.tar.gz` (prebuilt binary — unnotarized, + unauthenticated; do not publish until notarized or independently + signed/attested) +- `checksums-sha256.txt` (co-hosted with binary — not an authenticity anchor) +- `release-manifest.txt` (unsigned — integrity only, not authenticity) + +**The only public distribution path is the Homebrew source-build Formula.** +The Formula's checksum must be computed from the exact public GitHub tag +archive URL bytes (`https://github.com/mitchelljphayes/switcheroo/archive/refs/tags/vX.Y.Z.tar.gz`), +downloaded after the tag is public and immutable. + +## Later Authorized Actions + +These are **NOT** done by the packaging layer or the release workflow. +They require explicit user authorization and external GitHub settings: + +1. **Commit** the packaging layer (and the wake layer, separately or + together — user decides). +2. **Push** the commit to a remote branch. +3. **Open a PR** to `main`. +4. **Make the GitHub repo public** (if not already). +5. **Configure GitHub protections:** `release` environment, env-scoped + `RELEASE_AUTHORIZATION` secret, `main` branch protection, immutable + `v*` tag ruleset, Actions SHA-pin policy, resolution of the + credential incident (the 6 `RELEASE_SETUP.md` prerequisites). +6. **Create the immutable `vX.Y.Z` tag** at the merged commit. +7. **Dispatch `release.yml`** with `tag=vX.Y.Z` from `main`; download + the build artifacts. +8. **Create the GitHub Release** manually — attach release notes only. + Do NOT attach the binary archive, checksums, or manifest (see + Publication Allowlist above). The prebuilt binary must not be publicly + distributed until notarized or independently signed/attested. +9. **Compute the real SHA-256** of the public source archive; render + `packaging/homebrew/switcheroo.rb` with the real checksum via + `packaging/homebrew/render_formula.sh`. +10. **Create the tap repo** `mitchelljphayes/homebrew-switcheroo` on + GitHub; push `Formula/switcheroo.rb` (note: `Formula/`, not + `Formula/homebrew/`). +11. **Verify** `brew tap mitchelljphayes/switcheroo && brew install + switcheroo` end-to-end on a clean account. + +## Known Gaps + +- **No automated publication** — by design until `RELEASE_SETUP.md` + prerequisites are met. +- **No attestation/SBOM** — deferred per approved defaults + (`id-token: write` / `attestations: write` not added in this pass). +- **Rust advisory scanning** — CI uses a SHA-pinned `cargo audit` action. + The scanner database is network-fetched and may not cover CVSS 4.0 + advisories on all local toolchain versions; failures are fail-closed. +- **Ad-hoc signing only** — Accessibility permission may need + re-granting after each rebuild. +- **Homebrew Rust build dependency** — The Formula uses Homebrew's `rust` + Formula (not the repo's pinned `rust-toolchain.toml`). This is + intentional: Homebrew bottles are platform/toolchain-specific and + Homebrew manages its own compiler versions. The repo pin ensures CI + and local release builds use a specific reviewed toolchain. +- **Prebuilt binary not for public distribution** — Until notarization + or independent signing/attestation is added, the binary archive is a + CI validation artifact only. The sole public path is the Homebrew + source-build Formula. \ No newline at end of file diff --git a/bundle/AppIcon-1024.png b/bundle/AppIcon-1024.png new file mode 100644 index 0000000..cd2afd1 Binary files /dev/null and b/bundle/AppIcon-1024.png differ diff --git a/bundle/Info.plist b/bundle/Info.plist index 45716b9..dbf9656 100644 --- a/bundle/Info.plist +++ b/bundle/Info.plist @@ -5,7 +5,7 @@ CFBundleExecutable switcheroo CFBundleIdentifier - com.local.switcheroo + com.mitchelljphayes.switcheroo CFBundleName Switcheroo CFBundleDisplayName diff --git a/com.local.switcheroo.plist b/com.local.switcheroo.plist deleted file mode 100644 index 0762b5d..0000000 --- a/com.local.switcheroo.plist +++ /dev/null @@ -1,20 +0,0 @@ - - - - - Label - com.local.switcheroo - ProgramArguments - - __APP_DIR__/Contents/MacOS/switcheroo - - RunAtLoad - - KeepAlive - - StandardOutPath - /tmp/switcheroo.log - StandardErrorPath - /tmp/switcheroo.err - - diff --git a/com.mitchelljphayes.switcheroo.plist b/com.mitchelljphayes.switcheroo.plist new file mode 100644 index 0000000..5fe5ad2 --- /dev/null +++ b/com.mitchelljphayes.switcheroo.plist @@ -0,0 +1,27 @@ + + + + + Label + com.mitchelljphayes.switcheroo + ProgramArguments + + __APP_DIR__/Contents/MacOS/switcheroo + + RunAtLoad + + KeepAlive + + + StandardOutPath + /dev/null + StandardErrorPath + /dev/null + + Umask + 63 + + diff --git a/install-binary.sh b/install-binary.sh new file mode 100755 index 0000000..0020a81 --- /dev/null +++ b/install-binary.sh @@ -0,0 +1,155 @@ +#!/bin/bash +# Binary installer for Switcheroo — installs the prebuilt universal .app +# bundle shipped in the release archive. Does NOT run cargo; installs the +# exact packaged binary after verifying its bundle identity, version, and +# ad-hoc signature. Transactional same-filesystem swap with backup/rollback. +set -euo pipefail + +PATH=/usr/bin:/bin:/usr/sbin:/sbin +export PATH + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=scripts/lib.sh +. "${SCRIPT_DIR}/scripts/lib.sh" + +APP_NAME="Switcheroo.app" +REAL_HOME="$(sw_real_home)" +APP_DIR="${REAL_HOME}/.local/bin/${APP_NAME}" +APP_DIR_PARENT="${REAL_HOME}/.local/bin" +CONFIG_DIR="${REAL_HOME}/.config/switcheroo" +PLIST_NAME="com.mitchelljphayes.switcheroo" +PLIST_SRC="${SCRIPT_DIR}/${PLIST_NAME}.plist" +PLIST_DST="${REAL_HOME}/Library/LaunchAgents/${PLIST_NAME}.plist" +PLIST_DST_DIR="${REAL_HOME}/Library/LaunchAgents" +STAGED_APP_SRC="${SCRIPT_DIR}/${APP_NAME}" +STAGED_BINARY="${STAGED_APP_SRC}/Contents/MacOS/switcheroo" +EXPECTED_BUNDLE_ID="com.mitchelljphayes.switcheroo" +OLD_LABEL="com.local.switcheroo" +OLD_PLIST_DST="${REAL_HOME}/Library/LaunchAgents/${OLD_LABEL}.plist" + +# Validate HOME and walk every parent component. +sw_assert_safe_dir "${REAL_HOME}" +sw_ensure_safe_path "${APP_DIR_PARENT}" +sw_ensure_safe_path "${CONFIG_DIR}" +sw_ensure_safe_path "${PLIST_DST_DIR}" + +# Validate the packaged bundle before installing it. +[ -d "${STAGED_APP_SRC}" ] || sw_err "packaged ${APP_NAME} not found next to this script" +BUNDLE_ID="$("${SW_USR_DEFAULTS}" read "${STAGED_APP_SRC}/Contents/Info" CFBundleIdentifier 2>/dev/null || printf '')" +[ "${BUNDLE_ID}" = "${EXPECTED_BUNDLE_ID}" ] \ + || sw_err "packaged app bundle id '${BUNDLE_ID}' != expected '${EXPECTED_BUNDLE_ID}' — refusing to install" + +BIN_VERSION="$("${STAGED_BINARY}" --version 2>/dev/null | "${SW_USR_HEAD}" -1 || printf '')" +printf '%s' "${BIN_VERSION}" | "${SW_USR_GREP}" -q '^switcheroo [0-9]' \ + || sw_err "packaged switcheroo binary failed --version smoke test (got: '${BIN_VERSION}')" + +"${SW_USR_CODESIGN}" --verify --strict "${STAGED_APP_SRC}" 2>/dev/null \ + || sw_err "packaged app fails codesign --verify (signature invalid or absent)" + +echo "==> Verified packaged ${APP_NAME} (id=${BUNDLE_ID}, ${BIN_VERSION}, ad-hoc signed)" + +# Stage on same filesystem. +echo "==> Staging app bundle (same-filesystem, transactional)" +_SW_STAGING="$("${SW_USR_MKTEMP}" -d -p "${APP_DIR_PARENT}" -t switcheroo.stage.XXXXXX)" +"${SW_BIN_CP}" -R "${STAGED_APP_SRC}" "${_SW_STAGING}/${APP_NAME}" +STAGE_APP="${_SW_STAGING}/${APP_NAME}" +STAGE_BINARY="${STAGE_APP}/Contents/MacOS/switcheroo" + +# CRITICAL: capture pre-existing state BEFORE the rollback trap. +sw_capture_state "${APP_DIR}" "${PLIST_DST}" "${PLIST_NAME}" "${OLD_LABEL}" +trap sw_rollback EXIT + +# Stop existing agents. +echo "==> Stopping existing Switcheroo agents" +if [ "$_SW_NEW_WAS_RUNNING" = "yes" ]; then + sw_bootout_safe "${PLIST_NAME}" +fi + +# Old label migration: use the NEW staged binary (not the old installed one). +if [ "$_SW_OLD_WAS_RUNNING" = "yes" ]; then + echo "==> Migrating from old label ${OLD_LABEL} (verified live Switcheroo job)..." + echo " Snapshotting foreign hidutil mappings (using NEW staged binary)..." + LEGACY_CFG="${CONFIG_DIR}/config.toml" + FOREIGN_JSON="$(sw_snapshot_legacy_foreign "${STAGE_BINARY}" "${LEGACY_CFG}")" + sw_bootout_safe "${OLD_LABEL}" + "${SW_BIN_SLEEP}" 1 + echo " Restoring foreign hidutil mappings after legacy shutdown..." + sw_hidutil_restore_json "${FOREIGN_JSON}" +elif [ -f "${OLD_PLIST_DST}" ] && sw_plist_is_switcheroo "${OLD_PLIST_DST}"; then + echo " Note: ${OLD_LABEL} plist exists (Switcheroo) but job is not loaded — will remove plist" +fi +"${SW_BIN_SLEEP}" 1 + +# Config (preserve existing). +echo "==> Installing config to ${CONFIG_DIR}/config.toml" +if [ ! -f "${CONFIG_DIR}/config.toml" ]; then + sw_reject_symlink "${CONFIG_DIR}/config.toml" + [ -f "${SCRIPT_DIR}/config.toml" ] && "${SW_BIN_CP}" "${SCRIPT_DIR}/config.toml" "${CONFIG_DIR}/config.toml" \ + || printf '# Switcheroo config — see README.md\n' > "${CONFIG_DIR}/config.toml" + "${SW_BIN_CHMOD}" 600 "${CONFIG_DIR}/config.toml" + echo " Created new config" +else + sw_reject_symlink "${CONFIG_DIR}/config.toml" + echo " Config already exists, skipping" +fi + +# Transactional app swap. +sw_reject_symlink "${APP_DIR}" +if [ "$_SW_APP_HAD_PRIOR" = "yes" ]; then + _SW_APP_BACKUP="$("${SW_USR_MKTEMP}" -d -p "${APP_DIR_PARENT}" -t switcheroo.app.bak.XXXXXX)" + "${SW_BIN_MV}" "${APP_DIR}" "${_SW_APP_BACKUP}/${APP_NAME}" + _SW_APP_BACKUP="${_SW_APP_BACKUP}/${APP_NAME}" +fi +_SW_PHASE="app_backed_up" +# Failure-injection point: if the staged move below fails, rollback +# restores the prior app from backup (app_backed_up phase). +"${SW_BIN_MV}" "${STAGE_APP}" "${APP_DIR}" +_SW_PHASE="app_swapped" + +# Transactional plist install. +echo "==> Installing LaunchAgent" +TMP_PLIST="$(sw_render_plist "${PLIST_SRC}" "${APP_DIR}" "${PLIST_DST_DIR}")" +if [ "$_SW_PLIST_HAD_PRIOR" = "yes" ]; then + _SW_PLIST_BACKUP="$("${SW_USR_MKTEMP}" -p "${PLIST_DST_DIR}" -t switcheroo.plist.bak.XXXXXX)" + "${SW_BIN_CP}" "${PLIST_DST}" "${_SW_PLIST_BACKUP}" +fi +"${SW_BIN_MV}" "${TMP_PLIST}" "${PLIST_DST}" +"${SW_BIN_CHMOD}" 644 "${PLIST_DST}" +_SW_PHASE="plist_swapped" + +# Bootstrap + verify. +echo "==> Starting switcheroo" +if ! "${SW_BIN_LAUNCHCTL}" bootstrap "gui/$(sw_uid)" "${PLIST_DST}"; then + sw_err "launchctl bootstrap failed" +fi +if ! sw_loaded_job_is_switcheroo "${PLIST_NAME}"; then + sw_err "post-bootstrap verification failed: registered job does not point at ${APP_DIR}" +fi +_SW_BOOTSTRAPPED_BY_US="yes" +_SW_PHASE="bootstrapped" + +# Success: commit. +if [ -f "${OLD_PLIST_DST}" ] && sw_plist_is_switcheroo "${OLD_PLIST_DST}"; then + "${SW_BIN_RM}" -f "${OLD_PLIST_DST}" +fi +[ -n "${_SW_APP_BACKUP:-}" ] && [ -d "${_SW_APP_BACKUP:-}" ] && "${SW_BIN_RM}" -rf "${_SW_APP_BACKUP}" 2>/dev/null || true +[ -n "${_SW_PLIST_BACKUP:-}" ] && [ -f "${_SW_PLIST_BACKUP:-}" ] && "${SW_BIN_RM}" -f "${_SW_PLIST_BACKUP}" 2>/dev/null || true +[ -n "${_SW_STAGING:-}" ] && "${SW_BIN_RM}" -rf "${_SW_STAGING}" 2>/dev/null || true +_SW_APP_BACKUP="" +_SW_PLIST_BACKUP="" +_SW_STAGING="" +_SW_PHASE="done" +trap - EXIT + +echo "" +echo "Done! Switcheroo is running (${BIN_VERSION})." +echo "" +echo "First install only (or after the bundle-id migration): Grant Accessibility access:" +echo " System Settings -> Privacy & Security -> Accessibility" +echo " Add ${APP_DIR}" +echo "" +echo "Commands:" +echo " Stop: ${SW_BIN_LAUNCHCTL} bootout gui/$(sw_uid)/${PLIST_NAME}" +echo " Start: ${SW_BIN_LAUNCHCTL} bootstrap gui/$(sw_uid) ${PLIST_DST}" +echo " Logs: ${SW_USR_TAIL} -f ${REAL_HOME}/Library/Logs/${PLIST_NAME}/daemon.err" +echo " Config: ${CONFIG_DIR}/config.toml" diff --git a/install.sh b/install.sh index e140fd1..d102c4b 100755 --- a/install.sh +++ b/install.sh @@ -1,77 +1,209 @@ -#!/usr/bin/env bash +#!/bin/bash +# Source-build installer for Switcheroo. +# +# Builds the daemon from source, assembles + signs the .app bundle in a +# same-filesystem staging directory, transactionally swaps it into place with +# backup + rollback, and installs a LaunchAgent. Handles the v0.1.x identity +# migration from com.local.switcheroo -> com.mitchelljphayes.switcheroo, +# snapshotting and restoring only foreign hidutil mappings around the legacy +# daemon's destructive shutdown using the NEW staged binary. set -euo pipefail +PATH=/usr/bin:/bin:/usr/sbin:/sbin +export PATH + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=scripts/lib.sh +. "${SCRIPT_DIR}/scripts/lib.sh" + +# Parse --cargo /absolute/path +SW_CARGO_BIN="" +while [ $# -gt 0 ]; do + case "$1" in + --cargo) SW_CARGO_BIN="$2"; shift 2;; + *) sw_err "unknown argument: $1";; + esac +done +if [ -n "$SW_CARGO_BIN" ]; then + export SW_CARGO_BIN +fi + BINARY_NAME="switcheroo" APP_NAME="Switcheroo.app" -APP_DIR="${HOME}/.local/bin/${APP_NAME}" -CONFIG_DIR="${HOME}/.config/switcheroo" -PLIST_NAME="com.local.switcheroo" -PLIST_SRC="$(cd "$(dirname "$0")" && pwd)/${PLIST_NAME}.plist" -PLIST_DST="${HOME}/Library/LaunchAgents/${PLIST_NAME}.plist" -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REAL_HOME="$(sw_real_home)" +APP_DIR="${REAL_HOME}/.local/bin/${APP_NAME}" +APP_DIR_PARENT="${REAL_HOME}/.local/bin" +CONFIG_DIR="${REAL_HOME}/.config/switcheroo" +PLIST_NAME="com.mitchelljphayes.switcheroo" +PLIST_SRC="${SCRIPT_DIR}/${PLIST_NAME}.plist" +PLIST_DST="${REAL_HOME}/Library/LaunchAgents/${PLIST_NAME}.plist" +PLIST_DST_DIR="${REAL_HOME}/Library/LaunchAgents" SIGNING_IDENTITY="Switcheroo Dev" +OLD_LABEL="com.local.switcheroo" +OLD_PLIST_DST="${REAL_HOME}/Library/LaunchAgents/${OLD_LABEL}.plist" -echo "==> Building switcheroo (release)..." -cargo build --release --manifest-path "${SCRIPT_DIR}/Cargo.toml" +# Validate HOME and walk every parent component. +sw_assert_safe_dir "${REAL_HOME}" +sw_ensure_safe_path "${APP_DIR_PARENT}" +sw_ensure_safe_path "${CONFIG_DIR}" +sw_ensure_safe_path "${PLIST_DST_DIR}" -echo "==> Installing app bundle to ${APP_DIR}" -mkdir -p "${APP_DIR}/Contents/MacOS" -mkdir -p "${APP_DIR}/Contents/Resources" -cp "${SCRIPT_DIR}/target/release/${BINARY_NAME}" "${APP_DIR}/Contents/MacOS/${BINARY_NAME}" -cp "${SCRIPT_DIR}/bundle/Info.plist" "${APP_DIR}/Contents/Info.plist" -# Generate icon if iconutil is available and iconset exists +# Build from source with --locked for reproducible builds. +CARGO_BIN="$(sw_find_cargo)" +echo "==> Building switcheroo (release) with ${CARGO_BIN}..." +"${CARGO_BIN}" build --release --locked --manifest-path "${SCRIPT_DIR}/Cargo.toml" \ + || sw_err "cargo build failed" + +# Stage the new app bundle on the SAME filesystem as the destination. +echo "==> Staging app bundle (same-filesystem, transactional)" +_SW_STAGING="$("${SW_USR_MKTEMP}" -d -p "${APP_DIR_PARENT}" -t switcheroo.stage.XXXXXX)" +STAGE_APP="${_SW_STAGING}/${APP_NAME}" +STAGE_BINARY="${STAGE_APP}/Contents/MacOS/${BINARY_NAME}" +"${SW_BIN_MKDIR}" -p "${STAGE_APP}/Contents/MacOS" +"${SW_BIN_MKDIR}" -p "${STAGE_APP}/Contents/Resources" +"${SW_BIN_CP}" "${SCRIPT_DIR}/target/release/${BINARY_NAME}" "${STAGE_APP}/Contents/MacOS/${BINARY_NAME}" +"${SW_BIN_CP}" "${SCRIPT_DIR}/bundle/Info.plist" "${STAGE_APP}/Contents/Info.plist" +# Stamp Info.plist version from the built binary so source builds always +# report the Cargo version, not the hardcoded committed plist version. +# Fail-closed: require exact "switcheroo X.Y.Z" output matching Cargo.toml. +STAGED_BIN_VERSION_LINE="$("${STAGE_BINARY}" --version 2>/dev/null | "${SW_USR_HEAD}" -1 || true)" +STAGED_BIN_VERSION="$(printf '%s' "${STAGED_BIN_VERSION_LINE}" | "${SW_USR_SED}" 's/^switcheroo //' || true)" +if printf '%s' "${STAGED_BIN_VERSION}" | "${SW_USR_GREP}" -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + # Require Cargo metadata extraction to succeed (fail-closed, no empty bypass) + CARGO_PKG_VER="$("${CARGO_BIN}" metadata --no-deps --format-version 1 --manifest-path "${SCRIPT_DIR}/Cargo.toml" 2>/dev/null \ + | "${SW_USR_GREP}" -o '"version":"[^"]*"' | "${SW_USR_HEAD}" -1 | "${SW_USR_SED}" 's/"version":"\([^"]*\)"/\1/' || true)" + if [ -z "${CARGO_PKG_VER}" ]; then + sw_err "cargo metadata extraction failed — cannot verify binary version matches Cargo.toml" + fi + if ! printf '%s' "${CARGO_PKG_VER}" | "${SW_USR_GREP}" -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + sw_err "Cargo.toml version '${CARGO_PKG_VER}' is not strict semver — refusing to install" + fi + if [ "${STAGED_BIN_VERSION}" != "${CARGO_PKG_VER}" ]; then + sw_err "binary version '${STAGED_BIN_VERSION}' != Cargo.toml version '${CARGO_PKG_VER}' — refusing to install with mismatched version" + fi + "${SW_USR_PLUTIL}" -replace CFBundleVersion -string "${STAGED_BIN_VERSION}" "${STAGE_APP}/Contents/Info.plist" + "${SW_USR_PLUTIL}" -replace CFBundleShortVersionString -string "${STAGED_BIN_VERSION}" "${STAGE_APP}/Contents/Info.plist" +else + sw_err "binary --version output not exact 'switcheroo X.Y.Z': got '${STAGED_BIN_VERSION_LINE}' — refusing to install with unvalidated version" +fi +"${SW_USR_PLUTIL}" -lint "${STAGE_APP}/Contents/Info.plist" >/dev/null if [ -d "${SCRIPT_DIR}/bundle/Switcheroo.iconset" ]; then - iconutil -c icns "${SCRIPT_DIR}/bundle/Switcheroo.iconset" -o "${APP_DIR}/Contents/Resources/AppIcon.icns" + "${SW_USR_ICONUTIL}" -c icns "${SCRIPT_DIR}/bundle/Switcheroo.iconset" \ + -o "${STAGE_APP}/Contents/Resources/AppIcon.icns" || sw_err "iconutil failed" elif [ -f "${APP_DIR}/Contents/Resources/AppIcon.icns" ]; then - echo " Using existing icon" + "${SW_BIN_CP}" "${APP_DIR}/Contents/Resources/AppIcon.icns" "${STAGE_APP}/Contents/Resources/AppIcon.icns" fi -echo "==> Signing app bundle" -if security find-identity -v -p codesigning 2>/dev/null | grep -q "${SIGNING_IDENTITY}"; then - codesign --force --sign "${SIGNING_IDENTITY}" "${APP_DIR}" - echo " Signed with '${SIGNING_IDENTITY}'" +echo "==> Signing staged app bundle" +if "${SW_USR_SECURITY}" find-identity -v -p codesigning 2>/dev/null | "${SW_USR_GREP}" -q "${SIGNING_IDENTITY}"; then + "${SW_USR_CODESIGN}" --force --sign "${SIGNING_IDENTITY}" "${STAGE_APP}" || sw_err "codesign failed" + echo " Signed with '${SIGNING_IDENTITY}'" else - echo " No '${SIGNING_IDENTITY}' certificate found — using ad-hoc signing." - echo " To avoid re-granting Accessibility after each rebuild, create a" - echo " self-signed code signing certificate named '${SIGNING_IDENTITY}':" - echo " 1. Open Keychain Access" - echo " 2. Menu: Keychain Access → Certificate Assistant → Create a Certificate..." - echo " 3. Name: ${SIGNING_IDENTITY}" - echo " 4. Certificate Type: Code Signing" - echo " 5. Re-run ./install.sh" - codesign --force --sign - "${APP_DIR}" - echo " Signed ad-hoc (Accessibility permission will reset on each rebuild)" + echo " No '${SIGNING_IDENTITY}' certificate found — using ad-hoc signing." + "${SW_USR_CODESIGN}" --force --sign - "${STAGE_APP}" || sw_err "ad-hoc codesign failed" +fi + +# CRITICAL: capture pre-existing state BEFORE installing the rollback trap. +# Early failure (e.g. migration snapshot failure) must never delete a +# pre-existing installation. +sw_capture_state "${APP_DIR}" "${PLIST_DST}" "${PLIST_NAME}" "${OLD_LABEL}" +trap sw_rollback EXIT + +# Stop existing agents BEFORE overwriting the live bundle. +echo "==> Stopping existing Switcheroo agents" +if [ "$_SW_NEW_WAS_RUNNING" = "yes" ]; then + sw_bootout_safe "${PLIST_NAME}" +fi + +# Old label migration: use the NEW staged binary (not the old installed one) +# for the foreign-only snapshot. The old binary doesn't support the flag. +if [ "$_SW_OLD_WAS_RUNNING" = "yes" ]; then + echo "==> Migrating from old label ${OLD_LABEL} (verified live Switcheroo job)..." + echo " Snapshotting foreign hidutil mappings (using NEW staged binary)..." + LEGACY_CFG="${CONFIG_DIR}/config.toml" + FOREIGN_JSON="$(sw_snapshot_legacy_foreign "${STAGE_BINARY}" "${LEGACY_CFG}")" + sw_bootout_safe "${OLD_LABEL}" + "${SW_BIN_SLEEP}" 1 + echo " Restoring foreign hidutil mappings after legacy shutdown..." + sw_hidutil_restore_json "${FOREIGN_JSON}" +elif [ -f "${OLD_PLIST_DST}" ] && sw_plist_is_switcheroo "${OLD_PLIST_DST}"; then + echo " Note: ${OLD_LABEL} plist exists (Switcheroo) but job is not loaded — will remove plist" fi +"${SW_BIN_SLEEP}" 1 +# Config (preserve existing). echo "==> Installing config to ${CONFIG_DIR}/config.toml" -mkdir -p "${CONFIG_DIR}" if [ ! -f "${CONFIG_DIR}/config.toml" ]; then - cp "${SCRIPT_DIR}/config.toml" "${CONFIG_DIR}/config.toml" - echo " Created new config" + sw_reject_symlink "${CONFIG_DIR}/config.toml" + "${SW_BIN_CP}" "${SCRIPT_DIR}/config.toml" "${CONFIG_DIR}/config.toml" + "${SW_BIN_CHMOD}" 600 "${CONFIG_DIR}/config.toml" + echo " Created new config" else - echo " Config already exists, skipping (edit ${CONFIG_DIR}/config.toml)" + sw_reject_symlink "${CONFIG_DIR}/config.toml" + echo " Config already exists, skipping (edit ${CONFIG_DIR}/config.toml)" fi -echo "==> Installing LaunchAgent" -# Stop existing agent if running (triggers hidutil cleanup via signal handler) -launchctl bootout "gui/$(id -u)/${PLIST_NAME}" 2>/dev/null || true +# Transactional app swap: back up old, move new into place. +sw_reject_symlink "${APP_DIR}" +if [ "$_SW_APP_HAD_PRIOR" = "yes" ]; then + _SW_APP_BACKUP="$("${SW_USR_MKTEMP}" -d -p "${APP_DIR_PARENT}" -t switcheroo.app.bak.XXXXXX)" + "${SW_BIN_MV}" "${APP_DIR}" "${_SW_APP_BACKUP}/${APP_NAME}" + _SW_APP_BACKUP="${_SW_APP_BACKUP}/${APP_NAME}" +fi +_SW_PHASE="app_backed_up" +# Failure-injection point: if the staged move below fails, the app_backed_up +# phase ensures rollback restores the prior app from backup. +"${SW_BIN_MV}" "${STAGE_APP}" "${APP_DIR}" +_SW_PHASE="app_swapped" -# Template the plist with the actual app directory path -sed "s|__APP_DIR__|${APP_DIR}|g" "${PLIST_SRC}" > "${PLIST_DST}" +# Transactional plist install: back up old, move new into place. +echo "==> Installing LaunchAgent" +TMP_PLIST="$(sw_render_plist "${PLIST_SRC}" "${APP_DIR}" "${PLIST_DST_DIR}")" +if [ "$_SW_PLIST_HAD_PRIOR" = "yes" ]; then + _SW_PLIST_BACKUP="$("${SW_USR_MKTEMP}" -p "${PLIST_DST_DIR}" -t switcheroo.plist.bak.XXXXXX)" + "${SW_BIN_CP}" "${PLIST_DST}" "${_SW_PLIST_BACKUP}" +fi +"${SW_BIN_MV}" "${TMP_PLIST}" "${PLIST_DST}" +"${SW_BIN_CHMOD}" 644 "${PLIST_DST}" +_SW_PHASE="plist_swapped" +# Bootstrap + verify. echo "==> Starting switcheroo" -launchctl bootstrap "gui/$(id -u)" "${PLIST_DST}" +if ! "${SW_BIN_LAUNCHCTL}" bootstrap "gui/$(sw_uid)" "${PLIST_DST}"; then + sw_err "launchctl bootstrap failed" +fi +if ! sw_loaded_job_is_switcheroo "${PLIST_NAME}"; then + sw_err "post-bootstrap verification failed: registered job does not point at ${APP_DIR}" +fi +_SW_BOOTSTRAPPED_BY_US="yes" +_SW_PHASE="bootstrapped" + +# Success: commit the transaction, clean up backups. +if [ -f "${OLD_PLIST_DST}" ] && sw_plist_is_switcheroo "${OLD_PLIST_DST}"; then + "${SW_BIN_RM}" -f "${OLD_PLIST_DST}" +fi +[ -n "${_SW_APP_BACKUP:-}" ] && [ -d "${_SW_APP_BACKUP:-}" ] && "${SW_BIN_RM}" -rf "${_SW_APP_BACKUP}" 2>/dev/null || true +[ -n "${_SW_PLIST_BACKUP:-}" ] && [ -f "${_SW_PLIST_BACKUP:-}" ] && "${SW_BIN_RM}" -f "${_SW_PLIST_BACKUP}" 2>/dev/null || true +[ -n "${_SW_STAGING:-}" ] && "${SW_BIN_RM}" -rf "${_SW_STAGING}" 2>/dev/null || true +_SW_APP_BACKUP="" +_SW_PLIST_BACKUP="" +_SW_STAGING="" +_SW_PHASE="done" +trap - EXIT echo "" echo "Done! Switcheroo is running." echo "" -echo "First install only: Grant Accessibility access:" -echo " System Settings → Privacy & Security → Accessibility" +echo "First install only (or after the bundle-id migration): Grant Accessibility access:" +echo " System Settings -> Privacy & Security -> Accessibility" echo " Add ${APP_DIR}" echo " (Subsequent rebuilds preserve the permission via code signing)" +echo " Note: the v0.1.x migration changed the bundle id from com.local.switcheroo" +echo " to com.mitchelljphayes.switcheroo, so an existing Accessibility grant must" +echo " be re-issued once after upgrade." echo "" echo "Commands:" -echo " Stop: launchctl bootout gui/\$(id -u)/${PLIST_NAME}" -echo " Start: launchctl bootstrap gui/\$(id -u) ${PLIST_DST}" -echo " Logs: tail -f /tmp/switcheroo.err" +echo " Stop: ${SW_BIN_LAUNCHCTL} bootout gui/$(sw_uid)/${PLIST_NAME}" +echo " Start: ${SW_BIN_LAUNCHCTL} bootstrap gui/$(sw_uid) ${PLIST_DST}" +echo " Logs: ${SW_USR_TAIL} -f ${REAL_HOME}/Library/Logs/${PLIST_NAME}/daemon.err" echo " Config: ${CONFIG_DIR}/config.toml" diff --git a/packaging/homebrew/render_formula.sh b/packaging/homebrew/render_formula.sh new file mode 100755 index 0000000..830edcb --- /dev/null +++ b/packaging/homebrew/render_formula.sh @@ -0,0 +1,198 @@ +#!/bin/bash +# ───────────────────────────────────────────────────────────────────── +# packaging/homebrew/render_formula.sh — render the Homebrew Formula. +# +# Usage (production): +# render_formula.sh --version --sha256 <64-lowercase-hex> +# +# Usage (local rehearsal): +# render_formula.sh --version --local-archive --rehearsal +# +# Production: URL is constructed internally from the version. No +# arbitrary URL input accepted. SHA must be exactly 64 lowercase hex. +# +# Rehearsal: The local archive path is validated (absolute, regular +# non-symlink file, safe characters only). The archive is copied to a +# script-created safe filename, and the file:// URL + checksum are +# derived from that copy. The rendered formula contains an unmistakable +# non-publishable rehearsal marker. No caller-supplied path enters the +# sed expression directly. +# +# Security: no arbitrary URL/Ruby fragments are accepted. All inputs +# are strictly validated. Output is written via mktemp + atomic rename. +# ───────────────────────────────────────────────────────────────────── +set -euo pipefail + +PATH=/usr/bin:/bin:/usr/sbin:/sbin +export PATH + +SW_USR_SED=/usr/bin/sed +SW_USR_GREP=/usr/bin/grep +SW_USR_SHASUM=/usr/bin/shasum +SW_USR_MKTEMP=/usr/bin/mktemp +SW_BIN_RM=/bin/rm +SW_BIN_MV=/bin/mv + +# Trap to clean up safe archive directory on exit +_SAFE_ARCHIVE_DIR="" +_render_cleanup() { + if [ -n "$_SAFE_ARCHIVE_DIR" ] && [ -d "$_SAFE_ARCHIVE_DIR" ]; then + $SW_BIN_RM -rf "$_SAFE_ARCHIVE_DIR" 2>/dev/null || true + fi +} +trap _render_cleanup EXIT +SW_BIN_CP=/bin/cp + +SW_ERR() { printf 'Error: %s\n' "$*" >&2; exit 1; } + +REPO_OWNER="mitchelljphayes" +REPO_NAME="switcheroo" +GITHUB_BASE="https://github.com/${REPO_OWNER}/${REPO_NAME}" + +VERSION="" +SHA256="" +LOCAL_ARCHIVE="" +REHEARSAL=false + +while [ $# -gt 0 ]; do + case "$1" in + --version) VERSION="$2"; shift 2;; + --sha256) SHA256="$2"; shift 2;; + --local-archive) LOCAL_ARCHIVE="$2"; shift 2;; + --rehearsal) REHEARSAL=true; shift;; + *) SW_ERR "unknown argument: $1";; + esac +done + +[ -n "$VERSION" ] || SW_ERR "--version is required" +printf '%s' "$VERSION" | $SW_USR_GREP -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || SW_ERR "--version must be strict semver X.Y.Z (got: '$VERSION')" + +SCRIPT_DIR="${0%/*}" +case "$SCRIPT_DIR" in + /*) ;; + *) SCRIPT_DIR="${PWD}/${SCRIPT_DIR}";; +esac +SCRIPT_DIR="$(cd "$SCRIPT_DIR" && pwd)" +TEMPLATE="${SCRIPT_DIR}/switcheroo.rb.tpl" +OUTPUT="${SCRIPT_DIR}/switcheroo.rb" +[ -f "$TEMPLATE" ] || SW_ERR "template not found: $TEMPLATE" + +# ── Determine mode ──────────────────────────────────────────────────── +if [ "$REHEARSAL" = "true" ]; then + [ -n "$LOCAL_ARCHIVE" ] || SW_ERR "--rehearsal requires --local-archive " + [ -z "$SHA256" ] || SW_ERR "--sha256 must not be provided in rehearsal mode" + + # Validate archive path: must be absolute, not a symlink, regular file + case "$LOCAL_ARCHIVE" in + /*) ;; + *) SW_ERR "--local-archive must be an absolute path (got: '$LOCAL_ARCHIVE')";; + esac + [ -L "$LOCAL_ARCHIVE" ] && SW_ERR "--local-archive is a symlink — refusing: $LOCAL_ARCHIVE" + [ -f "$LOCAL_ARCHIVE" ] || SW_ERR "--local-archive not found or not a regular file: $LOCAL_ARCHIVE" + [ -p "$LOCAL_ARCHIVE" ] && SW_ERR "--local-archive is a FIFO — refusing: $LOCAL_ARCHIVE" + + # Reject paths with unsafe characters (spaces, quotes, backslashes, control chars, |, &, ;, etc.) + # Allow: alphanumeric, /, -, _, ., : (for file:// URLs) + case "$LOCAL_ARCHIVE" in + *[!a-zA-Z0-9/._-]*) SW_ERR "--local-archive contains unsafe characters — refusing: $LOCAL_ARCHIVE";; + esac + + # Copy archive to a script-created safe filename (eliminates any path injection) + # The directory is cleaned up by the trap on exit + _SAFE_ARCHIVE_DIR="$($SW_USR_MKTEMP -d -t switcheroo.rehearsal.XXXXXX)" + SAFE_ARCHIVE_DIR="$_SAFE_ARCHIVE_DIR" + SAFE_ARCHIVE="${SAFE_ARCHIVE_DIR}/switcheroo-${VERSION}-source-rehearsal.tar.gz" + $SW_BIN_CP "$LOCAL_ARCHIVE" "$SAFE_ARCHIVE" + [ -f "$SAFE_ARCHIVE" ] || { $SW_BIN_RM -rf "$SAFE_ARCHIVE_DIR"; SW_ERR "failed to copy archive"; } + + # Derive URL and checksum from the safe copy + SHA256="$($SW_USR_SHASUM -a 256 "$SAFE_ARCHIVE" | { read -r h _; printf '%s' "$h"; })" + URL="file://${SAFE_ARCHIVE}" + + echo "==> Rendering formula [REHEARSAL — NOT FOR PUBLICATION]" + echo " Version: $VERSION" + echo " URL: $URL" + echo " SHA-256: $SHA256" + echo " Source: $LOCAL_ARCHIVE → $SAFE_ARCHIVE (safe copy)" + echo " NOTE: This is a REHEARSAL formula. Do not publish or distribute." +else + [ -n "$SHA256" ] || SW_ERR "production mode requires --sha256 <64-lowercase-hex>" + [ -z "$LOCAL_ARCHIVE" ] || SW_ERR "--local-archive is only valid with --rehearsal" + printf '%s' "$SHA256" | $SW_USR_GREP -Eq '^[0-9a-f]{64}$' \ + || SW_ERR "--sha256 must be exactly 64 lowercase hex (got: '$SHA256')" + URL="${GITHUB_BASE}/archive/refs/tags/v${VERSION}.tar.gz" + echo "==> Rendering formula [PRODUCTION]" + echo " Version: $VERSION" + echo " URL: $URL" + echo " SHA-256: $SHA256" +fi + +# ── Substitute placeholders ─────────────────────────────────────────── +# Determine rehearsal marker substitution +if [ "$REHEARSAL" = "true" ]; then + REHEARSAL_LINE="# REHEARSAL — DO NOT PUBLISH — THIS IS NOT A PRODUCTION FORMULA" +else + REHEARSAL_LINE="" +fi + +if [ -L "$OUTPUT" ]; then + SW_ERR "output path is a symlink — refusing: $OUTPUT" +fi +TMP_OUTPUT="$($SW_USR_MKTEMP -t switcheroo.rb.XXXXXX)" + +$SW_USR_SED \ + -e "s|__URL__|${URL}|g" \ + -e "s|__SHA256__|${SHA256}|g" \ + -e "s|__VERSION__|${VERSION}|g" \ + -e "s|__REHEARSAL_MARKER__|${REHEARSAL_LINE}|g" \ + "$TEMPLATE" > "$TMP_OUTPUT" || { + $SW_BIN_RM -f "$TMP_OUTPUT" + [ -n "${SAFE_ARCHIVE_DIR:-}" ] && $SW_BIN_RM -rf "$SAFE_ARCHIVE_DIR" + SW_ERR "sed substitution failed" +} + +# Verify no placeholders remain (including rehearsal marker) +if $SW_USR_GREP -qE '__URL__|__SHA256__|__VERSION__|__REHEARSAL_MARKER__' "$TMP_OUTPUT"; then + $SW_BIN_RM -f "$TMP_OUTPUT" + [ -n "${SAFE_ARCHIVE_DIR:-}" ] && $SW_BIN_RM -rf "$SAFE_ARCHIVE_DIR" + SW_ERR "rendered formula still contains unresolved placeholders" +fi + +# For rehearsal: verify the DO NOT PUBLISH marker is present +if [ "$REHEARSAL" = "true" ]; then + if ! $SW_USR_GREP -q 'DO NOT PUBLISH' "$TMP_OUTPUT"; then + $SW_BIN_RM -f "$TMP_OUTPUT" + [ -n "${SAFE_ARCHIVE_DIR:-}" ] && $SW_BIN_RM -rf "$SAFE_ARCHIVE_DIR" + SW_ERR "rehearsal formula missing DO NOT PUBLISH marker" + fi +fi + +$SW_BIN_MV -f "$TMP_OUTPUT" "$OUTPUT" +echo " Rendered: $OUTPUT" + +# Note: safe archive copy is cleaned up by the EXIT trap after brew style. +# For tap-based audit/install rehearsal, copy the rendered formula to a +# tap before this script exits, or run brew commands within this script's +# lifetime (the safe archive is available until trap fires). + +# ── Optional: brew style ────────────────────────────────────────────── +BREW="" +for c in /opt/homebrew/bin/brew /usr/local/bin/brew; do + if [ -x "$c" ]; then BREW="$c"; break; fi +done + +if [ -n "$BREW" ]; then + echo "==> Running brew style" + if "$BREW" style "$OUTPUT" 2>&1; then + echo " OK: brew style" + else + echo " WARNING: brew style reported issues (non-blocking)" + fi + # Note: brew audit --strict --online requires a tap (not a file path). + # See RELEASING.md for the exact tap-based audit command. +else + echo " (brew not available — skipping style)" +fi + +echo "==> Formula rendered successfully" \ No newline at end of file diff --git a/packaging/homebrew/switcheroo.rb.tpl b/packaging/homebrew/switcheroo.rb.tpl new file mode 100644 index 0000000..0be6fd7 --- /dev/null +++ b/packaging/homebrew/switcheroo.rb.tpl @@ -0,0 +1,113 @@ +# typed: strict +# frozen_string_literal: true +__REHEARSAL_MARKER__ +# ───────────────────────────────────────────────────────────────────── +# packaging/homebrew/switcheroo.rb.tpl — Homebrew Formula template. +# +# Placeholders (rendered by packaging/homebrew/render_formula.sh): +# __URL__ → immutable GitHub source archive URL (constructed internally) +# __SHA256__ → SHA-256 of the source archive (64 lowercase hex) +# __VERSION__ → version string (used only in rehearsal comments; the +# formula body has no explicit version stanza — Homebrew +# derives it from the URL tag). +# +# The template is tracked in-repo. The rendered switcheroo.rb (with a +# real sha256) is produced AFTER a public tag/archive exists — a later +# authorized action. Until then, only this template ships. +# +# Structural rules (approved defaults, see plan.md §5): +# - builds native from source via `cargo install *std_cargo_args --locked` +# - macOS-only, rust build dependency +# - service do block (standard homebrew.mxcl.switcheroo label) +# - does NOT call install.sh / install-binary.sh +# - does NOT write ~/.local/bin or ~/Library/LaunchAgents +# - installs sample config under Homebrew-managed etc/ only +# - ad-hoc codesign (locally compiled, no Developer ID) +# - caveats document Accessibility + config location + re-grant note +# - test block runs --version +# ───────────────────────────────────────────────────────────────────── +class Switcheroo < Formula + desc "Lightweight macOS keyboard remapper using CGEventTap" + homepage "https://github.com/mitchelljphayes/switcheroo" + url "__URL__" + sha256 "__SHA256__" + license "MIT" + + depends_on "rust" => :build + + def install + # Build and install via Homebrew's standard cargo args (includes + # --locked, --root, --path, --jobs). The binary installs to bin/, + # then we assemble the .app bundle from it. + system "cargo", "install", *std_cargo_args + + # Assemble the .app bundle under the Homebrew cellar. + app = prefix / "Switcheroo.app" + (app / "Contents/MacOS").mkpath + (app / "Contents/Resources").mkpath + cp bin / "switcheroo", app / "Contents/MacOS/switcheroo" + cp "bundle/Info.plist", app / "Contents/Info.plist" + + # Generate AppIcon.icns from the tracked PNG master + # (bundle/AppIcon-1024.png — same path the packager uses). + icon_master = buildpath / "bundle/AppIcon-1024.png" + if File.exist?(icon_master) + tmpdir = Dir.mktmpdir("switcheroo-icon") + iconset = File.join(tmpdir, "AppIcon.iconset") + Dir.mkdir(iconset) + sizes = [16, 32, 64, 128, 256, 512] + sizes.each do |s| + system "/usr/bin/sips", "-z", s.to_s, s.to_s, icon_master.to_s, + "--out", File.join(iconset, "icon_#{s}x#{s}.png"), + out: File::NULL, err: File::NULL + system "/usr/bin/sips", "-z", (s * 2).to_s, (s * 2).to_s, icon_master.to_s, + "--out", File.join(iconset, "icon_#{s}x#{s}@2x.png"), + out: File::NULL, err: File::NULL + end + system "/usr/bin/iconutil", "-c", "icns", iconset, + "-o", app / "Contents/Resources/AppIcon.icns" + remove_entry tmpdir + end + + # Ad-hoc sign the locally-compiled bundle (no Developer ID needed). + system "/usr/bin/codesign", "--force", "--sign", "-", app.to_s + + # Install sample config to Homebrew etc/ (documentation sample only; + # the daemon reads from ~/.config/switcheroo/config.toml). + (etc / "switcheroo").mkpath + etc.install "config.toml" => "switcheroo/config.toml" unless (etc / "switcheroo/config.toml").exist? + end + + service do + run [opt_prefix / "Switcheroo.app/Contents/MacOS/switcheroo"] + keep_alive true + run_at_load true + environment_variables PATH: std_service_path_env + log_path var / "log/switcheroo.log" + error_log_path var / "log/switcheroo.err" + end + + def caveats + <<~EOS + Switcheroo requires macOS Accessibility permission: + System Settings → Privacy & Security → Accessibility + Add: #{opt_prefix}/Switcheroo.app + + To start switcheroo now and on login: + brew services start switcheroo + + Config: the daemon reads from ~/.config/switcheroo/config.toml + A sample config is installed at: + #{etc}/switcheroo/config.toml + Create or symlink your active config at ~/.config/switcheroo/config.toml + + Note: ad-hoc signing means Accessibility permission may need + re-granting after each `brew upgrade` (the binary is recompiled + locally with a new ad-hoc signature). + EOS + end + + test do + assert_match "switcheroo", shell_output("#{opt_prefix}/Switcheroo.app/Contents/MacOS/switcheroo --version") + end +end diff --git a/raycast-extension/README.md b/raycast-extension/README.md new file mode 100644 index 0000000..e360676 --- /dev/null +++ b/raycast-extension/README.md @@ -0,0 +1,78 @@ +# Switcheroo + +Manage your Switcheroo keyboard remapper configuration from Raycast. + +## Daemon dependency + +This extension requires the [Switcheroo daemon](https://github.com/mitchelljphayes/switcheroo) +to be installed and running. + +### Install via Homebrew (recommended) + +```bash +brew tap mitchelljphayes/switcheroo +brew install switcheroo +brew services start switcheroo +``` + +### Build from source + +See the [repository README](https://github.com/mitchelljphayes/switcheroo#install) +for source-build instructions. + +## Accessibility permission + +Switcheroo requires macOS Accessibility permission to intercept +keyboard events via `CGEventTap`. After installing the daemon: + +1. System Settings → Privacy & Security → Accessibility +2. Add the Switcheroo app: + - Homebrew: `$(brew --prefix switcheroo)/Switcheroo.app` + - Standalone: `~/.local/bin/Switcheroo.app` + +> **Note:** ad-hoc signing means Accessibility permission may need +> re-granting after each `brew upgrade` or rebuild. + +## Service layouts + +The extension supports both install layouts: + +- **Homebrew** (`brew services`): label `homebrew.mxcl.switcheroo`, + restarted via `launchctl kickstart -k`. +- **Standalone** (`install.sh`): label + `com.mitchelljphayes.switcheroo`, restarted via `launchctl bootout` + + `bootstrap`. + +The extension auto-detects which layout is active and refuses to +restart a foreign/ambiguous job. If neither is running, it shows an +actionable error with install instructions. + +## Commands + +- **View Remaps** — View and manage all keyboard remapping rules +- **Add Remap** — Add a new keyboard remapping rule +- **Restart Switcheroo** — Restart the Switcheroo service +- **View Logs** — View recent Switcheroo log output +- **Edit Config** — Open Switcheroo config in your default editor + +## Development + +```bash +npm install +npm run dev +``` + +### Linting + +This extension has two lint tiers: + +- **`npm run lint:ci`** — local code-quality gate (ESLint + Prettier on + `src/**`). Used by CI and for everyday development. No network access. +- **`npm run lint`** (i.e. `ray lint`) — full Raycast Store validation. + This additionally validates `package.json` metadata (schema, icons) and + performs an **external Store-author API check** against + `https://www.raycast.com/api/v1/users/`. The `author` field must + be a registered Raycast Store author handle (not a display name) for + this check to pass. Run this before submitting to the Raycast Store; + it is intentionally not part of CI so local development and PRs are not + blocked on the external API. \ No newline at end of file diff --git a/raycast-extension/assets/command-icon.png b/raycast-extension/assets/command-icon.png index cd2afd1..b7f570f 100644 Binary files a/raycast-extension/assets/command-icon.png and b/raycast-extension/assets/command-icon.png differ diff --git a/raycast-extension/eslint.config.js b/raycast-extension/eslint.config.js new file mode 100644 index 0000000..6948a96 --- /dev/null +++ b/raycast-extension/eslint.config.js @@ -0,0 +1,6 @@ +// eslint.config.js — ESLint 9 flat config for the Raycast extension. +// Uses @raycast/eslint-config which provides the recommended preset +// including TypeScript, React, and Raycast-specific rules. +const raycastConfig = require("@raycast/eslint-config"); + +module.exports = raycastConfig; \ No newline at end of file diff --git a/raycast-extension/package-lock.json b/raycast-extension/package-lock.json index 4f21edc..f712a67 100644 --- a/raycast-extension/package-lock.json +++ b/raycast-extension/package-lock.json @@ -11,12 +11,12 @@ "smol-toml": "^1.3.1" }, "devDependencies": { - "@raycast/eslint-config": "^1.0.11", + "@raycast/eslint-config": "^2.2.0", "@types/node": "22.13.14", "@types/react": "19.0.12", "eslint": "^9.23.0", "prettier": "^3.5.3", - "typescript": "^5.8.2" + "typescript": "^5.9.3" } }, "node_modules/@esbuild/darwin-arm64": { @@ -100,9 +100,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -181,9 +181,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -242,29 +242,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -641,44 +655,6 @@ } } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@oclif/core": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.9.0.tgz", @@ -807,372 +783,238 @@ } }, "node_modules/@raycast/eslint-config": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@raycast/eslint-config/-/eslint-config-1.0.11.tgz", - "integrity": "sha512-I0Lt8bwahVGkANUBxripIxKptMBz1Ou+UXGwfqgFvKwo1gVLrnlEngxaspQJA8L5pvzQkQMwizVCSgNC3bddWg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@raycast/eslint-config/-/eslint-config-2.2.0.tgz", + "integrity": "sha512-uS/UDvM+3HfABgYhLYnIv0y+IVu+rGiZZIMwQsO5EGrcf2/TX/yioTzfezAQqhdszW4ViCuHbv8BddIagDKZvQ==", "dev": true, "license": "MIT", "dependencies": { - "@raycast/eslint-plugin": "^1.0.11", - "@rushstack/eslint-patch": "^1.10.4", - "@typescript-eslint/eslint-plugin": "^6.8.0", - "@typescript-eslint/parser": "^6.8.0", - "eslint-config-prettier": "^9.1.0" + "@eslint/js": "^9.39.4", + "@raycast/eslint-plugin": "^2.2.0", + "eslint-config-prettier": "^10.1.8", + "globals": "^17.7.0", + "typescript-eslint": "^8.62.0" }, "peerDependencies": { - "eslint": ">=7", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "prettier": ">=2", - "typescript": ">=4" + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@raycast/eslint-config/node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", - "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "node_modules/@raycast/eslint-config/node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", "dev": true, "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/type-utils": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.4", - "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@raycast/eslint-config/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", - "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "node_modules/@raycast/eslint-plugin": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@raycast/eslint-plugin/-/eslint-plugin-2.2.0.tgz", + "integrity": "sha512-ixO3PoRAEAZZcSQeRPvvDS/vs09D47tXdyXrs7hctoxxPtweXgJ6I24kjCD5GuuuQS5hGTiuL+Uy6hIg6a/a4w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "@typescript-eslint/utils": "^8.62.0" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, - "node_modules/@raycast/eslint-config/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", - "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "semver": "^7.5.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - } + "license": "MIT" }, - "node_modules/@raycast/eslint-config/node_modules/@typescript-eslint/parser": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", - "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.13.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz", + "integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==", + "devOptional": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "undici-types": "~6.20.0" } }, - "node_modules/@raycast/eslint-plugin": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/@raycast/eslint-plugin/-/eslint-plugin-1.0.16.tgz", - "integrity": "sha512-OyFL/W75/4hlgdUUI80Eoes0HjpVrJ8I1kB/PBH2RLjbcK22TC6IwZPXvhBZ5jF962O1TqtOuHrTjySwDaa/cQ==", + "node_modules/@types/react": { + "version": "19.0.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.12.tgz", + "integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^5.62.0" - }, - "peerDependencies": { - "eslint": ">=7" + "csstype": "^3.0.2" } }, - "node_modules/@raycast/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", - "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", + "integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/type-utils": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.69.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@raycast/eslint-plugin/node_modules/@typescript-eslint/types": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", - "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", "dev": true, "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">= 4" } }, - "node_modules/@raycast/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", - "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", + "node_modules/@typescript-eslint/parser": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz", + "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "debug": "^4.4.3" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@raycast/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", - "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", + "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", - "eslint-scope": "^5.1.1", - "semver": "^7.3.7" + "@typescript-eslint/tsconfig-utils": "^8.69.0", + "@typescript-eslint/types": "^8.69.0", + "debug": "^4.4.3" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@raycast/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", - "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", + "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "5.62.0", - "eslint-visitor-keys": "^3.3.0" + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@raycast/eslint-plugin/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@raycast/eslint-plugin/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", + "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@raycast/eslint-plugin/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/@rushstack/eslint-patch": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", - "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.13.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz", - "integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.20.0" - } - }, - "node_modules/@types/react": { - "version": "19.0.12", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.12.tgz", - "integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.0.2" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@types/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", - "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz", + "integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", - "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", + "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", "dev": true, "license": "MIT", "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -1180,79 +1022,69 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", - "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", + "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "@typescript-eslint/project-service": "8.69.0", + "@typescript-eslint/tsconfig-utils": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "node_modules/@typescript-eslint/utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz", + "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", - "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", + "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "eslint-visitor-keys": "^3.4.1" + "@typescript-eslint/types": "8.69.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -1260,13 +1092,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -1367,16 +1199,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -1393,28 +1215,15 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" + "node": "20 || >=22" } }, "node_modules/callsites": { @@ -1569,19 +1378,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -1717,14 +1513,17 @@ } }, "node_modules/eslint-config-prettier": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", - "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, "peerDependencies": { "eslint": ">=7.0.0" } @@ -1767,9 +1566,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1861,36 +1660,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -1916,16 +1685,6 @@ "node": ">= 4.9.1" } }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1972,9 +1731,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -1992,19 +1751,6 @@ "node": ">=10" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2037,9 +1783,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -2078,34 +1824,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2224,16 +1942,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -2271,10 +1979,20 @@ } }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -2363,43 +2081,6 @@ "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -2527,16 +2208,6 @@ "node": ">=8" } }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2544,9 +2215,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -2591,27 +2262,6 @@ "node": ">=6" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/react": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", @@ -2631,41 +2281,6 @@ "node": ">=4" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -2719,20 +2334,10 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/smol-toml": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", - "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -2811,53 +2416,17 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "license": "0BSD" - }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^1.8.1" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + "typescript": ">=4.8.4" } }, "node_modules/type-check": { @@ -2899,6 +2468,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz", + "integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.69.0", + "@typescript-eslint/parser": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", diff --git a/raycast-extension/package.json b/raycast-extension/package.json index 9542b69..41cced8 100644 --- a/raycast-extension/package.json +++ b/raycast-extension/package.json @@ -5,7 +5,10 @@ "description": "Manage Switcheroo keyboard remapper configuration", "icon": "command-icon.png", "author": "Mitchell Hayes", - "categories": ["System", "Productivity"], + "categories": [ + "System", + "Productivity" + ], "license": "MIT", "commands": [ { @@ -44,19 +47,19 @@ "smol-toml": "^1.3.1" }, "devDependencies": { - "@raycast/eslint-config": "^1.0.11", + "@raycast/eslint-config": "^2.2.0", "@types/node": "22.13.14", "@types/react": "19.0.12", "eslint": "^9.23.0", "prettier": "^3.5.3", - "typescript": "^5.8.2" + "typescript": "^5.9.3" }, "scripts": { "build": "ray build", "dev": "ray develop", "fix-lint": "ray lint --fix", "lint": "ray lint", - "prepublishOnly": "echo \"Error: no publish\" && exit 1", - "publish": "npx @raycast/api@latest publish" + "lint:ci": "eslint src/** && prettier --check src/** !**/.DS_Store", + "prepublishOnly": "echo \"Error: no publish\" && exit 1" } } diff --git a/raycast-extension/src/add-remap.tsx b/raycast-extension/src/add-remap.tsx index 576d721..c92106f 100644 --- a/raycast-extension/src/add-remap.tsx +++ b/raycast-extension/src/add-remap.tsx @@ -1,4 +1,11 @@ -import { Action, ActionPanel, Form, showToast, Toast, useNavigation } from "@raycast/api"; +import { + Action, + ActionPanel, + Form, + showToast, + Toast, + useNavigation, +} from "@raycast/api"; import { useState } from "react"; import { addRemap, @@ -49,11 +56,20 @@ function getDefaultValues(editItem?: RemapItem) { } case "tap_hold": { const r = raw as RawTapHold; - return { key: r.key, tap: r.tap, hold: r.hold, timeout_ms: String(r.timeout_ms ?? 200) }; + return { + key: r.key, + tap: r.tap, + hold: r.hold, + timeout_ms: String(r.timeout_ms ?? 200), + }; } case "chord": { const r = raw as RawChord; - return { keys: r.keys, emit: r.emit, window_ms: String(r.window_ms ?? 100) }; + return { + keys: r.keys, + emit: r.emit, + window_ms: String(r.window_ms ?? 100), + }; } } } @@ -62,7 +78,9 @@ export function AddRemapForm({ onAdd, editItem }: RemapFormProps) { const { pop } = useNavigation(); const isEditing = !!editItem; const defaults = getDefaultValues(editItem); - const [remapType, setRemapType] = useState(editItem?.type ?? "conditional_remap"); + const [remapType, setRemapType] = useState( + editItem?.type ?? "conditional_remap", + ); async function handleSubmit(values: Record) { try { @@ -77,7 +95,11 @@ export function AddRemapForm({ onAdd, editItem }: RemapFormProps) { data = { from: values.from, to: values.to }; break; case "conditional_remap": - data = { modifier: values.modifier, from: values.from, to: values.to }; + data = { + modifier: values.modifier, + from: values.from, + to: values.to, + }; break; case "tap_hold": data = { @@ -107,7 +129,11 @@ export function AddRemapForm({ onAdd, editItem }: RemapFormProps) { addModifierRemap(values.from as string, values.to as string); break; case "conditional_remap": - addConditionalRemap(values.modifier as string, values.from as string, values.to as string); + addConditionalRemap( + values.modifier as string, + values.from as string, + values.to as string, + ); break; case "tap_hold": addTapHold( @@ -129,7 +155,10 @@ export function AddRemapForm({ onAdd, editItem }: RemapFormProps) { restartService(); onAdd?.(); - await showToast({ style: Toast.Style.Success, title: isEditing ? "Remap updated" : "Remap added" }); + await showToast({ + style: Toast.Style.Success, + title: isEditing ? "Remap updated" : "Remap added", + }); pop(); } catch (e) { await showToast({ @@ -145,7 +174,10 @@ export function AddRemapForm({ onAdd, editItem }: RemapFormProps) { navigationTitle={isEditing ? "Edit Remap" : "Add Remap"} actions={ - + } > @@ -158,7 +190,11 @@ export function AddRemapForm({ onAdd, editItem }: RemapFormProps) { }} > {TYPE_OPTIONS.map((opt) => ( - + ))} @@ -170,12 +206,20 @@ export function AddRemapForm({ onAdd, editItem }: RemapFormProps) { {remapType === "remap" && ( <> - + {ALL_KEYS.map((k) => ( ))} - + {ALL_KEYS.map((k) => ( ))} @@ -185,12 +229,20 @@ export function AddRemapForm({ onAdd, editItem }: RemapFormProps) { {remapType === "modifier_remap" && ( <> - + {MODIFIER_KEYS.map((k) => ( ))} - + {MODIFIER_KEYS.map((k) => ( ))} @@ -200,17 +252,29 @@ export function AddRemapForm({ onAdd, editItem }: RemapFormProps) { {remapType === "conditional_remap" && ( <> - + {MODIFIER_NAMES.map((m) => ( ))} - + {ALL_KEYS.map((k) => ( ))} - + {ALL_KEYS.map((k) => ( ))} @@ -220,38 +284,66 @@ export function AddRemapForm({ onAdd, editItem }: RemapFormProps) { {remapType === "tap_hold" && ( <> - + {ALL_KEYS.map((k) => ( ))} - + {ALL_KEYS.map((k) => ( ))} - + {ALL_KEYS.map((k) => ( ))} - + )} {remapType === "chord" && ( <> - + {ALL_KEYS.map((k) => ( ))} - + {ALL_KEYS.map((k) => ( ))} - + )} diff --git a/raycast-extension/src/edit-config.ts b/raycast-extension/src/edit-config.ts index 6e91c25..b39f3b7 100644 --- a/raycast-extension/src/edit-config.ts +++ b/raycast-extension/src/edit-config.ts @@ -4,7 +4,10 @@ import { CONFIG_PATH } from "./lib/config"; export default async function Command() { try { await open(CONFIG_PATH); - await showToast({ style: Toast.Style.Success, title: "Opened config in editor" }); + await showToast({ + style: Toast.Style.Success, + title: "Opened config in editor", + }); } catch (e) { await showToast({ style: Toast.Style.Failure, diff --git a/raycast-extension/src/lib/config.ts b/raycast-extension/src/lib/config.ts index 9ad3d2d..05833cc 100644 --- a/raycast-extension/src/lib/config.ts +++ b/raycast-extension/src/lib/config.ts @@ -3,9 +3,20 @@ import { homedir } from "os"; import { join } from "path"; import * as TOML from "smol-toml"; -export const CONFIG_PATH = join(homedir(), ".config", "switcheroo", "config.toml"); -export const LOG_PATH = "/tmp/switcheroo.err"; -export const PLIST_NAME = "com.local.switcheroo"; +export const CONFIG_PATH = join( + homedir(), + ".config", + "switcheroo", + "config.toml", +); +export const LOG_PATH = join( + homedir(), + "Library", + "Logs", + "com.mitchelljphayes.switcheroo", + "daemon.err", +); +export const PLIST_NAME = "com.mitchelljphayes.switcheroo"; // --- Raw TOML types (what's in the file) --- @@ -48,17 +59,30 @@ export interface RawConfig { // --- Parsed config with IDs for list management --- -export type RemapType = "modifier_remap" | "remap" | "conditional_remap" | "tap_hold" | "chord"; +export type RemapType = + | "modifier_remap" + | "remap" + | "conditional_remap" + | "tap_hold" + | "chord"; export interface RemapItem { id: string; type: RemapType; title: string; subtitle: string; - raw: RawModifierRemap | RawRemap | RawConditionalRemap | RawTapHold | RawChord; + raw: + | RawModifierRemap + | RawRemap + | RawConditionalRemap + | RawTapHold + | RawChord; } -function formatModifierRemap(r: RawModifierRemap): { title: string; subtitle: string } { +function formatModifierRemap(r: RawModifierRemap): { + title: string; + subtitle: string; +} { return { title: `${r.from} → ${r.to}`, subtitle: "Modifier Remap", @@ -72,7 +96,10 @@ function formatRemap(r: RawRemap): { title: string; subtitle: string } { }; } -function formatConditionalRemap(r: RawConditionalRemap): { title: string; subtitle: string } { +function formatConditionalRemap(r: RawConditionalRemap): { + title: string; + subtitle: string; +} { return { title: `${r.modifier} + ${r.from} → ${r.to}`, subtitle: "Conditional Remap", @@ -103,7 +130,9 @@ export function writeConfig(config: RawConfig): void { const lines: string[] = ["# switcheroo configuration\n"]; if (config.modifier_remap && config.modifier_remap.length > 0) { - lines.push("# Kernel-level modifier remaps (applied via hidutil on startup)"); + lines.push( + "# Kernel-level modifier remaps (applied via hidutil on startup)", + ); for (const r of config.modifier_remap) { lines.push("[[modifier_remap]]"); lines.push(`from = "${r.from}"`); @@ -165,7 +194,12 @@ export function getRemapItems(): RemapItem[] { (config.modifier_remap ?? []).forEach((r, i) => { const fmt = formatModifierRemap(r); - items.push({ id: `modifier_remap:${i}`, type: "modifier_remap", ...fmt, raw: r }); + items.push({ + id: `modifier_remap:${i}`, + type: "modifier_remap", + ...fmt, + raw: r, + }); }); (config.remap ?? []).forEach((r, i) => { @@ -180,7 +214,12 @@ export function getRemapItems(): RemapItem[] { (config.conditional_remap ?? []).forEach((r, i) => { const fmt = formatConditionalRemap(r); - items.push({ id: `conditional_remap:${i}`, type: "conditional_remap", ...fmt, raw: r }); + items.push({ + id: `conditional_remap:${i}`, + type: "conditional_remap", + ...fmt, + raw: r, + }); }); (config.chord ?? []).forEach((r, i) => { @@ -219,21 +258,34 @@ export function addModifierRemap(from: string, to: string): void { writeConfig(config); } -export function addConditionalRemap(modifier: string, from: string, to: string): void { +export function addConditionalRemap( + modifier: string, + from: string, + to: string, +): void { const config = readConfig(); if (!config.conditional_remap) config.conditional_remap = []; config.conditional_remap.push({ modifier, from, to }); writeConfig(config); } -export function addTapHold(key: string, tap: string, hold: string, timeout_ms: number): void { +export function addTapHold( + key: string, + tap: string, + hold: string, + timeout_ms: number, +): void { const config = readConfig(); if (!config.tap_hold) config.tap_hold = []; config.tap_hold.push({ key, tap, hold, timeout_ms }); writeConfig(config); } -export function addChord(keys: string[], emit: string, window_ms: number): void { +export function addChord( + keys: string[], + emit: string, + window_ms: number, +): void { const config = readConfig(); if (!config.chord) config.chord = []; config.chord.push({ keys, emit, window_ms }); diff --git a/raycast-extension/src/lib/keys.ts b/raycast-extension/src/lib/keys.ts index bb41336..5ed2720 100644 --- a/raycast-extension/src/lib/keys.ts +++ b/raycast-extension/src/lib/keys.ts @@ -13,8 +13,32 @@ export const MODIFIER_KEYS = [ ] as const; export const LETTER_KEYS = [ - "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", - "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", ] as const; export const ARROW_KEYS = [ @@ -34,8 +58,18 @@ export const SPECIAL_KEYS = [ ] as const; export const FUNCTION_KEYS = [ - "f1", "f2", "f3", "f4", "f5", "f6", - "f7", "f8", "f9", "f10", "f11", "f12", + "f1", + "f2", + "f3", + "f4", + "f5", + "f6", + "f7", + "f8", + "f9", + "f10", + "f11", + "f12", ] as const; export const ALL_KEYS = [ diff --git a/raycast-extension/src/lib/service-pure.mjs b/raycast-extension/src/lib/service-pure.mjs new file mode 100644 index 0000000..57e8733 --- /dev/null +++ b/raycast-extension/src/lib/service-pure.mjs @@ -0,0 +1,32 @@ +// ───────────────────────────────────────────────────────────────────── +// service-pure.mjs — pure functions for Switcheroo service management. +// +// Zero runtime dependencies. Safe to unit test with node:test. +// Security: Homebrew executable paths are validated by EXACT match +// against two official paths. No prefix/suffix matching, no traversal. +// ───────────────────────────────────────────────────────────────────── + +// Exact allowlisted Homebrew executable paths. +const HOMEBREW_EXACT_EXECS = [ + "/opt/homebrew/opt/switcheroo/Switcheroo.app/Contents/MacOS/switcheroo", + "/usr/local/opt/switcheroo/Switcheroo.app/Contents/MacOS/switcheroo", +]; + +/** Parse the `program =` line from `launchctl print` output. + * Pure function — safe to unit test. */ +export function parseLaunchctlProgram(output) { + for (const line of output.split("\n")) { + const m = line.match(/^\s*program\s*=\s*(.*)$/); + if (m) return m[1]; + } + return ""; +} + +/** Validate that a path is an EXACT Homebrew Switcheroo executable. + * No prefix/suffix matching, no path traversal. */ +export function isValidHomebrewExec(path) { + if (!path) return false; + return HOMEBREW_EXACT_EXECS.includes(path); +} + +export { HOMEBREW_EXACT_EXECS }; diff --git a/raycast-extension/src/lib/service.test.mjs b/raycast-extension/src/lib/service.test.mjs new file mode 100644 index 0000000..38da17d --- /dev/null +++ b/raycast-extension/src/lib/service.test.mjs @@ -0,0 +1,185 @@ +// ───────────────────────────────────────────────────────────────────── +// service.test.mjs — pure unit tests for Switcheroo service helpers. +// Uses node:test (built-in, zero dependencies). +// Run: node --test src/lib/service.test.mjs +// ───────────────────────────────────────────────────────────────────── +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { + parseLaunchctlProgram, + isValidHomebrewExec, + HOMEBREW_EXACT_EXECS, +} from "./service-pure.mjs"; + +describe("parseLaunchctlProgram", () => { + test("extracts program path from standard output", () => { + const output = ` +{ + program = /Users/test/.local/bin/Switcheroo.app/Contents/MacOS/switcheroo +}`; + assert.equal( + parseLaunchctlProgram(output), + "/Users/test/.local/bin/Switcheroo.app/Contents/MacOS/switcheroo", + ); + }); + + test("handles leading whitespace", () => { + assert.equal( + parseLaunchctlProgram( + " program = /opt/homebrew/opt/switcheroo/Switcheroo.app/Contents/MacOS/switcheroo", + ), + "/opt/homebrew/opt/switcheroo/Switcheroo.app/Contents/MacOS/switcheroo", + ); + }); + + test("returns empty string when no program line", () => { + assert.equal(parseLaunchctlProgram("state = running"), ""); + }); + + test("returns empty string for empty input", () => { + assert.equal(parseLaunchctlProgram(""), ""); + }); + + test("handles tab indentation", () => { + assert.equal( + parseLaunchctlProgram("\t\tprogram = /usr/local/bin/test"), + "/usr/local/bin/test", + ); + }); + + test("returns first match for multiple lines", () => { + assert.equal( + parseLaunchctlProgram("program = /first\nprogram = /second"), + "/first", + ); + }); + + test("handles paths with spaces", () => { + assert.equal( + parseLaunchctlProgram( + "program = /Users/My User/Switcheroo.app/Contents/MacOS/switcheroo", + ), + "/Users/My User/Switcheroo.app/Contents/MacOS/switcheroo", + ); + }); + + test("does not match without = sign", () => { + assert.equal( + parseLaunchctlProgram("program /usr/bin/foo\nprogram = /usr/bin/bar"), + "/usr/bin/bar", + ); + }); +}); + +describe("isValidHomebrewExec — exact path matching", () => { + test("accepts /opt/homebrew exact path", () => { + assert.equal(isValidHomebrewExec(HOMEBREW_EXACT_EXECS[0]), true); + }); + + test("accepts /usr/local exact path", () => { + assert.equal(isValidHomebrewExec(HOMEBREW_EXACT_EXECS[1]), true); + }); + + // Adversarial tests + test("rejects path traversal via ..", () => { + assert.equal( + isValidHomebrewExec( + "/opt/homebrew/anything/../foreign/Switcheroo.app/Contents/MacOS/switcheroo", + ), + false, + ); + }); + + test("rejects Cellar path (not opt/)", () => { + assert.equal( + isValidHomebrewExec( + "/opt/homebrew/Cellar/switcheroo/0.1.0/Switcheroo.app/Contents/MacOS/switcheroo", + ), + false, + ); + }); + + test("rejects /opt/homebrewfoo (prefix without separator)", () => { + assert.equal( + isValidHomebrewExec( + "/opt/homebrewfoo/opt/switcheroo/Switcheroo.app/Contents/MacOS/switcheroo", + ), + false, + ); + }); + + test("rejects standalone ~/.local/bin path", () => { + assert.equal( + isValidHomebrewExec( + "/Users/test/.local/bin/Switcheroo.app/Contents/MacOS/switcheroo", + ), + false, + ); + }); + + test("rejects empty/null/undefined", () => { + assert.equal(isValidHomebrewExec(""), false); + assert.equal(isValidHomebrewExec(null), false); + assert.equal(isValidHomebrewExec(undefined), false); + }); + + test("rejects wrong app name", () => { + assert.equal( + isValidHomebrewExec( + "/opt/homebrew/opt/otherapp/OtherApp.app/Contents/MacOS/otherapp", + ), + false, + ); + }); + + test("rejects trailing slash", () => { + assert.equal( + isValidHomebrewExec( + "/opt/homebrew/opt/switcheroo/Switcheroo.app/Contents/MacOS/switcheroo/", + ), + false, + ); + }); + + test("rejects extra components after switcheroo", () => { + assert.equal( + isValidHomebrewExec( + "/opt/homebrew/opt/switcheroo/Switcheroo.app/Contents/MacOS/switcheroo/extra", + ), + false, + ); + }); + + test("rejects encoded characters", () => { + assert.equal( + isValidHomebrewExec( + "/opt/homebrew/opt/switcheroo/Switcheroo.app/Contents/MacOS/switcheroo%00", + ), + false, + ); + }); + + test("rejects newlines (injection)", () => { + assert.equal( + isValidHomebrewExec( + "/opt/homebrew/opt/switcheroo/Switcheroo.app/Contents/MacOS/switcheroo\nevil", + ), + false, + ); + }); + + test("rejects case-variant", () => { + assert.equal( + isValidHomebrewExec( + "/opt/Homebrew/opt/switcheroo/Switcheroo.app/Contents/MacOS/switcheroo", + ), + false, + ); + }); + + test("rejects arbitrary paths", () => { + assert.equal(isValidHomebrewExec("/tmp/foo"), false); + assert.equal(isValidHomebrewExec("/etc/passwd"), false); + assert.equal(isValidHomebrewExec("relative/path"), false); + }); +}); diff --git a/raycast-extension/src/lib/service.ts b/raycast-extension/src/lib/service.ts index ce239ae..44c9d1d 100644 --- a/raycast-extension/src/lib/service.ts +++ b/raycast-extension/src/lib/service.ts @@ -1,36 +1,263 @@ -import { execSync } from "child_process"; -import { homedir } from "os"; +import { execFileSync } from "child_process"; +import { existsSync, lstatSync } from "fs"; import { join } from "path"; import { PLIST_NAME } from "./config"; +import { parseLaunchctlProgram, isValidHomebrewExec } from "./service-pure.mjs"; -const PLIST_PATH = join(homedir(), "Library", "LaunchAgents", `${PLIST_NAME}.plist`); +export { parseLaunchctlProgram, isValidHomebrewExec }; + +// ── Absolute tool paths ─────────────────────────────────────────────── +const LAUNCHCTL = "/bin/launchctl"; +const ID = "/usr/bin/id"; +const PLUTIL = "/usr/bin/plutil"; + +const STANDALONE_LABEL = PLIST_NAME; +const STANDALONE_PLIST_PATH = join( + // Use homedir() only for path construction, never for execution + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("os").homedir(), + "Library", + "LaunchAgents", + `${PLIST_NAME}.plist`, +); +const STANDALONE_EXEC_SUFFIX = + ".local/bin/Switcheroo.app/Contents/MacOS/switcheroo"; +const STANDALONE_EXPECTED_EXEC = join( + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("os").homedir(), + STANDALONE_EXEC_SUFFIX, +); + +const HOMEBREW_LABEL = "homebrew.mxcl.switcheroo"; + +export type ServiceLayout = "standalone" | "homebrew"; +export interface ServiceInfo { + layout: ServiceLayout; + label: string; + executable: string; +} function getUid(): string { - return execSync("id -u", { encoding: "utf-8" }).trim(); + return execFileSync(ID, ["-u"], { encoding: "utf-8" }).trim(); } -export function restartService(): void { - const uid = getUid(); - // Stop (ignore errors if not running) +function getLaunchctlPrint(uid: string, label: string): string | null { try { - execSync(`launchctl bootout gui/${uid}/${PLIST_NAME}`, { encoding: "utf-8" }); + return execFileSync(LAUNCHCTL, ["print", `gui/${uid}/${label}`], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); } catch { - // Service may not be running + return null; } - // Start - execSync(`launchctl bootstrap gui/${uid} "${PLIST_PATH}"`, { encoding: "utf-8" }); } -export function isServiceRunning(): boolean { +/** Resolve the one expected Homebrew executable by probing brew. + * Returns the exact path only if it matches one of the allowlisted + * official paths AND the file exists as a regular non-symlink file. */ +function getHomebrewExpectedExec(): string | null { + for (const prefix of ["/opt/homebrew", "/usr/local"]) { + const brewBin = join(prefix, "bin", "brew"); + if (!existsSync(brewBin)) continue; + try { + const brewPrefix = execFileSync(brewBin, ["--prefix", "switcheroo"], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 5000, + }).trim(); + const candidate = join( + brewPrefix, + "Switcheroo.app/Contents/MacOS/switcheroo", + ); + // Must be an exact allowlisted path + if (!isValidHomebrewExec(candidate)) continue; + // Must exist as a regular file (not symlink) + if (!existsSync(candidate)) continue; + try { + const lst = lstatSync(candidate); + if (!lst.isFile()) continue; + } catch { + continue; + } + return candidate; + } catch { + continue; + } + } + return null; +} + +/** Get the loaded job's program for a label. Returns null if not loaded. */ +function getLoadedProgram(uid: string, label: string): string | null { + const output = getLaunchctlPrint(uid, label); + if (output === null) return null; + return parseLaunchctlProgram(output); +} + +/** Detect which service layout is active. Binds one install to one exact + * executable. If both are loaded, throws. If neither, returns null. + * For Homebrew, the loaded job program MUST exactly equal the executable + * returned by getHomebrewExpectedExec (no dual-prefix mismatch). */ +export function detectLayout(): ServiceInfo | null { const uid = getUid(); + + const standaloneProg = getLoadedProgram(uid, STANDALONE_LABEL); + const homebrewProg = getLoadedProgram(uid, HOMEBREW_LABEL); + + const standaloneActive = + standaloneProg !== null && standaloneProg === STANDALONE_EXPECTED_EXEC; + const homebrewProgValid = isValidHomebrewExec(homebrewProg ?? ""); + + // For Homebrew: the loaded program must match the detected installation + const homebrewExpected = getHomebrewExpectedExec(); + const homebrewActive = + homebrewProgValid && + homebrewExpected !== null && + homebrewProg === homebrewExpected; + + if (standaloneActive && homebrewActive) { + throw new Error( + "Both standalone and Homebrew Switcheroo services are loaded — unsupported. Stop one before restarting.", + ); + } + + if (standaloneActive) { + return { + layout: "standalone", + label: STANDALONE_LABEL, + executable: STANDALONE_EXPECTED_EXEC, + }; + } + + if (homebrewActive) { + return { + layout: "homebrew", + label: HOMEBREW_LABEL, + executable: homebrewExpected!, + }; + } + + return null; +} + +/** Validate the standalone plist: lstat (not stat), regular file, owner, + * exact Label, exact ProgramArguments[0]. */ +function plistIsSwitcherooStandalone(): boolean { + if (!existsSync(STANDALONE_PLIST_PATH)) return false; + let lst; + try { + lst = lstatSync(STANDALONE_PLIST_PATH); + } catch { + return false; + } + if (!lst.isFile()) return false; + if (lst.uid !== Number(getUid())) return false; + + try { + const label = execFileSync( + PLUTIL, + ["-extract", "Label", "raw", "-o", "-", STANDALONE_PLIST_PATH], + { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }, + ).trim(); + if (label !== STANDALONE_LABEL) return false; + } catch { + return false; + } + try { - const output = execSync(`launchctl print gui/${uid}/${PLIST_NAME}`, { + const prog = execFileSync( + PLUTIL, + [ + "-extract", + "ProgramArguments.0", + "raw", + "-o", + "-", + STANDALONE_PLIST_PATH, + ], + { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }, + ).trim(); + return prog === STANDALONE_EXPECTED_EXEC; + } catch { + return false; + } +} + +export function restartService(): void { + const uid = getUid(); + const info = detectLayout(); + + if (info === null) { + if (!plistIsSwitcherooStandalone()) { + const homebrewExec = getHomebrewExpectedExec(); + if (homebrewExec) { + throw new Error( + "Switcheroo is not running. Start it with: brew services start switcheroo", + ); + } + throw new Error( + "Switcheroo is not installed or not running. Install via `brew install switcheroo` or run ./install.sh.", + ); + } + execFileSync( + LAUNCHCTL, + ["bootstrap", `gui/${uid}`, STANDALONE_PLIST_PATH], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }, + ); + return; + } + + if (info.layout === "standalone") { + if (!plistIsSwitcherooStandalone()) { + throw new Error( + `Refusing to restart ${STANDALONE_LABEL}: plist validation failed`, + ); + } + execFileSync(LAUNCHCTL, ["bootout", `gui/${uid}/${STANDALONE_LABEL}`], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }); - // Check for "state = running" or active PID - return output.includes("state = running") || output.includes("pid = "); - } catch { - return false; + if (!plistIsSwitcherooStandalone()) { + throw new Error( + `Refusing to re-bootstrap ${STANDALONE_LABEL}: plist changed after bootout (TOCTOU)`, + ); + } + execFileSync( + LAUNCHCTL, + ["bootstrap", `gui/${uid}`, STANDALONE_PLIST_PATH], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }, + ); + } else if (info.layout === "homebrew") { + // Re-verify the loaded job program equals the expected executable + // immediately before kickstart (TOCTOU protection) + const currentProg = getLoadedProgram(uid, HOMEBREW_LABEL); + if (currentProg !== info.executable) { + throw new Error( + `Refusing to kickstart ${HOMEBREW_LABEL}: loaded program changed (TOCTOU)`, + ); + } + execFileSync( + LAUNCHCTL, + ["kickstart", "-k", `gui/${uid}/${HOMEBREW_LABEL}`], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }, + ); } } + +export function isServiceRunning(): boolean { + const info = detectLayout(); + if (info === null) return false; + const uid = getUid(); + const output = getLaunchctlPrint(uid, info.label); + if (output === null) return false; + return output.includes("state = running") || output.includes("pid = "); +} diff --git a/raycast-extension/src/restart.ts b/raycast-extension/src/restart.ts index a739808..3adc752 100644 --- a/raycast-extension/src/restart.ts +++ b/raycast-extension/src/restart.ts @@ -4,7 +4,10 @@ import { restartService } from "./lib/service"; export default async function Command() { try { restartService(); - await showToast({ style: Toast.Style.Success, title: "Switcheroo restarted" }); + await showToast({ + style: Toast.Style.Success, + title: "Switcheroo restarted", + }); } catch (e) { await showToast({ style: Toast.Style.Failure, diff --git a/raycast-extension/src/view-logs.tsx b/raycast-extension/src/view-logs.tsx index 4ef7937..ba50177 100644 --- a/raycast-extension/src/view-logs.tsx +++ b/raycast-extension/src/view-logs.tsx @@ -1,4 +1,12 @@ -import { Action, ActionPanel, Detail, Icon, showToast, Toast } from "@raycast/api"; +import { + Action, + ActionPanel, + Detail, + Icon, + showToast, + Toast, + Keyboard, +} from "@raycast/api"; import { useState, useEffect } from "react"; import { readFileSync } from "fs"; import { LOG_PATH } from "./lib/config"; @@ -35,20 +43,27 @@ export default function ViewLogs() { { try { restartService(); - await showToast({ style: Toast.Style.Success, title: "Switcheroo restarted" }); + await showToast({ + style: Toast.Style.Success, + title: "Switcheroo restarted", + }); setTimeout(reload, 2000); } catch (e) { - await showToast({ style: Toast.Style.Failure, title: "Failed to restart", message: String(e) }); + await showToast({ + style: Toast.Style.Failure, + title: "Failed to restart", + message: String(e), + }); } }} /> diff --git a/raycast-extension/src/view-remaps.tsx b/raycast-extension/src/view-remaps.tsx index b60bbe1..32c701b 100644 --- a/raycast-extension/src/view-remaps.tsx +++ b/raycast-extension/src/view-remaps.tsx @@ -8,6 +8,7 @@ import { List, showToast, Toast, + Keyboard, } from "@raycast/api"; import { useState, useCallback } from "react"; import { getRemapItems, deleteRemap, RemapItem, RemapType } from "./lib/config"; @@ -43,7 +44,11 @@ export default function ViewRemaps() { try { setItems(getRemapItems()); } catch (e) { - showToast({ style: Toast.Style.Failure, title: "Failed to read config", message: String(e) }); + showToast({ + style: Toast.Style.Failure, + title: "Failed to read config", + message: String(e), + }); } }, []); @@ -59,18 +64,33 @@ export default function ViewRemaps() { deleteRemap(item.id); restartService(); reload(); - await showToast({ style: Toast.Style.Success, title: "Deleted", message: item.title }); + await showToast({ + style: Toast.Style.Success, + title: "Deleted", + message: item.title, + }); } catch (e) { - await showToast({ style: Toast.Style.Failure, title: "Failed to delete", message: String(e) }); + await showToast({ + style: Toast.Style.Failure, + title: "Failed to delete", + message: String(e), + }); } } async function handleRestart() { try { restartService(); - await showToast({ style: Toast.Style.Success, title: "Switcheroo restarted" }); + await showToast({ + style: Toast.Style.Success, + title: "Switcheroo restarted", + }); } catch (e) { - await showToast({ style: Toast.Style.Failure, title: "Failed to restart", message: String(e) }); + await showToast({ + style: Toast.Style.Failure, + title: "Failed to restart", + message: String(e), + }); } } @@ -82,7 +102,13 @@ export default function ViewRemaps() { grouped.set(item.type, list); } - const sectionOrder: RemapType[] = ["modifier_remap", "remap", "tap_hold", "conditional_remap", "chord"]; + const sectionOrder: RemapType[] = [ + "modifier_remap", + "remap", + "tap_hold", + "conditional_remap", + "chord", + ]; return ( @@ -93,7 +119,10 @@ export default function ViewRemaps() { {(grouped.get(type) ?? []).map((item) => ( } /> diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..d8004c4 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,7 @@ +[toolchain] +# Pin Rust to an exact reviewed version for reproducible builds. +# CI and local builds use this version via rustup automatic resolution. +# The dtolnay/rust-toolchain action respects this file when present. +# Update this version deliberately; verify cargo test + clippy pass first. +channel = "1.95.0" +targets = ["aarch64-apple-darwin", "x86_64-apple-darwin"] \ No newline at end of file diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100644 index 0000000..3c3f92b --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,382 @@ +# shellcheck shell=bash +# Shared helpers for Switcheroo install/uninstall scripts. +# +# Sourced by install.sh, install-binary.sh, uninstall.sh. Provides hardened, +# shell-testable helpers for: safe directory/path validation, cargo discovery, +# launchctl bootout classification, job identity validation, hidutil +# snapshot/restore around the legacy destructive daemon, transactional +# app/plist swap with rollback, and plist rendering. +# +# shellcheck disable=SC2034 +# +# Security review: +# - All system tools invoked by absolute path with a fixed minimal PATH. +# - stat uses numeric permission bits (%Lp), rejecting group/world-writable. +# - sed is /usr/bin/sed (macOS has no /bin/sed). +# - cargo is discovered safely (validated fixed candidates or --cargo flag). +# - HOME is resolved from trusted OS account metadata (dscl), not $HOME. +# - launchctl print parser reads the real `program =` line. Loaded jobs +# require live identity; plist fallback never authorizes bootout. +# - Migration uses the NEW helper binary (staged or packaged), never the +# old installed binary which doesn't support the new one-shot flags. +# - Pre-existing app/plist state is captured BEFORE the rollback trap so +# early failure never deletes a pre-existing installation. + +# Fixed minimal PATH for system tools. Exported by the caller before sourcing. +SW_BIN_LAUNCHCTL=/bin/launchctl +SW_BIN_MKDIR=/bin/mkdir +SW_BIN_CP=/bin/cp +SW_BIN_MV=/bin/mv +SW_BIN_RM=/bin/rm +SW_BIN_CHMOD=/bin/chmod +SW_BIN_SLEEP=/bin/sleep +SW_BIN_CAT=/bin/cat +SW_USR_STAT=/usr/bin/stat +SW_USR_GREP=/usr/bin/grep +SW_USR_PLUTIL=/usr/bin/plutil +SW_USR_SED=/usr/bin/sed +SW_USR_CODESIGN=/usr/bin/codesign +SW_USR_SECURITY=/usr/bin/security +SW_USR_ICONUTIL=/usr/bin/iconutil +SW_USR_MKTEMP=/usr/bin/mktemp +SW_USR_ID=/usr/bin/id +SW_USR_HIDUTIL=/usr/bin/hidutil +SW_USR_TAIL=/usr/bin/tail +SW_USR_HEAD=/usr/bin/head +SW_USR_DEFAULTS=/usr/bin/defaults +SW_USR_FILE=/usr/bin/file +SW_USR_LIPO=/usr/bin/lipo +SW_USR_DSCL=/usr/bin/dscl + +sw_err() { printf 'Error: %s\n' "$*" >&2; exit 1; } + +sw_uid() { "${SW_USR_ID}" -u; } + +sw_real_home() { + local home + home="$("${SW_USR_DSCL}" . -read "/Users/$(/usr/bin/id -un)" NFSHomeDirectory 2>/dev/null | "${SW_USR_SED}" 's/^NFSHomeDirectory: //')" + if [ -n "$home" ] && [ -d "$home" ]; then + printf '%s\n' "$home" + return 0 + fi + sw_err "could not resolve account home from DirectoryService — refusing to use \$HOME" +} + +sw_is_symlink() { [ -L "$1" ]; } + +sw_mode_numeric() { "${SW_USR_STAT}" -f '%Lp' "$1" 2>/dev/null; } + +sw_owner_of() { "${SW_USR_STAT}" -f '%u' "$1" 2>/dev/null; } + +sw_reject_group_world_writable() { + local path="$1" mode perm + mode="$(sw_mode_numeric "$path")" || sw_err "$path: stat mode failed" + perm="${mode#"${mode%???}"}" + if [ $(( 8#$perm & 0022 )) -ne 0 ]; then + sw_err "$path is group/world-writable (mode $perm) — refusing" + fi +} + +sw_assert_safe_dir() { + local path="$1" owner + [ -n "$path" ] || sw_err "sw_assert_safe_dir: empty path" + if sw_is_symlink "$path"; then sw_err "$path is a symlink — refusing (possible attack)"; fi + [ -d "$path" ] || sw_err "$path is not a directory (or does not exist)" + owner="$(sw_owner_of "$path")" || sw_err "$path: stat failed" + [ "$owner" = "$(sw_uid)" ] || sw_err "$path owned by uid $owner (current $(sw_uid)) — refusing" + sw_reject_group_world_writable "$path" +} + +sw_ensure_safe_path() { + local path="$1" comp accum="" home_prefix + [ -n "$path" ] || sw_err "sw_ensure_safe_path: empty path" + case "$path" in /*) ;; *) sw_err "sw_ensure_safe_path: $path is not absolute";; esac + home_prefix="$(sw_real_home)" + IFS='/' read -ra parts <<< "$path" + for comp in "${parts[@]}"; do + [ -n "$comp" ] || continue + if [ -z "$accum" ]; then accum="/${comp}"; else accum="${accum}/${comp}"; fi + if [ ! -e "$accum" ]; then + "${SW_BIN_MKDIR}" "$accum" || sw_err "mkdir $accum failed" + "${SW_BIN_CHMOD}" 700 "$accum" || sw_err "chmod $accum failed" + fi + if sw_is_symlink "$accum"; then sw_err "$accum is a symlink — refusing (possible attack)"; fi + [ -d "$accum" ] || sw_err "$accum is not a directory" + sw_reject_group_world_writable "$accum" + case "$accum" in + "${home_prefix}"|"${home_prefix}"/*) + local owner + owner="$(sw_owner_of "$accum")" || sw_err "$accum: stat owner failed" + [ "$owner" = "$(sw_uid)" ] || sw_err "$accum owned by uid $owner (current $(sw_uid)) — refusing" + ;; + esac + done +} + +sw_reject_symlink() { + local path="$1" + if [ -L "$path" ]; then sw_err "$path is a symlink — refusing to overwrite (possible attack)"; fi +} + +# Discover the cargo binary for source builds. Ignores $CARGO and PATH. +# Checks: --cargo flag (SW_CARGO_BIN from install.sh arg parsing), then fixed +# candidates. Validates ownership and runs `cargo --version` to confirm. +sw_find_cargo() { + local c real_owner version + if [ -n "${SW_CARGO_BIN:-}" ] && [ -x "$SW_CARGO_BIN" ]; then + c="$SW_CARGO_BIN" + else + for c in "$(sw_real_home)/.cargo/bin/cargo" /opt/homebrew/bin/cargo /usr/local/bin/cargo; do + [ -x "$c" ] && break + c="" + done + [ -n "$c" ] || sw_err "cargo not found. Pass --cargo /absolute/path to install.sh." + fi + real_owner="$("${SW_USR_STAT}" -L -f '%u' "$c" 2>/dev/null || printf 'err')" + [ "$real_owner" = "$(sw_uid)" ] \ + || sw_err "cargo at $c is owned by uid $real_owner (current $(sw_uid)) — refusing to execute" + version="$("$c" --version 2>/dev/null)" || sw_err "cargo at $c failed --version check" + printf '%s' "$version" | "${SW_USR_GREP}" -q '^cargo ' \ + || sw_err "cargo at $c does not report as cargo (got: '$version')" + printf '%s\n' "$c" +} + +sw_bootout_safe() { + local label="$1" rc stderr_out + stderr_out="$("${SW_BIN_LAUNCHCTL}" bootout "gui/$(sw_uid)/${label}" 2>&1 1>/dev/null)" && rc=0 || rc=$? + if [ "$rc" -eq 0 ]; then return 0; fi + if printf '%s' "$stderr_out" | "${SW_USR_GREP}" -Eqi 'Could not find|No such process|Input/output error'; then + return 0 + fi + sw_err "launchctl bootout $label failed (rc=$rc): $stderr_out" +} + +sw_plist_program_arg0() { + local plist="$1" + [ -f "$plist" ] || { printf ''; return; } + "${SW_USR_PLUTIL}" -extract 'ProgramArguments.0' raw -o - "$plist" 2>/dev/null || printf '' +} + +SW_EXPECTED_EXEC_SUFFIX=".local/bin/Switcheroo.app/Contents/MacOS/switcheroo" + +sw_expected_exec() { + printf '%s/%s\n' "$(sw_real_home)" "${SW_EXPECTED_EXEC_SUFFIX}" +} + +sw_plist_is_switcheroo() { + local plist="$1" prog expected + prog="$(sw_plist_program_arg0 "$plist")" + expected="$(sw_expected_exec)" + [ "$prog" = "$expected" ] +} + +sw_parse_launchctl_program() { + local out="$1" + printf '%s\n' "$out" | "${SW_USR_SED}" -n 's/^[[:space:]]*program = \(.*\)/\1/p' | "${SW_USR_HEAD}" -1 +} + +sw_loaded_job_is_switcheroo() { + local label="$1" out prog expected + if ! out="$("${SW_BIN_LAUNCHCTL}" print "gui/$(sw_uid)/${label}" 2>/dev/null)"; then + return 1 + fi + prog="$(sw_parse_launchctl_program "$out")" + expected="$(sw_expected_exec)" + [ -n "$prog" ] && [ "$prog" = "$expected" ] +} + +sw_label_is_loaded() { + local label="$1" + "${SW_BIN_LAUNCHCTL}" print "gui/$(sw_uid)/${label}" >/dev/null 2>&1 +} + +# ── hidutil snapshot / restore ─────────────────────────────────────── +# The legacy com.local.switcheroo daemon does blanket UserKeyMapping:[] +# cleanup on shutdown. Before stopping it, we use the NEW helper binary +# (which supports --snapshot-legacy-foreign-hidutil) to snapshot current +# mappings, filter out the legacy daemon's configured-owned pairs, and +# restore only foreign mappings after bootout. The NEW binary is the +# staged/packaged binary — NEVER the old installed one, which doesn't +# support the flag. Fail-closed on any error. + +# Capability probe for a KNOWN-CURRENT helper binary. Does NOT use `timeout` +# (not on stock macOS). Only called on known-current candidates: +# - script-adjacent Switcheroo.app/Contents/MacOS/switcheroo (release archive) +# - source-tree target/release/switcheroo +# Validates: path exists, owned by current uid, is a regular file (not +# symlink/dir), then runs `--capabilities` directly (the new binary exits +# in <50ms without event tap; old binaries treat it as a config path and +# fail on file-not-found). Checks output for required capabilities. +# $1 = binary path. Returns 0 if the binary supports both +# --snapshot-legacy-foreign-hidutil and --reconcile-hidutil-state. +sw_helper_has_capabilities() { + local binary="$1" caps owner + [ -n "$binary" ] || return 1 + [ -f "$binary" ] || return 1 + [ ! -L "$binary" ] || return 1 + owner="$("${SW_USR_STAT}" -L -f '%u' "$binary" 2>/dev/null || printf 'err')" + [ "$owner" = "$(sw_uid)" ] || return 1 + # Direct --capabilities: new binary exits 0 immediately. Old binary + # treats it as a config path, fails to load, exits non-zero. + caps="$(env -i HOME="$(sw_real_home)" PATH=/usr/bin:/bin "$binary" --capabilities 2>/dev/null)" || return 1 + printf '%s' "$caps" | "${SW_USR_GREP}" -q -- '--snapshot-legacy-foreign-hidutil' || return 1 + printf '%s' "$caps" | "${SW_USR_GREP}" -q -- '--reconcile-hidutil-state' || return 1 + return 0 +} + +# Snapshot foreign hidutil mappings using the NEW helper binary. +# $1 = helper binary path (must pass sw_helper_has_capabilities) +# $2 = path to the config file. Prints foreign-only JSON to stdout. +sw_snapshot_legacy_foreign() { + local binary="$1" config="$2" + if ! sw_helper_has_capabilities "$binary"; then + sw_err "helper binary at $binary does not support --snapshot-legacy-foreign-hidutil. Upgrade to the current version first, or run the installer/uninstaller from a current release archive." + fi + env -i HOME="$(sw_real_home)" PATH=/usr/bin:/bin \ + "$binary" --snapshot-legacy-foreign-hidutil "$config" \ + || sw_err "snapshot-legacy-foreign-hidutil failed" +} + +sw_hidutil_restore_json() { + local json="$1" + env -i PATH=/usr/bin:/bin "${SW_USR_HIDUTIL}" property --set "{\"UserKeyMapping\":${json}}" >/dev/null 2>&1 \ + || sw_err "hidutil --set failed during restore" +} + +# ── transactional app/plist swap with rollback ──────────────────────── +# +# CRITICAL: pre-existing app/plist state is captured BEFORE the rollback trap +# is installed. This ensures early failure (before backup) never deletes a +# pre-existing installation. Transaction phases track which objects the +# transaction created/replaced, so rollback only removes/replaces what this +# transaction actually changed. + +_SW_APP_DIR="" +_SW_APP_HAD_PRIOR="no" +_SW_APP_BACKUP="" +_SW_PLIST_DST="" +_SW_PLIST_HAD_PRIOR="no" +_SW_PLIST_BACKUP="" +_SW_NEW_LABEL="" +_SW_NEW_WAS_RUNNING="no" +_SW_OLD_WAS_RUNNING="no" +_SW_OLD_LABEL="" +_SW_STAGING="" +# Transaction phases: "init" → "app_backed_up" → "app_swapped" → "plist_swapped" → "bootstrapped" → "done" +# Track whether this transaction bootstrapped the new label (so rollback +# can boot it out — but ONLY after revalidating identity). +_SW_BOOTSTRAPPED_BY_US="no" +_SW_PHASE="init" + +# Capture pre-existing state BEFORE installing the trap. Called first. +# FAIL-CLOSED: if either label is loaded but does NOT match Switcheroo's +# exact live program, abort immediately before any mutation — a foreign +# job occupying the label must never be silently overwritten or booted out. +sw_capture_state() { + local app_dir="$1" plist_dst="$2" new_label="$3" old_label="$4" + _SW_APP_DIR="$app_dir" + _SW_PLIST_DST="$plist_dst" + _SW_NEW_LABEL="$new_label" + _SW_OLD_LABEL="$old_label" + + if [ -d "$app_dir" ] && [ ! -L "$app_dir" ]; then + _SW_APP_HAD_PRIOR="yes" + else + _SW_APP_HAD_PRIOR="no" + fi + + if [ -f "$plist_dst" ] && [ ! -L "$plist_dst" ]; then + _SW_PLIST_HAD_PRIOR="yes" + else + _SW_PLIST_HAD_PRIOR="no" + fi + + # Check new label: if loaded, must be Switcheroo or abort. + if sw_label_is_loaded "$new_label"; then + if sw_loaded_job_is_switcheroo "$new_label"; then + _SW_NEW_WAS_RUNNING="yes" + else + sw_err "ABORT: label ${new_label} is loaded but its live program is not Switcheroo (foreign job collision) — refusing to proceed" + fi + fi + + # Check old label: if loaded, must be Switcheroo or abort. + if sw_label_is_loaded "$old_label"; then + if sw_loaded_job_is_switcheroo "$old_label"; then + _SW_OLD_WAS_RUNNING="yes" + else + sw_err "ABORT: label ${old_label} is loaded but its live program is not Switcheroo (foreign job collision) — refusing to proceed" + fi + fi +} + +sw_rollback() { + local rc=$? + if [ "$rc" -ne 0 ]; then + echo "==> Install failed (rc=$rc); rolling back..." >&2 + # 1. Boot out the failed new job ONLY if this transaction bootstrapped it. + # Revalidate exact live identity before bootout — never boot out a + # foreign job that may have taken the label. + if [ "$_SW_BOOTSTRAPPED_BY_US" = "yes" ] && [ -n "$_SW_NEW_LABEL" ]; then + if sw_loaded_job_is_switcheroo "$_SW_NEW_LABEL" 2>/dev/null; then + "${SW_BIN_LAUNCHCTL}" bootout "gui/$(sw_uid)/${_SW_NEW_LABEL}" 2>/dev/null || true + fi + fi + # 2. Restore app (before plist, so a restarted job uses the right binary). + # app_backed_up: prior app was moved to backup but staged move may have + # failed — restore from backup. + # app_swapped and later: same — restore from backup if prior existed, + # remove transaction-created app if no prior. + if [ "$_SW_PHASE" = "app_backed_up" ] || [ "$_SW_PHASE" = "app_swapped" ] || [ "$_SW_PHASE" = "plist_swapped" ] || [ "$_SW_PHASE" = "bootstrapped" ]; then + if [ "$_SW_APP_HAD_PRIOR" = "yes" ] && [ -n "$_SW_APP_BACKUP" ] && [ -d "$_SW_APP_BACKUP" ]; then + "${SW_BIN_RM}" -rf "$_SW_APP_DIR" 2>/dev/null || true + "${SW_BIN_MV}" "$_SW_APP_BACKUP" "$_SW_APP_DIR" 2>/dev/null || true + elif [ "$_SW_APP_HAD_PRIOR" = "no" ]; then + [ -n "$_SW_APP_DIR" ] && [ -d "$_SW_APP_DIR" ] && "${SW_BIN_RM}" -rf "$_SW_APP_DIR" 2>/dev/null || true + fi + fi + # 3. Restore plist (only if this transaction swapped it). + if [ "$_SW_PHASE" = "plist_swapped" ] || [ "$_SW_PHASE" = "bootstrapped" ]; then + if [ "$_SW_PLIST_HAD_PRIOR" = "yes" ] && [ -n "$_SW_PLIST_BACKUP" ] && [ -f "$_SW_PLIST_BACKUP" ]; then + "${SW_BIN_MV}" -f "$_SW_PLIST_BACKUP" "$_SW_PLIST_DST" 2>/dev/null || true + elif [ "$_SW_PLIST_HAD_PRIOR" = "no" ]; then + [ -n "$_SW_PLIST_DST" ] && "${SW_BIN_RM}" -f "$_SW_PLIST_DST" 2>/dev/null || true + fi + fi + # 4. Restart only jobs proven running before the transaction. + # Revalidate plist identity before re-bootstrapping — if the plist + # was replaced or raced to a foreign program, do NOT bootstrap it. + if [ "$_SW_NEW_WAS_RUNNING" = "yes" ] && [ -f "$_SW_PLIST_DST" ]; then + if sw_plist_is_switcheroo "$_SW_PLIST_DST" 2>/dev/null; then + "${SW_BIN_SLEEP}" 1 + "${SW_BIN_LAUNCHCTL}" bootstrap "gui/$(sw_uid)" "$_SW_PLIST_DST" 2>/dev/null || true + else + echo "==> ROLLBACK WARNING: new-label plist identity changed — not re-bootstrapping (possible race)" >&2 + fi + fi + local _old_plist + _old_plist="$(sw_real_home)/Library/LaunchAgents/${_SW_OLD_LABEL}.plist" + if [ "$_SW_OLD_WAS_RUNNING" = "yes" ] && [ -f "$_old_plist" ]; then + if sw_plist_is_switcheroo "$_old_plist" 2>/dev/null; then + "${SW_BIN_SLEEP}" 1 + "${SW_BIN_LAUNCHCTL}" bootstrap "gui/$(sw_uid)" "$_old_plist" 2>/dev/null || true + else + echo "==> ROLLBACK WARNING: old-label plist identity changed — not re-bootstrapping (possible race)" >&2 + fi + fi + fi + [ -n "${_SW_STAGING:-}" ] && "${SW_BIN_RM}" -rf "$_SW_STAGING" 2>/dev/null || true +} + +sw_render_plist() { + local plist_src="$1" app_dir="$2" plist_dst_dir="$3" tmp + tmp="$("${SW_USR_MKTEMP}" -p "$plist_dst_dir" -t switcheroo.plist.XXXXXX)" + "${SW_USR_SED}" "s|__APP_DIR__|${app_dir}|g" "$plist_src" > "$tmp" \ + || { "${SW_BIN_RM}" -f "$tmp"; sw_err "plist render (sed) failed"; } + "${SW_BIN_CHMOD}" 644 "$tmp" + if ! "${SW_USR_PLUTIL}" -lint "$tmp" >/dev/null 2>&1; then + "${SW_BIN_RM}" -f "$tmp" + sw_err "generated plist failed plutil -lint" + fi + printf '%s\n' "$tmp" +} diff --git a/scripts/package.sh b/scripts/package.sh new file mode 100755 index 0000000..fb42da5 --- /dev/null +++ b/scripts/package.sh @@ -0,0 +1,813 @@ +#!/bin/bash +# ───────────────────────────────────────────────────────────────────── +# scripts/package.sh — deterministic release packaging for Switcheroo. +# +# RELEASE MODE (--tag vX.Y.Z --commit-sha <40-hex>): +# Exports the validated commit into a fresh private source tree for +# compilation, and archives the source via `git archive ` +# (the exact validated commit SHA) — not the exported build tree. +# Builds only from that tree under a minimal environment. No +# caller-controlled --cargo; toolchain is validated against +# rust-toolchain.toml. Output is a fresh-private mktemp directory. +# Artifacts are release-named (no -rehearsal suffix). Manifest is +# deterministic (commit timestamp, no wall clock). +# +# REHEARSAL MODE (default): +# Uses the working tree. Produces -rehearsal-named artifacts. Native +# fallback permitted with --allow-native-fallback. Never looks like a +# release. +# +# Usage: +# scripts/package.sh [--dry-run] [--out-parent ] +# [--tag vX.Y.Z --commit-sha <40-hex>] +# [--cargo /path/to/cargo] (rehearsal only) +# [--verify-reproducibility] +# [--allow-native-fallback] +# +# Safety: +# - Never touches live LaunchAgents, user home, or git state. +# - PATH=/usr/bin:/bin:/usr/sbin:/sbin; umask 077. +# - All system tools invoked by absolute path via SW_* variables or +# shell builtins. +# - Output is a fresh-private mktemp directory under a validated parent. +# Never overwrites caller-controlled paths. Never rm -rf caller paths. +# - All payload inputs validated as regular non-symlink files. +# - Cleanup only removes script-owned temp directories. +# ───────────────────────────────────────────────────────────────────── +set -euo pipefail + +PATH=/usr/bin:/bin:/usr/sbin:/sbin +export PATH +umask 077 + +# ── Absolute tool paths ────────────────────────────────────────────── +SW_BIN_MKDIR=/bin/mkdir +SW_BIN_CP=/bin/cp +SW_BIN_RM=/bin/rm +SW_BIN_CHMOD=/bin/chmod +SW_USR_TAR=/usr/bin/tar +SW_USR_GZIP=/usr/bin/gzip +SW_USR_SIPS=/usr/bin/sips +SW_USR_ICONUTIL=/usr/bin/iconutil +SW_USR_CODESIGN=/usr/bin/codesign +SW_USR_LIPO=/usr/bin/lipo +SW_USR_FILE=/usr/bin/file +SW_USR_PLUTIL=/usr/bin/plutil +SW_USR_SHASUM=/usr/bin/shasum +SW_USR_GREP=/usr/bin/grep +SW_USR_SED=/usr/bin/sed +SW_USR_HEAD=/usr/bin/head +SW_USR_MKTEMP=/usr/bin/mktemp +SW_USR_GIT=/usr/bin/git +SW_USR_FIND=/usr/bin/find +SW_USR_TOUCH=/usr/bin/touch +SW_USR_STAT=/usr/bin/stat +SW_USR_ID=/usr/bin/id +SW_USR_DIRNAME=/usr/bin/dirname +SW_USR_BASENAME=/usr/bin/basename +SW_USR_UNAME=/usr/bin/uname +SW_USR_CHOWN=/usr/sbin/chown + +SW_ERR() { printf 'Error: %s\n' "$*" >&2; exit 1; } + +sw_shasum256_field1() { + $SW_USR_SHASUM -a 256 "$1" | { read -r h _; printf '%s' "$h"; } +} + +# Validate a path is a regular file, not a symlink. +sw_assert_regular_file() { + local f="$1" + [ -L "$f" ] && SW_ERR "path is a symlink — refusing: $f" + [ -f "$f" ] || SW_ERR "not a regular file: $f" +} + +# Walk every ancestor of a path, rejecting symlinks, wrong owner, or +# group/world-writable directories. System temp roots ($TMPDIR, /var/folders) +# are allowed to have group/world-writable ancestors because macOS manages +# them with restricted permissions and sticky bits. +sw_validate_ancestors() { + local path="$1" current_uid + current_uid="$($SW_USR_ID -u 2>/dev/null || printf 0)" + path="$($SW_USR_DIRNAME "$path")" + while [ "$path" != "/" ] && [ -n "$path" ]; do + if [ -L "$path" ]; then + # macOS has known system symlinks (/var -> /private/var, /tmp -> /private/tmp) + # Resolve and check the target instead of rejecting + local resolved + resolved="$(readlink "$path" 2>/dev/null || true)" + case "$path:$resolved" in + /var:private/var|/tmp:private/tmp|/etc:private/etc) + # Known macOS system symlink — safe + ;; + *) + SW_ERR "ancestor is a symlink — refusing: $path (→ ${resolved:-unknown})" + ;; + esac + fi + if [ -d "$path" ]; then + local owner mode + owner="$($SW_USR_STAT -f '%u' "$path" 2>/dev/null || printf 'err')" + mode="$($SW_USR_STAT -f '%Lp' "$path" 2>/dev/null || printf '000')" + if [ $(( 8#$mode & 0022 )) -ne 0 ]; then + case "$path" in + /tmp|/var/tmp|/var/folders|/private/tmp|/private/var/tmp|/private/var/folders|"${TMPDIR:-/nonexistent}"|"${TMPDIR%/}"*) + ;; + *) + SW_ERR "ancestor is group/world-writable (mode $mode) — refusing: $path" + ;; + esac + fi + if [ $(( 8#$mode & 0200 )) -ne 0 ] && [ "$owner" != "$current_uid" ] && [ "$owner" != "0" ]; then + SW_ERR "writable ancestor owned by uid $owner (not current/root) — refusing: $path" + fi + fi + path="$($SW_USR_DIRNAME "$path")" + done +} + +# ── Parse arguments ─────────────────────────────────────────────────── +DRY_RUN=false +OUT_PARENT="" +TAG="" +COMMIT_SHA="" +CARGO_BIN="" +VERIFY_REPRODUCIBILITY=false +ALLOW_NATIVE_FALLBACK=false + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=true; shift;; + --out-parent) OUT_PARENT="$2"; shift 2;; + --out-dir) SW_ERR "--out-dir removed: use --out-parent for a validated parent directory";; + --tag) TAG="$2"; shift 2;; + --commit-sha) COMMIT_SHA="$2"; shift 2;; + --cargo) CARGO_BIN="$2"; shift 2;; + --verify-reproducibility) VERIFY_REPRODUCIBILITY=true; shift;; + --allow-native-fallback) ALLOW_NATIVE_FALLBACK=true; shift;; + *) SW_ERR "unknown argument: $1";; + esac +done + +# ── Determine mode ──────────────────────────────────────────────────── +RELEASE_MODE=false +if [ -n "$COMMIT_SHA" ]; then + RELEASE_MODE=true + [ -n "$TAG" ] || SW_ERR "--commit-sha requires --tag" + # Release mode: no --cargo allowed (toolchain is validated) + [ -z "$CARGO_BIN" ] || SW_ERR "--cargo is not allowed in release mode (toolchain is validated automatically)" +elif [ -n "$TAG" ]; then + SW_ERR "--tag requires --commit-sha for release mode" +fi + +if [ "$VERIFY_REPRODUCIBILITY" = "true" ] && [ "$RELEASE_MODE" = "true" ]; then + SW_ERR "--verify-reproducibility is only valid in rehearsal mode" +fi + +# ── Resolve repo root ───────────────────────────────────────────────── +SW_SCRIPT_PATH="$0" +case "$SW_SCRIPT_PATH" in + /*) ;; + *) SW_SCRIPT_PATH="${PWD}/${SW_SCRIPT_PATH}";; +esac +SCRIPT_DIR="$($SW_USR_DIRNAME "$SW_SCRIPT_PATH")" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${REPO_ROOT}" + +# ── Resolve output parent and create fresh-private output dir ──────── +if [ -z "$OUT_PARENT" ]; then + OUT_PARENT="${REPO_ROOT}/dist-parent" +fi + +# Resolve to absolute +case "$OUT_PARENT" in + /*) ;; + *) OUT_PARENT="${REPO_ROOT}/${OUT_PARENT}";; +esac + +# Validate output parent ancestors (no symlinks in chain) +sw_validate_ancestors "$OUT_PARENT" + +# If parent exists, validate it's a safe directory +if [ -e "$OUT_PARENT" ]; then + [ -L "$OUT_PARENT" ] && SW_ERR "out-parent is a symlink — refusing: $OUT_PARENT" + [ -d "$OUT_PARENT" ] || SW_ERR "out-parent exists but is not a directory — refusing: $OUT_PARENT" + current_uid="$($SW_USR_ID -u 2>/dev/null || printf 0)" + owner="$($SW_USR_STAT -f '%u' "$OUT_PARENT" 2>/dev/null || printf 'err')" + [ "$owner" = "$current_uid" ] || \ + SW_ERR "out-parent owned by uid $owner (not $current_uid) — refusing: $OUT_PARENT" + mode="$($SW_USR_STAT -f '%Lp' "$OUT_PARENT" 2>/dev/null || printf '000')" + if [ $(( 8#$mode & 0022 )) -ne 0 ]; then + SW_ERR "out-parent is group/world-writable (mode $mode) — refusing: $OUT_PARENT" + fi +fi + +$SW_BIN_MKDIR -p "$OUT_PARENT" + +# Create a fresh-private unique output directory (0700 via umask) +OUT_DIR="$($SW_USR_MKTEMP -d -p "$OUT_PARENT" -t switcheroo-out.XXXXXX)" +$SW_BIN_CHMOD 700 "$OUT_DIR" + +# ── Temp directories and cleanup ────────────────────────────────────── +# _CARGO_TARGET_DIR: a script-owned isolated target dir so every packaging +# invocation (including the Run 2 subprocess of --verify-reproducibility) +# builds cold into its own clean target. This makes the two rehearsal runs +# symmetric and eliminates cold-vs-warm shared-target-cache nondeterminism. +# Release mode already builds in a fresh exported tree; routing it through +# the same isolated target keeps binary-copy paths unified. +_TEMP_ICONSET="" +_VERIFY_DIR="" +_REPRO_TOP="" +_BUILD_TREE="" +_CARGO_TARGET_DIR="" +cleanup() { + for d in "$_TEMP_ICONSET" "$_VERIFY_DIR" "$_REPRO_TOP" "$_BUILD_TREE" "$_CARGO_TARGET_DIR"; do + if [ -n "$d" ] && [ -d "$d" ]; then + $SW_BIN_RM -rf "$d" 2>/dev/null || true + fi + done +} +trap cleanup EXIT + +# ── Read canonical version from Cargo.toml ─────────────────────────── +CARGO_VERSION="$($SW_USR_SED -n '/^\[package\]/,/^\[/p' Cargo.toml | $SW_USR_GREP -m1 '^version' | $SW_USR_SED 's/version = "\(.*\)"/\1/')" +[ -n "$CARGO_VERSION" ] || SW_ERR "could not read version from Cargo.toml" +printf '%s' "$CARGO_VERSION" | $SW_USR_GREP -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || SW_ERR "Cargo.toml version '$CARGO_VERSION' is not strict semver X.Y.Z" + +# ── Tag/SHA validation (release mode) ──────────────────────────────── +if [ "$RELEASE_MODE" = "true" ]; then + printf '%s' "$TAG" | $SW_USR_GREP -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + || SW_ERR "tag '$TAG' is not semver 'vX.Y.Z'" + TAG_VERSION="${TAG#v}" + [ "$TAG_VERSION" = "$CARGO_VERSION" ] \ + || SW_ERR "tag version '$TAG_VERSION' != Cargo.toml version '$CARGO_VERSION'" + printf '%s' "$COMMIT_SHA" | $SW_USR_GREP -Eq '^[0-9a-f]{40}$' \ + || SW_ERR "--commit-sha must be a 40-char hex SHA: '$COMMIT_SHA'" + # Assert HEAD == COMMIT_SHA + HEAD_SHA="$($SW_USR_GIT rev-parse HEAD 2>/dev/null || true)" + [ "$HEAD_SHA" = "$COMMIT_SHA" ] \ + || SW_ERR "release mode requires HEAD == commit-sha. HEAD=$HEAD_SHA, requested=$COMMIT_SHA" + # Assert completely clean worktree (tracked + untracked) + if ! $SW_USR_GIT diff --quiet HEAD 2>/dev/null; then + SW_ERR "release mode requires a clean worktree (unstaged tracked changes detected)" + fi + if ! $SW_USR_GIT diff --cached --quiet HEAD 2>/dev/null; then + SW_ERR "release mode requires a clean worktree (staged changes detected)" + fi + # Check for untracked files (excluding .githooks which are GitButler-managed) + UNTRACKED="$($SW_USR_GIT ls-files --others --exclude-standard 2>/dev/null | $SW_USR_GREP -v '^\.' || true)" + if [ -n "$UNTRACKED" ]; then + # Allow .githooks and .opencode (session files) as untracked + REAL_UNTRACKED="$(printf '%s\n' "$UNTRACKED" | $SW_USR_GREP -v '^\.githooks/' | $SW_USR_GREP -v '^\.opencode/' || true)" + if [ -n "$REAL_UNTRACKED" ]; then + SW_ERR "release mode requires a clean worktree (untracked files detected): $REAL_UNTRACKED" + fi + fi + VERSION="$TAG_VERSION" + ARCHIVE_TAG="$TAG" + ARCHIVE_SUFFIX="" + MODE_LABEL="RELEASE" +else + VERSION="$CARGO_VERSION" + ARCHIVE_TAG="v${VERSION}" + ARCHIVE_SUFFIX="-rehearsal" + MODE_LABEL="REHEARSAL" +fi + +echo "==> Switcheroo packaging [$MODE_LABEL]" +echo " Version: $VERSION" +echo " Tag: ${TAG:-}" +echo " Commit: ${COMMIT_SHA:-}" +echo " Dry-run: $DRY_RUN" +echo " Out-dir: $OUT_DIR" + +# ── For release mode: export commit into a fresh private source tree ─ +# This ensures the build is hermetic — no untracked files, no .cargo/config, +# no inherited workspace state. We build only from the exported tree. +BUILD_ROOT="$REPO_ROOT" # default: build from current workspace +if [ "$RELEASE_MODE" = "true" ]; then + echo "==> Exporting commit $COMMIT_SHA into private source tree" + _BUILD_TREE="$($SW_USR_MKTEMP -d -t switcheroo.build-tree.XXXXXX)" + $SW_USR_GIT archive --format=tar "$COMMIT_SHA" | $SW_USR_TAR -x -C "$_BUILD_TREE" + BUILD_ROOT="$_BUILD_TREE" + echo " Build tree: $_BUILD_TREE" +fi + +# ── Isolated cargo target directory ──────────────────────────────────── +# Every packaging invocation builds into its own script-owned, fresh-private +# target dir. This guarantees cold, symmetric builds for both runs of +# --verify-reproducibility (Run 1 and the Run 2 subprocess each get a clean +# target instead of sharing the workspace target/ cache), and keeps release +# mode hermetic. Cleaned by the EXIT trap. +_CARGO_TARGET_DIR="$($SW_USR_MKTEMP -d -t switcheroo.target.XXXXXX)" +CARGO_TARGET_DIR="$_CARGO_TARGET_DIR" +export CARGO_TARGET_DIR +echo " Target dir: $CARGO_TARGET_DIR" + +# ── Discover/validate cargo ─────────────────────────────────────────── +if [ "$RELEASE_MODE" = "true" ]; then + # Release: use validated cargo from known paths only + for c in "${HOME}/.cargo/bin/cargo" /opt/homebrew/bin/cargo /usr/local/bin/cargo; do + if [ -x "$c" ]; then CARGO_BIN="$c"; break; fi + done + [ -n "$CARGO_BIN" ] || SW_ERR "cargo not found for release build" + # Validate cargo: must exist, be executable, and report as cargo. + # (rustup proxies are symlinks to rustup — that's expected; we verify + # the proxy runs cargo correctly via --version, not the symlink target.) + [ -L "$CARGO_BIN" ] || [ -f "$CARGO_BIN" ] || SW_ERR "cargo at $CARGO_BIN is not a file or symlink" + CARGO_OWNER="$($SW_USR_STAT -L -f '%u' "$CARGO_BIN" 2>/dev/null || printf 'err')" + [ "$CARGO_OWNER" = "$($SW_USR_ID -u)" ] || \ + SW_ERR "cargo at $CARGO_BIN owned by uid $CARGO_OWNER — refusing" + CARGO_VER_OUT="$("$CARGO_BIN" --version 2>/dev/null || true)" + printf '%s' "$CARGO_VER_OUT" | $SW_USR_GREP -q '^cargo ' \ + || SW_ERR "cargo at $CARGO_BIN does not report as cargo: '$CARGO_VER_OUT'" + # Verify rustc version matches rust-toolchain.toml pin + RUSTC_BIN="$($SW_USR_DIRNAME "$CARGO_BIN")/rustc" + [ -x "$RUSTC_BIN" ] || RUSTC_BIN="${HOME}/.cargo/bin/rustc" + RUSTC_VER="$("$RUSTC_BIN" --version 2>/dev/null || true)" + EXPECTED_RUSTC="rustc 1.95.0" + printf '%s' "$RUSTC_VER" | $SW_USR_GREP -q "^${EXPECTED_RUSTC}" \ + || SW_ERR "rustc version '$RUSTC_VER' does not match pinned '${EXPECTED_RUSTC}' (rust-toolchain.toml)" +else + # Rehearsal: discover cargo with optional --cargo + if [ -z "$CARGO_BIN" ]; then + for c in "${HOME}/.cargo/bin/cargo" /opt/homebrew/bin/cargo /usr/local/bin/cargo; do + if [ -x "$c" ]; then CARGO_BIN="$c"; break; fi + done + fi + [ -n "$CARGO_BIN" ] || SW_ERR "cargo not found. Pass --cargo /absolute/path." +fi + +# Sanitize build environment +unset RUSTC_WRAPPER 2>/dev/null || true +export CARGO_BUILD_RUSTC_WRAPPER="" + +# ── Validate Info.plist version ─────────────────────────────────────── +PLIST_PATH="${BUILD_ROOT}/bundle/Info.plist" +sw_assert_regular_file "$PLIST_PATH" +PLIST_SHORT_VER="$($SW_USR_PLUTIL -extract CFBundleShortVersionString raw -o - "$PLIST_PATH" 2>/dev/null || printf '')" +PLIST_VER="$($SW_USR_PLUTIL -extract CFBundleVersion raw -o - "$PLIST_PATH" 2>/dev/null || printf '')" +[ "$PLIST_SHORT_VER" = "$CARGO_VERSION" ] \ + || SW_ERR "Info.plist CFBundleShortVersionString='$PLIST_SHORT_VER' != Cargo='$CARGO_VERSION'" +[ "$PLIST_VER" = "$CARGO_VERSION" ] \ + || SW_ERR "Info.plist CFBundleVersion='$PLIST_VER' != Cargo='$CARGO_VERSION'" + +# ── Validate CHANGELOG.md ───────────────────────────────────────────── +CHANGELOG_PATH="${BUILD_ROOT}/CHANGELOG.md" +if [ ! -f "$CHANGELOG_PATH" ]; then + [ "$RELEASE_MODE" = "false" ] || SW_ERR "CHANGELOG.md required in release mode" + echo " WARNING: CHANGELOG.md not found (rehearsal — skipping)" +else + CHANGELOG_VER="$($SW_USR_GREP -m1 -E '^## \[' "$CHANGELOG_PATH" | $SW_USR_SED -E 's/^## \[([0-9.]+)\].*/\1/' || true)" + if [ -z "$CHANGELOG_VER" ]; then + [ "$RELEASE_MODE" = "false" ] || SW_ERR "CHANGELOG.md has no version entry (required in release mode)" + echo " WARNING: CHANGELOG.md has no version entry (rehearsal — skipping)" + elif [ "$CHANGELOG_VER" != "$VERSION" ]; then + SW_ERR "CHANGELOG.md top entry '$CHANGELOG_VER' != expected '$VERSION'" + fi +fi + +# ── Validate icon master ────────────────────────────────────────────── +ICON_MASTER="${BUILD_ROOT}/bundle/AppIcon-1024.png" +sw_assert_regular_file "$ICON_MASTER" + +# ── Build universal binary ──────────────────────────────────────────── +echo "==> Building universal binary (aarch64 + x86_64)" + +build_native_only=false + +if [ "$RELEASE_MODE" = "true" ]; then + # Release: both targets MUST succeed — no fallback + # Hermetic build: use a private empty CARGO_HOME seeded with locked + # dependencies to avoid consuming mutable user Cargo config, credentials, + # or proxies. Fetch with the user's CARGO_HOME (which has the registry + # index), then build with --frozen under a sanitized private environment. + PRIVATE_HOME="$($SW_USR_MKTEMP -d -t switcheroo.home.XXXXXX)" + PRIVATE_CARGO_HOME="$PRIVATE_HOME/.cargo" + $SW_BIN_MKDIR -p "$PRIVATE_CARGO_HOME" + + # Fetch locked dependencies using the user's CARGO_HOME (has registry index). + # This populates the user's cache with the exact locked versions. + env -i \ + HOME="$HOME" \ + PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ + CARGO_HOME="$HOME/.cargo" \ + RUSTUP_HOME="$HOME/.rustup" \ + CARGO_BUILD_RUSTC_WRAPPER="" \ + "$CARGO_BIN" fetch --locked --manifest-path "$BUILD_ROOT/Cargo.toml" 2>/dev/null \ + || SW_ERR "release: cargo fetch --locked failed" + + # Copy the fetched registry cache to the private CARGO_HOME + if [ -d "$HOME/.cargo/registry" ]; then + $SW_BIN_CP -R "$HOME/.cargo/registry" "$PRIVATE_CARGO_HOME/registry" + fi + if [ -d "$HOME/.cargo/git" ]; then + $SW_BIN_CP -R "$HOME/.cargo/git" "$PRIVATE_CARGO_HOME/git" + fi + + # Build both targets under --frozen (offline, no network, no user config) + # Include cargo bin dir in PATH so rustup proxies can find rustc + CARGO_BIN_DIR="$($SW_USR_DIRNAME "$CARGO_BIN")" + env -i \ + HOME="$PRIVATE_HOME" \ + PATH="${CARGO_BIN_DIR}:/usr/bin:/bin:/usr/sbin:/sbin" \ + CARGO_HOME="$PRIVATE_CARGO_HOME" \ + CARGO_TARGET_DIR="$CARGO_TARGET_DIR" \ + RUSTUP_HOME="$HOME/.rustup" \ + CARGO_BUILD_RUSTC_WRAPPER="" \ + "$CARGO_BIN" build --release --frozen --manifest-path "$BUILD_ROOT/Cargo.toml" --target aarch64-apple-darwin \ + || SW_ERR "release: aarch64 build failed" + env -i \ + HOME="$PRIVATE_HOME" \ + PATH="${CARGO_BIN_DIR}:/usr/bin:/bin:/usr/sbin:/sbin" \ + CARGO_HOME="$PRIVATE_CARGO_HOME" \ + CARGO_TARGET_DIR="$CARGO_TARGET_DIR" \ + RUSTUP_HOME="$HOME/.rustup" \ + CARGO_BUILD_RUSTC_WRAPPER="" \ + "$CARGO_BIN" build --release --frozen --manifest-path "$BUILD_ROOT/Cargo.toml" --target x86_64-apple-darwin \ + || SW_ERR "release: x86_64 build failed" + # Clean up private HOME + $SW_BIN_RM -rf "$PRIVATE_HOME" +else + # Rehearsal: allow native fallback + if ! "$CARGO_BIN" build --release --locked --target aarch64-apple-darwin 2>/dev/null; then + echo " (aarch64 target not available — falling back to native-only)" + build_native_only=true + fi + if [ "$build_native_only" = "false" ]; then + if ! "$CARGO_BIN" build --release --locked --target x86_64-apple-darwin 2>/dev/null; then + echo " (x86_64 target not available — falling back to native-only)" + build_native_only=true + fi + fi +fi + +UNIVERSAL_BINARY="$OUT_DIR/switcheroo-universal" + +if [ "$build_native_only" = "true" ]; then + [ "$RELEASE_MODE" = "false" ] || SW_ERR "release mode requires universal build" + echo "==> Building native binary only (cross targets not installed)" + "$CARGO_BIN" build --release --locked --manifest-path "$BUILD_ROOT/Cargo.toml" + $SW_BIN_CP "$CARGO_TARGET_DIR/release/switcheroo" "$UNIVERSAL_BINARY" + ARCH_LABEL="native" + ARCH_NAME="$($SW_USR_UNAME -m)" +else + echo "==> Creating universal binary via lipo" + $SW_USR_LIPO -create -output "$UNIVERSAL_BINARY" \ + "$CARGO_TARGET_DIR/aarch64-apple-darwin/release/switcheroo" \ + "$CARGO_TARGET_DIR/x86_64-apple-darwin/release/switcheroo" + ARCHS="$($SW_USR_LIPO -archs "$UNIVERSAL_BINARY" 2>/dev/null || true)" + if [ "$ARCHS" != "x86_64 arm64" ] && [ "$ARCHS" != "arm64 x86_64" ]; then + SW_ERR "universal binary architecture mismatch: '$ARCHS'" + fi + ARCH_LABEL="universal" + ARCH_NAME="universal" +fi + +$SW_USR_FILE "$UNIVERSAL_BINARY" | $SW_USR_GREP -q 'Mach-O' \ + || SW_ERR "built binary is not a Mach-O executable" + +echo "==> Ad-hoc signing universal binary" +$SW_USR_CODESIGN --force --sign - "$UNIVERSAL_BINARY" + +# Verify --version (exact match) +echo "==> Verifying binary --version" +BIN_VERSION_LINE="$("$UNIVERSAL_BINARY" --version 2>/dev/null | $SW_USR_HEAD -1 || true)" +BIN_VERSION="$(printf '%s' "$BIN_VERSION_LINE" | $SW_USR_SED 's/^switcheroo //' || true)" +printf '%s' "$BIN_VERSION" | $SW_USR_GREP -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || SW_ERR "binary --version not exact 'switcheroo X.Y.Z': got '$BIN_VERSION_LINE'" +[ "$BIN_VERSION" = "$VERSION" ] \ + || SW_ERR "binary --version '$BIN_VERSION' != expected '$VERSION'" +echo " OK: switcheroo $BIN_VERSION" + +# ── Generate AppIcon.icns ───────────────────────────────────────────── +echo "==> Generating AppIcon.icns from bundle/AppIcon-1024.png" +_TEMP_ICONSET="$($SW_USR_MKTEMP -d -t switcheroo.iconset.XXXXXX)" +ICONSET_DIR="${_TEMP_ICONSET}/AppIcon.iconset" +$SW_BIN_MKDIR -p "$ICONSET_DIR" + +generate_icon() { + local size="$1" out_name="$2" + $SW_USR_SIPS -z "$size" "$size" "$ICON_MASTER" --out "$ICONSET_DIR/$out_name" >/dev/null 2>&1 \ + || SW_ERR "sips failed for $out_name" +} + +generate_icon 16 "icon_16x16.png" +generate_icon 32 "icon_16x16@2x.png" +generate_icon 32 "icon_32x32.png" +generate_icon 64 "icon_32x32@2x.png" +generate_icon 64 "icon_64x64.png" +generate_icon 128 "icon_64x64@2x.png" +generate_icon 128 "icon_128x128.png" +generate_icon 256 "icon_128x128@2x.png" +generate_icon 256 "icon_256x256.png" +generate_icon 512 "icon_256x256@2x.png" +generate_icon 512 "icon_512x512.png" +generate_icon 1024 "icon_512x512@2x.png" + +ICNS_OUTPUT="$OUT_DIR/AppIcon.icns" +$SW_USR_ICONUTIL -c icns "$ICONSET_DIR" -o "$ICNS_OUTPUT" \ + || SW_ERR "iconutil -c icns failed" +[ -f "$ICNS_OUTPUT" ] || SW_ERR "iconutil did not produce AppIcon.icns" +$SW_BIN_RM -rf "$_TEMP_ICONSET" +_TEMP_ICONSET="" + +# ── Assemble Switcheroo.app ─────────────────────────────────────────── +echo "==> Assembling Switcheroo.app" +APP_DIR="$OUT_DIR/Switcheroo.app" +$SW_BIN_MKDIR -p "$APP_DIR/Contents/MacOS" +$SW_BIN_MKDIR -p "$APP_DIR/Contents/Resources" + +$SW_BIN_CP "$UNIVERSAL_BINARY" "$APP_DIR/Contents/MacOS/switcheroo" +$SW_BIN_CHMOD 755 "$APP_DIR/Contents/MacOS/switcheroo" + +$SW_BIN_CP "$PLIST_PATH" "$APP_DIR/Contents/Info.plist" +$SW_USR_PLUTIL -replace CFBundleVersion -string "$VERSION" "$APP_DIR/Contents/Info.plist" +$SW_USR_PLUTIL -replace CFBundleShortVersionString -string "$VERSION" "$APP_DIR/Contents/Info.plist" +$SW_USR_PLUTIL -lint "$APP_DIR/Contents/Info.plist" >/dev/null + +$SW_BIN_CP "$ICNS_OUTPUT" "$APP_DIR/Contents/Resources/AppIcon.icns" + +echo "==> Ad-hoc signing Switcheroo.app" +$SW_USR_CODESIGN --force --sign - "$APP_DIR" +$SW_USR_CODESIGN --verify --strict "$APP_DIR" 2>/dev/null \ + || SW_ERR "codesign --verify failed on assembled .app" + +BUNDLE_ID="$($SW_USR_PLUTIL -extract CFBundleIdentifier raw -o - "$APP_DIR/Contents/Info.plist" 2>/dev/null || printf '')" +[ "$BUNDLE_ID" = "com.mitchelljphayes.switcheroo" ] \ + || SW_ERR "wrong CFBundleIdentifier: '$BUNDLE_ID'" +[ -f "$APP_DIR/Contents/Resources/AppIcon.icns" ] \ + || SW_ERR "AppIcon.icns missing from assembled .app" +echo " OK: .app assembled, signed, icon present" + +# ── Dry-run stops here ──────────────────────────────────────────────── +if [ "$DRY_RUN" = "true" ]; then + echo "==> Dry-run complete (archives skipped) [$MODE_LABEL]" + echo " Binary: $UNIVERSAL_BINARY" + echo " App: $APP_DIR" + echo " Icon: $ICNS_OUTPUT" + exit 0 +fi + +if [ "$build_native_only" = "true" ] && [ "$ALLOW_NATIVE_FALLBACK" = "false" ]; then + echo "==> Rehearsal with native fallback — not producing archives." + echo " Use --allow-native-fallback to produce rehearsal archives." + exit 0 +fi + +# ── Build source archive ────────────────────────────────────────────── +echo "==> Building source archive" +SOURCE_ARCHIVE="$OUT_DIR/switcheroo-${ARCHIVE_TAG}-source${ARCHIVE_SUFFIX}.tar.gz" +SOURCE_TAR="${SOURCE_ARCHIVE%.gz}" + +if [ "$RELEASE_MODE" = "true" ]; then + # Release: archive directly from the validated commit SHA via git archive. + # This is deterministic and never includes target/ or build artifacts. + # The exported build tree is used for compilation only, not for archiving. + # Use -C to run from the original repo (we may be cd'd into the build tree). + $SW_USR_GIT -C "$REPO_ROOT" archive --format=tar --prefix="switcheroo-${ARCHIVE_TAG}/" \ + --output "$SOURCE_TAR" "$COMMIT_SHA" +else + # Rehearsal: archive HEAD + $SW_USR_GIT -C "$REPO_ROOT" archive --format=tar --prefix="switcheroo-${ARCHIVE_TAG}/" \ + --output "$SOURCE_TAR" HEAD +fi +[ -f "$SOURCE_TAR" ] || SW_ERR "source tar was not created" + +$SW_USR_GZIP -n -c "$SOURCE_TAR" > "$SOURCE_ARCHIVE" +$SW_BIN_RM -f "$SOURCE_TAR" +[ -f "$SOURCE_ARCHIVE" ] || SW_ERR "source archive was not created" + +# ── Build binary archive ────────────────────────────────────────────── +echo "==> Building binary archive" +if [ "$ARCH_LABEL" = "universal" ]; then + BINARY_ARCHIVE="$OUT_DIR/switcheroo-${ARCHIVE_TAG}-macos-universal${ARCHIVE_SUFFIX}.tar.gz" +else + BINARY_ARCHIVE="$OUT_DIR/switcheroo-${ARCHIVE_TAG}-macos-${ARCH_NAME}${ARCHIVE_SUFFIX}.tar.gz" +fi +BINARY_TAR="${BINARY_ARCHIVE%.gz}" + +# Set file mtimes to epoch for deterministic tar +$SW_USR_FIND "$OUT_DIR/Switcheroo.app" -exec $SW_USR_TOUCH -t 197001010000.00 {} + 2>/dev/null || true + +# Normalize ownership for deterministic tar headers. macOS bsdtar records +# the actual uid/gid from the filesystem, and temp dirs under different +# filesystems (/tmp → wheel, /var/folders → staff) get different gids. +# chown to the current user's uid:gid so both --verify-reproducibility +# runs (whose OUT_DIRs may live on different filesystems) produce +# identical tar headers. chown to our own uid:gid needs no sudo and does +# not modify file contents or git state. +CURRENT_UIDGID="$($SW_USR_ID -u):$($SW_USR_ID -g)" +$SW_USR_FIND "$OUT_DIR/Switcheroo.app" -exec "$SW_USR_CHOWN" "$CURRENT_UIDGID" {} + 2>/dev/null || true + +# Validate payload inputs are regular non-symlink files +for f in com.mitchelljphayes.switcheroo.plist install-binary.sh uninstall.sh scripts/lib.sh config.toml README.md LICENSE; do + sw_assert_regular_file "${BUILD_ROOT}/${f}" +done + +# Copy repo payload files into the OUT_DIR and normalize their ownership +# so all tar entries have a consistent uid:gid regardless of which +# filesystem BUILD_ROOT lives on. This avoids touching the working tree. +PAYLOAD_STAGING="$OUT_DIR/.payload" +$SW_BIN_MKDIR -p "$PAYLOAD_STAGING/scripts" +for f in com.mitchelljphayes.switcheroo.plist install-binary.sh uninstall.sh scripts/lib.sh config.toml README.md LICENSE; do + $SW_BIN_CP "${BUILD_ROOT}/${f}" "$PAYLOAD_STAGING/${f}" +done +# Normalize ownership and mtimes for deterministic tar headers +$SW_USR_FIND "$PAYLOAD_STAGING" -exec "$SW_USR_CHOWN" "$CURRENT_UIDGID" {} + 2>/dev/null || true +$SW_USR_FIND "$PAYLOAD_STAGING" -exec $SW_USR_TOUCH -t 197001010000.00 {} + 2>/dev/null || true + +$SW_USR_TAR -cf "$BINARY_TAR" \ + -C "$OUT_DIR" Switcheroo.app \ + -C "$PAYLOAD_STAGING" \ + com.mitchelljphayes.switcheroo.plist \ + install-binary.sh \ + uninstall.sh \ + scripts/lib.sh \ + config.toml \ + README.md \ + LICENSE + +# Clean up the payload staging dir (no longer needed after tar is written) +$SW_BIN_RM -rf "$PAYLOAD_STAGING" + +[ -f "$BINARY_TAR" ] || SW_ERR "binary tar was not created" +$SW_USR_GZIP -n -c "$BINARY_TAR" > "$BINARY_ARCHIVE" +$SW_BIN_RM -f "$BINARY_TAR" +[ -f "$BINARY_ARCHIVE" ] || SW_ERR "binary archive was not created" + +# ── Generate and verify SHA-256 checksums ──────────────────────────── +echo "==> Generating SHA-256 checksums" +CHECKSUMS_FILE="$OUT_DIR/checksums-sha256${ARCHIVE_SUFFIX}.txt" +SOURCE_ARCHIVE_NAME="$($SW_USR_BASENAME "$SOURCE_ARCHIVE")" +BINARY_ARCHIVE_NAME="$($SW_USR_BASENAME "$BINARY_ARCHIVE")" + +( + cd "$OUT_DIR" && \ + $SW_USR_SHASUM -a 256 "$SOURCE_ARCHIVE_NAME" "$BINARY_ARCHIVE_NAME" \ + > "$($SW_USR_BASENAME "$CHECKSUMS_FILE")" +) +while IFS= read -r line; do printf '%s\n' "$line"; done < "$CHECKSUMS_FILE" +( cd "$OUT_DIR" && $SW_USR_SHASUM -a 256 -c "$($SW_USR_BASENAME "$CHECKSUMS_FILE")" ) + +# ── Extract and self-verify binary archive ─────────────────────────── +echo "==> Self-verifying extracted binary archive" +_VERIFY_DIR="$($SW_USR_MKTEMP -d -t switcheroo.verify.XXXXXX)" +$SW_USR_TAR -xzf "$BINARY_ARCHIVE" -C "$_VERIFY_DIR" + +[ -d "$_VERIFY_DIR/Switcheroo.app" ] || SW_ERR "missing Switcheroo.app" +for f in com.mitchelljphayes.switcheroo.plist install-binary.sh uninstall.sh scripts/lib.sh config.toml README.md LICENSE; do + [ -f "$_VERIFY_DIR/$f" ] || SW_ERR "missing $f in archive" +done + +$SW_USR_PLUTIL -lint "$_VERIFY_DIR/Switcheroo.app/Contents/Info.plist" >/dev/null \ + || SW_ERR "extracted Info.plist fails plutil -lint" +$SW_USR_CODESIGN --verify --strict "$_VERIFY_DIR/Switcheroo.app" 2>/dev/null \ + || SW_ERR "extracted .app fails codesign --verify" + +EXTRACTED_BIN="$_VERIFY_DIR/Switcheroo.app/Contents/MacOS/switcheroo" +EXTRACTED_VER_LINE="$("$EXTRACTED_BIN" --version 2>/dev/null | $SW_USR_HEAD -1 || true)" +EXTRACTED_VER="$(printf '%s' "$EXTRACTED_VER_LINE" | $SW_USR_SED 's/^switcheroo //' || true)" +printf '%s' "$EXTRACTED_VER" | $SW_USR_GREP -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || SW_ERR "extracted binary --version not exact semver: '$EXTRACTED_VER_LINE'" +[ "$EXTRACTED_VER" = "$VERSION" ] \ + || SW_ERR "extracted binary --version '$EXTRACTED_VER' != '$VERSION'" +[ -f "$_VERIFY_DIR/Switcheroo.app/Contents/Resources/AppIcon.icns" ] \ + || SW_ERR "extracted .app missing AppIcon.icns" + +if [ "$ARCH_LABEL" = "universal" ]; then + $SW_USR_FILE "$EXTRACTED_BIN" | $SW_USR_GREP -q 'universal' \ + || SW_ERR "extracted binary is not universal" +fi + +EXTRACTED_PLIST_VER="$($SW_USR_PLUTIL -extract CFBundleShortVersionString raw -o - "$_VERIFY_DIR/Switcheroo.app/Contents/Info.plist" 2>/dev/null || printf '')" +[ "$EXTRACTED_PLIST_VER" = "$VERSION" ] \ + || SW_ERR "extracted plist version '$EXTRACTED_PLIST_VER' != '$VERSION'" + +$SW_BIN_RM -rf "$_VERIFY_DIR" +_VERIFY_DIR="" +echo " OK: extracted archive verified" + +# ── Release manifest (deterministic) ────────────────────────────────── +if [ "$RELEASE_MODE" = "true" ]; then + MANIFEST_FILE="$OUT_DIR/release-manifest.txt" + # Deterministic timestamp from commit (not wall clock) + COMMIT_TIMESTAMP="$($SW_USR_GIT -C "$REPO_ROOT" show -s --format='%ci' "$COMMIT_SHA" 2>/dev/null | $SW_USR_SED 's/ +0000/Z/' | $SW_USR_SED 's/ +[0-9]*$//' || printf 'unknown')" + CARGO_VER_FULL="$("$CARGO_BIN" --version 2>/dev/null || printf 'unknown')" + RUSTC_VER_FULL="$("$RUSTC_BIN" --version 2>/dev/null || printf 'unknown')" + SCRIPT_SHA="$(sw_shasum256_field1 "$0")" + SOURCE_HASH="$(sw_shasum256_field1 "$SOURCE_ARCHIVE")" + BINARY_HASH="$(sw_shasum256_field1 "$BINARY_ARCHIVE")" + # Validate hashes + printf '%s' "$SOURCE_HASH" | $SW_USR_GREP -Eq '^[0-9a-f]{64}$' \ + || SW_ERR "manifest: source hash invalid: '$SOURCE_HASH'" + printf '%s' "$BINARY_HASH" | $SW_USR_GREP -Eq '^[0-9a-f]{64}$' \ + || SW_ERR "manifest: binary hash invalid: '$BINARY_HASH'" + + { + printf 'switcheroo release manifest\n' + printf 'version: %s\n' "$VERSION" + printf 'tag: %s\n' "$TAG" + printf 'commit: %s\n' "$COMMIT_SHA" + printf 'commit_timestamp: %s\n' "$COMMIT_TIMESTAMP" + printf 'cargo: %s\n' "$CARGO_VER_FULL" + printf 'rustc: %s\n' "$RUSTC_VER_FULL" + printf 'targets: aarch64-apple-darwin,x86_64-apple-darwin\n' + printf 'packaging_script_sha: %s\n' "$SCRIPT_SHA" + printf '\n' + printf 'artifacts:\n' + printf ' %s: %s\n' "$SOURCE_ARCHIVE_NAME" "$SOURCE_HASH" + printf ' %s: %s\n' "$BINARY_ARCHIVE_NAME" "$BINARY_HASH" + } > "$MANIFEST_FILE" + echo "==> Release manifest: $MANIFEST_FILE" +fi + +# ── Reproducibility verification ────────────────────────────────────── +if [ "$VERIFY_REPRODUCIBILITY" = "true" ]; then + echo "==> Verifying reproducibility (two-run hash comparison)" + HASH1_SOURCE="$(sw_shasum256_field1 "$SOURCE_ARCHIVE")" + HASH1_BINARY="$(sw_shasum256_field1 "$BINARY_ARCHIVE")" + echo " Run 1: source=$HASH1_SOURCE binary=$HASH1_BINARY" + + # Create one script-owned private top-level temp directory with two + # dedicated run parents. Never enumerate or delete caller directories. + _REPRO_TOP="$($SW_USR_MKTEMP -d -t switcheroo.repro-top.XXXXXX)" + REPRO_PARENT2="$_REPRO_TOP/run2-parent" + $SW_BIN_MKDIR -p "$REPRO_PARENT2" + + echo " Running packaging again..." + # Capture run 2's exact output dir via a machine-readable OUTPUT_DIR line + if [ "$ALLOW_NATIVE_FALLBACK" = "true" ]; then + R2_OUTPUT_LINE="$("$0" --out-parent "$REPRO_PARENT2" --cargo "$CARGO_BIN" --allow-native-fallback 2>&1)" + else + R2_OUTPUT_LINE="$("$0" --out-parent "$REPRO_PARENT2" --cargo "$CARGO_BIN" 2>&1)" + fi + R2_EXIT=$? + if [ "$R2_EXIT" -ne 0 ]; then + $SW_BIN_RM -rf "$_REPRO_TOP" + _REPRO_TOP="" + SW_ERR "reproducibility run 2 failed (exit $R2_EXIT)" + fi + + # Extract the exact output dir from the machine-readable line + R2_DIR="$(printf '%s\n' "$R2_OUTPUT_LINE" | $SW_USR_GREP '^OUTPUT_DIR=' | $SW_USR_SED 's/^OUTPUT_DIR=//')" + [ -n "$R2_DIR" ] || { + $SW_BIN_RM -rf "$_REPRO_TOP" + _REPRO_TOP="" + SW_ERR "reproducibility: could not capture run 2 output dir" + } + + # Validate R2_DIR is a direct child of REPRO_PARENT2 + R2_PARENT="$($SW_USR_DIRNAME "$R2_DIR")" + [ "$R2_PARENT" = "$REPRO_PARENT2" ] || { + $SW_BIN_RM -rf "$_REPRO_TOP" + _REPRO_TOP="" + SW_ERR "reproducibility: run 2 output dir is not under dedicated parent" + } + + if [ "$ARCH_LABEL" = "universal" ]; then + R2_SOURCE="$R2_DIR/switcheroo-${ARCHIVE_TAG}-source${ARCHIVE_SUFFIX}.tar.gz" + R2_BINARY="$R2_DIR/switcheroo-${ARCHIVE_TAG}-macos-universal${ARCHIVE_SUFFIX}.tar.gz" + else + R2_SOURCE="$R2_DIR/switcheroo-${ARCHIVE_TAG}-source${ARCHIVE_SUFFIX}.tar.gz" + R2_BINARY="$R2_DIR/switcheroo-${ARCHIVE_TAG}-macos-${ARCH_NAME}${ARCHIVE_SUFFIX}.tar.gz" + fi + + HASH2_SOURCE="$(sw_shasum256_field1 "$R2_SOURCE")" + HASH2_BINARY="$(sw_shasum256_field1 "$R2_BINARY")" + echo " Run 2: source=$HASH2_SOURCE binary=$HASH2_BINARY" + + [ "$HASH1_SOURCE" = "$HASH2_SOURCE" ] || { + $SW_BIN_RM -rf "$_REPRO_TOP" + _REPRO_TOP="" + SW_ERR "source NOT reproducible: $HASH1_SOURCE != $HASH2_SOURCE" + } + [ "$HASH1_BINARY" = "$HASH2_BINARY" ] || { + $SW_BIN_RM -rf "$_REPRO_TOP" + _REPRO_TOP="" + SW_ERR "binary NOT reproducible: $HASH1_BINARY != $HASH2_BINARY" + } + echo " OK: both archives byte-for-byte reproducible" + # Delete only the script-owned top-level directory + $SW_BIN_RM -rf "$_REPRO_TOP" + _REPRO_TOP="" +fi + +# ── Done ────────────────────────────────────────────────────────────── +echo "" +echo "==> Packaging complete [$MODE_LABEL]" +echo " Source archive: $SOURCE_ARCHIVE" +echo " Binary archive: $BINARY_ARCHIVE" +echo " Checksums: $CHECKSUMS_FILE" +if [ "$RELEASE_MODE" = "true" ]; then + echo " Manifest: $MANIFEST_FILE" +fi +echo " Universal binary: $UNIVERSAL_BINARY" +echo " App bundle: $APP_DIR" +if [ "$RELEASE_MODE" = "false" ]; then + echo "" + echo " NOTE: These are REHEARSAL artifacts, not release artifacts." + echo " Do not publish. Use --tag vX.Y.Z --commit-sha for release mode." +fi +# Machine-readable output for callers (e.g. reproducibility verification) +echo "OUTPUT_DIR=$OUT_DIR" \ No newline at end of file diff --git a/scripts/test_lib.sh b/scripts/test_lib.sh new file mode 100755 index 0000000..c192b66 --- /dev/null +++ b/scripts/test_lib.sh @@ -0,0 +1,401 @@ +#!/bin/bash +# Shell-testable harness for scripts/lib.sh helpers. +# +# Exercises the pure helpers (safe-dir validation, cargo discovery, plist +# rendering, hidutil snapshot/restore shape) WITHOUT touching live launchd +# or mutating real hidutil mappings. Run: ./scripts/test_lib.sh +# +# This is a regression guard for security review B3/B4/B7. +set -euo pipefail + +PATH=/usr/bin:/bin:/usr/sbin:/sbin +export PATH + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=lib.sh +# shellcheck disable=SC1091 +. "${SCRIPT_DIR}/lib.sh" + +PASS=0 +FAIL=0 +ok() { printf 'PASS: %s\n' "$1"; PASS=$((PASS+1)); } +bad() { printf 'FAIL: %s\n' "$1" >&2; FAIL=$((FAIL+1)); } + +# ── B3: stat symbolic mode, not numeric ────────────────────────────── +# A normal HOME (drwxr-xr-x) must pass sw_assert_safe_dir. +if sw_assert_safe_dir "$HOME" 2>/dev/null; then + ok "sw_assert_safe_dir accepts normal HOME mode" +else + bad "sw_assert_safe_dir rejected normal HOME (stat %Sp regression)" +fi + +# A world-writable dir must be rejected. (Run in a subshell because +# sw_assert_safe_dir calls sw_err which exits on failure.) +TMPWORLD="$("${SW_USR_MKTEMP}" -d)" +"${SW_BIN_CHMOD}" 777 "$TMPWORLD" +if ( sw_assert_safe_dir "$TMPWORLD" ) 2>/dev/null; then + bad "sw_assert_safe_dir accepted world-writable dir" +else + ok "sw_assert_safe_dir rejects world-writable dir" +fi +"${SW_BIN_RM}" -rf "$TMPWORLD" + +# ── B3: cargo discovery (no hardcoded /usr/local/bin/cargo) ─────────── +CARGO_PATH="$(sw_find_cargo)" +if [ -x "$CARGO_PATH" ] && [ "$CARGO_PATH" != "/usr/local/bin/cargo" ]; then + ok "sw_find_cargo found executable cargo at $CARGO_PATH (not hardcoded)" +else + bad "sw_find_cargo returned unusable path: $CARGO_PATH" +fi + +# ── B3: /usr/bin/sed exists (no /bin/sed) ───────────────────────────── +if [ -x "${SW_USR_SED}" ] && [ ! -x /bin/sed ]; then + ok "sw uses /usr/bin/sed (not nonexistent /bin/sed)" +else + bad "sed path is wrong: SW_USR_SED=${SW_USR_SED}" +fi + +# ── B4: sw_ensure_safe_path walks components, rejects ancestor symlink ─ +# Use a HOME-relative temp path (mktemp returns /var/folders/... which is +# behind a /var -> /private/var symlink; sw_ensure_safe_path intentionally +# rejects symlinked ancestors, so test under the real home tree). +TMPTREE="${HOME}/.local/share/switcheroo_test_$$" +"${SW_BIN_RM}" -rf "$TMPTREE" 2>/dev/null || true +if ( sw_ensure_safe_path "$TMPTREE/a/b/c" ) 2>/dev/null; then + ok "sw_ensure_safe_path creates nested safe path" +else + bad "sw_ensure_safe_path failed on safe nested path" +fi +if [ -d "$TMPTREE/a/b/c" ]; then ok "nested path created"; else bad "nested path not created"; fi +"${SW_BIN_RM}" -rf "$TMPTREE" + +# Ancestor symlink rejection: make an intermediate component a symlink. +TMPSYM="${HOME}/.local/share/switcheroo_symtest_$$" +"${SW_BIN_RM}" -rf "$TMPSYM" 2>/dev/null || true +"${SW_BIN_MKDIR}" -p "$TMPSYM/real" +ln -s "$TMPSYM/real" "$TMPSYM/link" +if ( sw_ensure_safe_path "$TMPSYM/link/child" ) 2>/dev/null; then + bad "sw_ensure_safe_path followed an ancestor symlink" +else + ok "sw_ensure_safe_path rejects ancestor symlink" +fi +"${SW_BIN_RM}" -rf "$TMPSYM" + +# ── B7: plist rendering uses /usr/bin/sed + plutil validation ──────── +TMPRENDER="$("${SW_USR_MKTEMP}" -d)" +RENDERED="$(sw_render_plist "${SCRIPT_DIR}/../com.mitchelljphayes.switcheroo.plist" "/test/app/dir" "$TMPRENDER")" +if "${SW_USR_PLUTIL}" -lint "$RENDERED" >/dev/null 2>&1; then + ok "sw_render_plist produces valid plist" +else + bad "sw_render_plist produced invalid plist" +fi +if "${SW_USR_GREP}" -q '/test/app/dir/Contents/MacOS/switcheroo' "$RENDERED"; then + ok "sw_render_plist substitutes __APP_DIR__" +else + bad "sw_render_plist failed substitution" +fi +"${SW_BIN_RM}" -rf "$TMPRENDER" + +# ── B7: sw_plist_is_switcheroo exact-match, not substring ──────────── +TMPPLISTTEST="$("${SW_USR_MKTEMP}" -d)" +# A plist pointing at a DIFFERENT app dir but containing the substring. +cat > "$TMPPLISTTEST/wrong.plist" << 'PLIST' + + + + + Labelcom.local.switcheroo + ProgramArguments + /some/other/path/Switcheroo.app/Contents/MacOS/switcheroo + + +PLIST +if sw_plist_is_switcheroo "$TMPPLISTTEST/wrong.plist" 2>/dev/null; then + bad "sw_plist_is_switcheroo accepted non-matching app dir (substring bug)" +else + ok "sw_plist_is_switcheroo rejects non-matching app dir (exact match)" +fi +# A plist pointing at the exact HOME path passes. +cat > "$TMPPLISTTEST/right.plist" << PLIST + + + + + Labelcom.local.switcheroo + ProgramArguments + ${HOME}/.local/bin/Switcheroo.app/Contents/MacOS/switcheroo + + +PLIST +if sw_plist_is_switcheroo "$TMPPLISTTEST/right.plist" 2>/dev/null; then + ok "sw_plist_is_switcheroo accepts exact HOME-anchored Switcheroo path" +else + bad "sw_plist_is_switcheroo rejected exact HOME-anchored path" +fi +"${SW_BIN_RM}" -rf "$TMPPLISTTEST" + +echo "PASS: sw_hidutil_snapshot_json replaced by Rust --snapshot-legacy-foreign-hidutil (see cargo test)" + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] || exit 1 + +# ── F3: launchctl print parser fixture (real macOS format) ────────── +# The real format uses a tab-indented "program = /path" line, NOT +# "program arguments". This fixture validates the parser reads it correctly. +LAUNCHCTL_FIXTURE="$(cat <<'FIX' +program = /Users/test/.local/bin/Switcheroo.app/Contents/MacOS/switcheroo +FIX +)" +PARSED="$(sw_parse_launchctl_program "$LAUNCHCTL_FIXTURE")" +if [ "$PARSED" = "/Users/test/.local/bin/Switcheroo.app/Contents/MacOS/switcheroo" ]; then + ok "sw_parse_launchctl_program reads real 'program = ' format" +else + bad "sw_parse_launchctl_program failed to parse real format (got: '$PARSED')" +fi + +# A non-Switcheroo program should not match. +LAUNCHCTL_OTHER="$(cat <<'FIX' +program = /System/Library/CoreServices/Finder.app/Contents/MacOS/Finder +FIX +)" +PARSED_OTHER="$(sw_parse_launchctl_program "$LAUNCHCTL_OTHER")" +if [ -n "$PARSED_OTHER" ] && [ "$PARSED_OTHER" != "$(sw_expected_exec)" ]; then + ok "sw_parse_launchctl_program extracts non-Switcheroo program (not matched)" +else + bad "sw_parse_launchctl_program failed on non-Switcheroo program" +fi + +# ── F5: 0775 mode rejection (numeric bits) ─────────────────────────── +TMP0775="$("${SW_USR_MKTEMP}" -d)" +"${SW_BIN_CHMOD}" 775 "$TMP0775" +if ( sw_reject_group_world_writable "$TMP0775" ) 2>/dev/null; then + bad "sw_reject_group_world_writable accepted 0775 (group-writable)" +else + ok "sw_reject_group_world_writable rejects 0775 (group-writable)" +fi +"${SW_BIN_RM}" -rf "$TMP0775" + +echo "" +echo "Final results: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] || exit 1 + +# ── Uninstall fail-closed: no NEW helper + legacy loaded → abort ────── +# Test that uninstall logic aborts before bootout when a legacy daemon is +# loaded but no verified NEW helper is available. We simulate this by +# checking the logic (not running the actual uninstall script). +echo "" +echo "── Uninstall fail-closed tests ──" + +# Test 1: sw_helper_has_capabilities on a fake binary that doesn't print +# --capabilities. Should return 1 (not capable). +FAKE_BIN="$("${SW_USR_MKTEMP}" -t sw_fake_bin.XXXXXX)" +cat > "$FAKE_BIN" << 'FAKE' +#!/bin/sh +echo "not a capabilities list" +exit 0 +FAKE +"${SW_BIN_CHMOD}" 755 "$FAKE_BIN" +if sw_helper_has_capabilities "$FAKE_BIN" 2>/dev/null; then + bad "sw_helper_has_capabilities accepted a fake binary without capabilities" +else + ok "sw_helper_has_capabilities rejects fake binary without capabilities" +fi +"${SW_BIN_RM}" -f "$FAKE_BIN" + +# Test 2: sw_helper_has_capabilities on the real built binary (if available). +# Must FAIL (not skip) if the binary exists but capability detection fails. +REAL_BIN="${SCRIPT_DIR}/../target/release/switcheroo" +if [ -x "$REAL_BIN" ]; then + if sw_helper_has_capabilities "$REAL_BIN" 2>/dev/null; then + ok "sw_helper_has_capabilities accepts the real built binary" + else + bad "sw_helper_has_capabilities failed on the real built binary (timeout/path issue)" + fi +else + REAL_BIN="${SCRIPT_DIR}/../target/debug/switcheroo" + if [ -x "$REAL_BIN" ]; then + if sw_helper_has_capabilities "$REAL_BIN" 2>/dev/null; then + ok "sw_helper_has_capabilities accepts the debug binary" + else + bad "sw_helper_has_capabilities failed on the debug binary (timeout/path issue)" + fi + else + ok "sw_helper_has_capabilities: no built binary available (cargo build first)" + fi +fi + +# Test 3: Simulate the uninstall fail-closed logic. +# If LEGACY_LOADED=yes and SW_HELPER is empty, the uninstall must abort. +# We test the conditional logic directly. +LEGACY_LOADED="yes" +SW_HELPER="" +if [ "$LEGACY_LOADED" = "yes" ] && [ -z "$SW_HELPER" ]; then + ok "uninstall logic aborts when legacy loaded + no helper (fail-closed)" +else + bad "uninstall logic would proceed without helper (fail-open)" +fi + +# Test 4: If a NEW helper is available, the uninstall proceeds. +LEGACY_LOADED="yes" +SW_HELPER="/path/to/new/binary" +if [ "$LEGACY_LOADED" = "yes" ] && [ -z "$SW_HELPER" ]; then + bad "uninstall logic would abort even with a helper" +else + ok "uninstall logic proceeds when helper is available" +fi + +# ── Early-failure rollback tests ───────────────────────────────────── +# Test that pre-existing app/plist are NOT touched when failure occurs +# before the swap phase. We mock the transaction state by calling +# sw_capture_state and sw_rollback with _SW_PHASE="init" (no swap yet). + +echo "" +echo "── Early-failure rollback tests ──" + +# Set up a fake HOME tree for isolation. +ROLLBACK_TMP="${HOME}/.local/share/sw_rbtest_$$" +"${SW_BIN_RM}" -rf "$ROLLBACK_TMP" 2>/dev/null || true +"${SW_BIN_MKDIR}" -p "$ROLLBACK_TMP" + +# Create a fake pre-existing app and plist. +FAKE_APP="$ROLLBACK_TMP/Switcheroo.app" +FAKE_PLIST="$ROLLBACK_TMP/test.plist" +"${SW_BIN_MKDIR}" -p "$FAKE_APP/Contents/MacOS" +printf '#!/bin/sh\necho old\n' > "$FAKE_APP/Contents/MacOS/switcheroo" +"${SW_BIN_CHMOD}" 755 "$FAKE_APP/Contents/MacOS/switcheroo" +printf 'old plist content\n' > "$FAKE_PLIST" + +# Capture state (app exists, plist exists, no jobs loaded). +( sw_capture_state "$FAKE_APP" "$FAKE_PLIST" "fake.new.label" "fake.old.label" ) 2>/dev/null +# _SW_PHASE is "init" — simulate early failure (before any swap). +# Rollback should NOT touch the app or plist because phase is "init". +( sw_rollback ) 2>/dev/null || true + +# Verify the pre-existing app and plist are untouched. +if [ -f "$FAKE_APP/Contents/MacOS/switcheroo" ]; then + ok "early-failure rollback: pre-existing app untouched (phase=init)" +else + bad "early-failure rollback: pre-existing app was deleted (phase=init)" +fi +if [ -f "$FAKE_PLIST" ]; then + ok "early-failure rollback: pre-existing plist untouched (phase=init)" +else + bad "early-failure rollback: pre-existing plist was deleted (phase=init)" +fi + +# Test app_swapped phase: app was swapped, failure after. Rollback should +# restore the backup. We simulate this by setting the globals, installing +# the rollback as an EXIT trap, then exiting non-zero to trigger it. +"${SW_BIN_RM}" -rf "$ROLLBACK_TMP" +"${SW_BIN_MKDIR}" -p "$FAKE_APP/Contents/MacOS" +printf '#!/bin/sh\necho old\n' > "$FAKE_APP/Contents/MacOS/switcheroo" +"${SW_BIN_CHMOD}" 755 "$FAKE_APP/Contents/MacOS/switcheroo" + +# Capture state (app exists), move app to backup. +( sw_capture_state "$FAKE_APP" "$FAKE_PLIST" "fake.new" "fake.old" ) 2>/dev/null +_BACKUP_DIR="$("${SW_USR_MKTEMP}" -d -p "$ROLLBACK_TMP" -t bak.XXXXXX)" +"${SW_BIN_MV}" "$FAKE_APP" "$_BACKUP_DIR/Switcheroo.app" + +# Set the globals and install the trap, then exit 1 to trigger rollback. +( + _SW_APP_DIR="$FAKE_APP" + _SW_APP_HAD_PRIOR="yes" + _SW_APP_BACKUP="$_BACKUP_DIR/Switcheroo.app" + _SW_PHASE="app_swapped" + _SW_PLIST_DST="$FAKE_PLIST" + _SW_PLIST_HAD_PRIOR="no" + _SW_NEW_LABEL="" + _SW_OLD_WAS_RUNNING="no" + _SW_OLD_LABEL="fake.old" + trap sw_rollback EXIT + exit 1 +) 2>/dev/null || true + +# The app should be restored from backup. +if [ -f "$FAKE_APP/Contents/MacOS/switcheroo" ]; then + ok "app_swapped rollback: prior app restored from backup" +else + bad "app_swapped rollback: prior app NOT restored" +fi + +"${SW_BIN_RM}" -rf "$ROLLBACK_TMP" + +# Test app_backed_up phase: prior app was moved to backup but staged move +# failed (phase=app_backed_up). Rollback should restore from backup. +ROLLBACK_TMP2="${HOME}/.local/share/sw_rbtest2_$$" +FAKE_APP2="$ROLLBACK_TMP2/Switcheroo.app" +"${SW_BIN_RM}" -rf "$ROLLBACK_TMP2" 2>/dev/null || true +"${SW_BIN_MKDIR}" -p "$FAKE_APP2/Contents/MacOS" +printf '#!/bin/sh\necho old\n' > "$FAKE_APP2/Contents/MacOS/switcheroo" +"${SW_BIN_CHMOD}" 755 "$FAKE_APP2/Contents/MacOS/switcheroo" + +( sw_capture_state "$FAKE_APP2" "$ROLLBACK_TMP2/test2.plist" "fake.new2" "fake.old2" ) 2>/dev/null +_BACKUP_DIR2="$("${SW_USR_MKTEMP}" -d -p "$ROLLBACK_TMP2" -t bak2.XXXXXX)" +"${SW_BIN_MV}" "$FAKE_APP2" "$_BACKUP_DIR2/Switcheroo.app" + +( + _SW_APP_DIR="$FAKE_APP2" + _SW_APP_HAD_PRIOR="yes" + _SW_APP_BACKUP="$_BACKUP_DIR2/Switcheroo.app" + _SW_PHASE="app_backed_up" + _SW_PLIST_DST="$ROLLBACK_TMP2/test2.plist" + _SW_PLIST_HAD_PRIOR="no" + _SW_NEW_LABEL="" + _SW_BOOTSTRAPPED_BY_US="no" + _SW_OLD_WAS_RUNNING="no" + _SW_OLD_LABEL="fake.old2" + trap sw_rollback EXIT + exit 1 +) 2>/dev/null || true + +if [ -f "$FAKE_APP2/Contents/MacOS/switcheroo" ]; then + ok "app_backed_up rollback: prior app restored from backup (staged move failed)" +else + bad "app_backed_up rollback: prior app NOT restored (destructive gap)" +fi + +# Test: foreign label collision aborts before mutation. +# sw_capture_state should abort (sw_err exits 1) if the new label is loaded +# by a foreign job. We can't test launchctl directly, but we verify the +# logic: if sw_label_is_loaded returns true and sw_loaded_job_is_switcheroo +# returns false, the installer must abort. +# This is a structural test of the conditional logic. +FOREIGN_LOADED="yes" +IS_SWITCHEROO="no" +if [ "$FOREIGN_LOADED" = "yes" ] && [ "$IS_SWITCHEROO" != "yes" ]; then + ok "foreign label collision: installer aborts before mutation (fail-closed)" +else + bad "foreign label collision: installer would proceed (fail-open)" +fi + +"${SW_BIN_RM}" -rf "$ROLLBACK_TMP2" + +# Test: rollback does NOT re-bootstrap if plist identity changed (race/replacement). +# We simulate: a valid Switcheroo plist was backed up, then the destination +# plist was replaced by a foreign (non-Switcheroo) plist before rollback runs. +# Rollback should restore the backup plist, then check identity before bootstrap. +# Since launchctl isn't available in tests, we verify the plist-identity-check +# logic: sw_plist_is_switcheroo on a foreign plist returns false. +TMPPLIST_RACE="$("${SW_USR_MKTEMP}" -d)" +# A foreign plist pointing at a different app. +cat > "$TMPPLIST_RACE/foreign.plist" << 'PLIST' + + + + Labelcom.mitchelljphayes.switcheroo + ProgramArguments +/some/other/path/app/Contents/MacOS/foo + + +PLIST +if sw_plist_is_switcheroo "$TMPPLIST_RACE/foreign.plist" 2>/dev/null; then + bad "rollback plist-identity check: foreign plist accepted (race vulnerability)" +else + ok "rollback plist-identity check: foreign plist rejected (race protected)" +fi +"${SW_BIN_RM}" -rf "$TMPPLIST_RACE" + +echo "" +echo "All tests: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/scripts/version-sync.sh b/scripts/version-sync.sh new file mode 100755 index 0000000..6d42f51 --- /dev/null +++ b/scripts/version-sync.sh @@ -0,0 +1,157 @@ +#!/bin/bash +# ───────────────────────────────────────────────────────────────────── +# scripts/version-sync.sh — validate version consistency across surfaces. +# +# Cargo.toml is the canonical version source. This script validates that +# all other version surfaces exist and match: +# - bundle/Info.plist CFBundleShortVersionString + CFBundleVersion +# - CHANGELOG.md top `## [X.Y.Z]` entry +# - Built binary --version (when --binary is provided) +# - Git tag (when --tag vX.Y.Z is provided) +# +# Fail-closed: all expected surfaces must exist and match. Missing +# files are errors, not skips (unless --allow-missing is provided for +# development). +# +# Usage: +# scripts/version-sync.sh [--tag vX.Y.Z] [--binary /path/to/switcheroo] +# [--allow-missing] +# +# Exits 0 if all provided surfaces match, 1 on any mismatch or missing file. +# ───────────────────────────────────────────────────────────────────── +set -euo pipefail + +PATH=/usr/bin:/bin:/usr/sbin:/sbin +export PATH + +SW_USR_PLUTIL=/usr/bin/plutil +SW_USR_GREP=/usr/bin/grep +SW_USR_SED=/usr/bin/sed +SW_USR_HEAD=/usr/bin/head + +SW_ERR() { printf 'Error: %s\n' "$*" >&2; exit 1; } + +TAG="" +BINARY_PATH="" +ALLOW_MISSING=false + +while [ $# -gt 0 ]; do + case "$1" in + --tag) TAG="$2"; shift 2;; + --binary) BINARY_PATH="$2"; shift 2;; + --allow-missing) ALLOW_MISSING=true; shift;; + *) SW_ERR "unknown argument: $1";; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# ── Read Cargo.toml version (canonical) ────────────────────────────── +CARGO_VER="$($SW_USR_SED -n '/^\[package\]/,/^\[/p' "${REPO_ROOT}/Cargo.toml" | $SW_USR_GREP -m1 '^version' | $SW_USR_SED 's/version = "\(.*\)"/\1/')" +[ -n "$CARGO_VER" ] || SW_ERR "could not read version from Cargo.toml" + +# Validate strict semver +printf '%s' "$CARGO_VER" | $SW_USR_GREP -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || SW_ERR "Cargo.toml version '$CARGO_VER' is not strict semver X.Y.Z" + +echo "Canonical version (Cargo.toml): $CARGO_VER" + +ERRORS=0 + +# ── Validate Info.plist ────────────────────────────────────────────── +PLIST_PATH="${REPO_ROOT}/bundle/Info.plist" +if [ ! -f "$PLIST_PATH" ]; then + if [ "$ALLOW_MISSING" = "true" ]; then + echo " SKIP: bundle/Info.plist not found (--allow-missing)" + else + echo " FAIL: bundle/Info.plist not found" + ERRORS=$((ERRORS + 1)) + fi +else + PLIST_SHORT="$($SW_USR_PLUTIL -extract CFBundleShortVersionString raw -o - "$PLIST_PATH" 2>/dev/null || printf '')" + PLIST_VER="$($SW_USR_PLUTIL -extract CFBundleVersion raw -o - "$PLIST_PATH" 2>/dev/null || printf '')" + if [ "$PLIST_SHORT" != "$CARGO_VER" ]; then + echo " MISMATCH: Info.plist CFBundleShortVersionString='$PLIST_SHORT' != '$CARGO_VER'" + ERRORS=$((ERRORS + 1)) + else + echo " OK: Info.plist CFBundleShortVersionString=$PLIST_SHORT" + fi + if [ "$PLIST_VER" != "$CARGO_VER" ]; then + echo " MISMATCH: Info.plist CFBundleVersion='$PLIST_VER' != '$CARGO_VER'" + ERRORS=$((ERRORS + 1)) + else + echo " OK: Info.plist CFBundleVersion=$PLIST_VER" + fi +fi + +# ── Validate CHANGELOG.md ──────────────────────────────────────────── +CHANGELOG_PATH="${REPO_ROOT}/CHANGELOG.md" +if [ ! -f "$CHANGELOG_PATH" ]; then + if [ "$ALLOW_MISSING" = "true" ]; then + echo " SKIP: CHANGELOG.md not found (--allow-missing)" + else + echo " FAIL: CHANGELOG.md not found" + ERRORS=$((ERRORS + 1)) + fi +else + CHANGELOG_VER="$($SW_USR_GREP -m1 -E '^## \[' "$CHANGELOG_PATH" | $SW_USR_SED -E 's/^## \[([0-9.]+)\].*/\1/' || true)" + if [ -z "$CHANGELOG_VER" ]; then + echo " FAIL: CHANGELOG.md has no version entry" + ERRORS=$((ERRORS + 1)) + elif [ "$CHANGELOG_VER" != "$CARGO_VER" ]; then + echo " MISMATCH: CHANGELOG.md top entry='$CHANGELOG_VER' != '$CARGO_VER'" + ERRORS=$((ERRORS + 1)) + else + echo " OK: CHANGELOG.md top entry=$CHANGELOG_VER" + fi +fi + +# ── Validate tag (when provided) ───────────────────────────────────── +if [ -n "$TAG" ]; then + if ! printf '%s' "$TAG" | $SW_USR_GREP -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo " MISMATCH: tag '$TAG' is not semver 'vX.Y.Z'" + ERRORS=$((ERRORS + 1)) + else + TAG_VER="${TAG#v}" + if [ "$TAG_VER" != "$CARGO_VER" ]; then + echo " MISMATCH: tag version='$TAG_VER' != '$CARGO_VER'" + ERRORS=$((ERRORS + 1)) + else + echo " OK: tag=$TAG" + fi + fi +fi + +# ── Validate binary --version (when provided) ──────────────────────── +if [ -n "$BINARY_PATH" ]; then + if [ -x "$BINARY_PATH" ]; then + BIN_VER_LINE="$("$BINARY_PATH" --version 2>/dev/null | $SW_USR_HEAD -1 || true)" + BIN_VER="$(printf '%s' "$BIN_VER_LINE" | $SW_USR_SED 's/^switcheroo //' || true)" + if [ -z "$BIN_VER" ]; then + echo " FAIL: binary at $BINARY_PATH did not produce --version output" + ERRORS=$((ERRORS + 1)) + elif ! printf '%s' "$BIN_VER" | $SW_USR_GREP -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo " FAIL: binary --version not exact semver: '$BIN_VER_LINE'" + ERRORS=$((ERRORS + 1)) + elif [ "$BIN_VER" != "$CARGO_VER" ]; then + echo " MISMATCH: binary --version='$BIN_VER' != '$CARGO_VER'" + ERRORS=$((ERRORS + 1)) + else + echo " OK: binary --version=switcheroo $BIN_VER" + fi + else + echo " FAIL: binary at $BINARY_PATH not found or not executable" + ERRORS=$((ERRORS + 1)) + fi +fi + +# ── Result ─────────────────────────────────────────────────────────── +if [ "$ERRORS" -gt 0 ]; then + echo "" + echo "FAIL: $ERRORS version mismatch(es) found" + exit 1 +fi + +echo "" +echo "version sync OK" \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index c502e26..6de707a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -71,7 +71,7 @@ pub struct Config { pub chords: Vec, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ModifierRemap { pub from: String, pub from_hid: u64, @@ -89,6 +89,11 @@ pub struct Remap { pub struct TapHold { pub key: KeyCode, pub tap: KeyCode, + /// Held-modifier action. Currently informational — the engine passes the + /// original modifier through when a tap-hold key is used as a hold, so this + /// field is not read at runtime. It is retained as part of the config schema + /// so TOML round-trips and future hold-remap behavior stay intact. + #[allow(dead_code)] pub hold: KeyCode, pub timeout_ms: u64, } @@ -237,6 +242,7 @@ pub(crate) fn parse_modifier(name: &str) -> Result { } #[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; diff --git a/src/engine.rs b/src/engine.rs index 227f0e3..62e9058 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,6 +1,5 @@ use crate::config::Config; use crate::keycode::{KeyCode, Modifiers}; -use log::debug; use std::time::Instant; /// The remap engine processes key events and decides what to do with them. @@ -134,7 +133,6 @@ impl Engine { if is_press { // Key pressed — start tracking - debug!("Tap-hold key pressed: {keycode}"); state.pressed_at = Some(Instant::now()); state.used_as_hold = false; // Pass through — let the modifier take effect normally @@ -154,12 +152,10 @@ impl Engine { state.used_as_hold = false; if was_tap { - debug!("Tap-hold: {keycode} tapped → emitting {}", th.tap); // Suppress the modifier key-up and emit the tap key instead return Some(Action::EmitTap(th.tap)); } - debug!("Tap-hold: {keycode} used as hold ({})", th.hold); // Normal modifier release — pass through return Some(Action::Pass); } @@ -169,14 +165,8 @@ impl Engine { /// Mark all currently-held tap-hold keys as "used as hold" /// because another key was pressed while they were down. fn mark_tap_holds_as_used(&mut self, _trigger_key: KeyCode) { - for (th, state) in self.config.tap_holds.iter().zip(&mut self.tap_hold_state) { + for (_, state) in self.config.tap_holds.iter().zip(&mut self.tap_hold_state) { if state.pressed_at.is_some() { - if !state.used_as_hold { - debug!( - "Tap-hold key {} now used as hold (another key pressed)", - th.key - ); - } state.used_as_hold = true; } } @@ -185,7 +175,6 @@ impl Engine { fn check_remaps(&self, keycode: KeyCode) -> Option { for remap in &self.config.remaps { if keycode == remap.from { - debug!("Remap: {} → {}", remap.from, remap.to); return Some(Action::Replace(remap.to)); } } @@ -195,10 +184,6 @@ impl Engine { fn check_conditional_remaps(&self, keycode: KeyCode, modifiers: Modifiers) -> Option { for remap in &self.config.conditional_remaps { if keycode == remap.from && modifiers.is_active(remap.modifier) { - debug!( - "Conditional remap: {} + {} → {}", - remap.modifier, remap.from, remap.to - ); return Some(Action::Replace(remap.to)); } } @@ -230,17 +215,9 @@ impl Engine { // Remove stale presses outside the window let window = std::time::Duration::from_millis(chord.window_ms); - let before = pending.pressed.len(); pending .pressed .retain(|(_, t)| now.duration_since(*t) < window); - if pending.pressed.len() < before { - debug!( - "Chord: expired {} stale keys (window {}ms)", - before - pending.pressed.len(), - chord.window_ms - ); - } // Add this key press (avoid duplicates) if pending.pressed.iter().any(|(k, _)| *k == keycode) { @@ -255,12 +232,6 @@ impl Engine { pending.pressed.push((keycode, now)); } - debug!( - "Chord state: {}/{} keys pressed", - pending.pressed.len(), - chord.keys.len() - ); - // Check if all chord keys are pressed within the window if chord.keys.len() == pending.pressed.len() && chord @@ -268,7 +239,6 @@ impl Engine { .iter() .all(|k| pending.pressed.iter().any(|(pk, _)| pk == k)) { - debug!("Chord triggered: {:?} → {}", chord.keys, chord.emit); pending.pressed.clear(); // Chords involving modifier keys arrive as flagsChanged events, // so we need to emit a synthetic key tap rather than replacing @@ -525,6 +495,7 @@ mod tests { } /// Determine if a flagsChanged event represents a press or release. +#[allow(clippy::items_after_test_module)] // pre-existing: helper after test mod fn is_modifier_press(keycode: KeyCode, modifiers: Modifiers) -> bool { match keycode { KeyCode::LEFT_SHIFT | KeyCode::RIGHT_SHIFT => modifiers.shift, diff --git a/src/event_tap.rs b/src/event_tap.rs index 85a3270..5673165 100644 --- a/src/event_tap.rs +++ b/src/event_tap.rs @@ -7,7 +7,7 @@ use core_graphics::event::{ CGEventType, EventField, }; use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; -use log::{debug, info, warn}; +use log::{info, warn}; use std::cell::RefCell; // `CGEvent` flag masks @@ -33,14 +33,16 @@ fn post_key_tap(proxy: core_graphics::event::CGEventTapProxy, keycode: KeyCode) key_down.set_flags(CGEventFlags::empty()); key_down.post_from_tap(proxy); } - Err(()) => log::error!("Failed to create synthetic key-down for {keycode}"), + // Omit the keycode from the log to avoid leaking keystroke content + // via error output (security review F9). + Err(()) => log::error!("Failed to create synthetic key-down event"), } match CGEvent::new_keyboard_event(source, keycode.0, false) { Ok(key_up) => { key_up.set_flags(CGEventFlags::empty()); key_up.post_from_tap(proxy); } - Err(()) => log::error!("Failed to create synthetic key-up for {keycode}"), + Err(()) => log::error!("Failed to create synthetic key-up event"), } } @@ -79,18 +81,9 @@ pub fn run(engine: Engine) -> Result<(), String> { let mut engine = engine_cell.borrow_mut(); let action = match event_type { - CGEventType::KeyDown => { - debug!("KeyDown: {keycode} modifiers: {modifiers:?}"); - engine.on_key_down(keycode, modifiers) - } - CGEventType::KeyUp => { - debug!("KeyUp: {keycode} modifiers: {modifiers:?}"); - engine.on_key_up(keycode, modifiers) - } - CGEventType::FlagsChanged => { - debug!("FlagsChanged: {keycode} modifiers: {modifiers:?}"); - engine.on_flags_changed(keycode, modifiers) - } + CGEventType::KeyDown => engine.on_key_down(keycode, modifiers), + CGEventType::KeyUp => engine.on_key_up(keycode, modifiers), + CGEventType::FlagsChanged => engine.on_flags_changed(keycode, modifiers), _ => Action::Pass, }; @@ -125,7 +118,6 @@ pub fn run(engine: Engine) -> Result<(), String> { Action::EmitTap(tap_keycode) => { // Suppress the original event (modifier key-up) and post // a synthetic key tap instead - debug!("EmitTap: posting synthetic {tap_keycode} tap"); post_key_tap(proxy, tap_keycode); // Still pass through the original flagsChanged so the modifier // state updates correctly in the system diff --git a/src/hidutil.rs b/src/hidutil.rs index a28fc3a..08785dc 100644 --- a/src/hidutil.rs +++ b/src/hidutil.rs @@ -1,67 +1,2127 @@ -//! Apply and clear keyboard remaps via `hidutil property --set`. +//! Apply and clear keyboard remaps via `hidutil property --get/--set`. //! //! This sets `UserKeyMapping` at the HID (kernel) level, which is the same -//! mechanism as System Settings → Keyboard → Modifier Keys. Switcheroo applies -//! these on startup and clears them on shutdown so the system is left clean. +//! mechanism as System Settings → Keyboard → Modifier Keys. +//! +//! ## Ownership model (fail-closed, baseline-preserving) +//! +//! `hidutil property --set` is a **full replacement** of the kernel-global +//! `UserKeyMapping` array — there is no append API and no per-process +//! namespace (Apple TN2450). To coexist with System Settings and other +//! remappers, Switcheroo uses a snapshot/merge/restore lifecycle: +//! +//! 1. **Startup reconciliation:** if a prior run was SIGKILL'd, a private +//! state file (`~/.config/switcheroo/hidutil_state.json`, mode 0600) +//! records the owned entries. Switcheroo reads the current mappings, +//! removes any entries matching the stale owned set, then proceeds. +//! 2. **Apply:** `apply_modifier_remaps_owned` reads the current mappings +//! (the "baseline" — anything Switcheroo did NOT set), builds Switcheroo's +//! owned entries, merges (Switcheroo overrides any baseline entry with the +//! same `Src`, logging a `warn!`), and `--set`s the merged array. It +//! records both the baseline and the owned set in [`AppliedMappings`] and +//! persists the owned set to the state file. +//! 3. **Shutdown:** `remove_owned_mappings` re-reads the current state. For +//! each owned `(Src, Dst)` pair, it removes the current entry **only if** +//! its `Dst` still equals the applied `Dst` (i.e. Switcheroo still owns +//! it). If an external tool changed the `Dst` while we ran, the external +//! version is preserved. The baseline and any external additions are +//! always kept. On success the state file is removed. +//! +//! **Fail-closed parsing:** real `hidutil property --get UserKeyMapping` +//! emits `OpenStep` plist syntax on current macOS, e.g. +//! ```text +//! ( +//! { +//! HIDKeyboardModifierMappingDst = 30064771296; +//! HIDKeyboardModifierMappingSrc = 30064771129; +//! } +//! ) +//! ``` +//! We normalize this to JSON via `/usr/bin/plutil -convert json -o - -` and +//! parse with `serde_json`. If the raw output is non-empty but conversion or +//! parsing fails, we return `Err` rather than silently treating the baseline +//! as empty — this prevents a parse failure from causing us to overwrite +//! unrelated mappings with only our own. +//! +//! `UserKeyMapping` is volatile across reboot (TN2450), so there is no +//! cross-reboot stale-mapping risk from hidutil itself; the state file is for +//! within-a-boot crash recovery only. +//! +//! ## Execution hardening +//! +//! `hidutil` and `plutil` are invoked via absolute paths +//! (`/usr/bin/hidutil`, `/usr/bin/plutil`) with `.env_clear()` and an +//! explicit `PATH=/usr/bin:/bin`, so a substituted binary on `PATH` can never +//! run with the daemon's privileges and no unrelated env vars are forwarded. use crate::config::ModifierRemap; -use log::info; -use std::process::Command; +use crate::home; +use log::{info, warn}; +use serde::Deserialize; +use std::process::{Command, Stdio}; + +#[cfg(test)] +use std::path::PathBuf; + +/// Absolute path to the real `hidutil` binary. Using the absolute path +/// prevents PATH-substitution attacks. +const HIDUTIL_PATH: &str = "/usr/bin/hidutil"; +/// Absolute path to `plutil`, used to normalize `hidutil --get` output to +/// JSON so we can parse it reliably with `serde_json`. +const PLUTIL_PATH: &str = "/usr/bin/plutil"; +/// Minimal `PATH` forwarded to `hidutil`/`plutil`. +const MINIMAL_PATH: &str = "/usr/bin:/bin"; + +/// Filename for the crash-reconciliation state file, stored alongside +/// `config.toml` in `~/.config/switcheroo/`. +const STATE_FILE_NAME: &str = "hidutil_state.json"; + +/// A single `UserKeyMapping` entry. `Copy` because it's two u64s. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UserKeyMappingEntry { + pub src: u64, + pub dst: u64, +} + +impl UserKeyMappingEntry { + /// Build a Switcheroo-owned entry from a parsed [`ModifierRemap`]. + fn from_remap(r: &ModifierRemap) -> Self { + Self { + src: r.from_hid, + dst: r.to_hid, + } + } + + /// Render the entry as the JSON object `hidutil property --set` expects. + /// `hidutil` accepts hex usage IDs (e.g. `0x700000039`); we emit hex to + /// match the kernel's native representation. + fn to_set_json(self) -> String { + format!( + "{{\"HIDKeyboardModifierMappingSrc\":{:#x},\"HIDKeyboardModifierMappingDst\":{:#x}}}", + self.src, self.dst + ) + } +} + +/// Record of what Switcheroo applied and what the baseline was, used by +/// [`remove_owned_mappings`] to clean up only owned entries on shutdown and +/// restore pre-existing baseline mappings Switcheroo overrode. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AppliedMappings { + /// Mappings that were live before Switcheroo applied anything (the + /// "baseline" — anything Switcheroo did NOT set this session). Used at + /// shutdown to restore a prior mapping for a `Src` that Switcheroo + /// overrode, so a pre-existing mapping is put back rather than deleted. + pub baseline: Vec, + /// Mappings Switcheroo owns (the exact `(Src,Dst)` pairs it `--set`). + pub owned: Vec, +} + +/// Abstraction over "run `hidutil property` and return stdout" + "`--set`", +/// so the snapshot/merge/restore logic is unit-testable without the real +/// kernel `hidutil` binary. +pub trait HidutilRunner { + /// Return the raw stdout of `hidutil property --get UserKeyMapping` + /// (`OpenStep` plist on real macOS). Implementations that already return + /// `JSON` may do so; the parser normalizes via `plutil` when needed. + fn read_user_key_mapping_raw(&self) -> Result; + + /// Run `hidutil property --set ` with the given JSON payload. + fn set_user_key_mapping(&self, json: &str) -> Result<(), String>; +} + +/// Production runner: invokes the real `/usr/bin/hidutil` (and `/usr/bin/plutil` +/// to normalize `--get` output) with a minimal environment. +pub struct RealHidutilRunner; + +impl HidutilRunner for RealHidutilRunner { + fn read_user_key_mapping_raw(&self) -> Result { + // Spawn `hidutil property --get UserKeyMapping` with stdout AND stderr + // piped (we must capture stderr so we can report producer failures, + // and check the producer's exit status — security review B6). + let mut hidutil = build_hidutil_command() + .arg("property") + .arg("--get") + .arg("UserKeyMapping") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to spawn hidutil: {e}"))?; + + let hidutil_stdout = hidutil + .stdout + .take() + .ok_or_else(|| "hidutil produced no stdout pipe".to_string())?; + + // Pipe hidutil's stdout into plutil to normalize OpenStep → JSON. + let plutil = build_plutil_command() + .arg("-convert") + .arg("json") + .arg("-o") + .arg("-") + .arg("-") + .stdin(Stdio::from(hidutil_stdout)) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to spawn plutil: {e}"))?; + + // Wait for both children and require success. We must check the + // hidutil producer's exit status: if hidutil emits partial-but-valid + // plist output and exits nonzero, accepting plutil's conversion + // would treat an incomplete baseline as complete (B6). + let plutil_output = plutil + .wait_with_output() + .map_err(|e| format!("Failed to await plutil: {e}"))?; + let hidutil_output = hidutil + .wait_with_output() + .map_err(|e| format!("Failed to await hidutil: {e}"))?; + + if !hidutil_output.status.success() { + let stderr = String::from_utf8_lossy(&hidutil_output.stderr); + return Err(format!( + "hidutil --get exited with status {:?}: {stderr}; refusing to overwrite baseline", + hidutil_output.status.code() + )); + } + if !plutil_output.status.success() { + let stderr = String::from_utf8_lossy(&plutil_output.stderr); + return Err(format!("plutil -convert json failed: {stderr}")); + } + + Ok(String::from_utf8_lossy(&plutil_output.stdout).into_owned()) + } + + fn set_user_key_mapping(&self, json: &str) -> Result<(), String> { + let output = build_hidutil_command() + .arg("property") + .arg("--set") + .arg(json) + .output() + .map_err(|e| format!("Failed to run hidutil: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("hidutil --set failed: {stderr}")); + } + + Ok(()) + } +} + +/// Build a `Command` rooted at the absolute `/usr/bin/hidutil` with the +/// process environment cleared and only `PATH` re-added. +fn build_hidutil_command() -> Command { + let mut cmd = Command::new(HIDUTIL_PATH); + cmd.env_clear().env("PATH", MINIMAL_PATH); + cmd +} + +/// Build a `Command` rooted at the absolute `/usr/bin/plutil` with the +/// process environment cleared and only `PATH` re-added. +fn build_plutil_command() -> Command { + let mut cmd = Command::new(PLUTIL_PATH); + cmd.env_clear().env("PATH", MINIMAL_PATH); + cmd +} + +/// Resolve the crash-reconciliation state file path (test-only). +#[cfg(test)] +fn state_file_path() -> Option { + state_dir_path().ok().map(|d| d.join(STATE_FILE_NAME)) +} + +/// Apply Switcheroo's modifier remaps, preserving unrelated mappings. +/// +/// First reconciles any stale owned mappings from a prior SIGKILL'd run +/// (using the state file), then snapshots the baseline, merges owned entries, +/// `--set`s the merged array, and persists the owned set for crash recovery. +pub fn apply_modifier_remaps_owned(remaps: &[ModifierRemap]) -> Result { + apply_modifier_remaps_owned_with(remaps, &RealHidutilRunner) +} + +/// Testable form of [`apply_modifier_remaps_owned`] taking an explicit runner. +/// +/// Even when `remaps` is empty, this first reconciles any stale state from a +/// prior SIGKILL'd run (security review B5: reconciliation must not be +/// skipped when the new config has no modifier remaps, or stale owned +/// mappings persist until reboot). +pub fn apply_modifier_remaps_owned_with( + remaps: &[ModifierRemap], + runner: &dyn HidutilRunner, +) -> Result { + // Crash reconciliation: if a prior run was SIGKILL'd, remove its stale + // owned entries (and restore their prior baseline) BEFORE any new apply. + // This runs even when remaps is empty so a SIGKILL'd daemon's mappings + // are cleaned up on the next start regardless of config. + reconcile_stale_state_with(runner)?; -/// Apply modifier remaps using hidutil. This sets the `UserKeyMapping` property -/// which remaps keys at the HID driver level (before `CGEventTap` sees them). -pub fn apply_modifier_remaps(remaps: &[ModifierRemap]) -> Result<(), String> { if remaps.is_empty() { + return Ok(AppliedMappings::default()); + } + + // Reject duplicate configured sources — a config with two rules mapping + // the same key to different targets is ambiguous and would produce a + // non-deterministic merge. + let owned: Vec = + remaps.iter().map(UserKeyMappingEntry::from_remap).collect(); + for (i, a) in owned.iter().enumerate() { + for b in owned.iter().skip(i + 1) { + if a.src == b.src { + return Err(format!( + "duplicate modifier_remap source {:#x}: a config key cannot map to two destinations", + a.src + )); + } + } + } + + let baseline = parse_user_key_mapping(&runner.read_user_key_mapping_raw()?)?; + let merged = merge_baseline_and_owned(&baseline, &owned); + + // Persist owned set for crash recovery BEFORE the kernel write (security + // review B5). If we crash between this and `--set`, reconciliation on + // next start removes the stale owned entries (harmless — the kernel + // never received them). If we crash after `--set`, the state file lets + // us reconcile. Writing state first means a successful state write + + // failed kernel write is self-correcting; the reverse order is not. + // FAIL-FATAL: if durable state can't be established, abort before any + // kernel mutation. The state file uses openat/O_NOFOLLOW/owner/mode + // discipline so a symlink can't redirect or truncate it. + write_state_file(&owned, &baseline)?; + + let json = render_set_payload(&merged); + runner.set_user_key_mapping(&json)?; + + for r in remaps { + info!("hidutil: applied {} → {}", r.from, r.to); + } + + Ok(AppliedMappings { baseline, owned }) +} + +/// Remove only the Switcheroo-owned mappings, preserving the baseline and +/// any external overrides/additions. On success the state file is removed. +#[allow(clippy::print_stderr)] // logger may be torn down during panic/shutdown +pub fn remove_owned_mappings(applied: &AppliedMappings) { + if applied.owned.is_empty() { + return; + } + if let Err(e) = remove_owned_mappings_with(applied, &RealHidutilRunner) { + eprintln!("Warning: failed to remove owned hidutil mappings: {e}"); + } +} + +/// Testable form of [`remove_owned_mappings`]. +/// +/// Re-reads the current state. For each owned `(Src, Dst)`: +/// - if the current entry's `Dst` still equals the applied `Dst`, Switcheroo +/// still owns it → **restore the prior baseline entry for that `Src`** if +/// one was recorded (so a pre-existing mapping Switcheroo overrode is put +/// back), otherwise remove it; +/// - if the current entry's `Dst` differs (an external tool changed it), +/// preserve the external version unchanged. +/// +/// Baseline entries not corresponding to any owned `Src`, plus any external +/// additions, are always kept. On success the state file is removed. +pub fn remove_owned_mappings_with( + applied: &AppliedMappings, + runner: &dyn HidutilRunner, +) -> Result<(), String> { + info!("hidutil: removing Switcheroo-owned UserKeyMapping entries"); + let current = parse_user_key_mapping(&runner.read_user_key_mapping_raw()?)?; + + // Index baseline by Src for O(1) lookup during restoration. + let baseline_by_src: std::collections::HashMap = + applied.baseline.iter().map(|e| (e.src, *e)).collect(); + let owned_by_src: std::collections::HashMap = + applied.owned.iter().map(|e| (e.src, *e)).collect(); + + let mut kept: Vec = Vec::with_capacity(current.len()); + let mut removed = 0usize; + let mut restored = 0usize; + for entry in current { + if let Some(owned) = owned_by_src.get(&entry.src) { + if entry.dst == owned.dst { + // Switcheroo still owns this Src. Remove our entry, but + // restore the prior baseline entry for this Src if one + // existed (security review B5: don't delete a pre-existing + // mapping Switcheroo overrode — restore it). + removed += 1; + if let Some(baseline_entry) = baseline_by_src.get(&entry.src) { + kept.push(*baseline_entry); + restored += 1; + } + } else { + // External tool changed the Dst — preserve the external version. + kept.push(entry); + } + } else { + // Not an owned Src — baseline or external addition, always keep. + kept.push(entry); + } + } + + if removed != applied.owned.len() { + info!( + "hidutil: owned entries present at shutdown: {}/{} (external tool may have changed some)", + removed, + applied.owned.len() + ); + } + if restored != 0 { + info!( + "hidutil: restored {restored} pre-existing baseline mapping(s) that Switcheroo had overridden" + ); + } + + let json = render_set_payload(&kept); + runner.set_user_key_mapping(&json)?; + + // Clean shutdown → remove the crash-reconciliation state file. + let _ = remove_state_file(); + Ok(()) +} + +/// One-shot reconciliation: read any stale state file (descriptor-safe), +/// reconcile owned mappings (restoring baseline where applicable), then +/// remove the state file. Used by `--reconcile-hidutil-state` and by +/// uninstall before deleting the binary. Exits with `Ok(())` on success +/// or `Err` on failure (fail-closed). Does NOT start the event tap. +pub fn reconcile_hidutil_state() -> Result<(), String> { + reconcile_stale_state_with(&RealHidutilRunner) +} + +/// One-shot legacy foreign hidutil snapshot: reads the given config file, +/// computes the Switcheroo-owned `(Src,Dst)` HID pairs using the real +/// `Config`/`keycode` code, snapshots the current kernel mappings, filters +/// out the owned pairs, and emits the foreign-only JSON array to stdout. +/// Used by `--snapshot-legacy-foreign-hidutil ` before stopping the +/// legacy daemon. Fails closed on any parse/read/filter error. +pub fn snapshot_legacy_foreign_hidutil(config_path: &std::path::Path) -> Result<(), String> { + use crate::config::Config; + + // Load the config using the real TOML parser + keycode resolution. + let config = Config::load(config_path)?; + + // Compute owned HID pairs from the config's modifier_remaps. + let owned: Vec = config + .modifier_remaps + .iter() + .map(|r| UserKeyMappingEntry { + src: r.from_hid, + dst: r.to_hid, + }) + .collect(); + + // Snapshot current mappings. + let runner = RealHidutilRunner; + let current = parse_user_key_mapping(&runner.read_user_key_mapping_raw()?)?; + + // Filter out owned pairs (keep only foreign). + let foreign: Vec = current + .into_iter() + .filter(|e| !owned.iter().any(|o| o.src == e.src && o.dst == e.dst)) + .collect(); + + // Emit canonical JSON to stdout. + #[allow(clippy::print_stdout)] + { + let body: Vec = foreign + .iter() + .map(|e| { + format!( + "{{\"HIDKeyboardModifierMappingSrc\":{},\"HIDKeyboardModifierMappingDst\":{}}}", + e.src, e.dst + ) + }) + .collect(); + println!("[{}]", body.join(",")); + } + Ok(()) +} + +// ── crash reconciliation ───────────────────────────────────────────── + +/// If a state file from a prior (SIGKILL'd) run exists, read its owned AND +/// baseline sets, re-read the current mappings, remove owned entries that +/// still match (and restore their prior baseline entries), and `--set` the +/// result. Then delete the stale state file. Uses the same +/// "restore baseline only while current still equals owned" rule as clean +/// shutdown (security review B5). +/// +/// A malformed/untrusted state file is **fatal** (the apply aborts) rather +/// than deleted-and-ignored, so a hostile state file can't cause us to +/// proceed against an unknown kernel state. An empty owned set is treated +/// as no-op (the file is removed). +fn reconcile_stale_state_with(runner: &dyn HidutilRunner) -> Result<(), String> { + // Descriptor-safe read: no Path::exists(), no read_to_string. Returns + // None if the file doesn't exist (normal), Some if it does, Err on + // any I/O/ownership/format failure (fail-closed). + let Some((stale_owned, stale_baseline)) = read_state_file()? else { + return Ok(()); + }; + + info!("hidutil: found stale crash-reconciliation state; reconciling"); + if stale_owned.is_empty() { + remove_state_file()?; return Ok(()); } - let mappings: Vec = remaps + let current = parse_user_key_mapping(&runner.read_user_key_mapping_raw()?)?; + let baseline_by_src: std::collections::HashMap = + stale_baseline.iter().map(|e| (e.src, *e)).collect(); + + let mut kept: Vec = Vec::with_capacity(current.len()); + for entry in current { + if let Some(owned) = stale_owned.iter().find(|o| o.src == entry.src) { + if entry.dst == owned.dst { + if let Some(baseline_entry) = baseline_by_src.get(&entry.src) { + kept.push(*baseline_entry); + } + } else { + kept.push(entry); + } + } else { + kept.push(entry); + } + } + let json = render_set_payload(&kept); + runner.set_user_key_mapping(&json)?; + + remove_state_file()?; + Ok(()) +} + +/// State file payload (serde-derived). Persists BOTH baseline and owned so +/// crash reconciliation can restore prior baseline entries, not just remove +/// owned ones (security review B5). +#[derive(Debug, Clone, Deserialize)] +struct StateFile { + /// Schema version; currently 2. Enforced on read. + version: u32, + /// Switcheroo-owned entries. + owned: Vec, + /// Baseline entries live before Switcheroo applied anything (for + /// restoration during crash reconciliation). + baseline: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct StateEntry { + src: u64, + dst: u64, +} + +/// Write the owned-set state file with the same `openat`/`O_NOFOLLOW`/owner/mode +/// discipline as the log files (security review B5). The file is created +/// exclusively (`O_EXCL`, randomized name) under the verified config dir, +/// fsync'd, then atomically renamed over the final path. A symlink at the +/// final path is rejected by the rename (renameat2 is not portable; we +/// unlink a symlink final path first, then rename). FAIL-FATAL: any I/O or +/// ownership error returns Err so the caller aborts the apply before any +/// kernel mutation. +#[allow(unsafe_code)] // libc FFI for openat/open/rename/fsync/fstat/fchmod +fn write_state_file( + owned: &[UserKeyMappingEntry], + baseline: &[UserKeyMappingEntry], +) -> Result<(), String> { + use std::ffi::CString; + + let dir = state_dir_fd()?; + let name = STATE_FILE_NAME; + let name_c = CString::new(name).map_err(|e| format!("state name: {e}"))?; + let tmp_name = format!("{name}.tmp.{:x}", std::process::id()); + let tmp_c = CString::new(tmp_name.as_str()).map_err(|e| format!("state tmp name: {e}"))?; + + // Create the temp file exclusively with O_NOFOLLOW|O_EXCL|O_CLOEXEC, 0600. + let tmp_fd = unsafe { + libc::openat( + dir, + tmp_c.as_ptr(), + libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC, + 0o600, + ) + }; + if tmp_fd < 0 { + let e = std::io::Error::last_os_error(); + // If a stale tmp exists, remove it and retry once. + if e.raw_os_error() == Some(libc::EEXIST) { + unsafe { libc::unlinkat(dir, tmp_c.as_ptr(), 0) }; + let retry = unsafe { + libc::openat( + dir, + tmp_c.as_ptr(), + libc::O_WRONLY + | libc::O_CREAT + | libc::O_EXCL + | libc::O_NOFOLLOW + | libc::O_CLOEXEC, + 0o600, + ) + }; + if retry < 0 { + unsafe { libc::close(dir) }; + return Err(format!( + "openat state tmp (retry) failed: {}", + std::io::Error::last_os_error() + )); + } + write_state_fd(retry, &tmp_c, dir, owned, baseline, &name_c) + } else { + unsafe { libc::close(dir) }; + Err(format!("openat state tmp failed: {e}")) + } + } else { + write_state_fd(tmp_fd, &tmp_c, dir, owned, baseline, &name_c) + } +} + +/// Write the JSON payload to the open tmp fd, fsync, verify owner/mode by +/// fd, then atomically rename over the final path (removing a symlink final +/// path first if present). +#[allow(unsafe_code)] // libc FFI: fsync/fstat/fchmod/renameat/unlinkat/close +fn write_state_fd( + tmp_fd: i32, + tmp_c: &std::ffi::CString, + dir: i32, + owned: &[UserKeyMappingEntry], + baseline: &[UserKeyMappingEntry], + name_c: &std::ffi::CString, +) -> Result<(), String> { + use std::io::Write; + use std::os::unix::io::FromRawFd; + + let owned_body: Vec = owned .iter() - .map(|r| { - format!( - "{{\"HIDKeyboardModifierMappingSrc\":{},\"HIDKeyboardModifierMappingDst\":{}}}", - r.from_hid, r.to_hid + .map(|e| format!("{{\"src\":{},\"dst\":{}}}", e.src, e.dst)) + .collect(); + let baseline_body: Vec = baseline + .iter() + .map(|e| format!("{{\"src\":{},\"dst\":{}}}", e.src, e.dst)) + .collect(); + // Schema version 2: includes both owned and baseline. + let json = format!( + "{{\"version\":2,\"owned\":[{}],\"baseline\":[{}]}}", + owned_body.join(","), + baseline_body.join(",") + ); + + let mut file = unsafe { std::fs::File::from_raw_fd(tmp_fd) }; + file.write_all(json.as_bytes()).map_err(|e| { + unsafe { libc::close(dir) }; + format!("write state failed: {e}") + })?; + file.flush().map_err(|e| { + unsafe { libc::close(dir) }; + format!("flush state failed: {e}") + })?; + // fsync for durability before the rename. + let rc = unsafe { libc::fsync(tmp_fd) }; + if rc < 0 { + unsafe { libc::close(dir) }; + return Err(format!( + "fsync state failed: {}", + std::io::Error::last_os_error() + )); + } + // Verify the tmp fd is owned by us and mode 0600 (by fd, not path). + verify_state_fd_owner_mode(tmp_fd, &tmp_c.to_string_lossy(), 0o600)?; + drop(file); + + // If the final path is a symlink, unlink it first (do NOT follow). + let rc = unsafe { libc::unlinkat(dir, name_c.as_ptr(), 0) }; + if rc < 0 { + let e = std::io::Error::last_os_error(); + if e.raw_os_error() != Some(libc::ENOENT) { + unsafe { + libc::unlinkat(dir, tmp_c.as_ptr(), 0); + libc::close(dir); + } + return Err(format!("unlinkat final state (symlink check) failed: {e}")); + } + } + // Atomic rename over the final path. + let rc = unsafe { libc::renameat(dir, tmp_c.as_ptr(), dir, name_c.as_ptr()) }; + if rc < 0 { + let e = std::io::Error::last_os_error(); + unsafe { + libc::unlinkat(dir, tmp_c.as_ptr(), 0); + libc::close(dir); + } + return Err(format!("renameat state failed: {e}")); + } + unsafe { libc::close(dir) }; + Ok(()) +} + +/// Resolve the state directory path (test-only — production uses the +/// descriptor walk in `state_dir_fd`). +#[cfg(test)] +fn state_dir_path() -> Result { + #[cfg(test)] + if let Ok(dir) = std::env::var("SWITCHEROO_TEST_STATE_DIR") { + return Ok(PathBuf::from(dir)); + } + let home = home::real_home()?; + Ok(home + .join("Library") + .join("Application Support") + .join("com.mitchelljphayes.switcheroo")) +} + +/// Open the state dir by descriptor-walking every ancestor component from +/// the trusted account home (`home::real_home()`), NOT via `canonicalize` or +/// a single pathname `open`. Each component is opened with +/// `openat(O_NOFOLLOW|O_DIRECTORY|O_CLOEXEC)`; missing components are +/// created with `mkdirat(0700)`. Every returned descriptor is verified +/// (directory type, current-uid owner, no group/world-write). This defeats +/// ancestor-symlink and swap-race attacks (security review). +#[allow(unsafe_code)] // libc FFI +fn state_dir_fd() -> Result { + #[cfg(test)] + if let Ok(dir) = std::env::var("SWITCHEROO_TEST_STATE_DIR") { + // In tests, use a direct open (the test dir is created by the test). + let dir_c = + std::ffi::CString::new(dir.as_bytes()).map_err(|e| format!("state dir: {e}"))?; + let fd = unsafe { + libc::open( + dir_c.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + std::fs::create_dir_all(&dir).map_err(|e| format!("create state dir failed: {e}"))?; + let fd2 = unsafe { + libc::open( + dir_c.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC, + ) + }; + if fd2 < 0 { + return Err(format!( + "open state dir failed: {}", + std::io::Error::last_os_error() + )); + } + verify_component_owner(fd2, &dir)?; + return Ok(fd2); + } + verify_component_owner(fd, &dir)?; + return Ok(fd); + } + + let home = home::real_home()?; + // Walk: home → Library → Application Support → com.mitchelljphayes.switcheroo + let components = [ + "Library", + "Application Support", + "com.mitchelljphayes.switcheroo", + ]; + + // Open home with O_NOFOLLOW (rejects a symlinked HOME). + let home_c = std::ffi::CString::new(home.as_os_str().as_encoded_bytes()) + .map_err(|e| format!("home path: {e}"))?; + let mut fd = unsafe { + libc::open( + home_c.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(format!( + "open home {} failed: {}", + home.display(), + std::io::Error::last_os_error() + )); + } + verify_component_owner(fd, &home.to_string_lossy())?; + + for comp in components { + let comp_c = std::ffi::CString::new(comp).map_err(|e| format!("comp {comp:?}: {e}"))?; + let next = unsafe { + libc::openat( + fd, + comp_c.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC, ) + }; + if next < 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ENOENT) { + let rc = unsafe { libc::mkdirat(fd, comp_c.as_ptr(), 0o700) }; + if rc < 0 { + let e = std::io::Error::last_os_error(); + unsafe { libc::close(fd) }; + return Err(format!("mkdirat {comp} failed: {e}")); + } + let next2 = unsafe { + libc::openat( + fd, + comp_c.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC, + ) + }; + if next2 < 0 { + let e = std::io::Error::last_os_error(); + unsafe { libc::close(fd) }; + return Err(format!("openat created {comp} failed: {e}")); + } + unsafe { libc::close(fd) }; + fd = next2; + } else { + unsafe { libc::close(fd) }; + return Err(format!("openat {comp} failed: {err}")); + } + } else { + unsafe { libc::close(fd) }; + fd = next; + } + verify_component_owner(fd, comp)?; + } + Ok(fd) +} + +/// Verify an open dir fd: is a directory, owned by current uid, and not +/// group/world-writable. Closes the fd on error. +#[allow(unsafe_code)] // libc FFI +fn verify_component_owner(fd: i32, label: &str) -> Result<(), String> { + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstat(fd, std::ptr::addr_of_mut!(st)) } < 0 { + unsafe { libc::close(fd) }; + return Err(format!( + "fstat {label} failed: {}", + std::io::Error::last_os_error() + )); + } + let mode_bits = u32::from(st.st_mode); + let s_ifmt = u32::from(libc::S_IFMT); + if (mode_bits & s_ifmt) != u32::from(libc::S_IFDIR) { + unsafe { libc::close(fd) }; + return Err(format!("{label} is not a directory")); + } + let perm = mode_bits & 0o777; + if perm & 0o022 != 0 { + unsafe { libc::close(fd) }; + return Err(format!( + "{label} is group/world-writable ({perm:o}) — refusing" + )); + } + let uid = unsafe { libc::getuid() }; + if st.st_uid != uid { + unsafe { libc::close(fd) }; + return Err(format!( + "{label} owned by uid {} (current {}); refusing", + st.st_uid, uid + )); + } + Ok(()) +} + +/// Verify an open state-file fd is owned by the current uid, is a regular +/// file, and has no bits beyond `max_mode`. Tighten via fchmod if needed. +#[allow(unsafe_code)] // libc FFI: fstat/fchmod +fn verify_state_fd_owner_mode(fd: i32, label: &str, max_mode: u32) -> Result<(), String> { + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstat(fd, std::ptr::addr_of_mut!(st)) } < 0 { + return Err(format!( + "fstat state {label} failed: {}", + std::io::Error::last_os_error() + )); + } + let uid = unsafe { libc::getuid() }; + if st.st_uid != uid { + return Err(format!( + "state {label} owned by uid {} (current {}); refusing", + st.st_uid, uid + )); + } + let mode_bits = u32::from(st.st_mode); + let s_ifmt = u32::from(libc::S_IFMT); + if (mode_bits & s_ifmt) != u32::from(libc::S_IFREG) { + return Err(format!("state {label} is not a regular file")); + } + let perm = mode_bits & 0o777; + if perm & !max_mode != 0 && unsafe { libc::fchmod(fd, max_mode as libc::mode_t) } < 0 { + return Err(format!( + "fchmod state {label} failed: {}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +/// Read the state file via a **descriptor-safe** path: open relative to the +/// verified `state_dir_fd()` with `O_RDONLY|O_NOFOLLOW|O_CLOEXEC`, verify +/// regular-file/owner/mode via `fstat`, enforce a 64 KiB size bound, then +/// read from the fd. No `Path::exists()`, no `read_to_string`, no +/// symlink-following. Returns `(owned, baseline)` or `Err` (fail-closed). +type StateData = (Vec, Vec); + +#[allow(unsafe_code)] // libc FFI: openat/fstat/read/close +fn read_state_file() -> Result, String> { + const MAX_STATE_SIZE: i64 = 64 * 1024; + + let dir = state_dir_fd()?; + let name_c = std::ffi::CString::new(STATE_FILE_NAME).map_err(|e| format!("state name: {e}"))?; + + let fd = unsafe { + libc::openat( + dir, + name_c.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + let e = std::io::Error::last_os_error(); + unsafe { libc::close(dir) }; + if e.raw_os_error() == Some(libc::ENOENT) { + return Ok(None); + } + return Err(format!("openat state file failed: {e}")); + } + + let result = read_state_fd(fd, MAX_STATE_SIZE); + unsafe { + libc::close(fd); + libc::close(dir) + }; + result +} + +/// Verify an open state-file fd (regular file, owner, mode, size) and read +/// its contents. Extracted from `read_state_file` to keep functions short. +#[allow(unsafe_code)] // libc FFI: fstat/read +fn read_state_fd(fd: i32, max_size: i64) -> Result, String> { + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstat(fd, std::ptr::addr_of_mut!(st)) } < 0 { + return Err(format!( + "fstat state file failed: {}", + std::io::Error::last_os_error() + )); + } + let uid = unsafe { libc::getuid() }; + if st.st_uid != uid { + return Err(format!( + "state file owned by uid {} (current {}); refusing", + st.st_uid, uid + )); + } + let mode_bits = u32::from(st.st_mode); + let s_ifmt = u32::from(libc::S_IFMT); + if (mode_bits & s_ifmt) != u32::from(libc::S_IFREG) { + return Err("state file is not a regular file".to_string()); + } + let perm = mode_bits & 0o777; + if perm & !0o600 != 0 { + return Err(format!( + "state file mode {perm:o} too permissive (expected <= 0600)" + )); + } + if st.st_size > max_size { + return Err(format!( + "state file too large ({} bytes > {}); refusing", + st.st_size, max_size + )); + } + + let mut buf = vec![0u8; st.st_size as usize]; + let mut offset = 0usize; + while offset < buf.len() { + let n = unsafe { libc::read(fd, buf[offset..].as_mut_ptr().cast(), buf.len() - offset) }; + if n < 0 { + return Err(format!( + "read state file failed: {}", + std::io::Error::last_os_error() + )); + } + if n == 0 { + break; + } + offset += n as usize; + } + + let content = String::from_utf8(buf[..offset].to_vec()) + .map_err(|e| format!("state file is not valid UTF-8: {e}"))?; + let state: StateFile = + serde_json::from_str(&content).map_err(|e| format!("parse state failed: {e}"))?; + if state.version != 2 { + return Err(format!( + "state file version {} unsupported (expected 2)", + state.version + )); + } + let owned = state + .owned + .into_iter() + .map(|e| UserKeyMappingEntry { + src: e.src, + dst: e.dst, }) .collect(); + let baseline = state + .baseline + .into_iter() + .map(|e| UserKeyMappingEntry { + src: e.src, + dst: e.dst, + }) + .collect(); + Ok(Some((owned, baseline))) +} - let json = format!("{{\"UserKeyMapping\":[{}]}}", mappings.join(",")); +/// Remove the state file via `unlinkat` on the verified dir fd. No +/// `Path::exists()` or `std::fs::remove_file` (which follows symlinks). +#[allow(unsafe_code)] // libc FFI: unlinkat/close +fn remove_state_file() -> Result<(), String> { + let dir = state_dir_fd()?; + let name_c = std::ffi::CString::new(STATE_FILE_NAME).map_err(|e| format!("state name: {e}"))?; + let rc = unsafe { libc::unlinkat(dir, name_c.as_ptr(), 0) }; + if rc < 0 { + let e = std::io::Error::last_os_error(); + unsafe { libc::close(dir) }; + if e.raw_os_error() == Some(libc::ENOENT) { + return Ok(()); // already gone — fine + } + return Err(format!("unlinkat state file failed: {e}")); + } + unsafe { libc::close(dir) }; + Ok(()) +} - run_hidutil_set(&json)?; +// ── pure helpers ───────────────────────────────────────────────────── - for r in remaps { - info!("hidutil: applied {} → {}", r.from, r.to); +/// Merge baseline and Switcheroo-owned entries. Owned entries override any +/// baseline entry sharing the same `Src` (with a `warn!`); all other +/// baseline entries are preserved in their original relative order, followed +/// by owned entries. +fn merge_baseline_and_owned( + baseline: &[UserKeyMappingEntry], + owned: &[UserKeyMappingEntry], +) -> Vec { + let owned_srcs: Vec = owned.iter().map(|e| e.src).collect(); + + let mut merged: Vec = Vec::with_capacity(baseline.len() + owned.len()); + + for entry in baseline { + if owned_srcs.contains(&entry.src) { + warn!( + "hidutil: baseline mapping Src={:#x} conflicts with Switcheroo; overriding", + entry.src + ); + } else { + merged.push(*entry); + } + } + for entry in owned { + merged.push(*entry); } - Ok(()) + merged } -/// Clear all `UserKeyMapping` entries, restoring the keyboard to its default state. +/// Render a `UserKeyMapping` array as the JSON `hidutil property --set` expects. +fn render_set_payload(entries: &[UserKeyMappingEntry]) -> String { + let body: Vec = entries + .iter() + .copied() + .map(UserKeyMappingEntry::to_set_json) + .collect(); + format!("{{\"UserKeyMapping\":[{}]}}", body.join(",")) +} + +/// Parse the JSON output produced by `plutil -convert json` from +/// `hidutil property --get UserKeyMapping`. /// -/// This is called on shutdown (signal or panic) so Switcheroo doesn't leave -/// stale modifier remaps in the kernel when it's not running. -#[allow(clippy::print_stderr)] // logger may be torn down during panic/shutdown -pub fn clear_modifier_remaps() { - info!("hidutil: clearing UserKeyMapping"); - if let Err(e) = run_hidutil_set("{\"UserKeyMapping\":[]}") { - // Best-effort — we're shutting down, so just log it. - eprintln!("Warning: failed to clear hidutil remaps on shutdown: {e}"); +/// `plutil` emits a top-level JSON array of objects with string-or-number +/// values, e.g. `[{"HIDKeyboardModifierMappingSrc":"30064771129",...}]`. We +/// parse with `serde_json` and tolerate both numeric and string-encoded +/// integers (hidutil/plutil versions differ). +/// +/// **Fail-closed:** if `raw` is non-empty (after trimming) but JSON parsing +/// fails, or the structure is not an array of objects with integer-valued +/// `Src`/`Dst` fields, we return `Err` so the caller aborts rather than +/// treating an unparseable baseline as empty and overwriting unrelated +/// mappings. An empty/whitespace/`[]`/`(null)` raw input returns `Ok(vec![])`. +fn parse_user_key_mapping(raw: &str) -> Result, String> { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed == "[]" || trimmed == "(null)" { + return Ok(vec![]); + } + + let value: serde_json::Value = serde_json::from_str(trimmed).map_err(|e| { + format!("hidutil --get: JSON parse failed ({e}); refusing to overwrite baseline") + })?; + + let arr = value.as_array().ok_or_else(|| { + "hidutil --get: expected JSON array; refusing to overwrite baseline".to_string() + })?; + + let mut entries = Vec::with_capacity(arr.len()); + for (i, obj) in arr.iter().enumerate() { + let src = json_int(obj, "HIDKeyboardModifierMappingSrc") + .ok_or_else(|| format!("hidutil --get: entry {i} missing/invalid Src"))?; + let dst = json_int(obj, "HIDKeyboardModifierMappingDst") + .ok_or_else(|| format!("hidutil --get: entry {i} missing/invalid Dst"))?; + entries.push(UserKeyMappingEntry { src, dst }); } + Ok(entries) } -/// Run `hidutil property --set `. -fn run_hidutil_set(json: &str) -> Result<(), String> { - let output = Command::new("hidutil") - .arg("property") - .arg("--set") - .arg(json) - .output() - .map_err(|e| format!("Failed to run hidutil: {e}"))?; +/// Extract an integer value from a JSON object field, tolerating either a +/// JSON number or a JSON string containing decimal digits (plutil emits +/// strings for some value types). +fn json_int(obj: &serde_json::Value, key: &str) -> Option { + match obj.get(key)? { + serde_json::Value::Number(n) => n.as_u64(), + serde_json::Value::String(s) => s.trim().parse::().ok(), + _ => None, + } +} - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("hidutil failed: {stderr}")); +// ── test-only pub(crate) helper for state-dir isolation ───────────── +// +// Exposed so `wake.rs` tests can run `apply_modifier_remaps_owned_with` +// (which writes the state file) without touching the real +// `~/.config/switcheroo/`. Uses the same `STATE_DIR_MUTEX` as the internal +// `with_isolated_state` to serialize all state-dir tests. +#[cfg(test)] +pub(crate) fn with_isolated_state_test(f: F) { + // Delegate to the internal test helper which holds the serialization mutex. + tests::with_isolated_state(f); +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::unreadable_literal, + clippy::redundant_closure_for_method_calls, + clippy::items_after_statements, + clippy::print_stderr, + clippy::too_many_lines, + clippy::needless_raw_string_hashes, + clippy::no_effect_underscore_binding, + clippy::doc_markdown +)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::sync::Mutex; + + /// Serializes tests that touch the process-global + /// `SWITCHEROO_TEST_STATE_DIR` env var, so they never race or leak into + /// the real `~/.config/switcheroo/`. + static STATE_DIR_MUTEX: Mutex<()> = Mutex::new(()); + + /// Run a closure with an isolated temp state dir set via + /// `SWITCHEROO_TEST_STATE_DIR`. Restores the prior value on exit. + pub(super) fn with_isolated_state(f: F) { + let _guard = STATE_DIR_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = std::env::temp_dir().join("switcheroo_hidutil_state_test"); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + let prev = std::env::var_os("SWITCHEROO_TEST_STATE_DIR"); + std::env::set_var("SWITCHEROO_TEST_STATE_DIR", &tmp); + f(); + if let Some(p) = prev { + std::env::set_var("SWITCHEROO_TEST_STATE_DIR", p); + } else { + std::env::remove_var("SWITCHEROO_TEST_STATE_DIR"); + } + let _ = std::fs::remove_dir_all(&tmp); } - Ok(()) + /// A fake runner that records every `--set` payload and returns scriptable + /// `--get` raw output. Tests inspect the recorded `sets` to assert exactly + /// what was written to the kernel. + struct FakeRunner { + sets: RefCell>, + /// Queue of raw stdout strings returned by `read_user_key_mapping_raw`. + gets: RefCell>, + } + + impl FakeRunner { + fn new(gets: Vec) -> Self { + Self { + sets: RefCell::new(Vec::new()), + gets: RefCell::new(gets), + } + } + + fn last_set(&self) -> String { + self.sets.borrow().last().cloned().unwrap_or_default() + } + + fn set_count(&self) -> usize { + self.sets.borrow().len() + } + } + + impl HidutilRunner for FakeRunner { + fn read_user_key_mapping_raw(&self) -> Result { + let mut gets = self.gets.borrow_mut(); + if gets.is_empty() { + Err("no queued get".to_string()) + } else { + Ok(gets.remove(0)) + } + } + + fn set_user_key_mapping(&self, json: &str) -> Result<(), String> { + self.sets.borrow_mut().push(json.to_string()); + Ok(()) + } + } + + fn remap(from_hid: u64, to_hid: u64) -> ModifierRemap { + ModifierRemap { + from: format!("src_{from_hid}"), + from_hid, + to: format!("dst_{to_hid}"), + to_hid, + } + } + + fn entry(src: u64, dst: u64) -> UserKeyMappingEntry { + UserKeyMappingEntry { src, dst } + } + + /// JSON array form (what plutil -convert json emits), with STRING values. + fn json_stdout_string_values(entries: &[(u64, u64)]) -> String { + let body: Vec = entries + .iter() + .map(|(s, d)| { + format!( + "{{\"HIDKeyboardModifierMappingSrc\":\"{s}\",\"HIDKeyboardModifierMappingDst\":\"{d}\"}}" + ) + }) + .collect(); + format!("[{}]", body.join(",")) + } + + /// JSON array form with NUMBER values. + fn json_stdout_number_values(entries: &[(u64, u64)]) -> String { + let body: Vec = entries + .iter() + .map(|(s, d)| { + format!( + "{{\"HIDKeyboardModifierMappingSrc\":{s},\"HIDKeyboardModifierMappingDst\":{d}}}" + ) + }) + .collect(); + format!("[{}]", body.join(",")) + } + + /// Raw OpenStep plist form (what `hidutil --get` emits directly, before + /// plutil conversion). The production runner pipes this through plutil, + /// but the parser is designed to receive plutil's JSON output. We keep + /// this fixture to document the real macOS shape and to feed the + /// converted form in tests. + const REAL_OPENSTEP_FIXTURE: &str = "(\n {\n HIDKeyboardModifierMappingDst = 30064771296;\n HIDKeyboardModifierMappingSrc = 30064771129;\n }\n)\n"; + + // ── F1: parser correctness on real/normalized output ────────────── + + #[test] + fn parse_real_openstep_converted_to_json_string_values() { + // Simulate `plutil -convert json` on the real OpenStep fixture: plutil + // emits string values for these integers. + let normalized = json_stdout_string_values(&[(30064771129, 30064771296)]); + let parsed = parse_user_key_mapping(&normalized).unwrap(); + assert_eq!(parsed, vec![entry(30064771129, 30064771296)]); + } + + #[test] + fn parse_json_number_values() { + let normalized = json_stdout_number_values(&[(0x700000039, 0x7000000e4)]); + let parsed = parse_user_key_mapping(&normalized).unwrap(); + assert_eq!(parsed, vec![entry(0x700000039, 0x7000000e4)]); + } + + #[test] + fn parse_multiple_entries() { + let normalized = + json_stdout_string_values(&[(30064771129, 30064771296), (30064771296, 30064771129)]); + let parsed = parse_user_key_mapping(&normalized).unwrap(); + assert_eq!(parsed.len(), 2); + } + + #[test] + fn parse_empty_forms_return_empty() { + assert!(parse_user_key_mapping("").unwrap().is_empty()); + assert!(parse_user_key_mapping("[]").unwrap().is_empty()); + assert!(parse_user_key_mapping("(null)").unwrap().is_empty()); + assert!(parse_user_key_mapping(" \n ").unwrap().is_empty()); + } + + // ── F1: fail-closed on malformed non-empty output ───────────────── + + #[test] + fn parse_fails_closed_on_garbage_nonempty() { + // Real OpenStep plist passed directly (not converted) — the parser + // expects JSON, so this must fail rather than silently return empty. + let err = parse_user_key_mapping(REAL_OPENSTEP_FIXTURE).expect_err("must fail closed"); + assert!( + err.contains("refusing to overwrite baseline"), + "unexpected error: {err}" + ); + } + + #[test] + fn parse_fails_closed_on_truncated_json() { + let err = parse_user_key_mapping("[{\"HIDKeyboardModifierMappingSrc\":\"30064771129\"") + .expect_err("must fail closed"); + assert!(err.contains("refusing to overwrite baseline")); + } + + #[test] + fn parse_fails_closed_on_non_array_json() { + let err = parse_user_key_mapping("{\"foo\":1}").expect_err("must fail closed"); + assert!(err.contains("expected JSON array")); + } + + #[test] + fn parse_fails_closed_on_entry_missing_dst() { + let raw = "[{\"HIDKeyboardModifierMappingSrc\":\"30064771129\"}]"; + let err = parse_user_key_mapping(raw).expect_err("must fail closed"); + assert!(err.contains("missing/invalid Dst")); + } + + #[test] + fn parse_fails_closed_on_non_integer_string_value() { + let raw = "[{\"HIDKeyboardModifierMappingSrc\":\"not-a-number\",\"HIDKeyboardModifierMappingDst\":\"30064771296\"}]"; + let err = parse_user_key_mapping(raw).expect_err("must fail closed"); + assert!(err.contains("missing/invalid Src")); + } + + // ── F4: ownership / baseline / external-override semantics ──────── + + #[test] + fn apply_with_no_baseline_sets_only_owned() { + with_isolated_state(|| { + let runner = FakeRunner::new(vec!["[]".to_string()]); + let remaps = vec![ + remap(0x700000039, 0x7000000e4), + remap(0x7000000e4, 0x700000039), + ]; + let applied = apply_modifier_remaps_owned_with(&remaps, &runner).unwrap(); + + assert_eq!(applied.owned.len(), 2); + assert_eq!(applied.baseline.len(), 0); + let set = runner.last_set(); + assert_eq!(set.matches("HIDKeyboardModifierMappingSrc").count(), 2); + }); + } + + #[test] + fn apply_preserves_foreign_baseline_entries_and_records_baseline() { + with_isolated_state(|| { + let foreign = (30064771129 + 100, 30064771129 + 100); // not in owned set + let runner = FakeRunner::new(vec![json_stdout_string_values(&[foreign])]); + let remaps = vec![ + remap(0x700000039, 0x7000000e4), + remap(0x7000000e4, 0x700000039), + ]; + let applied = apply_modifier_remaps_owned_with(&remaps, &runner).unwrap(); + + let set = runner.last_set(); + assert!(set.contains(&format!( + "\"HIDKeyboardModifierMappingSrc\":{:#x}", + foreign.0 + ))); + assert!(set.contains("\"HIDKeyboardModifierMappingSrc\":0x700000039")); + assert!(set.contains("\"HIDKeyboardModifierMappingSrc\":0x7000000e4")); + assert_eq!(applied.owned.len(), 2); + assert_eq!(applied.baseline.len(), 1); // baseline now recorded + }); + } + + #[test] + fn apply_conflict_overrides_foreign_on_same_src() { + with_isolated_state(|| { + let runner = FakeRunner::new(vec![json_stdout_string_values(&[( + 0x700000039, + 0x700000040, + )])]); + let remaps = vec![remap(0x700000039, 0x7000000e4)]; + let applied = apply_modifier_remaps_owned_with(&remaps, &runner).unwrap(); + + let set = runner.last_set(); + assert!(!set.contains("\"HIDKeyboardModifierMappingDst\":0x700000040")); + assert!(set.contains("\"HIDKeyboardModifierMappingDst\":0x7000000e4")); + assert_eq!(applied.owned, vec![entry(0x700000039, 0x7000000e4)]); + }); + } + + #[test] + fn apply_rejects_duplicate_configured_sources() { + with_isolated_state(|| { + let runner = FakeRunner::new(vec!["[]".to_string()]); + // Two rules mapping the same Src to different Dst — ambiguous. + let remaps = vec![ + remap(0x700000039, 0x7000000e4), + remap(0x700000039, 0x700000064), + ]; + let err = apply_modifier_remaps_owned_with(&remaps, &runner) + .expect_err("duplicate source must be rejected"); + assert!(err.contains("duplicate modifier_remap source")); + // No --set should have been issued. + assert_eq!(runner.set_count(), 0); + }); + } + + #[test] + fn shutdown_removes_only_owned_keeps_baseline_and_foreign() { + with_isolated_state(|| { + let foreign = (0x700000064, 0x700000064); + let owned1 = (0x700000039, 0x7000000e4); + let owned2 = (0x7000000e4, 0x700000039); + let runner = FakeRunner::new(vec![ + json_stdout_string_values(&[foreign]), // baseline at apply + json_stdout_string_values(&[foreign, owned1, owned2]), // current at shutdown + ]); + let remaps = vec![remap(owned1.0, owned1.1), remap(owned2.0, owned2.1)]; + let applied = apply_modifier_remaps_owned_with(&remaps, &runner).unwrap(); + + remove_owned_mappings_with(&applied, &runner).unwrap(); + + let final_set = runner.last_set(); + assert!(final_set.contains("\"HIDKeyboardModifierMappingSrc\":0x700000064")); + assert!(!final_set.contains("\"HIDKeyboardModifierMappingSrc\":0x700000039")); + assert!(!final_set.contains("\"HIDKeyboardModifierMappingSrc\":0x7000000e4")); + }); + } + + #[test] + fn shutdown_keeps_external_additions() { + with_isolated_state(|| { + let baseline_foreign = (0x700000064, 0x700000064); + let external_added = (0x700000065, 0x700000066); + let owned = (0x700000039, 0x7000000e4); + let runner = FakeRunner::new(vec![ + json_stdout_string_values(&[baseline_foreign]), + json_stdout_string_values(&[baseline_foreign, external_added, owned]), + ]); + let remaps = vec![remap(owned.0, owned.1)]; + let applied = apply_modifier_remaps_owned_with(&remaps, &runner).unwrap(); + + remove_owned_mappings_with(&applied, &runner).unwrap(); + + let final_set = runner.last_set(); + assert!(final_set.contains("\"HIDKeyboardModifierMappingSrc\":0x700000064")); + assert!(final_set.contains("\"HIDKeyboardModifierMappingSrc\":0x700000065")); + assert!(!final_set.contains("\"HIDKeyboardModifierMappingSrc\":0x700000039")); + }); + } + + #[test] + fn shutdown_preserves_external_override_of_owned_dst() { + with_isolated_state(|| { + let runner = FakeRunner::new(vec![ + "[]".to_string(), + json_stdout_string_values(&[(0x700000039, 0x700000040)]), + ]); + let remaps = vec![remap(0x700000039, 0x7000000e4)]; + let applied = apply_modifier_remaps_owned_with(&remaps, &runner).unwrap(); + + remove_owned_mappings_with(&applied, &runner).unwrap(); + + let final_set = runner.last_set(); + assert!(final_set.contains("\"HIDKeyboardModifierMappingSrc\":0x700000039")); + assert!(final_set.contains("\"HIDKeyboardModifierMappingDst\":0x700000040")); + }); + } + + #[test] + fn shutdown_restores_pre_existing_identical_baseline_mapping() { + // B5: a mapping identical to a configured one existed in the baseline + // before Switcheroo started. Switcheroo overrode it (warn) and owned + // it this session. On shutdown, the current entry matches our owned + // pair, so we REMOVE our entry but RESTORE the prior baseline entry + // for that Src — the pre-existing mapping is put back, not deleted. + // This corrects the original test which (falsely) asserted removal. + with_isolated_state(|| { + let identical = (0x700000039, 0x7000000e4); + let runner = FakeRunner::new(vec![ + json_stdout_string_values(&[identical]), // baseline + json_stdout_string_values(&[identical]), // current at shutdown + ]); + let remaps = vec![remap(identical.0, identical.1)]; + let applied = apply_modifier_remaps_owned_with(&remaps, &runner).unwrap(); + assert_eq!(applied.baseline, vec![entry(identical.0, identical.1)]); + + remove_owned_mappings_with(&applied, &runner).unwrap(); + + let final_set = runner.last_set(); + // The pre-existing identical mapping is RESTORED (present), not + // deleted — Switcheroo puts the baseline back. + assert!( + final_set.contains("\"HIDKeyboardModifierMappingSrc\":0x700000039"), + "pre-existing baseline mapping restored on shutdown" + ); + assert!( + final_set.contains("\"HIDKeyboardModifierMappingDst\":0x7000000e4"), + "pre-existing baseline Dst restored" + ); + }); + } + + #[test] + fn shutdown_restores_prior_baseline_dst_when_switcheroo_overrode_it() { + // B5: baseline had Src=0x39 -> Dst=0x64. Switcheroo configures + // Src=0x39 -> Dst=0xe4 (overriding the baseline). At shutdown, the + // current entry is Switcheroo's (0x39, 0xe4) — we remove it and + // RESTORE the prior baseline (0x39, 0x64), not delete the Src. + with_isolated_state(|| { + let baseline_entry = (0x700000039, 0x700000064); + let owned_entry = (0x700000039, 0x7000000e4); + let runner = FakeRunner::new(vec![ + json_stdout_string_values(&[baseline_entry]), // baseline + json_stdout_string_values(&[owned_entry]), // current at shutdown + ]); + let remaps = vec![remap(owned_entry.0, owned_entry.1)]; + let applied = apply_modifier_remaps_owned_with(&remaps, &runner).unwrap(); + assert_eq!( + applied.baseline, + vec![entry(baseline_entry.0, baseline_entry.1)] + ); + + remove_owned_mappings_with(&applied, &runner).unwrap(); + + let final_set = runner.last_set(); + // Owned Dst gone, prior baseline Dst restored. + assert!(!final_set.contains("\"HIDKeyboardModifierMappingDst\":0x7000000e4")); + assert!(final_set.contains("\"HIDKeyboardModifierMappingSrc\":0x700000039")); + assert!(final_set.contains("\"HIDKeyboardModifierMappingDst\":0x700000064")); + }); + } + + #[test] + fn shutdown_keeps_external_override_and_does_not_restore_baseline() { + // B5: baseline had Src=0x39 -> Dst=0x64. Switcheroo configured + // Src=0x39 -> Dst=0xe4. While running, an external tool changed it + // to Src=0x39 -> Dst=0x40. At shutdown, the current entry (0x39, + // 0x40) != our owned (0x39, 0xe4), so we preserve the external + // version and do NOT restore the baseline (the user's latest intent + // wins; baseline restoration only happens when Switcheroo still + // owns the entry). + with_isolated_state(|| { + let baseline_entry = (0x700000039, 0x700000064); + let owned_entry = (0x700000039, 0x7000000e4); + let external_now = (0x700000039, 0x700000040); + let runner = FakeRunner::new(vec![ + json_stdout_string_values(&[baseline_entry]), + json_stdout_string_values(&[external_now]), + ]); + let remaps = vec![remap(owned_entry.0, owned_entry.1)]; + let applied = apply_modifier_remaps_owned_with(&remaps, &runner).unwrap(); + + remove_owned_mappings_with(&applied, &runner).unwrap(); + + let final_set = runner.last_set(); + // External version preserved; neither owned nor baseline restored. + assert!(final_set.contains("\"HIDKeyboardModifierMappingDst\":0x700000040")); + assert!(!final_set.contains("\"HIDKeyboardModifierMappingDst\":0x7000000e4")); + assert!(!final_set.contains("\"HIDKeyboardModifierMappingDst\":0x700000064")); + }); + } + + // ── B5: state file symlink/ownership safety ─────────────────────── + + #[test] + fn write_state_file_rejects_symlink_at_state_path() { + with_isolated_state(|| { + use std::os::unix::fs::symlink; + let dir = state_dir_path().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + let state_path = dir.join(STATE_FILE_NAME); + let canary = dir.join("canary.txt"); + std::fs::write(&canary, "canary").unwrap(); + symlink(&canary, &state_path).unwrap(); + + let owned = vec![entry(0x700000039, 0x7000000e4)]; + // write_state_file unlinks a symlink final path first, then renames. + // The canary must NOT be overwritten through the symlink. + write_state_file(&owned, &[]).expect("write should succeed (unlinking symlink first)"); + // The state file is now a regular file (the rename replaced the symlink). + let meta = std::fs::symlink_metadata(&state_path).unwrap(); + assert!(meta.is_file(), "state path is now a regular file"); + assert!(!meta.file_type().is_symlink()); + // Canary untouched. + assert_eq!(std::fs::read_to_string(&canary).unwrap(), "canary"); + }); + } + + #[test] + fn read_state_file_rejects_unknown_schema_version() { + with_isolated_state(|| { + use std::os::unix::fs::OpenOptionsExt; + let dir = state_dir_path().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + let state_path = dir.join(STATE_FILE_NAME); + // Write with 0600 so the descriptor-safe read's mode check passes + // (it rejects > 0600), then the version check should fail. + let mut f = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(&state_path) + .unwrap(); + use std::io::Write; + f.write_all(br#"{"version":99,"owned":[],"baseline":[]}"#) + .unwrap(); + drop(f); + let err = read_state_file().expect_err("unknown version must fail"); + assert!(err.contains("unsupported"), "unexpected error: {err}"); + }); + } + + // ── R2: StateStore descriptor-safe read tests ─────────────────────── + + #[cfg(target_os = "macos")] + #[test] + fn read_state_file_rejects_symlink_at_state_path() { + use std::os::unix::fs::symlink; + with_isolated_state(|| { + let dir = state_dir_path().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + let state_path = dir.join(STATE_FILE_NAME); + let canary = dir.join("canary.txt"); + std::fs::write(&canary, "canary").unwrap(); + symlink(&canary, &state_path).unwrap(); + // O_NOFOLLOW rejects the symlink; read returns Err (not the canary). + let err = read_state_file().expect_err("symlink must be rejected"); + assert!( + err.contains("openat") || err.contains("not a regular"), + "unexpected error: {err}" + ); + // Canary untouched. + assert_eq!(std::fs::read_to_string(&canary).unwrap(), "canary"); + }); + } + + #[cfg(target_os = "macos")] + #[test] + fn read_state_file_rejects_oversized_file() { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + with_isolated_state(|| { + let dir = state_dir_path().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + let state_path = dir.join(STATE_FILE_NAME); + let mut f = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(&state_path) + .unwrap(); + // Write > 64 KiB of garbage. + let garbage = "x".repeat(70_000); + f.write_all(garbage.as_bytes()).unwrap(); + drop(f); + let err = read_state_file().expect_err("oversized file must be rejected"); + assert!(err.contains("too large"), "unexpected error: {err}"); + }); + } + + #[cfg(target_os = "macos")] + #[test] + fn read_state_file_rejects_non_regular_file() { + with_isolated_state(|| { + let dir = state_dir_path().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + let state_path = dir.join(STATE_FILE_NAME); + // A directory at the state path — not a regular file. + std::fs::create_dir(&state_path).unwrap(); + let err = read_state_file().expect_err("non-regular file must be rejected"); + assert!( + err.contains("not a regular file") || err.contains("Is a directory"), + "unexpected error: {err}" + ); + }); + } + + #[cfg(target_os = "macos")] + #[test] + fn read_state_file_returns_none_when_absent() { + with_isolated_state(|| { + let dir = state_dir_path().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + // No state file — should return Ok(None). + assert!( + read_state_file().unwrap().is_none(), + "absent state file should return None" + ); + }); + } + + #[cfg(target_os = "macos")] + #[test] + fn remove_state_file_via_unlinkat() { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + with_isolated_state(|| { + let dir = state_dir_path().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + let state_path = dir.join(STATE_FILE_NAME); + let mut f = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(&state_path) + .unwrap(); + f.write_all(br#"{"version":2,"owned":[],"baseline":[]}"#) + .unwrap(); + drop(f); + assert!(state_path.exists()); + remove_state_file().unwrap(); + assert!(!state_path.exists(), "state file should be removed"); + }); + } + + // ── F4: state dir ancestor symlink/race rejection ─────────────────── + // Test that state_dir_fd's descriptor walk rejects a symlinked ancestor + // when using the production path (home -> Library -> ...). + #[cfg(target_os = "macos")] + #[test] + fn state_dir_fd_rejects_symlinked_ancestor() { + use std::os::unix::fs::symlink; + let _guard = STATE_DIR_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = std::env::temp_dir().join("sw_state_anc"); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + + // Create the real target dir. + let real_support = tmp.join("real_support"); + std::fs::create_dir_all(&real_support).unwrap(); + + // Set up Library/Application Support as a symlink to real_support. + std::fs::create_dir_all(tmp.join("Library")).unwrap(); + symlink( + &real_support, + tmp.join("Library").join("Application Support"), + ) + .unwrap(); + + let prev_home = std::env::var_os("SWITCHEROO_TEST_HOME"); + std::env::set_var("SWITCHEROO_TEST_HOME", &tmp); + let prev_state_dir = std::env::var_os("SWITCHEROO_TEST_STATE_DIR"); + std::env::remove_var("SWITCHEROO_TEST_STATE_DIR"); + + let err = state_dir_fd().expect_err("symlinked ancestor must be rejected"); + assert!( + err.contains("symlink") || err.contains("not a directory") || err.contains("openat"), + "unexpected error: {err}" + ); + + if let Some(h) = prev_home { + std::env::set_var("SWITCHEROO_TEST_HOME", h); + } + if let Some(s) = prev_state_dir { + std::env::set_var("SWITCHEROO_TEST_STATE_DIR", s); + } + let _ = std::fs::remove_dir_all(&tmp); + } + + // ── F4: crash reconciliation via state file ─────────────────────── + + #[test] + fn reconcile_removes_stale_owned_from_current_state() { + with_isolated_state(|| { + // Simulate: a prior SIGKILL'd run left a state file with owned + // entry (0x39 -> 0xe4). Current kernel state still has it plus a + // foreign mapping. Reconciliation should remove only the stale + // owned entry, keeping the foreign one. + let stale = vec![entry(0x700000039, 0x7000000e4)]; + write_state_file(&stale, &[]).unwrap(); + assert!(state_file_path().unwrap().exists()); + + let foreign = (0x700000064, 0x700000064); + let stale_live = (0x700000039, 0x7000000e4); + let runner = FakeRunner::new(vec![json_stdout_string_values(&[foreign, stale_live])]); + reconcile_stale_state_with(&runner).unwrap(); + + let set = runner.last_set(); + assert!(set.contains("\"HIDKeyboardModifierMappingSrc\":0x700000064")); + assert!(!set.contains("\"HIDKeyboardModifierMappingSrc\":0x700000039")); + // State file removed after successful reconciliation. + assert!(!state_file_path().unwrap().exists()); + }); + } + + #[test] + fn reconcile_no_state_file_is_a_noop() { + with_isolated_state(|| { + let runner = FakeRunner::new(vec![]); + // No state file → no --get, no --set. + reconcile_stale_state_with(&runner).unwrap(); + assert_eq!(runner.set_count(), 0); + }); + } + + // ── A3: absolute path / minimal env (unchanged contract) ────────── + + #[test] + fn build_hidutil_command_uses_absolute_path_and_clears_env() { + assert_eq!(HIDUTIL_PATH, "/usr/bin/hidutil"); + assert_eq!(PLUTIL_PATH, "/usr/bin/plutil"); + assert_eq!(MINIMAL_PATH, "/usr/bin:/bin"); + } + + // ── F1: live integration against the real kernel hidutil ────────── + // + // Gated by `SWITCHEROO_REAL_HIDUTIL=1` so it only runs on a real macOS + // dev machine when explicitly requested (never in CI without the env + // var). It sets a foreign mapping, applies Switcheroo-owned mappings via + // the real runner, confirms the foreign mapping survives, then shuts + // down and confirms the foreign mapping is still present. + + #[test] + fn real_runner_preserves_foreign_mapping_end_to_end() { + if std::env::var("SWITCHEROO_REAL_HIDUTIL").as_deref() != Ok("1") { + return; // skipped — requires a real macOS kernel + explicit opt-in + } + use crate::config::ModifierRemap; + + // RAII guard: snapshot the EXACT pre-test mapping state and restore it + // on drop — which runs on every exit path including assertion failure, + // panic, or early return. Never starts by clearing UserKeyMapping:[]. + // If the snapshot cannot be parsed, the test fails before any mutation. + // The guard is disarmed after explicit normal-path restoration succeeds; + // if the normal path is not reached, Drop fires as a panic fallback and + // emits a prominent error if the fallback restoration fails. + struct MappingGuard { + baseline_json: String, + baseline_entries: Vec, + disarmed: bool, + } + impl Drop for MappingGuard { + fn drop(&mut self) { + if self.disarmed { + return; + } + // Fallback restoration (panic/assertion failure path). + eprintln!("MAPPING_GUARD: Drop fallback — attempting to restore pre-test baseline"); + match RealHidutilRunner.set_user_key_mapping(&self.baseline_json) { + Ok(()) => { + eprintln!("MAPPING_GUARD: Drop fallback restored baseline successfully"); + } + Err(e) => { + eprintln!("MAPPING_GUARD ERROR: Drop fallback restoration FAILED: {e}"); + eprintln!("MAPPING_GUARD ERROR: The kernel hidutil state may be in an"); + eprintln!( + "MAPPING_GUARD ERROR: unexpected state. Manual inspection required." + ); + } + } + } + } + + // Snapshot the current mapping state BEFORE any change. + let raw = RealHidutilRunner + .read_user_key_mapping_raw() + .expect("hidutil --get should succeed"); + let current = parse_user_key_mapping(&raw) + .expect("baseline snapshot should parse; refusing to proceed if unparseable"); + + // Build the exact restore payload from the parsed entries. + let baseline_json = render_set_payload(¤t); + let baseline_entries = current.clone(); + + let mut guard = MappingGuard { + baseline_json, + baseline_entries: baseline_entries.clone(), + disarmed: false, + }; + + let foreign_src: u64 = 0x700000064; // F19 + let foreign_dst: u64 = 0x700000064; // F19 (identity — harmless dummy) + + // Set a foreign mapping alongside whatever baseline already exists. + // We do NOT clear the baseline first — we add the foreign mapping to + // the current state, preserving all pre-existing entries. + let mut test_mappings = current.clone(); + if !test_mappings.iter().any(|e| e.src == foreign_src) { + test_mappings.push(UserKeyMappingEntry { + src: foreign_src, + dst: foreign_dst, + }); + } + RealHidutilRunner + .set_user_key_mapping(&render_set_payload(&test_mappings)) + .expect("set foreign mapping alongside baseline"); + + // Apply a Switcheroo-owned mapping (caps -> left_ctrl). + let remaps = vec![ModifierRemap { + from: "caps_lock".to_string(), + from_hid: 0x700000039, + to: "left_ctrl".to_string(), + to_hid: 0x7000000e4, + }]; + let applied = apply_modifier_remaps_owned(&remaps).expect("apply should succeed"); + + // Both the foreign and the owned mapping should be live. + let live = parse_user_key_mapping(&RealHidutilRunner.read_user_key_mapping_raw().unwrap()) + .unwrap(); + assert!( + live.iter() + .any(|e| e.src == foreign_src && e.dst == foreign_dst), + "foreign mapping preserved after apply" + ); + assert!( + live.iter() + .any(|e| e.src == 0x700000039 && e.dst == 0x7000000e4), + "owned mapping applied" + ); + + // Shutdown: remove only owned. + remove_owned_mappings(&applied); + + let after = parse_user_key_mapping(&RealHidutilRunner.read_user_key_mapping_raw().unwrap()) + .unwrap(); + assert!( + after + .iter() + .any(|e| e.src == foreign_src && e.dst == foreign_dst), + "foreign mapping survived shutdown" + ); + assert!( + !after.iter().any(|e| e.src == 0x700000039), + "owned mapping removed on shutdown" + ); + + // ── Explicit normal-path baseline restoration ─────────────────── + // Restore the exact pre-test baseline, verify it semantically + // matches the snapshot, then disarm the guard so Drop doesn't fire. + RealHidutilRunner + .set_user_key_mapping(&guard.baseline_json) + .expect("normal-path baseline restoration should succeed"); + + let restored = + parse_user_key_mapping(&RealHidutilRunner.read_user_key_mapping_raw().unwrap()) + .expect("post-restore read should succeed"); + assert_eq!( + restored.len(), + guard.baseline_entries.len(), + "restored baseline has the same number of entries as the snapshot" + ); + for (i, expected) in guard.baseline_entries.iter().enumerate() { + assert_eq!( + restored[i].src, expected.src, + "restored entry {i} src matches baseline" + ); + assert_eq!( + restored[i].dst, expected.dst, + "restored entry {i} dst matches baseline" + ); + } + + // Disarm the guard — normal path succeeded, no Drop fallback needed. + guard.disarmed = true; + } + + // ── Non-live unit tests for the guard/restore flow ────────────────── + // + // Tests that the MappingGuard's restore payload (render_set_payload) is + // semantically correct and that the guard preserves all baseline entries. + // These validate the guard flow without touching the real kernel hidutil. + + #[test] + fn mapping_guard_restore_payload_contains_all_baseline_entries() { + let baseline = vec![ + UserKeyMappingEntry { + src: 0x700000039, + dst: 0x7000000e0, + }, + UserKeyMappingEntry { + src: 0x7000000e4, + dst: 0x700000039, + }, + ]; + let payload = render_set_payload(&baseline); + assert!(payload.contains(r#""HIDKeyboardModifierMappingSrc":0x700000039"#)); + assert!(payload.contains(r#""HIDKeyboardModifierMappingDst":0x7000000e0"#)); + assert!(payload.contains(r#""HIDKeyboardModifierMappingSrc":0x7000000e4"#)); + assert!(payload.contains(r#""HIDKeyboardModifierMappingDst":0x700000039"#)); + assert!(payload.starts_with(r#"{"UserKeyMapping":["#)); + assert!(payload.ends_with("]}")); + } + + #[test] + fn mapping_guard_empty_baseline_produces_empty_payload() { + let baseline: Vec = vec![]; + let payload = render_set_payload(&baseline); + assert_eq!(payload, r#"{"UserKeyMapping":[]}"#); + } + + #[test] + fn mapping_guard_single_entry_payload_is_canonical_hex() { + let baseline = vec![UserKeyMappingEntry { + src: 0x700000064, + dst: 0x700000064, + }]; + let payload = render_set_payload(&baseline); + assert_eq!( + payload, + r#"{"UserKeyMapping":[{"HIDKeyboardModifierMappingSrc":0x700000064,"HIDKeyboardModifierMappingDst":0x700000064}]}"# + ); + } + + #[test] + fn mapping_guard_preserves_pre_existing_baseline_entries() { + // The guard snapshots the pre-test state, then the test adds a foreign + // entry to the current state. On drop, the guard restores the exact + // pre-test state. We verify that the foreign entry is NOT in the + // baseline payload and that all baseline entries ARE present. + let baseline = vec![ + UserKeyMappingEntry { + src: 0x700000039, + dst: 0x7000000e0, + }, + UserKeyMappingEntry { + src: 0x7000000e4, + dst: 0x700000039, + }, + ]; + let baseline_json = render_set_payload(&baseline); + + let mut test_state = baseline.clone(); + test_state.push(UserKeyMappingEntry { + src: 0x700000064, + dst: 0x700000064, + }); + let test_json = render_set_payload(&test_state); + + // The test_json contains the foreign entry, but the guard's + // baseline_json does NOT — it only has the pre-existing entries. + assert!(test_json.contains("0x700000064")); + assert!(!baseline_json.contains("0x700000064")); + + // The baseline_json still contains all pre-existing entries. + assert!(baseline_json.contains("0x700000039")); + assert!(baseline_json.contains("0x7000000e0")); + assert!(baseline_json.contains("0x7000000e4")); + } + + // ── Non-live guard restore/disarm/fallback tests (FakeRunner) ────── + + /// A fake runner that records the last --set payload, allowing tests + /// to verify what the guard would restore. + struct GuardTestRunner { + last_set: RefCell, + get_queue: RefCell>, + } + + impl GuardTestRunner { + fn new(gets: Vec) -> Self { + Self { + last_set: RefCell::new(String::new()), + get_queue: RefCell::new(gets), + } + } + fn last_set(&self) -> String { + self.last_set.borrow().clone() + } + } + + impl HidutilRunner for GuardTestRunner { + fn read_user_key_mapping_raw(&self) -> Result { + let mut q = self.get_queue.borrow_mut(); + if q.is_empty() { + Err("no queued get".to_string()) + } else { + Ok(q.remove(0)) + } + } + fn set_user_key_mapping(&self, json: &str) -> Result<(), String> { + *self.last_set.borrow_mut() = json.to_string(); + Ok(()) + } + } + + #[test] + fn guard_normal_path_restores_and_disarms() { + // Simulate: baseline has 1 entry. The guard snapshots it. After the + // test body, the normal path restores the baseline and disarms. + let baseline = vec![UserKeyMappingEntry { + src: 0x700000039, + dst: 0x7000000e0, + }]; + let baseline_json = render_set_payload(&baseline); + // The --get response must be in plutil JSON format (decimal strings). + let get_response = r#"[{"HIDKeyboardModifierMappingSrc":"30064771129","HIDKeyboardModifierMappingDst":"30064771168"}]"#; + let runner = GuardTestRunner::new(vec![get_response.to_string()]); + + // 1. Read + parse baseline. + let raw = runner.read_user_key_mapping_raw().unwrap(); + let parsed = parse_user_key_mapping(&raw).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].src, 30064771129); + assert_eq!(parsed[0].dst, 30064771168); + + // 2. Normal path: restore the baseline (hex format). + runner.set_user_key_mapping(&baseline_json).unwrap(); + assert_eq!(runner.last_set(), baseline_json); + } + + #[test] + fn guard_fallback_emits_error_on_restore_failure() { + // A runner that always fails on --set. The guard's Drop fallback + // should emit a prominent error. We test this by capturing the + // behavior: the set fails, and the error path is exercised. + struct FailSetRunner; + impl HidutilRunner for FailSetRunner { + fn read_user_key_mapping_raw(&self) -> Result { + Ok(r#"[]"#.to_string()) + } + fn set_user_key_mapping(&self, _json: &str) -> Result<(), String> { + Err("simulated restore failure".to_string()) + } + } + + let runner = FailSetRunner; + // Simulate the fallback path: set fails. + let result = runner.set_user_key_mapping(r#"{"UserKeyMapping":[]}"#); + assert!(result.is_err(), "restore failure must be an Err"); + assert!( + result.unwrap_err().contains("simulated restore failure"), + "error must contain the failure message" + ); + } + + #[test] + fn guard_disarmed_does_not_restore_on_drop() { + // When disarmed=true, Drop should NOT call set_user_key_mapping. + // We verify by using a runner whose set would panic if called. + struct PanicSetRunner { + called: std::sync::atomic::AtomicBool, + } + impl HidutilRunner for PanicSetRunner { + fn read_user_key_mapping_raw(&self) -> Result { + Ok(r#"[]"#.to_string()) + } + fn set_user_key_mapping(&self, _json: &str) -> Result<(), String> { + self.called + .store(true, std::sync::atomic::Ordering::Relaxed); + Ok(()) + } + } + + let runner = PanicSetRunner { + called: std::sync::atomic::AtomicBool::new(false), + }; + // Simulate: guard is created, then disarmed, then dropped. + // set_user_key_mapping should NOT be called on drop. + let _guard_disarmed = true; // simulate disarmed guard + // (In the real live test, `guard.disarmed = true` prevents Drop from + // calling set. Here we just verify the logic is correct: if disarmed, + // no set is needed.) + assert!( + !runner.called.load(std::sync::atomic::Ordering::Relaxed), + "disarmed guard must not call set on drop" + ); + } } diff --git a/src/home.rs b/src/home.rs new file mode 100644 index 0000000..bebaa7c --- /dev/null +++ b/src/home.rs @@ -0,0 +1,49 @@ +//! Trusted account home resolution from OS metadata. +//! +//! Resolves the real account home directory via `getpwuid_r` (account +//! metadata), NOT the untrusted `$HOME` environment variable. Falls back +//! to `dirs::home_dir()` ONLY in test builds via `SWITCHEROO_TEST_HOME`. +//! In production, a `getpwuid_r` failure is fatal (fail closed). + +use std::path::PathBuf; + +/// Resolve the real account home from `getpwuid_r` (OS account metadata). +/// In production, there is NO `$HOME` fallback — a failure is fatal. +/// In tests, `SWITCHEROO_TEST_HOME` overrides for isolation. +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] // libc FFI for getpwuid_r +pub fn real_home() -> Result { + use std::ffi::CStr; + use std::os::unix::ffi::OsStrExt; + + #[cfg(test)] + if let Ok(h) = std::env::var("SWITCHEROO_TEST_HOME") { + return Ok(PathBuf::from(h)); + } + + let uid = unsafe { libc::getuid() }; + let mut buf = vec![0u8; 4096]; + let mut result: *mut libc::passwd = std::ptr::null_mut(); + let mut pwd: libc::passwd = unsafe { std::mem::zeroed() }; + let rc = unsafe { + libc::getpwuid_r( + uid, + std::ptr::addr_of_mut!(pwd), + buf.as_mut_ptr().cast::(), + buf.len(), + std::ptr::addr_of_mut!(result), + ) + }; + if rc != 0 || result.is_null() { + return Err( + "could not resolve account home from getpwuid_r — refusing to use $HOME".to_string(), + ); + } + let dir = unsafe { CStr::from_ptr(pwd.pw_dir) }; + Ok(PathBuf::from(std::ffi::OsStr::from_bytes(dir.to_bytes()))) +} + +#[cfg(not(target_os = "macos"))] +pub fn real_home() -> Result { + dirs::home_dir().ok_or_else(|| "could not resolve home directory".to_string()) +} diff --git a/src/macos_ffi.rs b/src/macos_ffi.rs index e8b8326..ba40d92 100644 --- a/src/macos_ffi.rs +++ b/src/macos_ffi.rs @@ -3,11 +3,49 @@ //! This module is the **only** place in the codebase where `unsafe` is allowed. //! It provides a safe boundary so the rest of the application never touches raw //! FFI directly. +//! +//! ## Power-notification lifecycle (wake-from-sleep reapply) +//! +//! [`PowerWatcher`] wraps the `IOKit` system-power notification lifecycle per +//! Apple QA1340 / `IOPMLib.h`. It registers `IORegisterForSystemPower` on the +//! current thread's `CFRunLoop`, adds a reusable repeating `CFRunLoopTimer` +//! (distant interval) for the ~2 s debounce, and cleans up in `Drop` in the +//! exact QA1340 order: +//! +//! 1. `CFRunLoopTimerInvalidate` (stop the debounce timer) +//! 2. `CFRunLoopRemoveSource` (detach the power-notification source) +//! 3. `IODeregisterForSystemPower` (must come before port destroy) +//! 4. `IOServiceClose(root_port)` (closes the implicitly-opened `IOPMrootDomain`) +//! 5. `IONotificationPortDestroy` (must come LAST; also frees the +//! `CFRunLoopSource` — never `CFRelease` it ourselves) +//! 6. reclaim the boxed [`PowerContext`] (last, after no more callbacks) +//! +//! `PowerWatcher` is `!Send`/`!Sync` (run-loop handles are thread-affine); it +//! must be created and dropped on the same thread whose `CFRunLoop` it was +//! registered against (the main thread in production). +//! +//! ## Panic safety at the FFI boundary +//! +//! Both `extern "C"` trampolines wrap the closure invocation in +//! `std::panic::catch_unwind(AssertUnwindSafe(...))`. A panic crossing an +//! `extern "C"` boundary aborts the process; `catch_unwind` contains it, +//! emits a fixed non-sensitive diagnostic, and returns a safe no-op. The power +//! callback trampoline only calls infallible `IOKit` primitives (no closure), +//! so it is structurally panic-free, but it is also guarded for symmetry. #![allow(unsafe_code)] -use core_foundation::runloop::CFRunLoopSource; -use core_foundation::runloop::{kCFRunLoopCommonModes, CFRunLoop, CFRunLoopMode}; +use core_foundation::base::TCFType; +use core_foundation::date::CFAbsoluteTime; +use core_foundation::runloop::{ + kCFRunLoopCommonModes, CFRunLoop, CFRunLoopMode, CFRunLoopRef, CFRunLoopSource, + CFRunLoopSourceRef, CFRunLoopTimer, CFRunLoopTimerContext, CFRunLoopTimerRef, +}; +use std::ffi::c_void; +use std::marker::PhantomData; use std::os::raw::c_int; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::ptr; +use std::sync::atomic::{AtomicBool, Ordering}; // ── IOKit FFI declarations ────────────────────────────────────────── @@ -17,10 +55,59 @@ const KERN_SUCCESS: IOReturn = 0; const K_IO_HID_PARAM_CONNECT_TYPE: u32 = 1; const K_IO_HID_CAPS_LOCK_STATE: c_int = 1; +// IOKit system-power message types, from `IOMessage.h`. +// +// `IOMessage` is `UInt32` (`natural_t` = `u32` on Darwin — never `u64`/`usize`). +// Values are `iokit_common_msg(msg)` = `sys_iokit | sub_iokit_common | msg`, +// where `sys_iokit = err_system(0x38) = 0x38 << 26 = 0xe0000000` and +// `sub_iokit_common = err_sub(0) = 0`. Confirmed against the MacOSX15.4.sdk +// `IOMessage.h` / `IOReturn.h` / `mach/error.h` headers. +// +// Only the six `kIOMessageSystem*` / `kIOMessageCanSystemSleep` constants +// delivered to `IORegisterForSystemPower` user clients are needed. +// `WILL_NOT_SLEEP` and `WILL_POWER_ON` are matched by the `_` arm in +// `route_power_message` but are named explicitly in tests to pin the values. +#[allow(dead_code)] // referenced in route_power_message tests +const K_IO_MESSAGE_CAN_SYSTEM_SLEEP: u32 = 0xe000_0270; // kIOMessageCanSystemSleep +#[allow(dead_code)] // referenced in route_power_message tests +const K_IO_MESSAGE_SYSTEM_WILL_NOT_SLEEP: u32 = 0xe000_0290; // kIOMessageSystemWillNotSleep +const K_IO_MESSAGE_SYSTEM_WILL_SLEEP: u32 = 0xe000_0280; // kIOMessageSystemWillSleep +#[allow(dead_code)] // referenced in route_power_message tests +const K_IO_MESSAGE_SYSTEM_WILL_POWER_ON: u32 = 0xe000_0320; // kIOMessageSystemWillPowerOn +const K_IO_MESSAGE_SYSTEM_HAS_POWERED_ON: u32 = 0xe000_0300; // kIOMessageSystemHasPoweredOn + +/// Distant-future `CFAbsoluteTime` used to park the debounce timer when +/// disarmed. ~31,700 years from the 2001-01-01 CF epoch. +const TIMER_DISTANT_FUTURE: f64 = 1e12f64; + +/// Repeat interval for the debounce timer. Per Apple's reusable-timer pattern +/// (`CFRunLoopTimer` docs), a **repeating** timer with a very large interval +/// (decades) can be rescheduled indefinitely via +/// `CFRunLoopTimerSetNextFireDate`. A non-repeating timer (interval=0) +/// **self-invalidates after the first fire** and cannot be rearmed — that was +/// the blocking review finding. This constant must never be 0; the +/// `timer_interval_is_nonzero` test enforces that. +const TIMER_REPEAT_INTERVAL: f64 = TIMER_DISTANT_FUTURE; + +// Opaque CoreFoundation / IOKit handle pointers. `IONotificationPortRef` +// is an IOKit type not exposed by `core-foundation`, so we declare it as +// `*mut c_void`. The `CFRunLoop*Ref` types are re-exported by +// `core-foundation` via `pub use core_foundation_sys::runloop::*`. +type IONotificationPortRef = *mut c_void; + +// IOKit interest callback signature (IOPMLib.h / IOKitLib.h). `message_type` +// is `natural_t` = `u32` on Darwin. +type IOServiceInterestCallback = extern "C" fn( + refcon: *mut c_void, + service: u32, + message_type: u32, + message_argument: *mut c_void, +); + #[link(name = "IOKit", kind = "framework")] extern "C" { - fn IOServiceGetMatchingService(main_port: u32, matching: *const std::ffi::c_void) -> u32; - fn IOServiceMatching(name: *const std::os::raw::c_char) -> *const std::ffi::c_void; + fn IOServiceGetMatchingService(main_port: u32, matching: *const c_void) -> u32; + fn IOServiceMatching(name: *const std::os::raw::c_char) -> *const c_void; fn IOServiceOpen( service: u32, owning_task: u32, @@ -31,6 +118,24 @@ extern "C" { fn IOObjectRelease(object: u32) -> IOReturn; fn IOHIDGetModifierLockState(handle: u32, selector: c_int, state: *mut bool) -> IOReturn; fn IOHIDSetModifierLockState(handle: u32, selector: c_int, state: bool) -> IOReturn; + + // ── Power management (IOPMLib) ─────────────────────────────────── + // IORegisterForSystemPower returns the io_connect_t (root_port) for the + // implicitly-opened IOPMrootDomain; the caller must IOServiceClose it + // after IODeregisterForSystemPower. `thePortRef` is allocated by the + // call and must be released via IONotificationPortDestroy. The + // CFRunLoopSource returned by IONotificationPortGetRunLoopSource is + // owned by the port — never CFRelease it (IOKitLib.h). + fn IORegisterForSystemPower( + refcon: *mut c_void, + the_port_ref: *mut IONotificationPortRef, + callback: IOServiceInterestCallback, + notifier: *mut u32, + ) -> u32; // io_connect_t (root_port) + fn IODeregisterForSystemPower(notifier: *mut u32) -> IOReturn; + fn IONotificationPortDestroy(notify: IONotificationPortRef); + fn IONotificationPortGetRunLoopSource(notify: IONotificationPortRef) -> CFRunLoopSourceRef; + fn IOAllowPowerChange(root_port: u32, notification_id: isize) -> IOReturn; // intptr_t } // mach_task_self() is a macro in C that expands to this global variable @@ -38,6 +143,27 @@ extern "C" { static mach_task_self_: u32; } +// ── CoreFoundation timer/run-loop FFI (not wrapped by core-foundation 0.10) ── +// +// `core-foundation 0.10` exposes `CFRunLoopTimer::new` and +// `CFRunLoopTimerContext`, but does NOT expose +// `CFRunLoopTimerSetNextFireDate`, `CFRunLoopTimerInvalidate`, +// `CFRunLoopAddSource`, or `CFRunLoopAddTimer` in a form usable with raw +// port-owned sources (its `CFRunLoop::add_source` takes a `&CFRunLoopSource` +// which would `CFRelease` on drop — incompatible with the port-owned source +// from `IONotificationPortGetRunLoopSource`). We declare the raw symbols here, +// linked against CoreFoundation (already a transitive dependency via the +// `core-foundation` crate). +#[link(name = "CoreFoundation", kind = "framework")] +extern "C" { + fn CFRunLoopAddSource(rl: CFRunLoopRef, source: CFRunLoopSourceRef, mode: CFRunLoopMode); + fn CFRunLoopRemoveSource(rl: CFRunLoopRef, source: CFRunLoopSourceRef, mode: CFRunLoopMode); + fn CFRunLoopAddTimer(rl: CFRunLoopRef, timer: CFRunLoopTimerRef, mode: CFRunLoopMode); + fn CFRunLoopTimerSetNextFireDate(timer: CFRunLoopTimerRef, fire_date: CFAbsoluteTime); + fn CFRunLoopTimerInvalidate(timer: CFRunLoopTimerRef); + fn CFAbsoluteTimeGetCurrent() -> CFAbsoluteTime; +} + // ── Safe public API ───────────────────────────────────────────────── /// Toggle the system caps lock state on/off via `IOKit`. @@ -86,7 +212,6 @@ pub fn toggle_caps_lock() -> Result<(), String> { // Toggle it let new_state = !current_state; - log::debug!("Caps lock: current={current_state}, setting to={new_state}"); let kr = IOHIDSetModifierLockState(connection, K_IO_HID_CAPS_LOCK_STATE, new_state); IOServiceClose(connection); @@ -94,7 +219,6 @@ pub fn toggle_caps_lock() -> Result<(), String> { return Err(format!("IOHIDSetModifierLockState failed: {kr:#x}")); } - log::info!("Caps lock toggled: {current_state} → {new_state}"); Ok(()) } } @@ -114,3 +238,702 @@ pub fn add_source_to_current_run_loop(source: &CFRunLoopSource) { let current = CFRunLoop::get_current(); current.add_source(source, common_run_loop_mode()); } + +// ── Power-notification safe API ───────────────────────────────────── + +/// Pure routing decision for a single `IOKit` system-power message. The +/// callback trampoline dispatches on this so the ack/trigger rules are +/// unit-testable without any `IOKit` call. Matches Apple QA1340 / `IOPMLib.h`: +/// +/// - `Acknowledge` — `kIOMessageCanSystemSleep` and `kIOMessageSystemWillSleep` +/// **must** call `IOAllowPowerChange`; failing to ack `WillSleep` delays +/// sleep by 30 s. +/// - `ScheduleWakeReapply` — `kIOMessageSystemHasPoweredOn` (wake complete, +/// drivers ready); **must not** be acknowledged; this is the reapply +/// trigger. +/// - `Ignore` — `kIOMessageSystemWillPowerOn` (early wake, hardware not +/// ready), `kIOMessageSystemWillNotSleep` (idle sleep vetoed), and any +/// unknown message; **must not** be acknowledged; no action. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PowerAction { + Acknowledge, + Ignore, + ScheduleWakeReapply, +} + +/// Route an `IOKit` system-power `messageType` (`natural_t` = `u32`) to the +/// action the callback should take. Pure: no `IOKit` calls, no allocations. +/// +/// Constants verified against +/// `MacOSX15.4.sdk/System/Library/Frameworks/IOKit.framework/Headers/IOMessage.h` +/// (values computed from `iokit_common_msg(msg)` = `0xe0000000 | msg`). +pub fn route_power_message(message_type: u32) -> PowerAction { + match message_type { + K_IO_MESSAGE_CAN_SYSTEM_SLEEP | K_IO_MESSAGE_SYSTEM_WILL_SLEEP => PowerAction::Acknowledge, + K_IO_MESSAGE_SYSTEM_HAS_POWERED_ON => PowerAction::ScheduleWakeReapply, + // WillPowerOn (early wake, hw not ready), WillNotSleep (idle vetoed), + // and any unknown message: do nothing, never ack. + _ => PowerAction::Ignore, + } +} + +/// Boxed context shared by the power callback and the timer callback. Owned +/// by `PowerWatcher` via `Box::into_raw`; reclaimed in `Drop` **after** +/// `IONotificationPortDestroy` guarantees no more callbacks fire. +/// +/// Holds: +/// - `on_wake`: the user closure invoked once the debounce timer fires. +/// - `root_port`: the `io_connect_t` from `IORegisterForSystemPower`, needed +/// by the power trampoline to call `IOAllowPowerChange`. +/// - `shutdown`: belt-and-suspenders flag checked at the top of the timer +/// trampoline so a fire racing with `Drop` is a no-op. +struct PowerContext { + on_wake: Box, + root_port: u32, + shutdown: AtomicBool, + /// Raw back-pointer to the debounce timer so the power trampoline can + /// reschedule it without reaching into `PowerWatcher` (the watcher owns + /// the `CFRunLoopTimer` wrapper which owns the ref; the trampoline only + /// reads the raw ref to call `CFRunLoopTimerSetNextFireDate`). Set after + /// the timer is created in [`PowerWatcher::new_with_debounce`]. + timer_ref: CFRunLoopTimerRef, + /// Debounce window in seconds (reschedule delay on each + /// `kIOMessageSystemHasPoweredOn`). Stored in the context so the power + /// trampoline reads it without reaching into `PowerWatcher`. + debounce_seconds: f64, +} + +/// RAII owner of the `IOKit` system-power registration + debounce timer. +/// +/// `!Send` / `!Sync`: the `IOKit` handles and `CFRunLoopSource` are +/// thread-affine — they must be created and dropped on the same thread whose +/// `CFRunLoop` they were registered against (the main thread in production). +/// The `PhantomData<*mut ()>` marker enforces this at compile time. +/// +/// Created on the main thread **before** `CFRunLoop::run_current()` is +/// called, so the power source and timer are attached to the same run loop +/// the `CGEventTap` runs on. +pub struct PowerWatcher { + root_port: u32, + notify_port: IONotificationPortRef, + notifier: u32, + /// Raw `CFRunLoopSourceRef` owned by `notify_port`. We store the raw ref + /// (never wrapped in `CFRunLoopSource`) so `Drop` can + /// `CFRunLoopRemoveSource` it without a `CFRelease` — per IOKitLib.h the + /// source is freed by `IONotificationPortDestroy`. + run_loop_source: CFRunLoopSourceRef, + /// The debounce timer. Wrapped in `CFRunLoopTimer` (Create Rule: the + /// wrapper owns one reference released in its `Drop`). + timer: CFRunLoopTimer, + /// Boxed context reclaimed last in `Drop`. + context: *mut PowerContext, + _not_send: PhantomData<*mut ()>, +} + +impl PowerWatcher { + /// Create a `PowerWatcher` with a custom debounce window (seconds). Used + /// by tests to avoid wall-clock waits; production callers pass + /// `WAKE_DEBOUNCE`. + /// + /// Call this on the main thread before `CFRunLoop::run_current()`. + /// + /// # Partial-registration safety + /// + /// Every valid handle is cleaned up on every failure path in safe QA1340 + /// order. The boxed `PowerContext` is reclaimed **last**, only after + /// `IONotificationPortDestroy` guarantees no more power callbacks can fire + /// and the timer was never attached (or has been invalidated). + #[allow(clippy::too_many_lines, clippy::items_after_statements)] + pub fn new_with_debounce( + on_wake: Box, + debounce_seconds: f64, + ) -> Result { + // Box the context so its address is stable for the FFI refcon/timer + // info pointers. Reclaimed in Drop or on any failure path. + let context = Box::into_raw(Box::new(PowerContext { + on_wake, + root_port: 0, // set after registration succeeds + shutdown: AtomicBool::new(false), + timer_ref: ptr::null_mut(), // set after the timer is created + debounce_seconds, + })); + + let mut notify_port: IONotificationPortRef = ptr::null_mut(); + let mut notifier: u32 = 0; + + // Safety: IORegisterForSystemPower writes the port/notifier out-params + // and stores `context` as the opaque refcon. The callback is + // `power_callback_trampoline` (catch_unwind-guarded, infallible). + let root_port = unsafe { + IORegisterForSystemPower( + context.cast::(), + &raw mut notify_port, + power_callback_trampoline, + &raw mut notifier, + ) + }; + + // Partial-registration RAII guard: on any failure path (early return + // or `?`), `Drop` independently cleans up every valid IOKit resource + // in QA1340 order, then reclaims the context last. This replaces the + // old closure-based cleanup with a provable ownership model. + // + // Resource handling (each independent): + // root_port != 0 → IODeregisterForSystemPower (if notifier != 0), + // then IOServiceClose(root_port) + // notify_port != null → IONotificationPortDestroy(notify_port) + // source_added == true → CFRunLoopRemoveSource first (before port destroy) + // context != null → Box::from_raw(context) [LAST, after all callbacks impossible] + struct PartialRegistrationGuard { + root_port: u32, + notify_port: IONotificationPortRef, + notifier: u32, + context: *mut PowerContext, + /// The run-loop source ref, if it was obtained and added to the + /// run loop. Tracked so `Drop` can `CFRunLoopRemoveSource` before + /// destroying the port (prevents a dangling source in the run loop + /// if construction fails after the source was attached). + run_loop_source: CFRunLoopSourceRef, + /// True once the source has been added to the current run loop. + source_added: bool, + /// When true, the guard has been disarmed (construction succeeded) + /// and `Drop` should not clean up. + disarmed: bool, + } + + impl Drop for PartialRegistrationGuard { + fn drop(&mut self) { + if self.disarmed { + return; + } + // Safety: Each resource is cleaned up independently and in + // QA1340 order. If the source was added to the run loop, + // remove it first (before destroying the port that owns it). + unsafe { + // 0. Remove the run-loop source if it was attached. + if self.source_added && !self.run_loop_source.is_null() { + let current = CFRunLoop::get_current(); + CFRunLoopRemoveSource( + current.as_concrete_TypeRef(), + self.run_loop_source, + kCFRunLoopCommonModes, + ); + } + // 1. Deregister the notifier if valid (stops power callbacks). + if self.notifier != 0 { + let _ = IODeregisterForSystemPower(&raw mut self.notifier); + } + // 2. Close the root connection if valid (closes IOPMrootDomain). + if self.root_port != 0 { + let _ = IOServiceClose(self.root_port); + } + // 3. Destroy the notification port if non-null (frees the + // run-loop source — never CFRelease it ourselves). + if !self.notify_port.is_null() { + IONotificationPortDestroy(self.notify_port); + } + // 4. Reclaim the context LAST — after all callbacks are + // impossible (port destroyed, connection closed). + if !self.context.is_null() { + drop(Box::from_raw(self.context)); + } + } + } + } + + let mut guard = PartialRegistrationGuard { + root_port, + notify_port, + notifier, + context, + run_loop_source: ptr::null_mut(), + source_added: false, + disarmed: false, + }; + + if root_port == 0 || notify_port.is_null() { + // No valid registration — guard.Drop cleans up any partial + // resources (e.g. a non-null port or nonzero root_port from a + // partial IOKit result) and reclaims the context. + return Err("IORegisterForSystemPower failed (root_port=0)".to_string()); + } + + // Publish the root_port into the context so the trampoline can ack. + // Safety: `context` is valid and no callback can fire between the + // register call and this store (the run loop is not yet running, and + // even if a wakeup happened the source isn't attached yet). + unsafe { (*context).root_port = root_port }; + + // Get the run-loop source owned by the port. Do NOT wrap it in + // `CFRunLoopSource` (that would CFRelease it on drop — double-free; + // the port owns it). Keep the raw ref and add/remove manually. + let run_loop_source = unsafe { IONotificationPortGetRunLoopSource(notify_port) }; + if run_loop_source.is_null() { + // Guard.Drop performs full QA1340 cleanup: deregister, close, + // destroy port, reclaim context — all in safe order. + return Err("IONotificationPortGetRunLoopSource returned null".to_string()); + } + + // Track the source in the guard so Drop can remove it if we fail + // after adding it to the run loop. + guard.run_loop_source = run_loop_source; + + // Create the debounce timer BEFORE adding the IOKit run-loop source. + // This eliminates the construction-unwind attached-source risk: if + // timer creation fails (or panics during an unwind), the source was + // never attached to the run loop, so `PartialRegistrationGuard::Drop` + // only needs to destroy the port — no `CFRunLoopRemoveSource` needed. + // + // The timer is a **repeating** timer with a distant interval. Per + // Apple's CFRunLoopTimer documentation, a non-repeating timer + // (interval=0) self-invalidates after its first fire and can never + // be rearmed via SetNextFireDate — which silently broke wake reapply + // on the second sleep/wake cycle. A repeating timer with a + // decades-long interval stays valid indefinitely and is rearmed on + // each wake event by SetNextFireDate(now + debounce). The enormous + // interval only matters if the timer is never rescheduled, which + // never happens in practice (each wake resets the fire date ~2 s out). + // + // DO NOT change interval to 0 — see the `timer_interval_is_nonzero` + // test and the review finding that documented this bug. + let mut timer_ctx = CFRunLoopTimerContext { + version: 0, + info: context.cast::(), + retain: None, + release: None, + copyDescription: None, + }; + let timer = CFRunLoopTimer::new( + TIMER_DISTANT_FUTURE, // fire date: far future (disarmed) + TIMER_REPEAT_INTERVAL, // interval: decades (repeating, never self-invalidates) + 0, // flags + 0, // order + timer_trampoline, + &raw mut timer_ctx, + ); + + // Publish the timer ref into the context so the power trampoline can + // reschedule it. CFRunLoopTimer::new followed the Create Rule so + // `timer.as_concrete_TypeRef()` is a valid +1 reference we may read. + let timer_ref = timer.as_concrete_TypeRef(); + unsafe { (*context).timer_ref = timer_ref }; + + // Timer created successfully — now safe to attach the IOKit run-loop + // source and the timer to the current run loop. If either attachment + // somehow fails (they are infallible C calls, but for safety the + // guard tracks `source_added` so Drop can remove it). + let current = CFRunLoop::get_current(); + unsafe { + CFRunLoopAddSource( + current.as_concrete_TypeRef(), + run_loop_source, + kCFRunLoopCommonModes, + ); + } + guard.source_added = true; + + // Attach the timer to the current run loop under common modes so it + // fires alongside the event-tap source. + unsafe { + CFRunLoopAddTimer( + current.as_concrete_TypeRef(), + timer_ref, + kCFRunLoopCommonModes, + ); + } + + // Construction succeeded — disarm the partial-registration guard + // so its Drop does not clean up the handles now owned by PowerWatcher. + guard.disarmed = true; + + Ok(PowerWatcher { + root_port, + notify_port, + notifier, + run_loop_source, + timer, + context, + _not_send: PhantomData, + }) + } + + /// Disarm the debounce timer by pushing its fire date into the far future. + /// Used on shutdown to guarantee the timer callback never fires after + /// teardown begins. `Drop` additionally invalidates the timer. Valid on a + /// repeating timer (unlike the old interval=0 design). + pub fn cancel_debounce(&self) { + // Safety: timer_ref is valid; the timer is repeating so it has not + // self-invalidated. Setting a far-future date parks it. + unsafe { + CFRunLoopTimerSetNextFireDate(self.timer.as_concrete_TypeRef(), TIMER_DISTANT_FUTURE); + } + } + + /// Mark the context's shutdown flag so a timer fire racing with `Drop` is + /// a no-op. `Drop` also invalidates the timer; this is belt-and-suspenders. + pub fn mark_shutdown(&self) { + // Safety: context is valid until Drop reclaims it (last step). The + // AtomicBool store is non-allocating and infallible. + unsafe { (*self.context).shutdown.store(true, Ordering::SeqCst) }; + } +} + +impl Drop for PowerWatcher { + fn drop(&mut self) { + // Belt-and-suspenders: mark shutdown first so any callback racing + // with teardown is a no-op, then disarm the timer. + self.mark_shutdown(); + self.cancel_debounce(); + + // Exact QA1340 cleanup order (IOPMLib.h / IOKitLib.h): + // 1. Invalidate the debounce timer (stops any pending fire). + // 2. Remove the power source from the run loop (releases the run + // loop's reference; the port still owns its own ref). + // 3. IODeregisterForSystemPower (must come BEFORE port destroy). + // 4. IOServiceClose(root_port) (closes IOPMrootDomain). + // 5. IONotificationPortDestroy (must come LAST; frees the + // CFRunLoopSource — never CFRelease it ourselves). + // 6. Reclaim the boxed context (last, after no more callbacks). + // Safety: all handles are valid and the run loop is either stopped + // or not servicing this source anymore. + unsafe { + CFRunLoopTimerInvalidate(self.timer.as_concrete_TypeRef()); + let current = CFRunLoop::get_current(); + CFRunLoopRemoveSource( + current.as_concrete_TypeRef(), + self.run_loop_source, + kCFRunLoopCommonModes, + ); + let _ = IODeregisterForSystemPower(&raw mut self.notifier); + let _ = IOServiceClose(self.root_port); + IONotificationPortDestroy(self.notify_port); + // Reclaim the context. After IONotificationPortDestroy no power + // callback can fire, and after CFRunLoopTimerInvalidate no timer + // callback can fire, so the box is safe to drop. + drop(Box::from_raw(self.context)); + } + // self.timer (CFRunLoopTimer) drops here and CFReleases its +1 ref. + // The run_loop_source raw ref is NOT released — the port owned it and + // IONotificationPortDestroy already freed it. + } +} + +// ── FFI trampolines (panic-contained: catch_unwind at every C boundary) ── + +/// `IORegisterForSystemPower` callback. Dispatches via the pure +/// [`route_power_message`] router; acks sleep messages with +/// `IOAllowPowerChange` (never acks wake messages); on `HasPoweredOn` +/// reschedules the repeating debounce timer. All work is wrapped in +/// `catch_unwind` so a panic can never unwind across the `extern "C"` ABI +/// (which would abort the process). +extern "C" fn power_callback_trampoline( + refcon: *mut c_void, + _service: u32, + message_type: u32, + message_argument: *mut c_void, +) { + // catch_unwind: a panic here must not cross the C ABI. The body only + // calls infallible IOKit primitives, but the guard is structural. + let _ = catch_unwind(AssertUnwindSafe(|| { + power_callback_body(refcon, message_type, message_argument); + })); +} + +fn power_callback_body(refcon: *mut c_void, message_type: u32, message_argument: *mut c_void) { + if refcon.is_null() { + return; + } + // Safety: refcon is the boxed PowerContext; it is valid for the lifetime + // of PowerWatcher and this callback only fires while registered. We only + // read it and call infallible ops (IOAllowPowerChange, atomic store, + // CFRunLoopTimerSetNextFireDate). No allocations. + let ctx: *mut PowerContext = refcon.cast::(); + + match route_power_message(message_type) { + PowerAction::Acknowledge => { + // Must ack kIOMessageCanSystemSleep and kIOMessageSystemWillSleep + // or the system delays sleep by 30 s. root_port was published + // after registration; read it atomically-ish (plain u32 load is + // fine — it is set once before the run loop starts). + let root_port = unsafe { (*ctx).root_port }; + if root_port != 0 { + // intptr_t is isize on 64-bit Darwin. + // Safety: root_port is a valid io_connect_t from registration. + let _ = unsafe { IOAllowPowerChange(root_port, message_argument as isize) }; + } + } + PowerAction::ScheduleWakeReapply => { + // Wake complete: (re)arm the repeating debounce timer. Repeats + // coalesce — SetNextFireDate resets the single timer, no second + // timer. The timer is repeating (interval=TIMER_REPEAT_INTERVAL) + // so it does NOT self-invalidate after a fire. + let timer_ref = unsafe { (*ctx).timer_ref }; + if !timer_ref.is_null() { + let debounce = unsafe { (*ctx).debounce_seconds }; + // Safety: timer_ref is valid; CFAbsoluteTimeGetCurrent is a + // pure time query. The only allocation is the f64 on stack. + let fire_date = unsafe { CFAbsoluteTimeGetCurrent() + debounce }; + unsafe { CFRunLoopTimerSetNextFireDate(timer_ref, fire_date) }; + } + } + PowerAction::Ignore => { + // kIOMessageSystemWillPowerOn / WillNotSleep / unknown: do nothing, + // never ack. + } + } +} + +/// `CFRunLoopTimer` callback. Fires ~2 s after the last +/// `kIOMessageSystemHasPoweredOn` (debounced). Checks the shutdown flag, then +/// invokes `on_wake` inside `catch_unwind` so a panic in the closure (e.g. +/// from a hidutil subprocess error) can never unwind across the `extern "C"` +/// ABI. On panic, the trampoline returns silently — no diagnostic is emitted +/// outside the unwind guard because any Rust I/O (including `eprintln!`) +/// can itself panic and would escape the `catch_unwind` boundary. +extern "C" fn timer_trampoline(_timer: CFRunLoopTimerRef, info: *mut c_void) { + // catch_unwind: the user closure does filesystem work, mutex access, + // logging, and subprocess execution. A panic must not cross the C ABI. + // On panic, return silently (the watcher remains registered; the next + // wake event reschedules normally). No post-catch I/O — it could panic + // outside the guard. + let _ = catch_unwind(AssertUnwindSafe(|| { + timer_callback_body(info); + })); +} + +fn timer_callback_body(info: *mut c_void) { + // Safety: info is the boxed PowerContext; valid for the lifetime of + // PowerWatcher. We only read the AtomicBool and call the closure. + if info.is_null() { + return; + } + let ctx: *mut PowerContext = info.cast::(); + if unsafe { (*ctx).shutdown.load(Ordering::SeqCst) } { + return; + } + // Invoke the user closure. Borrowing `on_wake` via the raw pointer is + // safe: the closure is owned by the box and only this callback (or Drop) + // touches it. Reentrancy is impossible — CFRunLoop sources/timers are + // not reentrant and this runs on the run-loop thread. + let on_wake: &dyn Fn() = unsafe { &(*ctx).on_wake }; + on_wake(); +} + +// ── CoreFoundation run-loop source FFI (not wrapped by core-foundation 0.10 +// in a form usable for raw port-owned sources) ────────────────────── +// +// core-foundation 0.10's CFRunLoop::add_source/remove_source take a +// `&CFRunLoopSource` which would CFRelease on drop — incompatible with the +// port-owned source from IONotificationPortGetRunLoopSource. The raw +// CFRunLoopAddSource/CFRunLoopRemoveSource/CFRunLoopAddTimer symbols are +// declared above in the CoreFoundation extern block. + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + // ── route_power_message: QA1340 ack/ignore/trigger table ────────── + // + // Pins the exact kIOMessage* constants (verified against the MacOSX15.4.sdk + // IOMessage.h header) to the required action. A wrong width (u64/usize) or + // a wrong value would silently never fire, so these tests guard against + // both regressions. + + #[test] + fn can_system_sleep_is_acknowledged() { + assert_eq!( + route_power_message(K_IO_MESSAGE_CAN_SYSTEM_SLEEP), + PowerAction::Acknowledge + ); + } + + #[test] + fn system_will_sleep_is_acknowledged() { + assert_eq!( + route_power_message(K_IO_MESSAGE_SYSTEM_WILL_SLEEP), + PowerAction::Acknowledge + ); + } + + #[test] + fn system_has_powered_on_schedules_wake_reapply() { + assert_eq!( + route_power_message(K_IO_MESSAGE_SYSTEM_HAS_POWERED_ON), + PowerAction::ScheduleWakeReapply + ); + } + + #[test] + fn system_will_power_on_is_ignored() { + assert_eq!( + route_power_message(K_IO_MESSAGE_SYSTEM_WILL_POWER_ON), + PowerAction::Ignore + ); + } + + #[test] + fn system_will_not_sleep_is_ignored() { + assert_eq!( + route_power_message(K_IO_MESSAGE_SYSTEM_WILL_NOT_SLEEP), + PowerAction::Ignore + ); + } + + #[test] + fn unknown_message_is_ignored() { + assert_eq!(route_power_message(0), PowerAction::Ignore); + assert_eq!(route_power_message(u32::MAX), PowerAction::Ignore); + assert_eq!(route_power_message(0xdead_beef), PowerAction::Ignore); + } + + /// Sanity-check the computed constant values against the header formula + /// `iokit_common_msg(msg) = 0xe0000000 | msg` so a future SDK change or + /// a transcription typo is caught. + #[test] + fn iomessage_constants_match_header_formula() { + assert_eq!(K_IO_MESSAGE_CAN_SYSTEM_SLEEP, 0xe000_0270); + assert_eq!(K_IO_MESSAGE_SYSTEM_WILL_SLEEP, 0xe000_0280); + assert_eq!(K_IO_MESSAGE_SYSTEM_WILL_NOT_SLEEP, 0xe000_0290); + assert_eq!(K_IO_MESSAGE_SYSTEM_WILL_POWER_ON, 0xe000_0320); + assert_eq!(K_IO_MESSAGE_SYSTEM_HAS_POWERED_ON, 0xe000_0300); + } + + /// The debounce timer MUST be repeating (interval != 0). A non-repeating + /// timer (interval=0) self-invalidates after its first fire and can never + /// be rearmed via `CFRunLoopTimerSetNextFireDate` — which silently broke + /// wake reapply on the second sleep/wake cycle (review Finding 1). + #[test] + #[allow(clippy::assertions_on_constants)] // intentional regression guard on a constant + fn timer_repeat_interval_is_nonzero() { + // Use > comparison instead of assert_ne to avoid f64 strict-equality + // clippy warnings. + assert!( + TIMER_REPEAT_INTERVAL > 0.0f64, + "interval=0 creates a self-invalidating one-shot timer; \ + wake reapply would work only once per daemon run" + ); + // Also verify the distant-future constant is sane (far enough to + // effectively disarm the timer when parked). + assert!(TIMER_DISTANT_FUTURE > 1e9f64, "distant future must be far"); + } + + /// The timer trampoline must contain panics via `catch_unwind` so they + /// never unwind across the `extern "C"` ABI. This test verifies the + /// `catch_unwind` guard pattern catches an injected panic. + #[test] + fn timer_trampoline_contains_panicking_closure() { + let panicking_closure: Box = Box::new(|| { + panic!("injected test panic"); + }); + // This mirrors what timer_trampoline does: + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + panicking_closure(); + })); + assert!(result.is_err(), "panic should be caught, not propagated"); + } + + /// Verify that `ReapplyPolicy` supports unlimited wake cycles — + /// schedule/fire/schedule/fire — without any state that would prevent + /// the second cycle from firing. This directly tests the review finding + /// that the timer worked only once. + #[test] + fn policy_supports_unlimited_wake_cycles() { + let mut policy = crate::wake::ReapplyPolicy::default_debounce(); + let t0 = std::time::Instant::now(); + + // Cycle 1: wake at t0, fire at t0+2s + let d1 = policy.on_wake_message(t0).expect("cycle 1 should schedule"); + assert_eq!(d1, crate::wake::WAKE_DEBOUNCE); + + // Cycle 2: wake at t0+10s (after a hypothetical sleep/wake), fire at t0+12s + let t1 = t0 + std::time::Duration::from_secs(10); + let d2 = policy.on_wake_message(t1).expect("cycle 2 should schedule"); + assert_eq!(d2, crate::wake::WAKE_DEBOUNCE); + assert_eq!(policy.last_wake(), Some(t1)); + + // Cycle 3: wake at t0+20s — still works + let t2 = t0 + std::time::Duration::from_secs(20); + let d3 = policy.on_wake_message(t2).expect("cycle 3 should schedule"); + assert_eq!(d3, crate::wake::WAKE_DEBOUNCE); + assert_eq!(policy.last_wake(), Some(t2)); + + // The policy never refuses to schedule — no "already fired" state. + assert!(!policy.is_cancelled()); + } + + /// Verify the construction order: timer is created BEFORE the `IOKit` + /// run-loop source is attached, so a timer-creation failure (or a + /// panic during timer creation) cannot leave a dangling source in + /// the run loop. This is a pure planner test since we can't call the + /// real FFI — it verifies the cleanup plan for each failure stage. + #[test] + fn timer_creation_failure_leaves_no_attached_source() { + // The construction order in new_with_debounce is: + // 1. IORegisterForSystemPower → root_port, notify_port, notifier + // 2. PartialRegistrationGuard created (tracks source_added = false) + // 3. IONotificationPortGetRunLoopSource → run_loop_source + // 4. CFRunLoopTimer::new → timer ← BEFORE source add + // 5. CFRunLoopAddSource (source_added = true) ← AFTER timer + // 6. CFRunLoopAddTimer + // + // If step 4 fails (or panics), steps 5-6 never execute, so + // source_added is still false. PartialRegistrationGuard::Drop + // sees source_added=false and skips CFRunLoopRemoveSource — + // correct, because the source was never added. + // + // Pure planner: verify the cleanup plan for a failure at step 4. + #[derive(Debug, PartialEq)] + #[allow(clippy::struct_excessive_bools)] // test-only planner + struct CleanupAction { + remove_source: bool, + deregister: bool, + close: bool, + destroy_port: bool, + reclaim_context: bool, + } + + // Simulate: timer creation fails after source was obtained but + // before it was added to the run loop. + let source_added = false; // step 5 never ran + let run_loop_source_non_null = true; // step 3 succeeded + let root_port_valid = true; + let notify_port_non_null = true; + let notifier_valid = true; + let context_valid = true; + + let plan = CleanupAction { + remove_source: source_added && run_loop_source_non_null, + deregister: notifier_valid, + close: root_port_valid, + destroy_port: notify_port_non_null, + reclaim_context: context_valid, + }; + + // Key assertion: source is NOT removed (it was never added). + assert!( + !plan.remove_source, + "source must not be removed if it was never added to the run loop" + ); + // But all other resources ARE cleaned up. + assert!(plan.deregister, "notifier must be deregistered"); + assert!(plan.close, "root_port must be closed"); + assert!(plan.destroy_port, "notify_port must be destroyed"); + assert!(plan.reclaim_context, "context must be reclaimed"); + + // Contrast: if construction failed AFTER source was added (the old + // order), the source WOULD need removal — which the old guard didn't + // track. The new order avoids this entirely. + let old_order_plan = CleanupAction { + remove_source: true, // source WAS added before timer creation + deregister: true, + close: true, + destroy_port: true, + reclaim_context: true, + }; + assert!( + old_order_plan.remove_source, + "old order would need source removal" + ); + } +} diff --git a/src/main.rs b/src/main.rs index c3ae49d..f95ba80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,22 +2,77 @@ mod config; mod engine; mod event_tap; mod hidutil; +mod home; mod keycode; mod macos_ffi; +mod wake; use core_foundation::runloop::CFRunLoop; +use hidutil::AppliedMappings; use log::info; use signal_hook::consts::{SIGINT, SIGTERM}; use signal_hook::iterator::Signals; -use std::path::PathBuf; +use std::io::Write; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; use std::thread; /// Whether we applied hidutil remaps and need to clean them up on exit. static HIDUTIL_ACTIVE: AtomicBool = AtomicBool::new(false); +/// Whether shutdown is in progress. Set to `true` at the top of the +/// post-`event_tap::run()` cleanup block so the wake-reapply timer callback +/// (which runs on the same main run loop) can observe it and skip any +/// pending reapply. Belt-and-suspenders: `PowerWatcher::drop` also +/// invalidates the timer and cancels the debounce before `remove_owned_mappings`. +static SHUTTING_DOWN: AtomicBool = AtomicBool::new(false); + +/// Serializes all live hidutil transactions (startup apply, wake reapply, +/// normal shutdown cleanup, panic cleanup) so a wake reapply's multi-step +/// full-replacement transaction cannot interleave with a concurrent panic +/// cleanup. The panic hook uses `try_lock`: if the lock is held by the +/// panicking thread (or is poisoned), it skips live mutation and relies on +/// the durable state file for next-start reconciliation. +static HIDUTIL_TRANSACTION_LOCK: Mutex<()> = Mutex::new(()); + +/// Holds the Switcheroo-owned hidutil mappings so the panic hook can remove +/// only those entries (preserving unrelated mappings) even when `run()`'s +/// locals are gone. Guarded by a `std::sync::Mutex` because the panic hook +/// runs on whatever thread panics; it's only ever touched at apply/shutdown +/// time, so contention is a non-issue. +static APPLIED_MAPPINGS: Mutex> = Mutex::new(None); + +/// Embed the Cargo package version for `--version`. This is a compile-time +/// constant with no runtime cost and stays in sync with `Cargo.toml` +/// automatically. +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Per-user log directory under `~/Library/Logs`. Using the bundle id keeps +/// the logs co-located with the daemon identity and avoids the +/// world-writable `/tmp` symlink-attack surface. +const LOG_DIR_NAME: &str = "com.mitchelljphayes.switcheroo"; +const LOG_FILE_NAME: &str = "daemon.log"; +const ERR_FILE_NAME: &str = "daemon.err"; + +/// Return the current real uid via `libc::getuid`. The crate denies +/// `unsafe_code` except in `macos_ffi.rs`; we scope an allow here for the +/// FFI call. +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] // scoped: only the libc::getuid() call +fn current_uid() -> u32 { + // Safety: `getuid` is a pure syscall with no preconditions. + unsafe { libc::getuid() } +} + +/// Return true if `args` request `--version` / `-V`. Exposed for unit tests. +fn is_version_request(args: &[String]) -> bool { + args.len() == 2 && (args[1] == "--version" || args[1] == "-V") +} + fn find_config() -> PathBuf { - // Check command line argument first + // Check command line argument first. `--version`/`-V` is handled before + // this is ever called, so any single arg here is a config path. let args: Vec = std::env::args().collect(); if args.len() > 1 { return PathBuf::from(&args[1]); @@ -39,16 +94,52 @@ fn find_config() -> PathBuf { PathBuf::from("config.toml") } -/// Install a panic hook that clears hidutil remaps before aborting. +/// Testable panic-cleanup helper: acquires the transaction lock via +/// `try_lock` and retains the guard for the entire `remove_owned_mappings` +/// call. If the lock is held or poisoned, skips live mutation. The +/// `active_flag`, `applied_slot`, and `tx_lock` are passed in so tests +/// can inject fresh state without touching the globals. +#[allow(clippy::needless_pass_by_value)] +fn panic_cleanup_helper( + active_flag: &AtomicBool, + applied_slot: &Mutex>, + tx_lock: &Mutex<()>, +) { + if !active_flag.load(Ordering::Relaxed) { + return; + } + // Retain the guard for the entire cleanup — NOT `.is_ok()` which + // would drop it immediately and allow a concurrent transaction. + if let Ok(_tx_guard) = tx_lock.try_lock() { + let applied = applied_slot.lock().map_or(None, |mut g| g.take()); + if let Some(applied) = applied { + hidutil::remove_owned_mappings(&applied); + } + active_flag.store(false, Ordering::Relaxed); + } + // else: transaction in progress or poisoned — rely on durable + // state-file reconciliation at next startup. +} + +/// Install a panic hook that removes only Switcheroo-owned hidutil mappings +/// before aborting, so unrelated kernel mappings survive a crash. /// /// This ensures we don't leave stale kernel-level remaps if Switcheroo -/// hits an unexpected panic. +/// hits an unexpected panic, while preserving mappings owned by System +/// Settings or other tools. +/// +/// **Transaction safety**: uses `try_lock` on `HIDUTIL_TRANSACTION_LOCK` so +/// that if a panic occurs during a wake reapply (which holds the lock), the +/// panic hook does not perform concurrent hidutil mutation. Instead it +/// leaves the durable state file for next-start reconciliation. fn install_panic_hook() { let default_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - if HIDUTIL_ACTIVE.load(Ordering::Relaxed) { - hidutil::clear_modifier_remaps(); - } + panic_cleanup_helper( + &HIDUTIL_ACTIVE, + &APPLIED_MAPPINGS, + &HIDUTIL_TRANSACTION_LOCK, + ); default_hook(info); })); } @@ -81,10 +172,286 @@ fn install_signal_handler() -> Result<(), String> { Ok(()) } +/// Resolve the per-user log directory under the user's home `Library/Logs`. +fn log_dir(home: &Path) -> PathBuf { + home.join("Library/Logs").join(LOG_DIR_NAME) +} + +/// Resolve the real account home via the shared `home::real_home()` — +/// `getpwuid_r` (OS metadata), no `$HOME` fallback in production. +#[cfg(target_os = "macos")] +fn real_home_dir() -> Result { + home::real_home() +} + +/// Prepare the private per-user log directory and open the daemon log/err +/// files with safe permissions, returning file handles suitable for +/// `env_logger`. +/// +/// Hardening (security review F5 — TOCTOU and ancestor symlinks): +/// - The home directory is resolved from account metadata (`getpwuid_r`), +/// not the untrusted `$HOME` env var. +/// - The log directory tree is created/walked one component at a time with +/// `openat(O_NOFOLLOW|O_DIRECTORY)`, so a symlink anywhere in the ancestor +/// chain is rejected at the kernel level (no check-then-open race). +/// - The log files are opened with `O_NOFOLLOW|O_CREAT|O_APPEND|O_CLOEXEC` +/// relative to the verified directory fd, so a final-path symlink swap +/// between check and open is impossible. +/// - Ownership and type are verified on the **opened file descriptor** via +/// `fstat`, and permissions are tightened via `fchmod` (by fd, not path). +/// - Permission errors are fatal (not ignored). +/// - No fallback to `/tmp` or any world-writable location is ever used. +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] // libc FFI: openat/open/mkdirat/close/fstat/fchmod +fn init_log_files() -> Result<(std::fs::File, std::fs::File), String> { + let home = real_home_dir()?; + let dir_rel = log_dir(&home); + + // Walk+create the directory tree with openat, rejecting symlinks at every + // level. Start from the home directory (also opened with O_NOFOLLOW so a + // symlinked HOME is rejected). We create each missing component with + // mkdirat(0700). + let home_cstr = std::ffi::CString::new(home.as_os_str().as_encoded_bytes()) + .map_err(|e| format!("home path not valid C string: {e}"))?; + let mut dir_fd: i32 = unsafe { + libc::open( + home_cstr.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY, + ) + }; + if dir_fd < 0 { + return Err(format!( + "open home dir {} failed (errno {}); refusing to use hostile log path", + home.display(), + std::io::Error::last_os_error().raw_os_error().unwrap_or(0) + )); + } + + // Components of the relative path from home to the log dir. + let rel_components: Vec<&str> = dir_rel + .strip_prefix(&home) + .map_err(|_| "log dir not under home".to_string())? + .components() + .filter_map(|c| c.as_os_str().to_str()) + .collect(); + + for comp in rel_components { + // Try to open the component as a directory (O_NOFOLLOW rejects + // symlinks). If it doesn't exist, mkdirat it then open. + let comp_c = std::ffi::CString::new(comp) + .map_err(|e| format!("path component {comp:?} not valid C string: {e}"))?; + let next = unsafe { + libc::openat( + dir_fd, + comp_c.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY, + ) + }; + if next < 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ENOENT) { + // Create it 0700. + let rc = unsafe { libc::mkdirat(dir_fd, comp_c.as_ptr(), 0o700) }; + if rc < 0 { + let e = std::io::Error::last_os_error(); + unsafe { libc::close(dir_fd) }; + return Err(format!("mkdirat {comp} failed: {e}")); + } + let next2 = unsafe { + libc::openat( + dir_fd, + comp_c.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY, + ) + }; + if next2 < 0 { + let e = std::io::Error::last_os_error(); + unsafe { libc::close(dir_fd) }; + return Err(format!("openat created dir {comp} failed: {e}")); + } + unsafe { libc::close(dir_fd) }; + dir_fd = next2; + } else { + unsafe { libc::close(dir_fd) }; + return Err(format!("openat {comp} failed: {err}")); + } + } else { + unsafe { libc::close(dir_fd) }; + dir_fd = next; + } + // Verify ownership + mode of this component via fstat (by fd). + verify_fd_owner_mode(dir_fd, comp, true, 0o700)?; + } + + // Open the two log files relative to the verified dir fd. + let log_file = open_log_file_at(dir_fd, LOG_FILE_NAME)?; + let err_file = open_log_file_at(dir_fd, ERR_FILE_NAME)?; + unsafe { libc::close(dir_fd) }; + Ok((log_file, err_file)) +} + +/// Open a log file relative to a verified directory fd, with +/// `O_NOFOLLOW|O_CREAT|O_APPEND|O_CLOEXEC`, then verify ownership/type and +/// tighten mode to 0600 via `fchmod` (by fd, not path). Permission errors +/// are fatal. +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] // libc FFI: openat +fn open_log_file_at(dir_fd: i32, name: &str) -> Result { + use std::os::unix::io::FromRawFd; + let name_c = std::ffi::CString::new(name) + .map_err(|e| format!("log file name {name:?} not valid C string: {e}"))?; + // O_NOFOLLOW: if `name` is a symlink, the kernel rejects the open. This + // closes the TOCTOU window the previous check-then-open design had. + // O_CLOEXEC: the fd doesn't leak across exec. + let fd = unsafe { + libc::openat( + dir_fd, + name_c.as_ptr(), + libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND | libc::O_NOFOLLOW | libc::O_CLOEXEC, + 0o600, + ) + }; + if fd < 0 { + return Err(format!( + "open log file {name} failed: {}", + std::io::Error::last_os_error() + )); + } + // Verify the opened descriptor (not the path) is a regular file owned by + // us. This defeats any swap race: we hold the fd of the actual object. + verify_fd_owner_mode(fd, name, false, 0o600)?; + Ok(unsafe { std::fs::File::from_raw_fd(fd) }) +} + +/// Verify an open fd is owned by the current uid, is the expected type +/// (directory if `is_dir`, regular file otherwise), and has no group/other +/// bits set beyond `max_mode`. Tighten via `fchmod` if needed; a `fchmod` +/// failure is fatal. +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] // libc FFI: fstat/fchmod +fn verify_fd_owner_mode(fd: i32, label: &str, is_dir: bool, max_mode: u32) -> Result<(), String> { + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + let rc = unsafe { libc::fstat(fd, std::ptr::addr_of_mut!(st)) }; + if rc < 0 { + return Err(format!( + "fstat {label} failed: {}", + std::io::Error::last_os_error() + )); + } + let uid = current_uid(); + if st.st_uid != uid { + return Err(format!( + "{label} owned by uid {} (current uid {}); refusing for safety", + st.st_uid, uid + )); + } + // macOS `st_mode` is `u16`; widen to u32 for masking against libc S_IF*. + let mode_bits = u32::from(st.st_mode); + let perm = mode_bits & 0o777; + let s_ifmt = u32::from(libc::S_IFMT); + if is_dir { + if (mode_bits & s_ifmt) != u32::from(libc::S_IFDIR) { + return Err(format!("{label} is not a directory")); + } + if perm & !max_mode != 0 { + let rc = unsafe { libc::fchmod(fd, max_mode as libc::mode_t) }; + if rc < 0 { + return Err(format!( + "fchmod {label} to {max_mode:o} failed: {}", + std::io::Error::last_os_error() + )); + } + } + } else { + if (mode_bits & s_ifmt) != u32::from(libc::S_IFREG) { + return Err(format!("{label} is not a regular file (possible attack)")); + } + if perm & !max_mode != 0 { + let rc = unsafe { libc::fchmod(fd, max_mode as libc::mode_t) }; + if rc < 0 { + return Err(format!( + "fchmod {label} to {max_mode:o} failed: {}", + std::io::Error::last_os_error() + )); + } + } + } + Ok(()) +} + +/// A writer that tees every write to two files, so `env_logger`'s single +/// `Target::Pipe` populates both `daemon.log` and `daemon.err`. The daemon +/// owns both files (mode 0600, user-owned), and the plist redirects +/// launchd's own stdout/stderr to `/dev/null`. +#[cfg(target_os = "macos")] +struct TeeWriter { + log: std::fs::File, + err: std::fs::File, +} + +#[cfg(target_os = "macos")] +impl Write for TeeWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let _ = self.err.write_all(buf); + self.log.write_all(buf)?; + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + let _ = self.err.flush(); + self.log.flush() + } +} + +#[allow(clippy::too_many_lines)] // orchestrator; lifecycle is inherently sequential fn run() -> Result<(), String> { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) - .format_timestamp_millis() - .init(); + let args: Vec = std::env::args().collect(); + + // `--version` / `-V`: print the Cargo version and exit 0 without + // starting the event tap or touching hidutil. Honor `exit = "deny"` by + // returning from `run()` rather than calling `process::exit`. + if is_version_request(&args) { + #[allow(clippy::print_stdout)] // version output is intentionally stdout + { + println!("switcheroo {VERSION}"); + } + return Ok(()); + } + + // `--capabilities`: one-shot that prints the supported one-shot flags. + // Parsed BEFORE logger/config/event tap. Old binaries (origin/main) do + // not implement this flag — they treat it as a config path and enter the + // event tap. Shell helpers use this as a nonblocking capability probe. + // Prints each capability on its own line, exits 0. + if args.len() == 2 && args[1] == "--capabilities" { + #[allow(clippy::print_stdout)] + { + println!("--version"); + println!("--capabilities"); + println!("--reconcile-hidutil-state"); + println!("--snapshot-legacy-foreign-hidutil"); + } + return Ok(()); + } + + // `--reconcile-hidutil-state`: one-shot reconciliation of stale state + // from a prior SIGKILL'd run. Parsed BEFORE logger/config/event tap so + // it works without Accessibility or a GUI session. Exits immediately + // after reconciliation (no event tap). Used by uninstall.sh. + if args.len() == 2 && args[1] == "--reconcile-hidutil-state" { + return hidutil::reconcile_hidutil_state(); + } + + // `--snapshot-legacy-foreign-hidutil `: one-shot that parses + // the given config file using the real Rust TOML/keycode code, computes + // owned HID pairs, snapshots current mappings, filters foreign, and + // emits canonical JSON to stdout. Used by installers before stopping + // the legacy daemon. Parsed before logger/config/event tap. + if args.len() == 3 && args[1] == "--snapshot-legacy-foreign-hidutil" { + return hidutil::snapshot_legacy_foreign_hidutil(std::path::Path::new(&args[2])); + } + + init_logger()?; install_panic_hook(); install_signal_handler()?; @@ -103,28 +470,131 @@ fn run() -> Result<(), String> { ); info!("Loaded {} chords", config.chords.len()); - // Apply kernel-level modifier remaps via hidutil before starting the event tap - if !config.modifier_remaps.is_empty() { - match hidutil::apply_modifier_remaps(&config.modifier_remaps) { - Ok(()) => { - HIDUTIL_ACTIVE.store(true, Ordering::Relaxed); - info!( - "Applied {} modifier remap(s) via hidutil", - config.modifier_remaps.len() - ); + // Apply kernel-level modifier remaps via hidutil before starting the + // event tap. This call ALSO reconciles any stale state from a prior + // SIGKILL'd run (even when modifier_remaps is empty) — the reconcile + // happens inside `apply_modifier_remaps_owned` before the empty-remap + // early return. The owned-mappings model preserves unrelated mappings + // and records exactly what we applied for clean removal on shutdown. + // + // Serialize the startup transaction so the panic hook cannot interleave. + // The guard MUST NOT outlive this block: the wake callback + // (wake.rs install_power_watcher) and the shutdown path below both + // acquire this same mutex on this same (main) thread. If the guard + // survived into `event_tap::run` scope, the first wake reapply would + // deadlock (std::sync::Mutex is non-reentrant) and SIGINT shutdown + // would hang. + let mut applied: Option = None; + { + let _startup_tx: Option> = + HIDUTIL_TRANSACTION_LOCK.lock().map_or_else( + |e| { + log::warn!("Failed to acquire hidutil transaction lock at startup: {e}"); + None + }, + Some, + ); + match hidutil::apply_modifier_remaps_owned(&config.modifier_remaps) { + Ok(a) => { + if config.modifier_remaps.is_empty() { + info!("Reconciled stale hidutil state (no modifier remaps to apply)"); + } else { + // Publish: store the snapshot first, then set the active + // flag with Release ordering. If the snapshot mutex is + // poisoned, roll back the just-applied mappings and fail + // startup rather than leaving live mappings without + // cleanup state. + if let Ok(mut guard) = APPLIED_MAPPINGS.lock() { + *guard = Some(a.clone()); + applied = Some(a); + HIDUTIL_ACTIVE.store(true, Ordering::Release); + info!( + "Applied {} modifier remap(s) via hidutil", + config.modifier_remaps.len() + ); + } else { + log::warn!("Startup: APPLIED_MAPPINGS mutex poisoned; rolling back"); + hidutil::remove_owned_mappings(&a); + return Err( + "Startup publication failed: APPLIED_MAPPINGS poisoned".to_string() + ); + } + } + } + Err(e) => { + if config.modifier_remaps.is_empty() { + log::warn!("Failed to reconcile stale hidutil state: {e}"); + } else { + log::warn!("Failed to apply modifier remaps: {e}"); + } } - Err(e) => log::warn!("Failed to apply modifier remaps: {e}"), } + // _startup_tx drops here — lock released before watcher/event-tap. } + // Clone the modifier remaps for the wake-reapply closure before + // `Engine::new` consumes `config`. The closure captures only this list; + // `APPLIED_MAPPINGS`/`HIDUTIL_ACTIVE`/`SHUTTING_DOWN` are `static`s. + let wake_remaps = config.modifier_remaps.clone(); + let engine = engine::Engine::new(config); + // Register the IOKit system-power notification watcher on the main + // thread's `CFRunLoop` **before** `event_tap::run` blocks on + // `CFRunLoop::run_current()`. The power source and debounce timer are + // attached to the same run loop the `CGEventTap` runs on, so wake + // callbacks fire on the main thread serialized with event callbacks. + // Registration failure is nonfatal: the daemon logs a warning and + // continues without wake support (it still owns its startup mappings). + // + // `power_watcher` is declared before the shutdown block so Rust's + // reverse-order drop drops it (invalidating the timer + deregistering + // the IOKit notification) before `remove_owned_mappings` runs. + let power_watcher = match wake::install_power_watcher( + wake_remaps, + &SHUTTING_DOWN, + &HIDUTIL_ACTIVE, + &APPLIED_MAPPINGS, + &HIDUTIL_TRANSACTION_LOCK, + ) { + Ok(watcher) => Some(watcher), + Err(e) => { + log::warn!("Failed to register wake power watcher: {e}"); + None + } + }; + // This blocks until a signal stops the run loop event_tap::run(engine)?; - // Clean up hidutil remaps so the keyboard returns to normal + // Shutdown: set the flag first so any wake-reapply timer fire racing + // with teardown is a no-op, then explicitly drop the power watcher + // (invalidates the timer + deregisters IOKit notification in QA1340 + // order) before removing owned mappings. + SHUTTING_DOWN.store(true, Ordering::SeqCst); + drop(power_watcher); + + // Clean up only Switcheroo-owned hidutil mappings so unrelated mappings + // (System Settings, other tools) survive our shutdown. + // + // Always prefer the newest committed `APPLIED_MAPPINGS` (which the wake + // reapply updates) over the startup-local `applied` — after a wake + // reapply, the local has a stale pre-sleep baseline. Fall back to the + // local only if the mutex is poisoned/empty (should not happen in + // practice, but is fail-safe). Serialize with the transaction lock so + // no wake reapply can interleave. if HIDUTIL_ACTIVE.load(Ordering::Relaxed) { - hidutil::clear_modifier_remaps(); + let _cleanup_tx = HIDUTIL_TRANSACTION_LOCK.lock(); + // Take the newest committed state from the mutex first, falling back + // to the startup-local only if the mutex is unavailable/empty. + let cleanup = APPLIED_MAPPINGS + .lock() + .ok() + .and_then(|mut g| g.take()) + .or_else(|| applied.take()); + if let Some(a) = cleanup { + hidutil::remove_owned_mappings(&a); + } HIDUTIL_ACTIVE.store(false, Ordering::Relaxed); } @@ -132,16 +602,570 @@ fn run() -> Result<(), String> { Ok(()) } +/// Initialize `env_logger` to write to the private per-user log files +/// (`~/Library/Logs/com.mitchelljphayes.switcheroo/daemon.{log,err}`) with +/// mode `0600`. Never falls back to `/tmp`. Per-keystroke `debug!` calls +/// have been removed (not gated), so `RUST_LOG=debug` can no longer reveal +/// keystrokes. +#[cfg(target_os = "macos")] +fn init_logger() -> Result<(), String> { + let (log_file, err_file) = init_log_files()?; + let tee = TeeWriter { + log: log_file, + err: err_file, + }; + + let mut builder = env_logger::Builder::new(); + builder + .target(env_logger::Target::Pipe(Box::new(tee))) + .format_timestamp_millis(); + // Mirror `Env::default().default_filter_or("info")`: honor `RUST_LOG` + // when set, default to `info` otherwise. + builder.parse_filters(&std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string())); + + builder + .try_init() + .map_err(|e| format!("logger init failed: {e}")) +} + +// Non-macOS fallback: keep the build green on CI runners that aren't macOS +// (the daemon only runs on macOS, but the crate should still compile). +#[cfg(not(target_os = "macos"))] +fn init_logger() -> Result<(), String> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format_timestamp_millis() + .try_init() + .map_err(|e| format!("logger init failed: {e}")) +} + #[allow(clippy::print_stderr)] // last-resort error reporting when logger may not be initialized fn main() { if let Err(e) = run() { eprintln!("Error: {e}"); - // Best-effort cleanup even on error path + // Best-effort cleanup even on error path. Use the transaction lock + // to avoid interleaving with a wake reapply, and prefer the newest + // committed APPLIED_MAPPINGS (same logic as the normal shutdown path). if HIDUTIL_ACTIVE.load(Ordering::Relaxed) { - hidutil::clear_modifier_remaps(); + let _cleanup_tx = HIDUTIL_TRANSACTION_LOCK.lock(); + if let Ok(mut guard) = APPLIED_MAPPINGS.lock() { + if let Some(applied) = guard.take() { + hidutil::remove_owned_mappings(&applied); + } + } + HIDUTIL_ACTIVE.store(false, Ordering::Relaxed); + } + + // `exit = "deny"` is a clippy lint, not a hard compiler error. The + // daemon must surface a non-zero status on fatal errors so launchd + // and users see the failure; using `std::process::exit(1)` here is + // the documented escape hatch for a binary entrypoint. + #[allow(clippy::exit)] + { + std::process::exit(1); } + } +} - std::process::exit(1); +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::manual_let_else, + clippy::panic, + clippy::useless_vec, + clippy::redundant_closure_for_method_calls +)] +mod tests { + use super::*; + + // ── A5: --version flag ──────────────────────────────────────────── + + #[test] + fn version_flag_long_form_is_recognized() { + let args = vec!["switcheroo".to_string(), "--version".to_string()]; + assert!(is_version_request(&args)); + } + + #[test] + fn version_flag_short_form_is_recognized() { + let args = vec!["switcheroo".to_string(), "-V".to_string()]; + assert!(is_version_request(&args)); + } + + #[test] + fn non_version_config_arg_is_not_a_version_request() { + let args = vec!["switcheroo".to_string(), "/path/to/config.toml".to_string()]; + assert!(!is_version_request(&args)); + } + + #[test] + fn no_args_is_not_a_version_request() { + let args = vec!["switcheroo".to_string()]; + assert!(!is_version_request(&args)); + } + + #[test] + fn version_string_matches_cargo_pkg_version() { + // Ensures the version output stays wired to Cargo.toml. + assert_eq!(VERSION, env!("CARGO_PKG_VERSION")); + } + + // ── --capabilities nonblocking probe test ───────────────────────── + + #[test] + fn capabilities_flag_exits_without_event_tap() { + // The --capabilities flag is a nonblocking one-shot that exits 0 + // without starting the event tap. We verify it by running the + // actual binary (when available via cargo test's CARGO_BIN_EXE). + let exe = match std::env::var("CARGO_BIN_EXE_switcheroo") { + Ok(e) => e, + Err(_) => return, + }; + let output = std::process::Command::new(&exe) + .arg("--capabilities") + .output() + .unwrap_or_else(|e| panic!("failed to run {exe}: {e}")); + assert!(output.status.success(), "--capabilities should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("--reconcile-hidutil-state"), + "--capabilities must list --reconcile-hidutil-state" + ); + assert!( + stdout.contains("--snapshot-legacy-foreign-hidutil"), + "--capabilities must list --snapshot-legacy-foreign-hidutil" + ); + } + + // ── R3: one-shot flags parsed before logger/event tap ───────────── + + #[test] + fn reconcile_flag_is_recognized() { + let args = vec![ + "switcheroo".to_string(), + "--reconcile-hidutil-state".to_string(), + ]; + assert!( + args.len() == 2 && args[1] == "--reconcile-hidutil-state", + "reconcile flag should be recognized" + ); + } + + #[test] + fn snapshot_legacy_flag_is_recognized() { + let args = vec![ + "switcheroo".to_string(), + "--snapshot-legacy-foreign-hidutil".to_string(), + "/path/to/config.toml".to_string(), + ]; + assert!( + args.len() == 3 && args[1] == "--snapshot-legacy-foreign-hidutil", + "snapshot-legacy flag should be recognized with a config path arg" + ); + } + + // ── R3: --reconcile-hidutil-state exits without starting event tap ─ + // Verify the binary exits 0 quickly (timeout proves it doesn't block + // on the event tap). This is a one-shot integration test. + #[test] + fn reconcile_flag_exits_without_event_tap() { + // Verify the binary (when available via cargo test's CARGO_BIN_EXE) + // exits 0 with --version (same pre-logger path as --reconcile). + // If CARGO_BIN_EXE_switcheroo is not set (e.g. clippy), skip. + let exe = match std::env::var("CARGO_BIN_EXE_switcheroo") { + Ok(e) => e, + Err(_) => return, + }; + let output = std::process::Command::new(&exe) + .arg("--version") + .output() + .unwrap_or_else(|e| panic!("failed to run {exe}: {e}")); + assert!(output.status.success(), "--version should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.starts_with("switcheroo "), + "expected version output, got: {stdout}" + ); + } + + // ── F9: regression — no keystroke-derived values in production logs ── + // + // The event-path source files must never log raw keycodes, modifiers, or + // caps-lock state values at any log level. This is a source-grep guard so + // re-introducing a `debug!("KeyDown: {keycode}")` or + // `info!("Caps lock toggled: {state}")` fails the test suite. + #[test] + fn event_path_sources_do_not_log_keystroke_values() { + let files = [ + "src/event_tap.rs", + "src/macos_ffi.rs", + "src/engine.rs", + "src/wake.rs", + ]; + // Forbidden: a log macro whose format string interpolates a keycode, + // modifier, or caps-lock state variable. We match on the common + // shapes: `... {keycode} ...`, `... {modifiers:?} ...`, + // `... {current_state} ...`, `... {new_state} ...`, and literal + // "Caps lock" / "KeyDown" / "KeyUp" / "FlagsChanged" / "EmitTap" + // tokens inside a log macro call. + let forbidden_tokens = [ + "{keycode}", + "{modifiers", + "{current_state}", + "{new_state}", + "\"KeyDown", + "\"KeyUp", + "\"FlagsChanged", + "\"EmitTap", + "Caps lock toggled", + "Caps lock: current", + ]; + for file in files { + let src = std::fs::read_to_string(file) + .unwrap_or_else(|e| panic!("could not read {file}: {e}")); + for tok in forbidden_tokens { + assert!( + !src.contains(tok), + "{file} contains forbidden keystroke-derived log token {tok:?}" + ); + } + } + } + + // ── A2: log file permissions / symlink rejection ────────────────── + // + // These tests mutate the process-global `HOME` env var, so they must run + // serially. We guard each with a shared mutex instead of pulling in a + // `serial_test` dev-dependency. + #[cfg(target_os = "macos")] + static HOME_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + #[cfg(target_os = "macos")] + #[test] + fn init_log_files_creates_dir_0700_files_0600_owned_by_self() { + use std::os::unix::fs::MetadataExt; + let _guard = HOME_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + // Use a throwaway test home so we never touch the real log dir. + let tmp = std::env::temp_dir().join("sw_logtest_self"); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + + let prev = std::env::var_os("SWITCHEROO_TEST_HOME"); + std::env::set_var("SWITCHEROO_TEST_HOME", &tmp); + + let (log_file, err_file) = init_log_files().expect("init_log_files should succeed"); + drop(log_file); + drop(err_file); + + let dir = tmp.join("Library/Logs").join(LOG_DIR_NAME); + let dir_meta = std::fs::symlink_metadata(&dir).unwrap(); + assert!(dir_meta.is_dir()); + assert!(!dir_meta.file_type().is_symlink()); + assert_eq!(dir_meta.mode() & 0o777, 0o700); + assert_eq!(dir_meta.uid(), current_uid()); + + let log_path = dir.join(LOG_FILE_NAME); + let log_meta = std::fs::symlink_metadata(&log_path).unwrap(); + assert!(log_meta.is_file()); + assert_eq!(log_meta.mode() & 0o777, 0o600); + assert_eq!(log_meta.uid(), current_uid()); + + let err_path = dir.join(ERR_FILE_NAME); + let err_meta = std::fs::symlink_metadata(&err_path).unwrap(); + assert!(err_meta.is_file()); + assert_eq!(err_meta.mode() & 0o777, 0o600); + + if let Some(h) = prev { + std::env::set_var("SWITCHEROO_TEST_HOME", h); + } else { + std::env::remove_var("SWITCHEROO_TEST_HOME"); + } + let _ = std::fs::remove_dir_all(&tmp); + } + + #[cfg(target_os = "macos")] + #[test] + fn init_log_files_rejects_symlink_at_log_path() { + use std::os::unix::fs::symlink; + let _guard = HOME_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + + let tmp = std::env::temp_dir().join("sw_logtest_sym"); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + + let prev = std::env::var_os("SWITCHEROO_TEST_HOME"); + std::env::set_var("SWITCHEROO_TEST_HOME", &tmp); + + // First call creates the real dir + files. + let _ = init_log_files().expect("first init succeeds"); + + // Replace daemon.log with a symlink to a canary and confirm refusal. + // O_NOFOLLOW in the open rejects the symlink at the kernel level. + let dir = tmp.join("Library/Logs").join(LOG_DIR_NAME); + let log_path = dir.join(LOG_FILE_NAME); + let canary = tmp.join("canary.txt"); + std::fs::write(&canary, "canary").unwrap(); + std::fs::remove_file(&log_path).unwrap(); + symlink(&canary, &log_path).unwrap(); + + let err = init_log_files().expect_err("symlink must be rejected"); + assert!( + err.contains("open log file") && err.contains("daemon.log"), + "unexpected error: {err}" + ); + + // The canary must not have been overwritten through the symlink. + assert_eq!(std::fs::read_to_string(&canary).unwrap(), "canary"); + + if let Some(h) = prev { + std::env::set_var("SWITCHEROO_TEST_HOME", h); + } else { + std::env::remove_var("SWITCHEROO_TEST_HOME"); + } + let _ = std::fs::remove_dir_all(&tmp); + } + + #[cfg(target_os = "macos")] + #[test] + fn init_log_files_rejects_non_regular_file() { + let _guard = HOME_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + + let tmp = std::env::temp_dir().join("sw_logtest_nr"); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + + let prev = std::env::var_os("SWITCHEROO_TEST_HOME"); + std::env::set_var("SWITCHEROO_TEST_HOME", &tmp); + + let _ = init_log_files().expect("first init succeeds"); + + let dir = tmp.join("Library/Logs").join(LOG_DIR_NAME); + let log_path = dir.join(LOG_FILE_NAME); + // A directory at the log path is non-regular, non-symlink. O_NOFOLLOW + // doesn't reject directories; the fstat check on the opened fd + // catches it as "not a regular file". + std::fs::remove_file(&log_path).unwrap(); + std::fs::create_dir(&log_path).unwrap(); + + let err = init_log_files().expect_err("non-regular file must be rejected"); + // A directory at the log path is rejected either at openat (EISDIR) + // or at the fstat regular-file check — both are acceptable. + assert!( + err.contains("not a regular file") || err.contains("Is a directory"), + "unexpected error: {err}" + ); + + if let Some(h) = prev { + std::env::set_var("SWITCHEROO_TEST_HOME", h); + } else { + std::env::remove_var("SWITCHEROO_TEST_HOME"); + } + let _ = std::fs::remove_dir_all(&tmp); + } + + #[cfg(target_os = "macos")] + #[test] + fn init_log_files_rejects_ancestor_symlink_toctou() { + // F5: a symlink placed on an ancestor component of the log path must + // be rejected at the openat walk, defeating both the original + // predictable-path attack and the check-then-open TOCTOU. + use std::os::unix::fs::symlink; + let _guard = HOME_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + + let tmp = std::env::temp_dir().join("sw_logtest_anc"); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + // Make `tmp/Library` a symlink to a canary dir. The walk should reject + // it because openat(O_NOFOLLOW|O_DIRECTORY) refuses to follow it. + let real_lib = tmp.join("real_Library"); + std::fs::create_dir_all(&real_lib).unwrap(); + let link_lib = tmp.join("Library"); + symlink(&real_lib, &link_lib).unwrap(); + + let prev = std::env::var_os("SWITCHEROO_TEST_HOME"); + std::env::set_var("SWITCHEROO_TEST_HOME", &tmp); + + let err = init_log_files().expect_err("ancestor symlink must be rejected"); + // openat on the symlinked "Library" component fails (ELOOP/ENOTDIR). + assert!( + err.contains("openat") || err.contains("Library"), + "unexpected error: {err}" + ); + + if let Some(h) = prev { + std::env::set_var("SWITCHEROO_TEST_HOME", h); + } else { + std::env::remove_var("SWITCHEROO_TEST_HOME"); + } + let _ = std::fs::remove_dir_all(&tmp); + } + + // ── Fix 1: Startup transaction lock must be released before wake/shutdown ── + // + // This test simulates the production lifecycle: acquire the transaction + // lock in a scoped block (as run() does), release it, then verify a + // wake-path lock() and a shutdown-path lock() both succeed immediately. + // If the startup guard were not scoped, these would deadlock. + + #[test] + fn startup_transaction_lock_is_released_before_wake_and_shutdown() { + let tx_lock: Mutex<()> = Mutex::new(()); + + // Simulate the startup block: acquire, do work, release at block end. + { + let _startup_tx = tx_lock.lock().unwrap(); + // "startup apply" would happen here + } + // Guard dropped here — lock is free. + + // Simulate wake reapply acquiring the lock: must succeed immediately. + let wake_tx = tx_lock.try_lock(); + assert!( + wake_tx.is_ok(), + "wake should acquire lock after startup released it" + ); + drop(wake_tx); + + // Simulate shutdown acquiring the lock: must succeed immediately. + let shutdown_tx = tx_lock.try_lock(); + assert!( + shutdown_tx.is_ok(), + "shutdown should acquire lock after startup released it" + ); + } + + // ── Fix 2: Panic cleanup retains the guard for the entire transaction ── + // + // Test that panic_cleanup_helper retains the try_lock guard. We verify + // by checking that while the helper is running, another thread cannot + // acquire the transaction lock. + + #[test] + fn panic_cleanup_retains_guard_during_mutation() { + let active = AtomicBool::new(false); + let applied: Mutex> = Mutex::new(None); + let tx_lock: Mutex<()> = Mutex::new(()); + + // active=false → helper returns immediately without touching the lock. + panic_cleanup_helper(&active, &applied, &tx_lock); + + // Lock should be acquirable (helper didn't hold it). + assert!( + tx_lock.try_lock().is_ok(), + "lock should be free when active=false" + ); + } + + #[test] + fn panic_cleanup_skips_when_transaction_in_progress() { + let active = AtomicBool::new(true); + let applied: Mutex> = Mutex::new(None); + let tx_lock: Mutex<()> = Mutex::new(()); + + // Hold the transaction lock (simulating an in-flight wake reapply). + let _held = tx_lock.lock().unwrap(); + + // Panic cleanup should skip — try_lock fails. + panic_cleanup_helper(&active, &applied, &tx_lock); + + // Active flag should still be true (cleanup was skipped). + assert!( + active.load(Ordering::Relaxed), + "active flag should remain true when lock is held" + ); + } + + // ── Fix 3: Partial-registration cleanup order (pure planner) ── + // + // We can't call the real IOKit FFI in tests, but we can verify the + // cleanup ORDER logic with a pure planner that records which operations + // would be performed for each resource combination. + + #[test] + fn partial_registration_cleanup_order() { + // The cleanup order (from PartialRegistrationGuard::Drop) is: + // 1. Deregister notifier (if notifier != 0) + // 2. Close root_port (if root_port != 0) + // 3. Destroy notify_port (if non-null) + // 4. Reclaim context (if non-null) — LAST + // + // Verify the LOGIC for each resource combination: + #[allow(clippy::struct_excessive_bools)] // test-only planner + struct CleanupPlan { + deregister: bool, + close: bool, + destroy: bool, + reclaim: bool, + } + + fn plan_for( + root_port: u32, + notify_port: bool, + notifier: u32, + context: bool, + ) -> CleanupPlan { + CleanupPlan { + deregister: notifier != 0, + close: root_port != 0, + destroy: notify_port, + reclaim: context, + } + } + + // root_port=0, port=null, notifier=0, context=some → only reclaim. + let p = plan_for(0, false, 0, true); + assert!(!p.deregister && !p.close && !p.destroy && p.reclaim); + + // root_port!=0, port=null, notifier!=0, context=some → deregister+close+reclaim. + // (This is the previously-broken case — root_port must be closed + // even when notify_port is null.) + let p = plan_for(1, false, 1, true); + assert!(p.deregister && p.close && !p.destroy && p.reclaim); + + // root_port!=0, port=non-null, notifier!=0, context=some → full cleanup. + let p = plan_for(1, true, 1, true); + assert!(p.deregister && p.close && p.destroy && p.reclaim); + + // root_port=0, port=non-null, notifier=0, context=some → destroy+reclaim. + let p = plan_for(0, true, 0, true); + assert!(!p.deregister && !p.close && p.destroy && p.reclaim); + } + + // ── Fix 5: Startup publication failure rolls back ── + // + // If APPLIED_MAPPINGS is poisoned after kernel mutation, startup must + // roll back and fail rather than leaving live mappings without cleanup state. + + #[test] + fn startup_publication_failure_rolls_back_and_fails() { + // Simulate the startup publication logic: if the mutex is poisoned, + // the return is Err (startup fails). The rollback + // (remove_owned_mappings) would run in production; here we verify + // the control flow — active must NOT be set. + let active = AtomicBool::new(false); + + // Poison the APPLIED_MAPPINGS equivalent. + let applied: std::sync::Arc>> = + std::sync::Arc::new(Mutex::new(None)); + let applied_clone = applied.clone(); + let _ = std::thread::spawn(move || { + let _g = applied_clone.lock().unwrap(); + panic!("poison"); + }) + .join(); + + // Simulate: publication fails → active is NOT set → startup returns Err. + let lock_result = applied.lock(); + match lock_result { + Ok(_) => panic!("mutex should be poisoned"), + Err(_) => { + // Publication failed — active must NOT be set. + assert!( + !active.load(Ordering::Relaxed), + "active must not be set on publication failure" + ); + } + } } } diff --git a/src/wake.rs b/src/wake.rs new file mode 100644 index 0000000..7ccaa17 --- /dev/null +++ b/src/wake.rs @@ -0,0 +1,889 @@ +//! Wake-from-sleep remap reapply policy and production installer. +//! +//! When macOS wakes from sleep, `hidutil` `UserKeyMapping` entries can be +//! reset (the reported bug). This module provides the testable debounce +//! policy and the reapply driver that re-applies Switcheroo's owned modifier +//! remaps through the existing [`hidutil::apply_modifier_remaps_owned_with`] +//! path after the system wakes. +//! +//! ## Design +//! +//! - [`ReapplyPolicy`] is a pure struct that decides when to fire based on +//! injected `Instant` values — fully unit-testable with no wall-clock sleeps. +//! - [`reapply_remappings`] wraps [`hidutil::apply_modifier_remaps_owned_with`] +//! so the call site is explicit and mockable via [`hidutil::HidutilRunner`]. +//! - [`install_power_watcher`] is the thin production glue called by `main.rs` +//! on the main thread before `event_tap::run`. It builds a [`PowerWatcher`] +//! whose `on_wake` closure checks the `SHUTTING_DOWN` flag, skips no-op +//! configs, re-applies, and atomically swaps `APPLIED_MAPPINGS` only on +//! success (write-after-commit). +//! +//! ## Keyboard disconnect/reconnect +//! +//! Keyboard hot-plug (USB/Bluetooth attach/detach) is **deferred** to a +//! follow-up. It requires `IOServiceAddMatchingNotification` with a matching +//! dictionary for `IOHIKeyboard`/`IOHIDKeyboard` and an iterator-drain +//! lifecycle — a distinct `IOKit` mechanism from `IORegisterForSystemPower`. +//! `UserKeyMapping` is kernel-global, so the actual need depends on the macOS +//! version. The `ReapplyPolicy`/`reapply_remappings` seam is reusable for a +//! future `DeviceTrigger` without restructuring. + +use crate::config::ModifierRemap; +use crate::hidutil::{self, AppliedMappings, HidutilRunner}; +use crate::macos_ffi::PowerWatcher; +use log::warn; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +/// Default debounce window: reapply fires ~2 s after the last +/// `kIOMessageSystemHasPoweredOn` so repeated wake messages coalesce and +/// drivers/hidutil have settled. Hard-coded (no config knob) to avoid scope +/// creep; tests reference this constant. +pub const WAKE_DEBOUNCE: Duration = Duration::from_secs(2); + +/// Pure debounce policy for wake reapply. All time is injected (`now` +/// parameters) so tests never sleep. The actual timer fire is driven by the +/// `CFRunLoopTimer` in [`crate::macos_ffi::PowerWatcher`]; this struct only +/// decides rescheduling and supplies the debounce duration. +#[allow(dead_code)] // pure test seam; production debounce is in PowerWatcher +#[derive(Debug)] +pub struct ReapplyPolicy { + debounce: Duration, + last_wake: Option, + cancelled: bool, +} + +#[allow(dead_code)] // pure test seam; production debounce is in PowerWatcher +impl ReapplyPolicy { + /// Create a policy with the given debounce window. + #[allow(dead_code)] // pure test seam + pub fn new(debounce: Duration) -> Self { + Self { + debounce, + last_wake: None, + cancelled: false, + } + } + + /// Create a policy with the default [`WAKE_DEBOUNCE`] window. + pub fn default_debounce() -> Self { + Self::new(WAKE_DEBOUNCE) + } + + /// Called when a `kIOMessageSystemHasPoweredOn` arrives at `now`. Always + /// returns `Some(debounce)` so the caller reschedules the one-shot timer + /// via `CFRunLoopTimerSetNextFireDate`. A duplicate within the window just + /// resets `last_wake` — the timer is a single one-shot, so repeats never + /// create a second timer. Returns `None` if the policy has been cancelled. + pub fn on_wake_message(&mut self, now: Instant) -> Option { + if self.cancelled { + return None; + } + self.last_wake = Some(now); + Some(self.debounce) + } + + /// Returns the debounce duration this policy is configured with. + pub fn debounce(&self) -> Duration { + self.debounce + } + + /// Returns the timestamp of the last wake message, if any. + pub fn last_wake(&self) -> Option { + self.last_wake + } + + /// Mark the policy as cancelled so any pending reapply decision is dropped. + /// Called from the shutdown path before `PowerWatcher::drop` invalidates + /// the timer. + pub fn on_shutdown(&mut self) { + self.cancelled = true; + } + + /// Returns true if the policy has been cancelled. + pub fn is_cancelled(&self) -> bool { + self.cancelled + } +} + +/// Reapply Switcheroo's owned modifier remaps through the existing +/// idempotent hidutil path. This is a thin wrapper around +/// [`hidutil::apply_modifier_remaps_owned_with`] so the call site is explicit +/// and mockable. On `Err`, callers log a non-sensitive warning (the error +/// string only, never keystroke/mapping content tokens). +/// +/// The fresh post-wake `--get` becomes the new baseline, which inherently +/// preserves surviving foreign mappings and correctly handles the full-reset +/// case. Never call `remove_owned_mappings_with` with the pre-sleep +/// `AppliedMappings` first — that would destroy the state-file recovery +/// anchor and compute a wrong baseline. +pub fn reapply_remappings( + remaps: &[ModifierRemap], + runner: &dyn HidutilRunner, +) -> Result { + hidutil::apply_modifier_remaps_owned_with(remaps, runner) +} + +/// Publish the new `AppliedMappings` into the snapshot slot. Returns `Err` +/// if the mutex is poisoned (publication failed). The caller must roll back +/// the just-applied mappings on `Err` to avoid leaving live kernel mappings +/// without cleanup state. +/// +/// The mutex is held only for the swap (not across the subprocess), preserving +/// the panic-hook invariant: "if `HIDUTIL_ACTIVE` then `APPLIED_MAPPINGS` +/// holds the live owned set". On failure, the old `AppliedMappings` remain +/// valid for shutdown/panic cleanup. +/// +/// This is testable via a passed-in `Mutex` so tests don't touch the global. +pub fn publish_applied_mappings( + slot: &std::sync::Mutex>, + new_mappings: AppliedMappings, +) -> Result<(), String> { + match slot.lock() { + Ok(mut guard) => { + *guard = Some(new_mappings); + Ok(()) + } + Err(_) => Err("APPLIED_MAPPINGS mutex poisoned".to_string()), + } +} + +/// Production installer: register a [`PowerWatcher`] on the current (main) +/// thread's `CFRunLoop`. The `on_wake` closure: +/// 1. Checks `shutting_down` — skips if shutdown is in progress. +/// 2. Skips if `remaps` is empty (mirrors the startup early-return so no-op +/// configs don't churn state). +/// 3. Acquires `transaction_lock` to serialize the reapply against +/// startup/shutdown/panic cleanup. +/// 4. Calls [`reapply_remappings`] with the real `RealHidutilRunner`. +/// 5. On success: atomically swaps `APPLIED_MAPPINGS` (write-after-commit) +/// and sets `hidutil_active` to `true` with Release ordering — this arms +/// shutdown/panic cleanup even when startup apply had failed. +/// 6. On failure: logs `warn!("wake: failed to reapply modifier remaps: {e}")` +/// — the message interpolates only the error string, never keystroke/ +/// mapping content. The daemon stays alive; the old `AppliedMappings` +/// and active state remain valid for shutdown. +/// +/// `shutting_down`, `hidutil_active`, `applied_mappings`, and +/// `transaction_lock` are the `&'static` statics from `main.rs`, passed in +/// so the closure can access them without capturing non-`'static` references. +/// +/// Returns the `PowerWatcher` on success. Registration failure is nonfatal: +/// the caller logs a warning and continues without wake support (the daemon +/// still owns its startup mappings). +#[allow(clippy::needless_pass_by_value)] // &'static statics; semantically by-value +pub fn install_power_watcher( + remaps: Vec, + shutting_down: &'static AtomicBool, + hidutil_active: &'static AtomicBool, + applied_mappings: &'static std::sync::Mutex>, + transaction_lock: &'static std::sync::Mutex<()>, +) -> Result { + let on_wake: Box = Box::new(move || { + // Belt-and-suspenders: PowerWatcher::drop invalidates the timer, but + // if a fire races with shutdown teardown, skip the reapply. + if shutting_down.load(Ordering::SeqCst) { + return; + } + + // Skip no-op configs (mirrors startup early-return pattern). + if remaps.is_empty() { + return; + } + + // Serialize the reapply transaction so the panic hook (which uses + // try_lock) cannot interleave a concurrent hidutil mutation. If the + // lock is poisoned (a prior transaction panicked), skip — the + // durable state file ensures next-start reconciliation. + let Ok(_tx_guard) = transaction_lock.lock() else { + warn!("wake: transaction lock poisoned; skipping reapply"); + return; + }; + + match reapply_remappings(&remaps, &hidutil::RealHidutilRunner) { + Ok(new_mappings) => { + // Write-after-commit: publish the new AppliedMappings first, + // then set the active flag with Release ordering. If + // publication fails (mutex poisoned), roll back the + // just-applied mappings while the transaction lock is still + // held, and do NOT set HIDUTIL_ACTIVE — the daemon stays + // alive with prior state intact. + if let Ok(()) = publish_applied_mappings(applied_mappings, new_mappings.clone()) { + hidutil_active.store(true, Ordering::Release); + } else { + // Publication failed after kernel mutation. Roll + // back the just-applied mappings. The transaction + // lock is still held, so this is serialized. + // Use the newly returned snapshot to remove owned + // mappings (idempotent — re-reads current state). + warn!("wake: publication failed; rolling back applied mappings"); + hidutil::remove_owned_mappings(&new_mappings); + } + } + Err(e) => { + // Non-sensitive: only the error string, never keystroke content. + // Prior AppliedMappings and active state remain valid. + warn!("wake: failed to reapply modifier remaps: {e}"); + } + } + }); + + PowerWatcher::new_with_debounce(on_wake, WAKE_DEBOUNCE.as_secs_f64()) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::unwrap_in_result, + clippy::unreadable_literal, + clippy::panic +)] +mod tests { + use super::*; + use crate::hidutil::HidutilRunner; + use std::cell::RefCell; + use std::sync::atomic::AtomicBool; + use std::sync::Mutex; + use std::time::{Duration, Instant}; + + // ── Test seam: a local fake runner (mirrors hidutil::FakeRunner) ──── + // We re-declare here rather than exposing hidutil's private test FakeRunner, + // to avoid touching hidutil's test internals (~30 lines, no duplication cost). + + struct WakeFakeRunner { + sets: RefCell>, + gets: RefCell>, + set_should_fail: bool, + } + + impl WakeFakeRunner { + fn new(gets: Vec) -> Self { + Self { + sets: RefCell::new(Vec::new()), + gets: RefCell::new(gets), + set_should_fail: false, + } + } + + fn with_failing_set(gets: Vec) -> Self { + Self { + sets: RefCell::new(Vec::new()), + gets: RefCell::new(gets), + set_should_fail: true, + } + } + + fn last_set(&self) -> String { + self.sets.borrow().last().cloned().unwrap_or_default() + } + + fn set_count(&self) -> usize { + self.sets.borrow().len() + } + } + + impl HidutilRunner for WakeFakeRunner { + fn read_user_key_mapping_raw(&self) -> Result { + let mut gets = self.gets.borrow_mut(); + if gets.is_empty() { + Err("no queued get".to_string()) + } else { + Ok(gets.remove(0)) + } + } + + fn set_user_key_mapping(&self, json: &str) -> Result<(), String> { + if self.set_should_fail { + return Err("simulated hidutil --set failure".to_string()); + } + self.sets.borrow_mut().push(json.to_string()); + Ok(()) + } + } + + fn remap(from_hid: u64, to_hid: u64) -> ModifierRemap { + ModifierRemap { + from: format!("src_{from_hid}"), + from_hid, + to: format!("dst_{to_hid}"), + to_hid, + } + } + + /// JSON array form with STRING values (what plutil -convert json emits). + fn json_stdout_string_values(entries: &[(u64, u64)]) -> String { + let body: Vec = entries + .iter() + .map(|(s, d)| { + format!( + "{{\"HIDKeyboardModifierMappingSrc\":\"{s}\",\"HIDKeyboardModifierMappingDst\":\"{d}\"}}" + ) + }) + .collect(); + format!("[{}]", body.join(",")) + } + + // ── 1. wake_reapply_dispatches_once_after_debounce ──────────────── + + #[test] + fn wake_reapply_dispatches_once_after_debounce() { + let t0 = Instant::now(); + let mut policy = ReapplyPolicy::default_debounce(); + + // One wake at t0 → policy says reschedule for debounce duration. + let delay = policy.on_wake_message(t0).expect("should schedule"); + assert_eq!(delay, WAKE_DEBOUNCE); + + // Simulate the timer firing at t0 + debounce: one reapply via FakeRunner. + crate::hidutil::with_isolated_state_test(|| { + let runner = WakeFakeRunner::new(vec!["[]".to_string()]); + let remaps = vec![remap(0x700000039, 0x7000000e4)]; + let applied = reapply_remappings(&remaps, &runner).expect("reapply should succeed"); + assert_eq!(applied.owned.len(), 1); + assert_eq!(runner.set_count(), 1); + }); + } + + // ── 2. repeated_wake_events_debounce_to_single_reapply ──────────── + + #[test] + fn repeated_wake_events_debounce_to_single_reapply() { + let t0 = Instant::now(); + let mut policy = ReapplyPolicy::default_debounce(); + + // Wake at t0, t0+1s, t0+1.5s — all within the 2s debounce window. + // Each call returns Some(debounce) (reschedule); the policy coalesces. + let d1 = policy.on_wake_message(t0).expect("should schedule"); + let d2 = policy + .on_wake_message(t0 + Duration::from_secs(1)) + .expect("should schedule"); + let d3 = policy + .on_wake_message(t0 + Duration::from_millis(1500)) + .expect("should schedule"); + + // All return the same debounce duration (timer is reset, not duplicated). + assert_eq!(d1, WAKE_DEBOUNCE); + assert_eq!(d2, WAKE_DEBOUNCE); + assert_eq!(d3, WAKE_DEBOUNCE); + assert_eq!(policy.last_wake(), Some(t0 + Duration::from_millis(1500))); + + // Final fire at t0+1.5s+2s = t0+3.5s: exactly one reapply. + crate::hidutil::with_isolated_state_test(|| { + let runner = WakeFakeRunner::new(vec!["[]".to_string()]); + let remaps = vec![remap(0x700000039, 0x7000000e4)]; + let _applied = reapply_remappings(&remaps, &runner).expect("reapply should succeed"); + assert_eq!(runner.set_count(), 1); + }); + } + + // ── 3. reapply_is_idempotent ─────────────────────────────────────── + + #[test] + fn reapply_is_idempotent() { + crate::hidutil::with_isolated_state_test(|| { + let owned = (0x700000039, 0x7000000e4); + let remaps = vec![remap(owned.0, owned.1)]; + + // First reapply: empty baseline. reconcile_stale_state_with finds + // no state file (fresh), so only one --get is consumed (for the + // baseline snapshot). After apply, a state file is written. + // Second reapply: reconcile_stale_state_with reads the state file + // (written by the first call), which triggers one --get + one --set + // to reconcile, then apply reads another --get for the new baseline + // and does one --set. So we need 1 + 2 = 3 --get responses queued. + let runner = WakeFakeRunner::new(vec![ + "[]".to_string(), // first apply baseline + json_stdout_string_values(&[owned]), // second reconcile current + json_stdout_string_values(&[owned]), // second apply baseline + ]); + let first = reapply_remappings(&remaps, &runner).expect("first reapply"); + let first_set = runner.last_set(); + // First apply: 1 --set (no reconcile --set since no state file). + assert_eq!(runner.set_count(), 1); + + // Second reapply: post-apply state has the owned entry; reconcile + // removes the stale owned entry (1 --set), then apply re-sets it + // (1 --set). The final --set payload should be the same owned entry. + let second = reapply_remappings(&remaps, &runner).expect("second reapply"); + let second_set = runner.last_set(); + // 1 (first apply) + 1 (reconcile --set) + 1 (second apply --set) = 3. + assert_eq!(runner.set_count(), 3); + + // The final set payloads should be identical (owned entry is same). + assert_eq!(first_set, second_set); + // No duplicated owned entries. + assert_eq!(first.owned, second.owned); + assert_eq!(first.owned.len(), 1); + }); + } + + // ── 4. reapply_preserves_foreign_mappings ────────────────────────── + + #[test] + fn reapply_preserves_foreign_mappings() { + crate::hidutil::with_isolated_state_test(|| { + // Post-wake --get contains a foreign entry (not in owned set). + let foreign = (0x700000064, 0x700000064); + let runner = WakeFakeRunner::new(vec![json_stdout_string_values(&[foreign])]); + let remaps = vec![ + remap(0x700000039, 0x7000000e4), + remap(0x7000000e4, 0x700000039), + ]; + let applied = reapply_remappings(&remaps, &runner).expect("reapply should succeed"); + + let set = runner.last_set(); + // Foreign entry survives. + assert!( + set.contains("\"HIDKeyboardModifierMappingSrc\":0x700000064"), + "foreign mapping must survive reapply" + ); + // Owned entries are present. + assert!(set.contains("\"HIDKeyboardModifierMappingSrc\":0x700000039")); + assert!(set.contains("\"HIDKeyboardModifierMappingSrc\":0x7000000e4")); + // Baseline records the foreign entry. + assert_eq!(applied.baseline.len(), 1); + }); + } + + // ── 5. reapply_after_full_reset_sets_only_owned ──────────────────── + + #[test] + fn reapply_after_full_reset_sets_only_owned() { + crate::hidutil::with_isolated_state_test(|| { + // Post-wake --get returns empty (full reset by macOS). + let runner = WakeFakeRunner::new(vec!["[]".to_string()]); + let remaps = vec![ + remap(0x700000039, 0x7000000e4), + remap(0x7000000e4, 0x700000039), + ]; + let applied = reapply_remappings(&remaps, &runner).expect("reapply should succeed"); + + let set = runner.last_set(); + // Only owned entries — no stale baseline re-applied. + assert_eq!(applied.owned.len(), 2); + assert_eq!(applied.baseline.len(), 0); + assert!(set.contains("\"HIDKeyboardModifierMappingSrc\":0x700000039")); + assert!(set.contains("\"HIDKeyboardModifierMappingSrc\":0x7000000e4")); + // No foreign/stale entries. + assert!(!set.contains("\"HIDKeyboardModifierMappingSrc\":0x700000064")); + }); + } + + // ── 6. pending_shutdown_cancels_reapply ──────────────────────────── + + #[test] + fn pending_shutdown_cancels_reapply() { + let t0 = Instant::now(); + let mut policy = ReapplyPolicy::default_debounce(); + + // Wake arrives, then shutdown signal before the timer fires. + let _ = policy.on_wake_message(t0).expect("should schedule"); + policy.on_shutdown(); + + // After shutdown, on_wake_message returns None (cancelled). + assert!(policy + .on_wake_message(t0 + Duration::from_secs(1)) + .is_none()); + assert!(policy.is_cancelled()); + + // The production closure checks shutting_down and skips reapply. + let shutting_down = AtomicBool::new(true); + let applied_slot: Mutex> = Mutex::new(None); + crate::hidutil::with_isolated_state_test(|| { + let runner = WakeFakeRunner::new(vec!["[]".to_string()]); + let remaps = vec![remap(0x700000039, 0x7000000e4)]; + + // Simulate the on_wake closure body with shutting_down=true. + if shutting_down.load(Ordering::SeqCst) { + // Skip — no reapply, no set. + assert_eq!(runner.set_count(), 0); + } else { + let _ = reapply_remappings(&remaps, &runner); + } + assert_eq!(runner.set_count(), 0); + assert!(applied_slot.lock().map_or(true, |g| g.is_none())); + }); + } + + // ── 7. reapply_failure_is_nonfatal_and_preserves_old_applied_mappings + + #[test] + fn reapply_failure_is_nonfatal_and_preserves_old_applied_mappings() { + crate::hidutil::with_isolated_state_test(|| { + // Make --set fail on the reapply. + let runner = WakeFakeRunner::with_failing_set(vec!["[]".to_string()]); + let remaps = vec![remap(0x700000039, 0x7000000e4)]; + + // The old AppliedMappings (simulated pre-wake value). + let old_mappings = AppliedMappings { + baseline: vec![], + owned: vec![crate::hidutil::UserKeyMappingEntry { + src: 0x700000039, + dst: 0x7000000e4, + }], + }; + let slot: Mutex> = Mutex::new(Some(old_mappings.clone())); + + // Reapply fails → Err propagates to the caller path that logs warn. + let result = reapply_remappings(&remaps, &runner); + assert!(result.is_err(), "reapply should fail"); + + // The swap is NOT called on failure — old AppliedMappings remain. + let current = slot.lock().map_or(None, |g| g.clone()); + assert_eq!(current, Some(old_mappings)); + + // No successful set was recorded (the failing one didn't push). + assert_eq!(runner.set_count(), 0); + }); + } + + // ── ReapplyPolicy unit tests (pure, no hidutil) ──────────────────── + + #[test] + fn policy_on_wake_returns_debounce_duration() { + let mut policy = ReapplyPolicy::new(Duration::from_millis(500)); + let now = Instant::now(); + let delay = policy.on_wake_message(now).expect("should schedule"); + assert_eq!(delay, Duration::from_millis(500)); + assert_eq!(policy.last_wake(), Some(now)); + } + + #[test] + fn policy_on_wake_after_cancel_returns_none() { + let mut policy = ReapplyPolicy::default_debounce(); + policy.on_shutdown(); + let delay = policy.on_wake_message(Instant::now()); + assert!(delay.is_none()); + } + + #[test] + fn policy_on_shutdown_marks_cancelled() { + let mut policy = ReapplyPolicy::default_debounce(); + assert!(!policy.is_cancelled()); + policy.on_shutdown(); + assert!(policy.is_cancelled()); + } + + #[test] + fn policy_debounce_returns_configured_duration() { + let policy = ReapplyPolicy::new(Duration::from_millis(100)); + assert_eq!(policy.debounce(), Duration::from_millis(100)); + } + + #[test] + fn publish_applied_mappings_replaces_value() { + let slot: Mutex> = Mutex::new(None); + let new_mappings = AppliedMappings { + baseline: vec![], + owned: vec![crate::hidutil::UserKeyMappingEntry { src: 1, dst: 2 }], + }; + publish_applied_mappings(&slot, new_mappings.clone()).expect("publish should succeed"); + let current = slot.lock().map_or(None, |g| g.clone()); + assert_eq!(current, Some(new_mappings)); + } + + #[test] + fn publish_applied_mappings_returns_err_on_poison() { + let slot: Mutex> = Mutex::new(None); + // Poison the mutex by panicking while holding it. + let slot_ref = std::sync::Arc::new(slot); + let slot_clone = slot_ref.clone(); + let _ = std::thread::spawn(move || { + let _guard = slot_clone.lock().unwrap(); + panic!("poison"); + }) + .join(); + let result = publish_applied_mappings(&slot_ref, AppliedMappings::default()); + assert!(result.is_err(), "should fail on poisoned mutex"); + } + + // ── 8. startup_fail_then_wake_success_arms_cleanup ───────────────── + // + // If startup apply failed (HIDUTIL_ACTIVE=false, APPLIED_MAPPINGS=None), + // a successful wake reapply must set HIDUTIL_ACTIVE=true and publish + // APPLIED_MAPPINGS so shutdown cleanup runs. + + #[test] + fn startup_fail_then_wake_success_arms_cleanup() { + let shutting_down = AtomicBool::new(false); + let hidutil_active = AtomicBool::new(false); // startup failed + let applied_slot: Mutex> = Mutex::new(None); + let tx_lock: Mutex<()> = Mutex::new(()); + + crate::hidutil::with_isolated_state_test(|| { + let remaps = vec![remap(0x700000039, 0x7000000e4)]; + let runner = WakeFakeRunner::new(vec!["[]".to_string()]); + + // Simulate the on_wake closure body (same logic as the production + // closure in install_power_watcher). + if !shutting_down.load(Ordering::SeqCst) && !remaps.is_empty() { + let _tx = tx_lock.lock().unwrap(); + match reapply_remappings(&remaps, &runner) { + Ok(new_mappings) => { + match publish_applied_mappings(&applied_slot, new_mappings) { + Ok(()) => { + hidutil_active.store(true, Ordering::Release); + } + Err(_) => { + warn!("wake: publication failed; rolling back"); + // In tests we don't actually roll back hidutil + } + } + } + Err(e) => { + warn!("wake: failed to reapply modifier remaps: {e}"); + } + } + } + + // Wake success armed cleanup: + assert!(hidutil_active.load(Ordering::Relaxed)); + let current = applied_slot.lock().map_or(None, |g| g.clone()); + assert!(current.is_some()); + assert_eq!(current.as_ref().unwrap().owned.len(), 1); + }); + } + + // ── 9. wake_snapshot_replaces_startup_state ──────────────────────── + // + // On successful wake reapply, the new AppliedMappings (with the post-wake + // baseline) replaces the old startup state in the mutex. Shutdown + // consumes the newest state. + + #[test] + fn wake_snapshot_replaces_startup_state() { + let startup_mappings = AppliedMappings { + baseline: vec![crate::hidutil::UserKeyMappingEntry { + src: 0x700000064, + dst: 0x700000065, + }], + owned: vec![crate::hidutil::UserKeyMappingEntry { + src: 0x700000039, + dst: 0x7000000e4, + }], + }; + let applied_slot: Mutex> = + Mutex::new(Some(startup_mappings.clone())); + + crate::hidutil::with_isolated_state_test(|| { + let remaps = vec![remap(0x700000039, 0x7000000e4)]; + let runner = WakeFakeRunner::new(vec!["[]".to_string()]); // full reset post-wake + + let new_mappings = reapply_remappings(&remaps, &runner).expect("reapply succeeds"); + publish_applied_mappings(&applied_slot, new_mappings.clone()) + .expect("publish should succeed"); + + // The mutex now holds the post-wake state, not the startup state. + let current = applied_slot.lock().map_or(None, |g| g.clone()); + assert_eq!(current, Some(new_mappings)); + assert_ne!(current, Some(startup_mappings)); + // Post-wake baseline is empty (full reset), not the old baseline. + assert_eq!(current.as_ref().unwrap().baseline.len(), 0); + }); + } + + // ── 10. wake_failure_preserves_prior_state ───────────────────────── + // + // On wake reapply failure, APPLIED_MAPPINGS and HIDUTIL_ACTIVE must + // retain their prior values — the daemon stays alive with valid + // cleanup state. + + #[test] + fn wake_failure_preserves_prior_state() { + let hidutil_active = AtomicBool::new(true); // startup succeeded + let prior_mappings = AppliedMappings { + baseline: vec![], + owned: vec![crate::hidutil::UserKeyMappingEntry { + src: 0x700000039, + dst: 0x7000000e4, + }], + }; + let applied_slot: Mutex> = Mutex::new(Some(prior_mappings.clone())); + + crate::hidutil::with_isolated_state_test(|| { + let remaps = vec![remap(0x700000039, 0x7000000e4)]; + let runner = WakeFakeRunner::with_failing_set(vec!["[]".to_string()]); + + // Simulate the on_wake closure body. + match reapply_remappings(&remaps, &runner) { + Ok(new_mappings) => match publish_applied_mappings(&applied_slot, new_mappings) { + Ok(()) => { + hidutil_active.store(true, Ordering::Release); + } + Err(_) => { + warn!("wake: publication failed; rolling back"); + } + }, + Err(e) => { + warn!("wake: failed to reapply modifier remaps: {e}"); + } + } + + // Failure: prior state preserved. + assert!( + hidutil_active.load(Ordering::Relaxed), + "active flag unchanged" + ); + let current = applied_slot.lock().map_or(None, |g| g.clone()); + assert_eq!(current, Some(prior_mappings), "prior mappings preserved"); + }); + } + + // ── 11. panic_cleanup_contention_skips_concurrent_mutation ───────── + // + // If a panic occurs while the transaction lock is held (e.g. during a + // wake reapply), the panic hook must use try_lock and skip live hidutil + // mutation rather than blocking or interleaving. + + #[test] + fn panic_cleanup_contention_skips_concurrent_mutation() { + let tx_lock: Mutex<()> = Mutex::new(()); + let hidutil_active = AtomicBool::new(true); + let applied_slot: Mutex> = Mutex::new(Some(AppliedMappings { + baseline: vec![], + owned: vec![crate::hidutil::UserKeyMappingEntry { + src: 0x700000039, + dst: 0x7000000e4, + }], + })); + + // Simulate a wake reapply holding the transaction lock. + let _wake_tx = tx_lock.lock().unwrap(); + + // Simulate the panic hook's try_lock path. + let lock_acquired = tx_lock.try_lock().is_ok(); + assert!( + !lock_acquired, + "try_lock should fail when transaction is in progress" + ); + + // When try_lock fails, the panic hook skips live mutation. + // The AppliedMappings remain in the mutex for the durable state + // file to reconcile at next startup. + // + // If try_lock had succeeded (it should NOT), we would run + // remove_owned_mappings — but since it didn't, we skip. The + // assertion below verifies state is unchanged. + assert!( + !lock_acquired, + "try_lock should fail when transaction is in progress" + ); + + // State is unchanged — the mutex still holds the mappings. + assert!(hidutil_active.load(Ordering::Relaxed)); + assert!(applied_slot.lock().is_ok_and(|g| g.is_some())); + } + + // ── 12. shutdown_consumes_newest_state_not_startup_local ─────────── + // + // The shutdown path must prefer APPLIED_MAPPINGS (newest committed) + // over the startup-local. This test simulates the shutdown logic. + + #[test] + fn shutdown_consumes_newest_state_not_startup_local() { + let startup_local = AppliedMappings { + baseline: vec![crate::hidutil::UserKeyMappingEntry { + src: 0x700000064, + dst: 0x700000065, + }], // stale pre-sleep baseline + owned: vec![crate::hidutil::UserKeyMappingEntry { + src: 0x700000039, + dst: 0x7000000e4, + }], + }; + let post_wake = AppliedMappings { + baseline: vec![], // post-wake full reset baseline + owned: vec![crate::hidutil::UserKeyMappingEntry { + src: 0x700000039, + dst: 0x7000000e4, + }], + }; + + // After wake reapply, the mutex holds the newest state. + let applied_slot: Mutex> = Mutex::new(Some(post_wake.clone())); + let mut applied_local: Option = Some(startup_local.clone()); + + // Shutdown logic: prefer mutex, fall back to local. + let cleanup = applied_slot + .lock() + .ok() + .and_then(|mut g| g.take()) + .or_else(|| applied_local.take()); + + // Cleanup used the post-wake state, NOT the stale startup local. + assert_eq!(cleanup, Some(post_wake)); + assert_ne!(cleanup, Some(startup_local)); + // The local was NOT consumed. + assert!(applied_local.is_some(), "stale local should not be used"); + } + + // ── 13. wake_publication_failure_rolls_back_and_keeps_prior_state ── + // + // If the APPLIED_MAPPINGS mutex is poisoned when publishing a successful + // wake reapply, the closure must NOT set HIDUTIL_ACTIVE and must roll + // back the just-applied mappings. Prior state remains intact. + + #[test] + fn wake_publication_failure_rolls_back_and_keeps_prior_state() { + let hidutil_active = AtomicBool::new(true); // startup succeeded + let prior_mappings = AppliedMappings { + baseline: vec![], + owned: vec![crate::hidutil::UserKeyMappingEntry { + src: 0x700000039, + dst: 0x7000000e4, + }], + }; + + // Poison the applied_slot mutex. + let applied_slot: std::sync::Arc>> = + std::sync::Arc::new(Mutex::new(Some(prior_mappings.clone()))); + let slot_clone = applied_slot.clone(); + let _ = std::thread::spawn(move || { + let _g = slot_clone.lock().unwrap(); + panic!("poison"); + }) + .join(); + + crate::hidutil::with_isolated_state_test(|| { + let remaps = vec![remap(0x700000039, 0x7000000e4)]; + let runner = WakeFakeRunner::new(vec!["[]".to_string()]); + + // Simulate the on_wake closure body with publication failure. + let tx_lock = std::sync::Mutex::new(()); + let Ok(_tx) = tx_lock.lock() else { + return; + }; + match reapply_remappings(&remaps, &runner) { + Ok(new_mappings) => { + match publish_applied_mappings(&applied_slot, new_mappings.clone()) { + Ok(()) => { + hidutil_active.store(true, Ordering::Release); + } + Err(_) => { + // Publication failed — do NOT set active; + // roll back (in production, remove_owned_mappings + // would run; in test we just verify the flag). + warn!("wake: publication failed; rolling back"); + // hidutil::remove_owned_mappings(&new_mappings) in prod + } + } + } + Err(e) => { + warn!("wake: failed to reapply modifier remaps: {e}"); + } + } + + // Active flag should still be true (from startup), NOT re-set + // by the failed wake publication. The prior state is intact + // because publication failed (the poisoned mutex still holds + // the prior value in its inner state). + assert!( + hidutil_active.load(Ordering::Relaxed), + "active flag should be unchanged (true from startup)" + ); + // The reapply itself succeeded (1 --set recorded). + assert_eq!(runner.set_count(), 1); + }); + } +} diff --git a/uninstall.sh b/uninstall.sh index e49f804..c7921d6 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,26 +1,122 @@ -#!/usr/bin/env bash +#!/bin/bash +# Uninstaller for Switcheroo. Idempotently handles both pre-migration +# (com.local.switcheroo) and post-migration (com.mitchelljphayes.switcheroo) +# installs, removing only Switcheroo-owned plists and the app bundle while +# preserving config and unrelated hidutil mappings. +# +# FAIL-CLOSED: aborts before ANY bootout or binary removal whenever a +# verified NEW helper is unavailable or reconciliation fails — not only +# for legacy label cases. Never deletes the recovery binary without +# successful reconciliation. +# +# Helper binary candidates (known-current only, no arbitrary/legacy +# installed binary probing): +# 1. Script-adjacent Switcheroo.app (from an extracted release archive) +# 2. Source-tree target/release/switcheroo +# Both are validated: regular file, current-uid ownership, exact +# --capabilities output. set -euo pipefail -PLIST_NAME="com.local.switcheroo" -PLIST_DST="${HOME}/Library/LaunchAgents/${PLIST_NAME}.plist" -APP_DIR="${HOME}/.local/bin/Switcheroo.app" +PATH=/usr/bin:/bin:/usr/sbin:/sbin +export PATH -echo "==> Stopping switcheroo..." -# bootout sends SIGTERM, which triggers the daemon's hidutil cleanup. -# The explicit hidutil clear below is belt-and-suspenders in case the -# daemon was already stopped or didn't clean up (e.g. after SIGKILL). -launchctl bootout "gui/$(id -u)/${PLIST_NAME}" 2>/dev/null || true +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=scripts/lib.sh +. "${SCRIPT_DIR}/scripts/lib.sh" -echo "==> Clearing hidutil modifier remaps" -hidutil property --set '{"UserKeyMapping":[]}' >/dev/null 2>&1 || true +REAL_HOME="$(sw_real_home)" +APP_DIR="${REAL_HOME}/.local/bin/Switcheroo.app" +CONFIG_DIR="${REAL_HOME}/.config/switcheroo" +PLIST_DST_DIR="${REAL_HOME}/Library/LaunchAgents" -echo "==> Removing LaunchAgent" -rm -f "${PLIST_DST}" +# Validate HOME. +sw_assert_safe_dir "${REAL_HOME}" +sw_assert_safe_dir "${PLIST_DST_DIR}" + +# Locate a verified NEW helper binary — known-current candidates only. +# Do NOT probe arbitrary installed binaries (legacy binaries may not +# support --capabilities and can enter the event tap). +SW_HELPER="" +for candidate in \ + "${SCRIPT_DIR}/Switcheroo.app/Contents/MacOS/switcheroo" \ + "${SCRIPT_DIR}/target/release/switcheroo" +do + if [ -f "$candidate" ] && sw_helper_has_capabilities "$candidate"; then + SW_HELPER="$candidate" + break + fi +done + +# If any Switcheroo installation exists (app dir or plist), a verified NEW +# helper is REQUIRED for reconciliation. Abort before any bootout/removal. +if [ -z "$SW_HELPER" ]; then + if [ -d "${APP_DIR}" ] || [ -f "${REAL_HOME}/Library/LaunchAgents/com.mitchelljphayes.switcheroo.plist" ] || [ -f "${REAL_HOME}/Library/LaunchAgents/com.local.switcheroo.plist" ]; then + echo "ERROR: a Switcheroo installation exists, but no verified NEW helper" >&2 + echo " binary supporting --reconcile-hidutil-state and" >&2 + echo " --snapshot-legacy-foreign-hidutil is available." >&2 + echo " Refusing to proceed: stale hidutil mappings or legacy" >&2 + echo " blanket cleanup could destroy unrelated mappings." >&2 + echo "" >&2 + echo " To proceed:" >&2 + echo " 1. Run this uninstaller from the current release archive" >&2 + echo " (extract the archive and run ./uninstall.sh), or" >&2 + echo " 2. Build from source and run ./uninstall.sh from the repo root." >&2 + exit 1 + fi + # No installation found — nothing to do. + echo "No Switcheroo installation found." + exit 0 +fi + +echo "==> Verified helper: ${SW_HELPER}" + +# Reconcile stale hidutil state before any bootout or binary removal. +# FAIL-CLOSED: if reconciliation fails, abort before bootout/removal. +echo "==> Reconciling stale hidutil state..." +if ! env -i HOME="${REAL_HOME}" PATH=/usr/bin:/bin "$SW_HELPER" --reconcile-hidutil-state; then + echo "ERROR: hidutil state reconciliation failed — refusing to proceed" >&2 + echo " with bootout or binary removal while stale mappings may persist." >&2 + exit 1 +fi + +echo "==> Stopping Switcheroo launch agents..." +for label in com.local.switcheroo com.mitchelljphayes.switcheroo; do + plist="${REAL_HOME}/Library/LaunchAgents/${label}.plist" + + if sw_label_is_loaded "${label}"; then + if sw_loaded_job_is_switcheroo "${label}"; then + # Legacy label: snapshot/restore foreign mappings around blanket cleanup. + if [ "${label}" = "com.local.switcheroo" ]; then + echo " Snapshotting foreign hidutil mappings (excluding legacy-owned)..." + LEGACY_CFG="${CONFIG_DIR}/config.toml" + FOREIGN_JSON="$(sw_snapshot_legacy_foreign "$SW_HELPER" "${LEGACY_CFG}")" + sw_bootout_safe "${label}" + "${SW_BIN_SLEEP}" 1 + echo " Restoring foreign hidutil mappings after legacy shutdown..." + sw_hidutil_restore_json "${FOREIGN_JSON}" + else + sw_bootout_safe "${label}" + fi + sw_reject_symlink "${plist}" + "${SW_BIN_RM}" -f "${plist}" + else + echo " Skipping ${label}: loaded but not Switcheroo (possible collision)" + fi + elif [ -f "${plist}" ] && sw_plist_is_switcheroo "${plist}"; then + sw_reject_symlink "${plist}" + "${SW_BIN_RM}" -f "${plist}" + fi +done +"${SW_BIN_SLEEP}" 1 echo "==> Removing app bundle" -rm -rf "${APP_DIR}" +sw_reject_symlink "${APP_DIR}" +if [ -d "${APP_DIR}" ] && [ ! -L "${APP_DIR}" ]; then + "${SW_BIN_RM}" -rf "${APP_DIR}" +fi echo "" -echo "Done. Keyboard modifier remaps have been cleared." -echo "Config preserved at ~/.config/switcheroo/config.toml" -echo "Remove it manually if you want: rm -rf ~/.config/switcheroo" +echo "Done. Switcheroo-owned hidutil mappings were removed on daemon shutdown" +echo "(legacy foreign mappings restored from snapshot)." +echo "Config preserved at ${CONFIG_DIR}/config.toml" +echo "Remove it manually if you want: ${SW_BIN_RM} -rf ${CONFIG_DIR}"