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/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/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..f8d5a902 --- /dev/null +++ b/src-tauri/src/remote_scan.rs @@ -0,0 +1,588 @@ +//! 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, + cleanup_required: bool, +} +impl Mailbox { + fn new(config: Config) -> Self { + Self { + config, + 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); + } + 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, + 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 input_available(session_active: bool, cleanup_required: bool) -> bool { + !session_active && !cleanup_required +} +pub fn allow_direct(app: &AppHandle) -> Result<(), String> { + 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()); + 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()); + d.expire(now); + 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" || allow_direct(app).is_ok() { + 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; + 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(), + )) +} +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 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(); + 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..13aab09f 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,9 +187,12 @@ 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); + 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(); @@ -312,15 +326,27 @@ fn ensure(app: &AppHandle) { } } } -fn switch(app: &AppHandle, action: Action, input_generation: u64) { +fn source_is_current(remote: bool, remote_current: bool, local_current: bool) -> bool { + if remote { + remote_current + } else { + local_current + } +} +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 !app - .state::() - .active_generation(input_generation) - { + if !input_active(app, input_generation, remote) { disable::(app, "Switch capture stopped."); return; } @@ -344,7 +370,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) })(); @@ -359,14 +385,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) - || !app - .state::() - .active_generation(input_generation) - { + if !c.enabled.load(Ordering::SeqCst) || !input_active(app, input_generation, remote) { return Err("Scan action was cancelled.".into()); } render_tiles(&[])?; @@ -420,7 +443,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, true); + } + 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 +542,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) @@ -454,19 +559,21 @@ fn tick(app: &AppHandle) { } }; if let Some(action) = selected { - switch::(app, action, generation); + switch::(app, action, generation, false); } } _ => {} } } 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; } if app.state::().snapshot().bluetooth == crate::state::BluetoothState::Connected + && !crate::remote_scan::active(app) { disable::(app, "Android connected. Local scanning stopped."); return; @@ -490,9 +597,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); @@ -501,7 +609,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) { @@ -598,3 +710,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/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..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())?; @@ -1483,6 +1511,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 +1536,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 +1565,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 +1586,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 +1644,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 +2256,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 }

+ ); }