diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd4185e..ffb609a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} @@ -28,5 +28,27 @@ jobs: - run: cargo clippy --all-targets -- -D warnings # The pack tests decode and measure audio but never open a device, so - # they run fine on a headless runner. + # they run fine on a headless runner. On macOS this run is also the only + # thing that links the binary, and so the only check that every + # `extern "C"` name in keyboard/macos.rs is a symbol that really exists — + # clippy stops before the linker. - run: cargo test + + # Catches a broken cfg on macOS from the Linux and Windows machines most of + # Jaster is written on. No macOS SDK here, so this stops at type checking: it + # finds bad Rust, not a bad framework symbol. + macos-cross-check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin, x86_64-apple-darwin + components: clippy + + # No ALSA step: cpal takes the CoreAudio path for Apple targets, so + # alsa-sys is not in that dependency graph at all. + - run: cargo clippy --target aarch64-apple-darwin --all-targets -- -D warnings + - run: cargo clippy --target x86_64-apple-darwin --all-targets -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40bb9b0..8d20df4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,3 +71,57 @@ jobs: uses: softprops/action-gh-release@v2 with: files: jaster-windows-x86_64.zip + + build-macos: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + # The runners are Apple Silicon, so the Intel half is the cross + # build. Both targets ship with the same SDK. + targets: aarch64-apple-darwin, x86_64-apple-darwin + + # No system dependencies: CoreAudio, CoreGraphics and IOKit are part of + # the OS. + - name: Build both architectures + run: | + cargo build --release --target aarch64-apple-darwin + cargo build --release --target x86_64-apple-darwin + + # One universal binary rather than two assets, so install.sh has nothing + # to decide from `uname -m`. + - name: Make one universal binary + run: | + lipo -create -output jaster-universal \ + target/aarch64-apple-darwin/release/jaster \ + target/x86_64-apple-darwin/release/jaster + lipo -info jaster-universal + + # Apple Silicon refuses to execute an unsigned binary at all. The linker + # ad-hoc signs each slice, but lipo is exactly the surgery that can + # invalidate that, so re-sign the finished file. `--sign -` is ad-hoc: no + # certificate, no Apple account, and no notarization — none of which this + # download path needs, because curl never sets the quarantine attribute + # Gatekeeper keys on. + - name: Ad-hoc sign + run: | + codesign --force --sign - jaster-universal + codesign --verify --verbose jaster-universal + + - name: Package Release + run: | + mkdir jaster + cp jaster-universal jaster/jaster + cp scripts/install.sh jaster/install.sh + cp -r assets jaster/ + chmod +x jaster/jaster jaster/install.sh + + tar -czf jaster-macos-universal.tar.gz jaster + + - name: Upload Release + uses: softprops/action-gh-release@v2 + with: + files: jaster-macos-universal.tar.gz diff --git a/README.md b/README.md index 15d7a0c..652ca2b 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,14 @@ **Bring mechanical typing sounds to your native keyboard.** -Jaster is a lightweight CLI application that adds realistic mechanical typing sounds to any keyboard on Linux and Windows, providing an immersive typing experience with minimal setup. +Jaster is a lightweight CLI application that adds realistic mechanical typing sounds to any keyboard on Linux, macOS and Windows, providing an immersive typing experience with minimal setup. --- > [!NOTE] > **Current Platform Support** > -> Jaster supports **Linux** and **Windows**. -> -> Support for **macOS** is under active development as part of Jaster's cross-platform roadmap. +> Jaster supports **Linux**, **macOS** and **Windows**. ## Installation @@ -35,6 +33,29 @@ exec su - "$USER" jaster start ``` +### macOS + +Copy and paste the following into your terminal: + +```bash +# Download and install Jaster +curl -fsSL https://raw.githubusercontent.com/JoeCelaster/Jaster/main/install.sh | bash + +# Start Jaster and enable typing sounds +jaster start +``` + +macOS will not let anything read the keyboard until you say so. Open +**System Settings → Privacy & Security → Input Monitoring** and turn on the +entry for the terminal you ran Jaster from, then **quit that terminal +completely** (⌘Q — a new window is not enough) and reopen it. + +The permission belongs to the app that *launched* Jaster, not to Jaster, so the +switch is named after your terminal — Terminal, iTerm2, Ghostty, VS Code — and +there may be no "jaster" entry in the list at all. + +One universal binary covers both Apple Silicon and Intel. + ### Windows Paste this into PowerShell. No administrator rights are needed — Jaster @@ -177,6 +198,13 @@ Jaster listens for keyboard input events and plays synchronized typing sounds in - Audio system supported by your distribution (ALSA/PipeWire/PulseAudio) - Permission to access `/dev/input` devices +**macOS** + +- macOS 10.15 (Catalina) or later +- Apple Silicon or Intel — the installer ships one universal binary +- Input Monitoring granted to the terminal you start Jaster from +- Nothing else. Audio and key capture both use built-in system frameworks. + **Windows** - Windows 10 or later @@ -209,6 +237,20 @@ sudo usermod -aG input $USER exec su - "$USER" ``` +**On macOS**, four things are worth knowing: + +- The Input Monitoring switch carries your *terminal's* name, not Jaster's. + Grant it to every terminal you start Jaster from. +- Granting it only affects processes started afterwards, so quit the terminal + with ⌘Q and reopen it. Opening a new window is not enough. +- Typing is silent in password fields and in any app using Secure Keyboard + Entry (Terminal has it in its own menu). macOS shuts every event tap out of + those deliberately, and there is nothing Jaster can do about it. +- The grant is tied to the exact binary, so `jaster update` needs you to allow + it once more. + +`jaster doctor` reports which of these is in the way. + **On Windows**, two things are worth knowing: - Anti-cheat software (Vanguard, EasyAntiCheat, BattlEye) and some endpoint @@ -232,6 +274,15 @@ sudo rm -rf /usr/share/jaster rm -rf ~/.local/share/jaster ``` +**macOS** — remove the installed binary and its sounds, then revoke the +permission under *System Settings → Privacy & Security → Input Monitoring*: + +```bash +sudo rm /usr/local/bin/jaster +sudo rm -rf /usr/local/share/jaster +rm -rf ~/.local/share/jaster +``` + **Windows** — stop Jaster, then remove its folder and PATH entry: ```powershell diff --git a/SETUP.md b/SETUP.md index d044a8a..ac645d5 100644 --- a/SETUP.md +++ b/SETUP.md @@ -7,28 +7,31 @@ This guide gets you from a fresh clone to a running development build of Jaster ## Read this first: the current platform reality -**Linux and Windows are both first-class targets.** Each builds and runs -natively, and CI checks both on every push. +**Linux, macOS and Windows are all first-class targets.** Each builds and runs +natively, and CI checks all three on every push. Platform-specific code lives in exactly two places, and the rest of the codebase is free of `#[cfg]`: -| Concern | Linux | Windows | -|---------|-------|---------| -| Key capture | `src/keyboard/linux.rs` — `evdev`, one thread per `/dev/input` device | `src/keyboard/windows.rs` — a `WH_KEYBOARD_LL` hook and its message pump | -| Paths, process control, console | `#[cfg(unix)]` arms in `src/utils/` and `src/commands/` | `#[cfg(windows)]` arms alongside them | +| Concern | Linux | macOS | Windows | +|---------|-------|-------|---------| +| Key capture | `src/keyboard/linux.rs` — `evdev`, one thread per `/dev/input` device | `src/keyboard/macos.rs` — a `CGEventTap` and its run loop | `src/keyboard/windows.rs` — a `WH_KEYBOARD_LL` hook and its message pump | +| Paths, process control, console | `#[cfg(unix)]` arms in `src/utils/` and `src/commands/`, plus a few `#[cfg(target_os = "linux")]` ones | the same `#[cfg(unix)]` arms, plus `#[cfg(target_os = "macos")]` where `/proc` and `/usr/share` do not exist | `#[cfg(windows)]` arms alongside them | `src/keyboard/mod.rs` picks the backend with `#[cfg_attr(..., path = ...)]`, so -both sides must export the same `listen` and `sources`; a symbol missing from -one is a compile error rather than a silent gap. +all three must export the same `listen` and `sources`; a symbol missing from one +is a compile error rather than a silent gap. -Everything else already travels. `rodio` selects ALSA on Linux and WASAPI on -Windows automatically, and the decode/slice/normalize/limit pipeline in -`src/audio/` has no OS dependency at all. +Everything else already travels. `rodio` selects ALSA on Linux, CoreAudio on +macOS and WASAPI on Windows automatically, and the decode/slice/normalize/limit +pipeline in `src/audio/` has no OS dependency at all. -**macOS is not supported yet.** `src/keyboard/mod.rs` fails the build with a -clear message there. See [macOS](#macos) — adding a third backend is now a -matter of writing one file to that same two-function interface. +**Watch the `unix` / `linux` distinction.** macOS is Unix, so a `#[cfg(unix)]` +arm written with only Linux in mind compiles there and is wrong at runtime — +which is silent. `is_jaster` in `src/utils/pid.rs` is the worked example: the +`/proc//comm` read it used to do under `#[cfg(unix)]` would have answered +"not Jaster" for every pid on macOS, so `jaster stop` would have reported +success while the daemon played on. --- @@ -163,61 +166,126 @@ Linux backend. ## macOS -**macOS is not supported yet.** The build stops with a `compile_error!` from -`src/keyboard/mod.rs` saying so, rather than a dependency failure. Choose one of -the two paths below. - -### Path A — contribute to Linux/Windows from macOS - -Use a Linux VM or container to build and run, and edit code natively on macOS. - -**Docker / Podman** (works on Apple Silicon and Intel): +macOS builds and runs natively. Install Apple's command line tools, which is the +whole toolchain requirement: ```bash -docker run --rm -it \ - -v "$PWD":/work -w /work \ - rust:1.97 \ - bash -c "apt-get update && \ - apt-get install -y pkg-config libasound2-dev && \ - cargo build" +xcode-select --install # clang and the linker ``` -This gives you a compiling build for verifying code changes, `cargo clippy`, and -`cargo fmt`. It does **not** give you real keyboard or audio hardware, so -`jaster start` cannot be end-to-end tested in a container — a full VM (UTM, -Parallels, VMware Fusion, or OrbStack with a desktop image) with USB passthrough -is needed for that. - -**Practical split:** verify compilation and lints in the container, and ask a -Linux maintainer to smoke-test hardware behavior on the PR. +There is nothing else to install. CoreAudio, CoreGraphics and IOKit ship with +the OS, `rodio` targets CoreAudio directly, and `src/keyboard/macos.rs` declares +the framework calls it needs itself rather than pulling in a wrapper crate — the +same choice `windows.rs` makes with windows-sys. -### Path B — work on the macOS port itself +```bash +cargo build +cargo test +cargo run -- doctor +``` -This is the contribution that removes the need for Path A. Install the host -toolchain you will need: +macOS 10.15 (Catalina) is the floor: the permission calls the backend uses, +`CGPreflightListenEventAccess` and `IOHIDCheckAccess`, do not exist before it. + +### Input Monitoring, and why it is granted to your terminal + +A `CGEventTap` receives nothing until the user grants **Input Monitoring** under +*System Settings → Privacy & Security → Input Monitoring*. Four things about it +cost time if you learn them by debugging instead of reading: + +- **The grant belongs to whatever *launched* Jaster**, not to Jaster. Run from a + terminal, the switch to turn on carries your terminal's name — Terminal, + iTerm2, Ghostty, VS Code — and there may be no "jaster" entry in the list at + all. Each terminal you develop from needs its own. +- **Only processes started after the grant see it.** Quit the terminal with ⌘Q + and reopen it; a new window or tab is not enough. +- **The grant is tied to the exact binary that asked**, which is why users have + to allow Jaster once more after `jaster update` replaces it. Launching from a + terminal you have already granted, your own rebuilds are covered by the + terminal's grant. +- **Missing permission is not an error, it is a null port.** Since 10.15, + `CGEventTapCreate` returns null rather than a tap that never fires, which is + what `listen()` turns into a real error message. + +`cargo run -- doctor` reports all of this, and will trigger the system prompt +itself when nothing has asked yet — there is no entry in System Settings to +point anyone at until something requests it. + +**Secure Keyboard Entry.** Typing is silent inside password fields and in any app +that turns on Secure Input (Terminal has it in its own menu). macOS shuts every +event tap out of those by design. If keys stop making a sound in one app only, +that is this and not a bug. + +### Things to know when working on the macOS backend + +`src/keyboard/macos.rs` is a run loop with a callback, so it shares most of its +hazards with the Windows hook rather than with the evdev threads: + +- **The system switches the tap off** when a callback is slow + (`kCGEventTapDisabledByTimeout`) or when the user's input outruns it + (`...ByUserInput`), and reports it nowhere else — the daemon goes deaf while + still looking healthy. `handle` re-arms the tap from inside the callback, + which is what the `port` `Cell` on `Tap` exists for. This is the macOS twin of + the Windows 300 ms `LowLevelHooksTimeout`. +- **The callback does a table lookup and a `try_send`, nothing else.** No audio, + no allocation, no lock the audio side might be holding. The consumer thread in + `listen()` does the work. +- **The tap is listen-only** (`kCGEventTapOptionListenOnly`), so it cannot + swallow a keystroke even by mistake — the OS enforces the invariant + `windows.rs` has to uphold by hand with `CallNextHookEx`. +- **It is a session tap, not a HID tap.** `kCGHIDEventTap` wants root; the + session level sees every key in the login session without it. +- **Auto-repeat comes marked**, like evdev's `value == 2`, so there is no + held-key table to maintain — unlike Windows. +- **Modifiers arrive as `kCGEventFlagsChanged`**, which fires on both press and + release and says which it was nowhere. The flag bit only means "something in + this group is down", so left shift and right shift are indistinguishable by + flags alone. `transition()` tracks each key and uses the group bit to + resynchronise. + +**The half you can test anywhere.** The keycode table and that modifier logic +live in `src/keyboard/macos_keys.rs`, outside the backend, compiled under +`#[cfg(any(target_os = "macos", test))]`. They are pure arithmetic, they are the +part that fails silently — a wrong scancode does not crash, it plays the generic +click forever — and they are covered by `cargo test` on every runner, Linux and +Windows included. Anything you can express without a framework call belongs +there rather than in `macos.rs`. + +### Type-checking macOS from Linux or Windows + +Most breakage is reachable without a Mac. Type-checking needs no macOS SDK: ```bash -xcode-select --install # Apple's command line tools (clang, linker) -brew install pkg-config # if you do not already have it +rustup target add aarch64-apple-darwin x86_64-apple-darwin +cargo clippy --target aarch64-apple-darwin --all-targets -- -D warnings +cargo clippy --target x86_64-apple-darwin --all-targets -- -D warnings ``` -macOS needs no extra audio packages — `rodio` targets CoreAudio directly. The -Linux-only dependencies are already gated behind -`[target.'cfg(target_os = "linux")'.dependencies]`, so nothing blocks the build -except the missing backend itself: `src/keyboard/mod.rs` raises a -`compile_error!` until a `macos.rs` exists. See -[Porting roadmap](#porting-roadmap-adding-macos). +Run this before pushing anything that touches `#[cfg]`-gated code; CI does the +same in the `macos-cross-check` job. Its limit is worth knowing: there is no +linker step, so it finds bad Rust but not a misspelled framework symbol. Only +the real `macos-latest` job links the binary, which is why `cargo test` runs +there. -**macOS permissions.** Any global key listener on macOS requires the user to -grant your terminal (or the built binary) **Input Monitoring** and, for some -APIs, **Accessibility** access: +### Contributing from macOS to the Linux side -`System Settings → Privacy & Security → Input Monitoring` (and `→ Accessibility`) +If you need to verify Linux behavior, a container gets you compiling and linting +(Apple Silicon and Intel alike): -Without it, an event tap silently receives nothing. The macOS port will need to -detect this state and surface it in `jaster doctor`, the same way the Linux path -surfaces the `input` group requirement and the Windows path reports a blocked -hook. +```bash +docker run --rm -it \ + -v "$PWD":/work -w /work \ + rust:1.97 \ + bash -c "apt-get update && \ + apt-get install -y pkg-config libasound2-dev && \ + cargo build" +``` + +It does **not** give you real keyboard or audio hardware, so `jaster start` +cannot be tested end to end in a container — a full VM (UTM, Parallels, VMware +Fusion, or OrbStack with a desktop image) with USB passthrough is needed for +that. In practice: verify compilation and lints in the container, and say on the +PR that a Linux maintainer should smoke-test the hardware behavior. --- @@ -355,8 +423,10 @@ src/ │ └── version.rs prints CARGO_PKG_VERSION ├── keyboard/ the only place a key-capture backend lives │ ├── key.rs `Key` — a PS/2 set-1 scancode, the type the rest of the code uses -│ ├── mod.rs picks the backend by target; both must export sources() + listen() +│ ├── mod.rs picks the backend by target; all three must export sources() + listen() │ ├── linux.rs scans /dev/input for A + ENTER + SPACE, one thread per device +│ ├── macos.rs CGEventTap, its run loop, and the Input Monitoring checks +│ ├── macos_keys.rs the testable half of the macOS backend: keycodes + modifier state │ └── windows.rs WH_KEYBOARD_LL hook, its message pump, and the auto-repeat filter ├── audio/ │ ├── engine.rs rodio output stream @@ -374,12 +444,18 @@ src/ Only two areas branch on the OS, and keeping it that way is the point: - **`src/keyboard/`** — the backend is selected in `mod.rs` with - `#[cfg_attr(..., path = ...)]`, so `linux.rs` and `windows.rs` are never - compiled together and must both satisfy the same two-function interface. + `#[cfg_attr(..., path = ...)]`, so `linux.rs`, `macos.rs` and `windows.rs` are + never compiled together and must all satisfy the same two-function interface. + `macos_keys.rs` is the exception that proves it: it holds the parts of the + macOS backend that need no framework, so they can be compiled and tested on + every host. - **`#[cfg(unix)]` / `#[cfg(windows)]` arms** in `utils/paths.rs`, `utils/pid.rs`, `utils/select.rs`, `commands/start.rs`, `commands/stop.rs`, - `commands/doctor.rs`, and `commands/update.rs` — each is a small pair of - functions with the same signature, sitting next to each other. + `commands/doctor.rs`, and `commands/update.rs` — each is a small set of + functions with the same signature, sitting next to each other. Where macOS + parts company with Linux inside `unix` it gets its own arm: `installed_sounds` + in `paths.rs` (`/usr/local/share`, because SIP seals `/usr/share`) and + `is_jaster` in `pid.rs` (`proc_pidpath`, because there is no `/proc`). `src/audio/` contains no platform code at all, which is why `Key` exists. @@ -460,54 +536,12 @@ route to the command, never to the switch). --- -## Porting roadmap: adding macOS - -Linux and Windows are done. macOS is the remaining target, and the structure -the Windows port established makes it a much smaller job than it was. - -**What is already in place** - -- A platform-neutral key type, `src/keyboard/key.rs`. `Key` is a PS/2 set-1 - scancode, which is the space sound packs are written in, so `src/audio/` - never sees an OS-specific type. -- A two-function backend interface — `sources()` and `listen()` — selected in - `src/keyboard/mod.rs`. Both the threaded evdev backend and the single-pump - Win32 backend satisfy it, so it is unlikely to fight a third. -- `#[cfg(unix)]` arms already cover macOS for paths, `setsid` detaching, - `libc::kill`, and the termios picker, since those are Unix rather than Linux - concerns. - -**What a macOS port needs** - -1. **A `src/keyboard/macos.rs` backend**, wired into the `#[cfg_attr]` in - `src/keyboard/mod.rs`, and the `compile_error!` there relaxed. A - `CGEventTap` is the usual approach; it needs a `CFRunLoop`, which is - structurally the same shape as the Windows message pump. -2. **A keycode conversion.** macOS virtual keycodes are their own numbering, so - this is a table to `Key`, in the same spirit as `from_evdev` in - `src/keyboard/linux.rs`. Keep it in the backend file. -3. **Input Monitoring permission.** Unlike Windows, macOS requires the user to - grant it under *System Settings → Privacy & Security → Input Monitoring*, and - without it the tap silently receives nothing. `check_keyboard()` in - `src/commands/doctor.rs` should detect and explain this — it already returns - `(bool, Vec)` for exactly this kind of remediation text. -4. **`sound_root()` and `data_dir()`** in `src/utils/paths.rs` — decide whether - macOS follows the Unix layout (it does today, by falling through - `#[cfg(unix)]`) or moves to `~/Library/Application Support`. -5. **A release job and installer**, mirroring `build-windows` in - `.github/workflows/release.yml` and `scripts/install.ps1`. Add `macos-latest` - to the matrix in `.github/workflows/ci.yml` at the same time. - -Each of these is independently reviewable, so feel free to take just one. - ---- - ## Contribution workflow 1. Fork the repository and create a branch: ```bash - git checkout -b feat/macos-key-listener + git checkout -b feat/short-description ``` 2. Make your change. Before committing: @@ -519,6 +553,14 @@ Each of these is independently reviewable, so feel free to take just one. cargo test ``` + If you touched anything `#[cfg]`-gated, type-check the two targets your + machine is not: + + ```bash + cargo clippy --target aarch64-apple-darwin --all-targets -- -D warnings + cargo clippy --target x86_64-pc-windows-msvc --all-targets -- -D warnings + ``` + 3. Write a clear commit message describing what changed and why. 4. Open a pull request against `main`. In the description, state: @@ -527,20 +569,18 @@ Each of these is independently reviewable, so feel free to take just one. that it compiles (entirely fine — just say so, so a maintainer knows to smoke-test it). -Note that CI currently runs only on `v*` tags to publish releases; there is no -automated check on pull requests yet. Run the commands above locally — a -reviewer is relying on you having done so. +CI runs clippy and the test suite on Linux, macOS and Windows for every push and +pull request, plus a cross-check of both Apple targets from Linux. It cannot +type for you, though: nothing in CI presses a key, so hardware behavior is still +something a human has to confirm. ### Version bumps The release version lives in `Cargo.toml` and must be committed together with the updated `Cargo.lock` — several past commits exist purely to repair a -mismatch between the two. - -Also be aware of an existing inconsistency worth fixing if you touch this area: -`src/cli/args.rs` hardcodes `#[command(version = "0.1.0")]`, so `jaster --version` -reports `0.1.0` while `jaster version` correctly reports the `Cargo.toml` -version. Replacing the literal with `env!("CARGO_PKG_VERSION")` fixes it. +mismatch between the two. Everything else reads it from there: `jaster version` +through `env!("CARGO_PKG_VERSION")`, and `jaster --version` through clap's bare +`#[command(version)]` in `src/cli/args.rs`. --- @@ -548,7 +588,7 @@ version. Replacing the literal with `env!("CARGO_PKG_VERSION")` fixes it. | Symptom | Cause | Fix | |---------|-------|-----| -| `Jaster supports Linux and Windows` compile error | Building on macOS, which has no backend yet | See [Porting roadmap](#porting-roadmap-adding-macos) | +| `Jaster supports Linux, macOS and Windows` compile error | Building on a fourth OS | Expected — there is no backend for it in `src/keyboard/` | | `ALSA lib ... cannot find card` or link error on `-lasound` | Missing ALSA headers | Install `libasound2-dev` / `alsa-lib-devel` | | `jaster doctor` reports "Permission denied" | User is not in the `input` group | `sudo usermod -aG input $USER`, then start a new session | | No keyboards detected, permissions look correct | Session not refreshed since the group change | `exec su - "$USER"`, or log out and back in | @@ -558,6 +598,12 @@ version. Replacing the literal with `env!("CARGO_PKG_VERSION")` fixes it. | Windows: `jaster start` succeeds but nothing plays | Daemon failed after detaching | Read `%LOCALAPPDATA%\Jaster\daemon.log` | | Windows: typing is silent only in some apps | The app runs elevated, or anti-cheat blocks the hook | Run `jaster doctor`; elevate Jaster to match | | Windows: a held key machine-guns | Auto-repeat filter regressed in `src/keyboard/windows.rs` | The `HELD` set must be updated on key-up | +| macOS: no sound at all, `doctor` shows the tap unavailable | Input Monitoring not granted to the terminal you launched from | Grant it, then quit the terminal with ⌘Q and reopen it | +| macOS: granted the permission and still nothing | The grant only reaches processes started after it, and it names your terminal, not Jaster | ⌘Q the terminal — a new window is not enough — and check the entry is the terminal's | +| macOS: silent in one app only | That app uses Secure Keyboard Entry, which excludes every event tap | Expected; nothing Jaster can do | +| macOS: sound stops after a burst of typing, daemon still running | The tap was disabled by timeout or user input and not re-armed | `handle` in `src/keyboard/macos.rs` must call `CGEventTapEnable` on both disable events | +| macOS: one key plays the generic click | Its virtual keycode is missing from the table | Add it to `from_virtual` in `src/keyboard/macos_keys.rs`; the tests there cover the rest | +| macOS: `jaster stop` says "stopped" but sound continues | `is_jaster` failed to match the running binary | Check the `proc_pidpath` arm in `src/utils/pid.rs`; `pkill jaster` meanwhile | --- diff --git a/install.sh b/install.sh index 0ec6492..6576ab0 100755 --- a/install.sh +++ b/install.sh @@ -1,16 +1,41 @@ #!/usr/bin/env bash set -e +# macOS ships bash 3.2, so nothing here may use associative arrays, ${var,,} +# or mapfile. +case "$(uname -s)" in + Linux) + case "$(uname -m)" in + x86_64) ASSET="jaster-linux-x86_64.tar.gz" ;; + *) + echo "Jaster has no Linux build for $(uname -m) yet." >&2 + exit 1 + ;; + esac + ;; + Darwin) + # One universal binary covers Apple Silicon and Intel, so there is nothing + # to decide from `uname -m` here. + ASSET="jaster-macos-universal.tar.gz" + ;; + *) + echo "Jaster supports Linux, macOS and Windows. This is $(uname -s)." >&2 + exit 1 + ;; +esac + TMP_DIR=$(mktemp -d) echo "📦 Downloading Jaster..." -curl -L \ - https://github.com/JoeCelaster/Jaster/releases/latest/download/jaster-linux-x86_64.tar.gz \ +# -f so a 404 fails here, rather than writing an HTML error page into the +# tarball and failing inside `tar` with something unreadable. +curl -fL \ + "https://github.com/JoeCelaster/Jaster/releases/latest/download/$ASSET" \ -o "$TMP_DIR/jaster.tar.gz" tar -xzf "$TMP_DIR/jaster.tar.gz" -C "$TMP_DIR" cd "$TMP_DIR/jaster" -bash install.sh \ No newline at end of file +bash install.sh diff --git a/scripts/install.sh b/scripts/install.sh index c353fe9..ae3a5f7 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,8 +3,17 @@ set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# /usr/share is on macOS's sealed system volume, where SIP refuses writes even +# to root. /usr/local is the one prefix Apple leaves alone. Has to agree with +# `installed_sounds()` in src/utils/paths.rs. +if [ "$(uname -s)" = "Darwin" ]; then + SHARE_DIR=/usr/local/share/jaster +else + SHARE_DIR=/usr/share/jaster +fi + sudo mkdir -p /usr/local/bin -sudo mkdir -p /usr/share/jaster +sudo mkdir -p "$SHARE_DIR" # Staged, then renamed into place: `jaster update` runs this script from the # very binary we are replacing, and writing over a running executable fails @@ -13,7 +22,11 @@ sudo cp "$SCRIPT_DIR/jaster" /usr/local/bin/jaster.new sudo chmod +x /usr/local/bin/jaster.new sudo mv -f /usr/local/bin/jaster.new /usr/local/bin/jaster -sudo cp -r "$SCRIPT_DIR/assets/sounds" /usr/share/jaster/ +# Removed first because `cp -r src/sounds dst/` copies *into* an existing +# sounds/ rather than over it, so every update since launch has been nesting +# another sounds/sounds/ inside the last one. +sudo rm -rf "$SHARE_DIR/sounds" +sudo cp -r "$SCRIPT_DIR/assets/sounds" "$SHARE_DIR/" # `jaster update` sets this. It reports the new version and restarts the daemon # itself, so the first-run welcome below would only be noise on top of that. @@ -29,6 +42,25 @@ GRAY=$(printf '\033[90m') BOLD=$(printf '\033[1m') RESET=$(printf '\033[0m') +# The one part of the welcome that is not the same on both Unixes: Linux needs +# a group, macOS needs a permission granted to the terminal. +if [ "$(uname -s)" = "Darwin" ]; then + PERMISSION="${YELLOW}Let Jaster see your keyboard${RESET} + + ${GRAY}System Settings -> Privacy & Security -> Input Monitoring${RESET} + + Turn on the entry for your terminal, then quit it completely (Cmd-Q) + and reopen it. ${GRAY}macOS grants this to whatever launched jaster, + not to jaster, so there may be no \"jaster\" entry in the list.${RESET} +" +else + PERMISSION="${YELLOW}If no keyboards are detected${RESET} + + sudo usermod -aG input \$USER + exec su - \"\$USER\" +" +fi + cat < Result<(), Box> { println!("🩺 Jaster Doctor\n"); println!("Operating System"); - println!(" ✓ {}", if cfg!(windows) { "Windows" } else { "Linux" }); + println!(" ✓ {OS}"); println!(); let audio = check_audio(); @@ -152,6 +160,74 @@ fn check_keyboard() -> (bool, Vec) { (installable, advice) } +/// macOS answers this with a permission rather than a device list: the tap is +/// always there, it just receives nothing until Input Monitoring is granted. +#[cfg(target_os = "macos")] +fn check_keyboard() -> (bool, Vec) { + use crate::keyboard::Access; + + println!("Keyboard"); + + let mut access = keyboard::access(); + + // Nothing has asked yet, so there is no switch in System Settings to point + // anyone at. Asking is what creates it — and it may put the prompt on + // screen, which is the shortest path to a working install. + if access == Access::Unknown { + keyboard::request(); + access = keyboard::access(); + } + + match access { + Access::Granted => println!(" ✓ Input Monitoring granted"), + Access::Denied => println!(" ✗ Input Monitoring denied"), + Access::Unknown => println!(" ? Input Monitoring not decided yet"), + } + + let tap = keyboard::tap_can_be_created(); + + if tap { + println!(" ✓ System-wide keyboard event tap available"); + } else { + println!(" ✗ Could not create the keyboard event tap"); + } + + let ready = access == Access::Granted && tap; + + let mut advice = vec![ + "ℹ macOS gives Input Monitoring to whatever *launched* Jaster, not to".to_string(), + " Jaster. Started from a terminal, the switch to turn on carries your".to_string(), + " terminal's name — Terminal, iTerm2, Ghostty, VS Code — and there may".to_string(), + " be no \"jaster\" entry in the list at all.".to_string(), + String::new(), + "ℹ Typing is silent inside password fields and in any app using Secure".to_string(), + " Keyboard Entry (Terminal has it in its own menu). macOS shuts every".to_string(), + " event tap out of those, by design.".to_string(), + String::new(), + "ℹ The grant is tied to the exact binary, so expect to allow it once".to_string(), + " more after `jaster update` replaces it.".to_string(), + ]; + + if !ready { + advice.splice( + 0..0, + [ + "❌ Jaster cannot capture keys yet.".to_string(), + String::new(), + "Open System Settings → Privacy & Security → Input Monitoring and".to_string(), + "turn on the entry for your terminal. Then quit the terminal".to_string(), + "completely — ⌘Q, a new window is not enough, since the grant".to_string(), + "only reaches processes started after it — reopen it and run:".to_string(), + String::new(), + " jaster doctor".to_string(), + String::new(), + ], + ); + } + + (ready, advice) +} + fn ready() { println!("{}", " Jaster is Ready!".bold().cyan()); println!(); diff --git a/src/commands/update.rs b/src/commands/update.rs index da72862..e96aad1 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -67,13 +67,17 @@ pub fn run() -> Result<(), Box> { Ok(()) } +/// The same bootstrap serves Linux and macOS — it picks the release asset from +/// `uname` — which is why there is no macOS arm anywhere in this file. #[cfg(unix)] const INSTALLER: &str = "curl -fsSL https://raw.githubusercontent.com/JoeCelaster/Jaster/main/install.sh | bash"; -/// Where the installer puts the binary. We restart through this path rather -/// than `current_exe()` because by then `current_exe()` is the *replaced* -/// inode — the old build we just updated away from. +/// Where the installer puts the binary, on both Unixes: `/usr/local/bin` is in +/// `/etc/paths` on every macOS install and outside the set SIP protects. We +/// restart through this path rather than `current_exe()` because by then +/// `current_exe()` is the *replaced* inode — the old build we just updated +/// away from. #[cfg(unix)] const INSTALLED: &str = "/usr/local/bin/jaster"; diff --git a/src/keyboard/macos.rs b/src/keyboard/macos.rs new file mode 100644 index 0000000..a0c91d5 --- /dev/null +++ b/src/keyboard/macos.rs @@ -0,0 +1,344 @@ +use std::{ + cell::{Cell, RefCell}, + ffi::c_void, + ptr, + sync::mpsc::{SyncSender, sync_channel}, + thread, +}; + +use crate::keyboard::{Key, macos_keys}; + +type Error = Box; + +// The tap surface is a dozen calls across three frameworks, declared here +// rather than taken from the `core-graphics` crate. That crate is a safe +// wrapper whose event-tap API boxes a Rust closure, and it does not expose the +// raw port we need to re-arm a tap the system switches off; the permission +// calls below have no crate binding at all. `windows.rs` makes the same choice +// by using windows-sys, the raw sibling of `windows`. + +type CFMachPortRef = *mut c_void; +type CFRunLoopSourceRef = *mut c_void; +type CFRunLoopRef = *mut c_void; +type CFRunLoopMode = *const c_void; +type CFAllocatorRef = *const c_void; +type CGEventRef = *mut c_void; +type CGEventTapProxy = *mut c_void; + +/// `Boolean` is a byte, not C's `int`. +type Boolean = u8; + +type CGEventTapCallBack = unsafe extern "C" fn( + proxy: CGEventTapProxy, + event_type: u32, + event: CGEventRef, + user_info: *mut c_void, +) -> CGEventRef; + +#[link(name = "CoreGraphics", kind = "framework")] +unsafe extern "C" { + fn CGEventTapCreate( + tap: u32, + place: u32, + options: u32, + events_of_interest: u64, + callback: CGEventTapCallBack, + user_info: *mut c_void, + ) -> CFMachPortRef; + + fn CGEventTapEnable(tap: CFMachPortRef, enable: Boolean); + fn CGEventGetIntegerValueField(event: CGEventRef, field: u32) -> i64; + fn CGEventGetFlags(event: CGEventRef) -> u64; + + /// macOS 10.15+. Whether this process may listen to keyboard events — + /// the same permission the tap needs, as a plain yes or no. + fn CGPreflightListenEventAccess() -> Boolean; + fn CGRequestListenEventAccess() -> Boolean; +} + +#[link(name = "IOKit", kind = "framework")] +unsafe extern "C" { + /// macOS 10.15+. Tri-state, which is the only way to tell "denied" from + /// "never asked" — the difference between telling someone to flip a switch + /// and telling them one is about to appear. + fn IOHIDCheckAccess(request_type: u32) -> u32; +} + +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + fn CFMachPortCreateRunLoopSource( + allocator: CFAllocatorRef, + port: CFMachPortRef, + order: isize, + ) -> CFRunLoopSourceRef; + + fn CFMachPortInvalidate(port: CFMachPortRef); + fn CFRunLoopGetCurrent() -> CFRunLoopRef; + fn CFRunLoopAddSource(run_loop: CFRunLoopRef, source: CFRunLoopSourceRef, mode: CFRunLoopMode); + fn CFRunLoopRun(); + fn CFRelease(cf: *const c_void); + + #[link_name = "kCFRunLoopCommonModes"] + static COMMON_MODES: CFRunLoopMode; +} + +/// Session level rather than `kCGHIDEventTap`: it is the least privileged +/// location that still sees every key in the login session, and the HID one +/// wants root. +const SESSION_EVENT_TAP: u32 = 1; +const HEAD_INSERT_EVENT_TAP: u32 = 0; + +/// Listen only. A tap that can rewrite events is a tap that can swallow them, +/// and Jaster has no business touching a keystroke on its way to an app. This +/// is the same invariant `windows.rs` upholds by always calling +/// `CallNextHookEx`, except here the OS enforces it for us. +const TAP_OPTION_LISTEN_ONLY: u32 = 1; + +const EVENT_KEY_DOWN: u32 = 10; +const EVENT_FLAGS_CHANGED: u32 = 12; +const EVENT_TAP_DISABLED_BY_TIMEOUT: u32 = 0xFFFF_FFFE; +const EVENT_TAP_DISABLED_BY_USER_INPUT: u32 = 0xFFFF_FFFF; + +const FIELD_AUTOREPEAT: u32 = 8; +const FIELD_KEYCODE: u32 = 9; + +const REQUEST_TYPE_LISTEN_EVENT: u32 = 1; +const ACCESS_TYPE_GRANTED: u32 = 0; +const ACCESS_TYPE_DENIED: u32 = 1; + +const EVENT_MASK: u64 = (1 << EVENT_KEY_DOWN) | (1 << EVENT_FLAGS_CHANGED); + +/// What macOS thinks about our Input Monitoring request. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Access { + Granted, + Denied, + /// Nothing has asked yet, so there is no entry in System Settings to turn + /// on — which makes "go and switch it on" advice about a switch that does + /// not exist. `request()` creates it. + Unknown, +} + +/// Whether this process may listen to keys. +pub fn access() -> Access { + // The tap is what actually has to work, and this is the reading that + // matches it, so a yes here settles the question by itself. + if unsafe { CGPreflightListenEventAccess() } != 0 { + return Access::Granted; + } + + // It said no. IOKit's tri-state is only consulted to separate a refusal + // from a permission nobody has asked for yet — the symptom is identical + // and the advice is not. A stale "granted" from IOKit still counts as + // denied, because the call above is the one the tap will agree with. + match unsafe { IOHIDCheckAccess(REQUEST_TYPE_LISTEN_EVENT) } { + ACCESS_TYPE_DENIED | ACCESS_TYPE_GRANTED => Access::Denied, + _ => Access::Unknown, + } +} + +/// Ask macOS for the permission, which is what puts the entry in System +/// Settings in the first place. Returns whether it was granted. +pub fn request() -> bool { + unsafe { CGRequestListenEventAccess() != 0 } +} + +/// What the tap callback needs, reached through `CGEventTapCreate`'s refcon. +/// +/// `windows.rs` uses a thread-local because `SetWindowsHookExW` offers the hook +/// proc no user pointer. `CGEventTapCreate` does, so this takes it — which also +/// drops the "must be the installing thread" invariant that goes with it. +struct Tap { + /// Set once `CGEventTapCreate` has returned and before the run loop starts, + /// so a callback can re-arm the very tap it is running inside. A `Cell` and + /// not a lock: it is touched only from the run-loop thread, and taking a + /// lock in here is one of the few ways to miss the system's deadline. + port: Cell, + sink: SyncSender, + /// Which modifiers we believe are physically down. See `macos_keys`. + modifiers: RefCell<[bool; 128]>, +} + +/// Runs for every keystroke in the session. Like the Windows hook proc it does +/// nothing but a table lookup and a non-blocking send — no audio, no +/// allocation, no locks the audio side might hold. +unsafe extern "C" fn handle( + _proxy: CGEventTapProxy, + event_type: u32, + event: CGEventRef, + user_info: *mut c_void, +) -> CGEventRef { + // `tap_can_be_created` passes a null refcon, but it never adds its port to + // a run loop, so this is only ever reached with a real one. + if user_info.is_null() { + return event; + } + + let tap = unsafe { &*(user_info as *const Tap) }; + + match event_type { + // The system switches a tap off when a callback is slow, and again + // when the user's own input outruns it. Nothing else reports it: the + // daemon just goes deaf while still looking healthy, so re-arming here + // is not optional. This is macOS's version of the Windows 300 ms + // `LowLevelHooksTimeout` hazard. + EVENT_TAP_DISABLED_BY_TIMEOUT | EVENT_TAP_DISABLED_BY_USER_INPUT => { + let port = tap.port.get(); + + if !port.is_null() { + unsafe { CGEventTapEnable(port, 1) }; + } + + return event; + } + + EVENT_KEY_DOWN => { + // Unlike the Windows hook, macOS marks auto-repeat for us — the + // same favour evdev does with value 2 — so there is no held-key + // table to maintain and nothing to leak when a key goes up while + // we are not looking. + let repeat = unsafe { CGEventGetIntegerValueField(event, FIELD_AUTOREPEAT) }; + + if repeat == 0 { + let code = unsafe { CGEventGetIntegerValueField(event, FIELD_KEYCODE) }; + + send(tap, code as u16); + } + } + + EVENT_FLAGS_CHANGED => { + let code = unsafe { CGEventGetIntegerValueField(event, FIELD_KEYCODE) } as u16; + let flags = unsafe { CGEventGetFlags(event) }; + + let press = tap + .modifiers + .try_borrow_mut() + .map(|mut down| macos_keys::transition(code, flags, &mut down)) + .unwrap_or(false); + + if press { + send(tap, code); + } + } + + _ => {} + } + + // A listen-only tap has its return value ignored, but handing the event + // straight back is the contract and costs nothing. + event +} + +fn send(tap: &Tap, virtual_key: u16) { + // Never block the callback. A full queue means we are hundreds of keys + // behind and the audio is lost anyway. + let _ = tap.sink.try_send(macos_keys::from_virtual(virtual_key)); +} + +/// The tap is session-wide and deliberately device-agnostic, so there is +/// exactly one source to report. +pub fn sources() -> Result, Error> { + Ok(vec!["System-wide keyboard event tap".to_string()]) +} + +/// Blocks forever, calling `on_press` once per key-down. +pub fn listen(on_press: F) -> Result<(), Error> +where + F: Fn(Key) + Send + Sync + 'static, +{ + let (sender, receiver) = sync_channel::(256); + + let state = Box::into_raw(Box::new(Tap { + port: Cell::new(ptr::null_mut()), + sink: sender, + modifiers: RefCell::new([false; 128]), + })); + + // Audio happens here, off the run loop and clear of its deadline. + thread::spawn(move || { + for key in receiver { + on_press(key); + } + }); + + let port = unsafe { + CGEventTapCreate( + SESSION_EVENT_TAP, + HEAD_INSERT_EVENT_TAP, + TAP_OPTION_LISTEN_ONLY, + EVENT_MASK, + handle, + state.cast(), + ) + }; + + // Since 10.15 this is what a missing Input Monitoring grant looks like: + // not an error code, not an empty stream — a null port. + if port.is_null() { + unsafe { drop(Box::from_raw(state)) }; + + return Err("Could not create the keyboard event tap. Run `jaster doctor`.".into()); + } + + // Safe to write: nothing can call back until the source is on a running + // run loop, which is two statements away. + unsafe { (*state).port.set(port) }; + + unsafe { + let source = CFMachPortCreateRunLoopSource(ptr::null(), port, 0); + + if source.is_null() { + CFMachPortInvalidate(port); + CFRelease(port.cast()); + drop(Box::from_raw(state)); + + return Err("Could not attach the keyboard tap to a run loop.".into()); + } + + CFRunLoopAddSource(CFRunLoopGetCurrent(), source, COMMON_MODES); + CGEventTapEnable(port, 1); + + // A tap is delivered through the run loop it was added to, so the + // callback is never called without one running. We expect no other + // sources — this parks the thread somewhere the system can reach it, + // exactly as the Windows message pump does. + CFRunLoopRun(); + + CGEventTapEnable(port, 0); + CFMachPortInvalidate(port); + CFRelease(source.cast()); + CFRelease(port.cast()); + drop(Box::from_raw(state)); + } + + Ok(()) +} + +/// Whether an event tap can actually be created, which is the same question +/// `hook_is_available` answers on Windows. On macOS a `false` here is almost +/// always the Input Monitoring grant. +pub fn tap_can_be_created() -> bool { + let port = unsafe { + CGEventTapCreate( + SESSION_EVENT_TAP, + HEAD_INSERT_EVENT_TAP, + TAP_OPTION_LISTEN_ONLY, + EVENT_MASK, + handle, + // No state to pass: this port is never added to a run loop, so + // `handle` cannot run. It checks for null anyway. + ptr::null_mut(), + ) + }; + + if port.is_null() { + return false; + } + + unsafe { + CFMachPortInvalidate(port); + CFRelease(port.cast()); + } + + true +} diff --git a/src/keyboard/macos_keys.rs b/src/keyboard/macos_keys.rs new file mode 100644 index 0000000..15aed57 --- /dev/null +++ b/src/keyboard/macos_keys.rs @@ -0,0 +1,464 @@ +//! The pure half of the macOS backend: the keycode table and the modifier +//! press/release logic. +//! +//! It lives apart from `macos.rs` so it can be compiled — and tested — on any +//! host. `macos.rs` links against CoreGraphics, so nothing in it can run on the +//! Linux and Windows CI runners, and both pieces below are exactly the kind of +//! thing that fails silently: a wrong scancode does not crash, it just plays +//! the generic click forever. + +use crate::keyboard::Key; + +pub const FLAG_ALPHA_SHIFT: u64 = 0x0001_0000; +pub const FLAG_SHIFT: u64 = 0x0002_0000; +pub const FLAG_CONTROL: u64 = 0x0004_0000; +pub const FLAG_ALTERNATE: u64 = 0x0008_0000; +pub const FLAG_COMMAND: u64 = 0x0010_0000; + +pub const VK_CAPS_LOCK: u16 = 0x39; + +/// Where keys with no PS/2 equivalent go — the Fn key, the media keys, the JIS +/// block, and whatever an exotic keyboard invents. `0xE0FF` is a well-formed +/// extended code that no pack defines, so these reach the generic clip the way +/// an unknown key does on Linux, without borrowing a real key's sound. +pub const UNKNOWN: Key = Key(0xE0FF); + +/// Every modifier that reaches us as `kCGEventFlagsChanged`, paired with the +/// flag bit that says "something in this group is down". Left and right share a +/// bit, which is the whole reason `transition` below is not a one-liner. +/// +/// The Mac-only `fn` key is deliberately absent: it has no set-1 scancode, so +/// there is no sound to give it, and `from_virtual` sends it to [`UNKNOWN`]. +pub const MODIFIERS: [(u16, u64); 9] = [ + (0x38, FLAG_SHIFT), // left shift + (0x3C, FLAG_SHIFT), // right shift + (0x3B, FLAG_CONTROL), // left control + (0x3E, FLAG_CONTROL), // right control + (0x3A, FLAG_ALTERNATE), // left option + (0x3D, FLAG_ALTERNATE), // right option + (0x37, FLAG_COMMAND), // left command + (0x36, FLAG_COMMAND), // right command + (VK_CAPS_LOCK, FLAG_ALPHA_SHIFT), +]; + +fn modifier_group(virtual_key: u16) -> Option { + MODIFIERS + .iter() + .find(|(code, _)| *code == virtual_key) + .map(|(_, group)| *group) +} + +/// macOS virtual keycode to set-1 scancode. +/// +/// Unlike evdev there is no identity range to lean on — the Mac layout is its +/// own numbering, ordered by position on an ADB keyboard — so the whole table +/// is spelled out. Anything unrecognised becomes [`UNKNOWN`] and lands on the +/// pack's generic sound. +pub fn from_virtual(code: u16) -> Key { + Key(match code { + // Letters + 0x00 => 0x1E, // a + 0x0B => 0x30, // b + 0x08 => 0x2E, // c + 0x02 => 0x20, // d + 0x0E => 0x12, // e + 0x03 => 0x21, // f + 0x05 => 0x22, // g + 0x04 => 0x23, // h + 0x22 => 0x17, // i + 0x26 => 0x24, // j + 0x28 => 0x25, // k + 0x25 => 0x26, // l + 0x2E => 0x32, // m + 0x2D => 0x31, // n + 0x1F => 0x18, // o + 0x23 => 0x19, // p + 0x0C => 0x10, // q + 0x0F => 0x13, // r + 0x01 => 0x1F, // s + 0x11 => 0x14, // t + 0x20 => 0x16, // u + 0x09 => 0x2F, // v + 0x0D => 0x11, // w + 0x07 => 0x2D, // x + 0x10 => 0x15, // y + 0x06 => 0x2C, // z + + // Number row + 0x12 => 0x02, // 1 + 0x13 => 0x03, // 2 + 0x14 => 0x04, // 3 + 0x15 => 0x05, // 4 + 0x17 => 0x06, // 5 + 0x16 => 0x07, // 6 + 0x1A => 0x08, // 7 + 0x1C => 0x09, // 8 + 0x19 => 0x0A, // 9 + 0x1D => 0x0B, // 0 + 0x1B => 0x0C, // minus + 0x18 => 0x0D, // equal + + // Punctuation + 0x21 => 0x1A, // left bracket + 0x1E => 0x1B, // right bracket + 0x2A => 0x2B, // backslash + 0x29 => 0x27, // semicolon + 0x27 => 0x28, // quote + 0x32 => 0x29, // grave + 0x2B => 0x33, // comma + 0x2F => 0x34, // period + 0x2C => 0x35, // slash + 0x0A => 0x56, // ISO section, on non-ANSI layouts + + // Editing and whitespace + 0x24 => 0x1C, // return + 0x30 => 0x0F, // tab + 0x31 => 0x39, // space + 0x33 => 0x0E, // delete, which is backspace + 0x35 => 0x01, // escape + + // Modifiers + 0x38 => 0x2A, // left shift + 0x3C => 0x36, // right shift + 0x3B => 0x1D, // left control + 0x3E => 0xE01D, // right control + 0x3A => 0x38, // left option + 0x3D => 0xE038, // right option + 0x37 => 0xE05B, // left command + 0x36 => 0xE05C, // right command + 0x39 => 0x3A, // caps lock + 0x6E => 0xE05D, // contextual menu + + // Function row + 0x7A => 0x3B, // f1 + 0x78 => 0x3C, // f2 + 0x63 => 0x3D, // f3 + 0x76 => 0x3E, // f4 + 0x60 => 0x3F, // f5 + 0x61 => 0x40, // f6 + 0x62 => 0x41, // f7 + 0x64 => 0x42, // f8 + 0x65 => 0x43, // f9 + 0x6D => 0x44, // f10 + 0x67 => 0x57, // f11 + 0x6F => 0x58, // f12 + 0x69 => 0xE037, // f13, where a PC keyboard has print screen + 0x6B => 0x46, // f14, scroll lock + 0x71 => 0x45, // f15, pause — shares 0x45 with keypad clear below, + // exactly as num lock and pause do on the Linux side + + // Navigation + 0x72 => 0xE052, // help, where a PC keyboard has insert + 0x73 => 0xE047, // home + 0x74 => 0xE049, // page up + 0x75 => 0xE053, // forward delete + 0x77 => 0xE04F, // end + 0x79 => 0xE051, // page down + 0x7B => 0xE04B, // left + 0x7C => 0xE04D, // right + 0x7D => 0xE050, // down + 0x7E => 0xE048, // up + + // Keypad + 0x52 => 0x52, // 0 + 0x53 => 0x4F, // 1 + 0x54 => 0x50, // 2 + 0x55 => 0x51, // 3 + 0x56 => 0x4B, // 4 + 0x57 => 0x4C, // 5 + 0x58 => 0x4D, // 6 + 0x59 => 0x47, // 7 + 0x5B => 0x48, // 8 + 0x5C => 0x49, // 9 + 0x41 => 0x53, // decimal + 0x43 => 0x37, // multiply + 0x45 => 0x4E, // plus + 0x4E => 0x4A, // minus + 0x4B => 0xE035, // divide + 0x4C => 0xE01C, // enter + 0x51 => 0x59, // equals + 0x47 => 0x45, // clear, where a PC keyboard has num lock + + // No `other => other` here, unlike `from_evdev`. That fall-through is + // safe on Linux because evdev 1..=88 *is* set-1; the Mac numbering + // merely occupies the same range while meaning something else, so a + // passthrough would hand every unmapped key another key's sound — the + // Fn key would play F5. Land them on the generic clip instead. + _ => return UNKNOWN, + }) +} + +/// Whether a `kCGEventFlagsChanged` for `virtual_key` is a *press*, updating +/// `down` — our belief about which modifiers are physically held. +/// +/// The event fires on both press and release and says which it was nowhere. +/// The flag bit is no help on its own either: it means "some key in this group +/// is down", so left shift and right shift are indistinguishable by flags +/// alone. Hence tracking each key, with the group bit used to resynchronise. +pub fn transition(virtual_key: u16, flags: u64, down: &mut [bool; 128]) -> bool { + let Some(group) = modifier_group(virtual_key) else { + return false; + }; + + let index = virtual_key as usize; + + // Caps lock is the odd one: its flag reports the *lock* state rather than + // whether the key is down, so it flips exactly once per press. Comparing + // against the last value gives one sound per press, whether or not macOS + // also sends us the release. + if virtual_key == VK_CAPS_LOCK { + let lit = flags & FLAG_ALPHA_SHIFT != 0; + let changed = down[index] != lit; + + down[index] = lit; + + return changed; + } + + if flags & group == 0 { + // Nothing in the group is down any more. Clearing the whole group + // rather than just this key is what repairs the state after a release + // we never saw — an app switch or Secure Input can eat one. + for (code, other) in MODIFIERS { + if other == group { + down[code as usize] = false; + } + } + + return false; + } + + // The group is still active, so either this key just went down, or it went + // up while its twin on the other side holds the flag on. + if down[index] { + down[index] = false; + return false; + } + + down[index] = true; + true +} + +#[cfg(test)] +mod tests { + use super::{ + FLAG_ALPHA_SHIFT, FLAG_SHIFT, MODIFIERS, UNKNOWN, VK_CAPS_LOCK, from_virtual, transition, + }; + use crate::keyboard::Key; + use std::collections::HashMap; + + const LEFT_SHIFT: u16 = 0x38; + const RIGHT_SHIFT: u16 = 0x3C; + + #[test] + fn letters_and_whitespace_land_where_the_packs_are() { + assert_eq!(from_virtual(0x00), Key::A); + assert_eq!(from_virtual(0x31), Key::SPACE); + assert_eq!(from_virtual(0x24), Key::ENTER); + assert_eq!(from_virtual(0x33), Key::BACKSPACE); + } + + #[test] + fn arrows_reach_the_extended_encoding() { + assert_eq!(from_virtual(0x7E), Key::UP); + assert_eq!(from_virtual(0x7B), Key::LEFT); + assert_eq!(from_virtual(0x7C), Key::RIGHT); + assert_eq!(from_virtual(0x7D), Key::DOWN); + } + + /// Left and right have to stay distinct, or one side of the keyboard plays + /// the other's sound. + #[test] + fn modifiers_keep_their_sides_apart() { + assert_eq!(from_virtual(LEFT_SHIFT), Key(0x2A)); + assert_eq!(from_virtual(RIGHT_SHIFT), Key(0x36)); + assert_ne!(from_virtual(0x3B), from_virtual(0x3E)); // control + assert_ne!(from_virtual(0x3A), from_virtual(0x3D)); // option + assert_ne!(from_virtual(0x37), from_virtual(0x36)); // command + } + + /// Keypad enter must not collide with return — the same trap the pack + /// parser has its own test for. + #[test] + fn keypad_stays_distinct_from_the_main_block() { + assert_ne!(from_virtual(0x4C), from_virtual(0x24)); + assert_ne!(from_virtual(0x4B), from_virtual(0x2C)); + } + + /// The check that really matters on a table this size: whatever the Mac + /// reports has to land where the pack loader put its sounds. A typo here is + /// silent — the key just falls back to the generic click — so nothing else + /// would catch it. + #[test] + fn every_mapping_agrees_with_the_pack_parser() { + for code in 0x00..=0x7Fu16 { + let key = from_virtual(code); + + assert_eq!( + Key::from_pack_code(key.0 as u32), + Some(key), + "virtual key {code:#04x} maps to {:#06x}, which no pack can express", + key.0 + ); + } + } + + /// `transition` looks its group up in this table, so a modifier missing + /// from it is a modifier that never makes a sound. + #[test] + fn every_modifier_is_mapped_and_grouped() { + for (code, group) in MODIFIERS { + assert_ne!(group, 0, "modifier {code:#04x} has no flag"); + assert_ne!( + from_virtual(code), + Key(code), + "modifier {code:#04x} falls through the table unmapped" + ); + } + } + + #[test] + fn one_press_one_sound() { + let mut down = [false; 128]; + + assert!(transition(LEFT_SHIFT, FLAG_SHIFT, &mut down)); + assert!(!transition(LEFT_SHIFT, 0, &mut down)); + assert!(transition(LEFT_SHIFT, FLAG_SHIFT, &mut down)); + } + + /// Both shifts held is where a naive "did the flag change" check breaks: + /// the bit is already on when the second one goes down, and still on when + /// the first comes up. + #[test] + fn twin_modifiers_are_counted_separately() { + let mut down = [false; 128]; + + assert!(transition(LEFT_SHIFT, FLAG_SHIFT, &mut down)); + assert!(transition(RIGHT_SHIFT, FLAG_SHIFT, &mut down)); + + // Left comes up while right holds the flag on: no sound, and left must + // be free to sound again. + assert!(!transition(LEFT_SHIFT, FLAG_SHIFT, &mut down)); + assert!(transition(LEFT_SHIFT, FLAG_SHIFT, &mut down)); + + // Everything up. + assert!(!transition(LEFT_SHIFT, FLAG_SHIFT, &mut down)); + assert!(!transition(RIGHT_SHIFT, 0, &mut down)); + + assert!(transition(LEFT_SHIFT, FLAG_SHIFT, &mut down)); + } + + /// A release eaten by an app switch or Secure Input leaves us believing a + /// key is held. The next event that clears the group has to unstick it, + /// otherwise that modifier is silent for the rest of the session. + #[test] + fn a_cleared_group_resyncs_a_missed_release() { + let mut down = [false; 128]; + + assert!(transition(LEFT_SHIFT, FLAG_SHIFT, &mut down)); + + // Pretend the release never arrived, and the next thing we see is the + // right shift going down and up. + assert!(transition(RIGHT_SHIFT, FLAG_SHIFT, &mut down)); + assert!(!transition(RIGHT_SHIFT, 0, &mut down)); + + assert!(!down[LEFT_SHIFT as usize]); + assert!(transition(LEFT_SHIFT, FLAG_SHIFT, &mut down)); + } + + /// Caps lock reports the lock, not the key, so the state it is compared + /// against has to be the lock too — otherwise switching it back off is + /// silent. + #[test] + fn caps_lock_sounds_on_and_off() { + let mut down = [false; 128]; + + assert!(transition(VK_CAPS_LOCK, FLAG_ALPHA_SHIFT, &mut down)); + // A release event, if macOS sends one, must not double up. + assert!(!transition(VK_CAPS_LOCK, FLAG_ALPHA_SHIFT, &mut down)); + + assert!(transition(VK_CAPS_LOCK, 0, &mut down)); + assert!(!transition(VK_CAPS_LOCK, 0, &mut down)); + } + + /// The failure mode a hundred hand-written arms invites: two Mac keys + /// pointing at one scancode, so one plays the other's sound. Num lock and + /// pause genuinely share `0x45` in set-1 — the Linux backend collides them + /// the same way — and that is the only pair allowed. + #[test] + fn no_two_keys_share_a_scancode() { + let mut seen: HashMap = HashMap::new(); + + for code in 0x00..=0x7Fu16 { + let key = from_virtual(code); + + if key == UNKNOWN { + continue; + } + + if let Some(first) = seen.insert(key, code) { + let clear_and_f15 = [0x47, 0x71]; + + assert!( + clear_and_f15.contains(&first) && clear_and_f15.contains(&code), + "kVK {first:#04x} and kVK {code:#04x} both map to {key:?}" + ); + } + } + } + + /// Every key on a MacBook has to reach a real sound, checked against the + /// packs we actually ship rather than a second copy of the table — so it + /// fails if either side drifts. Parsing a pack reads its config.json + /// without decoding any audio, so this stays cheap. + #[test] + fn every_key_on_a_mac_keyboard_has_a_sound() { + // A MacBook keyboard by virtual keycode. No keypad, no f13+, no JIS: + // those are not on the machine most people will run this on. + const KEYBOARD: &[u16] = &[ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0B, 0x0C, 0x0D, 0x0E, + 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, + 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, + 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x35, 0x36, 0x37, 0x38, 0x39, + 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x67, 0x6D, 0x6F, + 0x72, 0x73, 0x74, 0x75, 0x77, 0x79, 0x7A, 0x7B, 0x7C, 0x7D, 0x7E, + ]; + + let packs = crate::audio::theme::available(); + + assert!(!packs.is_empty(), "no packs found to check against"); + + for pack in packs { + for &code in KEYBOARD { + let key = from_virtual(code); + + assert!( + pack.defines.contains_key(&key), + "'{}' has no sound for virtual key {code:#04x} ({key:?})", + pack.id + ); + } + } + } + + /// The sentinel has to stay unclaimable, or an exotic key would start + /// borrowing a real one's sound instead of falling back to the clip. + #[test] + fn the_unknown_sentinel_is_never_a_pack_define() { + for pack in crate::audio::theme::available() { + assert!( + !pack.defines.contains_key(&UNKNOWN), + "'{}' defines the unknown-key sentinel", + pack.id + ); + } + } + + #[test] + fn ordinary_keys_are_not_modifiers() { + let mut down = [false; 128]; + + assert!(!transition(0x00, FLAG_SHIFT, &mut down)); // a + assert!(!transition(0x31, 0, &mut down)); // space + } +} diff --git a/src/keyboard/mod.rs b/src/keyboard/mod.rs index b9d1a0a..f4c25fd 100644 --- a/src/keyboard/mod.rs +++ b/src/keyboard/mod.rs @@ -3,13 +3,24 @@ mod key; pub use key::Key; #[cfg_attr(target_os = "linux", path = "linux.rs")] +#[cfg_attr(target_os = "macos", path = "macos.rs")] #[cfg_attr(windows, path = "windows.rs")] mod backend; -#[cfg(not(any(target_os = "linux", windows)))] -compile_error!("Jaster supports Linux and Windows. See SETUP.md for the macOS port."); +/// The macOS keycode table and its modifier logic live outside the backend so +/// `cargo test` compiles and runs them on Linux and Windows too. They are pure +/// arithmetic — the only part of the macOS port that can be verified without a +/// Mac, so that is where the verification effort goes. +#[cfg(any(target_os = "macos", test))] +mod macos_keys; + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +compile_error!("Jaster supports Linux, macOS and Windows."); pub use backend::{listen, sources}; #[cfg(windows)] pub use backend::hook_is_available; + +#[cfg(target_os = "macos")] +pub use backend::{Access, access, request, tap_can_be_created}; diff --git a/src/utils/instance.rs b/src/utils/instance.rs index 1c9a700..3b2ecef 100644 --- a/src/utils/instance.rs +++ b/src/utils/instance.rs @@ -8,6 +8,10 @@ //! started because the first was invisible — and as `jaster stop` reporting //! success while the sound carries on. //! +//! Unix catches that on its own: `is_jaster` in `pid.rs` asks the kernel whether +//! the pid in the file is still a live Jaster, so a stale file reads as "nothing +//! running" rather than as a daemon. +//! //! So the Windows daemon also takes a named mutex, which no second daemon can //! take, and waits on a named event that `jaster stop` can set from anywhere. //! Both are kernel objects: they disappear the instant the daemon does, however @@ -140,8 +144,9 @@ mod imp { #[cfg(unix)] mod imp { - /// Linux finds the daemon through `/proc`, which cannot go stale the way a - /// pid file can, so there is nothing extra to hold. + /// Unix checks the pid file against the live process — `/proc//comm` on + /// Linux, `proc_pidpath` on macOS — so a stale entry is caught on read and + /// there is nothing extra to hold. pub struct Claim(()); pub fn claim() -> Result> { diff --git a/src/utils/paths.rs b/src/utils/paths.rs index 2d48d91..0b9b053 100644 --- a/src/utils/paths.rs +++ b/src/utils/paths.rs @@ -23,11 +23,20 @@ pub fn data_dir() -> Option { } /// Where the installer puts sound packs. -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "macos")))] fn installed_sounds() -> PathBuf { PathBuf::from("/usr/share/jaster/sounds") } +/// `/usr/share` is on macOS's sealed system volume, where SIP refuses writes +/// even to root — the installer cannot put anything there, so looking for the +/// packs there would find nothing. `/usr/local` is the prefix Apple leaves +/// alone, and `/usr/local/bin/jaster` already lives under it. +#[cfg(target_os = "macos")] +fn installed_sounds() -> PathBuf { + PathBuf::from("/usr/local/share/jaster/sounds") +} + #[cfg(windows)] fn installed_sounds() -> PathBuf { data_dir() diff --git a/src/utils/pid.rs b/src/utils/pid.rs index e32d57e..46ac9f8 100644 --- a/src/utils/pid.rs +++ b/src/utils/pid.rs @@ -26,12 +26,35 @@ pub fn running() -> Option { is_jaster(pid).then_some(pid) } -#[cfg(unix)] +#[cfg(target_os = "linux")] fn is_jaster(pid: u32) -> bool { fs::read_to_string(format!("/proc/{pid}/comm")) .is_ok_and(|command| command.trim().contains("jaster")) } +/// macOS is Unix but has no `/proc`, so the Linux arm above would compile here +/// and quietly answer "no" for every pid — `jaster stop` would report success +/// while the daemon played on, and the next `start` would stack a second one. +/// Ask the kernel for the executable path instead; it is the same recycled-pid +/// guard `comm` gives us on Linux. +#[cfg(target_os = "macos")] +fn is_jaster(pid: u32) -> bool { + let mut buffer = [0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + + let written = unsafe { + libc::proc_pidpath( + pid as libc::pid_t, + buffer.as_mut_ptr().cast(), + buffer.len() as u32, + ) + }; + + written > 0 + && String::from_utf8_lossy(&buffer[..written as usize]) + .to_ascii_lowercase() + .contains("jaster") +} + /// Windows has no `/proc`, so ask the kernel directly: the process must still /// be running *and* its image must be Jaster, which is the same recycled-PID /// guard the Linux path gets from `comm`.