From a164febda23a3656f9ee6124c0abcec59316a3e5 Mon Sep 17 00:00:00 2001 From: Leshiy Date: Fri, 31 Jul 2026 10:11:26 +0300 Subject: [PATCH 1/8] macos: subscribe to FlagsChanged and let go of held modifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 34 ++ crates/poltertype-input/src/lib.rs | 5 +- crates/poltertype-input/src/macos.rs | 473 ------------------ crates/poltertype-input/src/macos/codes.rs | 202 ++++++++ crates/poltertype-input/src/macos/consts.rs | 20 + crates/poltertype-input/src/macos/emitter.rs | 145 ++++++ crates/poltertype-input/src/macos/listener.rs | 280 +++++++++++ crates/poltertype-input/src/macos/mod.rs | 51 ++ crates/poltertype-input/src/macos/tests.rs | 160 ++++++ crates/poltertype-input/src/windows.rs | 33 +- docs/DECISIONS.md | 67 +++ 11 files changed, 994 insertions(+), 476 deletions(-) delete mode 100644 crates/poltertype-input/src/macos.rs create mode 100644 crates/poltertype-input/src/macos/codes.rs create mode 100644 crates/poltertype-input/src/macos/consts.rs create mode 100644 crates/poltertype-input/src/macos/emitter.rs create mode 100644 crates/poltertype-input/src/macos/listener.rs create mode 100644 crates/poltertype-input/src/macos/mod.rs create mode 100644 crates/poltertype-input/src/macos/tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ac5639d..a27af89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ 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 + +### 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/crates/poltertype-input/src/lib.rs b/crates/poltertype-input/src/lib.rs index 8dab53f..3c25032 100644 --- a/crates/poltertype-input/src/lib.rs +++ b/crates/poltertype-input/src/lib.rs @@ -19,7 +19,10 @@ pub mod focus; #[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/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/docs/DECISIONS.md b/docs/DECISIONS.md index 9f3506c..d13794f 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6,6 +6,73 @@ and any **alternatives** considered. --- +## 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 From dcf865cde6c788abde94a63a6b89b3e0cc54f710 Mon Sep 17 00:00:00 2001 From: Leshiy Date: Fri, 31 Jul 2026 10:24:29 +0300 Subject: [PATCH 2/8] update: verify an ed25519 signature over the release manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/release.yml | 32 +++ CHANGELOG.md | 19 ++ Cargo.lock | 139 ++++++++- Cargo.toml | 8 + README.md | 15 +- crates/poltertype-update/Cargo.toml | 3 + .../poltertype-update/release-signing-key.pub | 1 + crates/poltertype-update/src/consts.rs | 37 +++ crates/poltertype-update/src/enums.rs | 20 ++ crates/poltertype-update/src/lib.rs | 19 +- crates/poltertype-update/src/manifest.rs | 3 + crates/poltertype-update/src/signature.rs | 150 ++++++++++ crates/poltertype-update/src/tests.rs | 173 ++++++++++++ docs/DECISIONS.md | 56 ++++ docs/RELEASING.md | 55 ++++ xtask/Cargo.toml | 12 + xtask/src/main.rs | 12 + xtask/src/manifest.rs | 264 ++++++++++++++++++ 18 files changed, 1002 insertions(+), 16 deletions(-) create mode 100644 crates/poltertype-update/release-signing-key.pub create mode 100644 crates/poltertype-update/src/signature.rs create mode 100644 xtask/src/manifest.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50f26db..9327145 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -244,6 +244,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 }} @@ -287,6 +296,29 @@ 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: diff --git a/CHANGELOG.md b/CHANGELOG.md index a27af89..1876be9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ 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". + ### Fixed - **macOS: a suggestion accepted with its chord still held no longer 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..3177d02 100644 --- a/README.md +++ b/README.md @@ -112,11 +112,16 @@ 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 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..11f554b 100644 --- a/crates/poltertype-update/src/manifest.rs +++ b/crates/poltertype-update/src/manifest.rs @@ -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..7f7b1b7 100644 --- a/crates/poltertype-update/src/tests.rs +++ b/crates/poltertype-update/src/tests.rs @@ -110,6 +110,179 @@ 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(); diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d13794f..cff8c29 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6,6 +6,62 @@ and any **alternatives** considered. --- +## 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. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 22be069..8dc9e9c 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -354,9 +354,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/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())) +} From b24b7068614860b9ea076b8d3b1b399e3bb06b0b Mon Sep 17 00:00:00 2001 From: Leshiy Date: Fri, 31 Jul 2026 10:28:30 +0300 Subject: [PATCH 3/8] release: publish an aarch64 Linux AppImage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/release.yml | 52 ++++++++++++++++++------ CHANGELOG.md | 10 +++++ CONTRIBUTING.md | 2 +- README.md | 8 ++-- crates/poltertype-update/src/manifest.rs | 10 ++--- crates/poltertype-update/src/tests.rs | 8 +++- crates/poltertype-update/src/types.rs | 6 +-- docs/RELEASING.md | 8 ++-- installers/linux/build-appimage.sh | 27 +++++++++--- 9 files changed, 97 insertions(+), 34 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9327145..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 ──────────────────────────────────────────── @@ -283,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}" \ @@ -324,6 +347,7 @@ jobs: 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 @@ -361,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 1876be9..dda97d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,16 @@ and the project follows [Semantic Versioning](https://semver.org/). a one-constant flip in a later release, and only then does anything user-facing get to say "signed updates". +### Added + +- **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 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/README.md b/README.md index 3177d02..61346d4 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,6 +71,7 @@ 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 @@ -126,8 +127,9 @@ Three caveats worth stating plainly: 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 diff --git a/crates/poltertype-update/src/manifest.rs b/crates/poltertype-update/src/manifest.rs index 11f554b..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" diff --git a/crates/poltertype-update/src/tests.rs b/crates/poltertype-update/src/tests.rs index 7f7b1b7..f4b0fab 100644 --- a/crates/poltertype-update/src/tests.rs +++ b/crates/poltertype-update/src/tests.rs @@ -287,7 +287,13 @@ fn an_unsigned_manifest_is_accepted_only_while_signing_is_optional() { 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/RELEASING.md b/docs/RELEASING.md index 8dc9e9c..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 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 From 0b0c1afa751258146ed1393d3d2319c530d5ee6d Mon Sep 17 00:00:00 2001 From: Leshiy Date: Fri, 31 Jul 2026 10:45:13 +0300 Subject: [PATCH 4/8] popup: KDE was never the gap; give GNOME and KDE a caret anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 30 ++++ README.md | 19 ++- .../src/focus/linux_impl/caret_only.rs | 48 +++++++ .../src/focus/linux_impl/mod.rs | 10 +- .../src/focus/linux_impl/pick.rs | 19 ++- crates/poltertype-popup/src/factory.rs | 13 +- crates/poltertype-popup/src/lib.rs | 10 +- crates/poltertype-popup/src/linux/wayland.rs | 10 +- docs/DECISIONS.md | 134 ++++++++++++++++++ 9 files changed, 273 insertions(+), 20 deletions(-) create mode 100644 crates/poltertype-input/src/focus/linux_impl/caret_only.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index dda97d7..664886f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,36 @@ and the project follows [Semantic Versioning](https://semver.org/). ### Added +- **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 + +- **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 diff --git a/README.md b/README.md index 61346d4..3fe9465 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,15 @@ polls — you never download that one by hand): > 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). @@ -197,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-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-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/docs/DECISIONS.md b/docs/DECISIONS.md index cff8c29..92fa290 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6,6 +6,140 @@ and any **alternatives** considered. --- +## 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 From 8d455b1a3f87b1a7ee2e27ec832d438f04162eed Mon Sep 17 00:00:00 2001 From: Leshiy Date: Fri, 31 Jul 2026 11:12:23 +0300 Subject: [PATCH 5/8] ui: a Setup pane that checks this machine instead of linking to a doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 24 ++ crates/poltertype-app/src/main.rs | 39 ++- .../poltertype-app/src/settings_proc/enums.rs | 20 ++ .../poltertype-app/src/settings_proc/mod.rs | 2 +- .../poltertype-app/src/settings_proc/spawn.rs | 15 +- .../poltertype-app/src/settings_ui/consts.rs | 5 + .../poltertype-app/src/settings_ui/enums.rs | 21 ++ crates/poltertype-app/src/settings_ui/mod.rs | 51 +++- .../poltertype-app/src/settings_ui/state.rs | 21 +- .../poltertype-app/src/settings_ui/update.rs | 57 +++++ crates/poltertype-app/src/settings_ui/view.rs | 37 ++- .../src/settings_ui/view_setup.rs | 220 +++++++++++++++++ crates/poltertype-input/src/lib.rs | 1 + crates/poltertype-input/src/setup/consts.rs | 41 ++++ crates/poltertype-input/src/setup/enums.rs | 46 ++++ crates/poltertype-input/src/setup/linux.rs | 229 ++++++++++++++++++ crates/poltertype-input/src/setup/macos.rs | 140 +++++++++++ crates/poltertype-input/src/setup/mod.rs | 107 ++++++++ crates/poltertype-input/src/setup/tests.rs | 74 ++++++ crates/poltertype-input/src/setup/types.rs | 39 +++ docs/DECISIONS.md | 50 ++++ docs/PERMISSIONS.md | 28 ++- 22 files changed, 1225 insertions(+), 42 deletions(-) create mode 100644 crates/poltertype-app/src/settings_ui/view_setup.rs create mode 100644 crates/poltertype-input/src/setup/consts.rs create mode 100644 crates/poltertype-input/src/setup/enums.rs create mode 100644 crates/poltertype-input/src/setup/linux.rs create mode 100644 crates/poltertype-input/src/setup/macos.rs create mode 100644 crates/poltertype-input/src/setup/mod.rs create mode 100644 crates/poltertype-input/src/setup/tests.rs create mode 100644 crates/poltertype-input/src/setup/types.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 664886f..f6d1521 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,30 @@ and the project follows [Semantic Versioning](https://semver.org/). ### 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 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/lib.rs b/crates/poltertype-input/src/lib.rs index 3c25032..334638c 100644 --- a/crates/poltertype-input/src/lib.rs +++ b/crates/poltertype-input/src/lib.rs @@ -16,6 +16,7 @@ #![deny(unsafe_op_in_unsafe_fn)] pub mod focus; +pub mod setup; #[cfg(target_os = "linux")] mod linux; diff --git a/crates/poltertype-input/src/setup/consts.rs b/crates/poltertype-input/src/setup/consts.rs new file mode 100644 index 0000000..e724c0f --- /dev/null +++ b/crates/poltertype-input/src/setup/consts.rs @@ -0,0 +1,41 @@ +//! 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. +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..6a9851e --- /dev/null +++ b/crates/poltertype-input/src/setup/macos.rs @@ -0,0 +1,140 @@ +//! 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" { + fn AXIsProcessTrustedWithOptions(options: CFDictionaryRef) -> bool; + static kAXTrustedCheckOptionPrompt: CFStringRef; +} + +/// `IOHIDCheckAccess` / `IOHIDRequestAccess`, macOS 10.15+. +/// +/// 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: an empty options dictionary means "tell me, don't ask + // the user" — `kAXTrustedCheckOptionPrompt` absent is documented + // as no prompt. + let trusted = unsafe { + let empty: CFDictionary = CFDictionary::from_CFType_pairs(&[]); + AXIsProcessTrustedWithOptions(empty.as_concrete_TypeRef()) + }; + if trusted { + 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/docs/DECISIONS.md b/docs/DECISIONS.md index 92fa290..0a82f3c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6,6 +6,56 @@ 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 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 From 8a6749b6a173b6b7c08171327acfc3b63decfe06 Mon Sep 17 00:00:00 2001 From: Leshiy Date: Fri, 31 Jul 2026 13:30:41 +0300 Subject: [PATCH 6/8] packaging: stage AUR, winget and Homebrew manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 10 ++ packaging/README.md | 134 ++++++++++++++++++ packaging/aur/99-poltertype.rules | 13 ++ .../aur/poltertype-bin/99-poltertype.rules | 13 ++ packaging/aur/poltertype-bin/PKGBUILD | 81 +++++++++++ .../aur/poltertype-bin/poltertype.install | 42 ++++++ packaging/aur/poltertype-bin/uinput.conf | 4 + packaging/aur/poltertype/PKGBUILD | 85 +++++++++++ packaging/aur/poltertype/poltertype.install | 42 ++++++ packaging/aur/uinput.conf | 4 + packaging/bump.sh | 67 +++++++++ packaging/homebrew/poltertype.rb | 73 ++++++++++ .../winget/JustCode.PolterType.installer.yaml | 31 ++++ .../JustCode.PolterType.locale.en-US.yaml | 37 +++++ packaging/winget/JustCode.PolterType.yaml | 7 + 15 files changed, 643 insertions(+) create mode 100644 packaging/README.md create mode 100644 packaging/aur/99-poltertype.rules create mode 100644 packaging/aur/poltertype-bin/99-poltertype.rules create mode 100644 packaging/aur/poltertype-bin/PKGBUILD create mode 100644 packaging/aur/poltertype-bin/poltertype.install create mode 100644 packaging/aur/poltertype-bin/uinput.conf create mode 100644 packaging/aur/poltertype/PKGBUILD create mode 100644 packaging/aur/poltertype/poltertype.install create mode 100644 packaging/aur/uinput.conf create mode 100755 packaging/bump.sh create mode 100644 packaging/homebrew/poltertype.rb create mode 100644 packaging/winget/JustCode.PolterType.installer.yaml create mode 100644 packaging/winget/JustCode.PolterType.locale.en-US.yaml create mode 100644 packaging/winget/JustCode.PolterType.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d1521..f48b5fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,16 @@ and the project follows [Semantic Versioning](https://semver.org/). ### 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 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 From 320eb285234bb095fd44be1c4867ec8d36eb4ed8 Mon Sep 17 00:00:00 2001 From: Leshiy Date: Fri, 31 Jul 2026 13:54:42 +0300 Subject: [PATCH 7/8] setup: fix the macOS and Windows compiles CI caught MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/poltertype-input/src/setup/consts.rs | 5 +++++ crates/poltertype-input/src/setup/macos.rs | 19 +++++++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/poltertype-input/src/setup/consts.rs b/crates/poltertype-input/src/setup/consts.rs index e724c0f..2cca89f 100644 --- a/crates/poltertype-input/src/setup/consts.rs +++ b/crates/poltertype-input/src/setup/consts.rs @@ -3,6 +3,11 @@ /// 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 and macOS probes hand this out — Windows grants +/// nothing and its single step has no action, so the constant is dead +/// code there rather than merely unused. +#[cfg(any(target_os = "linux", target_os = "macos"))] pub(super) const PERMISSIONS_URL: &str = "https://github.com/Just-Code-NET/PolterType/blob/main/docs/PERMISSIONS.md"; diff --git a/crates/poltertype-input/src/setup/macos.rs b/crates/poltertype-input/src/setup/macos.rs index 6a9851e..b75b8b5 100644 --- a/crates/poltertype-input/src/setup/macos.rs +++ b/crates/poltertype-input/src/setup/macos.rs @@ -24,6 +24,13 @@ 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; } @@ -81,14 +88,10 @@ pub(super) fn probe() -> SetupReport { } fn accessibility_state() -> StepState { - // Safety: an empty options dictionary means "tell me, don't ask - // the user" — `kAXTrustedCheckOptionPrompt` absent is documented - // as no prompt. - let trusted = unsafe { - let empty: CFDictionary = CFDictionary::from_CFType_pairs(&[]); - AXIsProcessTrustedWithOptions(empty.as_concrete_TypeRef()) - }; - if trusted { + // 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 From fc384c7b4c062fedd7da365d7e16da3e4582e876 Mon Sep 17 00:00:00 2001 From: Leshiy Date: Fri, 31 Jul 2026 13:58:27 +0300 Subject: [PATCH 8/8] setup: silence two more macOS-only lints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/poltertype-input/src/setup/consts.rs | 9 +++++---- crates/poltertype-input/src/setup/macos.rs | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/crates/poltertype-input/src/setup/consts.rs b/crates/poltertype-input/src/setup/consts.rs index 2cca89f..6702264 100644 --- a/crates/poltertype-input/src/setup/consts.rs +++ b/crates/poltertype-input/src/setup/consts.rs @@ -4,10 +4,11 @@ /// tray's link is: it has to describe the current setup script, not /// the release the user happens to be running. /// -/// Only the Linux and macOS probes hand this out — Windows grants -/// nothing and its single step has no action, so the constant is dead -/// code there rather than merely unused. -#[cfg(any(target_os = "linux", target_os = "macos"))] +/// 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"; diff --git a/crates/poltertype-input/src/setup/macos.rs b/crates/poltertype-input/src/setup/macos.rs index b75b8b5..440705a 100644 --- a/crates/poltertype-input/src/setup/macos.rs +++ b/crates/poltertype-input/src/setup/macos.rs @@ -35,14 +35,16 @@ unsafe extern "C" { static kAXTrustedCheckOptionPrompt: CFStringRef; } -/// `IOHIDCheckAccess` / `IOHIDRequestAccess`, macOS 10.15+. -/// -/// 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. +// `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;