From 312287ecc21a71dbe29df2b91bfc67a0d2e617f6 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 18 Sep 2026 17:53:38 +0100 Subject: [PATCH 1/2] Recover Windows switch capture after hook loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Diagnose unexpected mapped-key pass-through without treating Raw Input as gesture delivery. - Replace failed hooks and consume a recovery gesture before rearming scanning. - Preserve cancellation, held-key ownership and generated-event filtering with regression coverage. 🤖 Auto-generated --- docs/switches.md | 2 +- src-tauri/src/scanning_runtime.rs | 21 +- src-tauri/src/switch_input/mod.rs | 18 ++ src-tauri/src/switch_input/windows.rs | 82 +++++- src-tauri/src/switch_input/windows_hook.rs | 311 ++++++++++++++++++++- src-tauri/src/switch_runtime.rs | 18 +- 6 files changed, 442 insertions(+), 10 deletions(-) diff --git a/docs/switches.md b/docs/switches.md index b2de56c5..4b64e0aa 100644 --- a/docs/switches.md +++ b/docs/switches.md @@ -18,7 +18,7 @@ Switchify owns local input through `switch_input`, a platform adapter that suppl Windows captures assigned physical keys and Escape with a low-level keyboard hook on a dedicated message-loop thread. It suppresses the complete press, autorepeat and release sequence while Switchify is foreground, background or minimized, including assigned keys pressed with Shift, Ctrl, Alt or Windows held. Unassigned keys and Switchify-generated shortcuts pass through. F12 remains unavailable. Learning captures supported keys until the first complete press/release; keys held before learning must be released before they can be learned. -The hook makes suppression decisions using thread-owned state and sends generation-tagged edges through a bounded queue. A separate worker feeds gesture timing and cancellation. Stopping discards queued actions but keeps consuming repeats and releases for presses already consumed. Startup failures are reported without a hotkey fallback; shutdown removes the hook. Raw Input is used only for device-removal diagnostics, never gesture delivery. If a disconnected switch was held and no release arrives, reconnect it (or use the same key on another keyboard) and press and release that key once to clear it without an action. Ownership is retained until that release so another still-held switch cannot leak repeats. Worker and application watchdogs detect stalled processing; idle heartbeats do not prove Windows still has the hook installed. Secure-desktop/UAC input is outside this capture's scope. macOS retains its existing event-tap capture. +The hook makes suppression decisions using thread-owned state and sends generation-tagged edges through a bounded queue. A separate worker feeds gesture timing and cancellation. Stopping discards queued actions but keeps consuming repeats and releases for presses already consumed. Startup failures are reported without a hotkey fallback; shutdown removes the hook. Raw Input diagnoses device removal and unexpected pass-through of assigned keys; it never delivers gestures or proves that a suppressed key was released. Unexpected pass-through cancels pending actions and replaces the hook. Recovery reserves the switch keys and asks you to release every held switch, then press and release a switch to reconnect. That recovery gesture executes no action; use a separate Select press to begin again. If an old held-key state is uncertain, an additional release may be needed before the recovery gesture. An event that reveals lost suppression may already have reached the foreground application. If a disconnected switch was held and no release arrives, reconnect it (or use the same key on another keyboard) and press and release that key once to clear it without an action. Ownership is retained until that release so another still-held switch cannot leak repeats. Worker and application watchdogs detect stalled processing; idle heartbeats do not prove Windows still has the hook installed. Secure-desktop/UAC input is outside this capture's scope. macOS retains its existing event-tap capture. The reusable gesture engine handles normal and ordered hold actions. The shared scan controller dispatches them to Session. Native prompts are click-through and nonactivating. diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 65535ba1..62c6f555 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -181,6 +181,9 @@ fn disable(app: &AppHandle, message: &str) { reset_scanner::(app, message); } fn reset_scanner(app: &AppHandle, message: &str) { + reset_scanner_for_capture::(app, message, false); +} +fn reset_scanner_for_capture(app: &AppHandle, message: &str, recovering: bool) { let c = app.state::>(); c.enabled.store(false, Ordering::SeqCst); c.generation.fetch_add(1, Ordering::SeqCst); @@ -199,7 +202,12 @@ fn reset_scanner(app: &AppHandle, message: &str) { c.data.lock().unwrap_or_else(|p| p.into_inner()).message = "Input cleanup will be retried before scanning resumes.".into(); } - app.state::().stop(); + if recovering { + app.state::() + .stop_for_recovery(); + } else { + app.state::().stop(); + } hide_scan_visuals(); let token = c .data @@ -360,7 +368,8 @@ fn switch(app: &AppHandle, action: Action, input_generation: u64, re return; } if !input_active(app, input_generation, remote) { - disable::(app, "Switch capture stopped."); + crate::remote_scan::cancel(app); + reset_scanner_for_capture::(app, "Switch capture stopped.", true); return; } if matches!(action, Action::Cancel | Action::Stop) { @@ -649,7 +658,13 @@ fn tick(app: &AppHandle) { } match event { Event::Stopped { reason, .. } => { - disable::(app, switch_runtime::stop_message(reason)); + crate::remote_scan::cancel(app); + reset_scanner_for_capture::( + app, + switch_runtime::stop_message(reason), + cfg!(target_os = "windows") + && reason == crate::switch_input::StopReason::CaptureLost, + ); return; } Event::Switch { diff --git a/src-tauri/src/switch_input/mod.rs b/src-tauri/src/switch_input/mod.rs index 47f3c243..b4ab4c06 100644 --- a/src-tauri/src/switch_input/mod.rs +++ b/src-tauri/src/switch_input/mod.rs @@ -401,6 +401,13 @@ impl Capture { } #[cfg(target_os = "windows")] { + if self + .native + .as_ref() + .is_some_and(windows::Capture::recovering) + { + bail!("{}", windows_hook::RECOVERY_MESSAGE); + } let mut held = self .driver .core @@ -469,6 +476,17 @@ impl Capture { .unwrap_or_else(|p| p.into_inner()) .stop(StopReason::Disabled); } + pub fn stop_for_recovery(&mut self) { + #[cfg(target_os = "windows")] + if let Some(native) = &self.native { + native.cancel_for_recovery(); + } + self.driver + .core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .stop(StopReason::Disabled); + } } impl Drop for Capture { fn drop(&mut self) { diff --git a/src-tauri/src/switch_input/windows.rs b/src-tauri/src/switch_input/windows.rs index c5fff61a..cce9c33f 100644 --- a/src-tauri/src/switch_input/windows.rs +++ b/src-tauri/src/switch_input/windows.rs @@ -118,11 +118,43 @@ unsafe extern "system" fn keyboard(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT CallNextHookEx(std::ptr::null_mut(), code, wp, lp) } unsafe extern "system" fn window(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT { + if msg == WM_INPUT { + let mut raw: RAWINPUT = std::mem::zeroed(); + let mut size = std::mem::size_of::() as u32; + let read = GetRawInputData( + lp as HRAWINPUT, + RID_INPUT, + &mut raw as *mut _ as *mut _, + &mut size, + std::mem::size_of::() as u32, + ); + if read != u32::MAX + && read >= std::mem::size_of::() as u32 + && raw.header.dwType == RIM_TYPEKEYBOARD + { + let key = raw.data.keyboard; + INPUT.with(|slot| { + if let Ok(mut slot) = slot.try_borrow_mut() { + if let Some(input) = slot.as_mut() { + let now = input.started.elapsed().as_millis().min(u64::MAX as u128) as u64; + input.state.raw( + &input.shared, + key.VKey as u32, + key.Flags & RI_KEY_BREAK as u16 == 0, + crate::input::own_input(key.ExtraInformation as i64), + now, + ); + } + } + }); + } + } if msg == WM_INPUT_DEVICE_CHANGE && wp == GIDC_REMOVAL as usize { INPUT.with(|slot| { - if let Ok(slot) = slot.try_borrow() { - if let Some(input) = slot.as_ref() { - input.shared.fail(StopReason::CaptureLost); + if let Ok(mut slot) = slot.try_borrow_mut() { + if let Some(input) = slot.as_mut() { + let now = input.started.elapsed().as_millis().min(u64::MAX as u128) as u64; + input.state.interrupted(&input.shared, now); } } }); @@ -139,6 +171,23 @@ struct Resources { hook: HHOOK, } impl NativeResources for Resources { + fn replace_hook(&mut self) -> bool { + unsafe { + let replacement = SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard), self.module, 0); + if replacement.is_null() { + return false; + } + if replacement != self.hook + && UnhookWindowsHookEx(self.hook) == 0 + && GetLastError() != ERROR_INVALID_HOOK_HANDLE + { + UnhookWindowsHookEx(replacement); + return false; + } + self.hook = replacement; + true + } + } fn acquire(&mut self, resource: Resource) -> bool { unsafe { match resource { @@ -249,8 +298,14 @@ impl Capture { } } pub fn cancel(&self) { + self.shared.cancel_recovery(); + } + pub fn cancel_for_recovery(&self) { self.shared.cancel(); } + pub fn recovering(&self) -> bool { + self.shared.recovering() + } pub fn await_shutdown(&self) -> Result<()> { let started = std::time::Instant::now(); await_shutdown( @@ -300,6 +355,11 @@ unsafe fn run( shared: Arc, tx: mpsc::SyncSender>, ) { + let escape_ms = driver + .core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .escape_ms; let module = GetModuleHandleW(std::ptr::null()); let class = wide("SwitchifyLocalKeyboardInput"); let wc = WNDCLASSW { @@ -390,6 +450,22 @@ unsafe fn run( } TranslateMessage(&msg); DispatchMessageW(&msg); + let now = driver.now(); + let replace = INPUT.with(|slot| { + slot.borrow_mut() + .as_mut() + .is_some_and(|input| input.state.maintenance(&shared, now, escape_ms)) + }); + if replace { + let replaced = installation + .as_mut() + .is_some_and(Installation::replace_hook); + INPUT.with(|slot| { + if let Some(input) = slot.borrow_mut().as_mut() { + input.state.replaced(replaced, now); + } + }); + } if shared.drained() { break; } diff --git a/src-tauri/src/switch_input/windows_hook.rs b/src-tauri/src/switch_input/windows_hook.rs index 6ef0d1f4..6afb1970 100644 --- a/src-tauri/src/switch_input/windows_hook.rs +++ b/src-tauri/src/switch_input/windows_hook.rs @@ -1,6 +1,8 @@ use super::{Core, Driver, Mode, StopReason, HEARTBEAT_TIMEOUT_MS}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, AtomicUsize, Ordering::*}; +pub(super) const RECOVERY_MESSAGE: &str = "Keyboard capture interrupted. Release your switches, then press and release a switch to reconnect."; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum Resource { RawInput, @@ -10,6 +12,9 @@ pub(super) enum Resource { pub(super) trait NativeResources { fn acquire(&mut self, resource: Resource) -> bool; fn release(&mut self, resource: Resource) -> bool; + fn replace_hook(&mut self) -> bool { + false + } } pub(super) struct Installation<'a, T: NativeResources> { api: &'a mut T, @@ -35,6 +40,9 @@ impl<'a, T: NativeResources> Installation<'a, T> { } success } + pub fn replace_hook(&mut self) -> bool { + self.api.replace_hook() + } } impl Drop for Installation<'_, T> { fn drop(&mut self) { @@ -72,6 +80,8 @@ struct Slot { pub(super) struct Shared { pub enabled: AtomicBool, pub shutdown: AtomicBool, + pub recovering: AtomicBool, + pub recovery_cancelled: AtomicBool, pub owned: [AtomicBool; 256], pub worker_ms: AtomicU64, fault: AtomicU8, @@ -85,6 +95,8 @@ impl Shared { Self { enabled: AtomicBool::new(true), shutdown: AtomicBool::new(false), + recovering: AtomicBool::new(false), + recovery_cancelled: AtomicBool::new(false), owned: std::array::from_fn(|_| AtomicBool::new(false)), worker_ms: AtomicU64::new(now), fault: AtomicU8::new(0), @@ -101,16 +113,25 @@ impl Shared { self.owned.iter().any(|key| key.load(Acquire)) } pub fn drained(&self) -> bool { - !self.enabled.load(Acquire) && !self.has_owned() + !self.enabled.load(Acquire) && !self.recovering() && !self.has_owned() + } + pub fn recovering(&self) -> bool { + self.recovering.load(Acquire) && !self.recovery_cancelled.load(Acquire) } pub fn cancel(&self) { self.enabled.store(false, Release); } + pub fn cancel_recovery(&self) { + self.recovery_cancelled.store(true, Release); + self.recovering.store(false, Release); + self.cancel(); + } pub fn fail(&self, reason: StopReason) { let value = match reason { StopReason::Escape => 1, StopReason::QueueOverflow => 2, StopReason::HeartbeatTimeout => 3, + StopReason::HoldEscape => 5, _ => 4, }; let _ = self.fault.compare_exchange(0, value, AcqRel, Acquire); @@ -156,6 +177,7 @@ impl Shared { 1 => StopReason::Escape, 2 => StopReason::QueueOverflow, 3 => StopReason::HeartbeatTimeout, + 5 => StopReason::HoldEscape, _ => StopReason::CaptureLost, }; if reason == StopReason::CaptureLost { @@ -204,6 +226,20 @@ pub(super) struct State { down: [bool; 256], mode: Mode, learned: Option, + observations: std::collections::VecDeque, + reinstall_at: Option, + quarantine: bool, + recovery_pressed: [Option; 256], + recovered_gesture: bool, + emergency: bool, +} + +#[derive(Clone, Copy)] +struct Observation { + code: u32, + pressed: bool, + raw: bool, + ms: u64, } impl State { pub fn new(mode: Mode, active: [bool; 256], down: [bool; 256]) -> Self { @@ -212,6 +248,96 @@ impl State { down, mode, learned: None, + observations: std::collections::VecDeque::with_capacity(CAPACITY), + reinstall_at: None, + quarantine: false, + recovery_pressed: [None; 256], + recovered_gesture: false, + emergency: false, + } + } + fn watches(&self, shared: &Shared, code: u32) -> bool { + code < 256 + && (self.active[code as usize] || code as usize == ESCAPE) + && (shared.enabled.load(Acquire) + || shared.owned[code as usize].load(Acquire) + || shared.recovering()) + } + fn observe(&mut self, shared: &Shared, code: u32, pressed: bool, raw: bool, ms: u64) { + if !self.watches(shared, code) { + return; + } + if let Some(index) = self.observations.iter().position(|edge| { + edge.code == code + && edge.pressed == pressed + && edge.raw != raw + && ms.saturating_sub(edge.ms) < HEARTBEAT_TIMEOUT_MS + }) { + self.observations.remove(index); + } else if self.observations.len() == CAPACITY { + self.interrupted(shared, ms); + } else { + self.observations.push_back(Observation { + code, + pressed, + raw, + ms, + }); + } + } + pub fn raw(&mut self, shared: &Shared, code: u32, pressed: bool, generated: bool, ms: u64) { + if !generated { + self.observe(shared, code, pressed, true, ms); + } + } + pub fn interrupted(&mut self, shared: &Shared, ms: u64) { + self.observations.clear(); + self.quarantine = false; + self.recovery_pressed.fill(None); + self.recovered_gesture = false; + self.emergency = false; + self.reinstall_at = Some(ms); + shared + .recovering + .store(!shared.recovery_cancelled.load(Acquire), Release); + shared.fail(StopReason::CaptureLost); + } + pub fn maintenance(&mut self, shared: &Shared, ms: u64, escape_ms: u64) -> bool { + if shared.recovery_cancelled.load(Acquire) { + shared.recovering.store(false, Release); + self.quarantine = false; + self.recovery_pressed.fill(None); + } + let lost = self + .observations + .iter() + .any(|edge| edge.raw && ms.saturating_sub(edge.ms) >= HEARTBEAT_TIMEOUT_MS); + self.observations + .retain(|edge| ms.saturating_sub(edge.ms) < HEARTBEAT_TIMEOUT_MS); + if lost { + self.interrupted(shared, ms); + } + if self.quarantine + && self + .recovery_pressed + .iter() + .flatten() + .any(|at| ms.saturating_sub(*at) >= escape_ms) + { + self.emergency = true; + self.recovery_pressed.fill(None); + shared.fail(StopReason::HoldEscape); + } + self.reinstall_at.is_some_and(|at| ms >= at) + } + pub fn replaced(&mut self, success: bool, ms: u64) { + if success { + self.reinstall_at = None; + self.quarantine = true; + self.recovery_pressed.fill(None); + self.observations.clear(); + } else { + self.reinstall_at = Some(ms.saturating_add(2000)); } } pub fn refresh_key(&mut self, code: usize, down: bool) { @@ -228,6 +354,33 @@ impl State { if generated || code >= 256 { return false; } + let consumed = self.physical_key(shared, code, pressed, ms); + if !consumed { + self.observe(shared, code, pressed, false, ms); + } + consumed + } + fn physical_key(&mut self, shared: &Shared, code: u32, pressed: bool, ms: u64) -> bool { + if shared.recovering() && self.watches(shared, code) { + let code = code as usize; + let was_owned = shared.owned[code].swap(pressed, AcqRel); + if self.quarantine { + if code == ESCAPE && pressed { + self.emergency = true; + shared.fail(StopReason::Escape); + } else if pressed && !was_owned { + self.recovery_pressed[code] = Some(ms); + } else if !pressed { + self.recovered_gesture |= self.recovery_pressed[code].take().is_some(); + } + if (self.recovered_gesture || self.emergency) && !shared.has_owned() { + shared.recovering.store(false, Release); + self.quarantine = false; + } + } + self.down[code] = pressed; + return true; + } if shared.enabled.load(Acquire) && ms.saturating_sub(shared.worker_ms.load(Acquire)) >= HEARTBEAT_TIMEOUT_MS { @@ -298,6 +451,162 @@ mod tests { ) } #[test] + fn missing_hook_edges_cancel_actions_and_require_a_separate_recovery_gesture() { + for missed_release in [false, true] { + let (mut state, shared, mut core, names) = setup(Mode::Active); + if missed_release { + assert!(state.key(&shared, 32, true, false, 1)); + } + state.raw(&shared, 32, !missed_release, false, 2); + assert!(!state.maintenance(&shared, 1501, 4000)); + assert!(state.maintenance(&shared, 1502, 4000)); + shared.drain_into(&mut core, &names, 1502); + assert_eq!(core.status.reason, Some(StopReason::CaptureLost)); + assert_eq!(core.events.len(), 1); + assert!(shared.recovering.load(Acquire)); + assert!(!shared.drained()); + state.replaced(true, 1503); + assert!(state.key(&shared, 32, false, false, 1504)); + assert!(shared.recovering.load(Acquire)); + assert!(state.key(&shared, 32, true, false, 1505)); + assert!(state.key(&shared, 32, true, false, 1506)); + assert!(state.key(&shared, 32, false, false, 1507)); + assert!(shared.drained()); + shared.drain_into(&mut core, &names, 1508); + assert_eq!(core.events.len(), 1); + assert!(core.physical.is_empty()); + } + } + #[test] + fn expected_passes_match_raw_in_either_order_and_generated_input_is_ignored() { + for raw_first in [true, false] { + let (mut state, shared, _, _) = setup(Mode::Active); + state.refresh_key(32, true); + if raw_first { + state.raw(&shared, 32, false, false, 1); + } + assert!(!state.key(&shared, 32, false, false, 2)); + if !raw_first { + state.raw(&shared, 32, false, false, 3); + } + state.raw(&shared, 13, true, false, 4); + state.raw(&shared, 32, true, true, 4); + assert!(!state.key(&shared, 32, true, true, 4)); + assert!(!state.maintenance(&shared, 2000, 4000)); + assert!(!shared.recovering.load(Acquire)); + assert!(state.observations.is_empty()); + } + } + #[test] + fn failed_replacement_retries_without_releasing_ownership_or_actions() { + let (mut state, shared, mut core, names) = setup(Mode::Active); + state.key(&shared, 32, true, false, 1); + state.interrupted(&shared, 2); + assert!(state.maintenance(&shared, 2, 4000)); + state.replaced(false, 2); + assert!(!state.maintenance(&shared, 2001, 4000)); + assert!(state.maintenance(&shared, 2002, 4000)); + assert!(shared.owned[32].load(Acquire)); + assert!(state.key(&shared, 32, false, false, 2003)); + assert!(state.key(&shared, 32, true, false, 2004)); + assert!(state.key(&shared, 32, false, false, 2005)); + assert!(shared.recovering.load(Acquire)); + shared.drain_into(&mut core, &names, 2006); + assert_eq!(core.events.len(), 1); + } + #[test] + fn recovery_waits_for_every_uncertain_key_and_cannot_learn_or_run_a_hold() { + let (mut state, shared, mut core, names) = setup(Mode::Learning); + state.key(&shared, 32, true, false, 1); + state.key(&shared, 13, true, false, 2); + state.interrupted(&shared, 3); + shared.drain_into(&mut core, &names, 4); + state.replaced(true, 5); + state.key(&shared, 32, false, false, 6); + state.key(&shared, 32, true, false, 7); + state.key(&shared, 32, false, false, 8); + assert!(shared.recovering.load(Acquire)); + state.key(&shared, 13, false, false, 9); + shared.drain_into(&mut core, &names, 10); + assert!(shared.drained()); + assert_eq!(core.events.len(), 1); + assert!(matches!( + core.events[0], + super::super::Event::Stopped { .. } + )); + } + #[test] + fn recovery_escape_and_emergency_hold_do_not_execute_a_switch() { + for escape in [false, true] { + let (mut state, shared, mut core, names) = setup(Mode::Active); + state.interrupted(&shared, 1); + shared.drain_into(&mut core, &names, 2); + state.replaced(true, 3); + let code = if escape { 27 } else { 32 }; + state.key(&shared, code, true, false, 4); + if !escape { + state.maintenance(&shared, 4004, 4000); + } + state.key(&shared, code, false, false, 4005); + shared.drain_into(&mut core, &names, 4006); + assert!(shared.drained()); + assert_eq!(core.events.len(), 1); + assert_eq!( + core.status.reason, + Some(if escape { + StopReason::Escape + } else { + StopReason::HoldEscape + }) + ); + } + } + #[test] + fn cancellation_wins_a_racing_recovery_publication() { + let (mut state, shared, _, _) = setup(Mode::Active); + state.interrupted(&shared, 1); + shared.cancel_recovery(); + shared.recovering.store(true, Release); + assert!(!shared.recovering()); + assert!(shared.drained()); + assert!(!state.key(&shared, 32, true, false, 2)); + state.maintenance(&shared, 3, 4000); + assert!(!shared.recovering.load(Acquire)); + } + #[test] + fn explicit_cancellation_stops_recovery_and_only_drains_owned_presses() { + for replaced in [false, true] { + let (mut state, shared, mut core, names) = setup(Mode::Learning); + state.key(&shared, 32, true, false, 1); + state.interrupted(&shared, 2); + state.replaced(replaced, 3); + shared.cancel_recovery(); + assert!(!shared.recovering.load(Acquire)); + assert!(!state.key(&shared, 13, true, false, 4)); + assert!(!state.key(&shared, 13, false, false, 5)); + assert!(state.key(&shared, 32, true, false, 6)); + assert!(state.key(&shared, 32, false, false, 7)); + assert!(!state.key(&shared, 32, true, false, 8)); + shared.drain_into(&mut core, &names, 9); + assert!(shared.drained()); + assert_eq!(core.events.len(), 1); + } + } + #[test] + fn diagnostic_ledger_is_bounded_and_idle_is_not_hook_health() { + let (mut state, shared, _, _) = setup(Mode::Active); + assert!(!state.maintenance(&shared, 100000, 4000)); + for i in 0..=CAPACITY { + state.raw(&shared, 32, true, false, 100001 + i as u64); + } + assert!(state.observations.len() <= CAPACITY); + assert!(shared.recovering.load(Acquire)); + state.replaced(true, 100500); + state.raw(&shared, 32, false, false, 100501); + assert!(state.maintenance(&shared, 102001, 4000)); + assert!(!state.quarantine); + } + #[test] fn immediate_restart_waits_for_shutdown_but_not_for_held_keys() { let polls = std::cell::Cell::new(0); let waits = std::cell::Cell::new(0); diff --git a/src-tauri/src/switch_runtime.rs b/src-tauri/src/switch_runtime.rs index 6105dbb0..86198805 100644 --- a/src-tauri/src/switch_runtime.rs +++ b/src-tauri/src/switch_runtime.rs @@ -205,17 +205,28 @@ impl Controller { } pub fn cancel_capture(&self, app: &AppHandle) { let mut d = self.data.lock().unwrap_or_else(|p| p.into_inner()); - if !d.capture.active { + if !d.capture.active && !(cfg!(target_os = "windows") && d.capture.error.is_some()) { return; } + let generation = d.capture_generation; d.capture = CaptureState::default(); drop(d); - self.broker.lock().unwrap_or_else(|p| p.into_inner()).stop(); + let mut broker = self.broker.lock().unwrap_or_else(|p| p.into_inner()); + if broker.status().generation == generation { + broker.stop(); + } + drop(broker); self.publish(app); } pub fn stop(&self) { self.broker.lock().unwrap_or_else(|p| p.into_inner()).stop(); } + pub fn stop_for_recovery(&self) { + self.broker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .stop_for_recovery(); + } pub fn shutdown(&self) { self.broker .lock() @@ -273,6 +284,9 @@ pub fn stop_message(reason: StopReason) -> &'static str { StopReason::HeartbeatTimeout => "Switch capture stopped because its heartbeat was missed.", StopReason::QueueOverflow => "Switch capture stopped because input could not be processed.", StopReason::CaptureLost => { + #[cfg(target_os = "windows")] + return "Keyboard capture interrupted. Release your switches, then press and release a switch to reconnect."; + #[cfg(not(target_os = "windows"))] "Switch capture was lost. Check input permission; scanning retries." } } From c1dbf2ee7c5638e03e65ecf31c915762b56f50f6 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 18 Sep 2026 18:41:51 +0100 Subject: [PATCH 2/2] Isolate Windows capture from WebView focus interference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Run native switch capture in the same signed executable without initializing a WebView - Use bounded private IPC, generation checks and a shared time origin for gesture events - Preserve cancellation and latch worker failures until restart so uncertain held keys cannot re-arm actions - Contain the worker in a kill-on-close job and cover framing, stale events, failure races and clock translation 🤖 Auto-generated --- docs/switches.md | 4 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 4 + src-tauri/src/switch_input/mod.rs | 42 +- src-tauri/src/switch_input/windows.rs | 25 +- src-tauri/src/switch_input/windows_worker.rs | 379 ++++++++++++++++++ .../switch_input/windows_worker_protocol.rs | 277 +++++++++++++ 7 files changed, 702 insertions(+), 31 deletions(-) create mode 100644 src-tauri/src/switch_input/windows_worker.rs create mode 100644 src-tauri/src/switch_input/windows_worker_protocol.rs diff --git a/docs/switches.md b/docs/switches.md index 4b64e0aa..713eb9ab 100644 --- a/docs/switches.md +++ b/docs/switches.md @@ -16,9 +16,9 @@ Existing point-scan commands and event fields remain compatible for geometry and Switchify owns local input through `switch_input`, a platform adapter that supplies generation-tagged press/release events. There is no USAHP runtime or dependency. Output still uses Switchify's existing input adapters. The capture state and macOS event tap were adapted from MIT-licensed code; attribution is retained beside the source. -Windows captures assigned physical keys and Escape with a low-level keyboard hook on a dedicated message-loop thread. It suppresses the complete press, autorepeat and release sequence while Switchify is foreground, background or minimized, including assigned keys pressed with Shift, Ctrl, Alt or Windows held. Unassigned keys and Switchify-generated shortcuts pass through. F12 remains unavailable. Learning captures supported keys until the first complete press/release; keys held before learning must be released before they can be learned. +Windows captures assigned physical keys and Escape with a low-level keyboard hook on a dedicated message-loop thread in an isolated Switchify worker process. The worker uses the same executable and signing identity without starting a WebView. This avoids capture disappearing when Switchify’s WebView has focus. It suppresses the complete press, autorepeat and release sequence while Switchify is foreground, background or minimized, including assigned keys pressed with Shift, Ctrl, Alt or Windows held. Unassigned keys and Switchify-generated shortcuts pass through. F12 remains unavailable. Learning captures supported keys until the first complete press/release; keys held before learning must be released before they can be learned. -The hook makes suppression decisions using thread-owned state and sends generation-tagged edges through a bounded queue. A separate worker feeds gesture timing and cancellation. Stopping discards queued actions but keeps consuming repeats and releases for presses already consumed. Startup failures are reported without a hotkey fallback; shutdown removes the hook. Raw Input diagnoses device removal and unexpected pass-through of assigned keys; it never delivers gestures or proves that a suppressed key was released. Unexpected pass-through cancels pending actions and replaces the hook. Recovery reserves the switch keys and asks you to release every held switch, then press and release a switch to reconnect. That recovery gesture executes no action; use a separate Select press to begin again. If an old held-key state is uncertain, an additional release may be needed before the recovery gesture. An event that reveals lost suppression may already have reached the foreground application. If a disconnected switch was held and no release arrives, reconnect it (or use the same key on another keyboard) and press and release that key once to clear it without an action. Ownership is retained until that release so another still-held switch cannot leak repeats. Worker and application watchdogs detect stalled processing; idle heartbeats do not prove Windows still has the hook installed. Secure-desktop/UAC input is outside this capture's scope. macOS retains its existing event-tap capture. +The hook makes suppression decisions using thread-owned state and sends generation-tagged edges through a bounded queue. A separate worker feeds gesture timing and cancellation. Stopping discards queued actions but keeps consuming repeats and releases for presses already consumed. Startup failures are reported without a hotkey fallback; shutdown removes the hook. Raw Input diagnoses device removal and unexpected pass-through of assigned keys; it never delivers gestures or proves that a suppressed key was released. Unexpected pass-through cancels pending actions and replaces the hook. Recovery reserves the switch keys and asks you to release every held switch, then press and release a switch to reconnect. That recovery gesture executes no action; use a separate Select press to begin again. If an old held-key state is uncertain, an additional release may be needed before the recovery gesture. An event that reveals lost suppression may already have reached the foreground application. If a disconnected switch was held and no release arrives, reconnect it (or use the same key on another keyboard) and press and release that key once to clear it without an action. Ownership is retained until that release so another still-held switch cannot leak repeats. Private bounded IPC carries generation-tagged results; cancellation cannot be undone by a delayed worker response. The worker shares the parent’s time origin, and a Windows job ends it if the application exits. If the worker process dies or IPC stalls, capture stays stopped: release your switches, then restart Switchify. This failure does not automatically clear uncertain held keys or re-arm scanning. Worker and application watchdogs detect stalled processing; idle heartbeats do not prove Windows still has the hook installed. Secure-desktop/UAC input is outside this capture's scope. macOS retains its existing event-tap capture. The reusable gesture engine handles normal and ordered hold actions. The shared scan controller dispatches them to Session. Native prompts are click-through and nonactivating. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 73ed3c0f..3ba05b29 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,7 +46,7 @@ objc2-core-graphics = { version = "0.3.2", default-features = false, features = objc2-foundation = { version = "0.3.2", features = ["NSArray", "NSGeometry", "NSHost", "NSNotification", "NSObject", "NSOperation", "NSString", "NSThread", "NSURL", "block2"] } [target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Security", "Win32_System_JobObjects", "Win32_System_SystemInformation", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] } windows = { version = "0.62.2", features = [ "Devices_Bluetooth", "Devices_Bluetooth_GenericAttributeProfile", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3c14020f..317acb87 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1360,6 +1360,10 @@ fn point_scan_prepare(app: &AppHandle) -> Result<(), String> { } pub fn run() { + #[cfg(target_os = "windows")] + if switch_input::run_worker_from_args() { + return; + } #[cfg(target_os = "macos")] if macos_relaunch::run_from_args() { return; diff --git a/src-tauri/src/switch_input/mod.rs b/src-tauri/src/switch_input/mod.rs index b4ab4c06..c5468ae9 100644 --- a/src-tauri/src/switch_input/mod.rs +++ b/src-tauri/src/switch_input/mod.rs @@ -9,14 +9,23 @@ mod windows; #[cfg(any(target_os = "windows", test))] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] mod windows_hook; +#[cfg(target_os = "windows")] +mod windows_worker; +#[cfg(any(target_os = "windows", test))] +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +mod windows_worker_protocol; use anyhow::{bail, Result}; pub use keys::normalize as normalize_key; +#[cfg(target_os = "windows")] +pub fn run_worker_from_args() -> bool { + windows_worker::run_from_args() +} use std::{ collections::{HashMap, HashSet, VecDeque}, sync::{Arc, Mutex}, time::Instant, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum Action { Pressed, Released, @@ -30,7 +39,7 @@ pub const HEARTBEAT_INTERVAL_MS: u64 = 500; pub const HEARTBEAT_TIMEOUT_MS: u64 = 1500; const QUEUE_LIMIT: usize = 256; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum StopReason { Disabled, Escape, @@ -39,7 +48,7 @@ pub enum StopReason { QueueOverflow, CaptureLost, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum Event { Switch { generation: u64, @@ -56,13 +65,13 @@ pub enum Event { reason: StopReason, }, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum Mode { Off, Learning, Active, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] pub struct Status { pub generation: u64, pub mode: Mode, @@ -70,6 +79,8 @@ pub struct Status { } #[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))] struct Core { + #[cfg(any(target_os = "windows", test))] + worker_failed: bool, status: Status, native_lost: bool, mappings: HashMap, @@ -84,6 +95,8 @@ struct Core { impl Default for Core { fn default() -> Self { Self { + #[cfg(any(target_os = "windows", test))] + worker_failed: false, status: Status { generation: 0, mode: Mode::Off, @@ -307,7 +320,7 @@ impl Driver { pub struct Capture { driver: Driver, #[cfg(target_os = "windows")] - native: Option, + native: Option, #[cfg(target_os = "macos")] native: Option, } @@ -335,6 +348,10 @@ impl Capture { .status } pub fn heartbeat(&self) { + #[cfg(target_os = "windows")] + if let Some(native) = &self.native { + native.check_health(); + } let mut core = self.driver.core.lock().unwrap_or_else(|p| p.into_inner()); core.last_heartbeat = self.driver.now(); } @@ -401,10 +418,19 @@ impl Capture { } #[cfg(target_os = "windows")] { + if self + .driver + .core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .worker_failed + { + bail!("{}", windows_worker_protocol::WORKER_FAILURE_MESSAGE); + } if self .native .as_ref() - .is_some_and(windows::Capture::recovering) + .is_some_and(windows_worker::Capture::recovering) { bail!("{}", windows_hook::RECOVERY_MESSAGE); } @@ -432,7 +458,7 @@ impl Capture { native.await_shutdown()?; } self.native.take(); - self.native = Some(windows::Capture::start(self.driver.clone(), mode)?); + self.native = Some(windows_worker::Capture::start(self.driver.clone(), mode)?); Ok(self.status().generation) } #[cfg(not(target_os = "windows"))] diff --git a/src-tauri/src/switch_input/windows.rs b/src-tauri/src/switch_input/windows.rs index cce9c33f..e9ab4709 100644 --- a/src-tauri/src/switch_input/windows.rs +++ b/src-tauri/src/switch_input/windows.rs @@ -1,5 +1,5 @@ use super::{ - windows_hook::{await_shutdown, Installation, NativeResources, Resource, Shared, State}, + windows_hook::{Installation, NativeResources, Resource, Shared, State}, Driver, Mode, StopReason, }; use anyhow::{bail, Result}; @@ -306,25 +306,10 @@ impl Capture { pub fn recovering(&self) -> bool { self.shared.recovering() } - pub fn await_shutdown(&self) -> Result<()> { - let started = std::time::Instant::now(); - await_shutdown( - || { - let held = self.held_keys(); - if !held.is_empty() { - bail!( - "Release held keys before starting capture: {}.", - held.join(", ") - ); - } - Ok(self - .thread - .as_ref() - .is_none_or(std::thread::JoinHandle::is_finished)) - }, - || std::thread::sleep(Duration::from_millis(1)), - || started.elapsed().as_millis().min(u64::MAX as u128) as u64, - ) + pub fn finished(&self) -> bool { + self.thread + .as_ref() + .is_none_or(std::thread::JoinHandle::is_finished) } pub fn held_keys(&self) -> Vec { known_keys() diff --git a/src-tauri/src/switch_input/windows_worker.rs b/src-tauri/src/switch_input/windows_worker.rs new file mode 100644 index 00000000..0f77c157 --- /dev/null +++ b/src-tauri/src/switch_input/windows_worker.rs @@ -0,0 +1,379 @@ +use super::{ + windows, windows_hook::await_shutdown, windows_worker_protocol as wire, Core, Driver, Mode, + HEARTBEAT_TIMEOUT_MS, +}; +use anyhow::{bail, Context, Result}; +use std::{ + io, + os::windows::{io::AsRawHandle, process::CommandExt}, + process::{Command, Stdio}, + sync::{ + atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering::*}, + mpsc, Arc, Mutex, + }, + thread::JoinHandle, + time::{Duration, Instant}, +}; +use windows_sys::Win32::{ + Foundation::*, + System::{JobObjects::*, SystemInformation::GetTickCount64, Threading::CREATE_NO_WINDOW}, +}; + +const ARGUMENT: &str = "--switchify-capture-worker"; + +struct Job(HANDLE); +unsafe impl Send for Job {} +unsafe impl Sync for Job {} +impl Job { + fn new() -> Result { + unsafe { + let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + if handle.is_null() { + return Err(io::Error::last_os_error().into()); + } + let job = Self(handle); + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + &info as *const _ as *const _, + std::mem::size_of_val(&info) as u32, + ) == 0 + { + return Err(io::Error::last_os_error().into()); + } + Ok(job) + } + } + fn terminate(&self) { + unsafe { + TerminateJobObject(self.0, 1); + } + } +} +impl Drop for Job { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } +} + +struct Shared { + disposing: AtomicBool, + cancellation: AtomicU8, + recovering: AtomicBool, + finished: AtomicBool, + response_ms: AtomicU64, + owned: Mutex>, +} + +pub(super) struct Capture { + driver: Driver, + shared: Arc, + job: Arc, + thread: Option>, +} + +impl Capture { + pub fn start(driver: Driver, mode: Mode) -> Result { + let request = { + let core = driver.core.lock().unwrap_or_else(|p| p.into_inner()); + if core.worker_failed { + bail!(wire::WORKER_FAILURE_MESSAGE); + } + wire::Start { + version: wire::VERSION, + generation: core.status.generation, + mode, + mappings: core.mappings.clone(), + escape_ms: core.escape_ms, + parent_ms: driver.now(), + sent_ticks: unsafe { GetTickCount64() }, + } + }; + let generation = request.generation.wrapping_add(1); + let job = + Arc::new(Job::new().context("Could not create the keyboard capture worker job.")?); + let mut child = Command::new(std::env::current_exe()?) + .arg(ARGUMENT) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .context("Could not start the keyboard capture worker.")?; + if unsafe { AssignProcessToJobObject(job.0, child.as_raw_handle()) } == 0 { + let error = io::Error::last_os_error(); + let _ = child.kill(); + let _ = child.wait(); + return Err(error).context("Could not contain the keyboard capture worker."); + } + let shared = Arc::new(Shared { + disposing: AtomicBool::new(false), + cancellation: AtomicU8::new(0), + recovering: AtomicBool::new(false), + finished: AtomicBool::new(false), + response_ms: AtomicU64::new(driver.now()), + owned: Mutex::new(Vec::new()), + }); + let (tx, rx) = mpsc::sync_channel(1); + let running = shared.clone(); + let events = driver.clone(); + let contained = job.clone(); + let thread = std::thread::Builder::new() + .name("switchify-capture-worker-io".into()) + .spawn(move || { + let mut rejected = false; + let exchange = || -> Result<()> { + let mut input = child + .stdin + .take() + .context("Missing capture worker input.")?; + let mut output = child + .stdout + .take() + .context("Missing capture worker output.")?; + wire::write(&mut input, &request)?; + let initial: wire::Reply = wire::read(&mut output)?; + let snapshot = match initial { + wire::Reply::Ready(state) => state, + wire::Reply::Failed(error) => { + rejected = true; + bail!(error) + } + }; + { + let mut core = events.core.lock().unwrap_or_else(|p| p.into_inner()); + wire::ready(&mut core, &snapshot, generation, mode, events.now())?; + } + running.response_ms.store(events.now(), Release); + tx.send(Ok(())) + .map_err(|_| anyhow::anyhow!("Capture startup was cancelled."))?; + loop { + let heartbeat_ms = events + .core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .last_heartbeat; + wire::write( + &mut input, + &wire::Poll { + generation, + heartbeat_ms, + cancellation: running.cancellation.load(Acquire), + }, + )?; + let snapshot: wire::Snapshot = wire::read(&mut output)?; + { + let mut core = events.core.lock().unwrap_or_else(|p| p.into_inner()); + running.recovering.store(snapshot.recovering, Release); + *running.owned.lock().unwrap_or_else(|p| p.into_inner()) = + snapshot.owned.clone(); + wire::apply( + &mut core, + &snapshot, + generation, + running.cancellation.load(Acquire) != 0, + )?; + } + running.response_ms.store(events.now(), Release); + if snapshot.finished { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + Ok(()) + }; + let mut exchange = exchange; + let outcome = exchange(); + let healthy = outcome.is_ok(); + if let Err(error) = outcome { + let _ = tx.try_send(Err(error.to_string())); + if !rejected && !running.disposing.load(Acquire) { + wire::worker_failed( + &mut events.core.lock().unwrap_or_else(|p| p.into_inner()), + ); + } + } + contained.terminate(); + let _ = child.wait(); + if healthy { + running + .owned + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clear(); + events + .core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .physical + .clear(); + } + running.recovering.store(false, Release); + running.finished.store(true, Release); + })?; + let mut capture = Self { + driver, + shared, + job, + thread: Some(thread), + }; + match rx.recv_timeout(Duration::from_secs(3)) { + Ok(Ok(())) => Ok(capture), + result => { + capture.cancel(); + capture.job.terminate(); + if let Some(thread) = capture.thread.take() { + let _ = thread.join(); + } + capture.driver.lost(); + match result { + Ok(Err(error)) => bail!(error), + _ => bail!("Keyboard capture worker did not acknowledge startup."), + } + } + } + } + pub fn check_health(&self) { + if !self.shared.finished.load(Acquire) + && self + .driver + .now() + .saturating_sub(self.shared.response_ms.load(Acquire)) + >= HEARTBEAT_TIMEOUT_MS + { + self.job.terminate(); + wire::worker_failed(&mut self.driver.core.lock().unwrap_or_else(|p| p.into_inner())); + } + } + pub fn cancel(&self) { + self.shared.cancellation.store(2, Release); + } + pub fn cancel_for_recovery(&self) { + let _ = self + .shared + .cancellation + .compare_exchange(0, 1, AcqRel, Acquire); + } + pub fn recovering(&self) -> bool { + self.shared.recovering.load(Acquire) && self.shared.cancellation.load(Acquire) < 2 + } + pub fn held_keys(&self) -> Vec { + self.shared + .owned + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone() + } + pub fn await_shutdown(&self) -> Result<()> { + let started = Instant::now(); + await_shutdown( + || { + self.check_health(); + let held = self.held_keys(); + if !held.is_empty() { + bail!( + "Release held keys before starting capture: {}.", + held.join(", ") + ); + } + Ok(self.shared.finished.load(Acquire)) + }, + || std::thread::sleep(Duration::from_millis(1)), + || started.elapsed().as_millis() as u64, + ) + } +} +impl Drop for Capture { + fn drop(&mut self) { + self.shared.disposing.store(true, Release); + self.cancel(); + self.job.terminate(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn snapshot(driver: &Driver, native: &windows::Capture) -> wire::Snapshot { + let mut core = driver.core.lock().unwrap_or_else(|p| p.into_inner()); + wire::Snapshot { + status: core.status, + down: core.down.clone(), + owned: native.held_keys(), + native_lost: core.native_lost, + recovering: native.recovering(), + finished: native.finished(), + events: core.events.drain(..).collect(), + } +} + +fn run() -> Result<()> { + let mut input = io::stdin().lock(); + let mut output = io::stdout().lock(); + let request: wire::Start = wire::read(&mut input)?; + if request.version != wire::VERSION + || !matches!(request.mode, Mode::Active | Mode::Learning) + || request.mappings.len() > 128 + || request.escape_ms < 4000 + { + bail!("Invalid keyboard capture worker configuration."); + } + let age_ms = wire::clock_age(request.parent_ms, request.sent_ticks, unsafe { + GetTickCount64() + })?; + let started = Instant::now() + .checked_sub(Duration::from_millis(age_ms)) + .context("Invalid capture clock origin.")?; + let mut core = Core { + mappings: request.mappings, + escape_ms: request.escape_ms, + ..Core::default() + }; + core.status.generation = request.generation; + let driver = Driver { + core: Arc::new(Mutex::new(core)), + started, + }; + let native = match windows::Capture::start(driver.clone(), request.mode) { + Ok(native) => native, + Err(error) => { + wire::write(&mut output, &wire::Reply::Failed(error.to_string()))?; + return Ok(()); + } + }; + wire::write(&mut output, &wire::Reply::Ready(snapshot(&driver, &native)))?; + loop { + let poll: wire::Poll = wire::read(&mut input)?; + if poll.generation != request.generation.wrapping_add(1) || poll.cancellation > 2 { + bail!("Invalid capture worker command."); + } + match poll.cancellation { + 1 => native.cancel_for_recovery(), + 2 => native.cancel(), + _ => {} + } + driver + .core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .last_heartbeat = poll.heartbeat_ms.min(driver.now()); + let state = snapshot(&driver, &native); + let finished = state.finished; + wire::write(&mut output, &state)?; + if finished { + return Ok(()); + } + } +} + +pub(super) fn run_from_args() -> bool { + if std::env::args_os().nth(1).as_deref() != Some(std::ffi::OsStr::new(ARGUMENT)) { + return false; + } + let _ = run(); + true +} diff --git a/src-tauri/src/switch_input/windows_worker_protocol.rs b/src-tauri/src/switch_input/windows_worker_protocol.rs new file mode 100644 index 00000000..36696204 --- /dev/null +++ b/src-tauri/src/switch_input/windows_worker_protocol.rs @@ -0,0 +1,277 @@ +use super::{Core, Event, Mode, Status}; +use anyhow::{bail, Result}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::{ + collections::{HashMap, HashSet}, + io::{Read, Write}, +}; + +const MAX_FRAME: usize = 128 * 1024; +pub(super) const VERSION: u32 = 1; +pub(super) const WORKER_FAILURE_MESSAGE: &str = + "Keyboard capture worker stopped. Release your switches, then restart Switchify."; + +pub(super) fn worker_failed(core: &mut Core) { + core.worker_failed = true; + core.native_lost = true; + core.stop(super::StopReason::CaptureLost); +} + +pub(super) fn ready( + core: &mut Core, + snapshot: &Snapshot, + generation: u64, + mode: Mode, + now: u64, +) -> Result<()> { + if core.worker_failed { + bail!(WORKER_FAILURE_MESSAGE); + } + if core.status.mode != Mode::Off + || core.status.generation.wrapping_add(1) != generation + || snapshot.status.generation != generation + || snapshot.status.mode != mode + { + bail!("Invalid capture worker startup acknowledgement."); + } + core.status = snapshot.status; + core.native_lost = false; + core.last_heartbeat = now; + core.events.clear(); + apply(core, snapshot, generation, false) +} + +pub(super) fn clock_age(parent_ms: u64, sent_ticks: u64, now_ticks: u64) -> Result { + if parent_ms > sent_ticks || sent_ticks > now_ticks { + bail!("Invalid capture worker clock origin."); + } + Ok(parent_ms + (now_ticks - sent_ticks)) +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Start { + pub version: u32, + pub generation: u64, + pub mode: Mode, + pub mappings: HashMap, + pub escape_ms: u64, + pub parent_ms: u64, + pub sent_ticks: u64, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Poll { + pub generation: u64, + pub heartbeat_ms: u64, + pub cancellation: u8, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Snapshot { + pub status: Status, + pub down: HashSet, + pub owned: Vec, + pub native_lost: bool, + pub recovering: bool, + pub finished: bool, + pub events: Vec, +} + +#[derive(Serialize, Deserialize)] +pub(super) enum Reply { + Ready(Snapshot), + Failed(String), +} + +pub(super) fn read(input: &mut impl Read) -> Result { + let mut size = [0; 4]; + input.read_exact(&mut size)?; + let size = u32::from_le_bytes(size) as usize; + if size == 0 || size > MAX_FRAME { + bail!("Invalid capture worker frame size."); + } + let mut bytes = vec![0; size]; + input.read_exact(&mut bytes)?; + Ok(serde_json::from_slice(&bytes)?) +} + +pub(super) fn write(output: &mut impl Write, value: &impl Serialize) -> Result<()> { + let bytes = serde_json::to_vec(value)?; + if bytes.is_empty() || bytes.len() > MAX_FRAME { + bail!("Capture worker frame exceeds its limit."); + } + output.write_all(&(bytes.len() as u32).to_le_bytes())?; + output.write_all(&bytes)?; + output.flush()?; + Ok(()) +} + +pub(super) fn apply( + core: &mut Core, + snapshot: &Snapshot, + generation: u64, + cancelled: bool, +) -> Result<()> { + if snapshot.status.generation != generation + || snapshot.events.len() > super::QUEUE_LIMIT + || snapshot.owned.len() > 256 + || snapshot.down.len() > 256 + { + bail!("Invalid capture worker state."); + } + if snapshot.events.iter().any(|event| match event { + Event::Switch { generation: g, .. } + | Event::Learned { generation: g, .. } + | Event::Stopped { generation: g, .. } => *g != generation, + }) { + bail!("Invalid capture worker event generation."); + } + if core.status.generation != generation { + return Ok(()); + } + core.physical = snapshot.owned.iter().cloned().collect(); + core.down = snapshot.down.clone(); + if !cancelled && !core.worker_failed && core.status.mode != Mode::Off { + core.status = snapshot.status; + core.native_lost = snapshot.native_lost; + if snapshot.status.mode == Mode::Off { + core.events.clear(); + } + for event in &snapshot.events { + core.emit(event.clone()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::switch_input::{Action, StopReason}; + + fn snapshot() -> Snapshot { + Snapshot { + status: Status { + generation: 9, + mode: Mode::Active, + reason: None, + }, + down: HashSet::from(["Space".into()]), + owned: vec!["Space".into()], + native_lost: false, + recovering: false, + finished: false, + events: vec![Event::Switch { + generation: 9, + switch_id: "one".into(), + action: Action::Pressed, + monotonic_ms: 60_000, + }], + } + } + #[test] + fn worker_clock_preserves_long_parent_uptime_and_includes_launch_delay() { + assert_eq!( + clock_age(86_400_000, 900_000_000, 900_000_250).unwrap(), + 86_400_250 + ); + assert!(clock_age(20, 10, 30).is_err()); + assert!(clock_age(10, 30, 20).is_err()); + assert_eq!(clock_age(0, 0, u64::MAX).unwrap(), u64::MAX); + } + #[test] + fn worker_death_latches_failure_even_before_ownership_was_reported() { + for reported in [false, true] { + let mut core = Core::default(); + core.begin(Mode::Active, 0); + core.status.generation = 9; + if reported { + apply(&mut core, &snapshot(), 9, false).unwrap(); + } + worker_failed(&mut core); + assert!(core.worker_failed); + assert_eq!(core.status.mode, Mode::Off); + assert_eq!(core.physical.contains("Space"), reported); + apply(&mut core, &snapshot(), 9, false).unwrap(); + assert_eq!(core.status.mode, Mode::Off); + assert!(!core + .events + .iter() + .any(|e| matches!(e, Event::Switch { .. }))); + core.stop(StopReason::Disabled); + assert!(core.worker_failed); + } + } + #[test] + fn failure_during_shutdown_or_startup_cannot_rearm_capture() { + let mut core = Core::default(); + core.status.generation = 8; + assert!(!core.worker_failed); + worker_failed(&mut core); + assert!(ready(&mut core, &snapshot(), 9, Mode::Active, 60_000).is_err()); + assert_eq!(core.status.mode, Mode::Off); + assert_eq!(core.status.generation, 8); + assert!(core.worker_failed); + } + #[test] + fn bounded_frames_reject_truncation_and_oversized_input_before_allocation() { + assert!(read::(&mut &u32::MAX.to_le_bytes()[..]).is_err()); + let mut bytes = Vec::new(); + write(&mut bytes, &snapshot()).unwrap(); + let restored: Snapshot = read(&mut bytes.as_slice()).unwrap(); + assert_eq!(restored.events, snapshot().events); + bytes.pop(); + assert!(read::(&mut bytes.as_slice()).is_err()); + } + #[test] + fn cancellation_preserves_owned_releases_without_reactivating_or_delivering_actions() { + let mut core = Core::default(); + core.status.generation = 9; + core.stop(StopReason::Disabled); + apply(&mut core, &snapshot(), 9, true).unwrap(); + assert_eq!(core.status.mode, Mode::Off); + assert!(core.physical.contains("Space")); + assert!(core + .events + .iter() + .all(|e| !matches!(e, Event::Switch { .. }))); + let mut released = snapshot(); + released.owned.clear(); + apply(&mut core, &released, 9, true).unwrap(); + assert!(core.physical.is_empty()); + } + #[test] + fn stale_or_mixed_generations_cannot_modify_the_current_session() { + let mut core = Core::default(); + core.status.generation = 10; + apply(&mut core, &snapshot(), 9, false).unwrap(); + assert!(core.physical.is_empty()); + let mut mixed = snapshot(); + mixed.events.push(Event::Stopped { + generation: 8, + reason: StopReason::Escape, + }); + assert!(apply(&mut core, &mixed, 9, false).is_err()); + } + #[test] + fn stopped_snapshot_cancels_queued_actions_and_retains_parent_clock_values() { + let mut core = Core::default(); + core.begin(Mode::Active, 59_000); + core.status.generation = 9; + apply(&mut core, &snapshot(), 9, false).unwrap(); + assert_eq!(core.events.front(), snapshot().events.first()); + let mut failed = snapshot(); + failed.status.mode = Mode::Off; + failed.status.reason = Some(StopReason::CaptureLost); + failed.events = vec![Event::Stopped { + generation: 9, + reason: StopReason::CaptureLost, + }]; + apply(&mut core, &failed, 9, false).unwrap(); + assert_eq!(core.events.len(), 1); + assert_eq!(core.events.front(), failed.events.first()); + } +}