diff --git a/site/src/content/docs/troubleshooting.md b/site/src/content/docs/troubleshooting.md index 20a8b0c4..0eb6681e 100644 --- a/site/src/content/docs/troubleshooting.md +++ b/site/src/content/docs/troubleshooting.md @@ -16,6 +16,8 @@ Switchify PC's Help page has **Setup** and **Troubleshooting** tabs with the sam Holding any switch for about 4 seconds is the emergency stop; so is Escape. Press Select to start again. The scan also resets after three passes without a selection, and when the display or the window in front changes during a point scan. +When the computer goes to sleep, Switchify PC releases the switch keys and shows **Switches paused while the computer sleeps**. After it wakes, scanning starts again on its own within a few seconds; press Select to begin. If the switches stay quiet after wake, release them, wait a moment and press Select once more, or restart Switchify PC. Sleep also ends a session forwarded from your phone; start forwarding again once the computer is awake. + If Home shows **Keys released** under Saved switches, someone chose **Release switch keys** in the tray or menu bar. Choose **Resume switch keys** there or on Home, or restart Switchify PC. ## My switch key no longer types in other apps diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index a943301e..a4364068 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -543,6 +543,9 @@ impl MacRuntime { } fn handle_system_suspend(&mut self) -> Result<(), String> { + if let Some(switches) = self.app.try_state::() { + switches.suspend(); + } self.lifecycle .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -559,6 +562,9 @@ impl MacRuntime { } fn handle_system_resume(&mut self) -> Result<(), String> { + if let Some(switches) = self.app.try_state::() { + switches.resume(); + } let generation = self .lifecycle .lock() diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 20855e98..161372d0 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -431,7 +431,13 @@ fn wanted(app: &AppHandle, config: &A::Config) -> Result<(), String> if switch_keys_released(app) { return Err(SWITCH_KEYS_RELEASED_MESSAGE.into()); } - let switches = app.state::().view(); + let controller = app.state::(); + if controller.suspended() { + return Err( + switch_runtime::stop_message(crate::switch_input::StopReason::Suspended).into(), + ); + } + let switches = controller.view(); if let Some(error) = switches.error { return Err(error); } @@ -1797,6 +1803,7 @@ mod ownership_tests { assert!(super::local_stop_ends_remote(StopReason::Escape)); assert!(super::local_stop_ends_remote(StopReason::HoldEscape)); assert!(super::local_stop_ends_remote(StopReason::Disabled)); + assert!(super::local_stop_ends_remote(StopReason::Suspended)); assert!(!super::local_stop_ends_remote(StopReason::CaptureLost)); assert!(!super::local_stop_ends_remote(StopReason::HeartbeatTimeout)); assert!(!super::local_stop_ends_remote(StopReason::QueueOverflow)); diff --git a/src-tauri/src/switch_input/mod.rs b/src-tauri/src/switch_input/mod.rs index 96f12281..22009672 100644 --- a/src-tauri/src/switch_input/mod.rs +++ b/src-tauri/src/switch_input/mod.rs @@ -62,6 +62,8 @@ pub enum StopReason { HeartbeatTimeout, QueueOverflow, CaptureLost, + /// The computer went to sleep. Capture restarts after wake. + Suspended, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum Event { @@ -155,6 +157,21 @@ impl Core { self.physical.retain(|key| down.contains(key)); self.down = down; } + /// Sleep ends the session. Capture that is already off emits nothing. + fn suspend(&mut self) { + if self.status.mode != Mode::Off { + self.stop(StopReason::Suspended); + } + } + /// Wake is a deliberate retry boundary: a worker that died while the + /// computer slept, or whose heartbeat the sleep interrupted, may start + /// again. Ordinary stops never clear this latch. + fn resume(&mut self) { + #[cfg(any(target_os = "windows", test))] + { + self.worker_failed = false; + } + } fn begin_with_pressed_keys( &mut self, mode: Mode, @@ -528,6 +545,51 @@ impl Capture { .unwrap_or_else(|p| p.into_inner()) .stop(StopReason::Disabled); } + /// The computer is going to sleep. Releases every switch key now so a + /// held switch cannot stay suppressed across the sleep. + pub fn suspend(&mut self) { + #[cfg(target_os = "windows")] + if let Some(native) = &self.native { + native.cancel(); + } + self.driver + .core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .suspend(); + } + /// The computer woke. Scanning re-arms capture on its next tick. + pub fn resume(&mut self) { + #[cfg(target_os = "windows")] + let failed = self + .driver + .core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .worker_failed; + #[cfg(target_os = "windows")] + match &self.native { + Some(native) if failed || native.finished() => { + // The worker process, and with it the hook, is gone (a + // latched failure always terminates its job): no key is + // suppressed, so its last reported ownership is stale. + self.native.take(); + self.driver + .core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .physical + .clear(); + } + Some(native) => native.resume(), + None => {} + } + self.driver + .core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .resume(); + } } impl Drop for Capture { fn drop(&mut self) { @@ -639,6 +701,25 @@ mod tests { assert!(!c.key("A", true, 6001)); } #[test] + fn sleep_stops_an_active_session_once_and_wake_unlatches_the_worker() { + let mut c = core(); + c.key("Space", true, 1); + c.suspend(); + assert_eq!(c.status.mode, Mode::Off); + assert_eq!(c.status.reason, Some(StopReason::Suspended)); + assert_eq!(c.events.len(), 1); + c.events.clear(); + c.suspend(); + assert!(c.events.is_empty()); + c.worker_failed = true; + c.stop(StopReason::Disabled); + assert!(c.worker_failed); + c.resume(); + assert!(!c.worker_failed); + c.begin(Mode::Active, 10); + assert_eq!(c.status.mode, Mode::Active); + } + #[test] fn learns_only_complete_press_and_escape_cancels() { let mut c = Core::default(); c.begin(Mode::Learning, 0); diff --git a/src-tauri/src/switch_input/windows_worker.rs b/src-tauri/src/switch_input/windows_worker.rs index 0f77c157..2694a1f3 100644 --- a/src-tauri/src/switch_input/windows_worker.rs +++ b/src-tauri/src/switch_input/windows_worker.rs @@ -1,6 +1,5 @@ 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::{ @@ -66,6 +65,7 @@ struct Shared { recovering: AtomicBool, finished: AtomicBool, response_ms: AtomicU64, + check_ms: AtomicU64, owned: Mutex>, } @@ -116,6 +116,7 @@ impl Capture { recovering: AtomicBool::new(false), finished: AtomicBool::new(false), response_ms: AtomicU64::new(driver.now()), + check_ms: AtomicU64::new(driver.now()), owned: Mutex::new(Vec::new()), }); let (tx, rx) = mpsc::sync_channel(1); @@ -238,16 +239,36 @@ impl Capture { } } 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())); + if self.shared.finished.load(Acquire) { + return; } + let now = self.driver.now(); + let last_check = self.shared.check_ms.swap(now, AcqRel); + match wire::worker_health(now, last_check, self.shared.response_ms.load(Acquire)) { + wire::WorkerHealth::Healthy => {} + wire::WorkerHealth::ParentStalled => { + // Sleep or a system stall paused this process too. The worker + // gets a full timeout from now before it counts as silent. + let _ = self.shared.response_ms.fetch_max(now, AcqRel); + } + wire::WorkerHealth::Failed => { + self.job.terminate(); + wire::worker_failed( + &mut self.driver.core.lock().unwrap_or_else(|p| p.into_inner()), + ); + } + } + } + /// The computer woke. Silence from before the sleep must not count + /// against the worker, whichever of the health check or the power signal + /// runs first. + pub fn resume(&self) { + let now = self.driver.now(); + self.shared.check_ms.store(now, Release); + let _ = self.shared.response_ms.fetch_max(now, AcqRel); + } + pub fn finished(&self) -> bool { + self.shared.finished.load(Acquire) } pub fn cancel(&self) { self.shared.cancellation.store(2, Release); diff --git a/src-tauri/src/switch_input/windows_worker_protocol.rs b/src-tauri/src/switch_input/windows_worker_protocol.rs index 36696204..9b10e9ee 100644 --- a/src-tauri/src/switch_input/windows_worker_protocol.rs +++ b/src-tauri/src/switch_input/windows_worker_protocol.rs @@ -41,6 +41,31 @@ pub(super) fn ready( apply(core, snapshot, generation, false) } +/// What the parent's periodic health check should do with the worker. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum WorkerHealth { + Healthy, + /// The parent itself missed checks, as it does across sleep or a + /// system-wide stall. The worker could not have answered, so it is + /// given a fresh timeout instead of being terminated. + ParentStalled, + Failed, +} + +pub(super) fn worker_health( + now_ms: u64, + last_check_ms: u64, + last_response_ms: u64, +) -> WorkerHealth { + if now_ms.saturating_sub(last_check_ms) >= super::HEARTBEAT_TIMEOUT_MS { + WorkerHealth::ParentStalled + } else if now_ms.saturating_sub(last_response_ms) >= super::HEARTBEAT_TIMEOUT_MS { + WorkerHealth::Failed + } else { + WorkerHealth::Healthy + } +} + 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."); @@ -173,6 +198,20 @@ mod tests { } } #[test] + fn health_check_terminates_a_silent_worker_but_not_after_the_parent_slept() { + assert_eq!(worker_health(1000, 500, 900), WorkerHealth::Healthy); + assert_eq!(worker_health(2000, 1500, 500), WorkerHealth::Failed); + assert_eq!(worker_health(2000, 1500, 501), WorkerHealth::Healthy); + // The parent slept for an hour: the worker's silence is not its fault. + assert_eq!( + worker_health(3_600_000, 500, 400), + WorkerHealth::ParentStalled + ); + assert_eq!(worker_health(1500, 0, 0), WorkerHealth::ParentStalled); + assert_eq!(worker_health(1499, 0, 0), WorkerHealth::Healthy); + assert_eq!(worker_health(1500, 1000, 0), WorkerHealth::Failed); + } + #[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(), diff --git a/src-tauri/src/switch_runtime.rs b/src-tauri/src/switch_runtime.rs index 5595a7f6..f6cd936e 100644 --- a/src-tauri/src/switch_runtime.rs +++ b/src-tauri/src/switch_runtime.rs @@ -2,7 +2,10 @@ use crate::switch_input::{Capture, Event, Mode, StopReason}; use crate::switches::Settings; use serde::Serialize; -use std::sync::Mutex; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, +}; use tauri::{AppHandle, Emitter, Manager}; #[derive(Debug, Default, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -32,6 +35,9 @@ struct Data { pub struct Controller { data: Mutex, broker: Mutex, + /// Set between the computer's suspend and resume signals. Scanning stays + /// off while it is set, so the switch keys stay released through sleep. + suspended: AtomicBool, } fn path(app: &AppHandle) -> Result { app.path() @@ -97,6 +103,7 @@ impl Controller { error, }), broker: Mutex::new(Capture::new()), + suspended: AtomicBool::new(false), } } pub fn view(&self) -> View { @@ -183,9 +190,40 @@ impl Controller { self.publish(app); Ok(self.view()) } + /// The computer is going to sleep: release the switch keys. Scanning + /// sees the stop and re-arms after wake. + pub fn suspend(&self) { + self.suspended.store(true, Ordering::SeqCst); + self.broker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .suspend(); + } + /// The computer woke: let capture start again even if its worker was + /// lost during the sleep. + pub fn resume(&self) { + self.broker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .resume(); + self.suspended.store(false, Ordering::SeqCst); + } + /// Whether the computer is between its suspend and resume signals. + pub fn suspended(&self) -> bool { + self.suspended.load(Ordering::SeqCst) + } + /// Checked under the broker lock, after scanning's own check, so a + /// suspend that lands between the two cannot start a worker. + fn require_awake(&self) -> Result<(), String> { + if self.suspended() { + return Err(stop_message(StopReason::Suspended).into()); + } + Ok(()) + } pub fn enable_escape(&self) -> Result<(), String> { self.require_switch_control()?; let mut broker = self.broker.lock().unwrap_or_else(|p| p.into_inner()); + self.require_awake()?; broker.stop(); broker.configure(&[], 4000).map_err(|e| e.to_string())?; broker.enable().map_err(|e| e.to_string())?; @@ -208,6 +246,7 @@ impl Controller { }) .collect::>(); let mut broker = self.broker.lock().unwrap_or_else(|p| p.into_inner()); + self.require_awake()?; broker .configure(&mappings, view.settings.escape_ms()) .map_err(|e| e.to_string())?; @@ -315,6 +354,9 @@ pub fn stop_message(reason: StopReason) -> &'static str { StopReason::HoldEscape => "Emergency hold released switch control.", StopReason::HeartbeatTimeout => "Switch capture stopped because its heartbeat was missed.", StopReason::QueueOverflow => "Switch capture stopped because input could not be processed.", + StopReason::Suspended => { + "Switches paused while the computer sleeps. Scanning resumes after wake." + } StopReason::CaptureLost => { #[cfg(target_os = "windows")] return "Keyboard capture interrupted. Release your switches, then press and release a switch to reconnect."; @@ -345,6 +387,33 @@ pub fn install(app: &AppHandle) { mod tests { use super::*; #[test] + fn sleep_holds_scanning_off_until_the_resume_signal() { + let controller = Controller { + data: Mutex::new(Data { + settings: Settings::default(), + capture: CaptureState::default(), + capture_generation: 0, + keyboard_entry: false, + error: None, + }), + broker: Mutex::new(Capture::new()), + suspended: AtomicBool::new(false), + }; + assert!(!controller.suspended()); + controller.suspend(); + assert!(controller.suspended()); + controller.suspend(); + assert!(controller.suspended()); + controller.suspend(); + assert_eq!( + controller.enable_escape().unwrap_err(), + stop_message(StopReason::Suspended) + ); + controller.resume(); + assert!(!controller.suspended()); + assert!(stop_message(StopReason::Suspended).contains("sleeps")); + } + #[test] fn keyboard_entry_excludes_capture_without_changing_bindings() { // An off broker never installs a native adapter in this test. let controller = Controller { @@ -356,6 +425,7 @@ mod tests { error: None, }), broker: Mutex::new(Capture::new()), + suspended: AtomicBool::new(false), }; controller.set_keyboard_entry(true).unwrap(); assert!(controller.view().keyboard_entry); diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index 7beba273..14c43cff 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -595,8 +595,16 @@ fn handle_power_signal( "Bluetooth paused while the PC sleeps.", ); emit_state(&app, &shared); + // Last: this may wait on the main thread finishing a capture + // start, and Bluetooth must already be paused by then. + if let Some(switches) = app.try_state::() { + switches.suspend(); + } } PowerSignal::Resume => { + if let Some(switches) = app.try_state::() { + switches.resume(); + } let generation = lifecycle .lock() .unwrap_or_else(|poisoned| poisoned.into_inner())