diff --git a/src/config/schema/hid.rs b/src/config/schema/hid.rs index 69e2bc9c..6cd56229 100644 --- a/src/config/schema/hid.rs +++ b/src/config/schema/hid.rs @@ -246,10 +246,39 @@ impl OtgHidProfile { } } +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct MouseJigglerConfig { + pub enabled: bool, + pub interval_secs: u32, +} + +impl Default for MouseJigglerConfig { + fn default() -> Self { + Self { + enabled: false, + interval_secs: 60, + } + } +} + +impl MouseJigglerConfig { + pub fn validate(&self) -> crate::error::Result<()> { + if !(10..=3600).contains(&self.interval_secs) { + return Err(crate::error::AppError::BadRequest( + "Mouse jiggler interval must be an integer between 10 and 3600 seconds".into(), + )); + } + Ok(()) + } +} + #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct HidConfig { + pub jiggler: MouseJigglerConfig, pub bluetooth: BluetoothHidConfig, pub backend: HidBackend, pub otg_udc: Option, @@ -276,6 +305,7 @@ pub struct HidConfig { impl Default for HidConfig { fn default() -> Self { Self { + jiggler: MouseJigglerConfig::default(), backend: HidBackend::None, bluetooth: BluetoothHidConfig::default(), otg_udc: None, @@ -294,6 +324,17 @@ impl Default for HidConfig { } impl HidConfig { + pub fn has_mouse(&self) -> bool { + match self.backend { + HidBackend::None => false, + HidBackend::Otg => { + let functions = self.effective_otg_functions(); + functions.mouse_relative || functions.mouse_absolute + } + _ => true, + } + } + pub fn effective_otg_functions(&self) -> OtgHidFunctions { self.otg_profile.resolve_functions(&self.otg_functions) } @@ -311,6 +352,7 @@ impl HidConfig { } pub fn validate_platform(&self) -> crate::error::Result<()> { + self.jiggler.validate()?; if self.backend == HidBackend::Ch9329Otg && !cfg!(target_os = "linux") { return Err(crate::error::AppError::BadRequest( "CH9329 + OTG requires Linux with a USB device controller".into(), diff --git a/src/config/store.rs b/src/config/store.rs index 6419b297..e1894114 100644 --- a/src/config/store.rs +++ b/src/config/store.rs @@ -144,6 +144,32 @@ mod tests { use crate::db::DatabasePool; use tempfile::tempdir; + #[tokio::test] + async fn jiggler_defaults_and_preferences_survive_restart() { + let defaults: crate::config::HidConfig = + serde_json::from_str(r#"{"backend":"otg"}"#).unwrap(); + assert!(!defaults.jiggler.enabled); + assert_eq!(defaults.jiggler.interval_secs, 60); + let dir = tempdir().unwrap(); + let db = DatabasePool::new(&dir.path().join("jiggler.db")) + .await + .unwrap(); + db.init_schema().await.unwrap(); + let store = ConfigStore::new(db.clone_pool()); + store.load().await.unwrap(); + store + .update(|config| { + config.hid.jiggler.enabled = true; + config.hid.jiggler.interval_secs = 120; + }) + .await + .unwrap(); + let reloaded = ConfigStore::new(db.clone_pool()); + reloaded.load().await.unwrap(); + assert!(reloaded.get().hid.jiggler.enabled); + assert_eq!(reloaded.get().hid.jiggler.interval_secs, 120); + } + #[tokio::test] async fn test_config_store() { let dir = tempdir().unwrap(); diff --git a/src/hid/jiggler.rs b/src/hid/jiggler.rs new file mode 100644 index 00000000..1f3ebce9 --- /dev/null +++ b/src/hid/jiggler.rs @@ -0,0 +1,500 @@ +//! Device-side mouse jiggler. Real input invalidates every remaining movement. + +use std::collections::HashSet; +use std::time::{Duration, Instant}; + +use crate::config::{HidBackend, HidConfig, MouseJigglerConfig}; + +use super::{CanonicalKey, KeyEventType, KeyboardEvent, MouseEvent, MouseEventType}; + +const STEP_DELAY: Duration = Duration::from_millis(100); + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + Relative, + Absolute, +} + +pub(super) struct JigglerState { + config: MouseJigglerConfig, + relative: bool, + absolute: bool, + mode: Mode, + ready: bool, + activity: Instant, + keys: HashSet, + modifiers: u8, + buttons: u8, + consumer: u16, + last_absolute: Option<(i32, i32)>, + return_move: Option<(MouseEvent, Instant)>, + generation: u64, +} + +impl Default for JigglerState { + fn default() -> Self { + Self { + config: MouseJigglerConfig::default(), + relative: false, + absolute: false, + mode: Mode::Relative, + ready: false, + activity: Instant::now(), + keys: HashSet::new(), + modifiers: 0, + buttons: 0, + consumer: 0, + last_absolute: None, + return_move: None, + generation: 0, + } + } +} + +impl JigglerState { + pub fn configure(&mut self, hid: &HidConfig, now: Instant) { + let (relative, absolute) = match hid.backend { + HidBackend::None => (false, false), + HidBackend::Bluetooth => (true, false), + HidBackend::Otg => { + let functions = hid.effective_otg_functions(); + (functions.mouse_relative, functions.mouse_absolute) + } + _ => (true, true), + }; + let mode = if absolute && (hid.mouse_absolute || !relative) { + Mode::Absolute + } else { + Mode::Relative + }; + if self.config != hid.jiggler + || (self.relative, self.absolute, self.mode) != (relative, absolute, mode) + { + self.config = hid.jiggler.clone(); + self.relative = relative; + self.absolute = absolute; + self.mode = mode; + self.activity(now); + } + } + + pub fn activity(&mut self, now: Instant) { + self.activity = now; + self.return_move = None; + self.generation = self.generation.wrapping_add(1); + } + + pub fn keyboard(&mut self, event: &KeyboardEvent, now: Instant) { + self.activity(now); + match event.event_type { + KeyEventType::Down => { + self.keys.insert(event.key); + } + KeyEventType::Up => { + self.keys.remove(&event.key); + } + } + self.modifiers = event.modifiers.to_hid_byte(); + } + + pub fn mouse(&mut self, event: &MouseEvent, now: Instant) { + self.activity(now); + match event.event_type { + MouseEventType::Move if self.relative => self.mode = Mode::Relative, + MouseEventType::MoveAbs if self.absolute => { + self.mode = Mode::Absolute; + self.last_absolute = Some((event.x.clamp(0, 32767), event.y.clamp(0, 32767))); + } + MouseEventType::Down => { + if let Some(button) = event.button { + self.buttons |= button.to_hid_bit(); + } + } + MouseEventType::Up => { + if let Some(button) = event.button { + self.buttons &= !button.to_hid_bit(); + } + } + _ => {} + } + } + + pub fn consumer(&mut self, usage: u16, now: Instant) { + self.activity(now); + self.consumer = usage; + } + + pub fn reset(&mut self, now: Instant) { + self.activity(now); + self.keys.clear(); + self.modifiers = 0; + self.buttons = 0; + self.consumer = 0; + } + + pub fn suspend(&mut self) { + self.ready = false; + self.last_absolute = None; + self.reset(Instant::now()); + } + + pub fn delay(&self) -> Option { + (self.config.enabled && (self.relative || self.absolute)).then_some( + if self.return_move.is_some() { + STEP_DELAY + } else { + Duration::from_secs(1) + }, + ) + } + + pub fn valid(&self, generation: u64) -> bool { + self.config.enabled && self.ready && self.generation == generation + } + + pub fn tick( + &mut self, + ready: bool, + resolution: (u32, u32), + now: Instant, + ) -> Option<(MouseEvent, u64)> { + if ready != self.ready { + self.ready = ready; + if ready { + // Input may already have arrived since reconnecting. Preserve + // held keys/buttons when starting the new idle period. + self.activity(now); + } else { + self.reset(now); + self.last_absolute = None; + } + } + if !self.ready + || !self.config.enabled + || (!self.relative && !self.absolute) + || !self.keys.is_empty() + || self.modifiers != 0 + || self.buttons != 0 + || self.consumer != 0 + { + return None; + } + if let Some((_, deadline)) = &self.return_move { + if now < *deadline { + return None; + } + let (event, _) = self.return_move.take().unwrap(); + self.activity = now; + return Some((event, self.generation)); + } + if now.duration_since(self.activity) < Duration::from_secs(self.config.interval_secs.into()) + { + return None; + } + let (event, back) = if self.mode == Mode::Absolute + && self.absolute + && (self.last_absolute.is_some() || !self.relative) + { + // An absolute-only device has no way to query the host cursor position. + let (x, y) = self.last_absolute.unwrap_or((16384, 16384)); + let offset = |position: i32, extent: u32| { + let delta = (65534 / extent.max(1)).clamp(1, 512) as i32; + if position + delta <= 32767 { + position + delta + } else { + position - delta + } + }; + ( + MouseEvent::move_abs(offset(x, resolution.0), offset(y, resolution.1)), + MouseEvent::move_abs(x, y), + ) + } else { + (MouseEvent::move_rel(2, 0), MouseEvent::move_rel(-2, 0)) + }; + self.return_move = Some((back, now + STEP_DELAY)); + Some((event, self.generation)) + } + + pub fn failed(&mut self, generation: u64, now: Instant) { + if self.generation == generation { + self.activity(now); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::OtgHidProfile; + use crate::hid::{KeyboardModifiers, MouseButton}; + + fn configured(now: Instant) -> (JigglerState, HidConfig) { + let mut config = HidConfig::default(); + config.backend = HidBackend::Otg; + config.jiggler.enabled = true; + config.jiggler.interval_secs = 10; + let mut state = JigglerState::default(); + state.configure(&config, now); + assert!(state.tick(true, (1920, 1080), now).is_none()); + (state, config) + } + + #[test] + fn jiggler_waits_then_moves_out_and_back_without_counting_synthetic_input() { + let now = Instant::now(); + let (mut state, _) = configured(now); + assert!(state + .tick(true, (1920, 1080), now + Duration::from_secs(9)) + .is_none()); + let due = now + Duration::from_secs(10); + let (out, generation) = state.tick(true, (1920, 1080), due).unwrap(); + assert_eq!((out.event_type, out.x, out.y), (MouseEventType::Move, 2, 0)); + assert!(state.valid(generation)); + assert!(state + .tick(true, (1920, 1080), due + STEP_DELAY / 2) + .is_none()); + let (back, _) = state.tick(true, (1920, 1080), due + STEP_DELAY).unwrap(); + assert_eq!((back.x, back.y), (-2, 0)); + assert!(state + .tick(true, (1920, 1080), due + Duration::from_secs(10)) + .is_none()); + assert!(state + .tick( + true, + (1920, 1080), + due + STEP_DELAY + Duration::from_secs(10) + ) + .is_some()); + } + + #[test] + fn jiggler_real_input_cancels_return_and_invalidates_dispatched_movement() { + let now = Instant::now(); + let (mut state, _) = configured(now); + state.mouse(&MouseEvent::move_abs(32767, 0), now); + let due = now + Duration::from_secs(10); + let (out, generation) = state.tick(true, (1920, 1080), due).unwrap(); + assert!(out.x < 32767 && out.y > 0); + let input = due + Duration::from_millis(50); + state.mouse(&MouseEvent::move_abs(12000, 24000), input); + assert!(!state.valid(generation)); + assert!(state.tick(true, (1920, 1080), due + STEP_DELAY).is_none()); + let (next, _) = state + .tick(true, (1920, 1080), input + Duration::from_secs(10)) + .unwrap(); + assert!(next.x > 12000 && next.x < 12100 && next.y > 24000 && next.y < 24100); + } + + #[test] + fn jiggler_pauses_for_held_keys_modifiers_buttons_and_consumer_controls() { + let now = Instant::now(); + let (mut state, _) = configured(now); + let down = KeyboardEvent::key_down(CanonicalKey::KeyA, KeyboardModifiers::default()); + state.keyboard(&down, now); + assert!(state + .tick(true, (1920, 1080), now + Duration::from_secs(100)) + .is_none()); + state.keyboard( + &KeyboardEvent::key_up( + CanonicalKey::KeyA, + KeyboardModifiers { + left_ctrl: true, + ..Default::default() + }, + ), + now, + ); + assert!(state + .tick(true, (1920, 1080), now + Duration::from_secs(100)) + .is_none()); + state.reset(now); + state.mouse(&MouseEvent::button_down(MouseButton::Left), now); + assert!(state + .tick(true, (1920, 1080), now + Duration::from_secs(100)) + .is_none()); + state.mouse(&MouseEvent::button_up(MouseButton::Left), now); + state.consumer(0xe9, now); + assert!(state + .tick(true, (1920, 1080), now + Duration::from_secs(100)) + .is_none()); + state.consumer(0, now); + assert!(state + .tick(true, (1920, 1080), now + Duration::from_secs(10)) + .is_some()); + } + + #[test] + fn jiggler_config_changes_disconnects_and_shutdown_cancel_and_restart_the_wait() { + let now = Instant::now(); + let (mut state, mut config) = configured(now); + let due = now + Duration::from_secs(10); + let (_, generation) = state.tick(true, (1920, 1080), due).unwrap(); + config.jiggler.enabled = false; + state.configure(&config, due); + assert!(!state.valid(generation)); + assert!(state.delay().is_none()); + config.jiggler.enabled = true; + config.jiggler.interval_secs = 60; + state.configure(&config, due); + assert!(state + .tick(true, (1920, 1080), due + Duration::from_secs(59)) + .is_none()); + assert!(state + .tick(false, (1920, 1080), due + Duration::from_secs(60)) + .is_none()); + let reconnect = due + Duration::from_secs(100); + assert!(state.tick(true, (1920, 1080), reconnect).is_none()); + assert!(state + .tick(true, (1920, 1080), reconnect + Duration::from_secs(60)) + .is_some()); + state.suspend(); + assert!(!state.valid(state.generation)); + } + + #[test] + fn jiggler_handles_bluetooth_relative_only_absolute_only_and_keyboard_only() { + let now = Instant::now(); + let (mut state, mut config) = configured(now); + config.backend = HidBackend::Bluetooth; + state.configure(&config, now); + assert_eq!( + state + .tick(true, (1920, 1080), now + Duration::from_secs(10)) + .unwrap() + .0 + .event_type, + MouseEventType::Move + ); + config.backend = HidBackend::Otg; + config.otg_profile = OtgHidProfile::Custom; + config.otg_functions.mouse_relative = false; + state.configure(&config, now); + assert_eq!( + state + .tick(true, (1920, 1080), now + Duration::from_secs(10)) + .unwrap() + .0 + .event_type, + MouseEventType::MoveAbs + ); + config.otg_profile = OtgHidProfile::LegacyKeyboard; + state.configure(&config, now); + assert!(state.delay().is_none()); + assert!(state + .tick(true, (1920, 1080), now + Duration::from_secs(100)) + .is_none()); + } + + #[test] + fn jiggler_failed_moves_wait_a_full_interval_before_retrying() { + let now = Instant::now(); + let (mut state, _) = configured(now); + let due = now + Duration::from_secs(10); + let (_, generation) = state.tick(true, (1920, 1080), due).unwrap(); + state.failed(generation, due); + assert!(state.tick(true, (1920, 1080), due + STEP_DELAY).is_none()); + assert!(state + .tick(true, (1920, 1080), due + Duration::from_secs(10)) + .is_some()); + } + + #[test] + fn jiggler_reconnect_does_not_forget_input_received_before_the_first_tick() { + let now = Instant::now(); + let (mut state, _) = configured(now); + state.suspend(); + state.keyboard( + &KeyboardEvent::key_down(CanonicalKey::KeyA, KeyboardModifiers::default()), + now, + ); + assert!(state.tick(true, (1920, 1080), now).is_none()); + assert!(state + .tick(true, (1920, 1080), now + Duration::from_secs(100)) + .is_none()); + state.keyboard( + &KeyboardEvent::key_up(CanonicalKey::KeyA, KeyboardModifiers::default()), + now, + ); + assert!(state + .tick(true, (1920, 1080), now + Duration::from_secs(10)) + .is_some()); + } + + struct RecordingBackend { + mouse: tokio::sync::mpsc::UnboundedSender, + runtime: tokio::sync::watch::Sender<()>, + } + + #[async_trait::async_trait] + impl crate::hid::HidBackend for RecordingBackend { + async fn init(&self) -> crate::error::Result<()> { + Ok(()) + } + async fn send_keyboard(&self, _: KeyboardEvent) -> crate::error::Result<()> { + Ok(()) + } + async fn send_mouse(&self, event: MouseEvent) -> crate::error::Result<()> { + self.mouse.send(event).unwrap(); + Ok(()) + } + async fn reset(&self) -> crate::error::Result<()> { + Ok(()) + } + async fn shutdown(&self) -> crate::error::Result<()> { + Ok(()) + } + fn runtime_snapshot(&self) -> crate::hid::HidBackendRuntimeSnapshot { + crate::hid::HidBackendRuntimeSnapshot { + initialized: true, + online: true, + ..Default::default() + } + } + fn subscribe_runtime(&self) -> tokio::sync::watch::Receiver<()> { + self.runtime.subscribe() + } + } + + #[tokio::test] + async fn jiggler_worker_runs_without_clients_and_confirmed_input_cancels_return() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let backend = std::sync::Arc::new(RecordingBackend { + mouse: tx, + runtime: tokio::sync::watch::channel(()).0, + }); + let controller = crate::hid::test_controller(backend).await; + let (_, config) = configured(Instant::now()); + controller.configure_jiggler(&config); + { + let mut state = controller.jiggler.lock(); + state.ready = true; + state.activity(Instant::now() - Duration::from_secs(9)); + } + // Bluetooth publishes status periodically, even with no user input. + let changed = controller.jiggler_changed.clone(); + let notifications = tokio::spawn(async move { + loop { + changed.notify_one(); + tokio::time::sleep(Duration::from_millis(20)).await; + } + }); + let out = tokio::time::timeout(Duration::from_secs(3), rx.recv()) + .await + .unwrap() + .unwrap(); + notifications.abort(); + assert_eq!(out.x, 2); + controller + .send_keyboard_confirmed(KeyboardEvent::key_down( + CanonicalKey::KeyA, + KeyboardModifiers::default(), + )) + .await + .unwrap(); + assert!(tokio::time::timeout(Duration::from_millis(300), rx.recv()) + .await + .is_err()); + controller.shutdown().await.unwrap(); + } +} diff --git a/src/hid/mod.rs b/src/hid/mod.rs index a29da42a..5da308ed 100644 --- a/src/hid/mod.rs +++ b/src/hid/mod.rs @@ -8,6 +8,7 @@ mod ch9329_proto; pub mod consumer; pub mod datachannel; mod factory; +mod jiggler; pub mod keyboard; mod macos_drag; #[cfg(unix)] @@ -141,6 +142,8 @@ pub struct HidController { backend_available: Arc, reset_requested: Arc, screen_resolution: parking_lot::RwLock<(u32, u32)>, + jiggler: Arc>, + jiggler_changed: Arc, } impl HidController { @@ -164,6 +167,8 @@ impl HidController { backend_available: Arc::new(AtomicBool::new(false)), reset_requested: Arc::new(AtomicBool::new(false)), screen_resolution: parking_lot::RwLock::new((1920, 1080)), + jiggler: Arc::new(parking_lot::Mutex::new(jiggler::JigglerState::default())), + jiggler_changed: Arc::new(tokio::sync::Notify::new()), } } @@ -187,6 +192,8 @@ impl HidController { backend_available: Arc::new(AtomicBool::new(false)), reset_requested: Arc::new(AtomicBool::new(false)), screen_resolution: parking_lot::RwLock::new((1920, 1080)), + jiggler: Arc::new(parking_lot::Mutex::new(jiggler::JigglerState::default())), + jiggler_changed: Arc::new(tokio::sync::Notify::new()), } } @@ -235,6 +242,9 @@ impl HidController { pub async fn shutdown(&self) -> Result<()> { info!("Shutting down HID controller"); + self.backend_available.store(false, Ordering::Release); + self.jiggler.lock().suspend(); + self.jiggler_changed.notify_one(); self.stop_runtime_worker().await; if let Some(backend) = self.backend.write().await.take() { @@ -265,6 +275,7 @@ impl HidController { info!("Preparing OTG HID backend for gadget rebuild"); self.backend_available.store(false, Ordering::Release); + self.jiggler.lock().suspend(); self.stop_runtime_worker().await; if let Some(backend) = self.backend.write().await.take() { @@ -403,6 +414,14 @@ impl HidController { self.runtime_state.read().await.clone() } + /// Jiggler preferences are independent of USB descriptors and device reloads. + pub fn configure_jiggler(&self, config: &crate::config::HidConfig) { + self.jiggler + .lock() + .configure(config, std::time::Instant::now()); + self.jiggler_changed.notify_one(); + } + async fn ch9329_backend(&self) -> Result> { if !matches!( *self.backend_type.read().await, @@ -436,6 +455,7 @@ impl HidController { pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> { info!("Reloading HID backend: {:?}", new_backend_type); self.backend_available.store(false, Ordering::Release); + self.jiggler.lock().suspend(); self.stop_runtime_worker().await; if let Some(backend) = self.backend.write().await.take() { @@ -544,13 +564,53 @@ impl HidController { let pending_move = self.pending_move.clone(); let pending_move_flag = self.pending_move_flag.clone(); let reset_requested = self.reset_requested.clone(); + let jiggler = self.jiggler.clone(); + let jiggler_changed = self.jiggler_changed.clone(); + let backend_available = self.backend_available.clone(); let handle = tokio::spawn(async move { let mut rx = rx; + let mut next_poll = tokio::time::Instant::now(); loop { - let event = match rx.recv().await { - Some(ev) => ev, - None => break, + let delay = jiggler.lock().delay(); + let event = tokio::select! { + biased; + event = rx.recv() => match event { + Some(event) => event, + None => break, + }, + _ = jiggler_changed.notified() => continue, + _ = tokio::time::sleep_until(next_poll), if delay.is_some() => { + // Synthetic motion is sent by this same worker, outside the + // coalescing queue, so opposite movements cannot cancel out. + let active_backend = backend.read().await.clone(); + let snapshot = active_backend.as_ref().map(|backend| backend.runtime_snapshot()); + let ready = backend_available.load(Ordering::Acquire) + && snapshot.as_ref().is_some_and(|state| state.initialized && state.online); + let resolution = snapshot.and_then(|state| state.screen_resolution).unwrap_or((1920, 1080)); + let movement = jiggler.lock().tick(ready, resolution, std::time::Instant::now()); + if let (Some((event, generation)), Some(active_backend)) = (movement, active_backend) { + let state = jiggler.clone(); + let available = backend_available.clone(); + let result = tokio::task::spawn_blocking(move || { + // Check again after dispatch to the blocking pool: + // real input, shutdown or a config change may win. + if !available.load(Ordering::Acquire) || !state.lock().valid(generation) { + return Ok(()); + } + futures::executor::block_on(active_backend.send_mouse(event)) + }).await; + if !matches!(result, Ok(Ok(()))) { + jiggler.lock().failed(generation, std::time::Instant::now()); + warn!("Mouse jiggler movement failed: {:?}", result); + } + } + // Keep an absolute deadline: periodic backend status + // notifications must not restart the timer indefinitely. + next_poll = tokio::time::Instant::now() + + jiggler.lock().delay().unwrap_or(Duration::from_secs(1)); + continue; + } }; if reset_requested.swap(false, Ordering::AcqRel) { @@ -559,15 +619,16 @@ impl HidController { while rx.try_recv().is_ok() {} *pending_move.lock() = None; pending_move_flag.store(false, Ordering::Release); - process_hid_event(QueuedHidEvent::Reset, &backend).await; + process_hid_event(QueuedHidEvent::Reset, &backend, &jiggler).await; continue; } - process_hid_event(event, &backend).await; + process_hid_event(event, &backend, &jiggler).await; if pending_move_flag.swap(false, Ordering::AcqRel) { let move_event = { pending_move.lock().take() }; if let Some(move_event) = move_event { - process_hid_event(QueuedHidEvent::Mouse(move_event), &backend).await; + process_hid_event(QueuedHidEvent::Mouse(move_event), &backend, &jiggler) + .await; } } } @@ -589,6 +650,8 @@ impl HidController { let events = self.events.clone(); let backend_available = self.backend_available.clone(); let backend_type = self.backend_type.clone(); + let jiggler = self.jiggler.clone(); + let jiggler_changed = self.jiggler_changed.clone(); let handle = tokio::spawn(async move { loop { @@ -604,6 +667,11 @@ impl HidController { Some(backend.as_ref()), ) .await; + let state = runtime_state.read().await; + if !state.initialized || !state.online { + jiggler.lock().suspend(); + } + jiggler_changed.notify_one(); } }); @@ -617,6 +685,7 @@ impl HidController { } fn enqueue_mouse_move(&self, event: MouseEvent) -> Result<()> { + self.jiggler.lock().activity(std::time::Instant::now()); match self.hid_tx.try_send(QueuedHidEvent::Mouse(event.clone())) { Ok(_) => Ok(()), Err(mpsc::error::TrySendError::Full(_)) => { @@ -631,6 +700,8 @@ impl HidController { } async fn enqueue_event(&self, event: QueuedHidEvent) -> Result<()> { + self.jiggler.lock().activity(std::time::Instant::now()); + self.jiggler_changed.notify_one(); match self.hid_tx.try_send(event) { Ok(_) => Ok(()), Err(mpsc::error::TrySendError::Full(ev)) => { @@ -690,11 +761,23 @@ async fn apply_backend_runtime_state( async fn process_hid_event( event: QueuedHidEvent, backend: &Arc>>>, + jiggler: &Arc>, ) { let (event, reply) = match event { QueuedHidEvent::Confirmed(event, reply) => (*event, Some(reply)), event => (event, None), }; + { + let mut state = jiggler.lock(); + let now = std::time::Instant::now(); + match &event { + QueuedHidEvent::Keyboard(event) => state.keyboard(event, now), + QueuedHidEvent::Mouse(event) => state.mouse(event, now), + QueuedHidEvent::Consumer(event) => state.consumer(event.usage, now), + QueuedHidEvent::Reset => state.reset(now), + QueuedHidEvent::Confirmed(..) => unreachable!("nested confirmed HID event"), + } + } let backend = backend.read().await.clone(); let result = match backend { Some(backend) => tokio::task::spawn_blocking(move || { diff --git a/src/runtime/builder.rs b/src/runtime/builder.rs index 76b66923..fba2dcd5 100644 --- a/src/runtime/builder.rs +++ b/src/runtime/builder.rs @@ -107,6 +107,7 @@ impl RuntimeBuilder { #[cfg(target_os = "linux")] hid.set_bond_store(config_store.hid_bonds()); hid.set_event_bus(events.clone()).await; + hid.configure_jiggler(&config.hid); hid.set_screen_resolution(video_resolution.width, video_resolution.height) .await; if let Err(error) = hid.init().await { diff --git a/src/runtime/usb.rs b/src/runtime/usb.rs index 7bacb9f9..766cc972 100644 --- a/src/runtime/usb.rs +++ b/src/runtime/usb.rs @@ -52,6 +52,8 @@ impl UsbCoordinator { pub async fn apply_config(&self, old_config: &AppConfig, new_config: &AppConfig) -> Result<()> { new_config.hid.validate_platform()?; + // Also runs for config-only updates and rollback; never forces a USB reload. + self.hid.configure_jiggler(&new_config.hid); #[cfg(unix)] { let transitioning_away_from_otg = old_config.hid.backend == HidBackend::Otg @@ -318,6 +320,32 @@ fn hid_otg_config_changed(old: &HidConfig, new: &HidConfig) -> bool { mod tests { use super::*; + #[test] + fn jiggler_preferences_do_not_reload_hid_or_change_usb_descriptors() { + let old = AppConfig::default(); + let mut new = old.clone(); + new.hid.jiggler.enabled = true; + new.hid.jiggler.interval_secs = 120; + assert!(!hid_requires_reload(&old.hid, &new.hid)); + #[cfg(unix)] + assert_eq!( + crate::otg::service::OtgDesiredState::from_config( + &old.hid, + &old.msd, + &old.otg_network, + &old.uac + ) + .unwrap(), + crate::otg::service::OtgDesiredState::from_config( + &new.hid, + &new.msd, + &new.otg_network, + &new.uac + ) + .unwrap() + ); + } + #[test] fn composite_otg_preferences_do_not_reload_ch9329_but_input_changes_do() { let old = HidConfig { diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index ea151871..68f60f84 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -399,9 +399,28 @@ impl Ch9329DescriptorConfigUpdate { } } +#[typeshare] +#[derive(Debug, Deserialize)] +pub struct MouseJigglerConfigUpdate { + pub enabled: Option, + pub interval_secs: Option, +} + +impl MouseJigglerConfigUpdate { + fn apply_to(&self, config: &mut MouseJigglerConfig) { + if let Some(enabled) = self.enabled { + config.enabled = enabled; + } + if let Some(interval) = self.interval_secs { + config.interval_secs = interval; + } + } +} + #[typeshare] #[derive(Debug, Deserialize)] pub struct HidConfigUpdate { + pub jiggler: Option, /// Request-only; never persisted or replayed during startup. pub bluetooth_reset_pairing: Option, pub bluetooth: Option, @@ -432,6 +451,11 @@ pub struct OtgConfigUpdate { impl HidConfigUpdate { pub fn validate(&self) -> crate::error::Result<()> { + if let Some(update) = &self.jiggler { + let mut config = MouseJigglerConfig::default(); + update.apply_to(&mut config); + config.validate()?; + } if let Some(config) = &self.bluetooth { config.validate()?; } @@ -453,6 +477,9 @@ impl HidConfigUpdate { } pub fn apply_to(&self, config: &mut HidConfig) { + if let Some(update) = &self.jiggler { + update.apply_to(&mut config.jiggler); + } if let Some(bluetooth) = &self.bluetooth { config.bluetooth = bluetooth.clone(); } diff --git a/src/web/handlers/config/usb_update.rs b/src/web/handlers/config/usb_update.rs index c9d3a697..b0014f29 100644 --- a/src/web/handlers/config/usb_update.rs +++ b/src/web/handlers/config/usb_update.rs @@ -12,15 +12,24 @@ pub(super) fn stage_hid_config_update( update.validate()?; let old_descriptor = staged_hid.ch9329_descriptor.clone(); - update.apply_to(staged_hid); + let mut updated_hid = staged_hid.clone(); + update.apply_to(&mut updated_hid); + if update.jiggler.as_ref().and_then(|update| update.enabled) == Some(true) + && !updated_hid.has_mouse() + { + return Err(crate::error::AppError::BadRequest( + "Mouse jiggler requires a configured mouse interface".into(), + )); + } let requested_descriptor = update .ch9329_descriptor .as_ref() - .map(|_| staged_hid.ch9329_descriptor.clone()); + .map(|_| updated_hid.ch9329_descriptor.clone()); if requested_descriptor.is_some() { - staged_hid.ch9329_descriptor = old_descriptor; + updated_hid.ch9329_descriptor = old_descriptor; } + *staged_hid = updated_hid; Ok(requested_descriptor) } @@ -318,6 +327,7 @@ mod tests { fn hid_update() -> HidConfigUpdate { HidConfigUpdate { + jiggler: None, bluetooth_reset_pairing: None, bluetooth: None, backend: None, @@ -335,6 +345,52 @@ mod tests { } } + #[test] + fn jiggler_partial_updates_validate_and_preserve_other_preferences() { + let mut hid = HidConfig::default(); + hid.backend = HidBackend::Ch9329; + for (request, enabled, interval) in [ + (serde_json::json!({"jiggler":{"enabled":true}}), true, 60), + ( + serde_json::json!({"jiggler":{"interval_secs":120}}), + true, + 120, + ), + (serde_json::json!({"jiggler":{"enabled":false}}), false, 120), + (serde_json::json!({"mouse_absolute":false}), false, 120), + ] { + let update: HidConfigUpdate = serde_json::from_value(request).unwrap(); + stage_hid_config_update(&mut hid, &update).unwrap(); + assert_eq!(hid.jiggler.enabled, enabled); + assert_eq!(hid.jiggler.interval_secs, interval); + assert_eq!(hid.backend, HidBackend::Ch9329); + } + for interval in [0, 9, 3601] { + let update: HidConfigUpdate = + serde_json::from_value(serde_json::json!({"jiggler":{"interval_secs":interval}})) + .unwrap(); + assert!(stage_hid_config_update(&mut hid, &update).is_err()); + assert_eq!(hid.jiggler.interval_secs, 120); + } + for interval in [ + serde_json::json!(-1), + serde_json::json!(10.5), + serde_json::json!("60"), + ] { + assert!(serde_json::from_value::( + serde_json::json!({"jiggler":{"interval_secs":interval}}) + ) + .is_err()); + } + hid.backend = HidBackend::None; + let enable: HidConfigUpdate = + serde_json::from_value(serde_json::json!({"jiggler":{"enabled":true}})).unwrap(); + assert!(stage_hid_config_update(&mut hid, &enable).is_err()); + let disable: HidConfigUpdate = + serde_json::from_value(serde_json::json!({"jiggler":{"enabled":false}})).unwrap(); + assert!(stage_hid_config_update(&mut hid, &disable).is_ok()); + } + #[test] fn reset_is_request_only_and_feature_updates_preserve_device_selection() { let mut hid = HidConfig::default(); diff --git a/web/src/components/HidConfigPopover.vue b/web/src/components/HidConfigPopover.vue index e69819c1..90015533 100644 --- a/web/src/components/HidConfigPopover.vue +++ b/web/src/components/HidConfigPopover.vue @@ -8,6 +8,7 @@ import { Label } from '@/components/ui/label' import { Separator } from '@/components/ui/separator' import { Switch } from '@/components/ui/switch' import { Slider } from '@/components/ui/slider' +import { Input } from '@/components/ui/input' import { Popover, PopoverContent, @@ -67,6 +68,53 @@ watch(showCursor, (newValue, oldValue) => { const buttonText = computed(() => t('actionbar.hidConfig')) const hidSaving = ref(false) const hidControlsDisabled = computed(() => hidSaving.value || configStore.hidLoading || !configStore.hid) +const jigglerEnabled = computed(() => configStore.hid?.jiggler?.enabled ?? false) +const jigglerInterval = ref(60) +const jigglerIntervalInvalid = ref(false) +const hasMouseInterface = computed(() => { + const hid = configStore.hid + if (!hid || hid.backend === HidBackend.None) return false + if (hid.backend !== HidBackend.Otg) return true + if (hid.otg_profile === OtgHidProfile.LegacyKeyboard) return false + if (hid.otg_profile === OtgHidProfile.Custom) { + return !!(hid.otg_functions?.mouse_relative || hid.otg_functions?.mouse_absolute) + } + return true +}) + +watch(() => [configStore.hid?.jiggler?.interval_secs, configStore.hid?.jiggler?.enabled] as const, ([interval]) => { + jigglerInterval.value = interval ?? 60 + jigglerIntervalInvalid.value = false +}, { immediate: true }) + +async function saveJiggler(update: { enabled?: boolean; interval_secs?: number }) { + if (hidControlsDisabled.value) return + hidSaving.value = true + try { + await configStore.updateHid({ jiggler: update }) + } catch (error) { + toast.error(t('config.updateFailed'), { + description: error instanceof Error ? error.message : undefined, + }) + await configStore.refreshHid().catch(() => undefined) + jigglerInterval.value = configStore.hid?.jiggler?.interval_secs ?? 60 + } finally { + hidSaving.value = false + } +} + +function toggleJiggler(enabled: boolean) { + if (enabled && !hasMouseInterface.value) return + void saveJiggler({ enabled }) +} + +function saveJigglerInterval() { + if (hidControlsDisabled.value || !jigglerEnabled.value) return + const interval = Number(jigglerInterval.value) + jigglerIntervalInvalid.value = !Number.isInteger(interval) || interval < 10 || interval > 3600 + if (jigglerIntervalInvalid.value || interval === configStore.hid?.jiggler?.interval_secs) return + void saveJiggler({ interval_secs: interval }) +} const supportsLinuxCompatibility = computed(() => usesCh9329(configStore.hid?.backend)) const supportsMacosCompatibility = computed(() => supportsLinuxCompatibility.value || configStore.hid?.backend === HidBackend.Otg @@ -140,6 +188,8 @@ watch(() => props.open, (open) => { mouseThrottle.value = loadMouseMoveSendIntervalFromStorage() showCursor.value = localStorage.getItem('hidShowCursor') !== 'false' if (!hidSaving.value) void configStore.refreshHid().catch(() => undefined) + jigglerInterval.value = configStore.hid?.jiggler?.interval_secs ?? 60 + jigglerIntervalInvalid.value = false }) @@ -160,7 +210,7 @@ watch(() => props.open, (open) => { @@ -231,6 +281,45 @@ watch(() => props.open, (open) => { + +
+
+
+ + +
+ +
+
+ +
+ + {{ t('actionbar.jigglerSeconds') }} +
+
+ +

