Skip to content
Merged
264 changes: 264 additions & 0 deletions src-tauri/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -434,6 +435,7 @@ pub struct DesktopInput<I: InputInjector> {
pub(crate) injector: I,
held_modifiers: HashSet<ModifierKey>,
pending_modifier_releases: HashSet<ModifierKey>,
pending_key_releases: HashSet<RepeatKey>,
modifier_overlay: Option<Arc<dyn ModifierKeyOverlayNotifier>>,
held_button: Option<MouseButton>,
pointer_scale_percent: u32,
Expand Down Expand Up @@ -462,6 +464,7 @@ impl<I: InputInjector> DesktopInput<I> {
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,
Expand Down Expand Up @@ -1195,6 +1198,27 @@ impl<I: InputInjector> DesktopInput<I> {
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();
Expand All @@ -1204,12 +1228,47 @@ impl<I: InputInjector> DesktopInput<I> {
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(())
}

/// 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.
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);
Expand Down Expand Up @@ -2243,6 +2302,211 @@ 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 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 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());
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());
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();
Expand Down
12 changes: 11 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -464,7 +468,13 @@ fn save_settings(
{
app.state::<dwell::DwellController>().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. 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);
Expand Down
Loading