From 49a39d78d82a477d7ce154d9110db27953894f00 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 7 Sep 2026 20:13:41 +0100 Subject: [PATCH 1/7] feat: repeat navigation keys through the repeat envelope Adds held/repeating keyboard input so a remote can repeat the arrow keys, Tab, Backspace, Delete, Page Up and Page Down, mirroring the existing mouse move and scroll repeat. Key repeat rides the existing `mouse.repeat.start` nested command envelope rather than introducing new command names. The desktop's command vocabulary is hand-maintained across five parallel lists, so a new namespace would need entries in all of them plus a permanent alias for existing clients; widening the envelope needs none and reuses the repeat generation, stop and cleanup machinery unchanged. A new `keyRepeat` capability block gates the feature, so old and new peers on either side degrade to single key presses. Each tick is a discrete press and release rather than a held key, so a repeat loop that dies for any reason cannot strand a key down. The one remaining gap, a press that succeeds followed by a failed release, is recorded in `pending_key_releases` and retried by `release_all` - the same deterministic cleanup path used for modifiers. Repeatable keys deliberately exclude printable characters and Space, which would flood text, modifiers, which already latch through `keyboard.modifierDown`, and Enter, which would re-submit on every tick. Modifiers latched before a repeat stay held across ticks so Shift with a repeating arrow extends a selection. Closes #677 Co-Authored-By: Claude Opus 5 (1M context) --- src-tauri/src/input.rs | 157 +++++++++++++++ src-tauri/src/macos.rs | 101 +++++++--- src-tauri/src/mouse_repeat.rs | 327 +++++++++++++++++++++++++++++++ src-tauri/src/overlay.rs | 4 + src-tauri/src/protocol.rs | 222 +++++++++++++++++++-- src-tauri/src/state.rs | 95 +++++++++ src-tauri/src/windows_runtime.rs | 89 +++++++-- src/App.test.tsx | 29 +++ src/App.tsx | 18 ++ src/api.ts | 1 + src/types.ts | 3 + 11 files changed, 997 insertions(+), 49 deletions(-) diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index abb0e75d..eb728f3c 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -7,6 +7,7 @@ use enigo::{Axis, Button, Direction, Enigo, Key, Keyboard, Mouse}; use serde_json::Value; use crate::modifier_overlay::ModifierKeyOverlayNotifier; +use crate::mouse_repeat::RepeatKey; use crate::protocol::MouseButton; use crate::state::{normalize_pointer_scale_percent, AppModel, SwitchBinding, SwitchProfile}; @@ -434,6 +435,7 @@ pub struct DesktopInput { pub(crate) injector: I, held_modifiers: HashSet, pending_modifier_releases: HashSet, + pending_key_releases: HashSet, modifier_overlay: Option>, held_button: Option, pointer_scale_percent: u32, @@ -462,6 +464,7 @@ impl DesktopInput { injector, held_modifiers: HashSet::new(), pending_modifier_releases: HashSet::new(), + pending_key_releases: HashSet::new(), modifier_overlay: None, held_button: None, pointer_scale_percent: 100, @@ -1195,6 +1198,27 @@ impl DesktopInput { Ok(()) } + /// Injects one discrete key tap for a repeat tick. + /// + /// A repeat never holds a key down between ticks, so a loop that dies for + /// any reason cannot leave a key stuck. The one remaining gap is a press + /// that succeeds followed by a release that fails; that key is recorded and + /// retried by `release_all`. + pub fn execute_repeat_key(&mut self, key: RepeatKey) -> Result<(), String> { + let name = key.protocol_name(); + self.injector.set_key(name, true)?; + match self.injector.set_key(name, false) { + Ok(()) => { + self.pending_key_releases.remove(&key); + Ok(()) + } + Err(error) => { + self.pending_key_releases.insert(key); + Err(error) + } + } + } + pub fn release_all(&mut self) -> Result<(), String> { let mut first_error = self.stop_switch_session().err(); self.text_streams.clear(); @@ -1204,12 +1228,35 @@ impl DesktopInput { if let Err(error) = self.release_held_modifiers() { first_error.get_or_insert(error); } + if let Err(error) = self.release_pending_keys() { + first_error.get_or_insert(error); + } if let Some(error) = first_error { return Err(error); } Ok(()) } + /// Retries key releases that failed during a repeat tick. Every key is + /// attempted even when one fails, so a single bad release cannot strand the + /// others. + fn release_pending_keys(&mut self) -> Result<(), String> { + let mut first_error = None; + for key in RepeatKey::ALL { + if !self.pending_key_releases.remove(&key) { + continue; + } + if let Err(error) = self.injector.set_key(key.protocol_name(), false) { + self.pending_key_releases.insert(key); + first_error.get_or_insert(error); + } + } + match first_error { + Some(error) => Err(error), + None => Ok(()), + } + } + fn release_held_modifiers(&mut self) -> Result<(), String> { for key in ModifierKey::RELEASE_ORDER { let was_held = self.held_modifiers.remove(&key); @@ -2243,6 +2290,116 @@ mod tests { ); } + #[test] + fn repeat_key_taps_press_and_release_without_holding_the_key() { + let mut input = DesktopInput::new(FakeInjector::default()); + for _ in 0..3 { + assert!(input.execute_repeat_key(RepeatKey::ArrowDown).is_ok()); + } + assert_eq!( + input.injector.keys, + vec![ + ("ArrowDown".into(), true), + ("ArrowDown".into(), false), + ("ArrowDown".into(), true), + ("ArrowDown".into(), false), + ("ArrowDown".into(), true), + ("ArrowDown".into(), false), + ] + ); + // Nothing is held between ticks, so cleanup has nothing to release. + assert!(input.pending_key_releases.is_empty()); + assert!(input.release_all().is_ok()); + assert_eq!(input.injector.keys.len(), 6); + } + + #[test] + fn a_failed_repeat_key_release_is_retried_by_release_all() { + let mut input = DesktopInput::new(FakeInjector::default()); + input.injector.fail_key_up = Some("Tab".into()); + assert!(input.execute_repeat_key(RepeatKey::Tab).is_err()); + assert_eq!( + input.pending_key_releases, + HashSet::from([RepeatKey::Tab]), + "a key whose release failed must be remembered" + ); + + // The retry succeeds once injection recovers, and the key is cleared. + input.injector.fail_key_up = None; + assert!(input.release_all().is_ok()); + assert!(input.pending_key_releases.is_empty()); + assert_eq!( + input.injector.keys, + vec![ + ("Tab".into(), true), + ("Tab".into(), false), + ("Tab".into(), false), + ] + ); + } + + #[test] + fn a_still_failing_repeat_key_release_stays_pending_for_the_next_cleanup() { + let mut input = DesktopInput::new(FakeInjector::default()); + input.injector.fail_key_up = Some("Delete".into()); + assert!(input.execute_repeat_key(RepeatKey::Delete).is_err()); + assert!(input.release_all().is_err()); + assert_eq!( + input.pending_key_releases, + HashSet::from([RepeatKey::Delete]), + "an unrecovered key must not be forgotten" + ); + + input.injector.fail_key_up = None; + assert!(input.release_all().is_ok()); + assert!(input.pending_key_releases.is_empty()); + } + + #[test] + fn a_later_successful_tap_clears_an_earlier_pending_release() { + let mut input = DesktopInput::new(FakeInjector::default()); + input.injector.fail_key_up = Some("PageUp".into()); + assert!(input.execute_repeat_key(RepeatKey::PageUp).is_err()); + assert!(!input.pending_key_releases.is_empty()); + + input.injector.fail_key_up = None; + assert!(input.execute_repeat_key(RepeatKey::PageUp).is_ok()); + assert!(input.pending_key_releases.is_empty()); + } + + #[test] + fn repeat_key_taps_leave_latched_modifiers_held() { + let mut input = DesktopInput::new(FakeInjector::default()); + assert!(input + .execute( + "device", + "keyboard.modifierDown", + &serde_json::json!({"key": "Shift"}), + &[], + ) + .is_ok()); + for _ in 0..2 { + assert!(input.execute_repeat_key(RepeatKey::ArrowRight).is_ok()); + } + // Shift stays down across every tick so a repeating arrow extends a + // selection rather than moving the caret. + assert_eq!( + input.injector.keys, + vec![ + ("Shift".into(), true), + ("ArrowRight".into(), true), + ("ArrowRight".into(), false), + ("ArrowRight".into(), true), + ("ArrowRight".into(), false), + ] + ); + assert!(input.release_all().is_ok()); + assert_eq!( + input.injector.keys.last().unwrap(), + &("Shift".into(), false) + ); + } + #[test] fn modifier_failures_keep_overlay_consistent() { let (mut down_input, down_overlay) = input_with_modifier_overlay(); diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 482b7888..7445dc3b 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -1435,9 +1435,18 @@ impl MacRuntime { self.stop_repeat_for_device(&command.device_id); return Ok(()); } - if !settings.mouse_repeat_enabled { + let enabled = if repeat_command.repeat_key().is_some() { + settings.key_repeat_enabled + } else { + settings.mouse_repeat_enabled + }; + if !enabled { self.stop_repeat_for_device(&command.device_id); - return Err("Mouse repeat is disabled in settings.".into()); + return Err(if repeat_command.repeat_key().is_some() { + "Key repeat is disabled in settings.".to_string() + } else { + "Mouse repeat is disabled in settings.".to_string() + }); } self.stop_repeat_for_device(&command.device_id); if self.input.is_none() && !self.refresh_accessibility(false)? { @@ -1457,15 +1466,30 @@ impl MacRuntime { .repeats .initial_move(&command.device_id, active.generation) .unwrap_or((0, 0)); - self.execute_repeat_move(active.generation, dx, dy) + self.execute_repeat_move(active.generation, dx, dy).map(Some) } RepeatCommand::Scroll { dx, dy } => match self.input.as_mut() { - Some(input) => input.execute_repeat_scroll(dx, dy), + Some(input) => input.execute_repeat_scroll(dx, dy).map(Some), None => Err( "Accessibility permission is required before input can be controlled." .to_string(), ), }, + // The first tap fires immediately; the tick then waits the + // initial delay before repeating. Key repeats produce no pointer + // feedback, so the cursor overlay is hidden rather than driven. + RepeatCommand::Key { .. } => { + match self.repeats.initial_key(&command.device_id, active.generation) { + Some(key) => match self.input.as_mut() { + Some(input) => input.execute_repeat_key(key).map(|()| None), + None => Err( + "Accessibility permission is required before input can be controlled." + .to_string(), + ), + }, + None => Ok(None), + } + } }; let initial_feedback = match initial { Ok(feedback) => feedback, @@ -1475,23 +1499,26 @@ impl MacRuntime { return Err(error); } }; - self.app.state::().begin_repeat( - active.generation, - repeat_command, - settings.mouse_repeat_acceleration_duration_ms > 0, - matches!(initial_feedback, PointerFeedback::Drag), - settings.clone(), - ); - self.schedule_repeat_tick( - command.device_id.clone(), - active.generation, - match repeat_command { - RepeatCommand::Move { .. } => MOVE_TICK_INTERVAL_MS, - RepeatCommand::Scroll { .. } => { - u64::from(repeat_command.interval_ms(&settings)) - } - }, - ); + match initial_feedback { + Some(feedback) => self.app.state::().begin_repeat( + active.generation, + repeat_command, + settings.mouse_repeat_acceleration_duration_ms > 0, + matches!(feedback, PointerFeedback::Drag), + settings.clone(), + ), + None => self.app.state::().hide_for_typing(), + } + let delay_ms = match repeat_command { + RepeatCommand::Move { .. } => MOVE_TICK_INTERVAL_MS, + RepeatCommand::Scroll { .. } => u64::from(repeat_command.interval_ms(&settings)), + RepeatCommand::Key { .. } => u64::from( + self.repeats + .key_delay_ms(&command.device_id, active.generation, &settings) + .unwrap_or_else(|| settings.key_repeat_initial_delay_ms.max(1)), + ), + }; + self.schedule_repeat_tick(command.device_id.clone(), active.generation, delay_ms); Ok(()) }); self.complete_repeat_command(command, injection); @@ -1548,7 +1575,13 @@ impl MacRuntime { let Some(active) = self.repeats.current(&device_id, generation) else { return Ok(()); }; - if !settings.mouse_repeat_enabled { + let is_key_repeat = active.command.repeat_key().is_some(); + let enabled = if is_key_repeat { + settings.key_repeat_enabled + } else { + settings.mouse_repeat_enabled + }; + if !enabled { self.stop_repeat_if_current(&device_id, generation, false); return Ok(()); } @@ -1575,7 +1608,7 @@ impl MacRuntime { settings.move_repeat_interval_ms, settings.pointer_scale_percent, ), - RepeatCommand::Scroll { .. } => None, + RepeatCommand::Scroll { .. } | RepeatCommand::Key { .. } => None, }; let result = match active.command { RepeatCommand::Move { .. } => match movement { @@ -1589,16 +1622,36 @@ impl MacRuntime { .to_string(), ), }, + RepeatCommand::Key { .. } => match self.repeats.advance_key(&device_id, generation) { + Some(key) => match self.input.as_mut() { + Some(input) => input.execute_repeat_key(key), + None => Err( + "Accessibility permission is required before input can be controlled." + .to_string(), + ), + }, + None => Ok(()), + }, }; if let Err(error) = result { if self.stop_repeat_if_current(&device_id, generation, false) { - self.report_error(format!("Mouse repeat stopped: {error}")); + self.report_error(format!( + "{} repeat stopped: {error}", + if is_key_repeat { "Key" } else { "Mouse" } + )); } return Ok(()); } let delay_ms = match active.command { RepeatCommand::Move { .. } => MOVE_TICK_INTERVAL_MS, RepeatCommand::Scroll { .. } => u64::from(active.command.interval_ms(&settings)), + RepeatCommand::Key { .. } => { + let Some(delay) = self.repeats.key_delay_ms(&device_id, generation, &settings) + else { + return Ok(()); + }; + u64::from(delay) + } }; self.schedule_repeat_tick(device_id, generation, delay_ms); Ok(()) diff --git a/src-tauri/src/mouse_repeat.rs b/src-tauri/src/mouse_repeat.rs index 15f0bff4..bb0aae0d 100644 --- a/src-tauri/src/mouse_repeat.rs +++ b/src-tauri/src/mouse_repeat.rs @@ -10,10 +10,65 @@ pub const MOVE_TICK_INTERVAL_MS: u64 = 8; const MAX_MOVE_ELAPSED_MS: f64 = 16.0; const PIXEL_EPSILON: f64 = 1e-9; +/// Keys that may be driven by a repeat. +/// +/// Deliberately excludes printable characters and Space (a runaway repeat fills +/// documents), modifiers (latching already belongs to `keyboard.modifierDown`), +/// and Enter (repeat-submitting a form is destructive in a way arrow keys are +/// not). The desktop advertises this list so it can widen without a client +/// release. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RepeatKey { + ArrowUp, + ArrowDown, + ArrowLeft, + ArrowRight, + Tab, + Backspace, + Delete, + PageUp, + PageDown, +} + +impl RepeatKey { + pub const ALL: [Self; 9] = [ + Self::ArrowUp, + Self::ArrowDown, + Self::ArrowLeft, + Self::ArrowRight, + Self::Tab, + Self::Backspace, + Self::Delete, + Self::PageUp, + Self::PageDown, + ]; + + pub fn parse(name: &str) -> Option { + Self::ALL + .into_iter() + .find(|key| key.protocol_name() == name) + } + + pub fn protocol_name(self) -> &'static str { + match self { + Self::ArrowUp => "ArrowUp", + Self::ArrowDown => "ArrowDown", + Self::ArrowLeft => "ArrowLeft", + Self::ArrowRight => "ArrowRight", + Self::Tab => "Tab", + Self::Backspace => "Backspace", + Self::Delete => "Delete", + Self::PageUp => "PageUp", + Self::PageDown => "PageDown", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RepeatCommand { Move { dx: i32, dy: i32 }, Scroll { dx: i32, dy: i32 }, + Key { key: RepeatKey }, } impl RepeatCommand { @@ -46,6 +101,14 @@ impl RepeatCommand { dx: value("dx", 50)?, dy: value("dy", 50)?, }), + "keyboard.key" => { + let key = nested + .get("key") + .and_then(Value::as_str) + .and_then(RepeatKey::parse) + .ok_or_else(|| "Key repeat command is invalid.".to_string())?; + Ok(Self::Key { key }) + } _ => Err("Mouse repeat command is invalid.".into()), } } @@ -54,6 +117,14 @@ impl RepeatCommand { match self { Self::Move { .. } => settings.move_repeat_interval_ms, Self::Scroll { .. } => settings.scroll_repeat_interval_ms, + Self::Key { .. } => settings.key_repeat_interval_ms, + } + } + + pub fn repeat_key(self) -> Option { + match self { + Self::Key { key } => Some(key), + _ => None, } } } @@ -64,6 +135,7 @@ pub struct ActiveRepeat { pub command: RepeatCommand, pub acceleration_duration_ms: u32, movement: Option, + key: Option, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -150,6 +222,45 @@ impl MoveRepeatState { } } +/// Cadence for a repeating key. +/// +/// The first tap fires immediately when the repeat starts, the second after +/// `key_repeat_initial_delay_ms`, and every later tap after +/// `key_repeat_interval_ms`. This mirrors how a keyboard's own auto-repeat +/// behaves and is the discrete analogue of the pointer acceleration ramp, which +/// has no meaning for whole key presses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct KeyRepeatState { + taps: u32, +} + +impl KeyRepeatState { + fn new() -> Self { + Self { taps: 0 } + } + + fn initial_tap(&mut self) -> bool { + if self.taps > 0 { + return false; + } + self.taps = 1; + true + } + + fn repeat_tap(&mut self) { + self.taps = self.taps.saturating_add(1); + } + + fn next_delay_ms(self, settings: &AppSettings) -> u32 { + let delay = if self.taps <= 1 { + settings.key_repeat_initial_delay_ms + } else { + settings.key_repeat_interval_ms + }; + delay.max(1) + } +} + #[derive(Debug, Default)] pub struct MouseRepeatController { next_generation: u64, @@ -171,6 +282,7 @@ impl MouseRepeatController { acceleration_duration_ms, movement: matches!(command, RepeatCommand::Move { .. }) .then(|| MoveRepeatState::new(now)), + key: matches!(command, RepeatCommand::Key { .. }).then(KeyRepeatState::new), }; self.active.insert(device_id, active); active @@ -224,6 +336,51 @@ impl MouseRepeatController { }) } + /// Emits the first key tap, which fires immediately when the repeat starts. + pub fn initial_key(&mut self, device_id: &str, generation: u64) -> Option { + let active = self.active.get_mut(device_id)?; + if active.generation != generation { + return None; + } + let RepeatCommand::Key { key } = active.command else { + return None; + }; + active + .key + .as_mut() + .and_then(|state| state.initial_tap().then_some(key)) + } + + /// Emits a subsequent key tap once the current delay has elapsed. + pub fn advance_key(&mut self, device_id: &str, generation: u64) -> Option { + let active = self.active.get_mut(device_id)?; + if active.generation != generation { + return None; + } + let RepeatCommand::Key { key } = active.command else { + return None; + }; + active.key.as_mut().map(|state| { + state.repeat_tap(); + key + }) + } + + /// How long to wait before the next key tap. Settings are read fresh on + /// every tick so a cadence change applies to a repeat already in flight. + pub fn key_delay_ms( + &self, + device_id: &str, + generation: u64, + settings: &AppSettings, + ) -> Option { + let active = self.active.get(device_id)?; + if active.generation != generation { + return None; + } + active.key.map(|state| state.next_delay_ms(settings)) + } + pub fn stop(&mut self, device_id: &str) -> Option { self.active.remove(device_id) } @@ -276,6 +433,176 @@ mod tests { use serde_json::json; use std::time::Duration; + #[test] + fn parses_repeatable_keys_and_rejects_everything_else() { + for key in RepeatKey::ALL { + assert_eq!( + RepeatCommand::parse( + &json!({"command":{"type":"keyboard.key","payload":{"key":key.protocol_name()}}}) + ), + Ok(RepeatCommand::Key { key }) + ); + } + for key in [ + "a", "A", "Space", "Enter", "Escape", "Ctrl", "Home", "F1", "", + ] { + assert_eq!( + RepeatCommand::parse( + &json!({"command":{"type":"keyboard.key","payload":{"key":key}}}) + ), + Err("Key repeat command is invalid.".to_string()) + ); + } + assert_eq!( + RepeatCommand::parse(&json!({"command":{"type":"keyboard.key","payload":{}}})), + Err("Key repeat command is invalid.".to_string()) + ); + } + + #[test] + fn key_repeat_fires_immediately_then_waits_the_initial_delay_then_the_interval() { + let settings = AppSettings { + key_repeat_interval_ms: 250, + key_repeat_initial_delay_ms: 500, + ..AppSettings::default() + }; + let mut controller = MouseRepeatController::default(); + let active = controller.start( + "device".into(), + RepeatCommand::Key { + key: RepeatKey::ArrowDown, + }, + 0, + Instant::now(), + ); + + // The first tap is immediate, and only ever emitted once. + assert_eq!( + controller.initial_key("device", active.generation), + Some(RepeatKey::ArrowDown) + ); + assert_eq!(controller.initial_key("device", active.generation), None); + assert_eq!( + controller.key_delay_ms("device", active.generation, &settings), + Some(500) + ); + + // Every tap after the second one uses the steady-state interval. + assert_eq!( + controller.advance_key("device", active.generation), + Some(RepeatKey::ArrowDown) + ); + assert_eq!( + controller.key_delay_ms("device", active.generation, &settings), + Some(250) + ); + assert_eq!( + controller.advance_key("device", active.generation), + Some(RepeatKey::ArrowDown) + ); + assert_eq!( + controller.key_delay_ms("device", active.generation, &settings), + Some(250) + ); + } + + #[test] + fn key_repeat_delay_never_reaches_zero_and_follows_live_settings_changes() { + let mut controller = MouseRepeatController::default(); + let active = controller.start( + "device".into(), + RepeatCommand::Key { + key: RepeatKey::Tab, + }, + 0, + Instant::now(), + ); + controller.initial_key("device", active.generation); + + // A zero initial delay must not spin the tick loop. + let immediate = AppSettings { + key_repeat_initial_delay_ms: 0, + ..AppSettings::default() + }; + assert_eq!( + controller.key_delay_ms("device", active.generation, &immediate), + Some(1) + ); + + // Settings are read per tick, so a cadence change applies mid-repeat. + controller.advance_key("device", active.generation); + let faster = AppSettings { + key_repeat_interval_ms: 100, + ..AppSettings::default() + }; + assert_eq!( + controller.key_delay_ms("device", active.generation, &faster), + Some(100) + ); + } + + #[test] + fn stale_generations_never_emit_key_taps() { + let mut controller = MouseRepeatController::default(); + let first = controller.start( + "device".into(), + RepeatCommand::Key { + key: RepeatKey::PageDown, + }, + 0, + Instant::now(), + ); + let second = controller.start( + "device".into(), + RepeatCommand::Key { + key: RepeatKey::PageUp, + }, + 0, + Instant::now(), + ); + assert_ne!(first.generation, second.generation); + assert_eq!(controller.initial_key("device", first.generation), None); + assert_eq!(controller.advance_key("device", first.generation), None); + assert_eq!( + controller.key_delay_ms("device", first.generation, &AppSettings::default()), + None + ); + assert_eq!( + controller.initial_key("device", second.generation), + Some(RepeatKey::PageUp) + ); + + controller.stop("device"); + assert_eq!(controller.advance_key("device", second.generation), None); + } + + #[test] + fn pointer_repeats_never_emit_key_taps_and_key_repeats_never_move_the_pointer() { + let mut controller = MouseRepeatController::default(); + let pointer = controller.start( + "device".into(), + RepeatCommand::Move { dx: 10, dy: 0 }, + 0, + Instant::now(), + ); + assert_eq!(controller.initial_key("device", pointer.generation), None); + assert_eq!(controller.advance_key("device", pointer.generation), None); + + let key = controller.start( + "device".into(), + RepeatCommand::Key { + key: RepeatKey::Backspace, + }, + 0, + Instant::now(), + ); + assert_eq!(controller.initial_move("device", key.generation), None); + assert_eq!( + controller.advance_move("device", key.generation, Instant::now(), 250, 100), + None + ); + } + #[test] fn parses_only_bounded_move_and_scroll_commands() { assert_eq!( diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index 5ca92e80..b8c30758 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -299,6 +299,10 @@ impl OverlayEngine { dragging, }, RepeatCommand::Scroll { dx, dy } => PointerFeedback::RepeatScroll { dx, dy }, + // Key repeats carry no pointer feedback: the runtime hides + // the overlay instead of beginning one. Handled defensively + // so the overlay thread cannot panic if that ever changes. + RepeatCommand::Key { .. } => return self.hide(), }; self.feedback = Some(feedback); self.deadline = None; diff --git a/src-tauri/src/protocol.rs b/src-tauri/src/protocol.rs index a633813b..f2486902 100644 --- a/src-tauri/src/protocol.rs +++ b/src-tauri/src/protocol.rs @@ -10,6 +10,7 @@ use serde_json::{json, Map, Value}; use sha2::Sha256; use uuid::Uuid; +use crate::mouse_repeat::RepeatKey; use crate::state::AppSettings; pub const PROTOCOL_VERSION: i64 = 1; @@ -1104,18 +1105,29 @@ fn valid_repeat_start(object: &Map) -> bool { let Some(payload_object) = payload.as_object() else { return false; }; - payload_object.len() == 2 - && match command_type { - "mouse.move" => { - bounded_number(payload, "dx", MAX_POINTER_DELTA).is_some() - && bounded_number(payload, "dy", MAX_POINTER_DELTA).is_some() - } - "mouse.scroll" => { - bounded_number(payload, "dx", 50.0).is_some() - && bounded_number(payload, "dy", 50.0).is_some() - } - _ => false, + match command_type { + "mouse.move" => { + payload_object.len() == 2 + && bounded_number(payload, "dx", MAX_POINTER_DELTA).is_some() + && bounded_number(payload, "dy", MAX_POINTER_DELTA).is_some() } + "mouse.scroll" => { + payload_object.len() == 2 + && bounded_number(payload, "dx", 50.0).is_some() + && bounded_number(payload, "dy", 50.0).is_some() + } + // The repeatable-key allowlist is owned by `RepeatKey` so the validator + // and the runtime can never disagree about what may repeat. + "keyboard.key" => { + payload_object.len() == 1 + && payload_object + .get("key") + .and_then(Value::as_str) + .and_then(RepeatKey::parse) + .is_some() + } + _ => false, + } } fn valid_stream_id(value: Option<&Value>) -> bool { @@ -1423,6 +1435,18 @@ pub fn pointer_profile_response( "accelerationDurationOptionsMs": [0, 500, 1000, 2000], "accelerationInitialScalePercent": 25 }, + "keyRepeat": { + "supported": true, + "enabled": settings.key_repeat_enabled, + "intervalMs": settings.key_repeat_interval_ms, + "initialDelayMs": settings.key_repeat_initial_delay_ms, + "minIntervalMs": 100, + "maxIntervalMs": 1000, + "repeatableKeys": RepeatKey::ALL + .iter() + .map(|key| key.protocol_name()) + .collect::>() + }, "pointerSpeed": { "supported": true, "setSupported": true, @@ -2375,6 +2399,182 @@ mod tests { )); } + #[test] + fn repeat_start_accepts_every_repeatable_key_and_rejects_the_rest() { + for key in RepeatKey::ALL { + assert!( + valid_desktop_payload( + "mouse.repeat.start", + &json!({"command":{"type":"keyboard.key","payload":{"key":key.protocol_name()}}}) + ), + "{} should be repeatable", + key.protocol_name() + ); + } + for payload in [ + // Printable characters and Space would flood text, modifiers belong + // to keyboard.modifierDown, and Enter would re-submit on every tick. + json!({"command":{"type":"keyboard.key","payload":{"key":"a"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":"A"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":"Space"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":"Enter"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":"Escape"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":"Ctrl"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":"Shift"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":"Home"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":"F1"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":""}}}), + // Shape violations. + json!({"command":{"type":"keyboard.key","payload":{"key":"Tab","extra":true}}}), + json!({"command":{"type":"keyboard.key","payload":{}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":1}}}), + json!({"command":{"type":"keyboard.shortcut","payload":{"keys":["Tab"]}}}), + ] { + assert!( + !valid_desktop_payload("mouse.repeat.start", &payload), + "{payload} should be rejected" + ); + } + } + + #[test] + fn repeat_start_still_accepts_the_v1_pointer_payloads_byte_for_byte() { + // Pins the exact shapes shipped clients send today. Widening the nested + // envelope for keyboard.key must not move these acceptance boundaries. + for payload in [ + json!({"command":{"type":"mouse.move","payload":{"dx":0,"dy":0}}}), + json!({"command":{"type":"mouse.move","payload":{"dx":-64,"dy":128}}}), + json!({"command":{"type":"mouse.move","payload":{"dx":500,"dy":-500}}}), + json!({"command":{"type":"mouse.scroll","payload":{"dx":0,"dy":5}}}), + json!({"command":{"type":"mouse.scroll","payload":{"dx":-50,"dy":50}}}), + ] { + assert!( + valid_desktop_payload("mouse.repeat.start", &payload), + "{payload} is a shipped v1 payload" + ); + } + for payload in [ + json!({"command":{"type":"mouse.move","payload":{"dx":501,"dy":0}}}), + json!({"command":{"type":"mouse.scroll","payload":{"dx":0,"dy":51}}}), + json!({"command":{"type":"mouse.move","payload":{"dx":1}}}), + json!({"command":{"type":"mouse.scroll","payload":{"dx":1,"dy":1,"extra":0}}}), + ] { + assert!( + !valid_desktop_payload("mouse.repeat.start", &payload), + "{payload} was rejected before this change" + ); + } + } + + #[test] + fn pointer_profile_advertises_key_repeat_without_disturbing_mouse_repeat() { + let profile = PointerProfile { + display_id: "display:0:0:1920:1080:1".into(), + scale_factor: 1.0, + x: 0, + y: 0, + width: 1920, + height: 1080, + small_delta: 49, + medium_delta: 130, + large_delta: 281, + display_navigation_supported: true, + display_count: 2, + }; + let settings = AppSettings::default(); + let response: Value = + serde_json::from_str(&pointer_profile_response("profile-1", &profile, &settings)) + .unwrap(); + let capabilities = &response["payload"]["capabilities"]; + + let key_repeat = &capabilities["keyRepeat"]; + assert_eq!(key_repeat["supported"], true); + assert_eq!(key_repeat["enabled"], settings.key_repeat_enabled); + assert_eq!(key_repeat["intervalMs"], settings.key_repeat_interval_ms); + assert_eq!( + key_repeat["initialDelayMs"], + settings.key_repeat_initial_delay_ms + ); + assert_eq!(key_repeat["minIntervalMs"], 100); + assert_eq!(key_repeat["maxIntervalMs"], 1000); + assert_eq!( + key_repeat["repeatableKeys"], + json!([ + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Tab", + "Backspace", + "Delete", + "PageUp", + "PageDown" + ]) + ); + + // An old client reads only these; adding keyRepeat must not move them. + assert_eq!( + capabilities["mouseRepeat"], + json!({ + "supported": true, + "enabled": settings.mouse_repeat_enabled, + "intervalMs": settings.move_repeat_interval_ms, + "moveIntervalMs": settings.move_repeat_interval_ms, + "scrollIntervalMs": settings.scroll_repeat_interval_ms, + "minIntervalMs": 100, + "maxIntervalMs": 2000, + "accelerationDurationMs": settings.mouse_repeat_acceleration_duration_ms, + "accelerationDurationOptionsMs": [0, 500, 1000, 2000], + "accelerationInitialScalePercent": 25 + }) + ); + } + + #[test] + fn repeat_command_names_stay_out_of_the_parallel_command_lists() { + // Key repeat rides the existing repeat envelope precisely so these + // hand-maintained lists need no new entries. Pinning them here makes an + // accidental future divergence fail loudly. + let profile = PointerProfile { + display_id: "display:0:0:1920:1080:1".into(), + scale_factor: 1.0, + x: 0, + y: 0, + width: 1920, + height: 1080, + small_delta: 49, + medium_delta: 130, + large_delta: 281, + display_navigation_supported: true, + display_count: 2, + }; + let response: Value = serde_json::from_str(&pointer_profile_response( + "profile-1", + &profile, + &AppSettings::default(), + )) + .unwrap(); + let capabilities = &response["payload"]["capabilities"]; + for list in ["supportedCommands", "noAckCommands"] { + let commands = capabilities[list].as_array().unwrap(); + assert!(commands.contains(&json!("mouse.repeat.start")), "{list}"); + assert!(commands.contains(&json!("mouse.repeat.stop")), "{list}"); + for absent in [ + "keyboard.repeat.start", + "keyboard.repeat.stop", + "input.repeat.start", + "input.repeat.stop", + ] { + assert!(!commands.contains(&json!(absent)), "{list} gained {absent}"); + } + } + for absent in ["keyboard.repeat.start", "input.repeat.start"] { + assert!(!is_desktop_command(absent), "{absent}"); + } + assert!(is_desktop_command("mouse.repeat.start")); + assert!(is_desktop_command("mouse.repeat.stop")); + } + #[test] fn pointer_profile_advertises_the_full_desktop_command_set() { let profile = PointerProfile { diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 10b1475d..4ffdbf45 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -63,6 +63,12 @@ pub struct AppSettings { pub scroll_repeat_interval_ms: u32, #[serde(default = "default_mouse_repeat_acceleration")] pub mouse_repeat_acceleration_duration_ms: u32, + #[serde(default = "default_key_repeat_enabled")] + pub key_repeat_enabled: bool, + #[serde(default = "default_key_repeat_interval")] + pub key_repeat_interval_ms: u32, + #[serde(default = "default_key_repeat_initial_delay")] + pub key_repeat_initial_delay_ms: u32, #[serde(default)] pub dwell_click_enabled: bool, #[serde(default = "default_dwell_click_delay")] @@ -85,6 +91,9 @@ impl Default for AppSettings { move_repeat_interval_ms: 250, scroll_repeat_interval_ms: 250, mouse_repeat_acceleration_duration_ms: default_mouse_repeat_acceleration(), + key_repeat_enabled: default_key_repeat_enabled(), + key_repeat_interval_ms: default_key_repeat_interval(), + key_repeat_initial_delay_ms: default_key_repeat_initial_delay(), dwell_click_enabled: false, dwell_click_delay_ms: default_dwell_click_delay(), cursor_overlay_enabled: true, @@ -112,6 +121,12 @@ impl AppSettings { if ![0, 500, 1000, 2000].contains(&self.mouse_repeat_acceleration_duration_ms) { return Err("Mouse repeat acceleration is invalid.".into()); } + if ![100, 250, 500, 1000].contains(&self.key_repeat_interval_ms) { + return Err("Key repeat interval is invalid.".into()); + } + if ![0, 250, 500, 1000].contains(&self.key_repeat_initial_delay_ms) { + return Err("Key repeat initial delay is invalid.".into()); + } if ![500, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 7000, 8000] .contains(&self.dwell_click_delay_ms) { @@ -145,6 +160,18 @@ fn default_mouse_repeat_acceleration() -> u32 { 1000 } +fn default_key_repeat_enabled() -> bool { + true +} + +fn default_key_repeat_interval() -> u32 { + 250 +} + +fn default_key_repeat_initial_delay() -> u32 { + 500 +} + fn default_dwell_click_delay() -> u32 { 1000 } @@ -1289,6 +1316,74 @@ mod tests { ); let _ = fs::remove_dir_all(root); } + #[test] + fn settings_written_before_key_repeat_still_load_with_defaults() { + // A settings blob exactly as shipped before key repeat existed. It must + // keep deserializing, and the new fields must take their defaults rather + // than failing the load and resetting every other preference. + let stored = serde_json::json!({ + "startWithSystem": true, + "pointerScalePercent": 150, + "mouseRepeatEnabled": true, + "moveRepeatIntervalMs": 500, + "scrollRepeatIntervalMs": 100, + "mouseRepeatAccelerationDurationMs": 2000, + "dwellClickEnabled": true, + "dwellClickDelayMs": 1500, + "cursorOverlayEnabled": true, + "cursorOverlaySize": "large", + "cursorOverlayColor": "blue", + "cursorOverlayVisibility": "whileControlling", + "cursorCrosshairs": true, + "shareDiagnostics": true + }); + let settings: AppSettings = serde_json::from_value(stored).unwrap(); + + assert!(settings.key_repeat_enabled); + assert_eq!(settings.key_repeat_interval_ms, 250); + assert_eq!(settings.key_repeat_initial_delay_ms, 500); + + // Everything that was stored survives untouched. + assert_eq!(settings.pointer_scale_percent, 150); + assert_eq!(settings.move_repeat_interval_ms, 500); + assert_eq!(settings.scroll_repeat_interval_ms, 100); + assert_eq!(settings.cursor_overlay_size, "large"); + assert!(settings.clone().normalized().is_ok()); + } + + #[test] + fn settings_reject_invalid_key_repeat_cadence() { + for interval in [0, 50, 200, 2000] { + let value = AppSettings { + key_repeat_interval_ms: interval, + ..AppSettings::default() + }; + assert!(value.normalized().is_err(), "interval {interval}"); + } + for delay in [100, 400, 2000] { + let value = AppSettings { + key_repeat_initial_delay_ms: delay, + ..AppSettings::default() + }; + assert!(value.normalized().is_err(), "delay {delay}"); + } + for interval in [100, 250, 500, 1000] { + let value = AppSettings { + key_repeat_interval_ms: interval, + ..AppSettings::default() + }; + assert!(value.normalized().is_ok(), "interval {interval}"); + } + // Zero is a valid initial delay: it means repeat straight away. + for delay in [0, 250, 500, 1000] { + let value = AppSettings { + key_repeat_initial_delay_ms: delay, + ..AppSettings::default() + }; + assert!(value.normalized().is_ok(), "delay {delay}"); + } + } + #[test] fn settings_reject_unknown_overlay_visibility() { let value = AppSettings { diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index aea9c2b9..d73e7968 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -1701,9 +1701,18 @@ fn complete_repeat_start( let settings = overlay_settings(shared); let repeat_command = RepeatCommand::parse(&command.payload); let result = repeat_command.and_then(|repeat_command| { - if !settings.mouse_repeat_enabled { + let enabled = if repeat_command.repeat_key().is_some() { + settings.key_repeat_enabled + } else { + settings.mouse_repeat_enabled + }; + if !enabled { stop_repeat_for_device(app, &command.device_id); - return Err("Mouse repeat is disabled in settings.".into()); + return Err(if repeat_command.repeat_key().is_some() { + "Key repeat is disabled in settings.".to_string() + } else { + "Mouse repeat is disabled in settings.".to_string() + }); } stop_repeat_for_device(app, &command.device_id); let (active, initial_feedback) = { @@ -1730,8 +1739,23 @@ fn complete_repeat_start( } else { runtime.input.move_pointer_pixels(dx, dy) } + .map(Some) + } + RepeatCommand::Scroll { dx, dy } => { + runtime.input.execute_repeat_scroll(dx, dy).map(Some) + } + // The first tap fires immediately; the loop then waits the + // initial delay before repeating. Key repeats produce no pointer + // feedback, so the cursor overlay is hidden rather than driven. + RepeatCommand::Key { .. } => { + match runtime + .repeats + .initial_key(&command.device_id, active.generation) + { + Some(key) => runtime.input.execute_repeat_key(key).map(|()| None), + None => Ok(None), + } } - RepeatCommand::Scroll { dx, dy } => runtime.input.execute_repeat_scroll(dx, dy), }; match initial { Ok(feedback) => (active, feedback), @@ -1743,13 +1767,16 @@ fn complete_repeat_start( } } }; - app.state::().begin_repeat( - active.generation, - repeat_command, - settings.mouse_repeat_acceleration_duration_ms > 0, - matches!(initial_feedback, PointerFeedback::Drag), - settings, - ); + match initial_feedback { + Some(feedback) => app.state::().begin_repeat( + active.generation, + repeat_command, + settings.mouse_repeat_acceleration_duration_ms > 0, + matches!(feedback, PointerFeedback::Drag), + settings, + ), + None => app.state::().hide_for_typing(), + } spawn_repeat_loop( app.clone(), shared.clone(), @@ -1797,13 +1824,32 @@ fn spawn_repeat_loop(app: AppHandle, shared: SharedModel, device_id: String, gen .and_then(|runtime| runtime.repeats.current(&device_id, generation)) }; let Some(active) = active else { return }; - if !settings.mouse_repeat_enabled { + let is_key_repeat = active.command.repeat_key().is_some(); + let enabled = if is_key_repeat { + settings.key_repeat_enabled + } else { + settings.mouse_repeat_enabled + }; + if !enabled { stop_repeat_if_current(&app, &device_id, generation); return; } let delay_ms = match active.command { RepeatCommand::Move { .. } => MOVE_TICK_INTERVAL_MS, RepeatCommand::Scroll { .. } => u64::from(active.command.interval_ms(&settings)), + RepeatCommand::Key { .. } => { + let delay = runtime() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .and_then(|runtime| { + runtime + .repeats + .key_delay_ms(&device_id, generation, &settings) + }); + let Some(delay) = delay else { return }; + u64::from(delay) + } }; tokio::time::sleep(Duration::from_millis(delay_ms)).await; let settings = overlay_settings(&shared); @@ -1817,8 +1863,13 @@ fn spawn_repeat_loop(app: AppHandle, shared: SharedModel, device_id: String, gen let Some(active) = runtime.repeats.current(&device_id, generation) else { return; }; - if !settings.mouse_repeat_enabled { - Err("Mouse repeat was disabled.".to_string()) + let enabled = if active.command.repeat_key().is_some() { + settings.key_repeat_enabled + } else { + settings.mouse_repeat_enabled + }; + if !enabled { + Err("Repeat was disabled.".to_string()) } else { match active.command { RepeatCommand::Move { .. } => { @@ -1840,6 +1891,13 @@ fn spawn_repeat_loop(app: AppHandle, shared: SharedModel, device_id: String, gen RepeatCommand::Scroll { dx, dy } => { runtime.input.execute_repeat_scroll(dx, dy).map(|_| ()) } + RepeatCommand::Key { .. } => { + let Some(key) = runtime.repeats.advance_key(&device_id, generation) + else { + return; + }; + runtime.input.execute_repeat_key(key) + } } } }; @@ -1848,7 +1906,10 @@ fn spawn_repeat_loop(app: AppHandle, shared: SharedModel, device_id: String, gen set_activity( &shared, ActivityKind::Error, - format!("Mouse repeat stopped: {error}"), + format!( + "{} repeat stopped: {error}", + if is_key_repeat { "Key" } else { "Mouse" } + ), ); emit_state(&app, &shared); } diff --git a/src/App.test.tsx b/src/App.test.tsx index 1c6c3cde..99d9b376 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -393,6 +393,35 @@ describe("Switchify PC shell", () => { expect(screen.getByRole("heading", { name: "Waiting for an Android device" })).toBeInTheDocument(); }); + it("exposes key repeat settings and disables them with the toggle", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + const toggle = screen.getByRole("checkbox", { name: "Repeat held keys" }); + expect(toggle).toBeChecked(); + + const delay = screen.getByRole("group", { name: "Delay before repeating" }); + expect(delay).not.toBeDisabled(); + for (const label of ["None", "Short", "Medium", "Long"]) { + expect(within(delay).getByRole("button", { name: label })).toBeInTheDocument(); + } + expect(within(delay).getByRole("button", { name: "Medium" })).toHaveAttribute("aria-pressed", "true"); + + const interval = screen.getByRole("group", { name: "Key interval" }); + expect(within(interval).getByRole("button", { name: "0.25s" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(within(interval).getByRole("button", { name: "0.5s" })); + expect(within(interval).getByRole("button", { name: "0.5s" })).toHaveAttribute("aria-pressed", "true"); + + fireEvent.click(within(delay).getByRole("button", { name: "None" })); + expect(within(delay).getByRole("button", { name: "None" })).toHaveAttribute("aria-pressed", "true"); + + // Turning the feature off must disable its cadence controls, matching the + // mouse repeat and dwell blocks. + fireEvent.click(toggle); + expect(screen.getByRole("group", { name: "Delay before repeating" })).toBeDisabled(); + expect(screen.getByRole("group", { name: "Key interval" })).toBeDisabled(); + }); + it("opens settings with accessible native controls", async () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); diff --git a/src/App.tsx b/src/App.tsx index 6ad81209..fcad1e39 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -292,6 +292,12 @@ function SettingGroup({ title, description, children, id, sectionRef, focusable const pointerSpeedOptions = [5, 25, 50, 75, 100] as const; const pointerSpeedValues = Array.from({ length: 45 }, (_, index) => (index + 1) * 5); const repeatIntervalOptions = [100, 250, 500, 1000] as const; +const keyRepeatDelayOptions = [ + { value: 0, label: "None" }, + { value: 250, label: "Short" }, + { value: 500, label: "Medium" }, + { value: 1000, label: "Long" }, +] as const; const accelerationOptions = [ { value: 0, label: "Off" }, { value: 500, label: "Short" }, @@ -403,6 +409,18 @@ function SettingsView({ state, settings, onChange, chooseTelemetry, updateAction +
+ update("keyRepeatEnabled", value)} /> +
+
Delay before repeating
+ {keyRepeatDelayOptions.map(({ value, label }) => )} +
+
Key interval
+ {repeatIntervalOptions.map((value) => )} +
+