{{ t('actionbar.jigglerNoMouse') }}

+
+

+ + {{ t('actionbar.applying') }} +

diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 714660be..541d4d93 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -278,6 +278,12 @@ export default { positioningMode: 'Positioning Mode', sendInterval: 'Send Interval', showCursor: 'Show Cursor', + mouseJiggler: 'Mouse jiggler', + mouseJigglerHelp: 'Gently moves the mouse while idle to help prevent the target from sleeping or turning off its display. Pauses during input or while keys or buttons are held. Keeps working after closing this page and remembers settings after restart.', + jigglerInterval: 'Jiggle interval', + jigglerSeconds: 'sec', + jigglerIntervalInvalid: 'Enter a whole number between 10 and 3600 seconds.', + jigglerNoMouse: 'Enable a mouse interface in HID device settings first.', backend: 'Backend', devicePath: 'Device Path', baudrate: 'Baudrate', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index a2d1a5ad..b96c1cd2 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -278,6 +278,12 @@ export default { positioningMode: '定位模式', sendInterval: '发送间隔', showCursor: '显示指针', + mouseJiggler: '鼠标抖动', + mouseJigglerHelp: '空闲时自动小幅移动鼠标,帮助防止目标主机息屏或休眠。操作键鼠或按住按键时暂停。关闭网页后仍生效,重启后保留设置。', + jigglerInterval: '抖动间隔', + jigglerSeconds: '秒', + jigglerIntervalInvalid: '请输入 10–3600 之间的整数秒数。', + jigglerNoMouse: '请先在 HID 设备设置中启用鼠标接口。', backend: '后端类型', devicePath: '设备路径', baudrate: '波特率', diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index 323cb40d..8c58bb95 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -61,7 +61,13 @@ export interface Ch9329DescriptorConfig { serial_number?: string; } +export interface MouseJigglerConfig { + enabled: boolean; + interval_secs: number; +} + export interface HidConfig { + jiggler: MouseJigglerConfig; bluetooth: BluetoothHidConfig; backend: HidBackend; otg_udc?: string; @@ -503,7 +509,13 @@ export interface OtgHidFunctionsUpdate { consumer?: boolean; } +export interface MouseJigglerConfigUpdate { + enabled?: boolean; + interval_secs?: number; +} + export interface HidConfigUpdate { + jiggler?: MouseJigglerConfigUpdate; bluetooth_reset_pairing?: boolean; bluetooth?: BluetoothHidConfig; backend?: HidBackend;