From b1bd5dc5782e11c21d1cd86e29b2860d3e1f7ae8 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 13 Sep 2026 18:56:14 +0100 Subject: [PATCH 1/3] Add remote forwarding ownership for PC scanning --- docs/point-scan.md | 8 + src-tauri/build.rs | 2 + src-tauri/capabilities/main.json | 2 + .../autogenerated/get_remote_switches.toml | 11 + .../autogenerated/save_remote_switches.toml | 11 + src-tauri/src/lib.rs | 25 +- src-tauri/src/macos.rs | 64 +- src-tauri/src/point_scan.rs | 3 +- src-tauri/src/point_scan_runtime.rs | 1 + src-tauri/src/protocol.rs | 28 +- src-tauri/src/remote_scan.rs | 545 ++++++++++++++++++ src-tauri/src/scanning_runtime.rs | 125 +++- src-tauri/src/switch_runtime.rs | 7 + src-tauri/src/windows_runtime.rs | 50 +- src/App.tsx | 2 +- src/scanning/useScanning.ts | 1 + src/settings/RemoteSwitches.test.tsx | 27 + src/settings/RemoteSwitches.tsx | 43 ++ src/settings/SwitchesSection.tsx | 2 + 19 files changed, 931 insertions(+), 26 deletions(-) create mode 100644 src-tauri/permissions/autogenerated/get_remote_switches.toml create mode 100644 src-tauri/permissions/autogenerated/save_remote_switches.toml create mode 100644 src-tauri/src/remote_scan.rs create mode 100644 src/settings/RemoteSwitches.test.tsx create mode 100644 src/settings/RemoteSwitches.tsx diff --git a/docs/point-scan.md b/docs/point-scan.md index a354ccdc..af57dd36 100644 --- a/docs/point-scan.md +++ b/docs/point-scan.md @@ -49,3 +49,11 @@ Drag retains the source and scans a destination on the same display without hold The desktop view adds menu, menuSuspended, dragDestination, dragConfirmation and executing phases. Existing point phases, saved settings and Bluetooth commands retain their shapes. Foreground changes, display changes, Android connections, switch editing/learning and shutdown cancel the workflow. Targets and foreground identity remain in memory and are never logged or emitted. Automated action tests use fake input only; physical Windows/macOS focus, scaling, target, scrolling and drag checks remain required for hardware qualification. The action menu uses a fixed grid of square icon tiles with labels beneath the artwork. A yellow border and amber background identify the current row or item. The same artwork is drawn on Windows and macOS. + +## Remote scanning + +Select **Switchify scanning** on Switchify Remote's Android Forwarding screen. Assign up to eight numbered remote slots under PC Settings → Switches → Remote switches. Defaults are Select, Next, Previous, Pause/resume, Reverse and Stop, with two unassigned slots. Remote uses the PC scan mode, colour, movement and hold timing; local keyboard assignments are separate. Manual mode requires connected slots covering Select, Next and Previous. + +Start forwarding, then press Select to begin. Local switch keys are inactive during the remote session; PC Escape remains an emergency stop. Forwarding's hold-to-stop and inactivity limits take precedence over hold actions. Saving remote assignments or shared scanner settings stops the session and changes the profile revision. Reload profiles before starting again. + +Cancelled edges, missing edges and sync mismatches never select. Held-state mismatches cancel the gesture and require neutral input. Sessions expire after five seconds without an authenticated edge or sync. Disconnects and safety stops discard the point and release drag buttons; start explicitly again. Ordinary forwarding and remote scanning cannot own input simultaneously. Local scanning resumes after Remote disconnects. diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 2b02733f..039659d7 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -6,6 +6,8 @@ fn main() { let app_manifest = tauri_build::AppManifest::new().commands(&[ "get_app_state", "get_switches", + "get_remote_switches", + "save_remote_switches", "save_switches", "begin_switch_capture", "cancel_switch_capture", diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index 049b6b74..97c7e611 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -7,6 +7,8 @@ "core:default", "allow-get-app-state", "allow-get-switches", + "allow-get-remote-switches", + "allow-save-remote-switches", "allow-save-switches", "allow-begin-switch-capture", "allow-cancel-switch-capture", diff --git a/src-tauri/permissions/autogenerated/get_remote_switches.toml b/src-tauri/permissions/autogenerated/get_remote_switches.toml new file mode 100644 index 00000000..5cfaa007 --- /dev/null +++ b/src-tauri/permissions/autogenerated/get_remote_switches.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-get-remote-switches" +description = "Enables the get_remote_switches command without any pre-configured scope." +commands.allow = ["get_remote_switches"] + +[[permission]] +identifier = "deny-get-remote-switches" +description = "Denies the get_remote_switches command without any pre-configured scope." +commands.deny = ["get_remote_switches"] diff --git a/src-tauri/permissions/autogenerated/save_remote_switches.toml b/src-tauri/permissions/autogenerated/save_remote_switches.toml new file mode 100644 index 00000000..d4761f4b --- /dev/null +++ b/src-tauri/permissions/autogenerated/save_remote_switches.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-save-remote-switches" +description = "Enables the save_remote_switches command without any pre-configured scope." +commands.allow = ["save_remote_switches"] + +[[permission]] +identifier = "deny-save-remote-switches" +description = "Denies the save_remote_switches command without any pre-configured scope." +commands.deny = ["save_remote_switches"] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 12c8b623..50faf926 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ mod point_scan_activation; mod point_scan_runtime; mod point_workflow; mod protocol; +mod remote_scan; mod scan_executor; mod scan_host; mod scan_menu; @@ -1251,6 +1252,24 @@ fn require_main(window: &tauri::WebviewWindow) -> Result<(), String> { Ok(()) } #[tauri::command] +fn get_remote_switches( + window: tauri::WebviewWindow, + app: AppHandle, +) -> Result { + require_main(&window)?; + Ok(remote_scan::config(&app)) +} +#[tauri::command] +fn save_remote_switches( + window: tauri::WebviewWindow, + app: AppHandle, + config: remote_scan::Config, +) -> Result { + require_main(&window)?; + point_scan_runtime::pause(&app); + remote_scan::save(&app, config) +} +#[tauri::command] fn get_switches( window: tauri::WebviewWindow, app: AppHandle, @@ -1267,6 +1286,7 @@ fn save_switches( require_main(&window)?; // Sync commands run on the main thread, which pausing the overlay needs. point_scan_runtime::pause(&app); + remote_scan::save(&app, remote_scan::config(&app))?; app.state::() .save(&app, settings) } @@ -1320,7 +1340,7 @@ async fn configure_point_scan( /// Pure environment check, safe to call every tick while scanning is off. fn point_scan_ready(app: &AppHandle) -> Result<(), String> { let state = app.state::().snapshot(); - if state.bluetooth == state::BluetoothState::Connected { + if state.bluetooth == state::BluetoothState::Connected && !remote_scan::active(app) { return Err("Local scanning pauses while Android is connected.".into()); } if state.accessibility != state::AccessibilityState::Granted { @@ -1376,6 +1396,7 @@ pub fn run() { .manage(PendingNavigation::default()) .setup(move |app| { switch_runtime::install(app.handle()); + remote_scan::install(app.handle()); point_scan_runtime::install(app.handle()); install_tray(app)?; if updater_is_configured(app.config().plugins.0.get("updater")) { @@ -1467,6 +1488,8 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ get_switches, + get_remote_switches, + save_remote_switches, save_switches, begin_switch_capture, cancel_switch_capture, diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 4ac784d1..7387f700 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -1134,7 +1134,8 @@ impl MacRuntime { self.stop_all_repeats(); let dx = command.dx.round() as i32; let dy = command.dy.round() as i32; - let injection = self.inject_pointer_move(dx, dy); + let injection = crate::remote_scan::allow_direct(&self.app) + .and_then(|_| self.inject_pointer_move(dx, dy)); if injection.is_ok() { let feedback = self .input @@ -1176,7 +1177,8 @@ impl MacRuntime { fn handle_mouse_click(&mut self, command: MouseClickCommand) { self.app.state::().cancel(&self.app); self.stop_all_repeats(); - let injection = self.inject_pointer_click(command.button, command.click_count); + let injection = crate::remote_scan::allow_direct(&self.app) + .and_then(|_| self.inject_pointer_click(command.button, command.click_count)); if injection.is_ok() { self.show_overlay(PointerFeedback::Click { button: command.button, @@ -1232,7 +1234,8 @@ impl MacRuntime { || self.stop_all_repeats(), ); let character_count = command.text.chars().count(); - let injection = self.inject_text(&command.text); + let injection = crate::remote_scan::allow_direct(&self.app) + .and_then(|_| self.inject_text(&command.text)); typing_route.finish(injection.is_ok(), || { self.app.state::().hide_for_typing() }); @@ -1266,6 +1269,53 @@ impl MacRuntime { } fn handle_desktop(&mut self, command: DesktopCommand) { + if command.command_type == "switch.session.start" { + self.stop_all_repeats(); + if let Some(input) = self.input.as_mut() { + if let Err(error) = input.release_all() { + let response = self + .shared + .lock() + .unwrap_or_else(|p| p.into_inner()) + .engine + .complete_desktop_command_with_error( + &command, + Err(("input_failed", error.as_str())), + ); + if let Some(response) = response { + if let Err(error) = self.enqueue_message(&response) { + self.report_error(error); + } + } + return; + } + } + } + if let Some(result) = crate::remote_scan::route( + &self.app, + &command.device_id, + &command.command_type, + &command.payload, + ) { + let response = self + .shared + .lock() + .unwrap_or_else(|p| p.into_inner()) + .engine + .complete_desktop_command_with_error( + &command, + result + .as_ref() + .map(|_| ()) + .map_err(|e| ("input_failed", e.as_str())), + ); + if let Some(response) = response { + if let Err(error) = self.enqueue_message(&response) { + self.report_error(error); + } + } + return; + } if command.command_type == "mouse.repeat.start" { self.handle_repeat_start(command); return; @@ -1296,9 +1346,11 @@ impl MacRuntime { .clone(); if command.command_type == "switch.profile.list" { self.stop_all_repeats(); - if let Err(error) = - self.enqueue_message(&switch_profile_catalog_response(&command.id, &profiles)) - { + if let Err(error) = self.enqueue_message(&crate::remote_scan::catalog( + &self.app, + &command.payload, + switch_profile_catalog_response(&command.id, &profiles), + )) { self.report_error(error); } return; diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index 5b3070f8..1e82b4a9 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -838,6 +838,7 @@ mod tests { assert!(!config.switches().automatic); assert_eq!(config.point().grid_size, 7); let view = crate::scanning_runtime::View { + remote: false, config, enabled: true, phase: Phase::Cell, @@ -847,7 +848,7 @@ mod tests { }; assert_eq!( serde_json::to_value(view).unwrap(), - serde_json::json!({"config":json,"enabled":true,"phase":"cell","paused":true,"message":"Ready","supported":true}) + serde_json::json!({"config":json,"enabled":true,"phase":"cell","paused":true,"message":"Ready","supported":true,"remote":false}) ); } #[test] diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 01aabab2..64c79fe9 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -20,6 +20,7 @@ pub fn configure(app: &AppHandle, config: Config) -> Result { if config.switches().keys() != previous.switches().keys() { return Err("Edit key assignments in Settings → Switches.".into()); } + crate::remote_scan::save(app, crate::remote_scan::config(app))?; scanning_runtime::configure::(app, config) } pub fn pause(app: &AppHandle) { diff --git a/src-tauri/src/protocol.rs b/src-tauri/src/protocol.rs index f2486902..5a6789f9 100644 --- a/src-tauri/src/protocol.rs +++ b/src-tauri/src/protocol.rs @@ -982,9 +982,12 @@ fn valid_desktop_payload(command: &str, payload: &Value) -> bool { .is_some_and(|sequence| sequence > 0) }; match command { - "switch.profile.list" | "connection.disconnecting" | "mouse.repeat.stop" => { + "switch.profile.list" => { object.is_empty() + || (object.len() == 1 + && object.get("includeScanning").is_some_and(Value::is_boolean)) } + "connection.disconnecting" | "mouse.repeat.stop" => object.is_empty(), "mouse.repeat.start" => valid_repeat_start(object), "switch.session.start" => { object.len() == 4 @@ -1400,6 +1403,7 @@ pub fn pointer_profile_response( }, "capabilities": { "noAckMouseMove": true, + "switchScanning": cfg!(any(target_os = "windows", target_os = "macos")), "noAckCommands": [ "mouse.move", "mouse.click", @@ -1580,6 +1584,28 @@ pub fn switch_profile_catalog_response( mod tests { use super::*; + #[test] + fn scanning_catalog_opt_in_preserves_existing_requests() { + assert!(valid_desktop_payload("switch.profile.list", &json!({}))); + assert!(valid_desktop_payload( + "switch.profile.list", + &json!({"includeScanning":true}) + )); + assert!(!valid_desktop_payload( + "switch.profile.list", + &json!({"includeScanning":"yes"}) + )); + let legacy: Value = serde_json::from_str(&switch_profile_catalog_response( + "id", + &crate::state::built_in_profiles(true), + )) + .unwrap(); + assert!(legacy["payload"]["profiles"] + .as_array() + .unwrap() + .iter() + .all(|p| p["kind"] != "scanning")); + } #[test] fn bluetooth_status_advertises_platform_without_a_version_bump() { for platform in ["windows", "macos"] { diff --git a/src-tauri/src/remote_scan.rs b/src-tauri/src/remote_scan.rs new file mode 100644 index 00000000..6d3c8021 --- /dev/null +++ b/src-tauri/src/remote_scan.rs @@ -0,0 +1,545 @@ +//! Remote switch sessions carry edges, never keyboard input. Transport validation +//! and authentication happen before this bounded, generation-scoped mailbox. +use crate::{ + scanning::Action, + switches::{Binding, Settings}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + collections::{HashSet, VecDeque}, + sync::Mutex, + time::Instant, +}; +use tauri::{AppHandle, Manager}; +pub const PROFILE_ID: &str = "builtin.switchify-scanning"; +const MAX_EVENTS: usize = 64; +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Slot { + pub press_action: Option, + pub hold_actions: Vec, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Config { + pub schema_version: u32, + pub revision: u32, + pub slots: Vec, +} +impl Default for Config { + fn default() -> Self { + Self { + schema_version: 1, + revision: 1, + slots: [ + Some(Action::Select), + Some(Action::Next), + Some(Action::Back), + Some(Action::Pause), + Some(Action::Reverse), + Some(Action::Stop), + None, + None, + ] + .into_iter() + .map(|press_action| Slot { + press_action, + hold_actions: vec![], + }) + .collect(), + } + } +} +impl Config { + pub fn validate(&self) -> Result<(), String> { + if self.schema_version != 1 + || self.revision == 0 + || self.slots.len() != 8 + || self.slots.iter().any(|s| { + s.hold_actions.len() > 32 + || s.press_action == Some(Action::Cancel) + || s.hold_actions.contains(&Action::Cancel) + || (s.press_action.is_none() && !s.hold_actions.is_empty()) + }) + { + return Err("Choose valid actions for eight remote switch slots.".into()); + } + Ok(()) + } + pub fn settings(&self, count: usize, interval: u64) -> Settings { + Settings { + schema_version: 1, + hold_interval_ms: interval, + bindings: self + .slots + .iter() + .take(count) + .enumerate() + .filter_map(|(i, s)| { + Some(Binding { + id: (i + 1).to_string(), + name: format!("Remote switch {}", i + 1), + key: format!("F{}", i + 1), + press_action: s.press_action?, + hold_actions: s.hold_actions.clone(), + }) + }) + .collect(), + } + } + pub fn profile(&self) -> Value { + json!({"id":PROFILE_ID,"name":"Switchify scanning","version":self.revision,"kind":"scanning","bindings":self.slots.iter().enumerate().map(|(i,s)| { + let mut label = s.press_action.map_or("Unassigned", Action::label).to_string(); + if !s.hold_actions.is_empty() { label.push_str("; hold: "); label.push_str(&s.hold_actions.iter().map(|a| a.label()).collect::>().join(", ")); } + json!({"switchId":i+1,"label":label,"behavior":if s.press_action.is_some(){"stateful"}else{"unassigned"}}) + }).collect::>()}) + } +} +#[derive(Clone, Debug, PartialEq)] +pub enum Edge { + Down(u8), + Up(u8), + Reset, +} +struct Session { + device: String, + id: String, + sequence: i64, + count: u8, + pressed: HashSet, + neutral: bool, + last_seen: u64, + settings: Settings, +} +pub struct Mailbox { + pub config: Config, + session: Option, + generation: u64, + queue: VecDeque, +} +impl Mailbox { + fn new(config: Config) -> Self { + Self { + config, + session: None, + generation: 0, + queue: VecDeque::new(), + } + } + fn stop(&mut self) { + self.session = None; + self.queue.clear(); + self.generation = self.generation.wrapping_add(1); + } + fn start( + &mut self, + device: &str, + payload: &Value, + interval: u64, + automatic: bool, + now: u64, + ) -> Result<(), String> { + let count = payload["switchCount"] + .as_u64() + .filter(|n| (1..=8).contains(n)) + .ok_or("Invalid remote switch count")? as u8; + if payload["profileVersion"].as_u64() != Some(self.config.revision.into()) { + return Err("The scanning profile changed. Reload profiles and start again.".into()); + } + let id = payload["sessionId"] + .as_str() + .filter(|id| uuid::Uuid::parse_str(id).is_ok()) + .ok_or("Invalid session ID")?; + let settings = self.config.settings(count as usize, interval); + settings.validate_actions(automatic)?; + self.stop(); + self.session = Some(Session { + device: device.into(), + id: id.into(), + count, + sequence: 0, + pressed: HashSet::new(), + neutral: true, + last_seen: now, + settings, + }); + Ok(()) + } + fn accept( + &mut self, + device: &str, + command: &str, + payload: &Value, + now: u64, + ) -> Result<(), String> { + let session = self + .session + .as_mut() + .ok_or("Remote scanning stopped. Start again.")?; + if session.device != device || payload["sessionId"].as_str() != Some(&session.id) { + return Err("Remote scanning session does not match.".into()); + } + let sequence = payload["sequence"] + .as_i64() + .filter(|n| *n > 0) + .ok_or("Invalid switch sequence")?; + if sequence <= session.sequence { + return Ok(()); + } + if command == "switch.session.stop" { + self.stop(); + return Ok(()); + } + if command == "switch.sync" { + let pressed: HashSet = payload["pressedSwitchIds"] + .as_array() + .ok_or("Invalid held switches")? + .iter() + .map(|v| { + v.as_u64() + .filter(|n| *n > 0 && *n <= session.count as u64) + .map(|n| n as u8) + .ok_or("Invalid switch slot") + }) + .collect::>()?; + if pressed != session.pressed { + self.queue.clear(); + self.queue.push_back(Edge::Reset); + session.neutral = true; + } + session.pressed = pressed; + if session.pressed.is_empty() { + session.neutral = false; + } + } else { + let id = payload["switchId"] + .as_u64() + .filter(|n| *n > 0 && *n <= session.count as u64) + .ok_or("Invalid switch slot")? as u8; + let down = match payload["state"].as_str() { + Some("down") => true, + Some("up") => false, + _ => return Err("Invalid switch state".into()), + }; + if sequence != session.sequence + 1 { + self.queue.clear(); + self.queue.push_back(Edge::Reset); + session.neutral = true; + } + let changed = if down { + session.pressed.insert(id) + } else { + session.pressed.remove(&id) + }; + if !session.neutral && changed { + self.queue + .push_back(if down { Edge::Down(id) } else { Edge::Up(id) }); + } + if session.neutral && session.pressed.is_empty() { + session.neutral = false; + } + } + session.sequence = sequence; + session.last_seen = now; + if self.queue.len() > MAX_EVENTS { + self.stop(); + return Err("Remote switch queue overflow. Start again.".into()); + } + Ok(()) + } +} +pub struct Controller { + data: Mutex, + clock: Instant, +} +fn path(app: &AppHandle) -> Result { + Ok(app + .path() + .app_config_dir() + .map_err(|e| e.to_string())? + .join("remote-switch-settings.json")) +} +pub fn install(app: &AppHandle) { + let config = path(app) + .ok() + .and_then(|p| std::fs::read(p).ok()) + .and_then(|b| serde_json::from_slice::(&b).ok()) + .filter(|c| c.validate().is_ok()) + .unwrap_or_default(); + app.manage(Controller { + data: Mutex::new(Mailbox::new(config)), + clock: Instant::now(), + }); +} +pub fn config(app: &AppHandle) -> Config { + app.state::() + .data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .config + .clone() +} +pub fn save(app: &AppHandle, mut config: Config) -> Result { + config.validate()?; + let controller = app.state::(); + let mut data = controller.data.lock().unwrap_or_else(|p| p.into_inner()); + config.revision = data + .config + .revision + .checked_add(1) + .ok_or("Scanning profile revision exhausted")?; + let p = path(app)?; + std::fs::create_dir_all(p.parent().unwrap()).map_err(|e| e.to_string())?; + std::fs::write( + p, + serde_json::to_vec_pretty(&config).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + data.stop(); + data.config = config.clone(); + Ok(config) +} +pub fn cancel(app: &AppHandle) { + if let Some(c) = app.try_state::() { + c.data.lock().unwrap_or_else(|p| p.into_inner()).stop(); + } +} +pub fn active(app: &AppHandle) -> bool { + app.try_state::().is_some_and(|c| { + c.data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .session + .is_some() + }) +} +pub fn allow_direct(app: &AppHandle) -> Result<(), String> { + if active(app) { + Err("Stop Switchify scanning before using other PC controls.".into()) + } else { + Ok(()) + } +} +pub fn active_generation(app: &AppHandle, generation: u64) -> bool { + app.try_state::().is_some_and(|c| { + let d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + d.session.is_some() && d.generation == generation + }) +} +pub fn poll(app: &AppHandle) -> Option<(u64, Settings, Vec, u64)> { + let c = app.state::(); + let now = c.clock.elapsed().as_millis() as u64; + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + if d.session + .as_ref() + .is_some_and(|s| now.saturating_sub(s.last_seen) >= 5000) + { + d.stop(); + } + let settings = d.session.as_ref()?.settings.clone(); + let generation = d.generation; + let edges = d.queue.drain(..).collect(); + Some((generation, settings, edges, now)) +} +/// Called only for authenticated commands. Native input is released by the caller +/// before a start is routed here; actual scanning runs on the next main-thread tick. +pub fn route( + app: &AppHandle, + device: &str, + command: &str, + payload: &Value, +) -> Option> { + let scanning_start = command == "switch.session.start" && payload["profileId"] == PROFILE_ID; + if command == "switch.session.start" { + crate::point_scan_runtime::pause(app); + if let Err(error) = crate::scan_executor::cleanup() { + return Some(Err(error)); + } + } + if scanning_start { + let view = app.state::().view(); + let interval = app + .state::() + .settings() + .hold_interval_ms; + let c = app.state::(); + let now = c.clock.elapsed().as_millis() as u64; + if app + .state::() + .snapshot() + .accessibility + != crate::state::AccessibilityState::Granted + { + return Some(Err("Grant input access before remote scanning.".into())); + } + return Some(c.data.lock().unwrap_or_else(|p| p.into_inner()).start( + device, + payload, + interval, + view.config.automatic, + now, + )); + } + if command == "switch.session.start" || command == "connection.disconnecting" { + cancel(app); + return None; + } + if command == "switch.profile.list" || !active(app) { + return None; + } + if matches!( + command, + "switch.edge" | "switch.sync" | "switch.session.stop" + ) { + let c = app.state::(); + let now = c.clock.elapsed().as_millis() as u64; + return Some( + c.data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .accept(device, command, payload, now), + ); + } + Some(Err( + "Stop Switchify scanning before using other PC controls.".into(), + )) +} +pub fn catalog(app: &AppHandle, payload: &Value, response: String) -> String { + if payload["includeScanning"] != true { + return response; + } + let mut value: Value = serde_json::from_str(&response).expect("local catalog JSON"); + value["payload"]["profiles"] + .as_array_mut() + .expect("local catalog profiles") + .push(config(app).profile()); + value.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + const ID: &str = "00000000-0000-4000-8000-000000000001"; + fn start(count: u8) -> Value { + json!({"sessionId":ID,"profileVersion":1,"switchCount":count}) + } + fn edge(seq: i64, id: u8, state: &str) -> Value { + json!({"sessionId":ID,"sequence":seq,"switchId":id,"state":state}) + } + fn sync(seq: i64, held: Vec) -> Value { + json!({"sessionId":ID,"sequence":seq,"pressedSwitchIds":held}) + } + fn ready() -> Mailbox { + let mut m = Mailbox::new(Config::default()); + m.start("peer", &start(8), 1000, true, 0).unwrap(); + m.accept("peer", "switch.sync", &sync(1, vec![]), 0) + .unwrap(); + m + } + #[test] + fn defaults_validate_connected_slots_and_revision() { + let mut m = Mailbox::new(Config::default()); + m.start("peer", &start(1), 1000, true, 0).unwrap(); + assert!(m.start("peer", &start(1), 1000, false, 0).is_err()); + m.start("peer", &start(3), 1000, false, 0).unwrap(); + let mut changed = start(3); + changed["profileVersion"] = json!(2); + assert!(m.start("peer", &changed, 1000, true, 0).is_err()); + serde_json::from_slice::(&serde_json::to_vec(&m.config).unwrap()) + .unwrap() + .validate() + .unwrap(); + assert_eq!(m.config.profile()["kind"], "scanning"); + } + #[test] + fn duplicates_wrong_owner_and_invalid_slots_do_not_select() { + let mut m = ready(); + assert!(m + .accept("other", "switch.edge", &edge(2, 1, "down"), 1) + .is_err()); + assert!(m + .accept("peer", "switch.edge", &edge(2, 9, "down"), 1) + .is_err()); + for (seq, state) in [(2, "down"), (2, "down"), (3, "up"), (3, "up")] { + m.accept("peer", "switch.edge", &edge(seq, 1, state), seq as u64) + .unwrap(); + } + assert_eq!(m.queue, VecDeque::from([Edge::Down(1), Edge::Up(1)])); + } + #[test] + fn missing_edges_and_resync_cancel_instead_of_selecting() { + let mut m = ready(); + m.accept("peer", "switch.edge", &edge(2, 1, "down"), 1) + .unwrap(); + m.accept("peer", "switch.sync", &sync(3, vec![]), 2) + .unwrap(); + assert_eq!(m.queue, VecDeque::from([Edge::Reset])); + m.queue.clear(); + m.accept("peer", "switch.edge", &edge(5, 1, "up"), 3) + .unwrap(); + assert_eq!(m.queue, VecDeque::from([Edge::Reset])); + m.accept("peer", "switch.edge", &edge(6, 1, "down"), 4) + .unwrap(); + m.accept("peer", "switch.edge", &edge(7, 1, "up"), 5) + .unwrap(); + assert_eq!( + m.queue, + VecDeque::from([Edge::Reset, Edge::Down(1), Edge::Up(1)]) + ); + } + #[test] + fn stop_and_overflow_discard_queued_input() { + let mut m = ready(); + let generation = m.generation; + m.accept("peer", "switch.edge", &edge(2, 1, "down"), 1) + .unwrap(); + m.accept( + "peer", + "switch.session.stop", + &json!({"sessionId":ID,"sequence":3}), + 2, + ) + .unwrap(); + assert!(m.queue.is_empty()); + assert!(m.session.is_none()); + assert_ne!(generation, m.generation); + let mut m = ready(); + for seq in 2..=66 { + let _ = m.accept( + "peer", + "switch.edge", + &edge(seq, 1, if seq % 2 == 0 { "down" } else { "up" }), + seq as u64, + ); + } + assert!(m.session.is_none()); + assert!(m.queue.is_empty()); + } + #[test] + fn remote_edges_use_shared_release_and_hold_gestures() { + let mut m = ready(); + m.config.slots[0].hold_actions = vec![Action::Next]; + let settings = m.config.settings(8, 1000); + let mut g = crate::switch_gestures::Gestures::default(); + m.accept("peer", "switch.edge", &edge(2, 1, "down"), 10) + .unwrap(); + for e in m.queue.drain(..) { + if let Edge::Down(id) = e { + g.pressed(&id.to_string(), 10, &settings); + } + } + assert!(g.held()); + assert_eq!(g.prompt(1010).unwrap().action, Action::Next); + m.accept("peer", "switch.edge", &edge(3, 1, "up"), 1010) + .unwrap(); + for e in m.queue.drain(..) { + if let Edge::Up(id) = e { + assert_eq!(g.released(&id.to_string(), 1010), Some(Action::Next)); + } + } + g.pressed("1", 2000, &settings); + g.cancel(); + assert_eq!(g.released("1", 2200), None); + } +} diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index e7d04e97..996bc88c 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -63,6 +63,8 @@ struct Data { pressed: Gestures, switches: Settings, input_generation: u64, + remote: bool, + remote_hold_started: Option, last_tick: Instant, message: String, next_attempt: Option, @@ -76,6 +78,7 @@ pub struct View { pub paused: bool, pub message: String, pub supported: bool, + pub remote: bool, } impl Controller { pub fn new(app: &AppHandle) -> Self { @@ -96,6 +99,8 @@ impl Controller { pressed: Gestures::default(), switches: Settings::default(), input_generation: 0, + remote: false, + remote_hold_started: None, last_tick: Instant::now(), message: "Starting switch scanning...".into(), next_attempt: None, @@ -113,6 +118,7 @@ impl Controller { .map_or_else(Default::default, |e| e.technique.phase()), paused: d.engine.as_ref().is_some_and(|e| e.paused()), message: d.message.clone(), + remote: d.remote, supported: cfg!(any(target_os = "windows", target_os = "macos")), } } @@ -150,6 +156,7 @@ fn halt_for(app: &AppHandle) { cancel_for::(app); } fn cancel_for(app: &AppHandle) { + crate::remote_scan::cancel(app); let Some(c) = app.try_state::>() else { return; }; @@ -168,6 +175,10 @@ fn cancel_for(app: &AppHandle) { }); } fn disable(app: &AppHandle, message: &str) { + crate::remote_scan::cancel(app); + reset_scanner::(app, message); +} +fn reset_scanner(app: &AppHandle, message: &str) { let c = app.state::>(); c.enabled.store(false, Ordering::SeqCst); c.generation.fetch_add(1, Ordering::SeqCst); @@ -176,6 +187,8 @@ fn disable(app: &AppHandle, message: &str) { d.engine = None; d.display = None; d.pressed.cancel(); + d.remote = false; + d.remote_hold_started = None; d.message = message.into(); } let cleanup = A::cleanup(app); @@ -312,15 +325,20 @@ fn ensure(app: &AppHandle) { } } } +fn input_active(app: &AppHandle, generation: u64) -> bool { + if crate::remote_scan::active(app) { + crate::remote_scan::active_generation(app, generation) + } else { + app.state::() + .active_generation(generation) + } +} fn switch(app: &AppHandle, action: Action, input_generation: u64) { let c = app.state::>(); if !c.enabled.load(Ordering::SeqCst) { return; } - if !app - .state::() - .active_generation(input_generation) - { + if !input_active(app, input_generation) { disable::(app, "Switch capture stopped."); return; } @@ -362,11 +380,7 @@ fn dispatch( ) -> Result<(), String> { A::validate_environment(app, environment)?; let c = app.state::>(); - if !c.enabled.load(Ordering::SeqCst) - || !app - .state::() - .active_generation(input_generation) - { + if !c.enabled.load(Ordering::SeqCst) || !input_active(app, input_generation) { return Err("Scan action was cancelled.".into()); } render_tiles(&[])?; @@ -420,7 +434,88 @@ fn render( } fn tick(app: &AppHandle) { let c = app.state::>(); - let (events, now_ms, _) = app.state::().poll(app); + let remote = crate::remote_scan::poll(app); + let (mut events, local_now, _) = app.state::().poll(app); + let now_ms = remote.as_ref().map_or(local_now, |r| r.3); + let was_remote = c.data.lock().unwrap_or_else(|p| p.into_inner()).remote; + if remote.is_none() && was_remote { + disable::(app, "Remote scanning stopped. Start forwarding again."); + return; + } + if let Some((generation, settings, edges, _)) = remote { + let needs_start = { + let d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + !d.remote || d.input_generation != generation + }; + if needs_start { + events.clear(); + reset_scanner::(app, "Starting remote scanning..."); + let start = (|| -> Result<(), String> { + A::prepare(app)?; + app.state::().enable_escape()?; + HOST.with(|slot| { + if slot.borrow().is_none() { + *slot.borrow_mut() = Some(Host::new()?); + } + Ok::<_, String>(()) + })?; + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + d.switches = settings.clone(); + d.input_generation = generation; + d.remote = true; + d.last_tick = Instant::now(); + d.message = "Remote scanning ready. Press Select on Remote.".into(); + c.enabled.store(true, Ordering::SeqCst); + Ok(()) + })(); + if let Err(error) = start { + disable::(app, &error); + return; + } + publish::(app); + } + for edge in edges { + let action = { + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + match edge { + crate::remote_scan::Edge::Reset => { + d.pressed.cancel(); + d.remote_hold_started = None; + None + } + crate::remote_scan::Edge::Down(id) => { + if !d.pressed.held() { + d.remote_hold_started = Some(now_ms); + } + d.pressed.pressed(&id.to_string(), now_ms, &settings); + None + } + crate::remote_scan::Edge::Up(id) => { + let action = d.pressed.released(&id.to_string(), now_ms); + if !d.pressed.held() { + d.remote_hold_started = None; + } + action + } + } + }; + if let Some(action) = action { + switch::(app, action, generation); + } + if !c.enabled.load(Ordering::SeqCst) { + return; + } + } + let expired = { + let d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + d.remote_hold_started + .is_some_and(|start| now_ms.saturating_sub(start) >= settings.escape_ms()) + }; + if expired { + disable::(app, "Remote switch held. Start forwarding again."); + return; + } + } for event in events { if !c.enabled.load(Ordering::SeqCst) { break; @@ -438,7 +533,8 @@ fn tick(app: &AppHandle) { } => { let selected = { let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); - if generation != d.input_generation + if d.remote + || generation != d.input_generation || !app .state::() .active_generation(generation) @@ -467,6 +563,7 @@ fn tick(app: &AppHandle) { } if app.state::().snapshot().bluetooth == crate::state::BluetoothState::Connected + && !crate::remote_scan::active(app) { disable::(app, "Android connected. Local scanning stopped."); return; @@ -501,7 +598,11 @@ fn tick(app: &AppHandle) { render::(app, prompt.as_ref()) })(); if let Err(error) = result { - disable::(app, &error); + if crate::remote_scan::active(app) && A::ready(app).is_ok() { + reset_scanner::(app, &error); + } else { + disable::(app, &error); + } } } pub fn install(app: &AppHandle) { diff --git a/src-tauri/src/switch_runtime.rs b/src-tauri/src/switch_runtime.rs index 67e6a57d..6105dbb0 100644 --- a/src-tauri/src/switch_runtime.rs +++ b/src-tauri/src/switch_runtime.rs @@ -154,6 +154,13 @@ impl Controller { self.publish(app); Ok(self.view()) } + pub fn enable_escape(&self) -> Result<(), String> { + let mut broker = self.broker.lock().unwrap_or_else(|p| p.into_inner()); + broker.stop(); + broker.configure(&[], 4000).map_err(|e| e.to_string())?; + broker.enable().map_err(|e| e.to_string())?; + Ok(()) + } pub fn enable(&self, automatic: bool) -> Result<(), String> { let view = self.view(); if let Some(e) = view.error { diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index 22a0b70f..d5d53195 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -1483,6 +1483,7 @@ fn complete_mouse_move( .settings .pointer_scale_percent; let result = with_runtime_input(|input| { + crate::remote_scan::allow_direct(app)?; input.set_pointer_scale_percent(scale); input.move_pointer(command.dx.round() as i32, command.dy.round() as i32)?; Ok(input.pointer_feedback_for_move()) @@ -1507,8 +1508,10 @@ fn complete_mouse_click( ) -> Option { app.state::().cancel(app); stop_all_repeats(app); - let result = - with_runtime_input(|input| input.click_pointer(command.button, command.click_count)); + let result = with_runtime_input(|input| { + crate::remote_scan::allow_direct(app)?; + input.click_pointer(command.button, command.click_count) + }); if result.is_ok() { show_overlay( app, @@ -1534,7 +1537,10 @@ fn complete_text(app: &AppHandle, shared: &SharedModel, command: TextCommand) -> || app.state::().cancel(app), || stop_all_repeats(app), ); - let result = with_runtime_input(|input| input.type_text(&command.text)); + let result = with_runtime_input(|input| { + crate::remote_scan::allow_direct(app)?; + input.type_text(&command.text) + }); typing_route.finish(result.is_ok(), || { app.state::().hide_for_typing() }); @@ -1552,6 +1558,37 @@ fn complete_desktop( shared: &SharedModel, command: DesktopCommand, ) -> Option { + if command.command_type == "switch.session.start" { + stop_all_repeats(app); + if let Err(error) = with_runtime_input(|input| input.release_all()) { + return shared + .lock() + .unwrap_or_else(|p| p.into_inner()) + .engine + .complete_desktop_command_with_error( + &command, + Err(("input_failed", error.as_str())), + ); + } + } + if let Some(result) = crate::remote_scan::route( + app, + &command.device_id, + &command.command_type, + &command.payload, + ) { + return shared + .lock() + .unwrap_or_else(|p| p.into_inner()) + .engine + .complete_desktop_command_with_error( + &command, + result + .as_ref() + .map(|_| ()) + .map_err(|e| ("input_failed", e.as_str())), + ); + } if command.command_type == "mouse.repeat.start" { return complete_repeat_start(app, shared, command); } @@ -1579,7 +1616,11 @@ fn complete_desktop( .clone(); if command.command_type == "switch.profile.list" { stop_all_repeats(app); - return Some(switch_profile_catalog_response(&command.id, &profiles)); + return Some(crate::remote_scan::catalog( + app, + &command.payload, + switch_profile_catalog_response(&command.id, &profiles), + )); } let (result, error_code) = if command.command_type == "pointer.display.move" { let direction = command.payload["direction"].as_str().unwrap_or_default(); @@ -2187,6 +2228,7 @@ fn expire_pairing(app: &AppHandle, shared: &SharedModel, request_id: &str) -> Re } pub fn disconnect_all(app: &AppHandle, shared: &SharedModel) -> Result<(), String> { + crate::scanning_runtime::cancel(app); app.state::().cancel(app); stop_all_repeats(app); let notifications = runtime() diff --git a/src/App.tsx b/src/App.tsx index b31dce8e..e62c75a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,7 +61,7 @@ function HomeView({ state, switches, scanning, navigate, onDisconnect, onAccessi const saved = switches.state?.settings.bindings ?? []; const hasSelect = saved.some((binding) => binding.pressAction === "select" || binding.holdActions.includes("select")); const ready = state.accessibility === "granted" && hasSelect && !switches.error && !scanning.error && state.bluetooth !== "connected" && scanning.state?.supported && scanning.state.enabled && !scanning.state.paused; - const message = switches.error ?? scanning.error ?? (state.accessibility !== "granted" ? "Allow input access to control this computer." : !scanning.state ? "Loading switch control..." : !scanning.state.supported ? scanning.state.message : state.bluetooth === "connected" ? "Local scanning is paused while an Android device is connected." : !hasSelect ? "Add a switch with the Select action to begin scanning." : scanning.state.paused ? "Scanning is paused. Use your Pause / resume switch to continue." : scanning.state.enabled ? "Focus the application you want to use, then press and release your Select switch." : scanning.state.message); + const message = switches.error ?? scanning.error ?? (state.accessibility !== "granted" ? "Allow input access to control this computer." : !scanning.state ? "Loading switch control..." : !scanning.state.supported ? scanning.state.message : scanning.state.remote ? "Remote controls scanning. Use the switches in Remote; PC Escape stops the session." : state.bluetooth === "connected" ? "Local scanning is paused while an Android device is connected." : !hasSelect ? "Add a switch with the Select action to begin scanning." : scanning.state.paused ? "Scanning is paused. Use your Pause / resume switch to continue." : scanning.state.enabled ? "Focus the application you want to use, then press and release your Select switch." : scanning.state.message); return

