diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50f26db..504dc94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,15 +50,37 @@ env: # once stable v3+ tags exist. jobs: - # ─── Linux: AppImage (x86_64) ──────────────────────────────────────── + # ─── Linux: AppImage (x86_64 + aarch64) ────────────────────────────── + # + # A matrix here and not three jobs, unlike the OS split below: the + # steps are byte-for-byte the same, only the runner and the triple + # change. Both build natively — GitHub's `ubuntu-24.04-arm` runners + # are free for public repositories, and native beats cross-compiling + # for anything that links system libraries (GTK, X11, Wayland) and + # then has to be packaged by a tool that is itself an AppImage. + # + # aarch64 is the *only* extra artifact we take on. Every installer is + # a support surface — a build to keep green, a download to explain, + # a self-update path to get right — so armv7, ARM Windows and the + # rest stay out until someone turns up with the hardware and the + # demand. `poltertype-update::platform_key()` and the `latest.json` + # step below must always name exactly the set published here. appimage: - name: AppImage (x86_64) - runs-on: ubuntu-latest + name: AppImage (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-latest + - arch: aarch64 + runner: ubuntu-24.04-arm steps: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable with: - targets: x86_64-unknown-linux-gnu + targets: ${{ matrix.arch }}-unknown-linux-gnu - uses: Swatinem/rust-cache@v2 with: cache-targets: false @@ -74,7 +96,7 @@ jobs: file desktop-file-utils - name: Build release binary - run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p poltertype-app + run: cargo build --release --locked --target ${{ matrix.arch }}-unknown-linux-gnu -p poltertype-app - name: Render placeholder icon # linuxdeploy wants a square PNG ≥ 256×256. We render at the @@ -90,15 +112,15 @@ jobs: - name: Build AppImage env: VERSION: ${{ github.ref_name }} - BIN_PATH: target/x86_64-unknown-linux-gnu/release/poltertype + ARCH: ${{ matrix.arch }} ICON_PNG: target/dist/icon-256.png OUT_DIR: target/dist run: bash installers/linux/build-appimage.sh - uses: actions/upload-artifact@v4 with: - name: appimage-x86_64 - path: target/dist/poltertype-*-x86_64.AppImage + name: appimage-${{ matrix.arch }} + path: target/dist/poltertype-*-${{ matrix.arch }}.AppImage if-no-files-found: error # ─── macOS: universal DMG ──────────────────────────────────────────── @@ -244,6 +266,15 @@ jobs: # Keep the shape in step with `poltertype-update`'s `Manifest` # struct and its `platform_key()`: the three keys below are # exactly what that function can produce. + # + # What this step does NOT do is sign the manifest. The updater + # verifies an ed25519 signature over it, and the whole point of + # that signature is to mean something a compromised GitHub + # account cannot forge — so the key is not an Actions secret and + # never enters this runner. Signing is a manual step between this + # draft and the moment a human publishes it; the job summary + # below spells out the two commands, and `docs/RELEASING.md` has + # the full ritual. - name: Write latest.json env: TAG: ${{ github.ref_name }} @@ -274,9 +305,10 @@ jobs: } { - entry "linux-x86_64" "poltertype-*-x86_64.AppImage" + entry "linux-x86_64" "poltertype-*-x86_64.AppImage" + entry "linux-aarch64" "poltertype-*-aarch64.AppImage" entry "macos-universal" "poltertype-*-universal-apple-darwin.dmg" - entry "windows-x86_64" "poltertype-*-x86_64-pc-windows-msvc.msi" + entry "windows-x86_64" "poltertype-*-x86_64-pc-windows-msvc.msi" } | jq -s \ --argjson schema 1 \ --arg version "${VERSION}" \ @@ -287,11 +319,35 @@ jobs: echo "── dist/latest.json ──" cat dist/latest.json + # The draft is not shippable yet and the reason is easy to forget + # a month later, so it goes where the person who just cut the tag + # is already looking. + - name: Remind the maintainer to sign + env: + TAG: ${{ github.ref_name }} + run: | + { + echo "## Before publishing \`${TAG}\`" + echo + echo 'The manifest attached to this draft is **unsigned**. Sign it with the' + echo 'release key (which lives on your machine, not here):' + echo + echo '```bash' + echo "gh release download ${TAG} --pattern latest.json --clobber" + echo 'cargo xtask manifest sign latest.json --key ~/.config/poltertype-signing/release.key' + echo "gh release upload ${TAG} latest.json --clobber" + echo '```' + echo + echo '`sign` verifies its own output against the public key compiled into the' + echo 'app, so a mismatch fails there rather than on a user machine.' + } >> "$GITHUB_STEP_SUMMARY" + - name: Create release uses: softprops/action-gh-release@v2 with: files: | dist/poltertype-*-x86_64.AppImage + dist/poltertype-*-aarch64.AppImage dist/poltertype-*-universal-apple-darwin.dmg dist/poltertype-*-x86_64-pc-windows-msvc.msi dist/latest.json @@ -329,8 +385,10 @@ jobs: `xattr -dr com.apple.quarantine /Applications/poltertype.app` once. Then grant Accessibility permission in System Settings. - * **Linux** — download the `.AppImage`, `chmod +x` it, run. - On Wayland sessions, see `docs/PERMISSIONS.md` for the - evdev permission setup. + * **Linux** — download the `.AppImage` for your + architecture (`x86_64` for a normal PC, `aarch64` for a + Raspberry Pi 5, an Asahi Mac or an ARM server), + `chmod +x` it, run. On Wayland sessions, see + `docs/PERMISSIONS.md` for the evdev permission setup. See [CHANGELOG.md](CHANGELOG.md) for the full list of changes. diff --git a/CHANGELOG.md b/CHANGELOG.md index ac5639d..f48b5fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,133 @@ All notable changes to PolterType are recorded here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] — 0.6.4 + +### Security + +- **The release manifest is signed, and the updater checks it.** Until + now the only thing standing between a user and a hostile update was + a SHA-256 that shipped in the same GitHub release as the installer — + so whoever could publish one could publish both. `latest.json` now + carries a detached ed25519 signature, verified against a public key + compiled into the binary, the moment the manifest is parsed and + before any URL in it is read. The private key is **not** a CI secret + and never touches a runner: signing is a manual step the maintainer + performs on the draft release (`cargo xtask manifest sign`), which is + the only version of this that a compromised GitHub account cannot + forge. + **Not yet mandatory.** A *wrong* signature is refused from this + release on, but a *missing* one is still accepted — otherwise every + user would be stranded on the last unsigned manifest. Enforcement is + a one-constant flip in a later release, and only then does anything + user-facing get to say "signed updates". + +### Added + +- **A Setup pane that checks this machine instead of linking to a + document.** When the keyboard hooks fail to start, the tray alert now + opens the Settings window on a new **Setup** pane rather than a + markdown file in a browser. It probes the running system and says + what is actually missing, per OS: on Wayland, whether key events can + be read and whether corrections can be typed, as two separate + answers, because they are two separate permissions and the half- + granted case (detection works, nothing gets fixed) is the confusing + one. On macOS, Accessibility and Input Monitoring separately, with + buttons that ask the system for each and deep links into the right + System Settings pane. On X11 and Windows it says there is nothing to + grant — most people arrive expecting the worst. *Check again* + re-probes, and answers even when nothing changed. + It also catches the trap that wastes an evening: `usermod -aG input` + updates the group database and cannot touch a login session that + already exists, so everything looks configured and nothing works. + That state gets its own answer — log out, don't re-run the script. + Nothing on the pane changes the system. The Linux script needs + `sudo`, so the button copies the command for the user to read and run + themselves. +- **An honest banner when layout switching is unavailable.** Hooks + working and no switcher backend is its own failure: PolterType spots + the wrong-layout word and rewrites it into the same wrong layout, so + it looks like the correction is broken rather than missing. +- **The suggestion tooltip anchors to the caret on GNOME and KDE + Wayland.** Those sessions have no compositor-agnostic active-window + query, so the focus tracker was a plain no-op there and the tooltip + fell back to the bottom of the screen. But AT-SPI is a session-bus + service and answers on any compositor — the caret watcher had simply + never been built on that path. It is now: `focused_exe()` still + returns `None` (nothing keyed off the focused app starts guessing), + while the tooltip gets the *best* anchor in the chain instead of the + worst. + +### Documented + +- **Packaging manifests for AUR, winget and Homebrew**, staged in + `packaging/` with the publish step for each written down. Nothing is + live yet — and the README install table stays silent until each one + is. `packaging/bump.sh ` re-points all three at a published + release by hashing the bytes GitHub actually serves. Two decisions + worth knowing: the AUR packages install the udev rule but will not + add anyone to the `input` group (that is the user's account, not + ours), and the Homebrew cask does **not** strip macOS quarantine — + removing that check silently for an unsigned app that reads every + keystroke is not a convenience we get to hand out. +- **The tooltip was never broken on KDE.** KWin has implemented + `zwlr_layer_shell_v1` for years; verified against KWin 6.7.3, where + the surface configures and maps exactly as on Hyprland. GNOME + Wayland is not a no-op either — Mutter has no layer-shell, but the + X11 override-redirect fallback maps through XWayland. Five places + claimed otherwise, including the error message users would see. The + backend has always *probed* rather than matched desktop names; the + prose was a hand-maintained list that went stale silently. The real + remaining gap is a Wayland session with neither layer-shell nor + XWayland, plus macOS and Windows. +- **No Flatpak, decided with evidence rather than left open.** The + emitter writes to `/dev/uinput`, which no Flatpak permission grants + short of `--device=all` — `device=input` deliberately excludes it — + and there is no portal. Layout switching additionally needs host + binaries a sandbox does not have. Reasoning, sources and the + conditions for revisiting are in `docs/DECISIONS.md`; the README + says so plainly so nobody has to ask twice. + +- **aarch64 Linux builds.** `poltertype--aarch64.AppImage` ships + alongside the x86_64 one, built natively on an ARM64 runner rather + than cross-compiled. Raspberry Pi 5, Asahi and ARM laptops/servers + had nothing to download and nothing for the in-app updater to offer + them; both now work. Deliberately the *only* architecture added — + every installer is a support surface, so armv7 and ARM Windows stay + out until there is hardware and demand behind them. + +### Fixed + +- **macOS: a suggestion accepted with its chord still held no longer + retypes the word under those modifiers.** `release_modifiers` was a + default no-op on macOS, and — worse — every event we posted inherited + the *live hardware* modifier flags from its `HIDSystemState` source, + so with ⌘ down our backspaces went out as ⌘⌫ ("delete to start of + line"). The emitter now clears the flags on everything it posts and + sends a `FlagsChanged` release for each modifier the engine believes + is down. Caps Lock is deliberately left alone — it is a latch, not a + held key. +- **Windows: the same no-op, the same bug.** `release_modifiers` now + sends key-ups for both sides of each held modifier. +- **macOS: modifier presses reach the engine at all.** The event tap + subscribed to `KeyDown`/`KeyUp` only, but macOS reports a modifier + moving as `FlagsChanged` — so the modifier arms of the keycode table + had been unreachable since they were written, and the engine only + learned what was held when an ordinary key arrived. It now sees the + same discrete modifier stream as the Windows and Linux backends, + which is what makes the fix above fire at the right moment. Keys with + no SC Set-1 equivalent (Fn, media) are dropped rather than falling + through the identity mapping into the word buffer's "navigation — end + the word" range. + +### Internal + +- The macOS backend is a directory rather than one file, and the part + most likely to be wrong — the Apple→SC Set-1 keycode table and the + `FlagsChanged` direction rules — carries no Apple dependency, so its + tests run on Linux and Windows CI too. Everything Mac-only stays + compile-checked by CI's `macos-latest` job. + ## [0.6.3] — the key gate grows a safety catch, corrections learn ñ, and the logs forget your words ### Security diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c591a8..bb4e0b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -254,7 +254,7 @@ Imperative mood, scope prefix when useful (`engine:`, `win:`, `ui:`, > the previous release. Nothing in CI will catch it for you. Releases are cut by pushing a `v*` tag. CI ([release.yml]) then -builds three installers in parallel and attaches them to a draft +builds four installers in parallel and attaches them to a draft GitHub Release, along with `latest.json` — the manifest the in-app updater polls, generated from the exact artifacts being uploaded so the checksums cannot drift out of step with the files: diff --git a/Cargo.lock b/Cargo.lock index 1bd022a..d32e628 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -419,6 +419,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -930,6 +939,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -989,6 +1007,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctor" version = "0.10.1" @@ -1004,6 +1031,33 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "d3d12" version = "0.19.0" @@ -1075,8 +1129,18 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", ] [[package]] @@ -1264,6 +1328,28 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2 0.11.0", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -1401,6 +1487,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "field-offset" version = "0.3.6" @@ -2136,6 +2228,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -4021,11 +4122,13 @@ dependencies = [ name = "poltertype-update" version = "0.6.3" dependencies = [ + "base64", "directories", + "ed25519-dalek", "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "tracing", "ureq", @@ -4746,8 +4849,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -4757,8 +4860,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -4786,6 +4900,12 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -6900,8 +7020,13 @@ name = "xtask" version = "0.0.0" dependencies = [ "anyhow", + "base64", + "ed25519-dalek", "flate2", + "getrandom 0.2.17", "png", + "poltertype-update", + "serde_json", "ureq", ] diff --git a/Cargo.toml b/Cargo.toml index afc3b20..0f5f5ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,6 +119,14 @@ opener = "0.7" ureq = { version = "2", features = ["tls", "json"] } sha2 = "0.10" semver = "1" +# Manifest signing. Verification only in the app — no key generation, +# no signing, so `default-features = false` drops `zeroize` (nothing +# secret is ever in the app's memory) and the precomputed tables. The +# signing half lives in `xtask`, which turns those features back on. +# `base64` is already in the tree via ureq/rustls; it decodes the +# 32-byte key and the 64-byte signature. +ed25519-dalek = { version = "3", default-features = false } +base64 = "0.22" # GUI for the Settings window. `tiny-skia` is iced's pure-CPU # renderer (no wgpu / GPU stack) — keeps the binary small and the diff --git a/README.md b/README.md index f33fec8..3fe9465 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ permissions story. ## Install Builds are published as GitHub Releases — -[**Releases page**](../../releases). Each release ships three +[**Releases page**](../../releases). Each release ships four installers (plus `latest.json`, the manifest the in-app updater polls — you never download that one by hand): @@ -71,11 +71,21 @@ polls — you never download that one by hand): | Windows 10 / 11 | `poltertype--x86_64-pc-windows-msvc.msi` | Double-click. Per-user install — no admin rights, no UAC prompt. SmartScreen may show "Windows protected your PC" → **More info** → **Run anyway**. | | macOS 11+ (Intel + Apple Silicon) | `poltertype--universal-apple-darwin.dmg` | Open the DMG, drag `poltertype.app` into `/Applications`. First launch: right-click the app → **Open** (or run `xattr -dr com.apple.quarantine /Applications/poltertype.app`). Then grant **Accessibility** and **Input Monitoring** — macOS prompts for both on first run. | | Linux (x86_64) | `poltertype--x86_64.AppImage` | `chmod +x` and run. Per-user, no system install. See [docs/PERMISSIONS.md](docs/PERMISSIONS.md) for evdev access on Wayland. | +| Linux (aarch64) | `poltertype--aarch64.AppImage` | Same, for ARM64 — Raspberry Pi 5, Asahi, ARM laptops and servers. Built natively, not cross-compiled. | > Installers are still **unsigned** — that's why Gatekeeper / > SmartScreen warn on first launch. Code signing comes in a later > phase. +> **No Flatpak, and there won't be one.** PolterType types by writing +> to `/dev/uinput`, which no Flatpak permission grants short of +> `--device=all` — the whole device tree — and there is no portal for +> it. Layout switching also needs host binaries (`hyprctl`, +> `gsettings`, `qdbus`, `ibus`) that a sandbox does not have. The +> reasoning, sources and the conditions under which we'd revisit are +> in [docs/DECISIONS.md](docs/DECISIONS.md) (2026-07-31). Use the +> AppImage, or a native package. + Building from source is documented in [CONTRIBUTING.md](CONTRIBUTING.md). @@ -112,17 +122,23 @@ updater is a normal (non-optional) part of the default build. Three caveats worth stating plainly: -- **The download is checksum-verified, not signed.** The checksum - comes from the same GitHub release as the installer, so it catches a - corrupted download or a tampered CDN — but not a compromised GitHub - account. Signing the manifest is planned (see - [docs/DECISIONS.md](docs/DECISIONS.md)). +- **Signature checking is on, but not yet required.** Every download is + verified against the SHA-256 in the release manifest, which catches a + corrupted transfer or a tampered CDN — but not a compromised GitHub + account, since the checksum lives in the same release as the + installer. The manifest is now signed with an ed25519 key that is + *not* in CI, and the app verifies it against a public key compiled + into the binary. Until every published manifest has been signed for a + full release cycle, an *absent* signature is still accepted (a + **wrong** one never is), so don't read this as "signed updates" yet — + see [docs/DECISIONS.md](docs/DECISIONS.md) for the rollout. - **Only our own installers self-update.** If you installed from a distro package, or you're running a `cargo build` binary, PolterType won't overwrite it — those files aren't ours. You'll get a notification pointing at the Releases page instead. The same applies - if you're not on x86_64 (or an Apple Silicon Mac): we don't publish - an artifact for you, so there's nothing to update to. + on an architecture we don't publish for — x86_64 and aarch64 Linux, + x86_64 Windows and universal macOS are the whole list — since there + is then nothing to update to. - **The macOS path is unproven.** The Windows and Linux self-update paths are exercised; the `.app`-bundle swap is written from Apple's docs and has never run on real hardware. Treat macOS self-updating @@ -190,10 +206,12 @@ good. The tooltip never steals keyboard focus and disappears after 30 seconds or the moment you type past it. Tune or disable it on the **Suggestions** pane (`[suggestions]` in `config.toml`). -> The tooltip currently renders on **Hyprland/Sway (Wayland -> layer-shell) and X11**. GNOME/KDE Wayland, macOS and Windows don't -> have an overlay backend yet; there the feature stays engine-side -> only. +> The tooltip renders on **Linux**: Wayland layer-shell on Hyprland, +> Sway and KDE Plasma, and an override-redirect window on X11 — which +> also covers GNOME Wayland, since Mutter has no layer-shell but does +> run XWayland. **macOS and Windows** have no overlay backend yet; +> there the feature stays engine-side only, so suggestions exist but +> nothing draws them. > > Where it lands depends on what the focused app will tell us. Apps > with a live accessibility bridge report the caret, and the tooltip diff --git a/crates/poltertype-app/src/main.rs b/crates/poltertype-app/src/main.rs index 35d9df3..e118c12 100644 --- a/crates/poltertype-app/src/main.rs +++ b/crates/poltertype-app/src/main.rs @@ -83,7 +83,12 @@ fn main() -> Result<()> { let mut args = std::env::args().skip(1); if let Some(arg) = args.next() { match arg.as_str() { - "--settings" | "-s" | "settings" => return settings_ui::run(), + "--settings" | "-s" | "settings" => return settings_ui::run(false), + // Same window, opened on the Setup pane. The tray uses + // this when the keyboard hooks failed to start, so the + // user lands on the one screen that can help instead of + // hunting for it. + "--setup" => return settings_ui::run(true), "--version" | "-V" => { println!("{APP_NAME} {}", env!("CARGO_PKG_VERSION")); return Ok(()); @@ -368,10 +373,11 @@ fn main() -> Result<()> { // Onboarding alert entry — present only when keyboard hooks // failed to start (Wayland without the `input` group, X11 connect // refused, macOS without Accessibility). Clicking opens the - // setup guide (docs/PERMISSIONS.md) in the browser. + // Settings window on its Setup pane, which probes this machine and + // says what is actually missing. let item_setup = input_alert .as_ref() - .map(|_| MenuItem::new("⚠ Keyboard hooks unavailable — Setup Guide…", true, None)); + .map(|_| MenuItem::new("⚠ Keyboard hooks unavailable — Setup…", true, None)); if let Some(item) = item_setup.as_ref() { menu.append_items(&[item, &PredefinedMenuItem::separator()]) .context("populate tray alert entry")?; @@ -473,7 +479,7 @@ fn main() -> Result<()> { spawn_error_notification(format!( "Keyboard hooks are unavailable — automatic layout switching is off.\n\ {reason}\n\ - Tray menu → \"Setup Guide\" explains the fix." + Tray menu → \"Setup…\" shows what is missing and how to fix it." )); } @@ -728,14 +734,22 @@ fn main() -> Result<()> { } else if id == pause_id { let _ = cmd_tx_for_loop.send(EngineCommand::TogglePause); } else if Some(&id) == setup_id.as_ref() { - // Pinned to `main`: the guide must reflect the - // latest setup script, not the binary that failed. - if let Err(e) = opener::open_browser(SETUP_GUIDE_URL) { - warn!(?e, "could not open the setup guide"); - spawn_error_notification(format!( - "Could not open the setup guide.\nSee {SETUP_GUIDE_URL}" - )); - } + // The Settings window on its Setup pane, not a + // browser tab. The pane says what is missing *on + // this machine* and re-checks after the user has + // fixed it; a markdown file can do neither. It + // still links out to the same guide for anything + // it cannot say in two sentences. + spawn_setup_ui(SettingsCloseDeps { + settings: Arc::clone(&settings_for_loop), + layouts: Arc::clone(&layouts), + data_dir: data_dir.clone(), + user_wordlist_dir: user_wordlist_dir.clone(), + dict_reload_handle: dict_reload_handle.handle(), + profile_dict_cache: Arc::clone(&profile_dict_cache), + profile_force_reapply: Arc::clone(&profile_force_reapply), + reload_tx: cmd_tx_for_loop.clone(), + }); } } Event::UserEvent(UserEvent::Hotkey(id)) => { @@ -841,6 +855,7 @@ fn print_help() { USAGE:\n \ poltertype start the tray app\n \ poltertype --settings open the settings window\n \ + poltertype --setup open the settings window on the Setup pane\n \ poltertype --version print version and exit\n \ poltertype --help show this help", ver = env!("CARGO_PKG_VERSION"), diff --git a/crates/poltertype-app/src/settings_proc/enums.rs b/crates/poltertype-app/src/settings_proc/enums.rs index de7d14a..f2e21fe 100644 --- a/crates/poltertype-app/src/settings_proc/enums.rs +++ b/crates/poltertype-app/src/settings_proc/enums.rs @@ -23,3 +23,23 @@ pub(super) enum OwnExe { /// for the log line. Gone(PathBuf), } + +/// Which pane the Settings child process should open on. +/// +/// A CLI flag rather than an IPC message because the two processes +/// share nothing at runtime by design — the child reads `config.toml` +/// and exits. One extra argv entry is the entire protocol. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum SettingsEntry { + Normal, + Setup, +} + +impl SettingsEntry { + pub(super) fn flag(self) -> &'static str { + match self { + SettingsEntry::Normal => "--settings", + SettingsEntry::Setup => "--setup", + } + } +} diff --git a/crates/poltertype-app/src/settings_proc/mod.rs b/crates/poltertype-app/src/settings_proc/mod.rs index 13b28a0..f432c3b 100644 --- a/crates/poltertype-app/src/settings_proc/mod.rs +++ b/crates/poltertype-app/src/settings_proc/mod.rs @@ -12,7 +12,7 @@ mod enums; mod exe; mod spawn; -pub(crate) use spawn::spawn_settings_ui; +pub(crate) use spawn::{spawn_settings_ui, spawn_setup_ui}; #[cfg(test)] mod tests; diff --git a/crates/poltertype-app/src/settings_proc/spawn.rs b/crates/poltertype-app/src/settings_proc/spawn.rs index 165131f..fff6828 100644 --- a/crates/poltertype-app/src/settings_proc/spawn.rs +++ b/crates/poltertype-app/src/settings_proc/spawn.rs @@ -42,11 +42,22 @@ use crate::types::*; /// rather than only a log line: the click produced no window, and a /// tray app has nowhere else to put an error. pub(crate) fn spawn_settings_ui(deps: SettingsCloseDeps) { + spawn_settings_ui_on(deps, SettingsEntry::Normal) +} + +/// Same, but opening on the Setup pane — what the tray's "keyboard +/// hooks unavailable" alert now does instead of throwing the user at a +/// markdown file in a browser. +pub(crate) fn spawn_setup_ui(deps: SettingsCloseDeps) { + spawn_settings_ui_on(deps, SettingsEntry::Setup) +} + +fn spawn_settings_ui_on(deps: SettingsCloseDeps, entry: SettingsEntry) { let Some(exe) = settings_ui_exe() else { return; }; - info!(?exe, "launching settings UI"); - let child = match std::process::Command::new(&exe).arg("--settings").spawn() { + info!(?exe, ?entry, "launching settings UI"); + let child = match std::process::Command::new(&exe).arg(entry.flag()).spawn() { Ok(c) => c, Err(e) => { warn!(?e, ?exe, "settings UI subprocess failed to start"); diff --git a/crates/poltertype-app/src/settings_ui/consts.rs b/crates/poltertype-app/src/settings_ui/consts.rs index 0788d4a..384f294 100644 --- a/crates/poltertype-app/src/settings_ui/consts.rs +++ b/crates/poltertype-app/src/settings_ui/consts.rs @@ -6,3 +6,8 @@ pub const SITE_URL: &str = "https://poltertype.com"; pub const REPO_URL: &str = "https://github.com/Just-Code-NET/PolterType"; /// Issue tracker. pub const ISSUES_URL: &str = "https://github.com/Just-Code-NET/PolterType/issues"; +/// The permissions guide the Setup pane links out to, for whatever +/// the pane itself cannot say in two sentences. Pinned to `main`, like +/// the tray's copy of the same link: it has to describe the current +/// setup script, not the release the reader happens to be running. +pub const PERMISSIONS_DOC_URL: &str = crate::consts::SETUP_GUIDE_URL; diff --git a/crates/poltertype-app/src/settings_ui/enums.rs b/crates/poltertype-app/src/settings_ui/enums.rs index 9ce0a36..1c2f66f 100644 --- a/crates/poltertype-app/src/settings_ui/enums.rs +++ b/crates/poltertype-app/src/settings_ui/enums.rs @@ -7,6 +7,10 @@ use poltertype_layout::LayoutId; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Pane { + /// Permission walkthrough. First in the list and first on open + /// when the tray launches us because the keyboard hooks failed — + /// at that moment nothing else in this window matters. + Setup, Languages, Hotkeys, Commands, @@ -233,6 +237,23 @@ pub enum Message { /// Open `url` in the default browser — the About pane's links. OpenUrl(&'static str), + // ── Setup pane ───────────────────────────────────────────────── + /// Re-run the permission probe. The pane's whole reason to exist + /// is that the user goes away, changes something, and comes back: + /// every answer it shows is a fresh reading, never a cached one. + SetupRecheck, + /// Open a URL the probe supplied — a documentation page, or a + /// macOS `x-apple.systempreferences:` deep link. Owned `String` + /// rather than `&'static str` because the probe builds these. + SetupOpen(String), + /// Copy a shell command to the clipboard. We never run it: the + /// Linux setup script needs `sudo`, and the user reading it first + /// is the point. + SetupCopy(String), + /// Ask the OS for a permission — macOS only, and always the + /// system's own dialog. + SetupRequestPermission(poltertype_input::setup::Permission), + /// User clicked the window close button (or otherwise asked /// the OS to close the window). We intercept this to auto-save /// any unsaved wordlist edit before letting the window close — diff --git a/crates/poltertype-app/src/settings_ui/mod.rs b/crates/poltertype-app/src/settings_ui/mod.rs index c91ac95..9888c13 100644 --- a/crates/poltertype-app/src/settings_ui/mod.rs +++ b/crates/poltertype-app/src/settings_ui/mod.rs @@ -79,6 +79,7 @@ mod system_theme; mod theme; mod update; mod view; +mod view_setup; use std::sync::Arc; @@ -93,7 +94,13 @@ use state::*; /// Entry point: load settings + OS layouts, run iced loop, save on /// "Save" click. Returns when the user closes the window. -pub fn run() -> Result<()> { +/// +/// `open_setup` starts the window on the **Setup** pane instead of +/// Languages. The tray passes it when the keyboard hooks failed to +/// start: at that moment every other pane is furniture, and making the +/// user find the right one is exactly the friction this pane exists to +/// remove. +pub fn run(open_setup: bool) -> Result<()> { let store = SettingsStore::load_or_default().context("load settings for UI")?; let initial_settings = store.snapshot(); let store = Arc::new(store); @@ -102,20 +109,31 @@ pub fn run() -> Result<()> { // present the user with an empty set and a hint, rather than // refusing to open the window. They can still edit other panes // and save. - let os_layouts: Vec = match create_switcher() { - Ok(switcher) => switcher.list_active().unwrap_or_else(|e| { - warn!( - ?e, - "could not list active OS layouts; Languages pane will be empty" - ); - Vec::new() - }), + // + // The same probe answers a second question the Setup pane asks: + // whether a layout switcher exists at all. Hooks working and + // switching missing is its own failure — corrections then rewrite + // the word into the layout that was already wrong — and it + // deserves to be said out loud rather than inferred from an app + // that behaves oddly. + let (os_layouts, layout_backend): (Vec, Option) = match create_switcher() { + Ok(switcher) => { + let backend = switcher.backend_name().to_owned(); + let layouts = switcher.list_active().unwrap_or_else(|e| { + warn!( + ?e, + "could not list active OS layouts; Languages pane will be empty" + ); + Vec::new() + }); + (layouts, Some(backend)) + } Err(e) => { warn!( ?e, "no layout switcher backend; Languages pane will be empty" ); - Vec::new() + (Vec::new(), None) } }; @@ -150,7 +168,18 @@ pub fn run() -> Result<()> { let store_for_init = Arc::clone(&store); app.run_with(move || { ( - SettingsApp::new(initial_settings, os_layouts, path, store_for_init), + SettingsApp::new( + initial_settings, + os_layouts, + path, + store_for_init, + if open_setup { + enums::Pane::Setup + } else { + enums::Pane::Languages + }, + layout_backend, + ), Task::none(), ) }) diff --git a/crates/poltertype-app/src/settings_ui/state.rs b/crates/poltertype-app/src/settings_ui/state.rs index 2d60c50..e80c2ee 100644 --- a/crates/poltertype-app/src/settings_ui/state.rs +++ b/crates/poltertype-app/src/settings_ui/state.rs @@ -39,6 +39,20 @@ pub struct SettingsApp { /// The keyboard subscription consults this to know whether to /// route key events to `HotkeyCaptured` or ignore them. pub(super) capturing: Option, + /// Live answer from the permission probe, re-read on every + /// *Check again* click. Held rather than probed inside `view` + /// because `view` runs on every frame and this touches the + /// filesystem — and because a value that changes under the user + /// mid-render is how a "did my click work?" question becomes + /// unanswerable. + pub(super) setup: poltertype_input::setup::SetupReport, + /// Name of the layout-switcher backend, or `None` when no backend + /// could be built. The honest banner for the case the hooks are + /// fine and switching is not. + pub(super) layout_backend: Option, + /// Feedback for the Setup pane's own buttons ("Copied.", "Nothing + /// changed yet."), kept apart from the global save banner. + pub(super) setup_status: Option, /// Free-form text in the "add a new disabled app" input on the /// Exceptions pane. Empty by default; cleared on Add. pub(super) exception_draft: String, @@ -107,6 +121,8 @@ impl SettingsApp { os_layouts: Vec, config_path: PathBuf, store: Arc, + initial_pane: Pane, + layout_backend: Option, ) -> Self { // Pre-populate the Wordlists pane with the first OS-active // layout so the user can start typing the moment they land @@ -127,7 +143,10 @@ impl SettingsApp { os_layouts, config_path, store, - pane: Pane::Languages, + pane: initial_pane, + setup: poltertype_input::setup::probe_setup(), + layout_backend, + setup_status: None, system_prefers_dark: super::system_theme::system_prefers_dark(), bg_jitter: std::cell::Cell::new(0), save_banner: None, diff --git a/crates/poltertype-app/src/settings_ui/update.rs b/crates/poltertype-app/src/settings_ui/update.rs index 1c28b96..a07f114 100644 --- a/crates/poltertype-app/src/settings_ui/update.rs +++ b/crates/poltertype-app/src/settings_ui/update.rs @@ -347,6 +347,63 @@ impl SettingsApp { let _ = opener::open(url); } + // ── Setup pane ───────────────────────────────────────── + Message::SetupRecheck => { + let before = self.setup.clone(); + self.setup = poltertype_input::setup::probe_setup(); + // Say something either way. A button that silently + // redraws the same screen reads as broken, and "still + // not granted" is the answer a user in the middle of + // fixing permissions most needs to hear. + self.setup_status = Some(if self.setup == before { + SaveBanner { + text: if self.setup.needs_attention() { + "Checked — nothing has changed yet.".to_owned() + } else { + "Checked — everything is in place.".to_owned() + }, + is_error: false, + } + } else { + SaveBanner { + text: "Checked — something changed. Restart PolterType to pick it up." + .to_owned(), + is_error: false, + } + }); + } + Message::SetupOpen(url) => { + // Covers http(s) documentation links and macOS + // `x-apple.systempreferences:` deep links alike — + // `opener` hands both to the OS handler. + if let Err(e) = opener::open(&url) { + warn!(?e, %url, "could not open setup link"); + self.setup_status = Some(SaveBanner { + text: format!("Couldn't open {url}"), + is_error: true, + }); + } + } + Message::SetupCopy(command) => { + self.setup_status = Some(SaveBanner { + text: format!("Copied: {command}"), + is_error: false, + }); + return iced::clipboard::write(command); + } + Message::SetupRequestPermission(permission) => { + // The OS shows its own dialog; ours never imitates + // one. Accessibility's prompt is asynchronous, so the + // return value is not an answer — re-probe instead of + // believing it. + poltertype_input::setup::request_permission(permission); + self.setup = poltertype_input::setup::probe_setup(); + self.setup_status = Some(SaveBanner { + text: "Asked the system. Approve it there, then press Check again.".to_owned(), + is_error: false, + }); + } + Message::WindowCloseRequested(id) => { // Last chance to flush any unsaved wordlist edit // before the window goes away. Failures are logged diff --git a/crates/poltertype-app/src/settings_ui/view.rs b/crates/poltertype-app/src/settings_ui/view.rs index 32e24eb..bfd2a53 100644 --- a/crates/poltertype-app/src/settings_ui/view.rs +++ b/crates/poltertype-app/src/settings_ui/view.rs @@ -22,6 +22,7 @@ use super::theme::{self, FONT_BOLD, GhostMark}; impl SettingsApp { pub(super) fn view(&self) -> Element<'_, Message> { let body = match self.pane { + Pane::Setup => self.view_setup(), Pane::Languages => self.view_languages(), Pane::Hotkeys => self.view_hotkeys(), Pane::Commands => self.view_commands(), @@ -114,6 +115,10 @@ impl SettingsApp { bottom: 14.0, left: 2.0, })) + .push(item( + setup_nav_label(self.setup.needs_attention()), + Pane::Setup, + )) .push(item("Languages", Pane::Languages)) .push(item("Hotkeys", Pane::Hotkeys)) .push(item("Commands", Pane::Commands)) @@ -1228,7 +1233,7 @@ impl SettingsApp { /// Pane title + one-paragraph explainer, replacing the old pattern of /// a bare `Text::new(title).size(24)` followed by unstyled body text. -fn pane_header( +pub(super) fn pane_header( b: &'static theme::BrandPalette, title: &'static str, subtitle: String, @@ -1242,7 +1247,7 @@ fn pane_header( /// Surface card with a hairline border — the pane's main grouping /// device, mirroring the landing page's feature cards. -fn card<'a>(content: impl Into>) -> Element<'a, Message> { +pub(super) fn card<'a>(content: impl Into>) -> Element<'a, Message> { Container::new(content) .style(theme::card) .padding(16) @@ -1251,12 +1256,31 @@ fn card<'a>(content: impl Into>) -> Element<'a, Message> { } /// Bold in-card section heading ("Behaviour", "Folders", …). -fn section_title(b: &'static theme::BrandPalette, text: &'static str) -> Element<'static, Message> { +pub(super) fn section_title( + b: &'static theme::BrandPalette, + text: &'static str, +) -> Element<'static, Message> { Text::new(text).size(14).font(FONT_BOLD).color(b.ink).into() } +/// Sidebar label for the Setup pane. Carries the warning glyph only +/// while something is actually unresolved — a permanent ⚠ in the nav +/// is a warning nobody reads by the second day. +fn setup_nav_label(needs_attention: bool) -> &'static str { + // ASCII on purpose: the bundled font has no ⚠ and draws a tofu + // box, which reads as a rendering bug rather than as a warning. + if needs_attention { + "Setup (!)" + } else { + "Setup" + } +} + /// Muted footnote at the bottom of a pane. -fn tip(b: &'static theme::BrandPalette, text: impl Into) -> Element<'static, Message> { +pub(super) fn tip( + b: &'static theme::BrandPalette, + text: impl Into, +) -> Element<'static, Message> { Text::new(text.into()).size(11).color(b.muted).into() } @@ -1318,7 +1342,10 @@ fn folder_button(label: &'static str, msg: Message) -> Element<'static, Message> /// Per-pane status banner text: ecto green for OK, garble pink for /// errors — the site's fixed/garbled word colours. -fn status_line(b: &'static theme::BrandPalette, banner: &SaveBanner) -> Element<'static, Message> { +pub(super) fn status_line( + b: &'static theme::BrandPalette, + banner: &SaveBanner, +) -> Element<'static, Message> { Text::new(banner.text.clone()) .size(11) .color(if banner.is_error { b.garble } else { b.ecto }) diff --git a/crates/poltertype-app/src/settings_ui/view_setup.rs b/crates/poltertype-app/src/settings_ui/view_setup.rs new file mode 100644 index 0000000..603d706 --- /dev/null +++ b/crates/poltertype-app/src/settings_ui/view_setup.rs @@ -0,0 +1,220 @@ +//! The Setup pane: what still has to be granted, and how. +//! +//! Everything on screen comes from `poltertype_input::setup::probe` +//! — this file only decides how a [`SetupStep`] looks. The split is +//! deliberate: "is the user in the `input` group" is platform code and +//! belongs in the input crate, while "what does an unresolved step +//! look like" is a design question and belongs here. +//! +//! Tone matters more than usual on this pane. It is the screen a user +//! reaches when the app they just installed does nothing, and the two +//! failure modes are equally bad: sounding alarmed about something +//! that is fine (X11 needs no permissions at all, and most people +//! expect the worst), or being vague about something that is broken. +//! So every row states what the capability is *for*, and a step we +//! cannot verify says so instead of guessing. + +use iced::widget::{Button, Column, Container, Row, Space, Text}; +use iced::{Alignment, Element, Length, Padding}; +use poltertype_input::setup::{StepAction, StepState}; + +use super::consts::PERMISSIONS_DOC_URL; +use super::enums::*; +use super::state::*; +use super::theme::{self, FONT_BOLD}; +use super::view::{card, pane_header, section_title, status_line, tip}; + +impl SettingsApp { + pub(super) fn view_setup(&self) -> Element<'_, Message> { + let b = self.brand(); + let report = &self.setup; + + let headline = if report.needs_attention() { + "PolterType needs one more permission before it can do anything." + } else { + "Everything PolterType needs is in place." + }; + + let mut steps = Column::new().spacing(14); + for (i, step) in report.steps.iter().enumerate() { + if i > 0 { + steps = steps.push(Space::with_height(2)); + } + steps = steps.push(self.step_row(step)); + } + + // The one sentence the `NeedsRelogin` state exists to say, + // printed once rather than per step: every step in that state + // has the same cause and the same fix, and repeating it makes + // a two-row pane look like a wall of text. + if report + .steps + .iter() + .any(|s| s.state == StepState::NeedsRelogin) + { + steps = steps.push(Space::with_height(6)); + steps = steps.push( + Text::new( + "Already set up — but this login session started before it, and a session \ + keeps the group membership it was created with. Log out and back in (a \ + reboot also does it). Re-running the setup script will not help.", + ) + .size(12) + .color(b.brand), + ); + } + + let mut body = Column::new() + .spacing(18) + .push(pane_header(b, "Setup", headline.to_owned())) + .push(card(steps)); + + // The second failure mode, and a genuinely different one: + // hooks fine, no way to change the layout. The app then + // detects a wrong-layout word and corrects it *into the same + // layout*, which looks like the correction being wrong rather + // than missing. Only shown when it is actually true. + if self.layout_backend.is_none() { + body = body.push(card( + Column::new() + .spacing(8) + .push(section_title(b, "Layout switching is unavailable")) + .push( + Text::new( + "PolterType found no way to change the keyboard layout on this \ + system. It can still detect a wrong-layout word and fix the \ + letters, but the layout itself will not change — so the next \ + word comes out wrong too.", + ) + .size(12) + .color(b.muted), + ) + .push( + Button::new(Text::new("What backends are supported?").size(12)) + .on_press(Message::SetupOpen(PERMISSIONS_DOC_URL.to_owned())) + .style(theme::secondary) + .padding(button_padding()), + ), + )); + } + + let mut footer = Row::new().spacing(10).align_y(Alignment::Center).push( + Button::new(Text::new("Check again").size(13)) + .on_press(Message::SetupRecheck) + .style(theme::primary) + .padding(Padding { + top: 7.0, + right: 16.0, + bottom: 7.0, + left: 16.0, + }), + ); + footer = footer.push( + Button::new(Text::new("Full setup guide").size(12)) + .on_press(Message::SetupOpen(PERMISSIONS_DOC_URL.to_owned())) + .style(theme::secondary) + .padding(button_padding()), + ); + if let Some(banner) = &self.setup_status { + footer = footer.push(status_line(b, banner)); + } + body = body.push(footer); + + // Changing a permission does not reach a process that already + // started without it — on every OS. Saying so here is the + // difference between "I granted it and nothing happened" and a + // ten-second fix. + body = body.push(tip( + b, + "Granted something just now? Quit PolterType from the tray and start it again — \ + permissions are read when the app starts, not while it runs.", + )); + + if let Some(backend) = &self.setup.backend { + body = body.push(tip( + b, + format!("Keyboard backend for this session: {backend}"), + )); + } + + body.into() + } + + fn step_row(&self, step: &poltertype_input::setup::SetupStep) -> Element<'_, Message> { + let b = self.brand(); + // A word, not a glyph. The bundled font renders ✓ and ↻ as + // tofu boxes on this stack (× and → happen to survive), and a + // status marker that shows as an empty rectangle is worse than + // no marker at all. Words also say which of "not yet" and + // "can't tell" this is, which a tick never could. + let (mark, mark_color) = match step.state { + StepState::Done => ("Ready", b.ecto), + StepState::Todo => ("Needs you", b.garble), + StepState::NeedsRelogin => ("Log out", b.brand), + StepState::Unknown => ("Unknown", b.muted), + }; + + let mut text_col = Column::new() + .spacing(4) + .push( + Text::new(step.title.clone()) + .size(14) + .font(FONT_BOLD) + .color(b.ink), + ) + .push(Text::new(step.detail.clone()).size(12).color(b.muted)); + + if let Some(action) = &step.action { + text_col = text_col.push(Space::with_height(2)); + text_col = text_col.push(action_button(action)); + } + + Row::new() + .spacing(12) + .align_y(Alignment::Start) + .push( + Container::new(Text::new(mark).size(11).font(FONT_BOLD).color(mark_color)) + .width(74) + .padding(Padding { + top: 3.0, + right: 0.0, + bottom: 0.0, + left: 0.0, + }), + ) + .push(text_col.width(Length::Fill)) + .into() + } +} + +/// One button per step, labelled by what it actually does. No step +/// ever offers to run something with `sudo` on the user's behalf — +/// `Copy` hands over the command instead, for them to read and run. +fn action_button(action: &StepAction) -> Element<'static, Message> { + let (label, msg): (String, Message) = match action { + StepAction::Open(url) if url.starts_with("x-apple") => ( + "Open System Settings".to_owned(), + Message::SetupOpen(url.clone()), + ), + StepAction::Open(url) => ("Read the guide".to_owned(), Message::SetupOpen(url.clone())), + StepAction::Copy(cmd) => (format!("Copy `{cmd}`"), Message::SetupCopy(cmd.clone())), + StepAction::RequestPermission(p) => ( + "Ask macOS now".to_owned(), + Message::SetupRequestPermission(*p), + ), + }; + Button::new(Text::new(label).size(12)) + .on_press(msg) + .style(theme::secondary) + .padding(button_padding()) + .into() +} + +fn button_padding() -> Padding { + Padding { + top: 5.0, + right: 12.0, + bottom: 5.0, + left: 12.0, + } +} diff --git a/crates/poltertype-input/src/focus/linux_impl/caret_only.rs b/crates/poltertype-input/src/focus/linux_impl/caret_only.rs new file mode 100644 index 0000000..f3c529a --- /dev/null +++ b/crates/poltertype-input/src/focus/linux_impl/caret_only.rs @@ -0,0 +1,48 @@ +//! Caret-only tracker for Wayland sessions with no active-window query. +//! +//! GNOME and KDE expose no compositor-agnostic "which window has +//! focus" — by design, the same reasoning that keeps global input out +//! of reach. That rules out `focused_exe()` and window geometry, and +//! for a long time the whole tracker was therefore a noop there. +//! +//! But the *caret* comes from AT-SPI, which is a session bus and knows +//! nothing about compositors: it answers on GNOME and KDE exactly as +//! it does on Hyprland. Leaving it unbuilt cost the suggestion tooltip +//! its best anchor on the two largest desktops and pinned it to the +//! bottom of the screen — not for a technical reason, but because the +//! watcher was only ever constructed inside the two branches that had +//! a window query as well. + +use std::sync::Arc; + +use crate::focus::{CaretHint, FocusTracker}; + +use super::atspi_caret::{AtspiCaretWatcher, CaretSample}; + +pub(crate) struct CaretOnlyFocusTracker { + caret: Arc, +} + +impl CaretOnlyFocusTracker { + pub(crate) fn new(caret: Arc) -> Self { + Self { caret } + } +} + +impl FocusTracker for CaretOnlyFocusTracker { + /// Always `None`, and deliberately so — everything keyed off the + /// focused app (`[exceptions].disabled_apps`, per-app wordlist + /// profiles, `apps = [...]` on smart commands) stays inert here + /// rather than acting on a guess. + fn focused_exe(&self) -> Option { + None + } + + fn caret_hint(&self) -> Option { + self.caret.latest().map(CaretSample::into_hint) + } + + fn backend_name(&self) -> &'static str { + "linux-atspi-caret-only" + } +} diff --git a/crates/poltertype-input/src/focus/linux_impl/mod.rs b/crates/poltertype-input/src/focus/linux_impl/mod.rs index adbfa80..2776193 100644 --- a/crates/poltertype-input/src/focus/linux_impl/mod.rs +++ b/crates/poltertype-input/src/focus/linux_impl/mod.rs @@ -15,9 +15,12 @@ //! can't read. //! //! GNOME / KDE on Wayland have no compositor-agnostic "active window" -//! query (by design — the same story as global input); they keep the -//! noop tracker until a per-DE backend (KWin script / GNOME shell -//! extension) exists. +//! query (by design — the same story as global input), so +//! `focused_exe()` and window geometry stay `None` there until a +//! per-DE backend (KWin script / GNOME shell extension) exists. They +//! do get [`caret_only`], because AT-SPI is a session-bus service and +//! answers regardless of compositor — and the caret is a *better* +//! tooltip anchor than the window rect, not a lesser one. //! //! Every real query is a socket or X11 round-trip, and `focused_exe()` //! sits on the engine's word-boundary path plus the 250 ms wordlist- @@ -31,6 +34,7 @@ mod atspi_caret; mod cache; +mod caret_only; mod consts; mod hyprland; mod hyprland_ipc; diff --git a/crates/poltertype-input/src/focus/linux_impl/pick.rs b/crates/poltertype-input/src/focus/linux_impl/pick.rs index a3528a1..4c5965e 100644 --- a/crates/poltertype-input/src/focus/linux_impl/pick.rs +++ b/crates/poltertype-input/src/focus/linux_impl/pick.rs @@ -9,6 +9,7 @@ use crate::linux::{SessionKind, session_kind}; use super::atspi_caret::AtspiCaretWatcher; use super::cache::CachedFocusTracker; +use super::caret_only::CaretOnlyFocusTracker; use super::consts::FOCUS_CACHE_TTL; use super::hyprland::HyprlandFocusTracker; use super::hyprland_ipc::hyprland_available; @@ -17,8 +18,17 @@ use super::x11::X11FocusTracker; /// Pick the focus backend for this session. Hyprland is probed first /// (its IPC works regardless of what `XDG_SESSION_TYPE` says), then /// plain X11 sessions get EWMH. Everything else — GNOME / KDE on -/// Wayland — stays on the noop tracker: there is no compositor- -/// agnostic active-window query there, by design. +/// Wayland — has no compositor-agnostic active-window query, by +/// design, so `focused_exe()` stays `None` there. +/// +/// It does **not** follow that those sessions get nothing. AT-SPI is a +/// session-bus service and does not care which compositor is running, +/// so the caret watcher answers on GNOME and KDE exactly as it does on +/// Hyprland — and the caret is the tooltip's *best* anchor, better +/// than the window geometry the other backends can offer. Building it +/// on its own is what stops the tooltip being pinned to the bottom of +/// the screen on the two largest desktops. No TTL cache: the caret +/// watcher is event-driven and already cheap to read. /// /// Note the X11 backend is deliberately NOT used on non-Hyprland /// Wayland even when `DISPLAY` points at XWayland: XWayland only sees @@ -38,7 +48,10 @@ pub(crate) fn create_linux_focus_tracker() -> Arc { FOCUS_CACHE_TTL, )); } - Arc::new(NoopFocusTracker) + match caret_watcher() { + Some(caret) => Arc::new(CaretOnlyFocusTracker::new(caret)), + None => Arc::new(NoopFocusTracker), + } } /// One AT-SPI caret watcher per tracker — created only for a branch diff --git a/crates/poltertype-input/src/lib.rs b/crates/poltertype-input/src/lib.rs index 8dab53f..334638c 100644 --- a/crates/poltertype-input/src/lib.rs +++ b/crates/poltertype-input/src/lib.rs @@ -16,10 +16,14 @@ #![deny(unsafe_op_in_unsafe_fn)] pub mod focus; +pub mod setup; #[cfg(target_os = "linux")] mod linux; -#[cfg(target_os = "macos")] +// Compiled under `cfg(test)` on every host, not just macOS: the +// keycode tables inside carry no Apple dependency and are exactly the +// part no Mac-less contributor can otherwise check. See `macos/mod.rs`. +#[cfg(any(target_os = "macos", test))] mod macos; #[cfg(windows)] mod windows; diff --git a/crates/poltertype-input/src/macos.rs b/crates/poltertype-input/src/macos.rs deleted file mode 100644 index bec00ee..0000000 --- a/crates/poltertype-input/src/macos.rs +++ /dev/null @@ -1,473 +0,0 @@ -//! macOS keyboard listener + emitter. -//! -//! ## Listener -//! -//! Built on `CGEventTapCreate(kCGSessionEventTap, …, listenOnly)`, -//! attached to the `CFRunLoop` of a dedicated thread. macOS requires -//! the calling app to be granted **Accessibility** in -//! System Settings → Privacy & Security → Accessibility. We surface -//! that requirement to the user via the tray onboarding banner; if -//! the tap fails to attach (typical first-launch state), `start()` -//! returns `InputError::Os` so the engine gracefully degrades. -//! -//! ## Emitter -//! -//! `CGEventPost` with `CGEventKeyboardSetUnicodeString` — same -//! layout-independent contract as Windows' `KEYEVENTF_UNICODE`. -//! -//! > **Status:** validated end-to-end on macOS 15 (Intel): the tap -//! > receives events, corrections emit, and injected events are -//! > recognised via the user-data tag. - -#![allow(unused_imports, dead_code)] // macOS-only. - -use std::ffi::{c_long, c_void}; -use std::sync::OnceLock; -use std::thread; -use std::time::Duration; - -use core_foundation::base::TCFType; -use core_foundation::mach_port::CFMachPortRef; -use core_foundation::runloop::{ - CFRunLoop, CFRunLoopAddSource, CFRunLoopRunInMode, CFRunLoopSource, CFRunLoopSourceRef, - kCFRunLoopCommonModes, kCFRunLoopDefaultMode, -}; -use core_graphics::event::{ - CGEvent, CGEventFlags, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, CGEventType, - CGKeyCode, -}; -use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; -use crossbeam_channel::Sender; -use tracing::{debug, info, trace, warn}; - -use crate::{InputError, InputListener, KeyDirection, KeyEmitter, KeyEvent, Modifiers}; - -// ─── Apple constants we read off CGEvent ───────────────────────────── -// -// `CGEventField` is a `u32` enum-like in Apple's C header; both -// versions of the `core-graphics` crate represent it differently -// across releases. We hard-code the integer values so we don't depend -// on whichever variant naming the active crate version exposes. - -const K_CG_KEYBOARD_EVENT_KEYCODE: u32 = 9; -const K_CG_EVENT_SOURCE_USER_DATA: u32 = 42; - -/// Magic value stamped into `kCGEventSourceUserData` on every event -/// WE post, so the listener can tag them `injected` and the engine -/// never mistakes our own backspaces / retypes for user keystrokes. -/// Without this the emitted events echo back through the tap as -/// "real" input: the backspace burst poisons the word buffer right -/// after a correction, and every second word gets skipped as tainted. -const EMITTER_TAG: i64 = 0x504F4C54; // "POLT" - -// ─── Accessibility permission prompt ───────────────────────────────── -// -// `CGEventTapCreate` fails *silently* when the app lacks Accessibility -// rights — no system dialog. The supported way to ask is -// `AXIsProcessTrustedWithOptions({ kAXTrustedCheckOptionPrompt: true })`, -// which drops the app into System Settings → Privacy & Security → -// Accessibility and shows the "PolterType would like to control this -// computer" alert. We call it when the tap fails to attach so a -// first-launch user gets the prompt instead of a dead tray icon. - -use core_foundation::dictionary::CFDictionaryRef; -use core_foundation::string::CFStringRef; - -#[link(name = "ApplicationServices", kind = "framework")] -unsafe extern "C" { - fn AXIsProcessTrustedWithOptions(options: CFDictionaryRef) -> bool; - static kAXTrustedCheckOptionPrompt: CFStringRef; -} - -/// Check Accessibility trust; prompt the user when not yet trusted. -fn request_accessibility_prompt() { - use core_foundation::base::TCFType; - unsafe { - let key = - core_foundation::string::CFString::wrap_under_get_rule(kAXTrustedCheckOptionPrompt); - let value = core_foundation::boolean::CFBoolean::true_value(); - let options = core_foundation::dictionary::CFDictionary::from_CFType_pairs(&[( - key.as_CFType(), - value.as_CFType(), - )]); - let trusted = AXIsProcessTrustedWithOptions(options.as_concrete_TypeRef()); - debug!(trusted, "AXIsProcessTrustedWithOptions(prompt) result"); - } -} - -// ─── Listener ──────────────────────────────────────────────────────── - -static EVENT_SINK: OnceLock>>> = OnceLock::new(); - -static FIRST_EVENT_LOGGED: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); - -fn sink_slot() -> &'static parking_lot::RwLock>> { - EVENT_SINK.get_or_init(|| parking_lot::RwLock::new(None)) -} - -pub struct MacosListener { - started: bool, -} - -impl MacosListener { - pub fn new() -> Self { - Self { started: false } - } -} - -impl InputListener for MacosListener { - fn start(&mut self, sink: Sender) -> Result<(), InputError> { - if self.started { - return Err(InputError::AlreadyStarted); - } - *sink_slot().write() = Some(sink); - - let (ready_tx, ready_rx) = crossbeam_channel::bounded::>(1); - thread::Builder::new() - .name("poltertype-input-macos-tap".into()) - .spawn(move || run_tap_thread(ready_tx)) - .map_err(|e| InputError::Os(format!("spawn tap thread: {e}")))?; - - match ready_rx.recv_timeout(Duration::from_secs(3)) { - Ok(Ok(())) => { - self.started = true; - info!("macOS CGEventTap attached"); - Ok(()) - } - Ok(Err(reason)) => Err(InputError::Os(reason)), - Err(_) => Err(InputError::Os("CGEventTap setup timed out".into())), - } - } - - fn stop(&mut self) { - if let Some(slot) = EVENT_SINK.get() { - *slot.write() = None; - } - } - - fn backend_name(&self) -> &'static str { - "macos-cg-event-tap" - } -} - -fn run_tap_thread(ready_tx: Sender>) { - use core_graphics::event::CGEventTapProxy; - - let callback = - |_proxy: CGEventTapProxy, ev_type: CGEventType, event: &CGEvent| -> Option { - let direction = match ev_type { - CGEventType::KeyDown => Some(KeyDirection::Press), - CGEventType::KeyUp => Some(KeyDirection::Release), - _ => None, - }; - if let Some(direction) = direction { - // `CGEventField` is a `u32` type-alias in core-graphics - // 0.24, so we feed the documented Apple constants - // straight through `get_integer_value_field`. - let vk = event.get_integer_value_field(K_CG_KEYBOARD_EVENT_KEYCODE) as u32; - let user_data = event.get_integer_value_field(K_CG_EVENT_SOURCE_USER_DATA); - let scancode = mac_keycode_to_sc1(vk as u16); - let flags = event.get_flags(); - let injected = user_data != 0; - // Fold Caps Lock into the shift bit the way the X11 - // backend does: caps-on + no Shift = uppercase, caps-on - // + held Shift = lowercase. The engine's all-caps and - // replay logic rely on this combined bit. - let shift = flags.contains(CGEventFlags::CGEventFlagShift) - ^ flags.contains(CGEventFlags::CGEventFlagAlphaShift); - - let ev_out = KeyEvent { - vk, - scancode, - direction, - modifiers: Modifiers { - shift, - control: flags.contains(CGEventFlags::CGEventFlagControl), - alt: flags.contains(CGEventFlags::CGEventFlagAlternate), - meta: flags.contains(CGEventFlags::CGEventFlagCommand), - }, - injected, - timestamp_ms: 0, - }; - if let Some(slot) = EVENT_SINK.get() { - if let Some(sink) = slot.read().as_ref() { - if !FIRST_EVENT_LOGGED.swap(true, std::sync::atomic::Ordering::Relaxed) { - debug!("first macOS key event delivered to engine"); - } - trace!( - scancode = ev_out.scancode, - ?direction, - shift = ev_out.modifiers.shift, - ctrl = ev_out.modifiers.control, - alt = ev_out.modifiers.alt, - meta = ev_out.modifiers.meta, - injected = ev_out.injected, - "mac key" - ); - if let Err(err) = sink.try_send(ev_out) { - debug!(?err, "dropping macOS key event"); - } - } - } - } - // Pass-through; we listen but don't suppress. - Some(event.clone()) - }; - - let tap = match core_graphics::event::CGEventTap::new( - CGEventTapLocation::Session, - CGEventTapPlacement::HeadInsertEventTap, - CGEventTapOptions::ListenOnly, - vec![CGEventType::KeyDown, CGEventType::KeyUp], - callback, - ) { - Ok(t) => t, - Err(()) => { - // Trigger the system Accessibility prompt so the user has - // a one-click path to System Settings, then report the - // failure as before. - request_accessibility_prompt(); - let _ = ready_tx.send(Err( - "CGEventTapCreate failed (likely missing Accessibility permission)".into(), - )); - return; - } - }; - - // Safety: hand the mach port to a CFRunLoopSource. The source - // owns a +1 refcount we wrap into Drop via CFRunLoopSource. - let source = unsafe { - let mach_port_ref: CFMachPortRef = tap.mach_port.as_concrete_TypeRef(); - let src_ref = CFMachPortCreateRunLoopSource(std::ptr::null(), mach_port_ref, 0); - if src_ref.is_null() { - let _ = ready_tx.send(Err("CFMachPortCreateRunLoopSource returned null".into())); - return; - } - CFRunLoopSource::wrap_under_create_rule(src_ref) - }; - - let run_loop = CFRunLoop::get_current(); - unsafe { - CFRunLoopAddSource( - run_loop.as_concrete_TypeRef(), - source.as_concrete_TypeRef(), - kCFRunLoopCommonModes, - ); - } - tap.enable(); - - let _ = ready_tx.send(Ok(())); - - loop { - // Safety: standard CFRunLoop call. Must run the loop in a real - // mode (kCFRunLoopDefaultMode is in the common-mode set the - // tap source was added to) — passing kCFRunLoopCommonModes as - // the *run* mode is legal per the docs but on macOS 15 the tap - // source never fires that way, so the callback starves. - unsafe { - let _ = CFRunLoopRunInMode(kCFRunLoopDefaultMode, 60.0, 0); - } - if EVENT_SINK.get().map(|s| s.read().is_none()).unwrap_or(true) { - break; - } - } - info!("macOS CGEventTap thread exiting"); -} - -// ─── Direct FFI: only the things core-foundation 0.10 doesn't expose ── -// -// `CFMachPortCreateRunLoopSource` is the one bit of glue that's not -// re-exported reliably across core-foundation crate versions. Declare -// it ourselves so we don't depend on whichever module the active -// version ships it from. - -type CFAllocatorRef = *const c_void; -type CFIndex = c_long; - -#[link(name = "CoreFoundation", kind = "framework")] -unsafe extern "C" { - fn CFMachPortCreateRunLoopSource( - allocator: CFAllocatorRef, - port: CFMachPortRef, - order: CFIndex, - ) -> CFRunLoopSourceRef; -} - -// ─── Emitter ───────────────────────────────────────────────────────── - -pub struct MacosEmitter; - -impl MacosEmitter { - pub fn new() -> Self { - Self - } -} - -const KVK_DELETE: CGKeyCode = 51; // = "Backspace" / kVK_Delete on Apple keyboards. - -impl KeyEmitter for MacosEmitter { - fn send_backspaces(&self, n: usize) -> Result<(), InputError> { - if n == 0 { - return Ok(()); - } - let src = CGEventSource::new(CGEventSourceStateID::HIDSystemState) - .map_err(|()| InputError::Os("CGEventSource::new failed".into()))?; - for _ in 0..n { - let down = CGEvent::new_keyboard_event(src.clone(), KVK_DELETE, true) - .map_err(|()| InputError::Os("CGEvent::new_keyboard_event(down) failed".into()))?; - down.set_integer_value_field(K_CG_EVENT_SOURCE_USER_DATA, EMITTER_TAG); - down.post(CGEventTapLocation::HID); - let up = CGEvent::new_keyboard_event(src.clone(), KVK_DELETE, false) - .map_err(|()| InputError::Os("CGEvent::new_keyboard_event(up) failed".into()))?; - up.set_integer_value_field(K_CG_EVENT_SOURCE_USER_DATA, EMITTER_TAG); - up.post(CGEventTapLocation::HID); - } - Ok(()) - } - - fn send_text(&self, text: &str) -> Result<(), InputError> { - if text.is_empty() { - return Ok(()); - } - let src = CGEventSource::new(CGEventSourceStateID::HIDSystemState) - .map_err(|()| InputError::Os("CGEventSource::new failed".into()))?; - - for c in text.chars() { - let utf16: Vec = c.encode_utf16(&mut [0u16; 2]).to_vec(); - let down = CGEvent::new_keyboard_event(src.clone(), 0, true) - .map_err(|()| InputError::Os("CGEvent::new_keyboard_event failed".into()))?; - down.set_string_from_utf16_unchecked(&utf16); - down.set_integer_value_field(K_CG_EVENT_SOURCE_USER_DATA, EMITTER_TAG); - down.post(CGEventTapLocation::HID); - - let up = CGEvent::new_keyboard_event(src.clone(), 0, false) - .map_err(|()| InputError::Os("CGEvent::new_keyboard_event failed".into()))?; - up.set_string_from_utf16_unchecked(&utf16); - up.set_integer_value_field(K_CG_EVENT_SOURCE_USER_DATA, EMITTER_TAG); - up.post(CGEventTapLocation::HID); - } - Ok(()) - } - - fn backend_name(&self) -> &'static str { - "macos-cg-event-post" - } -} - -// ─── Apple → Win SC Set-1 keycode mapping ──────────────────────────── -// -// The engine's buffer classifier is written against Windows SC-1 -// scancodes (0x2A = LShift, 0x36 = RShift, 0x39 = Space, 0x0E = -// Backspace, …). Apple virtual keycodes overlap that range with -// DIFFERENT meanings — e.g. Apple 0x39 is Caps Lock but SC-1 0x39 is -// Space, Apple 0x3C (RShift) lands in the classifier's F-row -// "end and discard" range. Every key the classifier pattern-matches -// must therefore be translated explicitly; an identity fallback is -// only safe for keys outside all of the classifier's ranges. - -fn mac_keycode_to_sc1(kvk: u16) -> u32 { - match kvk { - // Letters - 0x00 => 0x1E, // A - 0x01 => 0x1F, // S - 0x02 => 0x20, // D - 0x03 => 0x21, // F - 0x04 => 0x23, // H - 0x05 => 0x22, // G - 0x06 => 0x2C, // Z - 0x07 => 0x2D, // X - 0x08 => 0x2E, // C - 0x09 => 0x2F, // V - 0x0B => 0x30, // B - 0x0C => 0x10, // Q - 0x0D => 0x11, // W - 0x0E => 0x12, // E - 0x0F => 0x13, // R - 0x10 => 0x15, // Y - 0x11 => 0x14, // T - 0x1F => 0x18, // O - 0x20 => 0x16, // U - 0x22 => 0x17, // I - 0x23 => 0x19, // P - 0x25 => 0x26, // L - 0x26 => 0x24, // J - 0x28 => 0x25, // K - 0x2D => 0x31, // N - 0x2E => 0x32, // M - // Numbers - 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 - // Boundaries / nav - 0x24 => 0x1C, // Return - 0x4C => 0x1C, // Numpad Enter - 0x30 => 0x0F, // Tab - 0x31 => 0x39, // Space - 0x33 => 0x0E, // Delete (= Backspace) - 0x75 => 0x53, // Forward Delete - 0x35 => 0x01, // Esc - 0x2B => 0x33, // Comma - 0x2F => 0x34, // Period - 0x2C => 0x35, // Slash - 0x29 => 0x27, // ; - 0x27 => 0x28, // ' - 0x21 => 0x1A, // [ - 0x1E => 0x1B, // ] - 0x2A => 0x2B, // backslash - 0x32 => 0x29, // backtick - 0x18 => 0x0D, // = - 0x1B => 0x0C, // - - // Modifiers — mapped onto the SC-1 modifier slots the - // classifier recognises as "discard, stay inside the word". - // Note: macOS delivers pure modifier presses as `FlagsChanged` - // events, which the tap mask doesn't subscribe to yet, so - // these arms are dormant until it does — they exist for - // correctness and parity with the Windows/Linux streams, - // where modifiers do arrive. Without the mapping, Apple 0x3C - // (RShift) / 0x3B (LControl) would land in the classifier's - // F-row range and KILL any word typed with them, and Apple - // 0x39 (Caps Lock) would alias onto SC-1 Space. - 0x38 => 0x2A, // LShift - 0x3C => 0x36, // RShift - 0x3B => 0x1D, // LControl - 0x3E => 0x1D, // RControl - 0x3A => 0x38, // LOption (Alt) - 0x3D => 0x38, // ROption - 0x37 => 0x5B, // LCommand (SC-1 LWin) - 0x36 => 0x5C, // RCommand (SC-1 RWin) - 0x39 => 0x3A, // Caps Lock - // Arrow cluster (SC-1 extended positions → nav, end the word). - 0x7E => 0x48, // Up - 0x7D => 0x50, // Down - 0x7B => 0x4B, // Left - 0x7C => 0x4D, // Right - 0x73 => 0x47, // Home - 0x77 => 0x4F, // End - 0x74 => 0x49, // PageUp - 0x79 => 0x51, // PageDown - // Function row (SC-1 F1..F10 land in the classifier's - // end-and-discard range; F11/F12 sit outside it, same as on - // Windows — parity, not an omission). - 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 - _ => kvk as u32, - } -} diff --git a/crates/poltertype-input/src/macos/codes.rs b/crates/poltertype-input/src/macos/codes.rs new file mode 100644 index 0000000..c3a4737 --- /dev/null +++ b/crates/poltertype-input/src/macos/codes.rs @@ -0,0 +1,202 @@ +//! Apple keycodes and modifier-flag rules — plain data, no Apple API. +//! +//! Kept free of `core-graphics` on purpose: `core-foundation` and +//! `core-graphics` are macOS-only dependencies, so anything that touches +//! them can never be exercised by `cargo test` on a Linux or Windows +//! host. The rules below are the part most likely to be wrong (a wrong +//! scancode silently kills a word, and the modifier direction rules are +//! new), so they live here and their tests run everywhere — see +//! `super::tests`. The FFI in `listener.rs` / `emitter.rs` is verified +//! by CI's `macos-latest` job. + +use crate::KeyDirection; + +// ─── Apple virtual keycodes we care about ──────────────────────────── +// +// From `` (`kVK_*`). Layout-independent: these are +// positions, not characters. + +pub(crate) const KVK_DELETE: u16 = 0x33; // "Backspace" on a PC keyboard. +pub(crate) const KVK_COMMAND: u16 = 0x37; +pub(crate) const KVK_SHIFT: u16 = 0x38; +pub(crate) const KVK_CAPS_LOCK: u16 = 0x39; +pub(crate) const KVK_OPTION: u16 = 0x3A; +pub(crate) const KVK_CONTROL: u16 = 0x3B; +pub(crate) const KVK_RIGHT_SHIFT: u16 = 0x3C; +pub(crate) const KVK_RIGHT_OPTION: u16 = 0x3D; +pub(crate) const KVK_RIGHT_CONTROL: u16 = 0x3E; +pub(crate) const KVK_RIGHT_COMMAND: u16 = 0x36; + +// ─── Device-independent CGEventFlags bits ──────────────────────────── +// +// Mirrors `CGEventFlags` from `IOLLEvent.h`. Spelled out rather than +// imported so this module stays host-compilable; a macOS-only test +// asserts each value against the real crate constant, so the two +// cannot drift apart unnoticed. + +pub(crate) const FLAG_ALPHA_SHIFT: u64 = 0x0001_0000; +pub(crate) const FLAG_SHIFT: u64 = 0x0002_0000; +pub(crate) const FLAG_CONTROL: u64 = 0x0004_0000; +pub(crate) const FLAG_ALTERNATE: u64 = 0x0008_0000; +pub(crate) const FLAG_COMMAND: u64 = 0x0010_0000; + +/// The `CGEventFlags` bit a modifier keycode owns, or `None` for keys +/// that are not modifiers *we track*. +/// +/// Deliberately excludes `kVK_Function` (0x3F) and the media/eject keys: +/// macOS reports them through the same `FlagsChanged` stream, they have +/// no SC Set-1 equivalent, and the identity fallback in +/// [`mac_keycode_to_sc1`] would land Fn on SC-1 0x3F — inside the +/// classifier's `0x3B..=0x53` "nav, end and discard" range. A user +/// holding Fn to reach an arrow key would silently lose the word they +/// were typing. Returning `None` makes the listener drop the event +/// instead, which is what every other backend effectively does. +fn modifier_flag(kvk: u16) -> Option { + match kvk { + KVK_SHIFT | KVK_RIGHT_SHIFT => Some(FLAG_SHIFT), + KVK_CONTROL | KVK_RIGHT_CONTROL => Some(FLAG_CONTROL), + KVK_OPTION | KVK_RIGHT_OPTION => Some(FLAG_ALTERNATE), + KVK_COMMAND | KVK_RIGHT_COMMAND => Some(FLAG_COMMAND), + KVK_CAPS_LOCK => Some(FLAG_ALPHA_SHIFT), + _ => None, + } +} + +/// Press-or-release for a `kCGEventFlagsChanged` event. +/// +/// macOS does not tell us the direction: it delivers one event type for +/// "the modifier picture changed" and expects the reader to diff it. +/// Since the event's flags describe the state *after* the change, the +/// bit belonging to the keycode that moved is set exactly when that key +/// went down. +/// +/// Caps Lock is a latch, not a held key — the "press" is the event that +/// turns the light on and the "release" the one that turns it off. That +/// is the shape the engine wants anyway: SC-1 0x3A classifies as +/// `Discard`, so both edges keep the word alive without contributing a +/// character. +/// +/// `None` means "not a modifier we mirror" — see [`modifier_flag`]. +pub(crate) fn flags_changed_direction(kvk: u16, flags: u64) -> Option { + let bit = modifier_flag(kvk)?; + Some(if flags & bit != 0 { + KeyDirection::Press + } else { + KeyDirection::Release + }) +} + +// ─── Apple → Win SC Set-1 keycode mapping ──────────────────────────── +// +// The engine's buffer classifier is written against Windows SC-1 +// scancodes (0x2A = LShift, 0x36 = RShift, 0x39 = Space, 0x0E = +// Backspace, …). Apple virtual keycodes overlap that range with +// DIFFERENT meanings — e.g. Apple 0x39 is Caps Lock but SC-1 0x39 is +// Space, Apple 0x3C (RShift) lands in the classifier's F-row +// "end and discard" range. Every key the classifier pattern-matches +// must therefore be translated explicitly; an identity fallback is +// only safe for keys outside all of the classifier's ranges. + +pub(crate) fn mac_keycode_to_sc1(kvk: u16) -> u32 { + match kvk { + // Letters + 0x00 => 0x1E, // A + 0x01 => 0x1F, // S + 0x02 => 0x20, // D + 0x03 => 0x21, // F + 0x04 => 0x23, // H + 0x05 => 0x22, // G + 0x06 => 0x2C, // Z + 0x07 => 0x2D, // X + 0x08 => 0x2E, // C + 0x09 => 0x2F, // V + 0x0B => 0x30, // B + 0x0C => 0x10, // Q + 0x0D => 0x11, // W + 0x0E => 0x12, // E + 0x0F => 0x13, // R + 0x10 => 0x15, // Y + 0x11 => 0x14, // T + 0x1F => 0x18, // O + 0x20 => 0x16, // U + 0x22 => 0x17, // I + 0x23 => 0x19, // P + 0x25 => 0x26, // L + 0x26 => 0x24, // J + 0x28 => 0x25, // K + 0x2D => 0x31, // N + 0x2E => 0x32, // M + // Numbers + 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 + // Boundaries / nav + 0x24 => 0x1C, // Return + 0x4C => 0x1C, // Numpad Enter + 0x30 => 0x0F, // Tab + 0x31 => 0x39, // Space + KVK_DELETE => 0x0E, // Delete (= Backspace) + 0x75 => 0x53, // Forward Delete + 0x35 => 0x01, // Esc + 0x2B => 0x33, // Comma + 0x2F => 0x34, // Period + 0x2C => 0x35, // Slash + 0x29 => 0x27, // ; + 0x27 => 0x28, // ' + 0x21 => 0x1A, // [ + 0x1E => 0x1B, // ] + 0x2A => 0x2B, // backslash + 0x32 => 0x29, // backtick + 0x18 => 0x0D, // = + 0x1B => 0x0C, // - + // Modifiers — mapped onto the SC-1 modifier slots the + // classifier recognises as "discard, stay inside the word". + // Live since 0.6.4: the tap subscribes to `FlagsChanged`, so + // these arrive on macOS exactly as they do on Windows and + // Linux. Without the mapping, Apple 0x3C (RShift) / 0x3B + // (LControl) would land in the classifier's F-row range and + // KILL any word typed with them, and Apple 0x39 (Caps Lock) + // would alias onto SC-1 Space. + KVK_SHIFT => 0x2A, // LShift + KVK_RIGHT_SHIFT => 0x36, // RShift + KVK_CONTROL => 0x1D, // LControl + KVK_RIGHT_CONTROL => 0x1D, // RControl + KVK_OPTION => 0x38, // LOption (Alt) + KVK_RIGHT_OPTION => 0x38, // ROption + KVK_COMMAND => 0x5B, // LCommand (SC-1 LWin) + KVK_RIGHT_COMMAND => 0x5C, // RCommand (SC-1 RWin) + KVK_CAPS_LOCK => 0x3A, // Caps Lock + // Arrow cluster (SC-1 extended positions → nav, end the word). + 0x7E => 0x48, // Up + 0x7D => 0x50, // Down + 0x7B => 0x4B, // Left + 0x7C => 0x4D, // Right + 0x73 => 0x47, // Home + 0x77 => 0x4F, // End + 0x74 => 0x49, // PageUp + 0x79 => 0x51, // PageDown + // Function row (SC-1 F1..F10 land in the classifier's + // end-and-discard range; F11/F12 sit outside it, same as on + // Windows — parity, not an omission). + 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 + _ => kvk as u32, + } +} diff --git a/crates/poltertype-input/src/macos/consts.rs b/crates/poltertype-input/src/macos/consts.rs new file mode 100644 index 0000000..1227515 --- /dev/null +++ b/crates/poltertype-input/src/macos/consts.rs @@ -0,0 +1,20 @@ +//! `CGEvent` field ids and the tag we stamp on our own emissions. + +/// `kCGKeyboardEventKeycode`. +/// +/// `CGEventField` is a `u32` enum-like in Apple's C header; the +/// `core-graphics` crate has represented it differently across +/// releases, so we hard-code the documented integer values rather than +/// depend on whichever variant naming the active version exposes. +pub(super) const K_CG_KEYBOARD_EVENT_KEYCODE: u32 = 9; + +/// `kCGEventSourceUserData`. +pub(super) const K_CG_EVENT_SOURCE_USER_DATA: u32 = 42; + +/// Magic value stamped into `kCGEventSourceUserData` on every event +/// WE post, so the listener can tag them `injected` and the engine +/// never mistakes our own backspaces / retypes for user keystrokes. +/// Without this the emitted events echo back through the tap as +/// "real" input: the backspace burst poisons the word buffer right +/// after a correction, and every second word gets skipped as tainted. +pub(super) const EMITTER_TAG: i64 = 0x504F_4C54; // "POLT" diff --git a/crates/poltertype-input/src/macos/emitter.rs b/crates/poltertype-input/src/macos/emitter.rs new file mode 100644 index 0000000..5cab166 --- /dev/null +++ b/crates/poltertype-input/src/macos/emitter.rs @@ -0,0 +1,145 @@ +//! `CGEventPost` emitter. +//! +//! `CGEventKeyboardSetUnicodeString` gives macOS the same +//! layout-independent contract as Windows' `KEYEVENTF_UNICODE`, so +//! there is no scancode-replay path here — the trait default returns +//! `Unsupported` and the engine uses `send_text`. + +use std::time::Duration; + +use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGKeyCode}; +use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; +use tracing::debug; + +use super::codes::{ + FLAG_ALTERNATE, FLAG_COMMAND, FLAG_CONTROL, FLAG_SHIFT, KVK_COMMAND, KVK_CONTROL, KVK_DELETE, + KVK_OPTION, KVK_SHIFT, +}; +use super::consts::{EMITTER_TAG, K_CG_EVENT_SOURCE_USER_DATA}; +use crate::{InputError, KeyEmitter, Modifiers}; + +/// Gap between the modifier releases and whatever we type next. +/// +/// `CGEventPost` is asynchronous — it hands the event to the window +/// server and returns — so back-to-back posts can reach the focused +/// application in the same run-loop turn, before it has processed the +/// flags change. 4 ms matches the step the evdev and X11 emitters use +/// for the same reason. +const MODIFIER_SETTLE: Duration = Duration::from_millis(4); + +pub struct MacosEmitter; + +impl MacosEmitter { + pub fn new() -> Self { + Self + } +} + +fn event_source() -> Result { + CGEventSource::new(CGEventSourceStateID::HIDSystemState) + .map_err(|()| InputError::Os("CGEventSource::new failed".into())) +} + +/// Build one keyboard event, stamped as ours and stripped of modifiers. +/// +/// Both halves matter. The stamp is what lets the listener tag the +/// echo `injected`. The stripping is the macOS-specific half of +/// [`KeyEmitter::release_modifiers`]: an event created from a +/// `HIDSystemState` source inherits the *current hardware* modifier +/// flags, so with the user still holding the keys of an accept chord +/// our backspaces would post as ⌘⌫ ("delete to start of line") and our +/// replay as a burst of ⌃-shortcuts. Clearing the event's own flags +/// makes what the focused app receives independent of what the user's +/// fingers are doing, whether or not the flags-changed posts below +/// landed. +fn keyboard_event( + src: &CGEventSource, + keycode: CGKeyCode, + key_down: bool, +) -> Result { + let ev = CGEvent::new_keyboard_event(src.clone(), keycode, key_down) + .map_err(|()| InputError::Os("CGEvent::new_keyboard_event failed".into()))?; + ev.set_flags(CGEventFlags::CGEventFlagNull); + ev.set_integer_value_field(K_CG_EVENT_SOURCE_USER_DATA, EMITTER_TAG); + Ok(ev) +} + +impl KeyEmitter for MacosEmitter { + fn send_backspaces(&self, n: usize) -> Result<(), InputError> { + if n == 0 { + return Ok(()); + } + let src = event_source()?; + for _ in 0..n { + keyboard_event(&src, KVK_DELETE, true)?.post(CGEventTapLocation::HID); + keyboard_event(&src, KVK_DELETE, false)?.post(CGEventTapLocation::HID); + } + Ok(()) + } + + fn send_text(&self, text: &str) -> Result<(), InputError> { + if text.is_empty() { + return Ok(()); + } + let src = event_source()?; + for c in text.chars() { + let utf16: Vec = c.encode_utf16(&mut [0u16; 2]).to_vec(); + for key_down in [true, false] { + let ev = keyboard_event(&src, 0, key_down)?; + ev.set_string_from_utf16_unchecked(&utf16); + ev.post(CGEventTapLocation::HID); + } + } + Ok(()) + } + + fn release_modifiers(&self, held: Modifiers) -> Result<(), InputError> { + // macOS has no key-up for a modifier: the press and the release + // are both `kCGEventFlagsChanged` events whose *flags* say what + // is down afterwards. So we post one per modifier, each + // carrying the picture that remains once that key is up, and + // the last one carries an empty set. + // + // Left-hand keycodes only, like the X11 emitter: the + // device-independent flag bits don't distinguish sides, and the + // flags — not the keycode — are what the receiving app reads. + let mut remaining = 0u64; + let mut releases: Vec<(CGKeyCode, u64)> = Vec::new(); + for (down, bit, keycode) in [ + (held.control, FLAG_CONTROL, KVK_CONTROL), + (held.shift, FLAG_SHIFT, KVK_SHIFT), + (held.alt, FLAG_ALTERNATE, KVK_OPTION), + (held.meta, FLAG_COMMAND, KVK_COMMAND), + ] { + if down { + remaining |= bit; + releases.push((keycode, bit)); + } + } + if releases.is_empty() { + return Ok(()); + } + + // Caps Lock is deliberately absent: it is a latch, not a held + // key, and the engine folds it into `shift`. Posting a + // flags-changed that clears it would turn the light off behind + // the user's back. + let src = event_source()?; + for (keycode, bit) in releases { + remaining &= !bit; + let ev = CGEvent::new_keyboard_event(src.clone(), keycode, false) + .map_err(|()| InputError::Os("CGEvent::new_keyboard_event failed".into()))?; + ev.set_type(CGEventType::FlagsChanged); + ev.set_flags(CGEventFlags::from_bits_truncate(remaining)); + ev.set_integer_value_field(K_CG_EVENT_SOURCE_USER_DATA, EMITTER_TAG); + ev.post(CGEventTapLocation::HID); + std::thread::sleep(MODIFIER_SETTLE); + } + debug!(?held, "posted macOS modifier releases"); + Ok(()) + } + + fn backend_name(&self) -> &'static str { + "macos-cg-event-post" + } +} diff --git a/crates/poltertype-input/src/macos/listener.rs b/crates/poltertype-input/src/macos/listener.rs new file mode 100644 index 0000000..ed7ee29 --- /dev/null +++ b/crates/poltertype-input/src/macos/listener.rs @@ -0,0 +1,280 @@ +//! `CGEventTap` listener: attach, translate, forward. + +use std::ffi::{c_long, c_void}; +use std::sync::OnceLock; +use std::thread; +use std::time::Duration; + +use core_foundation::base::TCFType; +use core_foundation::mach_port::CFMachPortRef; +use core_foundation::runloop::{ + CFRunLoop, CFRunLoopAddSource, CFRunLoopRunInMode, CFRunLoopSource, CFRunLoopSourceRef, + kCFRunLoopCommonModes, kCFRunLoopDefaultMode, +}; +use core_graphics::event::{ + CGEvent, CGEventFlags, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, CGEventType, +}; +use crossbeam_channel::Sender; +use tracing::{debug, info, trace}; + +use super::codes::{flags_changed_direction, mac_keycode_to_sc1}; +use super::consts::{K_CG_EVENT_SOURCE_USER_DATA, K_CG_KEYBOARD_EVENT_KEYCODE}; +use crate::{InputError, InputListener, KeyDirection, KeyEvent, Modifiers}; + +// ─── Accessibility permission prompt ───────────────────────────────── +// +// `CGEventTapCreate` fails *silently* when the app lacks Accessibility +// rights — no system dialog. The supported way to ask is +// `AXIsProcessTrustedWithOptions({ kAXTrustedCheckOptionPrompt: true })`, +// which drops the app into System Settings → Privacy & Security → +// Accessibility and shows the "PolterType would like to control this +// computer" alert. We call it when the tap fails to attach so a +// first-launch user gets the prompt instead of a dead tray icon. + +use core_foundation::dictionary::CFDictionaryRef; +use core_foundation::string::CFStringRef; + +#[link(name = "ApplicationServices", kind = "framework")] +unsafe extern "C" { + fn AXIsProcessTrustedWithOptions(options: CFDictionaryRef) -> bool; + static kAXTrustedCheckOptionPrompt: CFStringRef; +} + +/// Check Accessibility trust; prompt the user when not yet trusted. +fn request_accessibility_prompt() { + unsafe { + let key = + core_foundation::string::CFString::wrap_under_get_rule(kAXTrustedCheckOptionPrompt); + let value = core_foundation::boolean::CFBoolean::true_value(); + let options = core_foundation::dictionary::CFDictionary::from_CFType_pairs(&[( + key.as_CFType(), + value.as_CFType(), + )]); + let trusted = AXIsProcessTrustedWithOptions(options.as_concrete_TypeRef()); + debug!(trusted, "AXIsProcessTrustedWithOptions(prompt) result"); + } +} + +// ─── Listener ──────────────────────────────────────────────────────── + +static EVENT_SINK: OnceLock>>> = OnceLock::new(); + +static FIRST_EVENT_LOGGED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +fn sink_slot() -> &'static parking_lot::RwLock>> { + EVENT_SINK.get_or_init(|| parking_lot::RwLock::new(None)) +} + +pub struct MacosListener { + started: bool, +} + +impl MacosListener { + pub fn new() -> Self { + Self { started: false } + } +} + +impl InputListener for MacosListener { + fn start(&mut self, sink: Sender) -> Result<(), InputError> { + if self.started { + return Err(InputError::AlreadyStarted); + } + *sink_slot().write() = Some(sink); + + let (ready_tx, ready_rx) = crossbeam_channel::bounded::>(1); + thread::Builder::new() + .name("poltertype-input-macos-tap".into()) + .spawn(move || run_tap_thread(ready_tx)) + .map_err(|e| InputError::Os(format!("spawn tap thread: {e}")))?; + + match ready_rx.recv_timeout(Duration::from_secs(3)) { + Ok(Ok(())) => { + self.started = true; + info!("macOS CGEventTap attached"); + Ok(()) + } + Ok(Err(reason)) => Err(InputError::Os(reason)), + Err(_) => Err(InputError::Os("CGEventTap setup timed out".into())), + } + } + + fn stop(&mut self) { + if let Some(slot) = EVENT_SINK.get() { + *slot.write() = None; + } + } + + fn backend_name(&self) -> &'static str { + "macos-cg-event-tap" + } +} + +/// Translate one tap event into a [`KeyEvent`], or `None` for events +/// the engine has no use for. +/// +/// Split out of the callback so the direction/flag rules it leans on +/// stay in one place; the callback itself must do nothing but this and +/// a `try_send`. +fn to_key_event(ev_type: CGEventType, event: &CGEvent) -> Option { + // `CGEventField` is a `u32` type-alias in core-graphics 0.24, so we + // feed the documented Apple constants straight through + // `get_integer_value_field`. + let vk = event.get_integer_value_field(K_CG_KEYBOARD_EVENT_KEYCODE) as u32; + let flags = event.get_flags(); + + let direction = match ev_type { + CGEventType::KeyDown => KeyDirection::Press, + CGEventType::KeyUp => KeyDirection::Release, + // A modifier moved. macOS reports no direction of its own: the + // flags describe the state *after* the change, so the bit + // belonging to the key that moved tells us which way it went. + // Keys we don't mirror (Fn, media) yield `None` and are dropped + // rather than falling through the SC-1 identity mapping into + // the classifier's "end the word" range. + CGEventType::FlagsChanged => flags_changed_direction(vk as u16, flags.bits())?, + _ => return None, + }; + + // Fold Caps Lock into the shift bit the way the X11 backend does: + // caps-on + no Shift = uppercase, caps-on + held Shift = lowercase. + // The engine's all-caps and replay logic rely on this combined bit. + let shift = flags.contains(CGEventFlags::CGEventFlagShift) + ^ flags.contains(CGEventFlags::CGEventFlagAlphaShift); + + Some(KeyEvent { + vk, + scancode: mac_keycode_to_sc1(vk as u16), + direction, + modifiers: Modifiers { + shift, + control: flags.contains(CGEventFlags::CGEventFlagControl), + alt: flags.contains(CGEventFlags::CGEventFlagAlternate), + meta: flags.contains(CGEventFlags::CGEventFlagCommand), + }, + injected: event.get_integer_value_field(K_CG_EVENT_SOURCE_USER_DATA) != 0, + timestamp_ms: 0, + }) +} + +fn run_tap_thread(ready_tx: Sender>) { + use core_graphics::event::CGEventTapProxy; + + let callback = + |_proxy: CGEventTapProxy, ev_type: CGEventType, event: &CGEvent| -> Option { + if let Some(ev_out) = to_key_event(ev_type, event) { + if let Some(slot) = EVENT_SINK.get() { + if let Some(sink) = slot.read().as_ref() { + if !FIRST_EVENT_LOGGED.swap(true, std::sync::atomic::Ordering::Relaxed) { + debug!("first macOS key event delivered to engine"); + } + trace!( + scancode = ev_out.scancode, + direction = ?ev_out.direction, + shift = ev_out.modifiers.shift, + ctrl = ev_out.modifiers.control, + alt = ev_out.modifiers.alt, + meta = ev_out.modifiers.meta, + injected = ev_out.injected, + "mac key" + ); + if let Err(err) = sink.try_send(ev_out) { + debug!(?err, "dropping macOS key event"); + } + } + } + } + // Pass-through; we listen but don't suppress. + Some(event.clone()) + }; + + let tap = match core_graphics::event::CGEventTap::new( + CGEventTapLocation::Session, + CGEventTapPlacement::HeadInsertEventTap, + CGEventTapOptions::ListenOnly, + // `FlagsChanged` is how macOS reports a modifier press or + // release — there is no KeyDown for Shift. Subscribing gives + // the engine the same discrete modifier stream the Windows and + // Linux backends produce, which is what `held_modifiers` (and + // therefore `release_modifiers`) needs to stay accurate between + // ordinary keystrokes. Cost is one extra event per modifier + // edge; the callback stays a translate-and-send. + vec![ + CGEventType::KeyDown, + CGEventType::KeyUp, + CGEventType::FlagsChanged, + ], + callback, + ) { + Ok(t) => t, + Err(()) => { + // Trigger the system Accessibility prompt so the user has + // a one-click path to System Settings, then report the + // failure as before. + request_accessibility_prompt(); + let _ = ready_tx.send(Err( + "CGEventTapCreate failed (likely missing Accessibility permission)".into(), + )); + return; + } + }; + + // Safety: hand the mach port to a CFRunLoopSource. The source + // owns a +1 refcount we wrap into Drop via CFRunLoopSource. + let source = unsafe { + let mach_port_ref: CFMachPortRef = tap.mach_port.as_concrete_TypeRef(); + let src_ref = CFMachPortCreateRunLoopSource(std::ptr::null(), mach_port_ref, 0); + if src_ref.is_null() { + let _ = ready_tx.send(Err("CFMachPortCreateRunLoopSource returned null".into())); + return; + } + CFRunLoopSource::wrap_under_create_rule(src_ref) + }; + + let run_loop = CFRunLoop::get_current(); + unsafe { + CFRunLoopAddSource( + run_loop.as_concrete_TypeRef(), + source.as_concrete_TypeRef(), + kCFRunLoopCommonModes, + ); + } + tap.enable(); + + let _ = ready_tx.send(Ok(())); + + loop { + // Safety: standard CFRunLoop call. Must run the loop in a real + // mode (kCFRunLoopDefaultMode is in the common-mode set the + // tap source was added to) — passing kCFRunLoopCommonModes as + // the *run* mode is legal per the docs but on macOS 15 the tap + // source never fires that way, so the callback starves. + unsafe { + let _ = CFRunLoopRunInMode(kCFRunLoopDefaultMode, 60.0, 0); + } + if EVENT_SINK.get().map(|s| s.read().is_none()).unwrap_or(true) { + break; + } + } + info!("macOS CGEventTap thread exiting"); +} + +// ─── Direct FFI: only the things core-foundation 0.10 doesn't expose ── +// +// `CFMachPortCreateRunLoopSource` is the one bit of glue that's not +// re-exported reliably across core-foundation crate versions. Declare +// it ourselves so we don't depend on whichever module the active +// version ships it from. + +type CFAllocatorRef = *const c_void; +type CFIndex = c_long; + +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + fn CFMachPortCreateRunLoopSource( + allocator: CFAllocatorRef, + port: CFMachPortRef, + order: CFIndex, + ) -> CFRunLoopSourceRef; +} diff --git a/crates/poltertype-input/src/macos/mod.rs b/crates/poltertype-input/src/macos/mod.rs new file mode 100644 index 0000000..4495a9e --- /dev/null +++ b/crates/poltertype-input/src/macos/mod.rs @@ -0,0 +1,51 @@ +//! macOS keyboard listener + emitter. +//! +//! ## Listener +//! +//! Built on `CGEventTapCreate(kCGSessionEventTap, …, listenOnly)`, +//! attached to the `CFRunLoop` of a dedicated thread. macOS requires +//! the calling app to be granted **Accessibility** in +//! System Settings → Privacy & Security → Accessibility. We surface +//! that requirement to the user via the tray onboarding banner; if +//! the tap fails to attach (typical first-launch state), `start()` +//! returns `InputError::Os` so the engine gracefully degrades. +//! +//! The tap subscribes to `KeyDown`, `KeyUp` **and** `FlagsChanged` — +//! the last is the only way macOS reports a modifier moving, and +//! without it `held_modifiers` only refreshed on ordinary keystrokes. +//! +//! ## Emitter +//! +//! `CGEventPost` with `CGEventKeyboardSetUnicodeString` — same +//! layout-independent contract as Windows' `KEYEVENTF_UNICODE`. +//! +//! > **Status:** validated end-to-end on macOS 15 (Intel): the tap +//! > receives events, corrections emit, and injected events are +//! > recognised via the user-data tag. The `FlagsChanged` subscription +//! > and `release_modifiers` (0.6.4) have not yet run on hardware. +//! +//! ## Why this is a directory +//! +//! `codes` holds the keyboard facts — the Apple → SC Set-1 table and +//! the rules that turn a `FlagsChanged` event into a press or a +//! release. It deliberately depends on nothing Apple-specific, so it +//! compiles under `cfg(test)` on every host and its tests run in CI on +//! Linux and Windows too. Everything that touches `core-graphics` is +//! macOS-only and can only be compiled by CI's `macos-latest` job. + +pub(crate) mod codes; + +#[cfg(target_os = "macos")] +mod consts; +#[cfg(target_os = "macos")] +mod emitter; +#[cfg(target_os = "macos")] +mod listener; + +#[cfg(test)] +mod tests; + +#[cfg(target_os = "macos")] +pub use emitter::MacosEmitter; +#[cfg(target_os = "macos")] +pub use listener::MacosListener; diff --git a/crates/poltertype-input/src/macos/tests.rs b/crates/poltertype-input/src/macos/tests.rs new file mode 100644 index 0000000..eb8ef85 --- /dev/null +++ b/crates/poltertype-input/src/macos/tests.rs @@ -0,0 +1,160 @@ +//! Host-runnable tests for the macOS keyboard facts. +//! +//! These run on Linux and Windows CI as well as on a Mac — the module +//! under test has no Apple dependency. That is the whole point: the +//! table and the direction rules are where a macOS mistake is silent +//! (a wrong scancode drops a word without any error), and a Mac is the +//! one machine this project does not have. + +use super::codes::*; +use crate::KeyDirection; + +/// Scancodes the engine's buffer classifier treats as "a modifier +/// moved — ignore it but stay inside the word" +/// (`poltertype-core/src/engine/buffer/classify.rs`). +const DISCARD: &[u32] = &[0x1D, 0x2A, 0x36, 0x38, 0x3A]; + +/// The range the classifier reads as "navigation / function key — end +/// the word and throw it away". +const END_AND_DISCARD: std::ops::RangeInclusive = 0x3B..=0x53; + +#[test] +fn every_modifier_maps_into_the_classifier_discard_set() { + for kvk in [ + KVK_SHIFT, + KVK_RIGHT_SHIFT, + KVK_CONTROL, + KVK_RIGHT_CONTROL, + KVK_OPTION, + KVK_RIGHT_OPTION, + KVK_CAPS_LOCK, + ] { + let sc = mac_keycode_to_sc1(kvk); + assert!( + DISCARD.contains(&sc), + "Apple keycode {kvk:#04x} maps to SC-1 {sc:#04x}, which is not a modifier \ + the classifier ignores — a word typed with this key held would be lost" + ); + } +} + +#[test] +fn command_keys_map_to_the_win_key_slots() { + // SC-1 0x5B / 0x5C sit outside every classifier range, so they fall + // through to "no character produced → Discard" exactly as LWin and + // RWin do on Windows. Asserted separately from the modifiers above + // because they get there by a different route. + assert_eq!(mac_keycode_to_sc1(KVK_COMMAND), 0x5B); + assert_eq!(mac_keycode_to_sc1(KVK_RIGHT_COMMAND), 0x5C); + for sc in [0x5B, 0x5C] { + assert!(!END_AND_DISCARD.contains(&sc)); + assert_ne!(sc, 0x39, "must not alias onto Space"); + } +} + +#[test] +fn no_letter_or_digit_lands_in_a_control_range() { + // The Apple keycodes for the alphanumeric block. If any of them + // translated into the classifier's control ranges, typing that + // letter would end the word instead of extending it. + let letters_and_digits: &[u16] = &[ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, + 0x10, 0x11, 0x1F, 0x20, 0x22, 0x23, 0x25, 0x26, 0x28, 0x2D, 0x2E, 0x12, 0x13, 0x14, 0x15, + 0x16, 0x17, 0x19, 0x1A, 0x1C, 0x1D, + ]; + for &kvk in letters_and_digits { + let sc = mac_keycode_to_sc1(kvk); + assert!( + !END_AND_DISCARD.contains(&sc) && !DISCARD.contains(&sc), + "Apple keycode {kvk:#04x} maps to SC-1 {sc:#04x}, inside a control range" + ); + assert_ne!(sc, 0x39, "Apple keycode {kvk:#04x} aliases onto Space"); + assert_ne!(sc, 0x0E, "Apple keycode {kvk:#04x} aliases onto Backspace"); + } +} + +#[test] +fn boundary_keys_keep_their_meaning() { + assert_eq!(mac_keycode_to_sc1(0x31), 0x39, "Space"); + assert_eq!(mac_keycode_to_sc1(0x24), 0x1C, "Return"); + assert_eq!(mac_keycode_to_sc1(0x4C), 0x1C, "Numpad Enter"); + assert_eq!(mac_keycode_to_sc1(0x30), 0x0F, "Tab"); + assert_eq!(mac_keycode_to_sc1(KVK_DELETE), 0x0E, "Backspace"); + assert_eq!(mac_keycode_to_sc1(0x35), 0x01, "Esc"); +} + +#[test] +fn flags_changed_reads_the_post_change_state() { + // Shift going down: its bit is set in the flags that come with the + // event. Going up: the bit is gone. + assert_eq!( + flags_changed_direction(KVK_SHIFT, FLAG_SHIFT), + Some(KeyDirection::Press) + ); + assert_eq!( + flags_changed_direction(KVK_SHIFT, 0), + Some(KeyDirection::Release) + ); + // Right-hand keys share the device-independent bit. + assert_eq!( + flags_changed_direction(KVK_RIGHT_SHIFT, FLAG_SHIFT), + Some(KeyDirection::Press) + ); + // Releasing Control while Shift stays down: only Control's bit is + // consulted, so the still-held Shift must not read as a press. + assert_eq!( + flags_changed_direction(KVK_CONTROL, FLAG_SHIFT), + Some(KeyDirection::Release) + ); + assert_eq!( + flags_changed_direction(KVK_CONTROL, FLAG_SHIFT | FLAG_CONTROL), + Some(KeyDirection::Press) + ); + assert_eq!( + flags_changed_direction(KVK_OPTION, FLAG_ALTERNATE), + Some(KeyDirection::Press) + ); + assert_eq!( + flags_changed_direction(KVK_COMMAND, FLAG_COMMAND), + Some(KeyDirection::Press) + ); +} + +#[test] +fn caps_lock_latch_reads_as_press_then_release() { + assert_eq!( + flags_changed_direction(KVK_CAPS_LOCK, FLAG_ALPHA_SHIFT), + Some(KeyDirection::Press) + ); + assert_eq!( + flags_changed_direction(KVK_CAPS_LOCK, 0), + Some(KeyDirection::Release) + ); +} + +#[test] +fn untracked_modifier_keys_are_dropped_not_mistranslated() { + // Fn (0x3F) and the media keys have no SC-1 equivalent. The + // identity fallback would put Fn at SC-1 0x3F — inside + // `0x3B..=0x53`, i.e. "end the word and discard it" — so holding Fn + // to reach an arrow key would silently eat whatever was typed. + // Returning `None` is what keeps the listener from emitting them. + const KVK_FUNCTION: u16 = 0x3F; + assert!(END_AND_DISCARD.contains(&mac_keycode_to_sc1(KVK_FUNCTION))); + assert_eq!(flags_changed_direction(KVK_FUNCTION, 0), None); + assert_eq!(flags_changed_direction(0x4F, 0), None); +} + +/// The flag bits are transcribed from Apple's header so the table can +/// compile off-Mac; on a Mac we can hold the transcription against the +/// real thing. +#[cfg(target_os = "macos")] +#[test] +fn flag_constants_match_core_graphics() { + use core_graphics::event::CGEventFlags; + assert_eq!(FLAG_ALPHA_SHIFT, CGEventFlags::CGEventFlagAlphaShift.bits()); + assert_eq!(FLAG_SHIFT, CGEventFlags::CGEventFlagShift.bits()); + assert_eq!(FLAG_CONTROL, CGEventFlags::CGEventFlagControl.bits()); + assert_eq!(FLAG_ALTERNATE, CGEventFlags::CGEventFlagAlternate.bits()); + assert_eq!(FLAG_COMMAND, CGEventFlags::CGEventFlagCommand.bits()); +} diff --git a/crates/poltertype-input/src/setup/consts.rs b/crates/poltertype-input/src/setup/consts.rs new file mode 100644 index 0000000..6702264 --- /dev/null +++ b/crates/poltertype-input/src/setup/consts.rs @@ -0,0 +1,47 @@ +//! Paths, group names and the links the setup steps point at. + +/// The permissions guide, pinned to `main` for the same reason the +/// tray's link is: it has to describe the current setup script, not +/// the release the user happens to be running. +/// +/// Only the Linux probe hands this out. macOS steps point at the +/// System Settings panes below instead, and Windows grants nothing at +/// all — on both, this constant would be dead code rather than merely +/// unused, which `-D warnings` treats as an error. +#[cfg(target_os = "linux")] +pub(super) const PERMISSIONS_URL: &str = + "https://github.com/Just-Code-NET/PolterType/blob/main/docs/PERMISSIONS.md"; + +// ─── Linux ──────────────────────────────────────────────────────────── + +#[cfg(target_os = "linux")] +pub(super) const EVENT_DEVICE_DIR: &str = "/dev/input"; + +#[cfg(target_os = "linux")] +pub(super) const UINPUT_DEVICE: &str = "/dev/uinput"; + +#[cfg(target_os = "linux")] +pub(super) const INPUT_GROUP: &str = "input"; + +/// What we put on the clipboard rather than run. +/// +/// The script needs `sudo`, and an app that quietly asks for root has +/// spent trust it will not get back. Handing over a command the user +/// can read, in a terminal they opened, keeps the decision theirs. The +/// `curl`-free form assumes a checkout or an unpacked AppImage; the +/// guide covers the rest. +#[cfg(target_os = "linux")] +pub(super) const SETUP_SCRIPT_COMMAND: &str = "bash scripts/setup-linux.sh"; + +// ─── macOS ──────────────────────────────────────────────────────────── + +/// Deep links into the exact System Settings panes. `x-apple.system +/// preferences:` is Apple's documented URL scheme for this; the +/// anchors are the ones the Privacy & Security pane registers. +#[cfg(target_os = "macos")] +pub(super) const ACCESSIBILITY_PANE_URL: &str = + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"; + +#[cfg(target_os = "macos")] +pub(super) const INPUT_MONITORING_PANE_URL: &str = + "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"; diff --git a/crates/poltertype-input/src/setup/enums.rs b/crates/poltertype-input/src/setup/enums.rs new file mode 100644 index 0000000..2e5cf4f --- /dev/null +++ b/crates/poltertype-input/src/setup/enums.rs @@ -0,0 +1,46 @@ +//! Step state and the actions a step's button can perform. + +/// Where a setup step stands right now. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StepState { + /// Satisfied. Nothing for the user to do. + Done, + /// Not satisfied, and the user can fix it — the step's `action` + /// says how. + Todo, + /// Satisfied on disk but not in *this* session. The specific + /// Linux trap: `usermod -aG input` updates the group database + /// immediately and the running session's credentials never, so + /// everything looks correct and nothing works until the user logs + /// out. Worth its own state precisely because "Todo" would send + /// them to re-run a script that has already done its job. + NeedsRelogin, + /// We could not tell. Shown as a neutral note rather than a + /// warning: claiming a problem we have not proven is how a setup + /// guide loses the user's trust. + Unknown, +} + +/// The single action a step offers. Rendering and execution live in +/// the app; this crate only says what should happen. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StepAction { + /// Open a URL — a documentation page, or a macOS + /// `x-apple.systempreferences:` deep link into the exact pane. + Open(String), + /// Put a shell command on the clipboard. Used where running it + /// ourselves would be wrong: `setup-linux.sh` needs `sudo`, and an + /// app that silently asks for root is an app nobody should trust. + /// The user reads it, then runs it in their own terminal. + Copy(String), + /// Ask the OS to show its own permission prompt (macOS + /// Accessibility / Input Monitoring). Only ever the *system* + /// dialog — we never imitate one. + RequestPermission(Permission), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Permission { + Accessibility, + InputMonitoring, +} diff --git a/crates/poltertype-input/src/setup/linux.rs b/crates/poltertype-input/src/setup/linux.rs new file mode 100644 index 0000000..073fb94 --- /dev/null +++ b/crates/poltertype-input/src/setup/linux.rs @@ -0,0 +1,229 @@ +//! What a Linux user has to grant, and whether they have. +//! +//! Only Wayland sessions have anything to grant. X11 needs no +//! permissions at all — XInput2 and XTest are available to any client +//! that can open the display — and saying so is part of the job: most +//! people arrive expecting the worst. + +use std::path::Path; + +use super::consts::{ + EVENT_DEVICE_DIR, INPUT_GROUP, PERMISSIONS_URL, SETUP_SCRIPT_COMMAND, UINPUT_DEVICE, +}; +use super::enums::{StepAction, StepState}; +use super::types::{SetupReport, SetupStep}; +use crate::linux::{SessionKind, session_kind}; + +pub(super) fn probe() -> SetupReport { + match session_kind() { + SessionKind::X11 => SetupReport { + backend: Some("linux-x11-xinput2".to_owned()), + steps: vec![SetupStep { + title: "Nothing to set up on X11".to_owned(), + detail: "X11 hands global key events to any client that can open the display, \ + so PolterType needs no group membership, no udev rule and no sudo here." + .to_owned(), + state: StepState::Done, + action: None, + }], + }, + SessionKind::Wayland | SessionKind::Unknown => wayland_report(), + } +} + +fn wayland_report() -> SetupReport { + // The two capabilities, probed independently: reading the keyboard + // and typing the correction are separate permissions and fail + // separately. A user with read access but no uinput sees detection + // work and nothing get fixed, which is the more confusing half. + let read = any_readable_event_device(Path::new(EVENT_DEVICE_DIR)); + let write = writable(Path::new(UINPUT_DEVICE)); + + // Only consulted when something is actually wrong: when both work, + // *how* the user got there is none of our business. + let group = group_state(); + + SetupReport { + backend: Some("linux-wayland-evdev".to_owned()), + steps: vec![ + step( + "Read the keyboard", + "PolterType watches key events straight from /dev/input, because Wayland \ + deliberately offers no way for one app to see another's keystrokes. \ + Read access only — nothing is written back to those devices.", + read, + group, + ), + step( + "Type the correction", + "Fixing a word means synthesising backspaces and letters through /dev/uinput, \ + a virtual keyboard the kernel creates for us. Without it PolterType can spot \ + the wrong layout but not repair it.", + write, + group, + ), + ], + } +} + +/// Turn "does this capability work" plus "what does the group database +/// say" into a state and the one useful next action. +fn step(title: &str, detail: &str, works: Option, group: GroupState) -> SetupStep { + let (state, action) = match (works, group) { + (Some(true), _) => (StepState::Done, None), + // The trap this whole state exists for: `usermod -aG input` + // updated the group database and cannot touch the credentials + // of an already-running session. Everything looks configured, + // nothing works, and re-running the script changes nothing — + // so telling the user to re-run it would waste their evening. + // No button: the fix is "log out", which we cannot do for + // them and a link cannot explain better than the one sentence + // the pane prints once for every step in this state. + (Some(false), GroupState::InDatabaseOnly) => (StepState::NeedsRelogin, None), + (Some(false), _) => ( + StepState::Todo, + Some(StepAction::Copy(SETUP_SCRIPT_COMMAND.to_owned())), + ), + (None, _) => ( + StepState::Unknown, + Some(StepAction::Open(PERMISSIONS_URL.to_owned())), + ), + }; + SetupStep { + title: title.to_owned(), + detail: detail.to_owned(), + state, + action, + } +} + +/// How the `input` group looks from the two places that disagree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GroupState { + /// The group database lists us *and* this session carries the gid. + Active, + /// Listed in `/etc/group`, absent from this session's credentials + /// — the log-out-and-back-in case. + InDatabaseOnly, + /// Not a member anywhere, or we could not tell. + Absent, +} + +fn group_state() -> GroupState { + let Some(gid) = input_group_gid() else { + return GroupState::Absent; + }; + // Safety: `getgid` takes no arguments and cannot fail. + let primary = unsafe { libc::getgid() }; + if primary == gid || session_groups().is_some_and(|gs| gs.contains(&gid)) { + return GroupState::Active; + } + if user_listed_in_input_group() { + return GroupState::InDatabaseOnly; + } + GroupState::Absent +} + +// ─── The probes themselves ──────────────────────────────────────────── + +/// `None` throughout this section means "could not tell" — a missing +/// directory, a read error. Never guessed at: a setup guide that +/// invents a problem is worse than one that admits ignorance. +fn any_readable_event_device(dir: &Path) -> Option { + let entries = std::fs::read_dir(dir).ok()?; + let mut saw_one = false; + for entry in entries.flatten() { + let path = entry.path(); + if !path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("event")) + { + continue; + } + saw_one = true; + if readable(&path) { + return Some(true); + } + } + // Devices exist and not one of them opens: a real, reportable no. + // No devices at all (a container, an odd kernel) is not something + // we can turn into advice. + saw_one.then_some(false) +} + +fn readable(path: &Path) -> bool { + std::fs::File::open(path).is_ok() +} + +/// A missing `/dev/uinput` counts as "not yet" rather than "cannot +/// tell": it means the kernel module is not loaded, which +/// `setup-linux.sh` also fixes, so the advice is the same. +fn writable(path: &Path) -> Option { + if !path.exists() { + return Some(false); + } + Some(std::fs::OpenOptions::new().write(true).open(path).is_ok()) +} + +/// gid of the `input` group from the group database. +fn input_group_gid() -> Option { + for line in std::fs::read_to_string("/etc/group").ok()?.lines() { + let mut parts = line.split(':'); + if parts.next()? != INPUT_GROUP { + continue; + } + let _passwd = parts.next()?; + return parts.next()?.parse().ok(); + } + None +} + +/// Is our user name in the `input` group's member list? +/// +/// Read from `/etc/group` rather than resolved through NSS: this is a +/// diagnostic, and the case it exists to catch — `usermod` has run, +/// the session predates it — is exactly a local-file edit. +fn user_listed_in_input_group() -> bool { + let Some(user) = current_user_name() else { + return false; + }; + let Ok(text) = std::fs::read_to_string("/etc/group") else { + return false; + }; + text.lines() + .filter(|l| l.starts_with(concat!("input", ":"))) + .any(|l| { + l.rsplit(':') + .next() + .is_some_and(|members| members.split(',').any(|m| m == user)) + }) +} + +fn current_user_name() -> Option { + std::env::var("USER") + .or_else(|_| std::env::var("LOGNAME")) + .ok() + .filter(|u| !u.is_empty()) +} + +/// The supplementary groups *this process* actually carries — the +/// half of the comparison that a `usermod` cannot change. +fn session_groups() -> Option> { + // Safety: the two-call form documented in getgroups(2). The first + // call (size 0) only reports how many there are and writes + // nothing, so the null pointer is what the manual asks for. + let count = unsafe { libc::getgroups(0, std::ptr::null_mut()) }; + if count < 0 { + return None; + } + let mut buf = vec![0 as libc::gid_t; count as usize]; + // Safety: `buf` has room for exactly `count` gids, which is what + // we tell the kernel. + let filled = unsafe { libc::getgroups(count, buf.as_mut_ptr()) }; + if filled < 0 { + return None; + } + buf.truncate(filled as usize); + Some(buf) +} diff --git a/crates/poltertype-input/src/setup/macos.rs b/crates/poltertype-input/src/setup/macos.rs new file mode 100644 index 0000000..440705a --- /dev/null +++ b/crates/poltertype-input/src/setup/macos.rs @@ -0,0 +1,145 @@ +//! What a macOS user has to grant, and whether they have. +//! +//! Two separate permissions that people routinely confuse, because +//! macOS shows them in adjacent rows of the same pane and neither +//! name says "keyboard": +//! +//! * **Accessibility** — required to create the `CGEventTap` we read +//! keys with, and to post the corrected ones back. +//! * **Input Monitoring** — required to *receive* key events from the +//! tap. Grant one without the other and the app looks half-alive. +//! +//! Both are checked without prompting. `AXIsProcessTrustedWithOptions` +//! shows the system dialog when asked to, and a guide that throws a +//! dialog every time the user presses *Check again* is a guide they +//! close. + +use core_foundation::base::TCFType; +use core_foundation::dictionary::{CFDictionary, CFDictionaryRef}; +use core_foundation::string::CFStringRef; + +use super::consts::{ACCESSIBILITY_PANE_URL, INPUT_MONITORING_PANE_URL}; +use super::enums::{Permission, StepAction, StepState}; +use super::types::{SetupReport, SetupStep}; + +#[link(name = "ApplicationServices", kind = "framework")] +unsafe extern "C" { + /// The no-argument form: reports trust and never prompts. The + /// options form below can do the same with an empty dictionary, + /// but an *empty* `CFDictionary::from_CFType_pairs(&[])` has no + /// key or value type to infer and does not compile — and reaching + /// for turbofish to build a dictionary we do not want is worse + /// than calling the function Apple provides for exactly this. + fn AXIsProcessTrusted() -> bool; + fn AXIsProcessTrustedWithOptions(options: CFDictionaryRef) -> bool; + static kAXTrustedCheckOptionPrompt: CFStringRef; +} + +// `IOHIDCheckAccess` / `IOHIDRequestAccess`, macOS 10.15+. Plain `//` +// because rustdoc generates nothing for an extern block and `-D +// warnings` rejects a doc comment that documents nothing. +// +// The request type we care about is `kIOHIDRequestTypeListenEvent` +// (1) — "may this process observe keystrokes", i.e. Input Monitoring. +// The returned `IOHIDAccessType` is 0 granted, 1 denied, 2 unknown, +// and we keep that third value rather than folding it into "denied": +// unknown means the system has not decided, which is a different +// sentence to write on screen. +#[link(name = "IOKit", kind = "framework")] +unsafe extern "C" { + fn IOHIDCheckAccess(request: u32) -> u32; + fn IOHIDRequestAccess(request: u32) -> bool; +} + +const K_IOHID_REQUEST_TYPE_LISTEN_EVENT: u32 = 1; +const K_IOHID_ACCESS_TYPE_GRANTED: u32 = 0; +const K_IOHID_ACCESS_TYPE_DENIED: u32 = 1; + +pub(super) fn probe() -> SetupReport { + SetupReport { + backend: Some("macos-cg-event-tap".to_owned()), + steps: vec![ + SetupStep { + title: "Grant Accessibility".to_owned(), + detail: "System Settings → Privacy & Security → Accessibility, then switch \ + PolterType on. This is what lets the app watch for a wrong-layout \ + word and type the corrected one back." + .to_owned(), + state: accessibility_state(), + action: Some(StepAction::RequestPermission(Permission::Accessibility)), + }, + SetupStep { + title: "Grant Input Monitoring".to_owned(), + detail: "System Settings → Privacy & Security → Input Monitoring, then switch \ + PolterType on. Separate from Accessibility and easy to miss — with \ + only one of the two granted the app starts but never sees a keystroke." + .to_owned(), + state: input_monitoring_state(), + action: Some(StepAction::RequestPermission(Permission::InputMonitoring)), + }, + SetupStep { + title: "Open the right pane".to_owned(), + detail: "Both switches live in Privacy & Security. If the buttons above don't \ + bring the window forward, these open the panes directly." + .to_owned(), + state: StepState::Unknown, + action: Some(StepAction::Open(ACCESSIBILITY_PANE_URL.to_owned())), + }, + ], + } +} + +fn accessibility_state() -> StepState { + // Safety: a nullary C call that reads the trust database. No + // prompt — a guide that throws a system dialog every time the user + // presses *Check again* is a guide they close. + if unsafe { AXIsProcessTrusted() } { + StepState::Done + } else { + StepState::Todo + } +} + +fn input_monitoring_state() -> StepState { + // Safety: a plain C call taking an integer request type. + match unsafe { IOHIDCheckAccess(K_IOHID_REQUEST_TYPE_LISTEN_EVENT) } { + K_IOHID_ACCESS_TYPE_GRANTED => StepState::Done, + K_IOHID_ACCESS_TYPE_DENIED => StepState::Todo, + _ => StepState::Unknown, + } +} + +/// Ask macOS to show its own permission dialog. Returns whether the +/// permission is granted *after* the call — the Accessibility prompt +/// is asynchronous, so a `false` there means "the user has been asked", +/// not "the user said no". +pub(super) fn request(permission: Permission) -> bool { + match permission { + Permission::Accessibility => { + // Safety: same call as above, with the prompt option set. + unsafe { + let key = core_foundation::string::CFString::wrap_under_get_rule( + kAXTrustedCheckOptionPrompt, + ); + let value = core_foundation::boolean::CFBoolean::true_value(); + let options = + CFDictionary::from_CFType_pairs(&[(key.as_CFType(), value.as_CFType())]); + AXIsProcessTrustedWithOptions(options.as_concrete_TypeRef()) + } + } + // Safety: a plain C call taking an integer request type. + Permission::InputMonitoring => unsafe { + IOHIDRequestAccess(K_IOHID_REQUEST_TYPE_LISTEN_EVENT) + }, + } +} + +/// The deep link for a permission, used when the system prompt has +/// already been answered once — macOS then never shows it again, and +/// the only way through is the Settings pane. +pub(super) fn settings_pane_url(permission: Permission) -> &'static str { + match permission { + Permission::Accessibility => ACCESSIBILITY_PANE_URL, + Permission::InputMonitoring => INPUT_MONITORING_PANE_URL, + } +} diff --git a/crates/poltertype-input/src/setup/mod.rs b/crates/poltertype-input/src/setup/mod.rs new file mode 100644 index 0000000..db48720 --- /dev/null +++ b/crates/poltertype-input/src/setup/mod.rs @@ -0,0 +1,107 @@ +//! The setup walkthrough's model: what the user still has to grant. +//! +//! When the keyboard hooks fail to start, the tray has always shown an +//! alert and a link to `docs/PERMISSIONS.md`. That stops the app +//! failing *silently*, and then leaves the user reading a markdown +//! file to fix their own machine. This module is the data the Settings +//! window's **Setup** pane renders instead: the same advice, but +//! specific to this OS, this session, and re-checkable after the user +//! has changed something. +//! +//! It lives here because probing input permissions is platform code, +//! and platform code lives in this crate (see the workspace +//! `CLAUDE.md`). The app renders [`SetupReport`]; it does not know +//! what a udev rule is. +//! +//! **Nothing here changes the system.** The most a step does is open a +//! documentation page or a System Settings pane, put a command on the +//! clipboard, or ask macOS to show its own permission dialog. The +//! Linux script needs `sudo`, and an app that quietly acquires root +//! has spent trust it will not get back — so the user runs it, in +//! their terminal, having read it. + +mod consts; +mod enums; +mod types; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; + +pub use enums::{Permission, StepAction, StepState}; +pub use types::{SetupReport, SetupStep}; + +/// Probe the current machine. Cheap enough to call on every *Check +/// again* click — a handful of `stat`s and one framework call — and +/// deliberately not cached, since the entire point is to notice that +/// the user just flipped a switch. +pub fn probe_setup() -> SetupReport { + #[cfg(target_os = "linux")] + { + linux::probe() + } + #[cfg(target_os = "macos")] + { + macos::probe() + } + #[cfg(windows)] + { + SetupReport { + backend: Some("windows-ll-hook".to_owned()), + steps: vec![SetupStep { + title: "Nothing to set up on Windows".to_owned(), + detail: "The low-level keyboard hook PolterType uses needs no permission and \ + no elevation — it works from a normal user account the moment the \ + app starts." + .to_owned(), + state: StepState::Done, + action: None, + }], + } + } + #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] + { + SetupReport { + backend: None, + steps: Vec::new(), + } + } +} + +/// Trigger the OS's own permission dialog (macOS only). +/// +/// Returns whether the permission is granted after the call. On +/// Accessibility that answer is usually `false` even when all is well: +/// the dialog is asynchronous, so the honest reading is "the user has +/// been asked", and the pane re-probes rather than believing this. +/// Everywhere else there is no such dialog and this is a no-op. +pub fn request_permission(permission: Permission) -> bool { + #[cfg(target_os = "macos")] + { + macos::request(permission) + } + #[cfg(not(target_os = "macos"))] + { + let _ = permission; + false + } +} + +/// Where to send a user whose system dialog will never appear again — +/// macOS shows each prompt once, and after that the only route is the +/// Settings pane itself. +pub fn permission_settings_url(permission: Permission) -> Option<&'static str> { + #[cfg(target_os = "macos")] + { + Some(macos::settings_pane_url(permission)) + } + #[cfg(not(target_os = "macos"))] + { + let _ = permission; + None + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/poltertype-input/src/setup/tests.rs b/crates/poltertype-input/src/setup/tests.rs new file mode 100644 index 0000000..2cb8786 --- /dev/null +++ b/crates/poltertype-input/src/setup/tests.rs @@ -0,0 +1,74 @@ +//! Tests for the setup probe. +//! +//! Deliberately about *shape*, not about this machine: a CI runner, a +//! developer laptop with the group already granted, and a fresh Wayland +//! install all give different answers, and any assertion about which +//! one is right would be an assertion about the test host. What must +//! hold everywhere is that the report is renderable and internally +//! consistent — the pane trusts both. + +use super::*; + +#[test] +fn the_probe_always_produces_something_renderable() { + let report = probe_setup(); + for step in &report.steps { + assert!(!step.title.is_empty(), "a step with no title renders blank"); + assert!( + !step.detail.is_empty(), + "step `{}` has no explanation — the whole point of the pane", + step.title + ); + } +} + +/// A step the user can do nothing about must not be presented as work. +/// The converse — a `Done` step with an action — is fine: macOS keeps +/// its "open the pane" button either way. +#[test] +fn every_actionable_step_says_what_to_do() { + for step in probe_setup().steps { + if matches!(step.state, StepState::Todo | StepState::NeedsRelogin) { + assert!( + step.action.is_some(), + "step `{}` tells the user they must act and gives them no way to", + step.title + ); + } + } +} + +#[test] +fn needs_attention_tracks_the_steps() { + let report = probe_setup(); + let unresolved = report + .steps + .iter() + .filter(|s| s.state != StepState::Done) + .count(); + assert_eq!(report.needs_attention(), unresolved > 0); +} + +/// The empty report is the one case where "nothing to show" and +/// "nothing wrong" must agree — an unsupported platform has no steps +/// and must not light the tray up with a warning. +#[test] +fn a_platform_with_no_steps_needs_no_attention() { + let empty = SetupReport { + backend: None, + steps: Vec::new(), + }; + assert!(!empty.needs_attention()); +} + +#[test] +fn requesting_a_permission_is_a_noop_where_there_is_no_dialog() { + // On macOS this would show a system dialog, so it is not called + // here; everywhere else the contract is "returns false, does + // nothing", which the Setup pane relies on to keep one code path. + #[cfg(not(target_os = "macos"))] + { + assert!(!request_permission(Permission::Accessibility)); + assert!(permission_settings_url(Permission::InputMonitoring).is_none()); + } +} diff --git a/crates/poltertype-input/src/setup/types.rs b/crates/poltertype-input/src/setup/types.rs new file mode 100644 index 0000000..967ca76 --- /dev/null +++ b/crates/poltertype-input/src/setup/types.rs @@ -0,0 +1,39 @@ +//! What the setup walkthrough shows the user. + +use super::enums::{StepAction, StepState}; + +/// One thing the user may have to do, and whether they have done it. +/// +/// Deliberately data, not widgets: the probe lives in this crate +/// because that is where the platform code is allowed to live, and the +/// Settings window renders whatever it is handed. That also makes the +/// per-OS logic testable without a GUI. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SetupStep { + /// Short imperative label — "Grant Accessibility", "Join the + /// `input` group". + pub title: String, + /// One or two sentences saying what this is for and what the user + /// will see. Written for someone who has never heard of evdev. + pub detail: String, + pub state: StepState, + /// The one thing the button on this row does, if there is one. + pub action: Option, +} + +/// The whole picture, re-probed every time the user asks. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SetupReport { + /// Which listener backend this session would use, for the log-ish + /// line at the bottom of the pane. `None` when no backend applies. + pub backend: Option, + pub steps: Vec, +} + +impl SetupReport { + /// True when at least one step is not satisfied — what the tray + /// alert and the pane's headline key off. + pub fn needs_attention(&self) -> bool { + self.steps.iter().any(|s| s.state != StepState::Done) + } +} diff --git a/crates/poltertype-input/src/windows.rs b/crates/poltertype-input/src/windows.rs index 659393d..146e80d 100644 --- a/crates/poltertype-input/src/windows.rs +++ b/crates/poltertype-input/src/windows.rs @@ -15,8 +15,8 @@ use tracing::{debug, error, info, warn}; use windows::Win32::Foundation::{HMODULE, LPARAM, LRESULT, WPARAM}; use windows::Win32::UI::Input::KeyboardAndMouse::{ GetAsyncKeyState, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBD_EVENT_FLAGS, KEYBDINPUT, - KEYEVENTF_KEYUP, KEYEVENTF_UNICODE, SendInput, VIRTUAL_KEY, VK_BACK, VK_CONTROL, VK_LMENU, - VK_LWIN, VK_MENU, VK_RMENU, VK_RWIN, VK_SHIFT, + KEYEVENTF_KEYUP, KEYEVENTF_UNICODE, SendInput, VIRTUAL_KEY, VK_BACK, VK_CONTROL, VK_LCONTROL, + VK_LMENU, VK_LSHIFT, VK_LWIN, VK_MENU, VK_RCONTROL, VK_RMENU, VK_RSHIFT, VK_RWIN, VK_SHIFT, }; use windows::Win32::UI::WindowsAndMessaging::{ CallNextHookEx, DispatchMessageW, GetMessageW, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, MSG, @@ -259,6 +259,35 @@ impl KeyEmitter for WindowsEmitter { send_inputs(&events) } + fn release_modifiers(&self, held: Modifiers) -> Result<(), InputError> { + // Both sides of each: `read_modifiers` reports "shift is down", + // not which shift, and `SendInput` of a key-up for a key that + // is already up is a no-op. + // + // This clears the *logical* modifier state applications read + // from the message queue, which is what decides whether our + // replay arrives as text or as a burst of shortcuts. The user's + // physical key stays down; their own release lands on an + // already-up key and is ignored, and we deliberately do not + // press the modifiers back — re-pressing one they have + // meanwhile let go of would leave it stuck down. + let mut events: Vec = Vec::new(); + for (down, keys) in [ + (held.control, [VK_LCONTROL, VK_RCONTROL].as_slice()), + (held.shift, [VK_LSHIFT, VK_RSHIFT].as_slice()), + (held.alt, [VK_LMENU, VK_RMENU].as_slice()), + (held.meta, [VK_LWIN, VK_RWIN].as_slice()), + ] { + if down { + events.extend(keys.iter().map(|&vk| make_vk_input(vk, true))); + } + } + if events.is_empty() { + return Ok(()); + } + send_inputs(&events) + } + fn backend_name(&self) -> &'static str { "windows-sendinput" } diff --git a/crates/poltertype-popup/src/factory.rs b/crates/poltertype-popup/src/factory.rs index 76d4657..0f865c8 100644 --- a/crates/poltertype-popup/src/factory.rs +++ b/crates/poltertype-popup/src/factory.rs @@ -10,11 +10,14 @@ use crate::traits::SuggestionPopup; /// Create the tooltip backend for this platform. `events` receives /// clicks and timeouts; the caller routes them to the engine. /// -/// Selection on Linux mirrors the input-listener probe order: -/// Wayland session → layer-shell (works on wlroots compositors — -/// Hyprland, Sway; GNOME/KDE expose no layer-shell to third-party -/// apps, detected at connect time → noop). X11 session → -/// override-redirect window. Everything else → noop. +/// Selection on Linux mirrors the input-listener probe order, and it +/// is a *probe*, not a lookup of desktop names — which matters, +/// because the names in the docs were wrong for two releases while the +/// probe was right. Wayland session → layer-shell (wlroots +/// compositors and KWin; Mutter has none, detected at connect time). +/// Then `DISPLAY` → override-redirect window, which on a GNOME Wayland +/// session means XWayland and still produces a visible tooltip. +/// Nothing left → noop. pub fn create_popup(events: Sender) -> Box { let popup = create_for_platform(events); info!(backend = popup.backend_name(), "suggestion popup backend"); diff --git a/crates/poltertype-popup/src/lib.rs b/crates/poltertype-popup/src/lib.rs index 59bd633..079c107 100644 --- a/crates/poltertype-popup/src/lib.rs +++ b/crates/poltertype-popup/src/lib.rs @@ -21,11 +21,17 @@ //! //! | Platform | Backend | Notes | //! |---|---|---| -//! | Wayland (wlroots: Hyprland, Sway, …) | layer-shell | primary target | +//! | Wayland — wlroots (Hyprland, Sway, …) **and KWin** | layer-shell | primary target; KWin verified 6.7.3 | //! | X11 | override-redirect window | zero-permission path | -//! | GNOME/KDE Wayland | noop | no layer-shell for 3rd-party apps | +//! | GNOME Wayland | override-redirect via XWayland | Mutter has no layer-shell; the X11 fallback still maps | +//! | Wayland with neither layer-shell nor XWayland | noop | the only remaining gap | //! | Windows / macOS | noop today | seam ready; see `docs/PLAN.md` | //! +//! The backends are *probed*, not selected from a table of desktop +//! names: layer-shell first, X11 second, noop last. That is why KDE +//! worked the whole time nobody claimed it did — and why a compositor +//! that gains layer-shell tomorrow needs no code change here. +//! //! This crate is one of the platform-code islands (see the workspace //! `CLAUDE.md` hard rules): `#[cfg(target_os)]` is allowed here and //! nowhere outside `poltertype-input` / `poltertype-layout` / diff --git a/crates/poltertype-popup/src/linux/wayland.rs b/crates/poltertype-popup/src/linux/wayland.rs index c1f40b4..57ae8f3 100644 --- a/crates/poltertype-popup/src/linux/wayland.rs +++ b/crates/poltertype-popup/src/linux/wayland.rs @@ -1,8 +1,12 @@ //! Wayland backend: a `wlr-layer-shell` overlay surface with //! `keyboard_interactivity = None`, so the popup can never steal the //! keys it exists to fix. Works on wlroots compositors (Hyprland, -//! Sway); GNOME/KDE expose no layer-shell to third parties — detected -//! at connect time so the factory can fall through to X11/noop. +//! Sway) **and on KWin** — KDE has implemented `zwlr_layer_shell_v1` +//! for years and hands it to third-party clients; verified against +//! KWin 6.7.3 (2026-07-31), where the surface configures and maps +//! exactly as it does on Hyprland. Mutter is the holdout +//! (GNOME/mutter#973, open since 2019), and its absence is detected at +//! connect time so the factory can fall through to X11/noop. //! //! All Wayland state lives on one dedicated thread; the public handle //! only pushes commands into a channel. The thread parks on the @@ -60,7 +64,7 @@ pub enum WaylandPopupError { Connect(#[from] ConnectError), #[error("wayland globals: {0}")] Globals(#[from] GlobalError), - #[error("compositor exposes no zwlr_layer_shell_v1 (GNOME/KDE)")] + #[error("compositor exposes no zwlr_layer_shell_v1 (GNOME/Mutter)")] NoLayerShell, #[error("spawn popup thread: {0}")] Spawn(std::io::Error), diff --git a/crates/poltertype-update/Cargo.toml b/crates/poltertype-update/Cargo.toml index 1e5a54b..a61814c 100644 --- a/crates/poltertype-update/Cargo.toml +++ b/crates/poltertype-update/Cargo.toml @@ -22,3 +22,6 @@ directories = { workspace = true } ureq = { workspace = true } sha2 = { workspace = true } semver = { workspace = true } + +ed25519-dalek = { workspace = true } +base64 = { workspace = true } diff --git a/crates/poltertype-update/release-signing-key.pub b/crates/poltertype-update/release-signing-key.pub new file mode 100644 index 0000000..ac64ee5 --- /dev/null +++ b/crates/poltertype-update/release-signing-key.pub @@ -0,0 +1 @@ +CY7BQyt8ExEiNgoV1iRPH3zNRmL8VyijRYRFTYZ1tYw= diff --git a/crates/poltertype-update/src/consts.rs b/crates/poltertype-update/src/consts.rs index 44c06c3..0b679b0 100644 --- a/crates/poltertype-update/src/consts.rs +++ b/crates/poltertype-update/src/consts.rs @@ -52,3 +52,40 @@ pub(crate) const MAX_INSTALL_ATTEMPTS: u32 = 3; /// an *older* app seeing a bumped number declines the update rather /// than guessing at fields it has never heard of. pub(crate) const SUPPORTED_SCHEMA: u32 = 1; + +/// Ed25519 public key the release manifest is checked against, base64 +/// of the raw 32 bytes. +/// +/// Compiled in, not fetched: a key the updater downloads is a key an +/// attacker can replace. Rotating it therefore means shipping a +/// release — which is the point, since the release binary is the thing +/// the user already decided to trust. The private half lives on the +/// maintainer's machine and never enters CI; see `docs/RELEASING.md`. +pub(crate) const TRUSTED_PUBLIC_KEY: &str = include_str!("../release-signing-key.pub"); + +/// First line of the signed payload. Domain-separates our signatures: +/// a signature made over some other document with this key can never +/// be replayed as a manifest signature. +pub(crate) const PAYLOAD_HEADER: &str = "poltertype-manifest-v1"; + +/// Whether a manifest without a signature is refused. +/// +/// **The staged rollout, in two releases.** Signing and verifying land +/// together, but they cannot become mandatory in the same release: a +/// user running 0.6.4 would be checking a manifest published before +/// anyone signed one. So: +/// +/// 1. **`false` (now).** A signature that is *present* must verify — a +/// wrong one is refused, loudly. A missing one is accepted with a +/// warning. This is the release in which signed manifests start +/// being published. +/// 2. **`true` (a later release).** Flip this the moment the manifest +/// that `releases/latest/download/latest.json` resolves to is +/// signed, and has been for a full release cycle. From then on an +/// unsigned manifest is an error, and an attacker who can publish a +/// GitHub release can no longer publish an update. +/// +/// Flipping it early strands every user on a "cannot update" error +/// until the next release is published *and* signed. Flipping it never +/// means the signature is decorative. +pub(crate) const REQUIRE_SIGNATURE: bool = false; diff --git a/crates/poltertype-update/src/enums.rs b/crates/poltertype-update/src/enums.rs index 3070e92..3a248e9 100644 --- a/crates/poltertype-update/src/enums.rs +++ b/crates/poltertype-update/src/enums.rs @@ -19,6 +19,26 @@ pub enum UpdateError { UnsupportedSchema { found: u32, supported: u32 }, #[error("manifest has no artifact for this platform ({0})")] NoArtifactForPlatform(String), + /// The manifest is signed, but not by us — or not over these + /// bytes. Indistinguishable from tampering, and treated as such: + /// the check stops before any URL in the manifest is read. + #[error("manifest signature does not verify: {0}")] + BadSignature(String), + /// No `signature` field, in a build that requires one. See + /// `consts::REQUIRE_SIGNATURE` for the rollout this belongs to. + #[error("release manifest is unsigned and this build requires a signature")] + UnsignedManifest, + /// A field contains a newline, so the signed payload it renders to + /// would be ambiguous — two different manifests could produce the + /// same bytes. Refusing is the only safe answer; see + /// `signature.rs`. + #[error("manifest field `{0}` contains a line break and cannot be signed or verified")] + UnsignablePayload(String), + /// The public key compiled into this binary is not a usable + /// ed25519 key. Only reachable if `release-signing-key.pub` was + /// corrupted in the build, never by anything a server said. + #[error("the signing key built into this binary is unusable: {0}")] + TrustedKeyBroken(String), #[error("manifest version `{0}` is not valid semver: {1}")] BadVersion(String, semver::Error), /// The bytes we got are not the bytes the release promised. Either diff --git a/crates/poltertype-update/src/lib.rs b/crates/poltertype-update/src/lib.rs index f5c5ec9..ab3edd9 100644 --- a/crates/poltertype-update/src/lib.rs +++ b/crates/poltertype-update/src/lib.rs @@ -31,10 +31,19 @@ //! The SHA-256 comes from the same release as the artifact, so it //! catches truncated downloads and a tampered *asset* CDN, but it does //! **not** defend against a compromised GitHub account: whoever can -//! publish a release can publish a matching checksum. [`types::Manifest`] -//! therefore carries an optional `signature` field, reserved for a -//! detached ed25519 signature over the manifest bytes, so that defence -//! can be added without a schema break. See `docs/DECISIONS.md`. +//! publish a release can publish a matching checksum. That is what the +//! manifest's `signature` field is for — a detached ed25519 signature +//! made on the maintainer's machine, with a key CI never sees, checked +//! against a public key compiled into this binary ([`signature`]). +//! It is verified the moment the manifest is parsed, before any URL in +//! it is read. +//! +//! Signatures are **not yet mandatory**: `consts::REQUIRE_SIGNATURE` +//! gates that, and flipping it is a deliberate second step once signed +//! releases are the ones users' updaters resolve to. Until then a +//! present signature must verify and a missing one only warns — so do +//! not describe the updater as "signed" anywhere user-facing until the +//! flip has happened. See `docs/DECISIONS.md`. #![forbid(unsafe_code)] #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] @@ -45,6 +54,7 @@ mod consts; mod download; mod enums; mod manifest; +mod signature; mod staging; mod types; mod version; @@ -54,6 +64,7 @@ pub use check::{check_and_stage, current_version}; pub use consts::MANIFEST_URL; pub use enums::UpdateError; pub use manifest::platform_key; +pub use signature::signing_payload; pub use staging::{clear_pending, read_pending}; pub use types::{Artifact, Manifest, PendingUpdate}; pub use version::is_newer; diff --git a/crates/poltertype-update/src/manifest.rs b/crates/poltertype-update/src/manifest.rs index f1f5adf..24369a2 100644 --- a/crates/poltertype-update/src/manifest.rs +++ b/crates/poltertype-update/src/manifest.rs @@ -13,11 +13,11 @@ use crate::types::{Artifact, Manifest}; /// /// These strings are the manifest's map keys — they must match what /// `.github/workflows/release.yml` writes. Deliberately coarse: we ship -/// one AppImage (x86_64), one universal DMG, one MSI (x86_64), so the -/// key space is exactly as wide as the release matrix and no wider. -/// An architecture we don't publish for resolves to a key that simply -/// isn't in the map, and the check ends with "no update for you" -/// rather than a wrong download. +/// two AppImages (x86_64, aarch64), one universal DMG and one MSI +/// (x86_64), so the key space is exactly as wide as the release matrix +/// and no wider. An architecture we don't publish for resolves to a key +/// that simply isn't in the map, and the check ends with "no update for +/// you" rather than a wrong download. pub fn platform_key() -> String { let os = if cfg!(target_os = "windows") { "windows" @@ -74,6 +74,9 @@ pub(crate) fn fetch() -> Result { supported: SUPPORTED_SCHEMA, }); } + // Before anything reads a URL out of this: a manifest we cannot + // authenticate decides nothing here. + crate::signature::verify(&manifest)?; debug!( version = %manifest.version, schema = manifest.schema, diff --git a/crates/poltertype-update/src/signature.rs b/crates/poltertype-update/src/signature.rs new file mode 100644 index 0000000..469ff60 --- /dev/null +++ b/crates/poltertype-update/src/signature.rs @@ -0,0 +1,150 @@ +//! Ed25519 signature over the release manifest. +//! +//! ## What this is for +//! +//! The per-artifact SHA-256 in `latest.json` proves the bytes we +//! downloaded are the bytes the manifest names. It proves nothing about +//! who wrote the manifest — it lives in the same GitHub release as the +//! artifact, so whoever can publish one can publish both. The signature +//! is the part that does not come from GitHub: it is made on the +//! maintainer's machine, with a key that never touches CI, and verified +//! against a public key compiled into this binary. +//! +//! ## What is signed +//! +//! Not the JSON. Signing raw JSON would make the check hostage to +//! formatting — a re-serialisation with different whitespace or key +//! order breaks a valid signature, and any "canonical JSON" scheme is a +//! second specification to get wrong. Instead we sign a flat, +//! newline-delimited rendering of the fields that carry meaning: +//! +//! ```text +//! poltertype-manifest-v1 +//! schema=1 +//! version=0.6.4 +//! notes_url=https://github.com/…/releases/tag/v0.6.4 +//! artifact=linux-x86_64 +//! url=https://github.com/…/poltertype-0.6.4-x86_64.AppImage +//! sha256=9f86d081… +//! size=28311552 +//! artifact=macos-universal +//! … +//! ``` +//! +//! Artifacts are ordered by key so a `HashMap` cannot change the +//! payload, and every line ends with `\n` including the last. Because +//! `\n` is the only separator, a value containing one could forge a +//! different manifest with the same payload — so any value carrying +//! `\n` or `\r` is rejected outright, on both the signing and the +//! verifying side. That is the whole ambiguity surface of the format. +//! +//! ## Rollout +//! +//! [`consts::REQUIRE_SIGNATURE`](crate::consts) is the switch. While it +//! is `false`, an unsigned manifest is accepted with a warning and a +//! *present* signature must still verify — that is the release where +//! signing starts happening without stranding anyone. Flipping it to +//! `true` makes an unsigned manifest an error, and can only be done +//! once a signed release is the one users' updaters will see. + +use std::collections::BTreeMap; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use ed25519_dalek::{Signature, VerifyingKey}; +use tracing::{debug, warn}; + +use crate::consts::{PAYLOAD_HEADER, REQUIRE_SIGNATURE, TRUSTED_PUBLIC_KEY}; +use crate::enums::UpdateError; +use crate::types::Manifest; + +/// Render the bytes a manifest signature covers. +/// +/// Fails rather than emitting an ambiguous payload — see the module +/// docs for why a newline in a value is fatal. +pub fn signing_payload(manifest: &Manifest) -> Result, UpdateError> { + let mut out = String::from(PAYLOAD_HEADER); + out.push('\n'); + push_field(&mut out, "schema", &manifest.schema.to_string())?; + push_field(&mut out, "version", &manifest.version)?; + push_field(&mut out, "notes_url", &manifest.notes_url)?; + + // BTreeMap, not the manifest's HashMap: iteration order is part of + // what is signed, so it has to be the key order and nothing else. + let sorted: BTreeMap<_, _> = manifest.artifacts.iter().collect(); + for (key, artifact) in sorted { + push_field(&mut out, "artifact", key)?; + push_field(&mut out, "url", &artifact.url)?; + push_field(&mut out, "sha256", &artifact.sha256)?; + push_field(&mut out, "size", &artifact.size.to_string())?; + } + Ok(out.into_bytes()) +} + +fn push_field(out: &mut String, name: &str, value: &str) -> Result<(), UpdateError> { + if value.contains('\n') || value.contains('\r') { + return Err(UpdateError::UnsignablePayload(name.to_owned())); + } + out.push_str(name); + out.push('='); + out.push_str(value); + out.push('\n'); + Ok(()) +} + +/// The key this build trusts, decoded from the checked-in +/// `release-signing-key.pub`. +pub(crate) fn trusted_key() -> Result { + let raw = BASE64 + .decode(TRUSTED_PUBLIC_KEY.trim()) + .map_err(|e| UpdateError::TrustedKeyBroken(e.to_string()))?; + let bytes: [u8; 32] = raw + .as_slice() + .try_into() + .map_err(|_| UpdateError::TrustedKeyBroken(format!("{} bytes, want 32", raw.len())))?; + VerifyingKey::from_bytes(&bytes).map_err(|e| UpdateError::TrustedKeyBroken(e.to_string())) +} + +/// Check the manifest against the baked-in public key. +/// +/// Called before anything reads a URL out of the manifest, so that +/// every decision downstream — which version, which artifact, which +/// host to fetch it from — rests on bytes we have authenticated. +pub(crate) fn verify(manifest: &Manifest) -> Result<(), UpdateError> { + verify_with(manifest, &trusted_key()?) +} + +/// [`verify`] against an explicit key. +/// +/// Exists so the tests can exercise the real accept/reject path with a +/// key they own — the private half of the shipped one is on the +/// maintainer's machine and, by design, nowhere near this repository. +pub(crate) fn verify_with(manifest: &Manifest, key: &VerifyingKey) -> Result<(), UpdateError> { + let Some(encoded) = manifest.signature.as_deref() else { + if REQUIRE_SIGNATURE { + return Err(UpdateError::UnsignedManifest); + } + // Not an error yet, but it is the thing that would become one: + // say so at a level a bug report will carry. + warn!( + "release manifest carries no signature — accepted because this build \ + predates mandatory signing" + ); + return Ok(()); + }; + + let raw = BASE64 + .decode(encoded.trim()) + .map_err(|e| UpdateError::BadSignature(e.to_string()))?; + let bytes: [u8; 64] = raw + .as_slice() + .try_into() + .map_err(|_| UpdateError::BadSignature(format!("{} bytes, want 64", raw.len())))?; + let signature = Signature::from_bytes(&bytes); + + let payload = signing_payload(manifest)?; + key.verify_strict(&payload, &signature) + .map_err(|e| UpdateError::BadSignature(e.to_string()))?; + debug!("release manifest signature verified"); + Ok(()) +} diff --git a/crates/poltertype-update/src/tests.rs b/crates/poltertype-update/src/tests.rs index 37226ea..f4b0fab 100644 --- a/crates/poltertype-update/src/tests.rs +++ b/crates/poltertype-update/src/tests.rs @@ -110,11 +110,190 @@ fn a_signed_manifest_still_parses_in_an_unsigning_build() { assert_eq!(m.signature.as_deref(), Some("abc123")); } +// ─── Manifest signature ─────────────────────────────────────────────── + +/// A keypair the tests own. The shipped public key's private half +/// lives on the maintainer's machine and must never be reachable from +/// here, so everything except "is the shipped key well-formed" is +/// exercised with this one. +fn test_keypair() -> (ed25519_dalek::SigningKey, ed25519_dalek::VerifyingKey) { + let signing = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); + let verifying = signing.verifying_key(); + (signing, verifying) +} + +fn sign(manifest: &mut Manifest, key: &ed25519_dalek::SigningKey) { + use base64::Engine; + use ed25519_dalek::Signer; + let payload = crate::signature::signing_payload(manifest).unwrap(); + let sig = key.sign(&payload); + manifest.signature = Some(base64::engine::general_purpose::STANDARD.encode(sig.to_bytes())); +} + +fn sample() -> Manifest { + serde_json::from_str(SAMPLE).unwrap() +} + +/// The key we ship has to be a key. Nothing at runtime can tell us +/// otherwise in a useful way — a broken one turns every update check +/// into `TrustedKeyBroken` — so it is checked at build time here. +#[test] +fn the_public_key_built_into_this_binary_is_usable() { + crate::signature::trusted_key().expect("release-signing-key.pub must be a valid ed25519 key"); +} + +/// The exact bytes that get signed. Pinned deliberately: changing this +/// rendering invalidates every signature ever made, so it must be a +/// decision someone takes on purpose and not a side effect of tidying +/// `signing_payload`. If this test fails, the format changed — bump +/// the header string and plan a rollout, don't update the expectation. +#[test] +fn the_signed_payload_has_exactly_this_shape() { + let payload = crate::signature::signing_payload(&sample()).unwrap(); + assert_eq!( + String::from_utf8(payload).unwrap(), + "poltertype-manifest-v1\n\ + schema=1\n\ + version=0.4.0\n\ + notes_url=https://github.com/Just-Code-NET/PolterType/releases/tag/v0.4.0\n\ + artifact=linux-x86_64\n\ + url=https://github.com/Just-Code-NET/PolterType/releases/download/v0.4.0/poltertype-0.4.0-x86_64.AppImage\n\ + sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08\n\ + size=28311552\n" + ); +} + +/// `artifacts` is a `HashMap`, whose iteration order is randomised per +/// process. If that order reached the payload, a manifest would verify +/// or not depending on the run. +#[test] +fn artifact_order_cannot_change_the_payload() { + let mut a = sample(); + a.artifacts.insert( + "windows-x86_64".to_owned(), + Artifact { + url: "https://example.com/a.msi".to_owned(), + sha256: "0".repeat(64), + size: 1, + }, + ); + a.artifacts.insert( + "macos-universal".to_owned(), + Artifact { + url: "https://example.com/a.dmg".to_owned(), + sha256: "1".repeat(64), + size: 2, + }, + ); + + // Same content, inserted in the opposite order. + let mut b = sample(); + let win = a.artifacts["windows-x86_64"].clone(); + let mac = a.artifacts["macos-universal"].clone(); + b.artifacts.insert("macos-universal".to_owned(), mac); + b.artifacts.insert("windows-x86_64".to_owned(), win); + + assert_eq!( + crate::signature::signing_payload(&a).unwrap(), + crate::signature::signing_payload(&b).unwrap() + ); +} + +#[test] +fn a_manifest_signed_with_the_right_key_verifies() { + let (signing, verifying) = test_keypair(); + let mut m = sample(); + sign(&mut m, &signing); + crate::signature::verify_with(&m, &verifying).expect("our own signature must verify"); +} + +/// The attack the signature exists to stop: someone who can publish a +/// GitHub release swaps the download URL (and its matching checksum, +/// which they can also compute) for one of their own. +#[test] +fn swapping_the_download_url_invalidates_the_signature() { + let (signing, verifying) = test_keypair(); + let mut m = sample(); + sign(&mut m, &signing); + + let artifact = m.artifacts.get_mut("linux-x86_64").unwrap(); + artifact.url = "https://evil.example/poltertype-0.4.0-x86_64.AppImage".to_owned(); + artifact.sha256 = "f".repeat(64); + + let err = crate::signature::verify_with(&m, &verifying) + .expect_err("a re-pointed artifact must not verify"); + assert!(matches!(err, UpdateError::BadSignature(_)), "{err:?}"); +} + +#[test] +fn a_signature_from_another_key_is_refused() { + let (_, verifying) = test_keypair(); + let attacker = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]); + let mut m = sample(); + sign(&mut m, &attacker); + + let err = crate::signature::verify_with(&m, &verifying) + .expect_err("a stranger's signature must not verify"); + assert!(matches!(err, UpdateError::BadSignature(_)), "{err:?}"); +} + +#[test] +fn a_signature_that_is_not_even_base64_is_an_error_not_a_panic() { + let (_, verifying) = test_keypair(); + for junk in ["", "not base64!!", "YWJj"] { + let mut m = sample(); + m.signature = Some(junk.to_owned()); + let err = crate::signature::verify_with(&m, &verifying).expect_err("must reject `{junk}`"); + assert!(matches!(err, UpdateError::BadSignature(_)), "{err:?}"); + } +} + +/// `\n` is the payload's only separator, so a value containing one +/// could describe two different manifests with the same bytes. Both +/// sides refuse rather than sign or accept something ambiguous. +#[test] +fn a_line_break_in_a_field_is_refused_instead_of_signed() { + let mut m = sample(); + m.version = "0.4.0\nversion=9.9.9".to_owned(); + let err = crate::signature::signing_payload(&m).expect_err("must refuse an ambiguous payload"); + assert!( + matches!(err, UpdateError::UnsignablePayload(f) if f == "version"), + "wrong field blamed" + ); + + let mut m = sample(); + m.artifacts.get_mut("linux-x86_64").unwrap().url = "https://ok/\nsize=1".to_owned(); + let err = crate::signature::signing_payload(&m).expect_err("must refuse an ambiguous payload"); + assert!(matches!(err, UpdateError::UnsignablePayload(f) if f == "url")); +} + +/// The rollout hinge. While `REQUIRE_SIGNATURE` is false an unsigned +/// manifest still works — every user on an older release depends on +/// that — but a *wrong* signature is refused from day one. +#[test] +fn an_unsigned_manifest_is_accepted_only_while_signing_is_optional() { + let (_, verifying) = test_keypair(); + let m = sample(); + assert!(m.signature.is_none()); + let result = crate::signature::verify_with(&m, &verifying); + if crate::consts::REQUIRE_SIGNATURE { + assert!(matches!(result, Err(UpdateError::UnsignedManifest))); + } else { + assert!(result.is_ok(), "{result:?}"); + } +} + #[test] fn platform_key_matches_a_key_the_release_workflow_publishes() { let key = platform_key(); assert!( - ["windows-x86_64", "macos-universal", "linux-x86_64"].contains(&key.as_str()), + [ + "windows-x86_64", + "macos-universal", + "linux-x86_64", + "linux-aarch64", + ] + .contains(&key.as_str()), "platform_key() produced `{key}`, which release.yml does not publish an artifact for" ); } diff --git a/crates/poltertype-update/src/types.rs b/crates/poltertype-update/src/types.rs index 744645c..6b0bbf9 100644 --- a/crates/poltertype-update/src/types.rs +++ b/crates/poltertype-update/src/types.rs @@ -35,9 +35,9 @@ pub struct Manifest { /// browser. pub notes_url: String, /// Keyed by [`crate::platform_key`]: `windows-x86_64`, - /// `macos-universal`, `linux-x86_64`. A platform missing from the - /// map simply gets no update — that is how we ship a release that - /// deliberately skips an OS. + /// `macos-universal`, `linux-x86_64`, `linux-aarch64`. A platform + /// missing from the map simply gets no update — that is how we ship + /// a release that deliberately skips an OS. pub artifacts: HashMap, /// Reserved for a detached ed25519 signature over the manifest /// bytes. Absent today: the current trust model is HTTPS plus the diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 9f3506c..0a82f3c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6,6 +6,313 @@ and any **alternatives** considered. --- +## 2026-07-31 — The setup walkthrough probes, and refuses to act on the user's behalf + +Replacing "here is a link to PERMISSIONS.md" with a screen that knows +what this machine is missing. Four choices worth defending. + +**The probe lives in `poltertype-input`, the rendering in the app.** +"Is the user in the `input` group" is platform code, and platform code +lives in the seven crates that are allowed to hold `#[cfg(target_os)]`. +The Settings window is handed a `SetupReport` — a list of steps with a +state and at most one action — and knows nothing about udev rules. It +also means the per-OS logic has tests without a GUI in the loop. + +**Nothing on the pane runs anything privileged.** The Linux fix needs +`sudo`, and the obvious "Run setup" button would mean an app quietly +acquiring root on a machine where it already reads every keystroke. +That is trust we would not get back. The button copies the command +instead; the user reads it and runs it in their own terminal. +Similarly, macOS permission prompts are always the *system's* dialog +(`AXIsProcessTrustedWithOptions`, `IOHIDRequestAccess`) — we never +draw something that looks like one. + +**Four states, not two.** `Done` / `Todo` would have been enough to +render a tick and a cross, and would have given the wrong advice in +the case that actually costs people an evening: `usermod -aG input` +writes the group database and cannot touch the credentials of a +session that already exists. Everything looks configured, nothing +works, and "re-run the setup script" — the only advice a two-state +model can give — changes nothing. `NeedsRelogin` exists to say *log +out* instead. The fourth, `Unknown`, is for what we genuinely cannot +determine (no `/dev/input` entries at all, an `IOHIDCheckAccess` that +returns "undecided"): a setup guide that invents a problem loses the +reader, and one that asserts a fix it has not verified is worse. + +**Read and write are separate rows.** They are separate permissions +and they fail separately, and the half-granted case — evdev readable, +uinput not — is the confusing one: detection works, corrections never +land, and the app looks like it is deciding wrongly rather than being +unable to act. + +*Not built:* the screenshots and GIFs of the macOS toggles that issue +#10 asked for. They would be the most useful part of that pane for a +first-time Mac user and they cannot be produced from a machine without +a Mac. The deep links into the exact System Settings panes are the +half we could do honestly. + +*Untested on hardware.* Verified on Wayland/evdev here, including the +unresolved states. The macOS half compiles in CI and has never run. + +--- + +## 2026-07-31 — The tooltip already worked on KDE; only the documentation said otherwise + +Issue #6 asked for a plan to bring the suggestion tooltip to GNOME and +KDE Wayland, where it was documented as a no-op. Before designing +anything, we ran the actual backend against an actual KWin. + +**KDE was never broken.** KWin has implemented `zwlr_layer_shell_v1` +for years and hands it to third-party clients. Against a nested +`kwin_wayland` 6.7.3 the popup crate selects +`linux-wayland-layer-shell` and logs `popup surface mapped` — the +surface is created, configured by KWin and mapped, exactly as on +Hyprland. No code was needed. The claim that it didn't work appeared +in five places (`poltertype-popup`'s crate docs, its factory, the +Wayland backend's module docs, the `NoLayerShell` error *message*, and +`CLAUDE.md`'s known-gaps list) and had been wrong since it was +written. + +The lesson is not "KDE works". It is that **the backend is a probe and +the documentation was a lookup table.** `create_popup` tries +layer-shell, then X11, then noop, and never asks what desktop it is +on; the prose asserted a mapping from desktop names to outcomes that +nothing in the code computed. A probe stays right when the world +changes underneath it. A hand-maintained list of desktop names is +wrong the moment one of them ships a protocol, and nothing fails to +tell you. + +**GNOME is not a no-op either.** Mutter genuinely has no layer-shell +(GNOME/mutter#973, open since 2019 and not implemented). But the +factory's second probe is `DISPLAY`, which on a GNOME Wayland session +points at XWayland — and an override-redirect X11 window maps and +displays there. Verified in the shape that matters: forcing the +Wayland probe to fail in a live Wayland session selects +`linux-x11-override-redirect` and logs `popup window mapped`. So the +honest gap is not "GNOME and KDE"; it is "a Wayland session with +neither layer-shell nor XWayland", which is rare and getting rarer. + +**What actually was missing on both, and is now fixed: the anchor.** +`create_linux_focus_tracker` returned `NoopFocusTracker` on anything +that is not Hyprland or X11, so the tooltip had no window rect *and* +no caret and fell back to screen-bottom-centre. The window rect is +genuinely unavailable there. The **caret is not**: the AT-SPI watcher +talks to the session bus and does not care which compositor is +running. It was simply never constructed, because it was built inside +the two branches that also had a window query. A `CaretOnlyFocusTracker` +now takes that path — `focused_exe()` stays `None`, so nothing keyed +off the focused app starts guessing, while `caret_hint()` gives +GNOME and KDE the *best* anchor in the chain rather than the worst. + +*Not attempted:* a GNOME Shell extension talking to the app over +D-Bus. It would buy the window rect (a worse anchor than the caret we +now have) at the cost of a second distribution channel, a review +process, and a component that breaks on every GNOME release. Should +Mutter ever implement layer-shell, the existing probe picks it up with +no code change. + +--- + +## 2026-07-31 — No Flatpak: `uinput` is not grantable, and the holes that would fake it are the ones Flathub rejects + +Asked and answered before anyone spends a weekend on it. The question +was not "can we build a Flatpak" — anything can be built — but "can +PolterType work inside the sandbox without punching holes so wide the +listing gets rejected, or so wide that the sandbox stops meaning +anything." **No, on both counts.** AppImage plus native packages is +the honest Linux story. + +**What we need from the host.** + +| Need | Where | Sandbox status | +|---|---|---| +| Read `/dev/input/event*` | evdev listener | `--device=input` (flatpak ≥ 1.15.6) | +| Write `/dev/uinput` | the emitter — *every correction* | **not grantable except via `--device=all`** | +| Grab the keyboard (`EVIOCGRAB`) | the key gate | same, and only via `--device=all` | +| `hyprctl` / Hyprland IPC socket | layout switching on wlroots | host binary + `$XDG_RUNTIME_DIR/hypr` socket | +| `gsettings` | GNOME layout switching | host binary + dconf | +| `qdbus` / `qdbus6` | KDE layout switching | host binary + session bus name | +| `ibus`, `fcitx5-remote` | IME layout switching | host binaries | +| AT-SPI bus | caret position for the tooltip | `--talk-name=org.a11y.Bus` | +| `~/.config/autostart/*.desktop` | run at login | portal exists — the one clean case | + +**The blocker is `uinput`, and it is a hard one.** `device=input` was +introduced for game controllers and *deliberately does not include +`/dev/uinput`*: the original patch carried `/dev/uinput` and +`/dev/hidraw*` and that part was removed before it landed +([Flathub discourse][fh-input]). So the only permission that gets us a +virtual keyboard is `--device=all`, which is the whole device tree — +webcam, disks, everything. Flathub's own requirement is that "static +permissions must be kept to an absolute minimum" and that a portal, if +one exists, is mandatory rather than optional +([Flathub requirements][fh-req]); there is no portal for creating a +virtual input device. Without `uinput` PolterType cannot type, which +is the entire product. + +**The precedent points the same way.** input-remapper's Flathub +request was closed as technically infeasible, on exactly this +reasoning — the sandbox is above the layer this class of app has to +operate at ([Flathub discourse][fh-remap]). We are in that class. + +**Even granting everything, the DE integration would still be +broken.** Layout switching shells out to `hyprctl`, `gsettings`, +`qdbus`, `ibus` and `fcitx5-remote` — host binaries that do not exist +inside the runtime. `flatpak-spawn --host` would work and is exactly +"disabling or bypassing security mechanisms" in Flathub's words. A +Flatpak that needed `--device=all` *and* host command execution is not +a sandboxed app; it is an AppImage with extra steps and a misleading +padlock. + +**What a Flatpak would genuinely improve** is autostart: +`org.freedesktop.portal.Background` is a cleaner mechanism than the +XDG `.desktop` file `poltertype-autostart` writes today. One clean win +against a load-bearing blocker is not a trade. + +**Verified, not assumed:** the self-updater already stands down inside +a Flatpak, and by the right mechanism. `apply/linux.rs` requires +`$APPIMAGE` to be set and non-empty — an allowlist, not a denylist of +`$FLATPAK_ID` and friends — so any install that is not our own +AppImage refuses and the user is pointed at the Releases page. Nothing +to change there whatever we decide. (It still *downloads* before +discovering it cannot install, which is a small waste for every +packaged user and worth fixing on its own merits, independently of +this decision.) + +**Revisit if** a portal for virtual input devices appears (a +`org.freedesktop.portal.InputCapture`-shaped API that covers *emitting* +rather than only capturing), or if `device=input` grows `/dev/uinput` +back. Both would have to land *and* be widely deployed before the +listing would be worth the maintenance. + +[fh-req]: https://docs.flathub.org/docs/for-app-authors/requirements +[fh-input]: https://discourse.flathub.org/t/support-for-device-input/6645 +[fh-remap]: https://discourse.flathub.org/t/input-remapper-flatpak-request/3814 + +--- + +## 2026-07-31 — The release manifest is signed by a human, not by CI + +The updater verified each download's SHA-256 against `latest.json` and +nothing verified `latest.json`. Since the checksum ships in the same +GitHub release as the artifact, anyone who could publish a release +could publish both — the checksum bought integrity against a broken +transfer or a tampered CDN, and nothing against the attacker who +actually matters. + +**`Manifest.signature` is now a real detached ed25519 signature**, +verified the moment the manifest parses and before any URL in it is +read. Three choices inside that are worth writing down. + +**The key never enters CI.** The obvious implementation is an Actions +secret and a signing step in `release.yml`. It would also have been +security theatre: the threat model is "someone can publish a GitHub +release", and someone who can do that can read the repository's +secrets. Signing therefore happens on the maintainer's machine, +between the draft CI produces and the moment a human publishes it +(`cargo xtask manifest sign`, `docs/RELEASING.md` §7). The cost is a +manual step that can be forgotten; the mitigation is that the workflow +summary and the release checklist both spell it out, and that a +forgotten signature degrades to today's behaviour rather than breaking +anything. + +**We sign a rendering, not the JSON.** Signing raw JSON makes the +check hostage to formatting — re-serialise with different whitespace +or key order and a valid signature stops verifying — and "canonical +JSON" is a second specification to get wrong. So the signature covers +a flat, newline-delimited rendering of the meaningful fields +(`crates/poltertype-update/src/signature.rs`), artifacts ordered by +key so a `HashMap`'s iteration order cannot leak in. Since `\n` is the +only separator, a value containing one could describe two different +manifests with the same bytes — both the signer and the verifier +refuse such a manifest outright, which is the format's entire +ambiguity surface. One function renders it and both ends call it: +`xtask` depends on `poltertype-update` precisely so there is no second +implementation to drift. + +*Alternative considered:* publish `latest.json.sig` as a second asset +and sign the file bytes verbatim. Simpler to reason about, no +canonicalisation at all — but a second network request on every update +check, and a manifest whose signature can be dropped without the +manifest looking any different. + +**Verification lands one release before enforcement.** +`REQUIRE_SIGNATURE` is `false` in the release that introduces all of +this. A present signature must verify; an absent one warns. Shipping +it as `true` would strand every user whose updater resolves to the +last unsigned manifest — including the one published before anyone +had the tooling. It gets flipped once a signed manifest has been the +published `latest.json` for a full cycle, and that flip, not this +release, is when the README gets to say "signed". + +--- + +## 2026-07-31 — macOS subscribes to `FlagsChanged`, and clears flags on everything it posts + +Two macOS gaps that turned out to be one story. + +**The dead arms (issue #4).** The event tap subscribed to `KeyDown` and +`KeyUp` only. macOS never sends those for a modifier — a Shift press is +a `kCGEventFlagsChanged` event (type 12) — so the Apple-modifier arms +of the keycode table could never be reached, and modifier *state* came +only from folding the flags carried by ordinary key events. The choice +was to subscribe (Option A) or delete the arms and document +flags-folding as the single source of truth (Option B). + +**Chose Option A.** Folding is accurate the instant a character key +arrives and stale at every other moment: let go of Ctrl and nothing +tells us until the next keystroke. `held_modifiers` is read at the +*start* of a correction — often triggered by a chord, i.e. precisely +when no ordinary key is flowing — so the stale window is the window +that matters. Subscribing also puts macOS on the same footing as the +Windows and X11 backends, which do get discrete modifier edges, and +costs one extra event per modifier edge through a callback that only +translates and `try_send`s. + +The subscription makes the keycode table's modifier arms live, which is +a behaviour change in the engine's word buffer: Apple 0x3C (RShift) +would otherwise land on SC-1 0x3C, inside the classifier's +`0x3B..=0x53` "navigation — end the word and discard it" range. The +arms were already written for exactly this, and now there are tests. +`kVK_Function` (0x3F) is the one that was *not* covered: it rides the +same `FlagsChanged` stream, has no SC Set-1 equivalent, and the +identity fallback would put it at SC-1 0x3F — inside that same range, +so holding Fn to reach an arrow key would have silently eaten the word +in progress. Untracked modifier keycodes are dropped in the listener +instead. + +**The missing release (issue #5).** `KeyEmitter::release_modifiers` +had a default no-op that macOS inherited, so accepting a suggestion +with `Ctrl+Shift+` while still holding the chord retyped the +word under those modifiers. + +The macOS fix is in two independent halves, both of which we want: + +1. **`release_modifiers` posts `FlagsChanged` events.** There is no + key-up for a modifier on macOS; the release *is* a flags-changed + event whose flags describe what remains down. We post one per held + modifier, each carrying the picture after that key is up. Caps Lock + is excluded on purpose — it is a latch, and clearing it would turn + the user's Caps light off behind their back. +2. **Every event we post has its flags cleared.** An event built from a + `HIDSystemState` source inherits the *live hardware* modifier flags, + so with the chord still held our backspaces would post as ⌘⌫ — + "delete to start of line" — and wreck far more than the word. This + half needs nothing to have worked at the OS level and covers the + case where the engine did not think anything was held. + +**Windows had the same no-op** and the same bug; it now sends key-ups +for both sides of each held modifier via `SendInput`. Issue #5 assumed +this already worked there. + +*Untested on hardware.* The tables and the direction rules moved into +`macos/codes.rs`, which carries no Apple dependency and therefore +compiles — and runs its tests — on Linux and Windows CI. The FFI +either side of it is compiled by CI's `macos-latest` job and executed +by nobody yet; the tap change in particular wants a real Mac before +0.6.4 ships. + +--- + ## 2026-07-30 — Every macOS TIS call goes through the main dispatch queue `TISCopyCurrentKeyboardInputSource` and friends look like ordinary C diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 60af8af..b48e5ee 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -39,15 +39,23 @@ added" notification. No elevation is involved and nothing is written outside the user's own home directory; unticking the setting deletes the file. -> **What exists:** when the keyboard hooks fail to start — the usual -> cause on macOS being exactly this permission — the tray shows a -> **⚠ Keyboard hooks unavailable — Setup Guide…** entry (which opens -> this document), a tooltip warning, and a one-shot notification. +> **What exists (0.6.4).** When the keyboard hooks fail to start — the +> usual cause on macOS being exactly this permission — the tray shows a +> **⚠ Keyboard hooks unavailable — Setup…** entry, a tooltip warning, +> and a one-shot notification. The entry opens the Settings window on +> its **Setup** pane (also reachable as `poltertype --setup`), which +> probes *this* machine: on macOS it reports Accessibility and Input +> Monitoring separately, offers to raise each system prompt, and deep- +> links into the matching System Settings pane; on Wayland it reports +> read access and uinput access separately, and distinguishes "not set +> up" from "set up, but this login session predates it". **Check +> again** re-probes. The pane also carries the "layout switching +> unavailable" banner when no switcher backend could be built. > -> **Still planned, not built:** a first-launch onboarding *window* that -> walks the user through the toggle before anything fails, and a banner -> for "layout switching unavailable". Today the user still has to act -> on the alert rather than being led through the grant. +> **Still planned, not built:** the screenshots / GIFs of the macOS +> toggles, and showing any of this *before* something fails rather than +> after. The pane is also, like the rest of the macOS backend, +> compiled by CI and never yet run on a Mac. ## Linux @@ -214,8 +222,8 @@ the app sits in the tray unable to switch anything — a layout switcher is a hard requirement, not a nice-to-have. (The separate case — keyboard *hooks* failing while layout switching -works — does keep the app running, and surfaces the ⚠ Setup Guide tray -entry described under macOS above.) +works — does keep the app running, and surfaces the ⚠ Setup tray entry +described under macOS above, which opens the Setup pane.) ## Network diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 22be069..f2c389d 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -3,7 +3,7 @@ > Step-by-step checklist for bumping the version and shipping a > tagged release. Once you push a `v*` tag, the GitHub Actions > workflow at [`.github/workflows/release.yml`][release-yml] -> builds the three platform installers in parallel and attaches +> builds the four platform installers in parallel and attaches > them to a draft release. The whole flow takes ~15 minutes of local work plus ~15 minutes of @@ -339,9 +339,11 @@ gh run watch # if you have gh CLI The workflow: -1. Builds three installers in parallel — Windows `.msi` (WiX +1. Builds four installers in parallel — Windows `.msi` (WiX 3.x), macOS universal `.dmg` (Intel + Apple Silicon merged - with `lipo`), Linux `.AppImage` (x86_64, `linuxdeploy`). + with `lipo`), Linux `.AppImage` for x86_64 and aarch64 + (`linuxdeploy`, each built natively on a runner of its own + architecture). 2. Writes `latest.json` — the manifest the **in-app updater** polls (version, release-notes URL, and a URL + SHA-256 + size per platform). Generated from the exact artefacts being @@ -354,9 +356,64 @@ The workflow: 4. Stops there — you publish the release manually after sanity- checking the artefacts. +### Sign the manifest — MANDATORY (~1 min) + +The manifest CI attached is **unsigned**. The updater checks an +ed25519 signature over it against a public key compiled into the +app; that signature is what a compromised GitHub account cannot +forge, which is only true because the private key is not an +Actions secret and never touches a runner. So this step is +yours, and it happens on the draft, before publishing: + +```bash +cd /tmp +gh release download v0.2.1 --pattern latest.json --clobber +cargo xtask manifest sign latest.json --key ~/.config/poltertype-signing/release.key +gh release upload v0.2.1 latest.json --clobber +``` + +`sign` re-reads what it wrote and verifies it against the public +key the app ships, so a mismatched key fails here rather than on +a user's machine. To look before you sign, +`cargo xtask manifest payload latest.json` prints the exact bytes +the signature covers, and `cargo xtask manifest verify +latest.json` re-checks an already-signed file. + +The workflow run's summary page carries these same commands, so +you don't have to remember they exist. + +**Key custody.** The private key is a 32-byte seed, base64, in +`~/.config/poltertype-signing/release.key` (mode 0600) — or +wherever you point `--key`, or in `$POLTERTYPE_SIGNING_KEY` if you +keep it in a password manager instead of on disk. It is the one +secret in this project with no recovery path: losing it means +shipping a release with a new public key baked in and asking +users to accept an unsigned interim manifest, and leaking it +means an attacker can sign updates. Back it up somewhere that is +neither this repository nor GitHub. + +`cargo xtask manifest keygen` creates a fresh keypair (and +refuses to overwrite an existing one). Rotating means: keygen → +put the new public key in +`crates/poltertype-update/release-signing-key.pub` → ship a +release built with it → only then sign with the new private key. +Signing before that release is out means every existing install +sees a signature it cannot check. + +> **Signatures are not yet mandatory.** +> `poltertype-update`'s `REQUIRE_SIGNATURE` is `false`, so an +> unsigned manifest still works — that is deliberate, since users +> on older builds would otherwise be stranded. It is also why +> forgetting to sign fails silently. Flip the constant to `true` +> only once a signed manifest has been the published `latest.json` +> for a full release cycle, and note the flip in the changelog: +> from then on, a forgotten signature is an outage for everyone's +> updater. + When the run completes: * Open the draft on the [Releases page][releases]. +* Sign and re-upload `latest.json` (above). * Edit the body if you want to add release notes (the auto- generated body is just the checksums). * Hit **Publish release**. diff --git a/installers/linux/build-appimage.sh b/installers/linux/build-appimage.sh index e6936e4..0206279 100644 --- a/installers/linux/build-appimage.sh +++ b/installers/linux/build-appimage.sh @@ -1,10 +1,16 @@ #!/usr/bin/env bash -# Build a PolterType AppImage (x86_64). +# Build a PolterType AppImage (x86_64 or aarch64). # # Inputs (env vars): # VERSION — release version (any leading "v" is stripped). +# ARCH — x86_64 (default) or aarch64. This is the AppImage +# naming convention, which happens to match both the +# Rust target triple prefix and linuxdeploy's own asset +# names, so one variable drives all three. There is no +# cross-compilation here: the release workflow runs this +# natively on a runner of the matching architecture. # BIN_PATH — path to the already-built poltertype binary -# (default: target/x86_64-unknown-linux-gnu/release/poltertype). +# (default: target/-unknown-linux-gnu/release/poltertype). # DATA_DIR — path to the prepared `data/` tree from # `crates/poltertype-core/build.rs` (default: target/dist/data). # ICON_PNG — path to a square PNG icon ≥ 256×256 @@ -37,12 +43,23 @@ set -euo pipefail VERSION="${VERSION:-0.0.0}" VERSION="${VERSION#v}" -BIN_PATH="${BIN_PATH:-target/x86_64-unknown-linux-gnu/release/poltertype}" +APP_NAME="poltertype" +ARCH="${ARCH:-x86_64}" + +# Refuse an arch we have no linuxdeploy asset for rather than fetching a +# 404 and failing three steps later with a confusing message. +case "${ARCH}" in + x86_64 | aarch64) ;; + *) + echo "Unsupported ARCH '${ARCH}' (expected x86_64 or aarch64)." >&2 + exit 1 + ;; +esac + +BIN_PATH="${BIN_PATH:-target/${ARCH}-unknown-linux-gnu/release/poltertype}" DATA_DIR="${DATA_DIR:-target/dist/data}" ICON_PNG="${ICON_PNG:?ICON_PNG is required (PNG ≥ 256×256)}" OUT_DIR="${OUT_DIR:-target/dist}" -APP_NAME="poltertype" -ARCH="x86_64" if [[ ! -x "${BIN_PATH}" ]]; then echo "Binary not found / not executable: ${BIN_PATH}" >&2 diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..02bfbc3 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,134 @@ +# Distribution packaging + +Ready-to-publish manifests for the three package managers PolterType's +users actually ask for. **Nothing in here is live yet** — every file is +staged, reviewed and version-controlled here, and publishing each one is +a deliberate human step against a third-party system. + +| Directory | Target | Publishing needs | +|---|---|---| +| [`aur/`](aur/) | Arch User Repository — `poltertype` (source) and `poltertype-bin` (repacked AppImage) | an AUR account + its SSH key | +| [`winget/`](winget/) | `winget install JustCode.PolterType` | a PR to `microsoft/winget-pkgs` | +| [`homebrew/`](homebrew/) | `brew install --cask just-code-net/tap/poltertype` | a `Just-Code-NET/homebrew-tap` repository | + +> **The README's install table does not mention any of these yet, and +> must not until the corresponding package is live.** The site and the +> README may only promise what a user can actually do today — see the +> workspace `CLAUDE.md`. Add the line in the same change that publishes +> the package, not before. + +## Bumping to a new release + +```bash +packaging/bump.sh 0.6.4 +``` + +Downloads that release's artifacts, hashes the bytes GitHub is actually +serving, and rewrites the version + checksum in all three targets. It +publishes nothing. Run it only against a **published** release: a +checksum is only worth anything if it came from the file users will +download. + +--- + +## AUR + +Two packages, either or both. They conflict with each other by design +(`poltertype-bin` provides `poltertype`), so a user picks one. + +* **`poltertype`** builds from the release tarball. `--frozen` and the + committed `Cargo.lock` mean it builds the dependency versions upstream + tested, not whatever crates.io resolved this morning. +* **`poltertype-bin`** extracts the release AppImage. `--appimage-extract` + unpacks without mounting anything, so it works in the clean chroot an + AUR package is supposed to build in — no FUSE required. + +Both install the udev rule and the `modules-load.d` entry. Neither adds +anyone to the `input` group: that is a change to a user's account, and a +package that makes it silently has decided something on their behalf +that they did not ask for. The `.install` notice says the one command to +run and — the part people lose an evening to — that a login session +keeps the group membership it was created with, so it must be restarted. + +Publishing (first time): + +```bash +git clone ssh://aur@aur.archlinux.org/poltertype.git aur-poltertype +cp packaging/aur/poltertype/* packaging/aur/99-poltertype.rules \ + packaging/aur/uinput.conf aur-poltertype/ +cd aur-poltertype +makepkg --printsrcinfo > .SRCINFO # AUR requires this, and requires it current +makepkg -si # build and install it once before pushing +namcap PKGBUILD ./*.pkg.tar.zst # catches missing/redundant deps +git add PKGBUILD .SRCINFO poltertype.install 99-poltertype.rules uinput.conf +git commit -m "Initial import: poltertype 0.6.3" +git push +``` + +Same for `poltertype-bin`, whose directory is already self-contained +(the shared files are copies — an AUR repo cannot reach outside itself, +so if you change one, change both). + +`.SRCINFO` is not committed here on purpose: it is generated from the +PKGBUILD, it is only meaningful inside an AUR repo, and a stale copy in +two places is a way to publish a package that does not match its own +metadata. + +**If someone else wants to maintain these under their own AUR account, +that is the better outcome** — AUR works best when the packager is a +user of the package. Point them at these files and link the result from +the README. + +Worth knowing: the built-in updater deliberately refuses to overwrite a +file pacman owns (it requires `$APPIMAGE` to be set — an allowlist, not +a list of package managers to recognise), so packaged users get updates +through the AUR. That is correct, not a limitation to work around. + +## winget + +The MSI is exactly what winget wants: per-user, no elevation, silent- +install capable. Suggested identifier `JustCode.PolterType`. + +```bash +# Regenerate rather than hand-edit — wingetcreate fills in the +# installer hash from the real download and validates the schema. +wingetcreate update JustCode.PolterType \ + --version 0.6.3 \ + --urls https://github.com/Just-Code-NET/PolterType/releases/download/v0.6.3/poltertype-0.6.3-x86_64-pc-windows-msvc.msi \ + --submit +``` + +The manifests here are the hand-written first version, kept in the repo +so the metadata (description, tags, support URLs) is reviewed like code +rather than typed into a wizard once. + +**The open question is the unsigned MSI.** Automated validation may +accept it — MSI plus Mark-of-the-Web is handled more gently than a bare +`.exe` — but if SmartScreen reputation blocks the submission, park the +PR and retry when signed installers ship. Do not work around it. + +Once merged: add the `winget install` line to the README install table +and the site's Windows card, and consider wiring `wingetcreate update` +into the release flow. + +## Homebrew + +**Stage 1 — our own tap, which needs no thresholds.** Create +`Just-Code-NET/homebrew-tap` and drop [`homebrew/poltertype.rb`](homebrew/poltertype.rb) +in as `Casks/poltertype.rb`. Install line becomes +`brew install --cask just-code-net/tap/poltertype`. + +The cask deliberately does **not** carry `quarantine: false` and does not +strip the quarantine flag in a postflight. The installers are unsigned; +removing that check silently, on the user's behalf, for an app that reads +every keystroke they type, is not a convenience we get to hand out. The +caveat explains the right-click → Open route and the two macOS +permissions instead. Revisit the day the DMG is notarised — and only +then. + +**Stage 2 — the main `homebrew-cask`, later.** It has notability +thresholds for GitHub-hosted apps (≥75 stars / 30 forks / 30 watchers, +with an exception path for apps that have their own website). +poltertype.com may qualify for that exception; a rejected PR costs +nothing and tells us exactly where the bar is. When it lands, promote +the cask and deprecate the tap entry. diff --git a/packaging/aur/99-poltertype.rules b/packaging/aur/99-poltertype.rules new file mode 100644 index 0000000..3dc50c3 --- /dev/null +++ b/packaging/aur/99-poltertype.rules @@ -0,0 +1,13 @@ +# Installed by the poltertype package. +# +# Grants the `input` group: +# - read access to keyboard event devices, so PolterType can see +# what you type on Wayland (which offers no other way); +# - read+write access to /dev/uinput, so the corrector can backspace +# the wrong-layout word and re-type it. +# +# Inert until your user is actually in that group — the package does +# not add you to it. See /usr/share/doc/poltertype/PERMISSIONS.md. +KERNEL=="event*", SUBSYSTEM=="input", GROUP="input", MODE="0640" +KERNEL=="uinput", SUBSYSTEM=="misc", GROUP="input", MODE="0660", \ + OPTIONS+="static_node=uinput" diff --git a/packaging/aur/poltertype-bin/99-poltertype.rules b/packaging/aur/poltertype-bin/99-poltertype.rules new file mode 100644 index 0000000..3dc50c3 --- /dev/null +++ b/packaging/aur/poltertype-bin/99-poltertype.rules @@ -0,0 +1,13 @@ +# Installed by the poltertype package. +# +# Grants the `input` group: +# - read access to keyboard event devices, so PolterType can see +# what you type on Wayland (which offers no other way); +# - read+write access to /dev/uinput, so the corrector can backspace +# the wrong-layout word and re-type it. +# +# Inert until your user is actually in that group — the package does +# not add you to it. See /usr/share/doc/poltertype/PERMISSIONS.md. +KERNEL=="event*", SUBSYSTEM=="input", GROUP="input", MODE="0640" +KERNEL=="uinput", SUBSYSTEM=="misc", GROUP="input", MODE="0660", \ + OPTIONS+="static_node=uinput" diff --git a/packaging/aur/poltertype-bin/PKGBUILD b/packaging/aur/poltertype-bin/PKGBUILD new file mode 100644 index 0000000..1859413 --- /dev/null +++ b/packaging/aur/poltertype-bin/PKGBUILD @@ -0,0 +1,81 @@ +# Maintainer: PolterType contributors +# +# Repacks the release AppImage for people who would rather not install +# a Rust toolchain to get a tray app. Same binary CI built and the +# release manifest is signed over; extracted, not run in place, so +# there is no FUSE requirement and the desktop entry is a normal one. +# +# See ../README.md for how this file gets published and updated. + +pkgname=poltertype-bin +_pkgname=poltertype +pkgver=0.6.3 +pkgrel=1 +pkgdesc="Detects text typed in the wrong keyboard layout, switches the layout and fixes the word (binary release)" +arch=('x86_64') +url="https://github.com/Just-Code-NET/PolterType" +license=('MIT') +provides=("${_pkgname}=${pkgver}") +conflicts=("${_pkgname}") +depends=( + 'gcc-libs' + 'glibc' + 'dbus' + 'systemd-libs' + 'libxkbcommon' + 'libxkbcommon-x11' + 'wayland' + 'libx11' + 'libxi' + 'libxtst' + 'gtk3' + 'libayatana-appindicator' + 'alsa-lib' +) +install="${_pkgname}.install" +# The udev rule and the modules-load entry are local files rather than +# a path into the source tree: an AUR package is its own git +# repository and can reach nothing outside itself. They are byte-identical +# to ../poltertype/ — change one, change both. +source=( + "${_pkgname}-${pkgver}-x86_64.AppImage::${url}/releases/download/v${pkgver}/${_pkgname}-${pkgver}-x86_64.AppImage" + '99-poltertype.rules' + 'uinput.conf' +) +sha256sums=( + 'a2fc069a139487a4376a4037919af49c0e997daf68936b2bc65da678119b9027' + 'SKIP' + 'SKIP' +) +noextract=("${_pkgname}-${pkgver}-x86_64.AppImage") + +prepare() { + chmod +x "${_pkgname}-${pkgver}-x86_64.AppImage" + # `--appimage-extract` unpacks without mounting anything, so this + # works in a clean chroot where FUSE is not available — which is + # exactly where AUR packages are supposed to build. + "./${_pkgname}-${pkgver}-x86_64.AppImage" --appimage-extract >/dev/null +} + +package() { + cd "${srcdir}/squashfs-root" + + install -Dm0755 "usr/bin/${_pkgname}" "${pkgdir}/usr/bin/${_pkgname}" + + # linuxdeploy bundles the libraries it decided were not safe to rely + # on. We drop them: every one is a real Arch package in depends(), + # and a bundled copy shadowing the system's is how a working app + # breaks three updates later. + install -dm0755 "${pkgdir}/usr/share/${_pkgname}/data" + cp -r "usr/share/${_pkgname}/data/." "${pkgdir}/usr/share/${_pkgname}/data/" + + install -Dm0644 "usr/share/applications/${_pkgname}.desktop" \ + "${pkgdir}/usr/share/applications/${_pkgname}.desktop" + install -Dm0644 "usr/share/icons/hicolor/256x256/apps/${_pkgname}.png" \ + "${pkgdir}/usr/share/icons/hicolor/256x256/apps/${_pkgname}.png" + + install -Dm0644 "${srcdir}/99-poltertype.rules" \ + "${pkgdir}/usr/lib/udev/rules.d/99-poltertype.rules" + install -Dm0644 "${srcdir}/uinput.conf" \ + "${pkgdir}/usr/lib/modules-load.d/poltertype-uinput.conf" +} diff --git a/packaging/aur/poltertype-bin/poltertype.install b/packaging/aur/poltertype-bin/poltertype.install new file mode 100644 index 0000000..35e2cfa --- /dev/null +++ b/packaging/aur/poltertype-bin/poltertype.install @@ -0,0 +1,42 @@ +# Printed once on install, and again on the upgrade that first ships +# this text. Deliberately short: a wall of text after `pacman -S` is a +# wall of text nobody reads. + +_notice() { + cat <<'EOF' + +==> PolterType: one manual step on Wayland + + The udev rule is installed. What the package will NOT do for you + is add your user to the `input` group — that is a change to your + account, and a package has no business making it silently: + + sudo usermod -aG input "$USER" + + Then LOG OUT and back in. A session keeps the group membership it + was created with, so until you do, everything looks configured and + nothing works. PolterType's Settings window has a Setup pane that + tells you exactly which of the two states you are in. + + On X11 none of this applies — nothing to grant, nothing to do. + +==> Updates come from the AUR + + PolterType's built-in updater deliberately refuses to overwrite a + file pacman owns. It will point you at the releases page instead; + that is correct, not a bug. Update with your AUR helper. + +EOF +} + +post_install() { + _notice +} + +post_upgrade() { + # Only on a fresh reading of the notice — an upgrade that changes + # nothing about setup should not re-lecture. + if [ -z "$2" ]; then + _notice + fi +} diff --git a/packaging/aur/poltertype-bin/uinput.conf b/packaging/aur/poltertype-bin/uinput.conf new file mode 100644 index 0000000..3621933 --- /dev/null +++ b/packaging/aur/poltertype-bin/uinput.conf @@ -0,0 +1,4 @@ +# Installed by the poltertype package. +# Loads the uinput kernel module at boot so PolterType can synthesise +# keystrokes on Wayland sessions. +uinput diff --git a/packaging/aur/poltertype/PKGBUILD b/packaging/aur/poltertype/PKGBUILD new file mode 100644 index 0000000..8c4caae --- /dev/null +++ b/packaging/aur/poltertype/PKGBUILD @@ -0,0 +1,85 @@ +# Maintainer: PolterType contributors +# +# Builds from the release tarball. The wordlists are in the repository, +# so `cargo build` needs nothing from the network beyond crates.io. +# +# See ../README.md for how this file gets published and updated. + +pkgname=poltertype +pkgver=0.6.3 +pkgrel=1 +pkgdesc="Detects text typed in the wrong keyboard layout, switches the layout and fixes the word" +arch=('x86_64' 'aarch64') +url="https://github.com/Just-Code-NET/PolterType" +license=('MIT') +depends=( + 'gcc-libs' + 'glibc' + 'dbus' + 'systemd-libs' + 'libxkbcommon' + 'libxkbcommon-x11' + 'wayland' + 'libx11' + 'libxi' + 'libxtst' + 'gtk3' + 'libayatana-appindicator' + 'alsa-lib' +) +makedepends=('cargo' 'pkgconf') +install="${pkgname}.install" +source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz") +sha256sums=('SKIP') + +prepare() { + cd "PolterType-${pkgver}" + # `--locked` here and in build(): the repository ships a Cargo.lock + # and a package must build the versions upstream tested, not + # whatever resolved today. + export RUSTUP_TOOLCHAIN=stable + cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" +} + +build() { + cd "PolterType-${pkgver}" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR=target + cargo build --frozen --release --all-features -p poltertype-app +} + +check() { + cd "PolterType-${pkgver}" + export RUSTUP_TOOLCHAIN=stable + cargo test --frozen --release --workspace +} + +package() { + cd "PolterType-${pkgver}" + + install -Dm0755 "target/release/${pkgname}" "${pkgdir}/usr/bin/${pkgname}" + + # The layout mappings and FST wordlists, written by + # poltertype-core's build script. `/usr/share/poltertype/data` is + # resolution rule 4 for a binary in `/usr/bin` — see + # `crates/poltertype-core/src/data_dir/mod.rs`; keep the two in step + # or the app starts and knows no languages. + install -dm0755 "${pkgdir}/usr/share/${pkgname}/data" + cp -r target/dist/data/. "${pkgdir}/usr/share/${pkgname}/data/" + + install -Dm0644 installers/linux/poltertype.desktop \ + "${pkgdir}/usr/share/applications/${pkgname}.desktop" + + # The udev rule the package can install on the user's behalf. It is + # inert until someone is in the `input` group, which the package + # deliberately does NOT do — see the .install file. + install -Dm0644 packaging/aur/99-poltertype.rules \ + "${pkgdir}/usr/lib/udev/rules.d/99-poltertype.rules" + install -Dm0644 packaging/aur/uinput.conf \ + "${pkgdir}/usr/lib/modules-load.d/poltertype-uinput.conf" + + install -Dm0644 README.md "${pkgdir}/usr/share/doc/${pkgname}/README.md" + install -Dm0644 docs/PERMISSIONS.md \ + "${pkgdir}/usr/share/doc/${pkgname}/PERMISSIONS.md" + install -Dm0644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} diff --git a/packaging/aur/poltertype/poltertype.install b/packaging/aur/poltertype/poltertype.install new file mode 100644 index 0000000..35e2cfa --- /dev/null +++ b/packaging/aur/poltertype/poltertype.install @@ -0,0 +1,42 @@ +# Printed once on install, and again on the upgrade that first ships +# this text. Deliberately short: a wall of text after `pacman -S` is a +# wall of text nobody reads. + +_notice() { + cat <<'EOF' + +==> PolterType: one manual step on Wayland + + The udev rule is installed. What the package will NOT do for you + is add your user to the `input` group — that is a change to your + account, and a package has no business making it silently: + + sudo usermod -aG input "$USER" + + Then LOG OUT and back in. A session keeps the group membership it + was created with, so until you do, everything looks configured and + nothing works. PolterType's Settings window has a Setup pane that + tells you exactly which of the two states you are in. + + On X11 none of this applies — nothing to grant, nothing to do. + +==> Updates come from the AUR + + PolterType's built-in updater deliberately refuses to overwrite a + file pacman owns. It will point you at the releases page instead; + that is correct, not a bug. Update with your AUR helper. + +EOF +} + +post_install() { + _notice +} + +post_upgrade() { + # Only on a fresh reading of the notice — an upgrade that changes + # nothing about setup should not re-lecture. + if [ -z "$2" ]; then + _notice + fi +} diff --git a/packaging/aur/uinput.conf b/packaging/aur/uinput.conf new file mode 100644 index 0000000..3621933 --- /dev/null +++ b/packaging/aur/uinput.conf @@ -0,0 +1,4 @@ +# Installed by the poltertype package. +# Loads the uinput kernel module at boot so PolterType can synthesise +# keystrokes on Wayland sessions. +uinput diff --git a/packaging/bump.sh b/packaging/bump.sh new file mode 100755 index 0000000..f7420fe --- /dev/null +++ b/packaging/bump.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Re-point every packaging manifest at a published release. +# +# packaging/bump.sh 0.6.4 +# +# Downloads that release's artifacts, hashes them, and rewrites the +# version + checksum in the AUR PKGBUILDs, the winget manifests and the +# Homebrew cask. It writes nothing outside this repository and publishes +# nothing — see packaging/README.md for the per-target publish step, +# which stays manual on purpose. +# +# Run it only against a release that is actually published: the point of +# a checksum is that it came from the bytes users will download. + +set -euo pipefail + +VERSION="${1:?usage: packaging/bump.sh (e.g. 0.6.4)}" +VERSION="${VERSION#v}" +REPO="Just-Code-NET/PolterType" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORK="$(mktemp -d)" +trap 'rm -rf "${WORK}"' EXIT + +fetch_sha() { + local name="$1" + if ! gh release download "v${VERSION}" -R "${REPO}" \ + --pattern "${name}" --dir "${WORK}" >/dev/null 2>&1; then + echo "missing from release v${VERSION}: ${name}" >&2 + return 1 + fi + sha256sum "${WORK}/${name}" | cut -d' ' -f1 +} + +echo "==> hashing the v${VERSION} artifacts" +APPIMAGE_SHA="$(fetch_sha "poltertype-${VERSION}-x86_64.AppImage")" +MSI_SHA="$(fetch_sha "poltertype-${VERSION}-x86_64-pc-windows-msvc.msi")" +DMG_SHA="$(fetch_sha "poltertype-${VERSION}-universal-apple-darwin.dmg")" + +# `sed -i` with anchored patterns rather than a templating pass: every +# line below is one we want to be able to read in a diff and recognise. +echo "==> AUR" +sed -i "s/^pkgver=.*/pkgver=${VERSION}/;s/^pkgrel=.*/pkgrel=1/" \ + "${ROOT}/packaging/aur/poltertype/PKGBUILD" \ + "${ROOT}/packaging/aur/poltertype-bin/PKGBUILD" +sed -i "s/^ '[0-9a-f]\{64\}'$/ '${APPIMAGE_SHA}'/" \ + "${ROOT}/packaging/aur/poltertype-bin/PKGBUILD" + +echo "==> winget" +sed -i "s/^PackageVersion: .*/PackageVersion: ${VERSION}/" \ + "${ROOT}/packaging/winget/"*.yaml +sed -i \ + -e "s|InstallerUrl: .*|InstallerUrl: https://github.com/${REPO}/releases/download/v${VERSION}/poltertype-${VERSION}-x86_64-pc-windows-msvc.msi|" \ + -e "s/InstallerSha256: .*/InstallerSha256: $(echo "${MSI_SHA}" | tr 'a-f' 'A-F')/" \ + "${ROOT}/packaging/winget/JustCode.PolterType.installer.yaml" + +echo "==> homebrew" +sed -i \ + -e "s/^ version \".*\"/ version \"${VERSION}\"/" \ + -e "s/^ sha256 \".*\"/ sha256 \"${DMG_SHA}\"/" \ + "${ROOT}/packaging/homebrew/poltertype.rb" + +echo +echo "Updated to ${VERSION}:" +git -C "${ROOT}" diff --stat -- packaging/ || true +echo +echo "Nothing has been published. packaging/README.md has the three" +echo "publish steps, each of which is a deliberate human action." diff --git a/packaging/homebrew/poltertype.rb b/packaging/homebrew/poltertype.rb new file mode 100644 index 0000000..5d275eb --- /dev/null +++ b/packaging/homebrew/poltertype.rb @@ -0,0 +1,73 @@ +# Homebrew cask for PolterType. +# +# Goes into Just-Code-NET/homebrew-tap as `Casks/poltertype.rb`, which +# makes the install line: +# +# brew install --cask just-code-net/tap/poltertype +# +# See ../README.md for creating the tap and keeping this file current. +# `bin/bump-cask.sh` in that tap rewrites `version` and `sha256` from a +# release; do not hand-edit them. + +cask "poltertype" do + version "0.6.3" + sha256 "3b86dba3385e3aba735da5082a4bfaeafe18e39f1aede7a6e8106b33f96f17fd" + + url "https://github.com/Just-Code-NET/PolterType/releases/download/v#{version}/poltertype-#{version}-universal-apple-darwin.dmg", + verified: "github.com/Just-Code-NET/PolterType/" + name "PolterType" + desc "Detects text typed in the wrong keyboard layout and fixes the word" + homepage "https://poltertype.com/" + + livecheck do + url :url + strategy :github_latest + end + + # The app owns a global keyboard hook, so it must not be running when + # its bundle is replaced. + depends_on macos: ">= :big_sur" + + app "poltertype.app" + + # NOT `quarantine: false`, and not an `xattr -d` in a postflight. + # + # PolterType's installers are unsigned and un-notarised today. A cask + # that strips the quarantine flag would make first launch smooth by + # removing the one check standing between the user and an unverified + # binary — and it would do it silently, on their behalf, for an app + # that reads every keystroke they type. The caveat below tells them + # what to do instead and why, and leaves the decision where it + # belongs. Delete this comment the day the DMG is notarised, not + # before. + caveats <<~EOS + PolterType is not code-signed or notarised yet, so macOS will refuse + the first launch with "the developer cannot be verified". + + To open it once: right-click poltertype.app in /Applications and + choose Open, then confirm. + + PolterType also needs two permissions before it can do anything, in + System Settings > Privacy & Security: + + * Accessibility — to read keys and type the correction + * Input Monitoring — to receive the key events at all + + Granting only one of the two is the usual reason it looks dead. The + app's Settings window has a Setup pane that says which of the two + is missing. + EOS + + # Paths taken from the code, not guessed: `ProjectDirs::from("dev", + # "opensource", "poltertype")` in poltertype-core's settings store, + # and `APP_ID` = dev.opensource.poltertype for the LaunchAgent + # (crates/poltertype-autostart/src/macos.rs). A `zap` that lists a + # path the app never writes is a cask quietly claiming to clean up + # after itself. + zap trash: [ + "~/Library/Application Support/dev.opensource.poltertype", + "~/Library/Caches/dev.opensource.poltertype", + "~/Library/LaunchAgents/dev.opensource.poltertype.plist", + "~/Library/Preferences/dev.opensource.poltertype.plist", + ] +end diff --git a/packaging/winget/JustCode.PolterType.installer.yaml b/packaging/winget/JustCode.PolterType.installer.yaml new file mode 100644 index 0000000..8bc88f5 --- /dev/null +++ b/packaging/winget/JustCode.PolterType.installer.yaml @@ -0,0 +1,31 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.6.0.schema.json +# +# Installer manifest for microsoft/winget-pkgs. See ../README.md for how +# this gets submitted and kept current — `wingetcreate update` will +# rewrite InstallerUrl / InstallerSha256 / PackageVersion for you, and +# that is the supported way to bump it. + +PackageIdentifier: JustCode.PolterType +PackageVersion: 0.6.3 +MinimumOSVersion: 10.0.0.0 +InstallerType: wix +# Per-user by design (installers/wix/main.wxs), which is what lets the +# install run without elevation and without a UAC prompt. If this ever +# becomes a per-machine MSI, Scope must change with it or winget will +# promise something the installer does not do. +Scope: user +InstallModes: + - interactive + - silent + - silentWithProgress +UpgradeBehavior: install +# The self-updater and winget do not fight: the updater only ever +# replaces installs it recognises as its own, and an MSI-over-MSI +# upgrade keeps the per-user layout. Nothing special is needed here. +ReleaseDate: 2026-07-13 +Installers: + - Architecture: x64 + InstallerUrl: https://github.com/Just-Code-NET/PolterType/releases/download/v0.6.3/poltertype-0.6.3-x86_64-pc-windows-msvc.msi + InstallerSha256: 388AA09DFB299542E88B56700C78897E4D8FEBFD0E49C2E08E4641A5871D2454 +ManifestType: installer +ManifestVersion: 1.6.0 diff --git a/packaging/winget/JustCode.PolterType.locale.en-US.yaml b/packaging/winget/JustCode.PolterType.locale.en-US.yaml new file mode 100644 index 0000000..4d7e46c --- /dev/null +++ b/packaging/winget/JustCode.PolterType.locale.en-US.yaml @@ -0,0 +1,37 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.6.0.schema.json + +PackageIdentifier: JustCode.PolterType +PackageVersion: 0.6.3 +PackageLocale: en-US +Publisher: Just Code +PublisherUrl: https://github.com/Just-Code-NET +PublisherSupportUrl: https://github.com/Just-Code-NET/PolterType/issues +PackageName: PolterType +PackageUrl: https://poltertype.com +License: MIT +LicenseUrl: https://github.com/Just-Code-NET/PolterType/blob/main/LICENSE +Copyright: Copyright (c) PolterType contributors +ShortDescription: Detects text typed in the wrong keyboard layout, switches the layout and fixes the word. +Description: |- + PolterType is a tray-only desktop app for people who type in more than + one language. When a word comes out as ghbdtn instead of привет, it + notices, switches the keyboard layout, and retypes the word — without + you reaching for the mouse. + + It runs entirely on your machine. Typed text is never logged and never + leaves the computer; the only network request the app makes is the + update check, which sends nothing about you and can be switched off. + + Note: the installer is not code-signed yet, so SmartScreen may warn on + first run. +Moniker: poltertype +Tags: + - keyboard + - layout + - language + - input + - productivity + - typing +ReleaseNotesUrl: https://github.com/Just-Code-NET/PolterType/releases/latest +ManifestType: defaultLocale +ManifestVersion: 1.6.0 diff --git a/packaging/winget/JustCode.PolterType.yaml b/packaging/winget/JustCode.PolterType.yaml new file mode 100644 index 0000000..93d30d7 --- /dev/null +++ b/packaging/winget/JustCode.PolterType.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.6.0.schema.json + +PackageIdentifier: JustCode.PolterType +PackageVersion: 0.6.3 +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.6.0 diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index b70cdbf..b344d94 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -15,6 +15,18 @@ workspace = true anyhow = { workspace = true } ureq = { version = "2", features = ["tls"] } +# `manifest sign` — the other half of the updater's signature check. +# `poltertype-update` is here for `Manifest` and `signing_payload`: the +# app and this tool must render the signed bytes with the same code, or +# a signature that verifies locally fails for every user. Unlike the +# app, this end needs the signing half of ed25519, so the default +# features (which include `zeroize` for the secret) stay on. +poltertype-update = { workspace = true } +ed25519-dalek = { version = "3" } +base64 = { workspace = true } +serde_json = { workspace = true } +getrandom = "0.2" + # Used by the `assets icon-png` subcommand to generate the placeholder # 1024×1024 source PNG that release CI then converts to per-platform # icon formats (.ico, .icns) — keeps the repo binary-asset-free while diff --git a/xtask/src/main.rs b/xtask/src/main.rs index a634651..2513aaa 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -16,6 +16,13 @@ //! before someone designs a real brand mark) to a PNG. See //! `assets.rs` for the rationale on why this is procedural. //! +//! ## `cargo xtask manifest [keygen | sign | verify | payload]` +//! +//! Signs `latest.json` with the release key so the in-app updater can +//! prove the manifest came from us and not merely from whoever can +//! publish a GitHub release. See `manifest.rs` for why the key stays +//! off CI, and `docs/RELEASING.md` for where this fits in the flow. +//! //! ## `cargo xtask version [bump | set ] [--dry-run]` //! //! Bumps the workspace version in lock-step across `Cargo.toml`, @@ -26,6 +33,7 @@ mod assets; mod hunspell; +mod manifest; mod version; use anyhow::{Context, Result, bail}; @@ -55,6 +63,7 @@ fn main() -> Result<()> { (Some("hooks"), Some("install")) => install_hooks(), (Some("hooks"), Some("uninstall")) => uninstall_hooks(), (Some("assets"), Some("icon-png")) => render_icon_command(&rest[2..]), + (Some("manifest"), _) => manifest::run(&rest[1..]), (Some("version"), _) => version::run(&rest[1..]), (Some(other), _) => bail!("unknown xtask command: {other} (try `cargo xtask help`)"), } @@ -70,6 +79,9 @@ fn print_help() { println!( " Render the placeholder app icon as a PNG (default size 1024)." ); + println!(" manifest Sign / verify the release manifest (see `manifest` alone"); + println!(" for the subcommands). Signing happens on the"); + println!(" maintainer's machine, never in CI."); println!(" version Print the current workspace version."); println!(" version bump Bump the workspace version (auto: pre-release counter,"); println!(" else patch). Updates Cargo.toml, CHANGELOG.md, Cargo.lock."); diff --git a/xtask/src/manifest.rs b/xtask/src/manifest.rs new file mode 100644 index 0000000..a4655ff --- /dev/null +++ b/xtask/src/manifest.rs @@ -0,0 +1,264 @@ +//! `cargo xtask manifest` — sign and inspect the release manifest. +//! +//! The updater in `poltertype-update` verifies `latest.json` against a +//! public key compiled into the binary. This is the other half: the +//! tool that makes that signature, run **on the maintainer's machine** +//! with a private key that is deliberately not available to CI. +//! +//! That asymmetry is the entire security gain. A signing key stored as +//! an Actions secret would be reachable by anyone who can compromise +//! the GitHub account — which is exactly the attacker the signature is +//! supposed to stop, so it would buy nothing. Signing by hand, between +//! the draft release CI produces and the moment a human publishes it, +//! keeps the key on hardware GitHub never touches. +//! +//! The signed bytes are produced by `poltertype_update::signing_payload` +//! — the same function the app verifies with, so there is exactly one +//! implementation of the format and no way for the two ends to drift. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use poltertype_update::{Manifest, signing_payload}; + +/// Where `keygen` puts a new keypair unless told otherwise. Outside the +/// repository and outside the app's own config directory: a signing key +/// that can be committed by accident, or wiped by "reset my settings", +/// is not a signing key. +const DEFAULT_KEY_DIR: &str = ".config/poltertype-signing"; + +/// Env var read when `--key` is not given, so the key can come from a +/// password manager (`POLTERTYPE_SIGNING_KEY=$(pass …)`) instead of +/// sitting on disk at all. +const KEY_ENV: &str = "POLTERTYPE_SIGNING_KEY"; + +pub fn run(args: &[String]) -> Result<()> { + match args.first().map(String::as_str) { + Some("keygen") => keygen(&args[1..]), + Some("sign") => sign(&args[1..]), + Some("verify") => verify(&args[1..]), + Some("payload") => payload(&args[1..]), + Some(other) => bail!("unknown `manifest` subcommand: {other}"), + None => { + print_help(); + Ok(()) + } + } +} + +fn print_help() { + println!("cargo xtask manifest "); + println!(); + println!(" keygen [--dir DIR] Create a release signing keypair (refuses to overwrite)."); + println!(" sign [--key FILE]"); + println!(" Sign in place. Key comes from --key, else ${KEY_ENV}."); + println!(" verify [--key PUBFILE]"); + println!(" Check against the key shipped in poltertype-update."); + println!(" payload Print the exact bytes a signature covers."); +} + +// ─── Subcommands ────────────────────────────────────────────────────── + +fn keygen(args: &[String]) -> Result<()> { + let dir = match flag(args, "--dir") { + Some(d) => PathBuf::from(d), + None => home()?.join(DEFAULT_KEY_DIR), + }; + let secret_path = dir.join("release.key"); + let public_path = dir.join("release.pub"); + + // Never clobber: overwriting a signing key silently orphans every + // release ever signed with it. + if secret_path.exists() || public_path.exists() { + bail!( + "{} already holds a keypair — refusing to overwrite it. \ + Delete it deliberately, or pass --dir.", + dir.display() + ); + } + + let mut seed = [0u8; 32]; + getrandom::getrandom(&mut seed) + .map_err(|e| anyhow::anyhow!("read 32 bytes from the OS random source: {e}"))?; + let signing = SigningKey::from_bytes(&seed); + let verifying = signing.verifying_key(); + + std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; + write_private(&secret_path, &format!("{}\n", BASE64.encode(seed)))?; + std::fs::write( + &public_path, + format!("{}\n", BASE64.encode(verifying.to_bytes())), + )?; + + println!("private key: {}", secret_path.display()); + println!("public key: {}", public_path.display()); + println!(); + println!("Put the public key in crates/poltertype-update/release-signing-key.pub"); + println!("and ship a release built with it BEFORE signing anything users will see:"); + println!(); + println!(" {}", BASE64.encode(verifying.to_bytes())); + Ok(()) +} + +fn sign(args: &[String]) -> Result<()> { + let path = positional(args, "manifest path")?; + let mut manifest = read_manifest(&path)?; + + let signing = load_signing_key(flag(args, "--key"))?; + // Signing a manifest that already carries a signature is fine — the + // field is not part of the payload — but say so, because it usually + // means someone is re-signing after an edit and wants to be sure the + // old one is gone. + if manifest.signature.is_some() { + println!( + "note: replacing the signature already in {}", + path.display() + ); + } + manifest.signature = None; + + let payload = signing_payload(&manifest).context("render the signed payload")?; + let signature = signing.sign(&payload); + manifest.signature = Some(BASE64.encode(signature.to_bytes())); + + let json = serde_json::to_string_pretty(&manifest).context("serialise the signed manifest")?; + std::fs::write(&path, format!("{json}\n")) + .with_context(|| format!("write {}", path.display()))?; + + // Verify what we just wrote, from disk, against the key the app + // ships. Signing something the released binaries cannot check is + // the one failure mode worth an extra read. + verify_file(&path, None)?; + println!("signed and verified: {}", path.display()); + Ok(()) +} + +fn verify(args: &[String]) -> Result<()> { + let path = positional(args, "manifest path")?; + verify_file(&path, flag(args, "--key"))?; + println!("signature OK: {}", path.display()); + Ok(()) +} + +fn payload(args: &[String]) -> Result<()> { + let path = positional(args, "manifest path")?; + let mut manifest = read_manifest(&path)?; + manifest.signature = None; + let bytes = signing_payload(&manifest).context("render the signed payload")?; + print!( + "{}", + String::from_utf8(bytes).context("payload is not UTF-8")? + ); + Ok(()) +} + +// ─── Helpers ────────────────────────────────────────────────────────── + +fn verify_file(path: &Path, key_path: Option<&str>) -> Result<()> { + let manifest = read_manifest(path)?; + let Some(encoded) = manifest.signature.as_deref() else { + bail!("{} carries no signature", path.display()); + }; + let raw = BASE64 + .decode(encoded.trim()) + .context("signature is not base64")?; + let bytes: [u8; 64] = raw + .as_slice() + .try_into() + .map_err(|_| anyhow::anyhow!("signature is {} bytes, want 64", raw.len()))?; + + let verifying = load_verifying_key(key_path)?; + let mut unsigned = manifest.clone(); + unsigned.signature = None; + let payload = signing_payload(&unsigned).context("render the signed payload")?; + verifying + .verify_strict(&payload, &Signature::from_bytes(&bytes)) + .context("signature does not match this manifest")?; + Ok(()) +} + +/// The 32-byte seed, base64, from `--key` or the environment. +fn load_signing_key(path: Option<&str>) -> Result { + let encoded = match path { + Some(p) => std::fs::read_to_string(p).with_context(|| format!("read key file {p}"))?, + None => std::env::var(KEY_ENV) + .map_err(|_| anyhow::anyhow!("no signing key: pass --key FILE or set {KEY_ENV}"))?, + }; + let raw = BASE64 + .decode(encoded.trim()) + .context("signing key is not base64")?; + let seed: [u8; 32] = raw + .as_slice() + .try_into() + .map_err(|_| anyhow::anyhow!("signing key is {} bytes, want 32", raw.len()))?; + Ok(SigningKey::from_bytes(&seed)) +} + +/// The public key to check against — by default the one the app ships, +/// because "does this verify for users" is the only question that +/// matters. +fn load_verifying_key(path: Option<&str>) -> Result { + let encoded = match path { + Some(p) => std::fs::read_to_string(p).with_context(|| format!("read key file {p}"))?, + None => include_str!("../../crates/poltertype-update/release-signing-key.pub").to_owned(), + }; + let raw = BASE64 + .decode(encoded.trim()) + .context("public key is not base64")?; + let bytes: [u8; 32] = raw + .as_slice() + .try_into() + .map_err(|_| anyhow::anyhow!("public key is {} bytes, want 32", raw.len()))?; + VerifyingKey::from_bytes(&bytes).context("not a valid ed25519 public key") +} + +fn read_manifest(path: &Path) -> Result { + let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + serde_json::from_str(&text) + .with_context(|| format!("{} is not a release manifest", path.display())) +} + +fn positional(args: &[String], what: &str) -> Result { + args.iter() + .find(|a| !a.starts_with("--")) + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("missing {what}")) +} + +fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + let i = args.iter().position(|a| a == name)?; + args.get(i + 1).map(String::as_str) +} + +fn home() -> Result { + std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("$HOME is not set; pass --dir")) +} + +/// Create the file with owner-only permissions *before* the secret goes +/// into it — writing first and chmod-ing after leaves a window where the +/// key is world-readable. +#[cfg(unix)] +fn write_private(path: &Path, contents: &str) -> Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .with_context(|| format!("create {}", path.display()))?; + f.write_all(contents.as_bytes())?; + Ok(()) +} + +#[cfg(not(unix))] +fn write_private(path: &Path, contents: &str) -> Result<()> { + // No mode bits to set; the file inherits the directory's ACL, and + // the recommended directory is under the user profile. + std::fs::write(path, contents).with_context(|| format!("create {}", path.display())) +}