diff --git a/CHANGELOG.md b/CHANGELOG.md index 5197964..abce70d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ 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] + +### Added — macOS: the key gate (opt-in) + +With `POLTERTYPE_HOLD_KEYS=1`, PolterType on macOS now holds your +keystrokes back while a correction types, and replays them behind it +— the race that used to scramble `зтзь ш ` into `ipnpm ` is closed on +a third platform. The event tap moves from listen-only to active +when the gate is on; our own emissions bypass the hold via the +emitter stamp; a tap the OS disables for overrunning its callback +budget is re-enabled instead of going deaf. Validated on Intel +hardware: a 4-key burst fired mid-correction lands exactly once, in +order, in the freshly switched layout. + +Off by default for the same reason as Windows: the flush delays held +keys until the burst ends, which reads as the caret lagging after +every correction. Turn it on if you type fast enough to hit the +race — see `docs/PERMISSIONS.md`. + +Two findings rode along: + +- `core-graphics` 0.24's tap trampoline mapped a callback's `None` + back to the *original* event, so an "active" tap swallowed nothing + — the reason the gate now requires 0.25 (`CallbackResult::Drop`). +- The final post-release sweep sent held keystrokes through + `send_keys`, which is `Unsupported` on macOS and Windows — they are + now emitted via the same `send_text` fallback as the main flush, + closing a narrow window where a fast typist could lose characters + outright. + ## [0.12.0] — the AI socket ships in the box ### Changed diff --git a/Cargo.lock b/Cargo.lock index 63aedbb..aae26ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -842,6 +842,19 @@ dependencies = [ "libc", ] +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "foreign-types", + "libc", +] + [[package]] name = "core-graphics-types" version = "0.1.3" @@ -4056,7 +4069,7 @@ name = "poltertype-input" version = "0.12.0" dependencies = [ "core-foundation 0.10.1", - "core-graphics 0.24.0", + "core-graphics 0.25.0", "crossbeam-channel", "directories", "evdev", diff --git a/README.md b/README.md index aae4db7..24623f5 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ tarde` lands correctly as typed.* > costs a noticeable delay after every correction, which is not a > trade worth making for everyone > ([#7](https://github.com/Just-Code-NET/PolterType/issues/7)). +> On macOS the same hold-back exists too, equally **off by default** +> (`POLTERTYPE_HOLD_KEYS=1`), validated on Intel hardware — see +> [issue #8](https://github.com/Just-Code-NET/PolterType/issues/8). > **macOS: read this > before updating.** 0.6.2 was validated on real hardware (macOS 15, > Intel), but 0.7.0 changed the macOS input path — modifier events now diff --git a/crates/poltertype-core/src/engine/switcher/correction.rs b/crates/poltertype-core/src/engine/switcher/correction.rs index 4926d95..2c00125 100644 --- a/crates/poltertype-core/src/engine/switcher/correction.rs +++ b/crates/poltertype-core/src/engine/switcher/correction.rs @@ -567,9 +567,13 @@ impl SwitcherEngine { } if !last.is_empty() { debug!(count = last.len(), "typing out the last held keystrokes"); - let sent = self.key_emitter.send_keys(&last); - self.push_echoes(self.key_emitter.take_emitted()); - if let Err(e) = sent { + // Not `send_keys` directly: on macOS and Windows + // that is `Unsupported`, and these keystrokes were + // already swallowed from the application — dropping + // them here would lose them outright. Same fix as + // the main flush path; the second call site was + // missed when `emit_held_keys` got its fallback. + if let Err(e) = self.emit_held_keys(&last, to) { warn!(?e, "flushing the last held keystrokes failed"); } } diff --git a/crates/poltertype-input/Cargo.toml b/crates/poltertype-input/Cargo.toml index c69f562..c6c9f16 100644 --- a/crates/poltertype-input/Cargo.toml +++ b/crates/poltertype-input/Cargo.toml @@ -31,7 +31,11 @@ windows = { version = "0.58", features = [ [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.10" -core-graphics = "0.24" +# 0.25 is the first release whose tap callback can actually swallow an +# event (CallbackResult::Drop → NULL). 0.24's trampoline turned our +# `None` back into the original event, so an "active" tap swallowed +# nothing and the key gate doubled the user's keystrokes. +core-graphics = "0.25" [target.'cfg(target_os = "linux")'.dependencies] # Wayland-first: evdev requires the user to be in the `input` group. diff --git a/crates/poltertype-input/src/factory.rs b/crates/poltertype-input/src/factory.rs index 58e3ed1..572089b 100644 --- a/crates/poltertype-input/src/factory.rs +++ b/crates/poltertype-input/src/factory.rs @@ -19,7 +19,11 @@ pub fn create_key_gate() -> KeyGate { { KeyGate::windows(std::sync::Arc::new(windows::WindowsGate::new())) } - #[cfg(not(any(target_os = "linux", windows)))] + #[cfg(target_os = "macos")] + { + KeyGate::macos(std::sync::Arc::new(macos::MacosGate::new())) + } + #[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] { KeyGate::disabled() } @@ -41,7 +45,12 @@ pub fn create_listener(gate: &KeyGate) -> Result, InputEr } #[cfg(target_os = "macos")] { - Ok(Box::new(macos::MacosListener::new())) + // Same wiring as Windows: the tap callback consults the gate + // on every keystroke. + Ok(Box::new(match gate.macos_inner() { + Some(g) => macos::MacosListener::with_gate(std::sync::Arc::clone(g)), + None => macos::MacosListener::new(), + })) } #[cfg(target_os = "linux")] { diff --git a/crates/poltertype-input/src/gate.rs b/crates/poltertype-input/src/gate.rs index ad5def6..c8eb054 100644 --- a/crates/poltertype-input/src/gate.rs +++ b/crates/poltertype-input/src/gate.rs @@ -1,8 +1,9 @@ //! `KeyGate` — the "hold the user's keystrokes back while we type" seam. -// Only the evdev and Windows backends have anything behind the gate; -// elsewhere `KeyGate` is an empty struct and this import would be dead. -#[cfg(any(target_os = "linux", windows))] +// Only the evdev, Windows and macOS backends have anything behind the +// gate; elsewhere `KeyGate` is an empty struct and this import would +// be dead. +#[cfg(any(target_os = "linux", windows, target_os = "macos"))] use std::sync::Arc; /// Holds physical keystrokes back from applications for the duration of @@ -17,10 +18,10 @@ use std::sync::Arc; /// behind the correction in the order they were typed. /// /// A gate that reports `available() == false` is a no-op, and that is -/// the common case: macOS has no implementation, the Windows one is -/// off unless `POLTERTYPE_HOLD_KEYS=1` because it has never run on real -/// hardware, and even the evdev gate stands down on stacks where it -/// would do more harm than good. Callers must therefore treat +/// the common case: the Windows one is off unless +/// `POLTERTYPE_HOLD_KEYS=1` because it has never run on real hardware, +/// and even the evdev gate stands down on stacks where it would do +/// more harm than good. Callers must therefore treat /// [`hold`](Self::hold) returning `false` as normal and stay correct /// without it. #[derive(Clone, Default)] @@ -29,6 +30,8 @@ pub struct KeyGate { inner: Option>, #[cfg(windows)] inner: Option>, + #[cfg(target_os = "macos")] + inner: Option>, } impl KeyGate { @@ -58,15 +61,25 @@ impl KeyGate { self.inner.as_ref() } + #[cfg(target_os = "macos")] + pub(crate) fn macos(inner: Arc) -> Self { + Self { inner: Some(inner) } + } + + #[cfg(target_os = "macos")] + pub(crate) fn macos_inner(&self) -> Option<&Arc> { + self.inner.as_ref() + } + /// Can this gate actually hold keys? Answered by the backend once /// the input stack is up, so it is only meaningful after the /// listener has started. pub fn available(&self) -> bool { - #[cfg(any(target_os = "linux", windows))] + #[cfg(any(target_os = "linux", windows, target_os = "macos"))] { self.inner.as_ref().is_some_and(|g| g.available()) } - #[cfg(not(any(target_os = "linux", windows)))] + #[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] { false } @@ -79,11 +92,11 @@ impl KeyGate { /// the backend also enforces its own ceiling: a caller that dies /// mid-correction cannot leave the keyboard dead. pub fn hold(&self) -> bool { - #[cfg(any(target_os = "linux", windows))] + #[cfg(any(target_os = "linux", windows, target_os = "macos"))] { self.inner.as_ref().is_some_and(|g| g.hold()) } - #[cfg(not(any(target_os = "linux", windows)))] + #[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] { false } @@ -91,7 +104,7 @@ impl KeyGate { /// Let the user's keystrokes through again. Idempotent. pub fn release(&self) { - #[cfg(any(target_os = "linux", windows))] + #[cfg(any(target_os = "linux", windows, target_os = "macos"))] if let Some(g) = self.inner.as_ref() { g.release(); } diff --git a/crates/poltertype-input/src/windows/hold.rs b/crates/poltertype-input/src/hold.rs similarity index 92% rename from crates/poltertype-input/src/windows/hold.rs rename to crates/poltertype-input/src/hold.rs index 1648a7c..a8bde4b 100644 --- a/crates/poltertype-input/src/windows/hold.rs +++ b/crates/poltertype-input/src/hold.rs @@ -1,10 +1,11 @@ -//! The key gate's decision, with no Win32 in it. +//! The key gate's decision, with no OS API in it. //! //! Everything that decides *whether to swallow a keystroke* lives here, -//! deliberately free of `windows-rs`, so it compiles under `cfg(test)` -//! on any host and the safety properties get tested on a machine this -//! project actually has. The hook callback in `listener.rs` does -//! nothing but read an event's flags and ask [`HoldState::swallow`]. +//! deliberately platform-free, so it compiles under `cfg(test)` on any +//! host and the safety properties get tested on machines this project +//! actually has. The Windows hook callback and the macOS event-tap +//! callback each do nothing but read an event's flags and ask +//! [`HoldState::swallow`]. //! //! ## Why this is safer than it sounds //! diff --git a/crates/poltertype-input/src/windows/hold/tests.rs b/crates/poltertype-input/src/hold/tests.rs similarity index 100% rename from crates/poltertype-input/src/windows/hold/tests.rs rename to crates/poltertype-input/src/hold/tests.rs diff --git a/crates/poltertype-input/src/lib.rs b/crates/poltertype-input/src/lib.rs index 262e26b..f620d4c 100644 --- a/crates/poltertype-input/src/lib.rs +++ b/crates/poltertype-input/src/lib.rs @@ -34,6 +34,12 @@ mod windows; mod enums; mod factory; mod gate; +// The key gate's swallow decision, shared by the Windows and macOS +// gates. Pure std, no OS imports — compiled under `cfg(test)` on every +// host so its safety properties are tested where the project actually +// runs CI. +#[cfg(any(windows, target_os = "macos", test))] +mod hold; mod traits; mod types; diff --git a/crates/poltertype-input/src/macos/gate.rs b/crates/poltertype-input/src/macos/gate.rs new file mode 100644 index 0000000..85717a5 --- /dev/null +++ b/crates/poltertype-input/src/macos/gate.rs @@ -0,0 +1,155 @@ +//! `MacosGate` — the key gate's public face on macOS. +//! +//! Thin by design, mirroring the Windows gate: the swallow decision +//! lives in [`HoldState`](crate::hold::HoldState) (pure, tested +//! everywhere); this type owns the two things that are genuinely +//! macOS-shaped — whether the gate is on at all, and whether the event +//! tap is actually there to do the swallowing. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use tracing::{debug, info}; + +use crate::hold::HoldState; + +/// Environment override for the key gate, read once at startup. +/// +/// `POLTERTYPE_HOLD_KEYS=1` turns it on, `=0` off. The **default on +/// macOS is off — same as Windows, and for the same current reason: +/// not fear, but latency.** Held keys are withheld from the +/// application for the length of the flush (engine-side: +/// `HELD_FLUSH_QUIET_PROBES × POST_EMIT_LAG`, ceiling `HELD_FLUSH`), +/// which reads as the caret lagging behind your typing after every +/// correction. Switch it on if you type fast enough to hit the race; +/// `docs/PERMISSIONS.md` states the trade. See +/// `windows/consts::HOLD_KEYS_ENV`. +pub(crate) const HOLD_KEYS_ENV: &str = "POLTERTYPE_HOLD_KEYS"; + +pub struct MacosGate { + state: HoldState, + /// The env override, read once. + enabled: bool, + /// The tap thread attached its tap and is servicing it. The engine + /// must never believe keys are held when nothing is listening — + /// with no tap, `swallow` never fires and the user's keystrokes + /// reach applications as always, so reporting `available` then + /// would make a correction skip its compensation path and lose + /// text. + tap_running: AtomicBool, +} + +impl Default for MacosGate { + fn default() -> Self { + Self::new() + } +} + +impl MacosGate { + pub(crate) fn new() -> Self { + let enabled = std::env::var(HOLD_KEYS_ENV).as_deref() == Ok("1"); + if enabled { + info!( + "key gate enabled by {HOLD_KEYS_ENV}=1 — keystrokes are held back during \ + corrections, at a small delay after each one (see docs/PERMISSIONS.md)" + ); + } + Self { + state: HoldState::new(), + enabled, + tap_running: AtomicBool::new(false), + } + } + + pub(crate) fn available(&self) -> bool { + self.enabled && self.tap_running.load(Ordering::Acquire) + } + + /// Whether the tap should be created active (able to swallow) — + /// i.e. the gate is administratively on. The tap decides this at + /// creation; runtime availability additionally needs the tap up. + pub(crate) fn wants_active_tap(&self) -> bool { + self.enabled + } + + /// Ask for the hold. Returns whether it is in force — `false` means + /// the correction proceeds unprotected, exactly as it always has. + pub(crate) fn hold(&self) -> bool { + if !self.available() { + return false; + } + self.state.hold(); + debug!("key gate: holding"); + true + } + + pub(crate) fn release(&self) { + self.state.release(); + debug!("key gate: released"); + } + + /// Called from the tap callback, once per keystroke. Must stay + /// allocation-free and lock-free — a callback that blocks gets the + /// tap disabled by the OS. + pub(crate) fn swallow(&self, ours: bool) -> bool { + let s = self.state.swallow(ours, self.state.now_ms()); + if s { + debug!("key gate: swallowing user keystroke"); + } + s + } + + /// The tap thread reports its lifecycle here. + pub(crate) fn set_tap_running(&self, running: bool) { + self.tap_running.store(running, Ordering::Release); + if running { + debug!("key gate: tap running — holds are possible"); + } else { + // The tap is gone; nothing can swallow now. Clear any + // armed hold so the next correction doesn't think keys + // are held when they are reaching applications. + self.state.release(); + debug!("key gate: tap stopped — holds unavailable"); + } + } +} + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use super::*; + + #[test] + fn unavailable_until_the_tap_reports_running() { + // Enabled via env so the test is independent of the default. + unsafe { std::env::set_var(HOLD_KEYS_ENV, "1") }; + let g = MacosGate::new(); + assert!(!g.available(), "no tap yet — must not claim to hold"); + assert!(!g.hold(), "hold without a tap reports unheld"); + g.set_tap_running(true); + assert!(g.available()); + assert!(g.hold()); + g.set_tap_running(false); + assert!(!g.available(), "tap gone — holds unavailable again"); + unsafe { std::env::remove_var(HOLD_KEYS_ENV) }; + } + + #[test] + fn env_zero_disables_even_with_a_running_tap() { + unsafe { std::env::set_var(HOLD_KEYS_ENV, "0") }; + let g = MacosGate::new(); + g.set_tap_running(true); + assert!(!g.available()); + assert!(!g.hold()); + unsafe { std::env::remove_var(HOLD_KEYS_ENV) }; + } + + #[test] + fn default_is_opt_in() { + unsafe { std::env::remove_var(HOLD_KEYS_ENV) }; + let g = MacosGate::new(); + g.set_tap_running(true); + assert!( + !g.available(), + "default must be opt-in (latency trade — see docs/PERMISSIONS.md)" + ); + } +} diff --git a/crates/poltertype-input/src/macos/listener.rs b/crates/poltertype-input/src/macos/listener.rs index ed7ee29..11f2a5a 100644 --- a/crates/poltertype-input/src/macos/listener.rs +++ b/crates/poltertype-input/src/macos/listener.rs @@ -1,7 +1,7 @@ //! `CGEventTap` listener: attach, translate, forward. use std::ffi::{c_long, c_void}; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; use std::thread; use std::time::Duration; @@ -13,12 +13,14 @@ use core_foundation::runloop::{ }; use core_graphics::event::{ CGEvent, CGEventFlags, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, CGEventType, + CallbackResult, }; 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 super::consts::{EMITTER_TAG, K_CG_EVENT_SOURCE_USER_DATA, K_CG_KEYBOARD_EVENT_KEYCODE}; +use super::gate::MacosGate; use crate::{InputError, InputListener, KeyDirection, KeyEvent, Modifiers}; // ─── Accessibility permission prompt ───────────────────────────────── @@ -68,11 +70,26 @@ fn sink_slot() -> &'static parking_lot::RwLock>> { pub struct MacosListener { started: bool, + /// The key gate the tap callback consults on every keystroke. + /// `None` = observe-only, the pre-gate behaviour. + gate: Option>, } impl MacosListener { pub fn new() -> Self { - Self { started: false } + Self { + started: false, + gate: None, + } + } + + /// Wire the listener to the gate the engine holds, so the tap + /// callback can swallow a keystroke instead of only observing it. + pub fn with_gate(gate: Arc) -> Self { + Self { + started: false, + gate: Some(gate), + } } } @@ -83,10 +100,11 @@ impl InputListener for MacosListener { } *sink_slot().write() = Some(sink); + let gate = self.gate.clone(); 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)) + .spawn(move || run_tap_thread(gate, ready_tx)) .map_err(|e| InputError::Os(format!("spawn tap thread: {e}")))?; match ready_rx.recv_timeout(Duration::from_secs(3)) { @@ -158,11 +176,45 @@ fn to_key_event(ev_type: CGEventType, event: &CGEvent) -> Option { }) } -fn run_tap_thread(ready_tx: Sender>) { +/// The tap's mach port, stashed after creation so the callback can +/// re-enable the tap if the OS disables it (`kCGEventTapDisabledByTimeout` +/// arrives when a callback overruns its budget — ours is a few atomic +/// loads, but an OS under load can still decide; coming back to life +/// beats staying deaf). +/// +/// One tap per process, by construction: `listener.start()` is called +/// once (main.rs), so the silent first-wins of `OnceLock::set` is +/// never observed. The set also runs after `tap.enable()` — a tap the +/// OS managed to disable inside that gap would re-enable against a +/// stale port, which fails toward keys reaching the application, the +/// safe direction. +static TAP_PORT: OnceLock = OnceLock::new(); + +fn run_tap_thread(gate: Option>, ready_tx: Sender>) { use core_graphics::event::CGEventTapProxy; + // The gate only gets to make swallow decisions when the tap is + // *active* — a listen-only tap's return value is ignored by the + // window server. Disabled-by-env gates keep the old listen-only tap. + let active = gate.as_ref().is_some_and(|g| g.wants_active_tap()); + let gate_for_callback = gate.clone(); + let callback = - |_proxy: CGEventTapProxy, ev_type: CGEventType, event: &CGEvent| -> Option { + move |_proxy: CGEventTapProxy, ev_type: CGEventType, event: &CGEvent| -> CallbackResult { + // The OS turned our tap off — put it back. Delivered on the + // tap itself, not in the key stream. + if matches!( + ev_type, + CGEventType::TapDisabledByTimeout | CGEventType::TapDisabledByUserInput + ) { + if let Some(port) = TAP_PORT.get() { + tracing::warn!(?ev_type, "event tap disabled by the OS; re-enabling"); + // Safety: the port belongs to our live tap. + unsafe { CGEventTapEnable(*port as CFMachPortRef, true) }; + } + return CallbackResult::Keep; + } + 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() { @@ -184,15 +236,38 @@ fn run_tap_thread(ready_tx: Sender>) { } } } + + // The key gate: while a correction burst is on the + // wire, the user's keystrokes are swallowed here (the + // engine already has them — it replays them behind the + // correction). Our own emissions are stamped and must + // always pass, or the correction swallows itself. + // `FlagsChanged` events never get swallowed: holding a + // modifier edge but not its counterpart would leave the + // system modifier state stuck. + if let Some(g) = gate_for_callback.as_ref() { + if matches!(ev_type, CGEventType::KeyDown | CGEventType::KeyUp) { + let ours = event.get_integer_value_field(K_CG_EVENT_SOURCE_USER_DATA) + == EMITTER_TAG; + if g.swallow(ours) { + trace!(scancode = ev_out.scancode, "key held by gate"); + return CallbackResult::Drop; + } + } + } } // Pass-through; we listen but don't suppress. - Some(event.clone()) + CallbackResult::Keep }; let tap = match core_graphics::event::CGEventTap::new( CGEventTapLocation::Session, CGEventTapPlacement::HeadInsertEventTap, - CGEventTapOptions::ListenOnly, + if active { + CGEventTapOptions::Default + } else { + 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 @@ -223,7 +298,7 @@ fn run_tap_thread(ready_tx: Sender>) { // 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 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())); @@ -241,6 +316,10 @@ fn run_tap_thread(ready_tx: Sender>) { ); } tap.enable(); + let _ = TAP_PORT.set(tap.mach_port().as_concrete_TypeRef() as usize); + if let Some(g) = gate.as_ref() { + g.set_tap_running(true); + } let _ = ready_tx.send(Ok(())); @@ -257,6 +336,9 @@ fn run_tap_thread(ready_tx: Sender>) { break; } } + if let Some(g) = gate.as_ref() { + g.set_tap_running(false); + } info!("macOS CGEventTap thread exiting"); } @@ -278,3 +360,8 @@ unsafe extern "C" { order: CFIndex, ) -> CFRunLoopSourceRef; } + +#[link(name = "CoreGraphics", kind = "framework")] +unsafe extern "C" { + fn CGEventTapEnable(tap: CFMachPortRef, enable: bool); +} diff --git a/crates/poltertype-input/src/macos/mod.rs b/crates/poltertype-input/src/macos/mod.rs index 6e32d80..5d15653 100644 --- a/crates/poltertype-input/src/macos/mod.rs +++ b/crates/poltertype-input/src/macos/mod.rs @@ -20,9 +20,10 @@ //! 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.7.0) have not yet run on hardware. +//! > receives events, corrections emit, injected events are +//! > recognised via the user-data tag, and the key gate holds the +//! > user's keystrokes back while a correction types (core-graphics +//! > 0.25 — 0.24's tap trampoline could not swallow). //! //! ## Why this is a directory //! @@ -40,6 +41,8 @@ mod consts; #[cfg(target_os = "macos")] mod emitter; #[cfg(target_os = "macos")] +mod gate; +#[cfg(target_os = "macos")] mod listener; #[cfg(test)] @@ -48,4 +51,6 @@ mod tests; #[cfg(target_os = "macos")] pub use emitter::MacosEmitter; #[cfg(target_os = "macos")] +pub use gate::MacosGate; +#[cfg(target_os = "macos")] pub use listener::MacosListener; diff --git a/crates/poltertype-input/src/windows/gate.rs b/crates/poltertype-input/src/windows/gate.rs index 5a8f68b..d74c5c3 100644 --- a/crates/poltertype-input/src/windows/gate.rs +++ b/crates/poltertype-input/src/windows/gate.rs @@ -1,6 +1,6 @@ //! `WindowsGate` — the key gate's public face on Windows. //! -//! Thin by design: [`HoldState`](super::hold::HoldState) holds every +//! Thin by design: [`HoldState`](crate::hold::HoldState) holds every //! decision and is testable anywhere, while this type owns the two //! things that are genuinely Windows-shaped — whether the gate is //! switched on at all, and the clock the hook callback reads. @@ -10,7 +10,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use tracing::info; use super::consts::HOLD_KEYS_ENV; -use super::hold::HoldState; +use crate::hold::HoldState; pub struct WindowsGate { state: HoldState, diff --git a/crates/poltertype-input/src/windows/mod.rs b/crates/poltertype-input/src/windows/mod.rs index 0e3cc56..519a102 100644 --- a/crates/poltertype-input/src/windows/mod.rs +++ b/crates/poltertype-input/src/windows/mod.rs @@ -2,20 +2,17 @@ //! //! ## Why this is a directory //! -//! `hold` holds the key gate's decision — whether a given keystroke is -//! kept from the focused application — and carries no `windows-rs` -//! dependency, so it compiles under `cfg(test)` on any host and its -//! tests run in CI on Linux and macOS too. That matters more here than -//! anywhere else in the crate: the property being tested is "the user's -//! keyboard always comes back", and this project has no Windows machine -//! to discover otherwise on. +//! The key gate's swallow decision lives one level up, in +//! `crate::hold` — it carries no OS dependency and is shared with the +//! macOS gate, so it compiles under `cfg(test)` on any host and its +//! tests run in CI on Linux and macOS too. That matters more here +//! than anywhere else in the crate: the property being tested is "the +//! user's keyboard always comes back", and this project has no Windows +//! machine to discover otherwise on. //! //! Everything that touches Win32 is `#[cfg(windows)]` and is compiled //! only by CI's `windows-latest` job. -// Compiled under `cfg(test)` everywhere; see above. -pub(crate) mod hold; - #[cfg(windows)] mod consts; #[cfg(windows)] diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 9d964f9..818d4ae 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -171,10 +171,13 @@ permission is needed — it uses the `/dev/input/event*` access > [issue #7](https://github.com/Just-Code-NET/PolterType/issues/7) for > the measurements. No keyboard wedge was observed at any point. > -> **On macOS there is no implementation**, so a keystroke can still -> land inside a correction there — and both bugs above were fixed in -> shared code, so whenever it is switched on there it starts from a -> working replay rather than these two. +> **On macOS the gate exists and is likewise opt-in** +> (`POLTERTYPE_HOLD_KEYS=1`) — same mechanism, same latency trade: +> held keys arrive together after the burst instead of interleaving +> with it. The event tap moves from listen-only to active only when +> the gate is on. Validated on Intel hardware; the replay path (the +> `send_text` fallback that both bugs above were fixed in) is what +> makes it safe to enable there, exactly as predicted above. **It stands down behind an input remapper.** keyd (and anything with the same design) holds every keyboard exclusively — *including diff --git a/docs/PLAN.md b/docs/PLAN.md index 2a2d140..9fb8f38 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -894,9 +894,11 @@ separate `poltertype --settings` process. GIFs of the toggles remain undone. - [ ] **`NSWorkspace` focus tracking** — not implemented, so the `FocusTracker` on macOS is a no-op (see Phase 6 and §3.9). -- [ ] **Keystroke hold-back.** The key gate is Linux/evdev only; on - macOS, as on Windows, a keystroke can still land inside a - correction. +- [ ] **Keystroke hold-back.** The key gate works on Linux/evdev and + on macOS (validated on Intel; opt-in via `POLTERTYPE_HOLD_KEYS=1` + because of the post-correction latency); on Windows it is + implemented but unvalidated, so a keystroke can still land + inside a correction there. - [ ] **Apple Silicon.** Validation so far is Intel-only. ### Phase 6 — Linux