From d4d88aaabf32f55f35ae4e9a7c642dfa28d97e9c Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 23 Oct 2026 19:46:15 +0100 Subject: [PATCH 1/3] Restart switch capture after the computer wakes from sleep On Windows the keyboard capture worker is health-checked every 500 ms with a 1.5 s timeout on a clock that keeps counting through sleep. The first check after wake could see the whole sleep as worker silence, terminate the worker and latch worker_failed, which nothing cleared, so switches stayed dead until the app restarted. The health check now notices when the parent itself missed checks and grants the worker a fresh timeout instead. The power monitors on Windows and macOS also stop capture when the computer suspends, so held switch keys are released, and clear the worker latch on resume so scanning can re-arm. A Suspended stop reason carries the pause message, and the troubleshooting page describes the behaviour. Closes #1179 Co-Authored-By: Claude Fable 5.1 --- site/src/content/docs/troubleshooting.md | 2 + src-tauri/src/macos.rs | 6 ++ src-tauri/src/scanning_runtime.rs | 1 + src-tauri/src/switch_input/mod.rs | 57 +++++++++++++++++++ src-tauri/src/switch_input/windows_worker.rs | 30 ++++++---- .../switch_input/windows_worker_protocol.rs | 39 +++++++++++++ src-tauri/src/switch_runtime.rs | 19 +++++++ src-tauri/src/windows_runtime.rs | 6 ++ 8 files changed, 150 insertions(+), 10 deletions(-) diff --git a/site/src/content/docs/troubleshooting.md b/site/src/content/docs/troubleshooting.md index 20a8b0c4..ee9c2c88 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. 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. + 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..46a75098 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -1797,6 +1797,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..517609d5 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,27 @@ 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) { + self.driver + .core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .resume(); + } } impl Drop for Capture { fn drop(&mut self) { @@ -639,6 +677,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..f046be8b 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,15 +239,24 @@ 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()), + ); + } } } pub fn cancel(&self) { 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..04b384e0 100644 --- a/src-tauri/src/switch_runtime.rs +++ b/src-tauri/src/switch_runtime.rs @@ -183,6 +183,22 @@ 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.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(); + } pub fn enable_escape(&self) -> Result<(), String> { self.require_switch_control()?; let mut broker = self.broker.lock().unwrap_or_else(|p| p.into_inner()); @@ -315,6 +331,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."; diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index 7beba273..a1365360 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -584,6 +584,9 @@ fn handle_power_signal( ) { match signal { PowerSignal::Suspend => { + if let Some(switches) = app.try_state::() { + switches.suspend(); + } lifecycle .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -597,6 +600,9 @@ fn handle_power_signal( emit_state(&app, &shared); } PowerSignal::Resume => { + if let Some(switches) = app.try_state::() { + switches.resume(); + } let generation = lifecycle .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) From b04343c75a81cf8a396ec1b4405a5fcf5a74381e Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 23 Oct 2026 19:55:02 +0100 Subject: [PATCH 2/3] Hold scanning off through sleep and refresh the worker clocks on wake Scanning re-armed capture one tick after the suspend stop, so the keys were suppressed again before the computer slept. The switch controller now stays suspended until the resume signal and scanning treats that like released keys. On wake the worker's health clocks are refreshed so pre-sleep silence can never count against it, and a worker that died while the computer slept is dropped with its stale key ownership. Co-Authored-By: Claude Fable 5.1 --- site/src/content/docs/troubleshooting.md | 2 +- src-tauri/src/scanning_runtime.rs | 8 ++++- src-tauri/src/switch_input/mod.rs | 16 +++++++++ src-tauri/src/switch_input/windows_worker.rs | 11 ++++++ src-tauri/src/switch_runtime.rs | 38 +++++++++++++++++++- 5 files changed, 72 insertions(+), 3 deletions(-) diff --git a/site/src/content/docs/troubleshooting.md b/site/src/content/docs/troubleshooting.md index ee9c2c88..975a4f75 100644 --- a/site/src/content/docs/troubleshooting.md +++ b/site/src/content/docs/troubleshooting.md @@ -16,7 +16,7 @@ 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. 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. +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. 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. diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 46a75098..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); } diff --git a/src-tauri/src/switch_input/mod.rs b/src-tauri/src/switch_input/mod.rs index 517609d5..8c798f04 100644 --- a/src-tauri/src/switch_input/mod.rs +++ b/src-tauri/src/switch_input/mod.rs @@ -560,6 +560,22 @@ impl Capture { } /// The computer woke. Scanning re-arms capture on its next tick. pub fn resume(&mut self) { + #[cfg(target_os = "windows")] + match &self.native { + Some(native) if native.finished() => { + // The worker process, and with it the hook, is gone: 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() diff --git a/src-tauri/src/switch_input/windows_worker.rs b/src-tauri/src/switch_input/windows_worker.rs index f046be8b..2694a1f3 100644 --- a/src-tauri/src/switch_input/windows_worker.rs +++ b/src-tauri/src/switch_input/windows_worker.rs @@ -259,6 +259,17 @@ impl Capture { } } } + /// 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_runtime.rs b/src-tauri/src/switch_runtime.rs index 04b384e0..3e4e3c1a 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 { @@ -186,6 +193,7 @@ impl Controller { /// 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()) @@ -198,6 +206,11 @@ impl Controller { .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) } pub fn enable_escape(&self) -> Result<(), String> { self.require_switch_control()?; @@ -364,6 +377,28 @@ 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.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 { @@ -375,6 +410,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); From 3d769e722872e0fa828104744757e4dad2035a95 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 23 Oct 2026 20:03:25 +0100 Subject: [PATCH 3/3] Close the suspend gaps found in review Drop a latched worker on wake even before its io thread has noticed the termination, refuse to enable capture under the broker lock while the computer is suspended, pause switches after Bluetooth in the Windows suspend handler so a slow capture start cannot delay the Bluetooth pause, and tell phone users that sleep ends a forwarded session. Co-Authored-By: Claude Fable 5.1 --- site/src/content/docs/troubleshooting.md | 2 +- src-tauri/src/switch_input/mod.rs | 14 +++++++++++--- src-tauri/src/switch_runtime.rs | 15 +++++++++++++++ src-tauri/src/windows_runtime.rs | 8 +++++--- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/site/src/content/docs/troubleshooting.md b/site/src/content/docs/troubleshooting.md index 975a4f75..0eb6681e 100644 --- a/site/src/content/docs/troubleshooting.md +++ b/site/src/content/docs/troubleshooting.md @@ -16,7 +16,7 @@ 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. +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. diff --git a/src-tauri/src/switch_input/mod.rs b/src-tauri/src/switch_input/mod.rs index 8c798f04..22009672 100644 --- a/src-tauri/src/switch_input/mod.rs +++ b/src-tauri/src/switch_input/mod.rs @@ -561,10 +561,18 @@ impl Capture { /// 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 native.finished() => { - // The worker process, and with it the hook, is gone: no key - // is suppressed, so its last reported ownership is stale. + 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 diff --git a/src-tauri/src/switch_runtime.rs b/src-tauri/src/switch_runtime.rs index 3e4e3c1a..f6cd936e 100644 --- a/src-tauri/src/switch_runtime.rs +++ b/src-tauri/src/switch_runtime.rs @@ -212,9 +212,18 @@ impl Controller { 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())?; @@ -237,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())?; @@ -394,6 +404,11 @@ mod tests { 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")); diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index a1365360..14c43cff 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -584,9 +584,6 @@ fn handle_power_signal( ) { match signal { PowerSignal::Suspend => { - if let Some(switches) = app.try_state::() { - switches.suspend(); - } lifecycle .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -598,6 +595,11 @@ 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::() {