Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions site/src/content/docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,9 @@ impl MacRuntime {
}

fn handle_system_suspend(&mut self) -> Result<(), String> {
if let Some(switches) = self.app.try_state::<crate::switch_runtime::Controller>() {
switches.suspend();
}
self.lifecycle
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
Expand All @@ -559,6 +562,9 @@ impl MacRuntime {
}

fn handle_system_resume(&mut self) -> Result<(), String> {
if let Some(switches) = self.app.try_state::<crate::switch_runtime::Controller>() {
switches.resume();
}
let generation = self
.lifecycle
.lock()
Expand Down
9 changes: 8 additions & 1 deletion src-tauri/src/scanning_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,13 @@ fn wanted<A: Adapter>(app: &AppHandle, config: &A::Config) -> Result<(), String>
if switch_keys_released(app) {
return Err(SWITCH_KEYS_RELEASED_MESSAGE.into());
}
let switches = app.state::<switch_runtime::Controller>().view();
let controller = app.state::<switch_runtime::Controller>();
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);
}
Expand Down Expand Up @@ -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));
Expand Down
81 changes: 81 additions & 0 deletions src-tauri/src/switch_input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
41 changes: 31 additions & 10 deletions src-tauri/src/switch_input/windows_worker.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -66,6 +65,7 @@ struct Shared {
recovering: AtomicBool,
finished: AtomicBool,
response_ms: AtomicU64,
check_ms: AtomicU64,
owned: Mutex<Vec<String>>,
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
39 changes: 39 additions & 0 deletions src-tauri/src/switch_input/windows_worker_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64> {
if parent_ms > sent_ticks || sent_ticks > now_ticks {
bail!("Invalid capture worker clock origin.");
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading