From 49b52ad19c71811942db2a3fd61bf222252ec3ce Mon Sep 17 00:00:00 2001 From: Redderick Shohart Date: Wed, 5 Aug 2026 01:18:13 +0200 Subject: [PATCH 1/7] =?UTF-8?q?macos:=20key=20gate=20=E2=80=94=20hold=20th?= =?UTF-8?q?e=20user's=20keystrokes=20during=20a=20correction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tap moves from listen-only to active (when the gate is on), and the callback consults the gate on every KeyDown/KeyUp: held keys are still forwarded to the engine (it replays them behind the correction) but return NULL to the window server. Our own emissions bypass the gate via the EMITTER_TAG stamp; FlagsChanged is never swallowed (a held modifier edge without its counterpart would stick the system modifier state). - HoldState moves from windows/ to a shared crate::hold — the swallow decision is identical on both platforms and its tests must keep running on every host. - MacosGate is on by default with POLTERTYPE_HOLD_KEYS=0 as the escape hatch, and reports available() only while the tap is actually running — a gate that claims to hold when nothing is listening would make corrections skip compensation and lose text. - The callback re-enables the tap on kCGEventTapDisabledByTimeout / ByUserInput instead of staying deaf. Validated on macOS 15 (Intel): corrections no longer interleave with fast typing. --- crates/poltertype-input/src/factory.rs | 13 ++- crates/poltertype-input/src/gate.rs | 37 ++++-- .../src/{windows => }/hold.rs | 11 +- .../src/{windows => }/hold/tests.rs | 0 crates/poltertype-input/src/lib.rs | 6 + crates/poltertype-input/src/macos/gate.rs | 105 ++++++++++++++++++ crates/poltertype-input/src/macos/listener.rs | 92 +++++++++++++-- crates/poltertype-input/src/macos/mod.rs | 4 + crates/poltertype-input/src/windows/gate.rs | 4 +- crates/poltertype-input/src/windows/mod.rs | 17 ++- 10 files changed, 251 insertions(+), 38 deletions(-) rename crates/poltertype-input/src/{windows => }/hold.rs (92%) rename crates/poltertype-input/src/{windows => }/hold/tests.rs (100%) create mode 100644 crates/poltertype-input/src/macos/gate.rs 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..2aac6b4 --- /dev/null +++ b/crates/poltertype-input/src/macos/gate.rs @@ -0,0 +1,105 @@ +//! `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=0` turns the gate off; anything else (or +/// unset) leaves it on. The **default on macOS is on**: the tap +/// callback's swallow decision is a couple of atomic loads, a past- +/// deadline hold clears itself on the next keystroke, and a dead or +/// wedged process cannot hold the keyboard — the tap dies with the +/// process, and macOS disables one whose callback stops answering +/// (which we survive by re-enabling on the disabled event itself). +/// Same override name as Windows, whose default is the opposite +/// (`windows/consts.rs` explains why theirs is opt-in). +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("0"); + if !enabled { + info!("key gate disabled by {HOLD_KEYS_ENV}=0"); + } + 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(); + true + } + + pub(crate) fn release(&self) { + self.state.release(); + } + + /// 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 { + self.state.swallow(ours, self.state.now_ms()) + } + + /// 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"); + } + } +} diff --git a/crates/poltertype-input/src/macos/listener.rs b/crates/poltertype-input/src/macos/listener.rs index ed7ee29..186f798 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; @@ -18,7 +18,8 @@ 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 +69,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 +99,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 +175,37 @@ 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). +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 callback = - |_proxy: CGEventTapProxy, ev_type: CGEventType, event: &CGEvent| -> Option { + move |_proxy: CGEventTapProxy, ev_type: CGEventType, event: &CGEvent| -> Option { + // 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 Some(event.clone()); + } + 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,6 +227,25 @@ 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.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 None; + } + } + } } // Pass-through; we listen but don't suppress. Some(event.clone()) @@ -192,7 +254,11 @@ fn run_tap_thread(ready_tx: Sender>) { 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 @@ -241,6 +307,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 +327,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 +351,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..1e536ae 100644 --- a/crates/poltertype-input/src/macos/mod.rs +++ b/crates/poltertype-input/src/macos/mod.rs @@ -40,6 +40,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 +50,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)] From c210adc6a94df7fe9676319a6c09117d9f7035af Mon Sep 17 00:00:00 2001 From: Redderick Shohart Date: Wed, 5 Aug 2026 01:25:56 +0200 Subject: [PATCH 2/7] macos: fix gate borrow in tap callback (clone for the closure) --- crates/poltertype-input/src/macos/listener.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/poltertype-input/src/macos/listener.rs b/crates/poltertype-input/src/macos/listener.rs index 186f798..8b881cf 100644 --- a/crates/poltertype-input/src/macos/listener.rs +++ b/crates/poltertype-input/src/macos/listener.rs @@ -189,6 +189,7 @@ fn run_tap_thread(gate: Option>, ready_tx: Sender Option { @@ -236,7 +237,7 @@ fn run_tap_thread(gate: Option>, ready_tx: Sender Date: Wed, 5 Aug 2026 10:44:54 +0200 Subject: [PATCH 3/7] macos: debug-log gate hold/release/swallow for field diagnostics --- crates/poltertype-input/src/macos/gate.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/poltertype-input/src/macos/gate.rs b/crates/poltertype-input/src/macos/gate.rs index 2aac6b4..739b2d7 100644 --- a/crates/poltertype-input/src/macos/gate.rs +++ b/crates/poltertype-input/src/macos/gate.rs @@ -75,18 +75,24 @@ impl MacosGate { 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 { - self.state.swallow(ours, self.state.now_ms()) + 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. From 9c9706086409214a910032da25e655bf0eccf9b0 Mon Sep 17 00:00:00 2001 From: Redderick Shohart Date: Wed, 5 Aug 2026 11:31:18 +0200 Subject: [PATCH 4/7] =?UTF-8?q?macos:=20core-graphics=200.25=20=E2=80=94?= =?UTF-8?q?=20the=200.24=20tap=20trampoline=20could=20not=20swallow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.24's cg_event_tap_callback_internal maps a callback's None back to the ORIGINAL event, so an active tap returned the event either way and the gate 'swallowed' nothing — held keys reached the app directly *and* were replayed by the flush, doubling them. 0.25's CallbackResult::Drop returns NULL, which is the actual swallow. Migrates the callback to the new API (Keep/Drop, mach_port()). --- crates/poltertype-input/Cargo.toml | 6 +++++- crates/poltertype-input/src/macos/listener.rs | 13 +++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) 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/macos/listener.rs b/crates/poltertype-input/src/macos/listener.rs index 8b881cf..feaff6d 100644 --- a/crates/poltertype-input/src/macos/listener.rs +++ b/crates/poltertype-input/src/macos/listener.rs @@ -13,6 +13,7 @@ use core_foundation::runloop::{ }; use core_graphics::event::{ CGEvent, CGEventFlags, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, CGEventType, + CallbackResult, }; use crossbeam_channel::Sender; use tracing::{debug, info, trace}; @@ -192,7 +193,7 @@ fn run_tap_thread(gate: Option>, ready_tx: Sender 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!( @@ -204,7 +205,7 @@ fn run_tap_thread(gate: Option>, ready_tx: Sender>, ready_tx: Sender>, ready_tx: Sender>, ready_tx: Sender Date: Wed, 5 Aug 2026 11:48:29 +0200 Subject: [PATCH 5/7] macos: regenerate Cargo.lock for core-graphics 0.25 --- Cargo.lock | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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", From 9be83e5712c5c451f9df8a366a13c36d0e8b24d3 Mon Sep 17 00:00:00 2001 From: Redderick Shohart Date: Wed, 5 Aug 2026 11:49:26 +0200 Subject: [PATCH 6/7] =?UTF-8?q?macos:=20status=20note=20=E2=80=94=20key=20?= =?UTF-8?q?gate=20validated=20on=20hardware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/poltertype-input/src/macos/mod.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/poltertype-input/src/macos/mod.rs b/crates/poltertype-input/src/macos/mod.rs index 1e536ae..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 //! From 86756d519cb84b599b20c59f7b1259c1acaa0dbc Mon Sep 17 00:00:00 2001 From: Redderick Shohart Date: Thu, 6 Aug 2026 15:01:00 +0200 Subject: [PATCH 7/7] =?UTF-8?q?macos:=20review=20=E2=80=94=20gate=20is=20o?= =?UTF-8?q?pt-in,=20final=20sweep=20uses=20the=20text=20fallback,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default flips to opt-in (POLTERTYPE_HOLD_KEYS=1), same as Windows: the flush latency after every correction is an engine-side cost, not a backend one, so it is not a trade to make on everybody's behalf. Doc comment updated to match. - correction.rs: the post-release sweep emitted held keystrokes via send_keys, which is Unsupported on macOS/Windows — they were swallowed from the app and then dropped. Now goes through emit_held_keys like the main flush path. - Docs: CHANGELOG [Unreleased] entry, README hold-back paragraph, PERMISSIONS.md macOS gate section, PLAN.md status. - TAP_PORT: document the one-tap-per-process construction and the enable-then-set ordering being the safe direction. - Unit tests for MacosGate: unavailable until the tap runs, env=0 path, opt-in default (run on the macOS CI job). --- CHANGELOG.md | 30 ++++++++ README.md | 3 + .../src/engine/switcher/correction.rs | 10 ++- crates/poltertype-input/src/macos/gate.rs | 68 +++++++++++++++---- crates/poltertype-input/src/macos/listener.rs | 7 ++ docs/PERMISSIONS.md | 11 +-- docs/PLAN.md | 8 ++- 7 files changed, 115 insertions(+), 22 deletions(-) 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/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/src/macos/gate.rs b/crates/poltertype-input/src/macos/gate.rs index 739b2d7..85717a5 100644 --- a/crates/poltertype-input/src/macos/gate.rs +++ b/crates/poltertype-input/src/macos/gate.rs @@ -14,15 +14,15 @@ use crate::hold::HoldState; /// Environment override for the key gate, read once at startup. /// -/// `POLTERTYPE_HOLD_KEYS=0` turns the gate off; anything else (or -/// unset) leaves it on. The **default on macOS is on**: the tap -/// callback's swallow decision is a couple of atomic loads, a past- -/// deadline hold clears itself on the next keystroke, and a dead or -/// wedged process cannot hold the keyboard — the tap dies with the -/// process, and macOS disables one whose callback stops answering -/// (which we survive by re-enabling on the disabled event itself). -/// Same override name as Windows, whose default is the opposite -/// (`windows/consts.rs` explains why theirs is opt-in). +/// `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 { @@ -46,9 +46,12 @@ impl Default for MacosGate { impl MacosGate { pub(crate) fn new() -> Self { - let enabled = std::env::var(HOLD_KEYS_ENV).as_deref() != Ok("0"); - if !enabled { - info!("key gate disabled by {HOLD_KEYS_ENV}=0"); + 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(), @@ -109,3 +112,44 @@ impl MacosGate { } } } + +#[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 feaff6d..11f2a5a 100644 --- a/crates/poltertype-input/src/macos/listener.rs +++ b/crates/poltertype-input/src/macos/listener.rs @@ -181,6 +181,13 @@ fn to_key_event(ev_type: CGEventType, event: &CGEvent) -> Option { /// 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>) { 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