Holding a navigation key on the remote repeats it, like holding a key on a keyboard. Applies to the arrow keys, Tab, Backspace, Delete, Page Up, and Page Down.

+
+
update("dwellClickEnabled", value)} />
diff --git a/src/api.ts b/src/api.ts index 7c2f3b40..14af34b0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -17,6 +17,7 @@ export const browserState: AppState = { startWithSystem: false, pointerScalePercent: 100, mouseRepeatEnabled: true, moveRepeatIntervalMs: 250, scrollRepeatIntervalMs: 250, mouseRepeatAccelerationDurationMs: 1000, + keyRepeatEnabled: true, keyRepeatIntervalMs: 250, keyRepeatInitialDelayMs: 500, dwellClickEnabled: false, dwellClickDelayMs: 1000, cursorOverlayEnabled: true, cursorOverlaySize: "medium", cursorOverlayColor: "red", cursorOverlayVisibility: "whileControlling", diff --git a/src/types.ts b/src/types.ts index b36cd430..b8737533 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,9 @@ export type AppSettings = { moveRepeatIntervalMs: number; scrollRepeatIntervalMs: number; mouseRepeatAccelerationDurationMs: number; + keyRepeatEnabled: boolean; + keyRepeatIntervalMs: number; + keyRepeatInitialDelayMs: number; dwellClickEnabled: boolean; dwellClickDelayMs: number; cursorOverlayEnabled: boolean; From 29f585f40a3c717eecba023e527aa287f3488922 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 7 Sep 2026 20:37:23 +0100 Subject: [PATCH 2/7] fix: free stuck repeat keys and guard key repeat during switch forwarding Addresses independent review findings on the key repeat change. A repeat tick whose key-up fails leaves the key physically held, and the OS's own auto-repeat then keeps firing it into the focused application. Recovery previously waited for release_all on a terminal path, so a stuck Backspace could delete continuously until the phone disconnected. Every repeat stop path on both runtimes now retries the pending release. Key repeats inject keystrokes but bypass DesktopInput::execute, which refuses keyboard input during Switch Forwarding. The repeat start path now enforces that guard itself rather than offering a way around it. Toggling "Repeat held keys" off mid-repeat also stops the active repeat, so the runtime loop ends deliberately instead of surfacing a red "Key repeat stopped" error on its next tick. Co-Authored-By: Claude Opus 5 (1M context) --- src-tauri/src/input.rs | 83 ++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 4 +- src-tauri/src/macos.rs | 30 +++++++++++- src-tauri/src/windows_runtime.rs | 68 ++++++++++++++++++++------ 4 files changed, 168 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index eb728f3c..5e669850 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -1237,6 +1237,18 @@ impl DesktopInput { Ok(()) } + /// Releases any key left down by a failed repeat-tick release. + /// + /// Called when a repeat ends rather than only on the terminal cleanup + /// paths: a key stuck down keeps the OS's own auto-repeat firing into the + /// focused application, so waiting for a disconnect is far too late. + pub fn release_repeat_keys(&mut self) -> Result<(), String> { + if self.pending_key_releases.is_empty() { + return Ok(()); + } + self.release_pending_keys() + } + /// Retries key releases that failed during a repeat tick. Every key is /// attempted even when one fails, so a single bad release cannot strand the /// others. @@ -2355,6 +2367,77 @@ mod tests { assert!(input.pending_key_releases.is_empty()); } + #[test] + fn release_repeat_keys_frees_a_stuck_key_without_waiting_for_a_disconnect() { + let mut input = DesktopInput::new(FakeInjector::default()); + input.injector.fail_key_up = Some("Backspace".into()); + assert!(input.execute_repeat_key(RepeatKey::Backspace).is_err()); + + // Stopping the repeat must free the key: while it is held the OS keeps + // auto-repeating it into the focused application. + input.injector.fail_key_up = None; + assert!(input.release_repeat_keys().is_ok()); + assert!(input.pending_key_releases.is_empty()); + assert_eq!( + input.injector.keys, + vec![ + ("Backspace".into(), true), + ("Backspace".into(), false), + ("Backspace".into(), false), + ] + ); + } + + #[test] + fn release_repeat_keys_injects_nothing_when_no_key_is_stuck() { + let mut input = DesktopInput::new(FakeInjector::default()); + assert!(input.execute_repeat_key(RepeatKey::ArrowUp).is_ok()); + let injected = input.injector.keys.len(); + + // The common case runs on every repeat stop, so it must not inject. + assert!(input.release_repeat_keys().is_ok()); + assert_eq!(input.injector.keys.len(), injected); + } + + #[test] + fn repeat_keys_are_refused_during_a_switch_session() { + // The repeat path bypasses execute(), so the runtimes enforce this guard + // themselves; this pins the message and the underlying predicate. + let mut input = DesktopInput::new(FakeInjector::default()); + assert!(!input.has_active_switch_session()); + let profile = SwitchProfile { + id: "profile".into(), + version: 1, + name: "Profile".into(), + provider: "mapped".into(), + built_in: false, + bindings: Vec::new(), + }; + input + .execute( + "device", + "switch.session.start", + &serde_json::json!({ + "sessionId": uuid::Uuid::new_v4().to_string(), + "profileId": "profile", + "profileVersion": 1, + "switchCount": 1 + }), + std::slice::from_ref(&profile), + ) + .unwrap(); + assert!(input.has_active_switch_session()); + assert_eq!( + input.execute( + "device", + "keyboard.key", + &serde_json::json!({"key": "ArrowDown"}), + &[], + ), + Err("Stop Switch Forwarding before using other PC control commands.".into()) + ); + } + #[test] fn a_later_successful_tap_clears_an_earlier_pending_release() { let mut input = DesktopInput::new(FakeInjector::default()); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b5c89cb8..39da49e2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -464,7 +464,9 @@ fn save_settings( { app.state::().cancel(&app); } - if !settings.mouse_repeat_enabled { + // Turning either repeat off stops whatever is in flight, so the runtime + // loops end deliberately instead of erroring out on their next tick. + if !settings.mouse_repeat_enabled || !settings.key_repeat_enabled { platform_stop_mouse_repeat(&app); } overlay.apply_settings(settings); diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 7445dc3b..d4356855 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -1454,6 +1454,19 @@ impl MacRuntime { "Accessibility permission is required before the pointer can move.".into(), ); } + // A key repeat injects keystrokes, which `DesktopInput::execute` + // refuses during Switch Forwarding; the repeat path bypasses that + // guard, so it has to enforce the same rule itself. + if repeat_command.repeat_key().is_some() + && self + .input + .as_ref() + .is_some_and(|input| input.has_active_switch_session()) + { + return Err( + "Stop Switch Forwarding before using other PC control commands.".to_string(), + ); + } let active = self.repeats.start( command.device_id.clone(), repeat_command, @@ -1713,6 +1726,7 @@ impl MacRuntime { .is_some_and(|active| matches!(active.command, RepeatCommand::Move { .. })); let stopped = self.repeats.stop_if_current(device_id, generation); if stopped { + self.release_repeat_keys(); self.pending_repeat_moves.remove(&generation); self.app.state::().end_repeat(generation); if arm_dwell && was_move { @@ -1722,12 +1736,22 @@ impl MacRuntime { stopped } + /// Releases a key left down by a failed repeat-tick release. Runs whenever a + /// repeat ends, not only on terminal cleanup, because a stuck key keeps the + /// OS auto-repeating into the focused application. + fn release_repeat_keys(&mut self) { + if let Some(input) = self.input.as_mut() { + let _ = input.release_repeat_keys(); + } + } + fn stop_repeat_for_device( &mut self, device_id: &str, ) -> Option { let active = self.repeats.stop(device_id); if let Some(active) = active { + self.release_repeat_keys(); self.pending_repeat_moves.remove(&active.generation); self.app .state::() @@ -1737,7 +1761,11 @@ impl MacRuntime { } fn stop_all_repeats(&mut self) { - for active in self.repeats.stop_all() { + let stopped = self.repeats.stop_all(); + if !stopped.is_empty() { + self.release_repeat_keys(); + } + for active in stopped { self.pending_repeat_moves.remove(&active.generation); self.app .state::() diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index d73e7968..8b02da95 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -1722,6 +1722,14 @@ fn complete_repeat_start( let runtime = guard .as_mut() .ok_or_else(|| "Bluetooth runtime is not ready.".to_string())?; + // A key repeat injects keystrokes, which `DesktopInput::execute` + // refuses during Switch Forwarding; the repeat path bypasses that + // guard, so it has to enforce the same rule itself. + if repeat_command.repeat_key().is_some() && runtime.input.has_active_switch_session() { + return Err( + "Stop Switch Forwarding before using other PC control commands.".to_string(), + ); + } let active = runtime.repeats.start( command.device_id.clone(), repeat_command, @@ -1920,11 +1928,21 @@ fn spawn_repeat_loop(app: AppHandle, shared: SharedModel, device_id: String, gen } fn stop_repeat_if_current(app: &AppHandle, device_id: &str, generation: u64) -> bool { - let stopped = runtime() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_mut() - .is_some_and(|runtime| runtime.repeats.stop_if_current(device_id, generation)); + let stopped = { + let mut guard = runtime() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match guard.as_mut() { + Some(runtime) => { + let stopped = runtime.repeats.stop_if_current(device_id, generation); + if stopped { + let _ = runtime.input.release_repeat_keys(); + } + stopped + } + None => false, + } + }; if stopped { app.state::().end_repeat(generation); } @@ -1935,11 +1953,21 @@ fn stop_repeat_for_device( app: &AppHandle, device_id: &str, ) -> Option { - let active = runtime() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_mut() - .and_then(|runtime| runtime.repeats.stop(device_id)); + let active = { + let mut guard = runtime() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match guard.as_mut() { + Some(runtime) => { + let active = runtime.repeats.stop(device_id); + if active.is_some() { + let _ = runtime.input.release_repeat_keys(); + } + active + } + None => None, + } + }; if let Some(active) = active { app.state::().end_repeat(active.generation); } @@ -1947,11 +1975,21 @@ fn stop_repeat_for_device( } fn stop_all_repeats(app: &AppHandle) { - let active = runtime() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_mut() - .map_or_else(Vec::new, |runtime| runtime.repeats.stop_all()); + let active = { + let mut guard = runtime() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match guard.as_mut() { + Some(runtime) => { + let active = runtime.repeats.stop_all(); + if !active.is_empty() { + let _ = runtime.input.release_repeat_keys(); + } + active + } + None => Vec::new(), + } + }; for repeat in active { app.state::().end_repeat(repeat.generation); } From 9200a4d18e0bc34b430a6d4700c6665d11eed898 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 7 Sep 2026 20:53:16 +0100 Subject: [PATCH 3/7] fix: release stuck keys on failed initial taps and only stop repeats on change Addresses the second independent review of this branch. The initial-tap failure arm on both runtimes dropped the repeat straight through the controller, bypassing the runtime helper that retries a failed key release. Every later cleanup path is gated on a repeat actually being stopped, so that key stayed held. Both arms now release it. save_settings tested the incoming values rather than a change, so with key repeat persisted off any unrelated settings save cancelled an in-flight mouse repeat. It now compares against the previous values, matching the dwell block above it. Co-Authored-By: Claude Opus 5 (1M context) --- src-tauri/src/lib.rs | 12 ++++++++++-- src-tauri/src/macos.rs | 2 ++ src-tauri/src/windows_runtime.rs | 2 ++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 39da49e2..68bac917 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -432,6 +432,8 @@ fn save_settings( previous_consent, previous_dwell_enabled, previous_dwell_delay, + previous_mouse_repeat_enabled, + previous_key_repeat_enabled, ) = { let state = &model .shared @@ -444,6 +446,8 @@ fn save_settings( state.telemetry.consent, state.settings.dwell_click_enabled, state.settings.dwell_click_delay_ms, + state.settings.mouse_repeat_enabled, + state.settings.key_repeat_enabled, ) }; if settings.start_with_system != previous_start_with_system { @@ -465,8 +469,12 @@ fn save_settings( app.state::().cancel(&app); } // Turning either repeat off stops whatever is in flight, so the runtime - // loops end deliberately instead of erroring out on their next tick. - if !settings.mouse_repeat_enabled || !settings.key_repeat_enabled { + // loops end deliberately instead of erroring out on their next tick. Only a + // change stops repeats: testing the current value would cancel an active + // mouse repeat on every unrelated save while key repeat is switched off. + if (previous_mouse_repeat_enabled && !settings.mouse_repeat_enabled) + || (previous_key_repeat_enabled && !settings.key_repeat_enabled) + { platform_stop_mouse_repeat(&app); } overlay.apply_settings(settings); diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index d4356855..34642c1d 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -1509,6 +1509,8 @@ impl MacRuntime { Err(error) => { self.repeats .stop_if_current(&command.device_id, active.generation); + // A failed initial key tap can still have left the key down. + self.release_repeat_keys(); return Err(error); } }; diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index 8b02da95..f860ed2d 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -1771,6 +1771,8 @@ fn complete_repeat_start( runtime .repeats .stop_if_current(&command.device_id, active.generation); + // A failed initial key tap can still have left the key down. + let _ = runtime.input.release_repeat_keys(); return Err(error); } } From fcf147966852e0c8127fad71aa501a85a8df510f Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 7 Sep 2026 21:11:54 +0100 Subject: [PATCH 4/7] fix: retry stuck repeat key releases unconditionally on stop Addresses the third independent review of this branch. The stop helpers only retried a pending key release when a repeat had actually been stopped. If a tick's key-up failed and the immediate retry failed too, the key stayed in pending_key_releases with no active repeat, so no later stop would ever free it and the OS kept auto-repeating it into the focused application until disconnect. release_repeat_keys already returns immediately when nothing is pending, so calling it unconditionally costs nothing on the common path and removes the gap on both runtimes. Co-Authored-By: Claude Opus 5 (1M context) --- src-tauri/src/input.rs | 24 ++++++++++++++++++++++++ src-tauri/src/macos.rs | 10 +++++----- src-tauri/src/windows_runtime.rs | 14 +++++--------- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index 5e669850..82b84ea1 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -2388,6 +2388,30 @@ mod tests { ); } + #[test] + fn a_key_stuck_after_a_failed_retry_is_freed_by_a_later_stop() { + let mut input = DesktopInput::new(FakeInjector::default()); + input.injector.fail_key_up = Some("ArrowLeft".into()); + assert!(input.execute_repeat_key(RepeatKey::ArrowLeft).is_err()); + // The retry at stop time fails too, so the key is still down with no + // repeat left to hang the recovery off. + assert!(input.release_repeat_keys().is_err()); + assert_eq!( + input.pending_key_releases, + HashSet::from([RepeatKey::ArrowLeft]) + ); + + // Stop paths run this unconditionally, so recovery arrives without + // needing another repeat to be active. + input.injector.fail_key_up = None; + assert!(input.release_repeat_keys().is_ok()); + assert!(input.pending_key_releases.is_empty()); + assert_eq!( + input.injector.keys.last().unwrap(), + &("ArrowLeft".to_string(), false) + ); + } + #[test] fn release_repeat_keys_injects_nothing_when_no_key_is_stuck() { let mut input = DesktopInput::new(FakeInjector::default()); diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 34642c1d..c689d590 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -1727,8 +1727,10 @@ impl MacRuntime { .current(device_id, generation) .is_some_and(|active| matches!(active.command, RepeatCommand::Move { .. })); let stopped = self.repeats.stop_if_current(device_id, generation); + // Unconditional: a key whose retry also failed is still pending with no + // active repeat, and nothing else would ever free it. + self.release_repeat_keys(); if stopped { - self.release_repeat_keys(); self.pending_repeat_moves.remove(&generation); self.app.state::().end_repeat(generation); if arm_dwell && was_move { @@ -1752,8 +1754,8 @@ impl MacRuntime { device_id: &str, ) -> Option { let active = self.repeats.stop(device_id); + self.release_repeat_keys(); if let Some(active) = active { - self.release_repeat_keys(); self.pending_repeat_moves.remove(&active.generation); self.app .state::() @@ -1764,9 +1766,7 @@ impl MacRuntime { fn stop_all_repeats(&mut self) { let stopped = self.repeats.stop_all(); - if !stopped.is_empty() { - self.release_repeat_keys(); - } + self.release_repeat_keys(); for active in stopped { self.pending_repeat_moves.remove(&active.generation); self.app diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index f860ed2d..ced73b38 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -1937,9 +1937,9 @@ fn stop_repeat_if_current(app: &AppHandle, device_id: &str, generation: u64) -> match guard.as_mut() { Some(runtime) => { let stopped = runtime.repeats.stop_if_current(device_id, generation); - if stopped { - let _ = runtime.input.release_repeat_keys(); - } + // Unconditional: a key whose retry also failed is still pending + // with no active repeat, and nothing else would ever free it. + let _ = runtime.input.release_repeat_keys(); stopped } None => false, @@ -1962,9 +1962,7 @@ fn stop_repeat_for_device( match guard.as_mut() { Some(runtime) => { let active = runtime.repeats.stop(device_id); - if active.is_some() { - let _ = runtime.input.release_repeat_keys(); - } + let _ = runtime.input.release_repeat_keys(); active } None => None, @@ -1984,9 +1982,7 @@ fn stop_all_repeats(app: &AppHandle) { match guard.as_mut() { Some(runtime) => { let active = runtime.repeats.stop_all(); - if !active.is_empty() { - let _ = runtime.input.release_repeat_keys(); - } + let _ = runtime.input.release_repeat_keys(); active } None => Vec::new(), From 1b54616181b8e07bf9f466cdf37a4eba5dd32850 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 7 Sep 2026 21:30:35 +0100 Subject: [PATCH 5/7] fix: keep the overlay key-repeat safety net free of stale state The defensive RepeatCommand::Key arm in BeginRepeat returned after control_active, drag_active and repeat_generation had already been set and without clearing feedback, so if it ever became reachable a later re-render could paint stale pointer feedback. The check now runs before any state is touched, alongside the disabled-overlay guard. Unreachable today, since both runtimes hide the overlay for key repeats rather than beginning one. Co-Authored-By: Claude Opus 5 (1M context) --- src-tauri/src/overlay.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index b8c30758..f11889bf 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -287,7 +287,12 @@ impl OverlayEngine { Command::BeginRepeat(generation, command, accelerated, dragging, settings) => { self.typing_suppressed = false; self.settings = settings; - if !self.settings.cursor_overlay_enabled { + // Key repeats carry no pointer feedback: the runtimes hide the + // overlay rather than beginning one. Checked before any state is + // touched so this safety net cannot leave stale feedback behind + // for a later re-render. + if !self.settings.cursor_overlay_enabled || command.repeat_key().is_some() { + self.feedback = None; return self.hide(); } self.control_active = true; @@ -299,9 +304,6 @@ impl OverlayEngine { dragging, }, RepeatCommand::Scroll { dx, dy } => PointerFeedback::RepeatScroll { dx, dy }, - // Key repeats carry no pointer feedback: the runtime hides - // the overlay instead of beginning one. Handled defensively - // so the overlay thread cannot panic if that ever changes. RepeatCommand::Key { .. } => return self.hide(), }; self.feedback = Some(feedback); From b842b4887bb48b6977cd4690dc1fca1a48aad404 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 7 Sep 2026 21:54:38 +0100 Subject: [PATCH 6/7] fix: report the keyboard accessibility message for key repeats on macOS Key repeat start reported "Accessibility permission is required before the pointer can move." even though it injects keystrokes, while the branches either side of it carefully distinguish key from mouse wording. Co-Authored-By: Claude Opus 5 (1M context) --- src-tauri/src/macos.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index c689d590..47ea09f5 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -1450,9 +1450,12 @@ impl MacRuntime { } self.stop_repeat_for_device(&command.device_id); if self.input.is_none() && !self.refresh_accessibility(false)? { - return Err( - "Accessibility permission is required before the pointer can move.".into(), - ); + return Err(if repeat_command.repeat_key().is_some() { + "Accessibility permission is required before input can be controlled." + .to_string() + } else { + "Accessibility permission is required before the pointer can move.".to_string() + }); } // A key repeat injects keystrokes, which `DesktopInput::execute` // refuses during Switch Forwarding; the repeat path bypasses that From 0ab736c7d2231be1d4bf91b20c4897179e2dba80 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 7 Sep 2026 22:56:49 +0100 Subject: [PATCH 7/7] docs: clarify that the repeat switch guard is keyboard-scoped by design The comment read as though the repeat path enforced Switch Forwarding for every repeat kind. Pointer repeats have always been allowed during a session; only the new keyboard injection is guarded here. --- src-tauri/src/macos.rs | 4 +++- src-tauri/src/windows_runtime.rs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 47ea09f5..fdeab741 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -1459,7 +1459,9 @@ impl MacRuntime { } // A key repeat injects keystrokes, which `DesktopInput::execute` // refuses during Switch Forwarding; the repeat path bypasses that - // guard, so it has to enforce the same rule itself. + // guard, so it enforces the same rule itself. Scoped to keys + // deliberately: pointer repeats have always been allowed here, and + // changing that is a separate behaviour decision. if repeat_command.repeat_key().is_some() && self .input diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index ced73b38..40ab955a 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -1724,7 +1724,9 @@ fn complete_repeat_start( .ok_or_else(|| "Bluetooth runtime is not ready.".to_string())?; // A key repeat injects keystrokes, which `DesktopInput::execute` // refuses during Switch Forwarding; the repeat path bypasses that - // guard, so it has to enforce the same rule itself. + // guard, so it enforces the same rule itself. Scoped to keys + // deliberately: pointer repeats have always been allowed here, and + // changing that is a separate behaviour decision. if repeat_command.repeat_key().is_some() && runtime.input.has_active_switch_session() { return Err( "Stop Switch Forwarding before using other PC control commands.".to_string(),