Switchify PC

Control your computer with switches.

diff --git a/src/scanning/useScanning.ts b/src/scanning/useScanning.ts index ff6c41dc..c8fdb07c 100644 --- a/src/scanning/useScanning.ts +++ b/src/scanning/useScanning.ts @@ -15,6 +15,7 @@ export type PointScanConfig = { pauseKey: string; }; export type PointScanState = { + remote?: boolean; config: PointScanConfig; enabled: boolean; phase: "idle" | "row" | "rowEscape" | "cell" | "x" | "y" | "menu" | "menuSuspended" | "dragDestination" | "dragConfirmation" | "executing"; diff --git a/src/settings/RemoteSwitches.test.tsx b/src/settings/RemoteSwitches.test.tsx new file mode 100644 index 00000000..b0f796c4 --- /dev/null +++ b/src/settings/RemoteSwitches.test.tsx @@ -0,0 +1,27 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { RemoteSwitches } from "./RemoteSwitches"; +const invoke = vi.hoisted(() => vi.fn()); +vi.mock("@tauri-apps/api/core", () => ({ invoke })); +const config = { schemaVersion: 1, revision: 1, slots: Array.from({ length: 8 }, (_, i) => ({ pressAction: i === 0 ? "select" : null, holdActions: [] })) }; +beforeEach(() => { Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true }); invoke.mockReset(); invoke.mockImplementation(async (command, args) => command === "get_remote_switches" ? structuredClone(config) : { ...args.config, revision: 2 }); }); +it("edits remote slots independently and saves ordered holds", async () => { + render(); + const action = await screen.findByLabelText("Remote switch 1 action"); + fireEvent.change(action, { target: { value: "next" } }); + fireEvent.click(screen.getByText("Add hold action for remote switch 1")); + fireEvent.change(screen.getByLabelText("Remote switch 1 hold action 1"), { target: { value: "select" } }); + fireEvent.click(screen.getByText("Save remote switches")); + await screen.findByRole("status"); + expect(invoke).toHaveBeenCalledWith("save_remote_switches", { config: expect.objectContaining({ slots: expect.arrayContaining([{ pressAction: "next", holdActions: ["select"] }]) }) }); + expect(invoke.mock.calls.some(([command]) => command === "save_switches")).toBe(false); +}); +it("retains edits on save failure and permits retry", async () => { + render(); await screen.findByLabelText("Remote switch 1 action"); + invoke.mockRejectedValueOnce(new Error("Save failed")); + fireEvent.click(screen.getByText("Save remote switches")); + await screen.findByRole("alert"); + await waitFor(() => expect(screen.getByText("Save remote switches")).not.toBeDisabled()); + fireEvent.click(screen.getByText("Save remote switches")); + await screen.findByRole("status"); +}); diff --git a/src/settings/RemoteSwitches.tsx b/src/settings/RemoteSwitches.tsx new file mode 100644 index 00000000..6c9cd674 --- /dev/null +++ b/src/settings/RemoteSwitches.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { actions, type SwitchAction } from "../scanning/useSwitches"; +import { SettingGroup } from "./controls"; +type Slot = { pressAction: SwitchAction | null; holdActions: SwitchAction[] }; +type Config = { schemaVersion: number; revision: number; slots: Slot[] }; +export function RemoteSwitches() { + const [config, setConfig] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + useEffect(() => { + let alive = true; + if (!("__TAURI_INTERNALS__" in window)) return; + void invoke("get_remote_switches").then((value) => { if (alive) setConfig(value); }).catch((e) => { if (alive) setError(String(e)); }); + return () => { alive = false; }; + }, []); + const update = (index: number, slot: Slot) => { if (config) setConfig({ ...config, slots: config.slots.map((s, i) => i === index ? slot : s) }); setSaved(false); }; + const save = async () => { + if (!config) return; + setBusy(true); setError(null); setSaved(false); + try { setConfig(await invoke("save_remote_switches", { config })); setSaved(true); } + catch (e) { setError(String(e)); } + finally { setBusy(false); } + }; + return +

Remote owns scanning while forwarding. PC Escape stops it. Remote’s hold-to-stop and inactivity limits take priority over hold actions. Saving stops an active remote scan.

+ {error &&

{error}

} + {config &&
+ {config.slots.map((slot, index) =>
Remote switch {index + 1}: {slot.pressAction ? actions[slot.pressAction] : "Unassigned"} + + {slot.holdActions.map((action, hold) =>
+ +
)} + +
)} + +
} + {saved &&

Remote switches saved. Reload profiles in Remote before starting again.

} +
; +} diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx index 9ae3e02d..7bb9dab3 100644 --- a/src/settings/SwitchesSection.tsx +++ b/src/settings/SwitchesSection.tsx @@ -1,3 +1,4 @@ +import { RemoteSwitches } from "./RemoteSwitches"; import { useEffect, useId, useRef, useState } from "react"; import { ChevronDown, ChevronUp, Keyboard, Trash2, X } from "lucide-react"; import { @@ -675,6 +676,7 @@ export function SwitchesSection({ controller, onDraftChange, suspended = false }

+ ); } From 25055971c06d09b612350294e1e78bde7e237728 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 13 Sep 2026 19:03:44 +0100 Subject: [PATCH 2/3] Serialize remote transitions and retain action ownership through cancellation --- src-tauri/src/remote_scan.rs | 33 ++++++++++--- src-tauri/src/scanning_runtime.rs | 43 ++++++++++++----- src-tauri/src/windows_runtime.rs | 78 +++++++++++++++++++++---------- 3 files changed, 111 insertions(+), 43 deletions(-) diff --git a/src-tauri/src/remote_scan.rs b/src-tauri/src/remote_scan.rs index 6d3c8021..1eb62e8f 100644 --- a/src-tauri/src/remote_scan.rs +++ b/src-tauri/src/remote_scan.rs @@ -132,6 +132,15 @@ impl Mailbox { self.queue.clear(); self.generation = self.generation.wrapping_add(1); } + fn expire(&mut self, now: u64) { + if self + .session + .as_ref() + .is_some_and(|s| now.saturating_sub(s.last_seen) >= 5000) + { + self.stop(); + } + } fn start( &mut self, device: &str, @@ -331,12 +340,7 @@ pub fn poll(app: &AppHandle) -> Option<(u64, Settings, Vec, u64)> { let c = app.state::(); let now = c.clock.elapsed().as_millis() as u64; let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); - if d.session - .as_ref() - .is_some_and(|s| now.saturating_sub(s.last_seen) >= 5000) - { - d.stop(); - } + d.expire(now); let settings = d.session.as_ref()?.settings.clone(); let generation = d.generation; let edges = d.queue.drain(..).collect(); @@ -438,6 +442,23 @@ mod tests { m } #[test] + fn timeout_invalidates_polled_work_and_never_restores_a_session() { + let mut m = ready(); + m.accept("peer", "switch.edge", &edge(2, 1, "down"), 10) + .unwrap(); + let polled_generation = m.generation; + let polled: Vec<_> = m.queue.drain(..).collect(); + assert_eq!(polled, vec![Edge::Down(1)]); + m.expire(5009); + assert!(m.session.is_some()); + m.expire(5010); + assert!(m.session.is_none()); + assert_ne!(m.generation, polled_generation); + assert!(m + .accept("peer", "switch.sync", &sync(3, vec![]), 5011) + .is_err()); + } + #[test] fn defaults_validate_connected_slots_and_revision() { let mut m = Mailbox::new(Config::default()); m.start("peer", &start(1), 1000, true, 0).unwrap(); diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 996bc88c..d7dbe882 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -325,20 +325,27 @@ fn ensure(app: &AppHandle) { } } } -fn input_active(app: &AppHandle, generation: u64) -> bool { - if crate::remote_scan::active(app) { - crate::remote_scan::active_generation(app, generation) +fn source_is_current(remote: bool, remote_current: bool, local_current: bool) -> bool { + if remote { + remote_current } else { - app.state::() - .active_generation(generation) + local_current } } -fn switch(app: &AppHandle, action: Action, input_generation: u64) { +fn input_active(app: &AppHandle, generation: u64, remote: bool) -> bool { + source_is_current( + remote, + crate::remote_scan::active_generation(app, generation), + app.state::() + .active_generation(generation), + ) +} +fn switch(app: &AppHandle, action: Action, input_generation: u64, remote: bool) { let c = app.state::>(); if !c.enabled.load(Ordering::SeqCst) { return; } - if !input_active(app, input_generation) { + if !input_active(app, input_generation, remote) { disable::
(app, "Switch capture stopped."); return; } @@ -362,7 +369,7 @@ fn switch(app: &AppHandle, action: Action, input_generation: u64) { let display = d.display.clone(); drop(d); if let Some(point) = point { - dispatch::(app, point, display.as_ref(), input_generation)?; + dispatch::(app, point, display.as_ref(), input_generation, remote)?; } render::(app, None) })(); @@ -377,10 +384,11 @@ fn dispatch( request: ::Selection, environment: Option<&A::Environment>, input_generation: u64, + remote: bool, ) -> Result<(), String> { A::validate_environment(app, environment)?; let c = app.state::>(); - if !c.enabled.load(Ordering::SeqCst) || !input_active(app, input_generation) { + if !c.enabled.load(Ordering::SeqCst) || !input_active(app, input_generation, remote) { return Err("Scan action was cancelled.".into()); } render_tiles(&[])?; @@ -500,7 +508,7 @@ fn tick(app: &AppHandle) { } }; if let Some(action) = action { - switch::(app, action, generation); + switch::(app, action, generation, true); } if !c.enabled.load(Ordering::SeqCst) { return; @@ -550,7 +558,7 @@ fn tick(app: &AppHandle) { } }; if let Some(action) = selected { - switch::(app, action, generation); + switch::(app, action, generation, false); } } _ => {} @@ -587,9 +595,10 @@ fn tick(app: &AppHandle) { }; let environment = d.display.clone(); let input_generation = d.input_generation; + let remote = d.remote; drop(d); if let Some(request) = request { - dispatch::(app, request, environment.as_ref(), input_generation)?; + dispatch::(app, request, environment.as_ref(), input_generation, remote)?; } if phase_changed { publish::(app); @@ -699,3 +708,13 @@ fn show_prompt( ) }) } + +#[cfg(test)] +mod ownership_tests { + #[test] + fn stopped_remote_input_cannot_fall_back_to_a_matching_local_generation() { + assert!(!super::source_is_current(true, false, true)); + assert!(super::source_is_current(false, false, true)); + assert!(super::source_is_current(true, true, false)); + } +} diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index d5d53195..ace5e27d 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -1303,40 +1303,68 @@ async fn handle_write( deferral: Deferral, ) -> Result<(), String> { let result = async { + static DISPATCH_SLOTS: OnceLock> = OnceLock::new(); + let _permit = DISPATCH_SLOTS + .get_or_init(|| Arc::new(tokio::sync::Semaphore::new(64))) + .clone() + .try_acquire_owned() + .map_err(|_| "Bluetooth command queue is full.".to_string())?; + let request = args .GetRequestAsync() .map_err(|error| error.to_string())? .await .map_err(|error| error.to_string())?; - let lifecycle_guard = lifecycle - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if !lifecycle_guard.is_current(generation) || !runtime_generation_is_current(generation) { + let bytes = { + let lifecycle_guard = lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !lifecycle_guard.is_current(generation) || !runtime_generation_is_current(generation) + { + if request.Option().map_err(|error| error.to_string())? + == GattWriteOption::WriteWithResponse + { + request + .RespondWithProtocolError(0x0e) + .map_err(|error| error.to_string())?; + } + return Ok(()); + } + let mut bytes = windows::core::Array::::new(); + CryptographicBuffer::CopyToByteArray( + &request.Value().map_err(|error| error.to_string())?, + &mut bytes, + ) + .map_err(|error| error.to_string())?; if request.Option().map_err(|error| error.to_string())? == GattWriteOption::WriteWithResponse { - request - .RespondWithProtocolError(0x0e) - .map_err(|error| error.to_string())?; + request.Respond().map_err(|error| error.to_string())?; } - return Ok(()); - } - let mut bytes = windows::core::Array::::new(); - CryptographicBuffer::CopyToByteArray( - &request.Value().map_err(|error| error.to_string())?, - &mut bytes, - ) - .map_err(|error| error.to_string())?; - if request.Option().map_err(|error| error.to_string())? - == GattWriteOption::WriteWithResponse - { - request.Respond().map_err(|error| error.to_string())?; - } - if let Some(response) = process_frame(&app, &shared, &bytes)? { - notify(response)?; - } - drop(lifecycle_guard); - Ok(()) + bytes.to_vec() + }; + // Scanner input and overlays are thread-local main-thread resources. + // Serialize command dispatch with scan ticks and recheck transport ownership. + let (tx, rx) = tokio::sync::oneshot::channel(); + let handle = app.clone(); + app.run_on_main_thread(move || { + let guard = lifecycle.lock().unwrap_or_else(|p| p.into_inner()); + let result = + if guard.is_current(generation) && runtime_generation_is_current(generation) { + process_frame(&handle, &shared, &bytes).and_then(|response| { + if let Some(response) = response { + notify(response)?; + } + Ok(()) + }) + } else { + Ok(()) + }; + let _ = tx.send(result); + }) + .map_err(|e| e.to_string())?; + rx.await + .map_err(|_| "Bluetooth command dispatch cancelled.".to_string())? } .await; deferral.Complete().map_err(|error| error.to_string())?; From 2ea3ca2532a1d874ae72f2cbc8cfb732bcf72c95 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 13 Sep 2026 19:06:32 +0100 Subject: [PATCH 3/3] Complete scanner cleanup before acknowledging remote stop --- src-tauri/src/input.rs | 30 ++++++++++++++++++++++++ src-tauri/src/remote_scan.rs | 38 ++++++++++++++++++++++++------- src-tauri/src/scanning_runtime.rs | 4 +++- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index 71caaeea..de058817 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -1468,6 +1468,36 @@ mod tests { assert!(!input.has_active_drag()); } #[test] + fn remote_stop_releases_drag_before_direct_input_and_blocks_failed_release() { + use crate::{ + point_workflow::Request, remote_scan::input_available, scan_executor::execute, + }; + for fail in [false, true] { + let mut input = DesktopInput::new(FakeInjector::default()); + execute(&mut input, Request::DragStart((10, 20)), true).unwrap(); + input.injector.fail_pointer_release = fail; + let mut cleanup_required = input.release_all().is_err(); + if input_available(false, cleanup_required) { + input.click_pointer(MouseButton::Left, 1).unwrap(); + } + if fail { + assert!(input.injector.clicks.is_empty()); + assert!(input.has_active_drag()); + input.injector.fail_pointer_release = false; + cleanup_required = input.release_all().is_err(); + assert!(input_available(false, cleanup_required)); + input.click_pointer(MouseButton::Left, 1).unwrap(); + assert_eq!( + input.injector.events, + vec!["move", "down", "up", "up", "click"] + ); + } else { + assert_eq!(input.injector.events, vec!["move", "down", "up", "click"]); + } + assert!(!input.has_active_drag()); + } + } + #[test] fn scan_drag_failures_remain_owned_until_cleanup_succeeds() { use crate::{point_workflow::Request, scan_executor::execute}; let mut input = DesktopInput::new(FakeInjector::default()); diff --git a/src-tauri/src/remote_scan.rs b/src-tauri/src/remote_scan.rs index 1eb62e8f..f8d5a902 100644 --- a/src-tauri/src/remote_scan.rs +++ b/src-tauri/src/remote_scan.rs @@ -117,6 +117,7 @@ pub struct Mailbox { session: Option, generation: u64, queue: VecDeque, + cleanup_required: bool, } impl Mailbox { fn new(config: Config) -> Self { @@ -125,9 +126,11 @@ impl Mailbox { session: None, generation: 0, queue: VecDeque::new(), + cleanup_required: false, } } fn stop(&mut self) { + self.cleanup_required |= self.session.is_some(); self.session = None; self.queue.clear(); self.generation = self.generation.wrapping_add(1); @@ -323,13 +326,26 @@ pub fn active(app: &AppHandle) -> bool { .is_some() }) } +pub fn input_available(session_active: bool, cleanup_required: bool) -> bool { + !session_active && !cleanup_required +} pub fn allow_direct(app: &AppHandle) -> Result<(), String> { - if active(app) { + let c = app.state::(); + let d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + if !input_available(d.session.is_some(), d.cleanup_required) { Err("Stop Switchify scanning before using other PC controls.".into()) } else { Ok(()) } } +pub fn record_cleanup(app: &AppHandle, success: bool) { + if let Some(c) = app.try_state::() { + c.data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .cleanup_required = !success; + } +} pub fn active_generation(app: &AppHandle, generation: u64) -> bool { app.try_state::().is_some_and(|c| { let d = c.data.lock().unwrap_or_else(|p| p.into_inner()); @@ -389,7 +405,7 @@ pub fn route( cancel(app); return None; } - if command == "switch.profile.list" || !active(app) { + if command == "switch.profile.list" || allow_direct(app).is_ok() { return None; } if matches!( @@ -398,12 +414,18 @@ pub fn route( ) { let c = app.state::(); let now = c.clock.elapsed().as_millis() as u64; - return Some( - c.data - .lock() - .unwrap_or_else(|p| p.into_inner()) - .accept(device, command, payload, now), - ); + let result = c + .data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .accept(device, command, payload, now); + if command == "switch.session.stop" && result.is_ok() && !active(app) { + crate::point_scan_runtime::pause(app); + let cleanup = crate::scan_executor::cleanup(); + record_cleanup(app, cleanup.is_ok()); + return Some(cleanup); + } + return Some(result); } Some(Err( "Stop Switchify scanning before using other PC controls.".into(), diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index d7dbe882..13aab09f 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -192,6 +192,7 @@ fn reset_scanner(app: &AppHandle, message: &str) { d.message = message.into(); } let cleanup = A::cleanup(app); + crate::remote_scan::record_cleanup(app, cleanup.is_ok()); if cleanup.is_err() { c.data.lock().unwrap_or_else(|p| p.into_inner()).message = "Input cleanup will be retried before scanning resumes.".into(); @@ -565,7 +566,8 @@ fn tick(app: &AppHandle) { } } if !c.enabled.load(Ordering::SeqCst) { - let _ = A::cleanup(app); + let cleanup = A::cleanup(app); + crate::remote_scan::record_cleanup(app, cleanup.is_ok()); ensure::(app); return; }