Skip to content

Close the accumulated issue backlog: macOS input, signed manifests, aarch64, setup pane, packaging - #17

Merged
vstrelnikof merged 8 commits into
mainfrom
issues-sweep
Jul 31, 2026
Merged

Close the accumulated issue backlog: macOS input, signed manifests, aarch64, setup pane, packaging#17
vstrelnikof merged 8 commits into
mainfrom
issues-sweep

Conversation

@vstrelnikof

Copy link
Copy Markdown
Contributor

Works through the issues that could be closed without hardware we don't
have. Six commits, each self-contained; Closes lines mean merging this
closes #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 FlagsChanged and derives the direction from
the 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_modifiers lands. The half that matters
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". 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.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. cargo xtask manifest sign runs on the maintainer's machine, on the draft release.
REQUIRE_SIGNATURE stays false for one release: a wrong signature is
refused 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-arm for arm64). platform_key() already produced
linux-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/Todo gives the wrong advice in the case
that costs an evening: usermod -aG input writes the group database and
cannot touch an existing session, so everything looks configured and
nothing works. NeedsRelogin says log out instead. Nothing on the pane
runs 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. CaretOnlyFocusTracker gives those sessions the
best 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=input deliberately excludes it), there is no
portal, and layout switching needs host binaries a sandbox has not got.
Written up in docs/DECISIONS.md with sources, and said plainly in the
README so nobody has to ask twice.

Packaging (#13, #14, #15). AUR (source + -bin), winget manifests
and a Homebrew cask, staged in packaging/ — reviewed like code, live
nowhere. 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 input group, and the cask does
not 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 deny all green locally, on Linux.

Verified for real: the KWin and XWayland popup probes (nested
kwin_wayland 6.7.3), the manifest sign→verify→tamper-detect round trip
against the real key, the Setup pane rendering in both its resolved and
unresolved states, and every bump.sh substitution dry-run against a
copy of the tree.

Not verified anywhere: everything macOS. The FlagsChanged
subscription, release_modifiers, and the Setup pane's IOKit calls
compile 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.

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.
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.
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.
@vstrelnikof

Copy link
Copy Markdown
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
what this PR existed to find:

  1. CFDictionary::from_CFType_pairs(&[]) has no key/value type to infer from
    an empty slice, so the no-prompt Accessibility check did not compile.
    AXIsProcessTrusted is the nullary form Apple provides for it.
  2. A doc comment on an extern block documents nothing (rustdoc skips extern
    blocks), and PERMISSIONS_URL is handed out by the Linux probe alone — dead
    code on the other two under -D warnings.

So macOS and Windows now compile and pass their tests. What still has not
happened, and cannot happen here, is any of it being run on a Mac — the
FlagsChanged subscription, release_modifiers, and the Setup pane's IOKit
calls. #3 is the issue that closes that gap and is the one thing worth doing
before v0.6.4 ships.

@vstrelnikof
vstrelnikof merged commit f86256b into main Jul 31, 2026
4 checks passed
@vstrelnikof
vstrelnikof deleted the issues-sweep branch July 31, 2026 11:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

macOS: decide the fate of the modifier-keycode arms — the tap mask never delivers FlagsChanged

1 participant