feat(hid): 支持控制台鼠标抖动

在控制台鼠键配置菜单增加鼠标抖动开关和间隔设置,默认关闭,间隔支持 10–3600 秒。
设备端按键鼠空闲时间调度,支持配置持久化、热更新、真实输入打断和断连后重新计时。
兼容绝对及相对鼠标模式,按键或按钮按住期间暂停抖动。

验证:608 项 Rust 单测通过;暂存快照前端构建和浏览器交互测试通过;已完成 ARMv7 OpenWrt 部署验证。

Refs #316
This commit is contained in:
mofeng-git
2026-10-28 07:11:44 +08:00
parent f303a8cb2f
commit fc99c6f274
12 changed files with 886 additions and 14 deletions

View File

@@ -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<String>,
@@ -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(),

View File

@@ -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();

500
src/hid/jiggler.rs Normal file
View File

@@ -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<CanonicalKey>,
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<Duration> {
(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<MouseEvent>,
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();
}
}

View File

@@ -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<AtomicBool>,
reset_requested: Arc<AtomicBool>,
screen_resolution: parking_lot::RwLock<(u32, u32)>,
jiggler: Arc<parking_lot::Mutex<jiggler::JigglerState>>,
jiggler_changed: Arc<tokio::sync::Notify>,
}
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<Arc<dyn HidBackend>> {
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<RwLock<Option<Arc<dyn HidBackend>>>>,
jiggler: &Arc<parking_lot::Mutex<jiggler::JigglerState>>,
) {
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 || {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -399,9 +399,28 @@ impl Ch9329DescriptorConfigUpdate {
}
}
#[typeshare]
#[derive(Debug, Deserialize)]
pub struct MouseJigglerConfigUpdate {
pub enabled: Option<bool>,
pub interval_secs: Option<u32>,
}
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<MouseJigglerConfigUpdate>,
/// Request-only; never persisted or replayed during startup.
pub bluetooth_reset_pairing: Option<bool>,
pub bluetooth: Option<crate::config::BluetoothHidConfig>,
@@ -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();
}

View File

@@ -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::<HidConfigUpdate>(
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();

View File

@@ -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<string | number>(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
})
</script>
@@ -160,7 +210,7 @@ watch(() => props.open, (open) => {
</PopoverTrigger>
<PopoverContent
@open-auto-focus="focusConsolePanel"
class="console-config-panel w-[min(320px,92vw)] p-3"
class="console-config-panel w-[min(320px,92vw)] max-h-(--reka-popover-content-available-height) overflow-y-auto p-3"
align="start"
:side="props.side ?? 'bottom'"
>
@@ -231,6 +281,45 @@ watch(() => props.open, (open) => {
<Label class="text-xs text-muted-foreground">{{ t('actionbar.showCursor') }}</Label>
<Switch v-model="showCursor" />
</div>
<div class="space-y-2" :aria-busy="hidSaving">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-1">
<Label for="hid-mouse-jiggler" class="text-xs text-muted-foreground">{{ t('actionbar.mouseJiggler') }}</Label>
<HelpTooltip :content="t('actionbar.mouseJigglerHelp')" icon-size="sm" />
</div>
<Switch
id="hid-mouse-jiggler"
:model-value="jigglerEnabled"
:disabled="hidControlsDisabled || (!hasMouseInterface && !jigglerEnabled)"
@update:model-value="toggleJiggler"
/>
</div>
<div v-if="jigglerEnabled" class="flex items-center justify-between gap-3">
<Label for="hid-jiggler-interval" class="text-xs text-muted-foreground">{{ t('actionbar.jigglerInterval') }}</Label>
<div class="flex items-center gap-2">
<Input
id="hid-jiggler-interval"
v-model="jigglerInterval"
type="number" min="10" max="3600" step="1" size="sm"
class="w-20 text-xs"
:disabled="hidControlsDisabled"
:aria-invalid="jigglerIntervalInvalid"
:aria-describedby="jigglerIntervalInvalid ? 'hid-jiggler-interval-error' : undefined"
@input="jigglerIntervalInvalid = false"
@blur="saveJigglerInterval"
@keydown.enter.prevent="saveJigglerInterval"
/>
<span class="text-xs text-muted-foreground">{{ t('actionbar.jigglerSeconds') }}</span>
</div>
</div>
<p v-if="jigglerEnabled && jigglerIntervalInvalid" id="hid-jiggler-interval-error" role="alert" class="text-xs text-destructive">{{ t('actionbar.jigglerIntervalInvalid') }}</p>
<p v-if="!hasMouseInterface && configStore.hid" class="text-xs text-muted-foreground">{{ t('actionbar.jigglerNoMouse') }}</p>
</div>
<p v-if="hidSaving" role="status" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="size-3 shrink-0 animate-spin" />
{{ t('actionbar.applying') }}
</p>
</div>
<template v-if="supportsMacosCompatibility">
@@ -265,10 +354,6 @@ watch(() => props.open, (open) => {
</div>
<p v-if="macosDragRequiresInterfaces" class="text-xs text-warning">{{ t('actionbar.macosDragRequiresBoth') }}</p>
<p v-if="hidSaving" role="status" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="size-3 shrink-0 animate-spin" />
{{ t('actionbar.applying') }}
</p>
</div>
</template>
</div>

View File

@@ -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',

View File

@@ -278,6 +278,12 @@ export default {
positioningMode: '定位模式',
sendInterval: '发送间隔',
showCursor: '显示指针',
mouseJiggler: '鼠标抖动',
mouseJigglerHelp: '空闲时自动小幅移动鼠标,帮助防止目标主机息屏或休眠。操作键鼠或按住按键时暂停。关闭网页后仍生效,重启后保留设置。',
jigglerInterval: '抖动间隔',
jigglerSeconds: '秒',
jigglerIntervalInvalid: '请输入 10–3600 之间的整数秒数。',
jigglerNoMouse: '请先在 HID 设备设置中启用鼠标接口。',
backend: '后端类型',
devicePath: '设备路径',
baudrate: '波特率',

View File

@@ -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;