Close the accumulated issue backlog: macOS input, signed manifests, aarch64, setup pane, packaging - #17
Merged
Merged
Conversation
The event tap only ever asked for KeyDown and KeyUp. 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 learned what was held only when an ordinary key arrived — stale at exactly the moment a chord-triggered correction reads it. Subscribe to FlagsChanged and derive the direction from the bit that belongs to the keycode that moved (the flags describe the state after the change). Keycodes with no SC Set-1 equivalent — Fn above all — are dropped instead of falling through the identity mapping into the word buffer's "navigation, end the word" range. With the state accurate, implement release_modifiers: post a FlagsChanged per held modifier carrying what remains down, and clear the flags on every event we post. The second half is the one that mattered most — an event built from a HIDSystemState source inherits the live hardware flags, so a correction under a held Command went out as ⌘⌫, "delete to start of line". Caps Lock stays untouched; it is a latch, and clearing it would turn the user's Caps light off. Windows inherited the same no-op and now sends key-ups for both sides of each held modifier. The backend becomes a directory so the keycode table and direction rules can live without an Apple dependency and be tested on every host — this project has no Mac, and a wrong scancode there fails silently. Closes #4, closes #5.
The updater checked every download against a SHA-256 that shipped in the same GitHub release as the installer, so the one attacker it could not stop was the one who can publish releases. latest.json now carries a detached ed25519 signature, verified against a key compiled into the binary before any URL in the manifest is read. The key is deliberately not a CI secret. An Actions secret is readable by exactly the attacker this defends against, so signing it there would be theatre; instead `cargo xtask manifest sign` runs on the maintainer's machine, on the draft release, before a human publishes it. RELEASING.md gains the step and the release job prints the commands into its own summary so it cannot quietly go missing. What gets signed is a flat newline-delimited rendering of the manifest's meaningful fields, not the JSON — a signature over raw JSON breaks on any reformatting, and canonical JSON is a second spec to get wrong. Artifacts are ordered by key so HashMap iteration cannot leak in, and a value containing a newline is refused by both ends, since it is the only way two manifests could render to the same bytes. xtask depends on poltertype-update so that rendering has exactly one implementation. REQUIRE_SIGNATURE stays false for this release: a wrong signature is refused from now on, a missing one warns. Flipping it early would strand every user whose updater resolves to the last unsigned manifest. Nothing user-facing may say "signed updates" until it flips. Closes #9.
An ARM64 Linux user — Raspberry Pi 5, Asahi, an ARM laptop or server — had nothing to download and the updater's platform key had nothing to offer them, since release.yml published three artifacts and platform_key() could name a fourth. The AppImage job becomes a two-entry matrix, both native: ubuntu-latest for x86_64, ubuntu-24.04-arm for aarch64 (free for public repos). Native rather than cross-compiled because the binary links the system GTK/X11/Wayland stack and is then packaged by a tool that is itself an AppImage — cross-compiling both halves buys nothing but breakage. build-appimage.sh takes ARCH, which drives the Rust triple, the linuxdeploy asset and the output name alike, and refuses anything it has no linuxdeploy build for. One extra artifact and no more. Every installer is a support surface — a build to keep green, a download to explain, a self-update path to get right — so armv7 and ARM Windows stay out until someone shows up with the hardware. platform_key() already produced linux-aarch64; what it lacked was a manifest entry to match. The test that pins it to the published set grows the key, so the workflow and the binary cannot drift apart without something going red. Closes #11.
Before designing a backend for the two desktops the docs called unsupported, we ran the existing one against them. KWin has implemented zwlr_layer_shell_v1 for years. Against a nested kwin_wayland 6.7.3 the popup selects linux-wayland-layer-shell and the surface configures and maps exactly as on Hyprland — no code needed, and no code added. Mutter genuinely has none (mutter#973, open since 2019), but GNOME Wayland runs XWayland and the X11 override-redirect probe maps a window there too; forcing the Wayland probe to fail in a live Wayland session selects linux-x11-override-redirect and maps. So the honest gap is neither GNOME nor KDE: it is a Wayland session with neither layer-shell nor XWayland, plus macOS and Windows. Five places said otherwise — the crate docs, the factory, the backend's module docs, the NoLayerShell error message a user would actually see, and CLAUDE.md. The backend has always probed the compositor; the prose was a hand-maintained table of desktop names, and a name-based claim goes stale without anything failing. What was really missing on both is the anchor. The Linux tracker returned NoopFocusTracker for anything that is not Hyprland or X11, so the tooltip had no window rect and no caret and fell back to screen-bottom. The rect is genuinely unavailable there; the caret is not — AT-SPI talks to the session bus and does not care which compositor is running. It was only ever constructed inside the two branches that also had a window query. CaretOnlyFocusTracker takes that path: focused_exe() stays None so nothing keyed off the focused app starts guessing, and the tooltip gets the best anchor in the chain rather than the worst. Also records the Flatpak go/no-go: uinput is not grantable short of --device=all, device=input deliberately excludes it, no portal exists, and layout switching needs host binaries a sandbox has not got. Closes #6, closes #12.
When the keyboard hooks failed to start, the tray offered a link to PERMISSIONS.md. That stopped the app failing silently and then left the user reading a markdown file to fix their own machine. The tray alert now opens the Settings window on a new Setup pane (poltertype --setup). It probes the running system through poltertype_input::setup and renders the answer: on Wayland whether key events can be read and whether corrections can be typed, as two separate rows because they are two separate permissions and the half-granted case — detection works, nothing gets fixed — is the one that reads as the app being broken. On macOS, Accessibility and Input Monitoring separately, each with a button that raises the system's own prompt and a deep link into the matching System Settings pane. On X11 and Windows it says there is nothing to grant, which most people arrive not believing. Four states, not two. Done/Todo would have given the wrong advice in the case that costs an evening: usermod -aG input writes the group database and cannot touch a session that already exists, so everything looks configured, nothing works, and re-running the script changes nothing. NeedsRelogin says log out instead. Unknown is for what we cannot determine — inventing a problem loses the reader, and asserting an unverified fix is worse. Nothing on the pane runs anything privileged. The Linux script needs sudo, and an app that quietly acquires root on a machine where it already reads every keystroke has spent trust it will not get back — so the button copies the command for the user to read and run. macOS prompts are always the system's dialog; we never draw one that looks like it. The probe lives in poltertype-input because that is where platform code is allowed to live, and the app renders a SetupReport without knowing what a udev rule is. Also adds the honest banner for hooks working while layout switching is unavailable — the case where corrections rewrite the word into the same wrong layout. Verified on Wayland/evdev, unresolved states included. The macOS half compiles in CI and has never run. Closes #10.
Ready-to-publish, reviewed like code, and live nowhere yet. Publishing each one is a deliberate action against a third-party system, so it stays a human step; packaging/README.md writes down exactly what that step is for each target. AUR gets two packages: poltertype builds from the release tarball with --frozen against the committed lock file, poltertype-bin extracts the AppImage with --appimage-extract so it works in the FUSE-less chroot an AUR package is supposed to build in. Both install the udev rule and the modules-load entry; neither adds anyone to the input group. That is a change to a user's account, and a package making it silently has decided something on their behalf that they did not ask for — the install notice gives them the command, and warns that a login session keeps the group it was created with, which is the part that costs people an evening. The Homebrew cask deliberately carries no `quarantine: false` and no xattr strip. The installers are unsigned; removing that check quietly, for an app that reads every keystroke, is not a convenience we get to hand out. The caveat explains right-click → Open and the two macOS permissions instead. Revisit when the DMG is notarised. winget is the hand-written first version so the metadata is reviewed rather than typed into a wizard; wingetcreate takes over from there. The open question is whether validation accepts an unsigned MSI — if SmartScreen blocks it, the PR gets parked, not worked around. packaging/bump.sh re-points all three at a published release by hashing the bytes GitHub actually serves. Every substitution in it was dry-run against a copy of the tree. The README install table stays silent about all three until each is live: the site and the README may only promise what a user can do today. Closes #13, closes #14, closes #15.
This was referenced Jul 31, 2026
Closed
Two mistakes that only a cross-compile could find, which is the whole reason this went through a PR. macOS: `CFDictionary::from_CFType_pairs(&[])` has no key or value type to infer from an empty slice, so the "check trust without prompting" call did not compile. `AXIsProcessTrusted` is the nullary form Apple provides for exactly this — better than turbofishing a dictionary we never wanted. Windows: PERMISSIONS_URL is handed out only by the Linux and macOS probes. Windows grants nothing and its one step has no action, so the constant is genuinely dead there, not merely unused.
This was referenced Jul 31, 2026
A doc comment on an extern block documents nothing — rustdoc skips extern blocks entirely — so `-D warnings` rejects it. Plain `//`. PERMISSIONS_URL is handed out by the Linux probe alone: macOS steps point at the System Settings panes instead. Gated to Linux.
This was referenced Jul 31, 2026
Closed
Closed
Contributor
Author
|
CI green on all three: ubuntu, macos-latest, windows-latest. Two rounds of failures before that, both macOS/Windows-only and both exactly
So macOS and Windows now compile and pass their tests. What still has not |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Works through the issues that could be closed without hardware we don't
have. Six commits, each self-contained;
Closeslines mean merging thiscloses #4, #5, #6, #9, #10, #11, #12, #13, #14, #15.
Left open deliberately — #2 (languages) and #16 (README translations),
which stay contributor on-ramps and want the docs to stabilise first, and
#3 / #7 / #8, which need a Mac or a Windows box to be worth shipping.
A key gate that swallows keystrokes system-wide is not something to write
blind.
What's here
macOS input (#4, #5). The event tap only asked for
KeyDown/KeyUp,so the modifier arms of the keycode table had been unreachable since they
were written and the engine learned what was held only when an ordinary
key arrived — stale at exactly the moment a chord-triggered correction
reads it. Now subscribes to
FlagsChangedand derives the direction fromthe bit belonging to the keycode that moved. Keycodes with no SC Set-1
equivalent (Fn above all) are dropped rather than falling into the word
buffer's "navigation, end the word" range.
With the state accurate,
release_modifierslands. The half that mattersmost: an event built from a
HIDSystemStatesource inherits the livehardware flags, so a correction under a held Command went out as ⌘⌫ —
"delete to start of line". Every posted event now has its flags cleared.
Windows inherited the same no-op and gets key-ups for both sides of each
held modifier.
The backend became a directory so the keycode table and direction rules
carry no Apple dependency and are tested on every host — this project
has no Mac, and a wrong scancode there fails silently.
Signed release manifests (#9). The updater checked every download
against a SHA-256 that shipped in the same GitHub release as the
installer, so the one attacker it could not stop was the one who can
publish releases.
latest.jsonnow carries a detached ed25519 signature,verified against a key compiled into the binary before any URL in the
manifest is read.
The key is deliberately not a CI secret — an Actions secret is
readable by exactly the attacker this defends against.
cargo xtask manifest signruns on the maintainer's machine, on the draft release.REQUIRE_SIGNATUREstaysfalsefor one release: a wrong signature isrefused from now on, a missing one warns. Nothing user-facing says
"signed updates" until that flips.
aarch64 Linux (#11). The AppImage job is a two-entry native matrix
(
ubuntu-24.04-armfor arm64).platform_key()already producedlinux-aarch64; what it lacked was a manifest entry to match.Setup pane (#10). The tray alert opened a link to
PERMISSIONS.md.It now opens the Settings window on a pane that probes this machine:
read access and uinput access as separate rows on Wayland (the
half-granted case — detection works, nothing gets fixed — is the
confusing one), Accessibility and Input Monitoring separately on macOS
with system prompts and deep links, and "nothing to grant" on X11 and
Windows.
Four states, not two.
Done/Todogives the wrong advice in the casethat costs an evening:
usermod -aG inputwrites the group database andcannot touch an existing session, so everything looks configured and
nothing works.
NeedsReloginsays log out instead. Nothing on the paneruns anything privileged — the Linux button copies the command.
GNOME/KDE tooltip (#6). Ran the existing backend against an actual
KWin before designing anything for it: KWin 6.7.3 accepts our layer-shell
surface and maps it exactly as Hyprland does. GNOME is not a no-op either
— Mutter has no layer-shell but XWayland carries the X11 fallback. Five
places claimed otherwise, including the error message users would see.
The backend always probed the compositor; the prose was a hand-maintained
table of desktop names.
What was missing on both is the anchor: the tracker returned a plain
no-op there, so the tooltip fell back to screen-bottom. The window rect
is genuinely unavailable; the caret is not — AT-SPI does not care which
compositor is running.
CaretOnlyFocusTrackergives those sessions thebest anchor in the chain rather than the worst.
Flatpak (#12). Decided with evidence rather than left open: the
emitter writes to
/dev/uinput, which no permission grants short of--device=all(device=inputdeliberately excludes it), there is noportal, and layout switching needs host binaries a sandbox has not got.
Written up in
docs/DECISIONS.mdwith sources, and said plainly in theREADME so nobody has to ask twice.
Packaging (#13, #14, #15). AUR (source +
-bin), winget manifestsand a Homebrew cask, staged in
packaging/— reviewed like code, livenowhere. The README install table stays silent until each is actually
published. Two calls worth flagging: the AUR packages install the udev
rule but will not add anyone to the
inputgroup, and the cask doesnot strip macOS quarantine — for an unsigned app that reads every
keystroke, that is not a convenience to hand out silently.
Verification
cargo fmt/clippy --all-features -D warnings/test --workspace(344 pass) /
cargo denyall green locally, on Linux.Verified for real: the KWin and XWayland popup probes (nested
kwin_wayland6.7.3), the manifest sign→verify→tamper-detect round tripagainst the real key, the Setup pane rendering in both its resolved and
unresolved states, and every
bump.shsubstitution dry-run against acopy of the tree.
Not verified anywhere: everything macOS. The
FlagsChangedsubscription,
release_modifiers, and the Setup pane'sIOKitcallscompile in CI and have been executed by nobody. This PR existing is how
they get compiled at all — and #3 is still the issue that matters most
before 0.6.4 ships.