From a065283b59a0c6fb6e73cdb3588c54b347e14513 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 17:40:19 +0800 Subject: [PATCH 01/21] feat(dictation): mouse middle/side buttons with Hold refcount Split from #724: Win/macOS global mouse hooks, prefs toggles, HoldSourceTracker for multi-source hold release. Linux evdev path deferred to PR3. Fixes Open-Less/openless#718 (partial: mouse triggers). Co-authored-by: Cursor --- .../app/src-tauri/src/commands/mod.rs | 5 + .../app/src-tauri/src/commands/settings.rs | 15 ++ openless-all/app/src-tauri/src/coordinator.rs | 59 +++++ .../src-tauri/src/coordinator/dictation.rs | 55 ++-- .../src-tauri/src/coordinator/hotkey_loops.rs | 122 ++++++++- .../app/src-tauri/src/hold_source_tracker.rs | 125 +++++++++ openless-all/app/src-tauri/src/hotkey.rs | 13 +- openless-all/app/src-tauri/src/lib.rs | 7 + .../src/mobile_stubs/mouse_dictation.rs | 38 +++ .../app/src-tauri/src/mouse_dictation.rs | 244 ++++++++++++++++++ openless-all/app/src-tauri/src/types.rs | 16 ++ openless-all/app/src/i18n/en.ts | 8 + openless-all/app/src/i18n/ja.ts | 8 + openless-all/app/src/i18n/ko.ts | 8 + openless-all/app/src/i18n/zh-CN.ts | 8 + openless-all/app/src/i18n/zh-TW.ts | 8 + openless-all/app/src/lib/ipc/mock-data.ts | 5 + openless-all/app/src/lib/stylePrefs.test.ts | 2 + openless-all/app/src/lib/types.ts | 4 + .../pages/settings/RecordingInputSection.tsx | 16 ++ 20 files changed, 743 insertions(+), 23 deletions(-) create mode 100644 openless-all/app/src-tauri/src/hold_source_tracker.rs create mode 100644 openless-all/app/src-tauri/src/mobile_stubs/mouse_dictation.rs create mode 100644 openless-all/app/src-tauri/src/mouse_dictation.rs diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index 2c128ba5..1bfbdfb7 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -267,6 +267,7 @@ mod tests { switch_style_refreshes: Mutex, open_app_refreshes: Mutex, coding_agent_refreshes: Mutex, + mouse_dictation_refreshes: Mutex, } fn snapshot() -> CredentialsSnapshot { @@ -636,6 +637,10 @@ mod tests { fn refresh_coding_agent_hotkey(&self) { *self.coding_agent_refreshes.lock().unwrap() += 1; } + + fn refresh_mouse_dictation(&self) { + *self.mouse_dictation_refreshes.lock().unwrap() += 1; + } } #[test] diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 06e47433..168cd475 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -17,6 +17,7 @@ pub(crate) trait SettingsWriter { fn refresh_dictation_hotkey(&self); fn refresh_qa_hotkey(&self); fn refresh_combo_hotkey(&self); + fn refresh_mouse_dictation(&self); fn refresh_translation_hotkey(&self); fn refresh_switch_style_hotkey(&self); fn refresh_open_app_hotkey(&self); @@ -48,6 +49,10 @@ impl SettingsWriter for Coordinator { self.update_combo_hotkey_binding(); } + fn refresh_mouse_dictation(&self) { + self.update_mouse_dictation_binding(); + } + fn refresh_translation_hotkey(&self) { self.update_translation_hotkey_binding(); } @@ -90,6 +95,10 @@ impl SettingsWriter for Arc { (**self).refresh_combo_hotkey(); } + fn refresh_mouse_dictation(&self) { + (**self).refresh_mouse_dictation(); + } + fn refresh_translation_hotkey(&self) { (**self).refresh_translation_hotkey(); } @@ -133,6 +142,9 @@ pub(crate) fn persist_settings_with_keyboard_apply( let translation_changed = previous.translation_hotkey != prefs.translation_hotkey; let switch_style_changed = previous.switch_style_hotkey != prefs.switch_style_hotkey; let open_app_changed = previous.open_app_hotkey != prefs.open_app_hotkey; + let mouse_dictation_changed = previous.mouse_middle_button_dictation + != prefs.mouse_middle_button_dictation + || previous.mouse_side_button_dictation != prefs.mouse_side_button_dictation; let coding_agent_changed = previous.coding_agent_enabled != prefs.coding_agent_enabled || previous.coding_agent_voice_hotkey != prefs.coding_agent_voice_hotkey; let windows_keyboard_list_changed = previous.windows_sendinput_insertion_only @@ -207,6 +219,9 @@ pub(crate) fn persist_settings_with_keyboard_apply( if dictation_shortcut_changed { coord.refresh_combo_hotkey(); } + if mouse_dictation_changed { + coord.refresh_mouse_dictation(); + } if qa_changed { coord.refresh_qa_hotkey(); } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 2cdebc10..76f1500b 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -283,6 +283,8 @@ struct Inner { /// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。 combo_hotkey: Mutex>, side_aware_combo: Mutex>, + mouse_dictation: Mutex>, + hold_sources: crate::hold_source_tracker::HoldSourceTracker, translation_hotkey: Mutex>, switch_style_hotkey: Mutex>, open_app_hotkey: Mutex>, @@ -405,6 +407,8 @@ impl Coordinator { shortcut_recording_active: AtomicBool::new(false), combo_hotkey: Mutex::new(None), side_aware_combo: Mutex::new(None), + mouse_dictation: Mutex::new(None), + hold_sources: crate::hold_source_tracker::HoldSourceTracker::new(), translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), open_app_hotkey: Mutex::new(None), @@ -498,6 +502,8 @@ impl Coordinator { shortcut_recording_active: AtomicBool::new(false), combo_hotkey: Mutex::new(None), side_aware_combo: Mutex::new(None), + mouse_dictation: Mutex::new(None), + hold_sources: crate::hold_source_tracker::HoldSourceTracker::new(), translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), open_app_hotkey: Mutex::new(None), @@ -769,6 +775,18 @@ impl Coordinator { .ok(); } + pub fn start_mouse_dictation_listener(&self) { + let inner = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-mouse-dictation-supervisor".into()) + .spawn(move || mouse_dictation_supervisor_loop(inner)) + .ok(); + } + + pub fn update_mouse_dictation_binding(&self) { + update_mouse_dictation_binding_now(&self.inner); + } + pub fn stop_combo_hotkey_listener(&self) { take_combo_hotkey_on_main_thread(&self.inner); } @@ -2062,6 +2080,7 @@ mod tests { use super::dictation::abort_recording_with_error; use super::dictation::{handle_pressed_edge, handle_released_edge}; use super::*; + use crate::hold_source_tracker::TriggerSource; use crate::types::{HotkeyMode, HotkeyTrigger}; use once_cell::sync::Lazy; @@ -2162,6 +2181,46 @@ mod tests { std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); } + #[tokio::test] + async fn hold_mode_ends_only_after_last_source_released() { + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + { + let mut prefs = coordinator.inner.prefs.get(); + prefs.hotkey.mode = HotkeyMode::Hold; + coordinator.inner.prefs.set(prefs).unwrap(); + } + + handle_pressed_edge(&coordinator.inner, TriggerSource::KeyboardDictation).await; + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + assert_eq!(coordinator.inner.hold_sources.active_count(), 1); + + handle_pressed_edge(&coordinator.inner, TriggerSource::MouseMiddle).await; + assert_eq!(coordinator.inner.hold_sources.active_count(), 2); + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + + handle_released_edge(&coordinator.inner, TriggerSource::KeyboardDictation).await; + assert_eq!(coordinator.inner.hold_sources.active_count(), 1); + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + + handle_released_edge(&coordinator.inner, TriggerSource::MouseMiddle).await; + assert_eq!(coordinator.inner.hold_sources.active_count(), 0); + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + #[tokio::test] async fn begin_session_dry_run_enters_listening_and_clears_stale_edges() { let _guard = ENV_LOCK.lock().await; diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 922d6866..2d34be3e 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -8,6 +8,7 @@ use crate::types::HotkeyMode; use super::qa::handle_qa_option_edge; use super::resources::*; use super::*; +use crate::hold_source_tracker::TriggerSource; /// 同一个 hotkey 边沿之间的最小间隔。低于此阈值的连按整体作为误触丢弃 —— /// 避免微动开关回弹 / 用户手抖双击造成的空转写报错和 ASR session 抢资源。 @@ -546,10 +547,23 @@ fn default_done_message(status: InsertStatus, polish_failed: bool) -> Option) { - let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst); - if !was_held { - // 防抖:相邻 < HOTKEY_DEBOUNCE 的边沿直接丢弃,记到 log 方便排查。 +pub(super) async fn handle_pressed_edge( + inner: &Arc, + source: TriggerSource, +) { + let mode = inner.prefs.get().hotkey.mode; + if mode == HotkeyMode::Hold { + if !inner.hold_sources.press(source) { + return; + } + if inner.hold_sources.active_count() != 1 { + return; + } + } else if inner.hotkey_trigger_held.swap(true, Ordering::SeqCst) { + return; + } + + // 防抖:相邻 < HOTKEY_DEBOUNCE 的边沿直接丢弃,记到 log 方便排查。 // 与 `hotkey_trigger_held` 互补:held 防 press-without-release,本检查防 // press-release-press 三连过快。每个有效边沿都会更新时间戳。 let now = std::time::Instant::now(); @@ -582,7 +596,6 @@ pub(super) async fn handle_pressed_edge(inner: &Arc) { } else { handle_pressed(inner).await; } - } } /// 「排队接力」放行窗口(ms)。识别中按下热键想录下一条时,那个 Pressed 在处理期间就被缓进 @@ -648,20 +661,30 @@ pub(super) async fn handle_pressed(inner: &Arc) { } } -pub(super) async fn handle_released_edge(inner: &Arc) { - let was_held = inner.hotkey_trigger_held.swap(false, Ordering::SeqCst); - if was_held { - // QA 浮窗可见时,Option 行为是 press-toggle(不分 hold/release),release 边沿忽略。 - // 与 handle_pressed_edge 的路由对称:dictation session 在跑时 Pressed 已经被路由到 - // dictation,那 Released 必须也路由到 dictation —— 否则 Hold 模式松开热键时 - // end_session 不会触发,dictation 永远停不下来。审计 3.3.1。 - let dictation_active = !matches!(inner.state.lock().phase, SessionPhase::Idle); - let panel_visible = inner.qa_state.lock().panel_visible; - if panel_visible && !dictation_active { +pub(super) async fn handle_released_edge( + inner: &Arc, + source: TriggerSource, +) { + let mode = inner.prefs.get().hotkey.mode; + if mode == HotkeyMode::Hold { + let remaining = inner.hold_sources.release(source); + if remaining != 0 { return; } - handle_released(inner).await; + } else if !inner.hotkey_trigger_held.swap(false, Ordering::SeqCst) { + return; + } + + // QA 浮窗可见时,Option 行为是 press-toggle(不分 hold/release),release 边沿忽略。 + // 与 handle_pressed_edge 的路由对称:dictation session 在跑时 Pressed 已经被路由到 + // dictation,那 Released 必须也路由到 dictation —— 否则 Hold 模式松开热键时 + // end_session 不会触发,dictation 永远停不下来。审计 3.3.1。 + let dictation_active = !matches!(inner.state.lock().phase, SessionPhase::Idle); + let panel_visible = inner.qa_state.lock().panel_visible; + if panel_visible && !dictation_active { + return; } + handle_released(inner).await; } pub(super) async fn handle_released(inner: &Arc) { diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index a8208550..7233e687 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -7,6 +7,8 @@ use super::*; +use crate::hold_source_tracker::TriggerSource; + // ─────────────────────────── hotkey bridging ─────────────────────────── pub(super) fn hotkey_supervisor_loop(inner: Arc) { @@ -604,12 +606,20 @@ pub(super) fn combo_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver { async_runtime::block_on(async { - handle_pressed_edge(&inner_cloned).await; + handle_pressed_edge( + &inner_cloned, + TriggerSource::KeyboardDictation, + ) + .await; }); } ComboHotkeyEvent::Released => { async_runtime::block_on(async { - handle_released_edge(&inner_cloned).await; + handle_released_edge( + &inner_cloned, + TriggerSource::KeyboardDictation, + ) + .await; }); } } @@ -1029,12 +1039,20 @@ pub(super) fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver { async_runtime::block_on(async { - handle_pressed_edge(&inner_cloned).await; + handle_pressed_edge( + &inner_cloned, + TriggerSource::KeyboardDictation, + ) + .await; }); } HotkeyEvent::Released => { async_runtime::block_on(async { - handle_released_edge(&inner_cloned).await; + handle_released_edge( + &inner_cloned, + TriggerSource::KeyboardDictation, + ) + .await; }); } HotkeyEvent::Cancelled => { @@ -1060,6 +1078,7 @@ pub(super) fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { inner.hotkey_trigger_held.store(false, Ordering::SeqCst); + inner.hold_sources.reset(); if let Some(monitor) = inner.hotkey.lock().as_ref() { monitor.reset_held_state(); } @@ -1162,11 +1181,11 @@ pub(super) async fn handle_window_hotkey_event( log::info!( "[window-hotkey] pressed trigger={trigger:?} code={code} repeat={repeat}" ); - handle_pressed_edge(inner).await; + handle_pressed_edge(inner, TriggerSource::KeyboardDictation).await; } "keyup" => { log::info!("[window-hotkey] released trigger={trigger:?} code={code}"); - handle_released_edge(inner).await; + handle_released_edge(inner, TriggerSource::KeyboardDictation).await; } _ => {} } @@ -1200,3 +1219,94 @@ pub(super) fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, k HotkeyTrigger::Custom => false, } } + +pub(super) fn mouse_dictation_bridge_loop( + inner: Arc, + rx: mpsc::Receiver<(HotkeyEvent, TriggerSource)>, +) { + while let Ok((evt, source)) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + let inner_cloned = Arc::clone(&inner); + match evt { + HotkeyEvent::Pressed => { + async_runtime::block_on(async { + handle_pressed_edge(&inner_cloned, source).await; + }); + } + HotkeyEvent::Released => { + async_runtime::block_on(async { + handle_released_edge(&inner_cloned, source).await; + }); + } + _ => {} + } + } +} + +pub(super) fn mouse_dictation_supervisor_loop(inner: Arc) { + loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } + let prefs = inner.prefs.get(); + let config = crate::mouse_dictation::MouseDictationConfig { + middle_enabled: prefs.mouse_middle_button_dictation, + side_enabled: prefs.mouse_side_button_dictation, + }; + let needs_mouse = config.middle_enabled || config.side_enabled; + + #[cfg(not(target_os = "linux"))] + if !needs_mouse { + inner.mouse_dictation.lock().take(); + return; + } + + #[cfg(target_os = "linux")] + if !needs_mouse { + inner.mouse_dictation.lock().take(); + return; + } + + if inner.mouse_dictation.lock().is_some() { + crate::mouse_dictation::MouseDictationMonitor::update_config(config); + return; + } + + let (tx, rx) = mpsc::channel::<(HotkeyEvent, TriggerSource)>(); + match start_mouse_dictation_monitor(config, tx.clone()) { + Ok(monitor) => { + *inner.mouse_dictation.lock() = Some(monitor); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-mouse-dictation-bridge".into()) + .spawn(move || mouse_dictation_bridge_loop(inner_clone, rx)) + .ok(); + return; + } + Err(err) => { + log::warn!("[coord] mouse dictation monitor failed: {err}; retry in 3s"); + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +fn start_mouse_dictation_monitor( + config: crate::mouse_dictation::MouseDictationConfig, + tx: mpsc::Sender<(HotkeyEvent, TriggerSource)>, +) -> Result { + #[cfg(target_os = "windows")] + crate::mouse_dictation::platform::ensure_hook_thread()?; + crate::mouse_dictation::MouseDictationMonitor::start(config, tx) +} + +pub(super) fn update_mouse_dictation_binding_now(inner: &Arc) { + inner.mouse_dictation.lock().take(); + let inner_clone = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-mouse-dictation-supervisor".into()) + .spawn(move || mouse_dictation_supervisor_loop(inner_clone)) + .ok(); +} diff --git a/openless-all/app/src-tauri/src/hold_source_tracker.rs b/openless-all/app/src-tauri/src/hold_source_tracker.rs new file mode 100644 index 00000000..8d1f24a8 --- /dev/null +++ b/openless-all/app/src-tauri/src/hold_source_tracker.rs @@ -0,0 +1,125 @@ +//! Tracks active hold-to-talk trigger sources (keyboard / mouse middle / mouse side). +//! +//! Hold mode begins when the first source is pressed and ends when the last source is released. + +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TriggerSource { + KeyboardDictation, + MouseMiddle, + MouseSide, +} + +pub struct HoldSourceTracker { + keyboard: AtomicBool, + mouse_middle: AtomicBool, + mouse_side: AtomicBool, + active_count: AtomicU32, +} + +impl HoldSourceTracker { + pub fn new() -> Self { + Self { + keyboard: AtomicBool::new(false), + mouse_middle: AtomicBool::new(false), + mouse_side: AtomicBool::new(false), + active_count: AtomicU32::new(0), + } + } + + pub fn reset(&self) { + self.keyboard.store(false, Ordering::SeqCst); + self.mouse_middle.store(false, Ordering::SeqCst); + self.mouse_side.store(false, Ordering::SeqCst); + self.active_count.store(0, Ordering::SeqCst); + } + + /// Returns `true` on a fresh press edge for this source. + pub fn press(&self, source: TriggerSource) -> bool { + let slot = self.slot(source); + if slot.swap(true, Ordering::SeqCst) { + return false; + } + self.active_count.fetch_add(1, Ordering::SeqCst); + true + } + + /// Returns the remaining active source count after release. + pub fn release(&self, source: TriggerSource) -> u32 { + let slot = self.slot(source); + if !slot.swap(false, Ordering::SeqCst) { + return self.active_count.load(Ordering::SeqCst); + } + self.active_count.fetch_sub(1, Ordering::SeqCst); + self.active_count.load(Ordering::SeqCst) + } + + pub fn active_count(&self) -> u32 { + self.active_count.load(Ordering::SeqCst) + } + + fn slot(&self, source: TriggerSource) -> &AtomicBool { + match source { + TriggerSource::KeyboardDictation => &self.keyboard, + TriggerSource::MouseMiddle => &self.mouse_middle, + TriggerSource::MouseSide => &self.mouse_side, + } + } +} + +impl Default for HoldSourceTracker { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hold_source_count_tracks_multiple_sources() { + let tracker = HoldSourceTracker::new(); + assert!(tracker.press(TriggerSource::KeyboardDictation)); + assert_eq!(tracker.active_count(), 1); + assert!(!tracker.press(TriggerSource::KeyboardDictation)); + assert_eq!(tracker.active_count(), 1); + + assert!(tracker.press(TriggerSource::MouseMiddle)); + assert_eq!(tracker.active_count(), 2); + + assert_eq!( + tracker.release(TriggerSource::KeyboardDictation), + 1 + ); + assert_eq!(tracker.release(TriggerSource::MouseMiddle), 0); + } + + #[test] + fn last_release_returns_zero() { + let tracker = HoldSourceTracker::new(); + assert!(tracker.press(TriggerSource::KeyboardDictation)); + assert_eq!(tracker.release(TriggerSource::KeyboardDictation), 0); + assert_eq!(tracker.active_count(), 0); + } + + #[test] + fn duplicate_release_is_no_op() { + let tracker = HoldSourceTracker::new(); + assert!(tracker.press(TriggerSource::MouseSide)); + assert_eq!(tracker.release(TriggerSource::MouseSide), 0); + assert_eq!(tracker.release(TriggerSource::MouseSide), 0); + assert_eq!(tracker.active_count(), 0); + } + + #[test] + fn keyboard_and_mouse_hold_tracks_independently() { + let tracker = HoldSourceTracker::new(); + assert!(tracker.press(TriggerSource::KeyboardDictation)); + assert!(tracker.press(TriggerSource::MouseMiddle)); + assert_eq!(tracker.active_count(), 2); + assert_eq!(tracker.release(TriggerSource::KeyboardDictation), 1); + assert_eq!(tracker.release(TriggerSource::MouseMiddle), 0); + } +} diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index b686f19e..f77159bd 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -400,10 +400,13 @@ mod platform { const KEY_DOWN: CgEventType = 10; const KEY_UP: CgEventType = 11; const FLAGS_CHANGED: CgEventType = 12; + const OTHER_MOUSE_DOWN: CgEventType = 25; + const OTHER_MOUSE_UP: CgEventType = 26; const TAP_DISABLED_BY_TIMEOUT: CgEventType = 0xFFFF_FFFE; const TAP_DISABLED_BY_USER_INPUT: CgEventType = 0xFFFF_FFFF; const KEYBOARD_EVENT_KEYCODE: CgEventField = 9; + const MOUSE_EVENT_BUTTON_NUMBER: CgEventField = 3; const FLAG_MASK_SHIFT: CgEventFlags = 0x0002_0000; const FLAG_MASK_CONTROL: CgEventFlags = 0x0004_0000; @@ -467,7 +470,9 @@ mod platform { ) { let mask: CgEventMask = (1u64 << FLAGS_CHANGED) | (1u64 << KEY_DOWN) - | (1u64 << KEY_UP); + | (1u64 << KEY_UP) + | (1u64 << OTHER_MOUSE_DOWN) + | (1u64 << OTHER_MOUSE_UP); let handles = Arc::new(MacShutdownHandles { tap: std::sync::Mutex::new(None), runloop: std::sync::Mutex::new(None), @@ -543,6 +548,12 @@ mod platform { let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; crate::side_aware_combo::platform::dispatch_keycode(keycode, false, 0, false); } + OTHER_MOUSE_DOWN | OTHER_MOUSE_UP => { + let button = + unsafe { CGEventGetIntegerValueField(event, MOUSE_EVENT_BUTTON_NUMBER) }; + let pressed = event_type == OTHER_MOUSE_DOWN; + crate::mouse_dictation::platform::dispatch_button_number(button, pressed); + } _ => {} } event diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 369ee0c6..38d01e8b 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -67,6 +67,12 @@ mod selection; mod selection; #[cfg(not(mobile))] mod shortcut_binding; +mod hold_source_tracker; +#[cfg(not(mobile))] +mod mouse_dictation; +#[cfg(mobile)] +#[path = "mobile_stubs/mouse_dictation.rs"] +mod mouse_dictation; #[cfg(not(mobile))] mod side_aware_combo; #[cfg(mobile)] @@ -707,6 +713,7 @@ fn run_desktop() { coordinator.start_translation_hotkey_listener(); coordinator.start_switch_style_hotkey_listener(); coordinator.start_open_app_hotkey_listener(); + coordinator.start_mouse_dictation_listener(); } #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => show_main_window(app), diff --git a/openless-all/app/src-tauri/src/mobile_stubs/mouse_dictation.rs b/openless-all/app/src-tauri/src/mobile_stubs/mouse_dictation.rs new file mode 100644 index 00000000..eebe739d --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/mouse_dictation.rs @@ -0,0 +1,38 @@ +//! Mobile stub — global mouse dictation triggers are unavailable on Android/iOS. + +use std::sync::mpsc::Sender; + +use crate::hold_source_tracker::TriggerSource; +use crate::hotkey::HotkeyEvent; + +pub struct MouseDictationConfig { + pub middle_enabled: bool, + pub side_enabled: bool, +} + +pub struct MouseDictationMonitor; + +impl MouseDictationMonitor { + pub fn start( + _config: MouseDictationConfig, + _tx: Sender<(HotkeyEvent, TriggerSource)>, + ) -> Result { + Err("mouse dictation is not available on mobile".into()) + } + + pub fn update_config(_config: MouseDictationConfig) {} +} + +#[cfg(target_os = "windows")] +pub mod platform { + pub fn ensure_hook_thread() -> Result<(), String> { + Err("mouse dictation is not available on mobile".into()) + } + + pub fn dispatch_button_number(_button: i64, _pressed: bool) {} +} + +#[cfg(target_os = "macos")] +pub mod platform { + pub fn dispatch_button_number(_button: i64, _pressed: bool) {} +} diff --git a/openless-all/app/src-tauri/src/mouse_dictation.rs b/openless-all/app/src-tauri/src/mouse_dictation.rs new file mode 100644 index 00000000..9899221f --- /dev/null +++ b/openless-all/app/src-tauri/src/mouse_dictation.rs @@ -0,0 +1,244 @@ +//! Global mouse-button dictation triggers (middle / side buttons). + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::{OnceLock, RwLock}; + +use crate::hold_source_tracker::TriggerSource; +use crate::hotkey::HotkeyEvent; + +static ACTIVE_MOUSE: OnceLock>> = OnceLock::new(); + +struct MouseMonitorState { + middle_enabled: bool, + side_enabled: bool, + tx: Sender<(HotkeyEvent, TriggerSource)>, + middle_held: AtomicBool, + side_held: AtomicBool, +} + +pub struct MouseDictationConfig { + pub middle_enabled: bool, + pub side_enabled: bool, +} + +pub struct MouseDictationMonitor; + +impl MouseDictationMonitor { + pub fn start( + config: MouseDictationConfig, + tx: Sender<(HotkeyEvent, TriggerSource)>, + ) -> Result { + if !config.middle_enabled && !config.side_enabled { + return Err("mouse dictation disabled".into()); + } + let slot = ACTIVE_MOUSE.get_or_init(|| RwLock::new(None)); + *slot.write().map_err(|e| e.to_string())? = Some(MouseMonitorState { + middle_enabled: config.middle_enabled, + side_enabled: config.side_enabled, + tx, + middle_held: AtomicBool::new(false), + side_held: AtomicBool::new(false), + }); + Ok(Self) + } + + pub fn update_config(config: MouseDictationConfig) { + if let Some(slot) = ACTIVE_MOUSE.get() { + if let Ok(mut guard) = slot.write() { + if let Some(state) = guard.as_mut() { + state.middle_enabled = config.middle_enabled; + state.side_enabled = config.side_enabled; + if !config.middle_enabled { + state.middle_held.store(false, Ordering::SeqCst); + } + if !config.side_enabled { + state.side_held.store(false, Ordering::SeqCst); + } + } + } + } + } +} + +impl Drop for MouseDictationMonitor { + fn drop(&mut self) { + if let Some(slot) = ACTIVE_MOUSE.get() { + if let Ok(mut guard) = slot.write() { + *guard = None; + } + } + } +} + +fn with_active(f: F) -> Option +where + F: FnOnce(&MouseMonitorState) -> R, +{ + let slot = ACTIVE_MOUSE.get()?; + let guard = slot.read().ok()?; + guard.as_ref().map(f) +} + +fn send_edge(state: &MouseMonitorState, evt: HotkeyEvent, source: TriggerSource) { + if let Err(err) = state.tx.send((evt, source)) { + log::warn!("[mouse-dictation] event send failed: {err}"); + } +} + +pub fn handle_button(button: MouseButton, pressed: bool) { + with_active(|state| { + let (enabled, held, source) = match button { + MouseButton::Middle => ( + state.middle_enabled, + &state.middle_held, + TriggerSource::MouseMiddle, + ), + MouseButton::Side => ( + state.side_enabled, + &state.side_held, + TriggerSource::MouseSide, + ), + }; + if !enabled { + return; + } + if pressed { + if !held.swap(true, Ordering::SeqCst) { + send_edge(state, HotkeyEvent::Pressed, source); + } + } else if held.swap(false, Ordering::SeqCst) { + send_edge(state, HotkeyEvent::Released, source); + } + }); +} + +#[derive(Debug, Clone, Copy)] +pub enum MouseButton { + Middle, + Side, +} + +#[cfg(target_os = "windows")] +pub mod platform { + use super::*; + use std::sync::Mutex; + use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM}; + use windows::Win32::UI::WindowsAndMessaging::{ + CallNextHookEx, HC_ACTION, SetWindowsHookExW, UnhookWindowsHookEx, HHOOK, WH_MOUSE_LL, + WM_MBUTTONDOWN, WM_MBUTTONUP, WM_XBUTTONDOWN, WM_XBUTTONUP, XBUTTON1, XBUTTON2, + }; + + static MOUSE_HOOK: OnceLock>> = OnceLock::new(); + static HOOK_THREAD_STARTED: OnceLock<()> = OnceLock::new(); + + pub fn ensure_hook_thread() -> Result<(), String> { + if HOOK_THREAD_STARTED.get().is_some() { + return Ok(()); + } + std::thread::Builder::new() + .name("openless-mouse-hook".into()) + .spawn(|| { + if let Err(err) = install_hook() { + log::error!("[mouse-dictation] hook install failed: {err}"); + return; + } + let mut msg = windows::Win32::UI::WindowsAndMessaging::MSG::default(); + unsafe { + while windows::Win32::UI::WindowsAndMessaging::GetMessageW( + &mut msg, + None, + 0, + 0, + ) + .0 + > 0 + { + let _ = windows::Win32::UI::WindowsAndMessaging::TranslateMessage(&msg); + let _ = windows::Win32::UI::WindowsAndMessaging::DispatchMessageW(&msg); + } + } + uninstall_hook(); + }) + .map_err(|e| format!("spawn mouse hook thread: {e}"))?; + let _ = HOOK_THREAD_STARTED.set(()); + Ok(()) + } + + pub fn install_hook() -> Result<(), String> { + let slot = MOUSE_HOOK.get_or_init(|| Mutex::new(None)); + let mut guard = slot.lock().map_err(|e| e.to_string())?; + if guard.is_some() { + return Ok(()); + } + unsafe { + let hook = SetWindowsHookExW(WH_MOUSE_LL, Some(low_level_mouse_proc), None, 0) + .map_err(|e| format!("mouse hook install failed: {e}"))?; + *guard = Some(hook.0 as isize); + } + Ok(()) + } + + pub fn uninstall_hook() { + if let Some(slot) = MOUSE_HOOK.get() { + if let Ok(mut guard) = slot.lock() { + if let Some(hook) = guard.take() { + unsafe { + let _ = UnhookWindowsHookEx(HHOOK(hook as *mut core::ffi::c_void)); + } + } + } + } + } + + unsafe extern "system" fn low_level_mouse_proc( + code: i32, + wparam: WPARAM, + lparam: LPARAM, + ) -> LRESULT { + if code == HC_ACTION as i32 && lparam.0 != 0 { + let msg = wparam.0 as u32; + let mouse = std::ptr::read(lparam.0 as *const MSLLHOOKSTRUCT); + match msg { + WM_MBUTTONDOWN => handle_button(MouseButton::Middle, true), + WM_MBUTTONUP => handle_button(MouseButton::Middle, false), + WM_XBUTTONDOWN | WM_XBUTTONUP => { + let hi = ((mouse.mouseData >> 16) & 0xFFFF) as u16; + if hi == XBUTTON1 as u16 || hi == XBUTTON2 as u16 { + let pressed = msg == WM_XBUTTONDOWN; + handle_button(MouseButton::Side, pressed); + } + } + _ => {} + } + } + CallNextHookEx(None, code, wparam, lparam) + } + + #[repr(C)] + #[derive(Copy, Clone)] + struct MSLLHOOKSTRUCT { + pt: windows::Win32::Foundation::POINT, + mouseData: u32, + flags: u32, + time: u32, + extraInfo: usize, + } +} + +#[cfg(target_os = "macos")] +pub mod platform { + use super::*; + + pub fn dispatch_button_number(button_number: i64, pressed: bool) { + // macOS CGEvent buttonNumber: 0=left, 1=right, 2=middle, 3/4=side buttons + let button = match button_number { + 2 => Some(MouseButton::Middle), + 3 | 4 => Some(MouseButton::Side), + _ => None, + }; + if let Some(button) = button { + handle_button(button, pressed); + } + } +} diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 0de6b479..89991da1 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -865,6 +865,12 @@ pub struct UserPreferences { /// Android: floating overlay control diameter in dp. #[serde(default = "default_android_overlay_size_dp")] pub android_overlay_size_dp: u32, + /// 桌面端:按下鼠标中键(滚轮)触发听写。与键盘快捷键独立,默认关闭。 + #[serde(default)] + pub mouse_middle_button_dictation: bool, + /// 桌面端:按下鼠标侧键(前进/后退)触发听写。与键盘快捷键独立,默认关闭。 + #[serde(default)] + pub mouse_side_button_dictation: bool, } fn default_local_asr_model() -> String { @@ -1062,6 +1068,10 @@ struct UserPreferencesWire { android_overlay_cancel_swipe_direction: AndroidOverlayCancelSwipeDirection, #[serde(default = "default_android_overlay_size_dp")] android_overlay_size_dp: u32, + #[serde(default)] + mouse_middle_button_dictation: bool, + #[serde(default)] + mouse_side_button_dictation: bool, } impl Default for UserPreferencesWire { @@ -1145,6 +1155,8 @@ impl Default for UserPreferencesWire { android_overlay_left_swipe_action: prefs.android_overlay_left_swipe_action, android_overlay_cancel_swipe_direction: prefs.android_overlay_cancel_swipe_direction, android_overlay_size_dp: prefs.android_overlay_size_dp, + mouse_middle_button_dictation: prefs.mouse_middle_button_dictation, + mouse_side_button_dictation: prefs.mouse_side_button_dictation, } } } @@ -1267,6 +1279,8 @@ impl<'de> Deserialize<'de> for UserPreferences { android_overlay_size_dp: normalize_android_overlay_size_dp( wire.android_overlay_size_dp, ), + mouse_middle_button_dictation: wire.mouse_middle_button_dictation, + mouse_side_button_dictation: wire.mouse_side_button_dictation, }) } } @@ -2004,6 +2018,8 @@ impl Default for UserPreferences { android_overlay_cancel_swipe_direction: default_android_overlay_cancel_swipe_direction( ), android_overlay_size_dp: default_android_overlay_size_dp(), + mouse_middle_button_dictation: false, + mouse_side_button_dictation: false, } } } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 066a43c2..f120a560 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -685,6 +685,11 @@ export const en: typeof zhCN = { comboRecorded: 'Recorded', comboClear: 'Clear', comboConflict: 'This shortcut combination is not available', + modifierPresetsLabel: 'Single-key presets:', + mouseMiddleLabel: 'Middle mouse button', + mouseMiddleDesc: 'Press the scroll wheel (middle click) to start/stop dictation. Independent of keyboard shortcuts.', + mouseSideLabel: 'Side mouse buttons', + mouseSideDesc: 'Press mouse side buttons (forward/back) to start/stop dictation. Independent of keyboard shortcuts.', allowNonTsfFallbackLabel: 'Allow non-TSF fallback', allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, use paced Unicode SendInput; if that still fails, copy the text to the clipboard.', windowsInsertionModeLabel: 'Windows insertion method', @@ -1132,6 +1137,9 @@ export const en: typeof zhCN = { rightControl: 'Right Control', leftControl: 'Left Control', rightCommand: 'Right Command', + leftCommand: 'Left Command', + leftShift: 'Left Shift', + rightShift: 'Right Shift', fn: 'Fn (Globe key)', rightAlt: 'Right Alt', mediaPlayPause: '⏯ Media Play/Pause', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 7ca687f5..a42dacdc 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -687,6 +687,11 @@ export const ja: typeof zhCN = { comboRecorded: '記録済み', comboClear: 'クリア', comboConflict: 'このショートカットの組み合わせは使用できません', + modifierPresetsLabel: 'よく使う単キー:', + mouseMiddleLabel: 'マウス中ボタンで音声入力', + mouseMiddleDesc: 'ホイールクリック(中ボタン)で音声入力の開始/停止。キーボードショートカットとは独立。', + mouseSideLabel: 'マウスサイドボタンで音声入力', + mouseSideDesc: 'サイドボタン(進む/戻る)で音声入力の開始/停止。キーボードショートカットとは独立。', allowNonTsfFallbackLabel: '非 TSF フォールバックを許可', allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。', windowsInsertionModeLabel: 'Windows 挿入方式', @@ -1100,6 +1105,9 @@ export const ja: typeof zhCN = { rightControl: '右 Control', leftControl: '左 Control', rightCommand: '右 Command', + leftCommand: '左 Command', + leftShift: '左 Shift', + rightShift: '右 Shift', fn: 'Fn (地球キー)', rightAlt: '右 Alt', mediaPlayPause: '⏯ メディア再生/一時停止', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 17578a1c..af498c93 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -687,6 +687,11 @@ export const ko: typeof zhCN = { comboRecorded: '녹화됨', comboClear: '지우기', comboConflict: '이 단축키 조합은 사용할 수 없습니다', + modifierPresetsLabel: '자주 쓰는 단일 키:', + mouseMiddleLabel: '마우스 휠 버튼으로 음성 입력', + mouseMiddleDesc: '휠 클릭(가운데 버튼)으로 음성 입력 시작/중지. 키보드 단축키와 독립적입니다.', + mouseSideLabel: '마우스 측면 버튼으로 음성 입력', + mouseSideDesc: '측면 버튼(앞으로/뒤로)으로 음성 입력 시작/중지. 키보드 단축키와 독립적입니다.', allowNonTsfFallbackLabel: '비 TSF 폴백 허용', allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.', windowsInsertionModeLabel: 'Windows 삽입 방식', @@ -1100,6 +1105,9 @@ export const ko: typeof zhCN = { rightControl: '오른쪽 Control', leftControl: '왼쪽 Control', rightCommand: '오른쪽 Command', + leftCommand: '왼쪽 Command', + leftShift: '왼쪽 Shift', + rightShift: '오른쪽 Shift', fn: 'Fn (지구본 키)', rightAlt: '오른쪽 Alt', mediaPlayPause: '⏯ 미디어 재생/일시정지', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index e27c5ed6..fd6f2e4c 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -683,6 +683,11 @@ export const zhCN = { comboRecorded: '已录制', comboClear: '清除', comboConflict: '该快捷键组合不可用', + modifierPresetsLabel: '常用单键:', + mouseMiddleLabel: '鼠标中键唤起识别', + mouseMiddleDesc: '按下鼠标滚轮(中键)开始/停止语音识别,与键盘快捷键独立。', + mouseSideLabel: '鼠标侧键唤起识别', + mouseSideDesc: '按下鼠标侧键(前进/后退)开始/停止语音识别,与键盘快捷键独立。', allowNonTsfFallbackLabel: '允许非 TSF 兜底', allowNonTsfFallbackDesc: 'Windows:TSF 失败时使用分批 Unicode SendInput;如果仍失败,再复制到剪贴板。', windowsInsertionModeLabel: 'Windows 插入方式', @@ -1130,6 +1135,9 @@ export const zhCN = { rightControl: '右 Control', leftControl: '左 Control', rightCommand: '右 Command', + leftCommand: '左 Command', + leftShift: '左 Shift', + rightShift: '右 Shift', fn: 'Fn (地球键)', rightAlt: '右 Alt', mediaPlayPause: '⏯ 媒体播放/暂停', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 0f1a28f2..3578268f 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -660,6 +660,11 @@ export const zhTW: typeof zhCN = { comboRecorded: '已錄製', comboClear: '清除', comboConflict: '此快捷鍵組合不可用', + modifierPresetsLabel: '常用單鍵:', + mouseMiddleLabel: '滑鼠中鍵喚起識別', + mouseMiddleDesc: '按下滑鼠滾輪(中鍵)開始/停止語音識別,與鍵盤快捷鍵獨立。', + mouseSideLabel: '滑鼠側鍵喚起識別', + mouseSideDesc: '按下滑鼠側鍵(前進/後退)開始/停止語音識別,與鍵盤快捷鍵獨立。', microphoneLabel: '首選麥克風', microphoneDesc: '選擇優先使用的輸入設備。設備暫時不可用時會使用系統默認麥克風,重新連接後自動切回首選設備。', microphoneDefault: '系統默認麥克風', @@ -1098,6 +1103,9 @@ export const zhTW: typeof zhCN = { rightControl: '右 Control', leftControl: '左 Control', rightCommand: '右 Command', + leftCommand: '左 Command', + leftShift: '左 Shift', + rightShift: '右 Shift', fn: 'Fn (地球鍵)', rightAlt: '右 Alt', mediaPlayPause: '⏯ 媒體播放/暫停', diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index c7f5adc8..306d98d2 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -109,6 +109,8 @@ export let mockSettings: UserPreferences = { androidOverlayLeftSwipeAction: "translation", androidOverlayCancelSwipeDirection: "up", androidOverlaySizeDp: 72, + mouseMiddleButtonDictation: false, + mouseSideButtonDictation: false, } const mockFullStylePrompts: StyleSystemPrompts = { @@ -398,6 +400,9 @@ export const mockHotkeyCapability: HotkeyCapability = { "rightAlt", "leftControl", "rightCommand", + "leftCommand", + "leftShift", + "rightShift", "custom", ], requiresAccessibilityPermission: false, diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 54414511..dcacb211 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -96,6 +96,8 @@ const previousPrefs: UserPreferences = { androidOverlayLeftSwipeAction: 'translation', androidOverlayCancelSwipeDirection: 'up', androidOverlaySizeDp: 72, + mouseMiddleButtonDictation: false, + mouseSideButtonDictation: false, }; const nextPrefs: UserPreferences = { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 95399330..04d73326 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -403,6 +403,10 @@ export interface UserPreferences { androidOverlayCancelSwipeDirection: AndroidOverlayCancelSwipeDirection; /** Android: floating overlay control diameter in dp. */ androidOverlaySizeDp: number; + /** 桌面端:鼠标中键(滚轮按下)触发听写。默认 false。 */ + mouseMiddleButtonDictation: boolean; + /** 桌面端:鼠标侧键(前进/后退)触发听写。默认 false。 */ + mouseSideButtonDictation: boolean; } export interface MarketplaceListItem { diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index e6281eb1..7a67f827 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -220,6 +220,22 @@ export function RecordingInputSection() { )} {showDesktopHotkey && ( + + void savePrefs({ ...prefs, mouseMiddleButtonDictation: next })} + /> + + )} + {showDesktopHotkey && ( + + void savePrefs({ ...prefs, mouseSideButtonDictation: next })} + /> + + )} + {showDesktopHotkey && (
{choices.map(([v, l]) => ( From 0f593985faf1c69b6b4fb99ea72d6198e5a773b8 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 17:47:18 +0800 Subject: [PATCH 02/21] fix(dictation): keep PR2 types mouse-only without side trigger variants Co-authored-by: Cursor --- openless-all/app/src-tauri/src/types.rs | 27 +------------------------ 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 89991da1..e15eb76a 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -1155,8 +1155,6 @@ impl Default for UserPreferencesWire { android_overlay_left_swipe_action: prefs.android_overlay_left_swipe_action, android_overlay_cancel_swipe_direction: prefs.android_overlay_cancel_swipe_direction, android_overlay_size_dp: prefs.android_overlay_size_dp, - mouse_middle_button_dictation: prefs.mouse_middle_button_dictation, - mouse_side_button_dictation: prefs.mouse_side_button_dictation, } } } @@ -1279,8 +1277,6 @@ impl<'de> Deserialize<'de> for UserPreferences { android_overlay_size_dp: normalize_android_overlay_size_dp( wire.android_overlay_size_dp, ), - mouse_middle_button_dictation: wire.mouse_middle_button_dictation, - mouse_side_button_dictation: wire.mouse_side_button_dictation, }) } } @@ -2018,8 +2014,6 @@ impl Default for UserPreferences { android_overlay_cancel_swipe_direction: default_android_overlay_cancel_swipe_direction( ), android_overlay_size_dp: default_android_overlay_size_dp(), - mouse_middle_button_dictation: false, - mouse_side_button_dictation: false, } } } @@ -2192,9 +2186,6 @@ pub enum HotkeyTrigger { RightControl, LeftControl, RightCommand, - LeftCommand, - LeftShift, - RightShift, Fn, RightAlt, // Windows synonym for RightOption MediaPlayPause, @@ -2209,9 +2200,6 @@ impl HotkeyTrigger { HotkeyTrigger::RightControl => "右 Control", HotkeyTrigger::LeftControl => "左 Control", HotkeyTrigger::RightCommand => "右 Command", - HotkeyTrigger::LeftCommand => "左 Command", - HotkeyTrigger::LeftShift => "左 Shift", - HotkeyTrigger::RightShift => "右 Shift", HotkeyTrigger::Fn => "Fn (地球键)", HotkeyTrigger::RightAlt => "右 Alt", HotkeyTrigger::MediaPlayPause => "⏯ Media 播放/暂停", @@ -2305,9 +2293,6 @@ fn legacy_trigger_code(trigger: HotkeyTrigger) -> &'static str { HotkeyTrigger::RightControl => "ControlRight", HotkeyTrigger::LeftControl => "ControlLeft", HotkeyTrigger::RightCommand => "MetaRight", - HotkeyTrigger::LeftCommand => "MetaLeft", - HotkeyTrigger::LeftShift => "ShiftLeft", - HotkeyTrigger::RightShift => "ShiftRight", #[cfg(target_os = "windows")] HotkeyTrigger::Fn => "ControlRight", #[cfg(not(target_os = "windows"))] @@ -2428,9 +2413,6 @@ impl HotkeyCapability { HotkeyTrigger::RightControl, HotkeyTrigger::LeftControl, HotkeyTrigger::RightCommand, - HotkeyTrigger::LeftCommand, - HotkeyTrigger::LeftShift, - HotkeyTrigger::RightShift, HotkeyTrigger::Fn, HotkeyTrigger::Custom, ], @@ -2451,9 +2433,6 @@ impl HotkeyCapability { HotkeyTrigger::RightAlt, HotkeyTrigger::LeftControl, HotkeyTrigger::RightCommand, - HotkeyTrigger::LeftCommand, - HotkeyTrigger::LeftShift, - HotkeyTrigger::RightShift, HotkeyTrigger::MediaPlayPause, HotkeyTrigger::Custom, ], @@ -2476,9 +2455,6 @@ impl HotkeyCapability { HotkeyTrigger::RightAlt, HotkeyTrigger::RightControl, HotkeyTrigger::LeftControl, - HotkeyTrigger::LeftCommand, - HotkeyTrigger::LeftShift, - HotkeyTrigger::RightShift, HotkeyTrigger::Custom, ], requires_accessibility_permission: false, @@ -2486,8 +2462,7 @@ impl HotkeyCapability { supports_side_specific_modifiers: true, explicit_fallback_available: false, status_hint: Some( - "Linux 使用 fcitx5 插件监听热键和提交文字。鼠标/侧别组合键需 evdev 读取 /dev/input/event*;若无权限请将用户加入 input 组(sudo usermod -aG input $USER)后重新登录。" - .into(), + "Linux 使用 fcitx5 插件监听热键和提交文字;无需桌面环境额外配置。".into(), ), } } From cd2ac4be2941ed668cb6f9eac3b94362e2615848 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 18:09:53 +0800 Subject: [PATCH 03/21] fix(dictation): tighten PR2 scope for mouse-only split - Fix hold-mode tests to pass TriggerSource::KeyboardDictation - Hide mouse toggles on Linux until PR3 evdev - Remove PR1 side-modifier type/i18n/mock remnants - Complete mouse pref wire/deserialize/default in types.rs Co-authored-by: Cursor --- openless-all/app/src-tauri/src/coordinator.rs | 4 ++-- openless-all/app/src-tauri/src/types.rs | 6 ++++++ openless-all/app/src/i18n/en.ts | 4 ---- openless-all/app/src/i18n/ja.ts | 4 ---- openless-all/app/src/i18n/ko.ts | 4 ---- openless-all/app/src/i18n/zh-CN.ts | 4 ---- openless-all/app/src/i18n/zh-TW.ts | 4 ---- openless-all/app/src/lib/ipc/mock-data.ts | 3 --- openless-all/app/src/lib/types.ts | 3 --- .../app/src/pages/settings/RecordingInputSection.tsx | 5 +++-- 10 files changed, 11 insertions(+), 30 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 76f1500b..b0c5a720 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2683,7 +2683,7 @@ mod tests { state.session_id = session_id(41); } - handle_pressed_edge(&coordinator.inner).await; + handle_pressed_edge(&coordinator.inner, TriggerSource::KeyboardDictation).await; let state = coordinator.inner.state.lock(); assert_eq!(state.phase, SessionPhase::Inserting); @@ -2711,7 +2711,7 @@ mod tests { .hotkey_trigger_held .store(true, Ordering::SeqCst); - handle_pressed_edge(&coordinator.inner).await; + handle_pressed_edge(&coordinator.inner, TriggerSource::KeyboardDictation).await; assert_eq!( coordinator.inner.state.lock().phase, diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index e15eb76a..d8ddfb09 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -1155,6 +1155,8 @@ impl Default for UserPreferencesWire { android_overlay_left_swipe_action: prefs.android_overlay_left_swipe_action, android_overlay_cancel_swipe_direction: prefs.android_overlay_cancel_swipe_direction, android_overlay_size_dp: prefs.android_overlay_size_dp, + mouse_middle_button_dictation: prefs.mouse_middle_button_dictation, + mouse_side_button_dictation: prefs.mouse_side_button_dictation, } } } @@ -1277,6 +1279,8 @@ impl<'de> Deserialize<'de> for UserPreferences { android_overlay_size_dp: normalize_android_overlay_size_dp( wire.android_overlay_size_dp, ), + mouse_middle_button_dictation: wire.mouse_middle_button_dictation, + mouse_side_button_dictation: wire.mouse_side_button_dictation, }) } } @@ -2014,6 +2018,8 @@ impl Default for UserPreferences { android_overlay_cancel_swipe_direction: default_android_overlay_cancel_swipe_direction( ), android_overlay_size_dp: default_android_overlay_size_dp(), + mouse_middle_button_dictation: false, + mouse_side_button_dictation: false, } } } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index f120a560..c27eee27 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -685,7 +685,6 @@ export const en: typeof zhCN = { comboRecorded: 'Recorded', comboClear: 'Clear', comboConflict: 'This shortcut combination is not available', - modifierPresetsLabel: 'Single-key presets:', mouseMiddleLabel: 'Middle mouse button', mouseMiddleDesc: 'Press the scroll wheel (middle click) to start/stop dictation. Independent of keyboard shortcuts.', mouseSideLabel: 'Side mouse buttons', @@ -1137,9 +1136,6 @@ export const en: typeof zhCN = { rightControl: 'Right Control', leftControl: 'Left Control', rightCommand: 'Right Command', - leftCommand: 'Left Command', - leftShift: 'Left Shift', - rightShift: 'Right Shift', fn: 'Fn (Globe key)', rightAlt: 'Right Alt', mediaPlayPause: '⏯ Media Play/Pause', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index a42dacdc..08a6018b 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -687,7 +687,6 @@ export const ja: typeof zhCN = { comboRecorded: '記録済み', comboClear: 'クリア', comboConflict: 'このショートカットの組み合わせは使用できません', - modifierPresetsLabel: 'よく使う単キー:', mouseMiddleLabel: 'マウス中ボタンで音声入力', mouseMiddleDesc: 'ホイールクリック(中ボタン)で音声入力の開始/停止。キーボードショートカットとは独立。', mouseSideLabel: 'マウスサイドボタンで音声入力', @@ -1105,9 +1104,6 @@ export const ja: typeof zhCN = { rightControl: '右 Control', leftControl: '左 Control', rightCommand: '右 Command', - leftCommand: '左 Command', - leftShift: '左 Shift', - rightShift: '右 Shift', fn: 'Fn (地球キー)', rightAlt: '右 Alt', mediaPlayPause: '⏯ メディア再生/一時停止', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index af498c93..fd5ad0d1 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -687,7 +687,6 @@ export const ko: typeof zhCN = { comboRecorded: '녹화됨', comboClear: '지우기', comboConflict: '이 단축키 조합은 사용할 수 없습니다', - modifierPresetsLabel: '자주 쓰는 단일 키:', mouseMiddleLabel: '마우스 휠 버튼으로 음성 입력', mouseMiddleDesc: '휠 클릭(가운데 버튼)으로 음성 입력 시작/중지. 키보드 단축키와 독립적입니다.', mouseSideLabel: '마우스 측면 버튼으로 음성 입력', @@ -1105,9 +1104,6 @@ export const ko: typeof zhCN = { rightControl: '오른쪽 Control', leftControl: '왼쪽 Control', rightCommand: '오른쪽 Command', - leftCommand: '왼쪽 Command', - leftShift: '왼쪽 Shift', - rightShift: '오른쪽 Shift', fn: 'Fn (지구본 키)', rightAlt: '오른쪽 Alt', mediaPlayPause: '⏯ 미디어 재생/일시정지', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index fd6f2e4c..72235a10 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -683,7 +683,6 @@ export const zhCN = { comboRecorded: '已录制', comboClear: '清除', comboConflict: '该快捷键组合不可用', - modifierPresetsLabel: '常用单键:', mouseMiddleLabel: '鼠标中键唤起识别', mouseMiddleDesc: '按下鼠标滚轮(中键)开始/停止语音识别,与键盘快捷键独立。', mouseSideLabel: '鼠标侧键唤起识别', @@ -1135,9 +1134,6 @@ export const zhCN = { rightControl: '右 Control', leftControl: '左 Control', rightCommand: '右 Command', - leftCommand: '左 Command', - leftShift: '左 Shift', - rightShift: '右 Shift', fn: 'Fn (地球键)', rightAlt: '右 Alt', mediaPlayPause: '⏯ 媒体播放/暂停', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 3578268f..54e531a4 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -660,7 +660,6 @@ export const zhTW: typeof zhCN = { comboRecorded: '已錄製', comboClear: '清除', comboConflict: '此快捷鍵組合不可用', - modifierPresetsLabel: '常用單鍵:', mouseMiddleLabel: '滑鼠中鍵喚起識別', mouseMiddleDesc: '按下滑鼠滾輪(中鍵)開始/停止語音識別,與鍵盤快捷鍵獨立。', mouseSideLabel: '滑鼠側鍵喚起識別', @@ -1103,9 +1102,6 @@ export const zhTW: typeof zhCN = { rightControl: '右 Control', leftControl: '左 Control', rightCommand: '右 Command', - leftCommand: '左 Command', - leftShift: '左 Shift', - rightShift: '右 Shift', fn: 'Fn (地球鍵)', rightAlt: '右 Alt', mediaPlayPause: '⏯ 媒體播放/暫停', diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 306d98d2..fc2ec9c3 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -400,9 +400,6 @@ export const mockHotkeyCapability: HotkeyCapability = { "rightAlt", "leftControl", "rightCommand", - "leftCommand", - "leftShift", - "rightShift", "custom", ], requiresAccessibilityPermission: false, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 04d73326..5486504d 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -81,9 +81,6 @@ export type HotkeyTrigger = | 'rightControl' | 'leftControl' | 'rightCommand' - | 'leftCommand' - | 'leftShift' - | 'rightShift' | 'fn' | 'rightAlt' | 'mediaPlayPause' diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index 7a67f827..96b20ce7 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -132,6 +132,7 @@ export function RecordingInputSection() { const isAndroid = platformCaps?.platform === 'android'; const showDesktopHotkey = platformCaps?.supportsDesktopHotkey === true; const showDesktopInsert = showDesktopHotkey && os !== 'linux'; + const showMouseDictation = showDesktopHotkey && os !== 'linux'; const showDesktopStartup = showDesktopHotkey; const onModeChange = (mode: HotkeyMode) => @@ -219,7 +220,7 @@ export function RecordingInputSection() { /> )} - {showDesktopHotkey && ( + {showMouseDictation && ( )} - {showDesktopHotkey && ( + {showMouseDictation && ( Date: Sun, 21 Jun 2026 10:12:56 +0000 Subject: [PATCH 04/21] Trigger CI From 03b473be9e504519029855654ac5a590a4058ed9 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 18:15:38 +0800 Subject: [PATCH 05/21] docs(types): drop PR1 side-modifier wording from ShortcutBinding comments Co-authored-by: Cursor --- openless-all/app/src/lib/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 5486504d..bec65013 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -125,9 +125,9 @@ export interface HotkeyStatus { } export interface ShortcutBinding { - /** 主键,例如 "D" / "Space" / "F1" / "RightOption" / "LeftShift" */ + /** 主键,例如 "D" / "Space" / "F1" / "RightOption" / "Shift" */ primary: string; - /** 修饰符:泛化 tag(cmd/ctrl/…)或侧别 tag(cmd-left/ctrl-right/…)。 */ + /** 修饰符 tag,例如 "cmd" | "shift" | "alt" | "ctrl" */ modifiers: string[]; } From 55cb49d85e9718bcd070424ea4cb575a7781f886 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 18:21:08 +0800 Subject: [PATCH 06/21] fix(dictation): emit mouse release when monitor drops or disables held source On update_config disable or Drop, swap held flags and send Released events so HoldSourceTracker does not retain stale mouse sources after prefs refresh. Co-authored-by: Cursor --- .../app/src-tauri/src/mouse_dictation.rs | 124 +++++++++++++++++- 1 file changed, 118 insertions(+), 6 deletions(-) diff --git a/openless-all/app/src-tauri/src/mouse_dictation.rs b/openless-all/app/src-tauri/src/mouse_dictation.rs index 9899221f..795dabfa 100644 --- a/openless-all/app/src-tauri/src/mouse_dictation.rs +++ b/openless-all/app/src-tauri/src/mouse_dictation.rs @@ -47,14 +47,14 @@ impl MouseDictationMonitor { if let Some(slot) = ACTIVE_MOUSE.get() { if let Ok(mut guard) = slot.write() { if let Some(state) = guard.as_mut() { - state.middle_enabled = config.middle_enabled; - state.side_enabled = config.side_enabled; - if !config.middle_enabled { - state.middle_held.store(false, Ordering::SeqCst); + if state.middle_enabled && !config.middle_enabled { + release_held_if_held(state, &state.middle_held, TriggerSource::MouseMiddle); } - if !config.side_enabled { - state.side_held.store(false, Ordering::SeqCst); + if state.side_enabled && !config.side_enabled { + release_held_if_held(state, &state.side_held, TriggerSource::MouseSide); } + state.middle_enabled = config.middle_enabled; + state.side_enabled = config.side_enabled; } } } @@ -65,12 +65,34 @@ impl Drop for MouseDictationMonitor { fn drop(&mut self) { if let Some(slot) = ACTIVE_MOUSE.get() { if let Ok(mut guard) = slot.write() { + if let Some(state) = guard.as_mut() { + release_held_sources(state, true, true); + } *guard = None; } } } } +fn release_held_if_held( + state: &MouseMonitorState, + held: &AtomicBool, + source: TriggerSource, +) { + if held.swap(false, Ordering::SeqCst) { + send_edge(state, HotkeyEvent::Released, source); + } +} + +fn release_held_sources(state: &MouseMonitorState, middle: bool, side: bool) { + if middle { + release_held_if_held(state, &state.middle_held, TriggerSource::MouseMiddle); + } + if side { + release_held_if_held(state, &state.side_held, TriggerSource::MouseSide); + } +} + fn with_active(f: F) -> Option where F: FnOnce(&MouseMonitorState) -> R, @@ -242,3 +264,93 @@ pub mod platform { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{mpsc, Mutex}; + + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn clear_active_monitor() { + if let Some(slot) = ACTIVE_MOUSE.get() { + if let Ok(mut guard) = slot.write() { + if let Some(state) = guard.take() { + release_held_sources(&state, true, true); + } + } + } + } + + #[test] + fn disabling_held_middle_emits_release() { + let _lock = TEST_LOCK.lock().unwrap(); + clear_active_monitor(); + + let (tx, rx) = mpsc::channel(); + let _monitor = MouseDictationMonitor::start( + MouseDictationConfig { + middle_enabled: true, + side_enabled: false, + }, + tx, + ) + .unwrap(); + + handle_button(MouseButton::Middle, true); + assert_eq!( + rx.recv().unwrap(), + (HotkeyEvent::Pressed, TriggerSource::MouseMiddle) + ); + + MouseDictationMonitor::update_config(MouseDictationConfig { + middle_enabled: false, + side_enabled: false, + }); + assert_eq!( + rx.recv().unwrap(), + (HotkeyEvent::Released, TriggerSource::MouseMiddle) + ); + + clear_active_monitor(); + } + + #[test] + fn dropping_monitor_emits_release_for_held_sources() { + let _lock = TEST_LOCK.lock().unwrap(); + clear_active_monitor(); + + let (tx, rx) = mpsc::channel(); + let monitor = MouseDictationMonitor::start( + MouseDictationConfig { + middle_enabled: true, + side_enabled: true, + }, + tx, + ) + .unwrap(); + + handle_button(MouseButton::Middle, true); + handle_button(MouseButton::Side, true); + assert_eq!( + rx.recv().unwrap(), + (HotkeyEvent::Pressed, TriggerSource::MouseMiddle) + ); + assert_eq!( + rx.recv().unwrap(), + (HotkeyEvent::Pressed, TriggerSource::MouseSide) + ); + + drop(monitor); + + let mut releases = Vec::new(); + while let Ok(evt) = rx.try_recv() { + releases.push(evt); + } + assert_eq!(releases.len(), 2); + assert!(releases.contains(&(HotkeyEvent::Released, TriggerSource::MouseMiddle))); + assert!(releases.contains(&(HotkeyEvent::Released, TriggerSource::MouseSide))); + + clear_active_monitor(); + } +} From 0e37b296db9e2d2fbd5c78acb3994a3c324795f4 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 18:33:17 +0800 Subject: [PATCH 07/21] feat(dictation): add mouse hold mode tests and improve mouse source handling --- openless-all/app/src-tauri/src/coordinator.rs | 51 +++++++++++++++++++ .../src-tauri/src/coordinator/dictation.rs | 11 ++-- .../src-tauri/src/coordinator/hotkey_loops.rs | 31 +++++++++++ .../app/src-tauri/src/hold_source_tracker.rs | 24 ++++----- .../pages/settings/RecordingInputSection.tsx | 10 +++- 5 files changed, 110 insertions(+), 17 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index b0c5a720..aba8063f 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2221,6 +2221,57 @@ mod tests { std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); } + #[tokio::test] + async fn hold_mode_mouse_disable_while_holding_ends_session() { + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + { + let mut prefs = coordinator.inner.prefs.get(); + prefs.hotkey.mode = HotkeyMode::Hold; + coordinator.inner.prefs.set(prefs).unwrap(); + } + + handle_pressed_edge(&coordinator.inner, TriggerSource::MouseMiddle).await; + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + assert_eq!(coordinator.inner.hold_sources.active_count(), 1); + + sync_release_mouse_hold_sources(&coordinator.inner); + + assert_eq!(coordinator.inner.hold_sources.active_count(), 0); + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + + #[tokio::test] + async fn hold_mode_concurrent_press_starts_once() { + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + { + let mut prefs = coordinator.inner.prefs.get(); + prefs.hotkey.mode = HotkeyMode::Hold; + coordinator.inner.prefs.set(prefs).unwrap(); + } + + handle_pressed_edge(&coordinator.inner, TriggerSource::KeyboardDictation).await; + handle_pressed_edge(&coordinator.inner, TriggerSource::MouseMiddle).await; + + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + assert_eq!(coordinator.inner.hold_sources.active_count(), 2); + + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + #[tokio::test] async fn begin_session_dry_run_enters_listening_and_clears_stale_edges() { let _guard = ENV_LOCK.lock().await; diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 2d34be3e..a6674d2c 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -553,10 +553,10 @@ pub(super) async fn handle_pressed_edge( ) { let mode = inner.prefs.get().hotkey.mode; if mode == HotkeyMode::Hold { - if !inner.hold_sources.press(source) { + let Some(prev_count) = inner.hold_sources.press(source) else { return; - } - if inner.hold_sources.active_count() != 1 { + }; + if prev_count != 0 { return; } } else if inner.hotkey_trigger_held.swap(true, Ordering::SeqCst) { @@ -580,6 +580,11 @@ pub(super) async fn handle_pressed_edge( "[coord] hotkey pressed edge debounced (< {} ms since last dispatch)", HOTKEY_DEBOUNCE.as_millis() ); + if mode == HotkeyMode::Hold { + inner.hold_sources.release(source); + } else { + inner.hotkey_trigger_held.store(false, Ordering::SeqCst); + } return; } diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 7233e687..27c4c97c 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1220,6 +1220,34 @@ pub(super) fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, k } } +pub(super) fn sync_release_mouse_hold_sources(inner: &Arc) { + let mode = inner.prefs.get().hotkey.mode; + if mode != HotkeyMode::Hold { + return; + } + inner.hold_sources.release(TriggerSource::MouseMiddle); + inner.hold_sources.release(TriggerSource::MouseSide); + if inner.hold_sources.active_count() != 0 { + return; + } + let phase = inner.state.lock().phase; + let inner_clone = Arc::clone(inner); + async_runtime::block_on(async { + match phase { + SessionPhase::Listening => { + let _ = end_session(&inner_clone).await; + } + SessionPhase::Starting => { + request_stop_during_starting( + &inner_clone, + "mouse dictation disabled while held", + ); + } + _ => {} + } + }); +} + pub(super) fn mouse_dictation_bridge_loop( inner: Arc, rx: mpsc::Receiver<(HotkeyEvent, TriggerSource)>, @@ -1259,12 +1287,14 @@ pub(super) fn mouse_dictation_supervisor_loop(inner: Arc) { #[cfg(not(target_os = "linux"))] if !needs_mouse { + sync_release_mouse_hold_sources(&inner); inner.mouse_dictation.lock().take(); return; } #[cfg(target_os = "linux")] if !needs_mouse { + sync_release_mouse_hold_sources(&inner); inner.mouse_dictation.lock().take(); return; } @@ -1303,6 +1333,7 @@ fn start_mouse_dictation_monitor( } pub(super) fn update_mouse_dictation_binding_now(inner: &Arc) { + sync_release_mouse_hold_sources(inner); inner.mouse_dictation.lock().take(); let inner_clone = Arc::clone(inner); std::thread::Builder::new() diff --git a/openless-all/app/src-tauri/src/hold_source_tracker.rs b/openless-all/app/src-tauri/src/hold_source_tracker.rs index 8d1f24a8..7a8f1440 100644 --- a/openless-all/app/src-tauri/src/hold_source_tracker.rs +++ b/openless-all/app/src-tauri/src/hold_source_tracker.rs @@ -35,14 +35,14 @@ impl HoldSourceTracker { self.active_count.store(0, Ordering::SeqCst); } - /// Returns `true` on a fresh press edge for this source. - pub fn press(&self, source: TriggerSource) -> bool { + /// Returns the active count **before** increment on a fresh press edge. + /// Duplicate press edges for the same source return `None`. + pub fn press(&self, source: TriggerSource) -> Option { let slot = self.slot(source); if slot.swap(true, Ordering::SeqCst) { - return false; + return None; } - self.active_count.fetch_add(1, Ordering::SeqCst); - true + Some(self.active_count.fetch_add(1, Ordering::SeqCst)) } /// Returns the remaining active source count after release. @@ -81,12 +81,12 @@ mod tests { #[test] fn hold_source_count_tracks_multiple_sources() { let tracker = HoldSourceTracker::new(); - assert!(tracker.press(TriggerSource::KeyboardDictation)); + assert_eq!(tracker.press(TriggerSource::KeyboardDictation), Some(0)); assert_eq!(tracker.active_count(), 1); - assert!(!tracker.press(TriggerSource::KeyboardDictation)); + assert_eq!(tracker.press(TriggerSource::KeyboardDictation), None); assert_eq!(tracker.active_count(), 1); - assert!(tracker.press(TriggerSource::MouseMiddle)); + assert_eq!(tracker.press(TriggerSource::MouseMiddle), Some(1)); assert_eq!(tracker.active_count(), 2); assert_eq!( @@ -99,7 +99,7 @@ mod tests { #[test] fn last_release_returns_zero() { let tracker = HoldSourceTracker::new(); - assert!(tracker.press(TriggerSource::KeyboardDictation)); + assert_eq!(tracker.press(TriggerSource::KeyboardDictation), Some(0)); assert_eq!(tracker.release(TriggerSource::KeyboardDictation), 0); assert_eq!(tracker.active_count(), 0); } @@ -107,7 +107,7 @@ mod tests { #[test] fn duplicate_release_is_no_op() { let tracker = HoldSourceTracker::new(); - assert!(tracker.press(TriggerSource::MouseSide)); + assert_eq!(tracker.press(TriggerSource::MouseSide), Some(0)); assert_eq!(tracker.release(TriggerSource::MouseSide), 0); assert_eq!(tracker.release(TriggerSource::MouseSide), 0); assert_eq!(tracker.active_count(), 0); @@ -116,8 +116,8 @@ mod tests { #[test] fn keyboard_and_mouse_hold_tracks_independently() { let tracker = HoldSourceTracker::new(); - assert!(tracker.press(TriggerSource::KeyboardDictation)); - assert!(tracker.press(TriggerSource::MouseMiddle)); + assert_eq!(tracker.press(TriggerSource::KeyboardDictation), Some(0)); + assert_eq!(tracker.press(TriggerSource::MouseMiddle), Some(1)); assert_eq!(tracker.active_count(), 2); assert_eq!(tracker.release(TriggerSource::KeyboardDictation), 1); assert_eq!(tracker.release(TriggerSource::MouseMiddle), 0); diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index 96b20ce7..dbdff689 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -221,7 +221,10 @@ export function RecordingInputSection() { )} {showMouseDictation && ( - + void savePrefs({ ...prefs, mouseMiddleButtonDictation: next })} @@ -229,7 +232,10 @@ export function RecordingInputSection() { )} {showMouseDictation && ( - + void savePrefs({ ...prefs, mouseSideButtonDictation: next })} From b26efbbdafabcc1fb5a87892c2866a33d8702d09 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 18:39:38 +0800 Subject: [PATCH 08/21] feat(linux): add evdev input for side combos and mouse dictation on Linux Co-authored-by: Cursor --- openless-all/app/src-tauri/Cargo.lock | 83 ++- openless-all/app/src-tauri/Cargo.toml | 1 + openless-all/app/src-tauri/src/coordinator.rs | 26 +- .../src-tauri/src/coordinator/hotkey_loops.rs | 57 ++- openless-all/app/src-tauri/src/lib.rs | 5 + .../app/src-tauri/src/linux_evdev_input.rs | 481 ++++++++++++++++++ .../src/mobile_stubs/linux_evdev_input.rs | 32 ++ .../pages/settings/RecordingInputSection.tsx | 2 +- 8 files changed, 680 insertions(+), 7 deletions(-) create mode 100644 openless-all/app/src-tauri/src/linux_evdev_input.rs create mode 100644 openless-all/app/src-tauri/src/mobile_stubs/linux_evdev_input.rs diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index bae2f703..042d732e 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -464,6 +464,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block" version = "0.1.6" @@ -1630,6 +1642,19 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "evdev" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab6055a93a963297befb0f4f6e18f314aec9767a4bbe88b151126df2433610a7" +dependencies = [ + "bitvec", + "cfg-if", + "libc", + "nix 0.23.2", + "thiserror 1.0.69", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1707,7 +1732,7 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "memoffset", + "memoffset 0.9.1", "rustc_version", ] @@ -1839,6 +1864,12 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -3136,6 +3167,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3350,6 +3390,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" +dependencies = [ + "bitflags 1.3.2", + "cc", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + [[package]] name = "nix" version = "0.29.0" @@ -3360,7 +3413,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -3901,6 +3954,7 @@ dependencies = [ "dbus", "enigo", "env_logger", + "evdev", "ferrous-opencc", "foundry-local-sdk", "futures-util", @@ -4497,6 +4551,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rancor" version = "0.1.1" @@ -5664,6 +5724,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.46" @@ -6556,7 +6622,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "windows-sys 0.61.2", ] @@ -8033,6 +8099,15 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" @@ -8165,7 +8240,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "rand 0.8.6", "serde", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index ff16aed3..6c3c947b 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -89,6 +89,7 @@ features = ["windows-native"] [target.'cfg(target_os = "linux")'.dependencies] dbus = "0.9" +evdev = "0.12" [target.'cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))'.dependencies.keyring] version = "3.6.3" default-features = false diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index aba8063f..e6298624 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -284,6 +284,8 @@ struct Inner { combo_hotkey: Mutex>, side_aware_combo: Mutex>, mouse_dictation: Mutex>, + #[cfg(target_os = "linux")] + linux_evdev: Mutex>, hold_sources: crate::hold_source_tracker::HoldSourceTracker, translation_hotkey: Mutex>, switch_style_hotkey: Mutex>, @@ -408,6 +410,8 @@ impl Coordinator { combo_hotkey: Mutex::new(None), side_aware_combo: Mutex::new(None), mouse_dictation: Mutex::new(None), + #[cfg(target_os = "linux")] + linux_evdev: Mutex::new(None), hold_sources: crate::hold_source_tracker::HoldSourceTracker::new(), translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), @@ -503,6 +507,8 @@ impl Coordinator { combo_hotkey: Mutex::new(None), side_aware_combo: Mutex::new(None), mouse_dictation: Mutex::new(None), + #[cfg(target_os = "linux")] + linux_evdev: Mutex::new(None), hold_sources: crate::hold_source_tracker::HoldSourceTracker::new(), translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), @@ -833,6 +839,8 @@ impl Coordinator { if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() { take_combo_hotkey_on_main_thread(&self.inner); self.inner.side_aware_combo.lock().take(); + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(&self.inner); log::info!("[coord] combo hotkey 已关闭(modifier-only)"); return; } @@ -840,6 +848,8 @@ impl Coordinator { if is_unconfigured_shortcut(&binding) { take_combo_hotkey_on_main_thread(&self.inner); self.inner.side_aware_combo.lock().take(); + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(&self.inner); log::info!("[coord] combo hotkey 已关闭(无绑定)"); return; } @@ -862,6 +872,8 @@ impl Coordinator { log::warn!("[coord] update side-aware combo binding 失败: {e}"); } } + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(&self.inner); return; } @@ -869,6 +881,8 @@ impl Coordinator { let app = self.inner.app.lock().clone(); let Some(app) = app else { log::warn!("[coord] update combo hotkey binding: AppHandle 未 bind,跳过"); + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(&self.inner); return; }; let inner_clone = Arc::clone(&self.inner); @@ -900,6 +914,8 @@ impl Coordinator { } } }); + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(&self.inner); } /// 用户在设置里改了 QA 组合键时调用。先持久化(由 prefs.set 完成), @@ -1224,7 +1240,15 @@ impl Coordinator { } pub fn hotkey_capability(&self) -> HotkeyCapability { - HotkeyMonitor::capability() + let mut cap = HotkeyMonitor::capability(); + #[cfg(all(not(mobile), target_os = "linux"))] + if let Some(msg) = crate::linux_evdev_input::status_message() { + cap.status_hint = Some(match cap.status_hint { + Some(base) if !base.is_empty() => format!("{base}\n{msg}"), + _ => msg, + }); + } + cap } pub async fn start_dictation(&self) -> Result<(), String> { diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 27c4c97c..d804ef3a 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -516,6 +516,8 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { .name("openless-side-combo-bridge".into()) .spawn(move || combo_hotkey_bridge_loop(inner_clone, rx)) .ok(); + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(&inner); return; } Err(e) => { @@ -1283,8 +1285,22 @@ pub(super) fn mouse_dictation_supervisor_loop(inner: Arc) { middle_enabled: prefs.mouse_middle_button_dictation, side_enabled: prefs.mouse_side_button_dictation, }; + let needs_side_combo = crate::shortcut_binding::binding_requires_side_aware_hook( + &prefs.dictation_hotkey, + ); let needs_mouse = config.middle_enabled || config.side_enabled; + #[cfg(target_os = "linux")] + if needs_side_combo && !needs_mouse { + refresh_linux_evdev_monitor(&inner); + if inner.linux_evdev.lock().is_some() { + return; + } + log::warn!("[coord] linux evdev monitor for side combo failed; retry in 3s"); + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + #[cfg(not(target_os = "linux"))] if !needs_mouse { sync_release_mouse_hold_sources(&inner); @@ -1293,14 +1309,17 @@ pub(super) fn mouse_dictation_supervisor_loop(inner: Arc) { } #[cfg(target_os = "linux")] - if !needs_mouse { + if !needs_mouse && !needs_side_combo { sync_release_mouse_hold_sources(&inner); inner.mouse_dictation.lock().take(); + inner.linux_evdev.lock().take(); return; } if inner.mouse_dictation.lock().is_some() { crate::mouse_dictation::MouseDictationMonitor::update_config(config); + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(&inner); return; } @@ -1313,6 +1332,8 @@ pub(super) fn mouse_dictation_supervisor_loop(inner: Arc) { .name("openless-mouse-dictation-bridge".into()) .spawn(move || mouse_dictation_bridge_loop(inner_clone, rx)) .ok(); + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(&inner); return; } Err(err) => { @@ -1332,9 +1353,43 @@ fn start_mouse_dictation_monitor( crate::mouse_dictation::MouseDictationMonitor::start(config, tx) } +#[cfg(target_os = "linux")] +pub(super) fn refresh_linux_evdev_monitor(inner: &Arc) { + inner.linux_evdev.lock().take(); + try_start_linux_evdev_monitor(inner); +} + +#[cfg(target_os = "linux")] +pub(super) fn try_start_linux_evdev_monitor(inner: &Arc) { + let config = crate::linux_evdev_input::config_from_prefs(&inner.prefs.get()); + if !crate::linux_evdev_input::monitor_needed(&config) { + crate::linux_evdev_input::set_status_message(None); + return; + } + let (tx, rx) = mpsc::channel::<(HotkeyEvent, TriggerSource)>(); + match crate::linux_evdev_input::LinuxEvdevMonitor::start(config, tx.clone()) { + Ok(monitor) => { + *inner.linux_evdev.lock() = Some(monitor); + let inner_clone = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-linux-evdev-mouse-bridge".into()) + .spawn(move || mouse_dictation_bridge_loop(inner_clone, rx)) + .ok(); + } + Err(err) => { + log::warn!("[coord] linux evdev monitor failed: {err}"); + crate::linux_evdev_input::set_status_message(Some(format!( + "{err} 鼠标/侧别组合键需读取 /dev/input/event*;若无权限请将用户加入 input 组(sudo usermod -aG input $USER)后重新登录。" + ))); + } + } +} + pub(super) fn update_mouse_dictation_binding_now(inner: &Arc) { sync_release_mouse_hold_sources(inner); inner.mouse_dictation.lock().take(); + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(inner); let inner_clone = Arc::clone(inner); std::thread::Builder::new() .name("openless-mouse-dictation-supervisor".into()) diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 38d01e8b..f4534fe7 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -78,6 +78,11 @@ mod side_aware_combo; #[cfg(mobile)] #[path = "mobile_stubs/side_aware_combo.rs"] mod side_aware_combo; +#[cfg(not(mobile))] +mod linux_evdev_input; +#[cfg(mobile)] +#[path = "mobile_stubs/linux_evdev_input.rs"] +mod linux_evdev_input; #[cfg(mobile)] #[path = "mobile_stubs/shortcut_binding.rs"] mod shortcut_binding; diff --git a/openless-all/app/src-tauri/src/linux_evdev_input.rs b/openless-all/app/src-tauri/src/linux_evdev_input.rs new file mode 100644 index 00000000..34e808a3 --- /dev/null +++ b/openless-all/app/src-tauri/src/linux_evdev_input.rs @@ -0,0 +1,481 @@ +//! Linux evdev input for side-specific combos and mouse dictation triggers. + +#[cfg(target_os = "linux")] +mod imp { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::mpsc::Sender; + use std::sync::{Arc, Mutex, OnceLock}; + use std::thread; + use std::time::Duration; + + use evdev::{Device, InputEvent, InputEventKind, Key}; + + use crate::hold_source_tracker::TriggerSource; + use crate::hotkey::HotkeyEvent; + use crate::mouse_dictation::MouseDictationConfig; + use crate::shortcut_binding::binding_requires_side_aware_hook; + use crate::side_aware_combo::{handle_primary_key, handle_side_modifier, SideModifier}; + use crate::types::ShortcutBinding; + + static USER_STATUS: OnceLock>> = OnceLock::new(); + + fn user_status() -> &'static Mutex> { + USER_STATUS.get_or_init(|| Mutex::new(None)) + } + + /// User-visible evdev status for settings / capability hints. + pub fn status_message() -> Option { + user_status().lock().ok()?.clone() + } + + pub fn set_status_message(message: Option) { + if let Ok(mut slot) = user_status().lock() { + *slot = message; + } + } + + const UDEV_HINT: &str = "鼠标/侧别组合键需读取 /dev/input/event*。若无权限,请将用户加入 input 组:`sudo usermod -aG input $USER`,然后重新登录。"; + + pub struct LinuxEvdevConfig { + pub side_combo: Option, + pub mouse: MouseDictationConfig, + } + + pub fn config_from_prefs(prefs: &crate::types::UserPreferences) -> LinuxEvdevConfig { + LinuxEvdevConfig { + side_combo: if binding_requires_side_aware_hook(&prefs.dictation_hotkey) { + Some(prefs.dictation_hotkey.clone()) + } else { + None + }, + mouse: MouseDictationConfig { + middle_enabled: prefs.mouse_middle_button_dictation, + side_enabled: prefs.mouse_side_button_dictation, + }, + } + } + + pub fn monitor_needed(config: &LinuxEvdevConfig) -> bool { + config.mouse.middle_enabled + || config.mouse.side_enabled + || config + .side_combo + .as_ref() + .is_some_and(binding_requires_side_aware_hook) + } + + pub struct LinuxEvdevMonitor { + shutdown: Arc, + } + + impl LinuxEvdevMonitor { + pub fn start( + config: LinuxEvdevConfig, + dictation_tx: Sender<(HotkeyEvent, TriggerSource)>, + ) -> Result { + let needs_combo = config + .side_combo + .as_ref() + .is_some_and(binding_requires_side_aware_hook); + if !config.mouse.middle_enabled + && !config.mouse.side_enabled + && !needs_combo + { + return Err("linux evdev monitor has nothing to watch".into()); + } + + let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_for_thread = Arc::clone(&shutdown); + thread::Builder::new() + .name("openless-linux-evdev".into()) + .spawn(move || { + run_loop(config, dictation_tx, shutdown_for_thread); + }) + .map_err(|e| format!("spawn linux evdev thread failed: {e}"))?; + + Ok(Self { shutdown }) + } + } + + impl Drop for LinuxEvdevMonitor { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::SeqCst); + } + } + + fn run_loop( + config: LinuxEvdevConfig, + dictation_tx: Sender<(HotkeyEvent, TriggerSource)>, + shutdown: Arc, + ) { + let mut devices: Vec = Vec::new(); + let mut rescan_at = std::time::Instant::now(); + let middle_held = AtomicBool::new(false); + let side_held = AtomicBool::new(false); + while !shutdown.load(Ordering::SeqCst) { + if devices.is_empty() || rescan_at.elapsed() >= Duration::from_secs(5) { + match open_input_devices() { + Ok(opened) if !opened.is_empty() => { + devices = opened; + set_status_message(None); + } + Ok(_) => { + set_status_message(Some(format!( + "未找到可读的输入设备。{UDEV_HINT}" + ))); + } + Err(err) => { + set_status_message(Some(format!("{err} {UDEV_HINT}"))); + } + } + rescan_at = std::time::Instant::now(); + } + + if devices.is_empty() { + thread::sleep(Duration::from_secs(3)); + continue; + } + + poll_devices( + &mut devices, + &config, + &dictation_tx, + &shutdown, + &middle_held, + &side_held, + ); + thread::sleep(Duration::from_millis(10)); + } + } + + fn open_input_devices() -> Result, String> { + let mut devices = Vec::new(); + for entry in + std::fs::read_dir("/dev/input").map_err(|e| format!("read /dev/input: {e}"))? + { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + if !path.to_string_lossy().contains("/dev/input/event") { + continue; + } + if let Ok(device) = Device::open(&path) { + devices.push(device); + } + } + Ok(devices) + } + + fn poll_devices( + devices: &mut Vec, + config: &LinuxEvdevConfig, + dictation_tx: &Sender<(HotkeyEvent, TriggerSource)>, + shutdown: &Arc, + middle_held: &AtomicBool, + side_held: &AtomicBool, + ) { + devices.retain_mut(|device| { + if shutdown.load(Ordering::SeqCst) { + return false; + } + match device.fetch_events() { + Ok(events) => { + for event in events { + dispatch_event( + &event, + config, + dictation_tx, + middle_held, + side_held, + ); + } + true + } + Err(err) => { + log::warn!("[linux-evdev] device read failed: {err}"); + false + } + } + }); + } + + fn key_edge_value(value: i32) -> Option { + match value { + 0 => Some(false), + 1 => Some(true), + _ => None, + } + } + + fn dispatch_event( + event: &InputEvent, + config: &LinuxEvdevConfig, + dictation_tx: &Sender<(HotkeyEvent, TriggerSource)>, + middle_held: &AtomicBool, + side_held: &AtomicBool, + ) { + let InputEventKind::Key(key) = event.kind() else { + return; + }; + let Some(pressed) = key_edge_value(event.value()) else { + return; + }; + + match key { + Key::BTN_MIDDLE if config.mouse.middle_enabled => { + dispatch_mouse_edge(dictation_tx, middle_held, TriggerSource::MouseMiddle, pressed); + } + Key::BTN_SIDE | Key::BTN_EXTRA if config.mouse.side_enabled => { + dispatch_mouse_edge(dictation_tx, side_held, TriggerSource::MouseSide, pressed); + } + _ if config.side_combo.is_some() => { + if let Some(side) = side_from_key(key) { + handle_side_modifier(side, pressed); + } else if let Some(primary) = primary_from_key(key) { + handle_primary_key(&primary, pressed); + } + } + _ => {} + } + } + + fn dispatch_mouse_edge( + tx: &Sender<(HotkeyEvent, TriggerSource)>, + held: &AtomicBool, + source: TriggerSource, + pressed: bool, + ) { + if pressed { + if !held.swap(true, Ordering::SeqCst) { + let _ = tx.send((HotkeyEvent::Pressed, source)); + } + } else if held.swap(false, Ordering::SeqCst) { + let _ = tx.send((HotkeyEvent::Released, source)); + } + } + + fn side_from_key(key: Key) -> Option { + match key { + Key::KEY_LEFTMETA => Some(SideModifier::CmdLeft), + Key::KEY_RIGHTMETA => Some(SideModifier::CmdRight), + Key::KEY_LEFTCTRL => Some(SideModifier::CtrlLeft), + Key::KEY_RIGHTCTRL => Some(SideModifier::CtrlRight), + Key::KEY_LEFTALT => Some(SideModifier::AltLeft), + Key::KEY_RIGHTALT => Some(SideModifier::AltRight), + Key::KEY_LEFTSHIFT => Some(SideModifier::ShiftLeft), + Key::KEY_RIGHTSHIFT => Some(SideModifier::ShiftRight), + _ => None, + } + } + + fn primary_from_key(key: Key) -> Option { + match key { + Key::KEY_A => Some("A".into()), + Key::KEY_B => Some("B".into()), + Key::KEY_C => Some("C".into()), + Key::KEY_D => Some("D".into()), + Key::KEY_E => Some("E".into()), + Key::KEY_F => Some("F".into()), + Key::KEY_G => Some("G".into()), + Key::KEY_H => Some("H".into()), + Key::KEY_I => Some("I".into()), + Key::KEY_J => Some("J".into()), + Key::KEY_K => Some("K".into()), + Key::KEY_L => Some("L".into()), + Key::KEY_M => Some("M".into()), + Key::KEY_N => Some("N".into()), + Key::KEY_O => Some("O".into()), + Key::KEY_P => Some("P".into()), + Key::KEY_Q => Some("Q".into()), + Key::KEY_R => Some("R".into()), + Key::KEY_S => Some("S".into()), + Key::KEY_T => Some("T".into()), + Key::KEY_U => Some("U".into()), + Key::KEY_V => Some("V".into()), + Key::KEY_W => Some("W".into()), + Key::KEY_X => Some("X".into()), + Key::KEY_Y => Some("Y".into()), + Key::KEY_Z => Some("Z".into()), + Key::KEY_0 => Some("0".into()), + Key::KEY_1 => Some("1".into()), + Key::KEY_2 => Some("2".into()), + Key::KEY_3 => Some("3".into()), + Key::KEY_4 => Some("4".into()), + Key::KEY_5 => Some("5".into()), + Key::KEY_6 => Some("6".into()), + Key::KEY_7 => Some("7".into()), + Key::KEY_8 => Some("8".into()), + Key::KEY_9 => Some("9".into()), + Key::KEY_SPACE => Some("Space".into()), + Key::KEY_ENTER => Some("Enter".into()), + Key::KEY_TAB => Some("Tab".into()), + Key::KEY_BACKSPACE => Some("Backspace".into()), + Key::KEY_DELETE => Some("Delete".into()), + Key::KEY_ESC => Some("Escape".into()), + Key::KEY_F1 => Some("F1".into()), + Key::KEY_F2 => Some("F2".into()), + Key::KEY_F3 => Some("F3".into()), + Key::KEY_F4 => Some("F4".into()), + Key::KEY_F5 => Some("F5".into()), + Key::KEY_F6 => Some("F6".into()), + Key::KEY_F7 => Some("F7".into()), + Key::KEY_F8 => Some("F8".into()), + Key::KEY_F9 => Some("F9".into()), + Key::KEY_F10 => Some("F10".into()), + Key::KEY_F11 => Some("F11".into()), + Key::KEY_F12 => Some("F12".into()), + _ => None, + } + } + + pub fn is_available() -> bool { + std::path::Path::new("/dev/input").is_dir() + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn mouse_middle_edge_dispatches_press_release() { + let (tx, rx) = std::sync::mpsc::channel(); + let held = AtomicBool::new(false); + dispatch_mouse_edge(&tx, &held, TriggerSource::MouseMiddle, true); + assert_eq!(rx.recv().unwrap().0, HotkeyEvent::Pressed); + dispatch_mouse_edge(&tx, &held, TriggerSource::MouseMiddle, true); + assert!(rx.try_recv().is_err()); + dispatch_mouse_edge(&tx, &held, TriggerSource::MouseMiddle, false); + assert_eq!(rx.recv().unwrap().0, HotkeyEvent::Released); + } + + #[test] + fn mouse_side_edge_dispatches() { + let (tx, rx) = std::sync::mpsc::channel(); + let held = AtomicBool::new(false); + dispatch_mouse_edge(&tx, &held, TriggerSource::MouseSide, true); + assert_eq!(rx.recv().unwrap().1, TriggerSource::MouseSide); + } + + #[test] + fn mouse_held_state_persists_across_poll_cycles() { + let (tx, rx) = std::sync::mpsc::channel(); + let middle_held = AtomicBool::new(false); + dispatch_mouse_edge(&tx, &middle_held, TriggerSource::MouseMiddle, true); + assert_eq!(rx.recv().unwrap().0, HotkeyEvent::Pressed); + dispatch_mouse_edge(&tx, &middle_held, TriggerSource::MouseMiddle, false); + assert_eq!(rx.recv().unwrap().0, HotkeyEvent::Released); + } + + #[test] + fn key_edge_value_ignores_repeat() { + assert_eq!(key_edge_value(1), Some(true)); + assert_eq!(key_edge_value(0), Some(false)); + assert_eq!(key_edge_value(2), None); + } + + #[test] + fn side_combo_follows_side_aware_dictation_hotkey() { + let mut prefs = crate::types::UserPreferences::default(); + prefs.dictation_hotkey = crate::types::ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd-left".into()], + }; + let config = config_from_prefs(&prefs); + assert!(config.side_combo.is_some()); + assert!(monitor_needed(&config)); + } + + #[test] + fn generic_combo_without_mouse_does_not_need_monitor() { + let mut prefs = crate::types::UserPreferences::default(); + prefs.dictation_hotkey = crate::types::ShortcutBinding { + primary: "D".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }; + prefs.mouse_middle_button_dictation = false; + prefs.mouse_side_button_dictation = false; + let config = config_from_prefs(&prefs); + assert!(config.side_combo.is_none()); + assert!(!monitor_needed(&config)); + } + + #[test] + fn mouse_only_config_has_no_side_combo() { + let mut prefs = crate::types::UserPreferences::default(); + prefs.mouse_middle_button_dictation = true; + let config = config_from_prefs(&prefs); + assert!(config.side_combo.is_none()); + assert!(monitor_needed(&config)); + } + + #[test] + fn side_combo_to_generic_without_mouse_stops_monitor() { + let mut prefs = crate::types::UserPreferences::default(); + prefs.dictation_hotkey = crate::types::ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd-left".into()], + }; + assert!(monitor_needed(&config_from_prefs(&prefs))); + + prefs.dictation_hotkey = crate::types::ShortcutBinding { + primary: "D".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }; + prefs.mouse_middle_button_dictation = false; + prefs.mouse_side_button_dictation = false; + assert!(!monitor_needed(&config_from_prefs(&prefs))); + } + + #[test] + fn mouse_only_to_side_combo_adds_side_combo() { + let mut prefs = crate::types::UserPreferences::default(); + prefs.mouse_middle_button_dictation = true; + assert!(config_from_prefs(&prefs).side_combo.is_none()); + + prefs.dictation_hotkey = crate::types::ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd-left".into()], + }; + let config = config_from_prefs(&prefs); + assert!(config.side_combo.is_some()); + assert!(monitor_needed(&config)); + } + } +} + +#[cfg(target_os = "linux")] +pub use imp::*; + +#[cfg(not(target_os = "linux"))] +pub struct LinuxEvdevConfig { + pub side_combo: Option, + pub mouse: crate::mouse_dictation::MouseDictationConfig, +} + +#[cfg(not(target_os = "linux"))] +pub struct LinuxEvdevMonitor; + +#[cfg(not(target_os = "linux"))] +impl LinuxEvdevMonitor { + pub fn start( + _config: LinuxEvdevConfig, + _dictation_tx: std::sync::mpsc::Sender<( + crate::hotkey::HotkeyEvent, + crate::hold_source_tracker::TriggerSource, + )>, + ) -> Result { + Err("linux evdev only available on linux".into()) + } +} + +#[cfg(not(target_os = "linux"))] +pub fn status_message() -> Option { + None +} + +#[cfg(not(target_os = "linux"))] +pub fn set_status_message(_message: Option) {} + +#[cfg(not(target_os = "linux"))] +pub fn is_available() -> bool { + false +} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/linux_evdev_input.rs b/openless-all/app/src-tauri/src/mobile_stubs/linux_evdev_input.rs new file mode 100644 index 00000000..d4f88f14 --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/linux_evdev_input.rs @@ -0,0 +1,32 @@ +//! Mobile stub — Linux evdev input is unavailable on Android/iOS. + +use crate::hold_source_tracker::TriggerSource; +use crate::hotkey::HotkeyEvent; +use crate::mouse_dictation::MouseDictationConfig; +use crate::types::ShortcutBinding; + +pub struct LinuxEvdevConfig { + pub side_combo: Option, + pub mouse: MouseDictationConfig, +} + +pub struct LinuxEvdevMonitor; + +impl LinuxEvdevMonitor { + pub fn start( + _config: LinuxEvdevConfig, + _dictation_tx: std::sync::mpsc::Sender<(HotkeyEvent, TriggerSource)>, + ) -> Result { + Err("linux evdev is not available on mobile".into()) + } +} + +pub fn status_message() -> Option { + None +} + +pub fn set_status_message(_message: Option) {} + +pub fn is_available() -> bool { + false +} diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index dbdff689..87d80b10 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -132,7 +132,7 @@ export function RecordingInputSection() { const isAndroid = platformCaps?.platform === 'android'; const showDesktopHotkey = platformCaps?.supportsDesktopHotkey === true; const showDesktopInsert = showDesktopHotkey && os !== 'linux'; - const showMouseDictation = showDesktopHotkey && os !== 'linux'; + const showMouseDictation = showDesktopHotkey; const showDesktopStartup = showDesktopHotkey; const onModeChange = (mode: HotkeyMode) => From 16b99c948b57be6dbecb9abccb40044b91cd7f58 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 18:48:53 +0800 Subject: [PATCH 09/21] fix(linux): sync mouse hold sources before evdev monitor refresh Co-authored-by: Cursor --- openless-all/app/src-tauri/src/coordinator.rs | 35 +++++++++++++++++++ .../src-tauri/src/coordinator/hotkey_loops.rs | 1 + 2 files changed, 36 insertions(+) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index e6298624..d4993949 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2296,6 +2296,41 @@ mod tests { std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); } + #[tokio::test] + async fn hold_mode_sync_release_mouse_only_keeps_keyboard_hold() { + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + { + let mut prefs = coordinator.inner.prefs.get(); + prefs.hotkey.mode = HotkeyMode::Hold; + coordinator.inner.prefs.set(prefs).unwrap(); + } + + handle_pressed_edge(&coordinator.inner, TriggerSource::KeyboardDictation).await; + handle_pressed_edge(&coordinator.inner, TriggerSource::MouseMiddle).await; + assert_eq!(coordinator.inner.hold_sources.active_count(), 2); + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + + sync_release_mouse_hold_sources(&coordinator.inner); + + assert_eq!(coordinator.inner.hold_sources.active_count(), 1); + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + + handle_released_edge(&coordinator.inner, TriggerSource::KeyboardDictation).await; + assert_eq!(coordinator.inner.hold_sources.active_count(), 0); + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + #[tokio::test] async fn begin_session_dry_run_enters_listening_and_clears_stale_edges() { let _guard = ENV_LOCK.lock().await; diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index d804ef3a..bfb3141e 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1355,6 +1355,7 @@ fn start_mouse_dictation_monitor( #[cfg(target_os = "linux")] pub(super) fn refresh_linux_evdev_monitor(inner: &Arc) { + sync_release_mouse_hold_sources(inner); inner.linux_evdev.lock().take(); try_start_linux_evdev_monitor(inner); } From 18a33b8d35006a3707d528adb6e48dd3e63dfdbc Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 19:02:44 +0800 Subject: [PATCH 10/21] fix(dictation): clear hold sources when hotkey binding changes mid-hold Co-authored-by: Cursor --- openless-all/app/src-tauri/src/coordinator.rs | 33 +++++++++++++++++++ .../src-tauri/src/coordinator/hotkey_loops.rs | 26 +++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index d4993949..1d41ff25 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1150,6 +1150,7 @@ impl Coordinator { } pub fn update_hotkey_binding(&self) { + clear_active_hold_sources_on_hotkey_rebind(&self.inner); let prefs = self.inner.prefs.get(); let dictation_trigger = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey); @@ -2331,6 +2332,38 @@ mod tests { std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); } + #[tokio::test] + async fn hold_mode_hotkey_rebind_while_holding_clears_sources_and_ends_session() { + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + { + let mut prefs = coordinator.inner.prefs.get(); + prefs.hotkey.mode = HotkeyMode::Hold; + coordinator.inner.prefs.set(prefs).unwrap(); + } + + handle_pressed_edge(&coordinator.inner, TriggerSource::MouseMiddle).await; + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + assert_eq!(coordinator.inner.hold_sources.active_count(), 1); + + { + let mut prefs = coordinator.inner.prefs.get(); + prefs.hotkey.mode = HotkeyMode::Toggle; + coordinator.inner.prefs.set(prefs).unwrap(); + } + coordinator.update_hotkey_binding(); + + assert_eq!(coordinator.inner.hold_sources.active_count(), 0); + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + #[tokio::test] async fn begin_session_dry_run_enters_listening_and_clears_stale_edges() { let _guard = ENV_LOCK.lock().await; diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index bfb3141e..04431b7c 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1250,6 +1250,32 @@ pub(super) fn sync_release_mouse_hold_sources(inner: &Arc) { }); } +/// Clears all active Hold sources when dictation hotkey/mode is rebound mid-hold. +/// Prefs may already reflect the new mode, so this must not gate on `HotkeyMode::Hold`. +pub(super) fn clear_active_hold_sources_on_hotkey_rebind(inner: &Arc) { + if inner.hold_sources.active_count() == 0 { + return; + } + inner.hold_sources.reset(); + inner.hotkey_trigger_held.store(false, Ordering::SeqCst); + let phase = inner.state.lock().phase; + let inner_clone = Arc::clone(inner); + async_runtime::block_on(async { + match phase { + SessionPhase::Listening => { + let _ = end_session(&inner_clone).await; + } + SessionPhase::Starting => { + request_stop_during_starting( + &inner_clone, + "hotkey binding changed while hold sources active", + ); + } + _ => {} + } + }); +} + pub(super) fn mouse_dictation_bridge_loop( inner: Arc, rx: mpsc::Receiver<(HotkeyEvent, TriggerSource)>, From 77a0cc9ce4d97e054bc09975b463bea40d70a08c Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 21 Jun 2026 11:25:41 +0000 Subject: [PATCH 11/21] fix: add missing HotkeyMode import in hotkey_loops.rs --- openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 04431b7c..758f198f 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -8,6 +8,7 @@ use super::*; use crate::hold_source_tracker::TriggerSource; +use crate::types::HotkeyMode; // ─────────────────────────── hotkey bridging ─────────────────────────── From b888c55a8cb3734e34ecc62570ec4d971e9dbfc0 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 21 Jun 2026 11:39:00 +0000 Subject: [PATCH 12/21] fix: avoid nested runtime panic in tests by using block_on_async helper --- .../src-tauri/src/coordinator/hotkey_loops.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 758f198f..3805d321 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -10,6 +10,20 @@ use super::*; use crate::hold_source_tracker::TriggerSource; use crate::types::HotkeyMode; +/// 在可能已有 tokio runtime 的线程上安全地执行异步块。 +/// 测试环境中(tokio::test)调用 `async_runtime::block_on` 会 panic, +/// 因此优先尝试 `Handle::current().block_on`。 +fn block_on_async(f: F) +where + F: std::future::Future, +{ + if let Ok(handle) = tokio::runtime::Handle::try_current() { + tokio::task::block_in_place(|| handle.block_on(f)); + } else { + async_runtime::block_on(f); + } +} + // ─────────────────────────── hotkey bridging ─────────────────────────── pub(super) fn hotkey_supervisor_loop(inner: Arc) { @@ -1235,7 +1249,7 @@ pub(super) fn sync_release_mouse_hold_sources(inner: &Arc) { } let phase = inner.state.lock().phase; let inner_clone = Arc::clone(inner); - async_runtime::block_on(async { + block_on_async(async { match phase { SessionPhase::Listening => { let _ = end_session(&inner_clone).await; @@ -1261,7 +1275,7 @@ pub(super) fn clear_active_hold_sources_on_hotkey_rebind(inner: &Arc) { inner.hotkey_trigger_held.store(false, Ordering::SeqCst); let phase = inner.state.lock().phase; let inner_clone = Arc::clone(inner); - async_runtime::block_on(async { + block_on_async(async { match phase { SessionPhase::Listening => { let _ = end_session(&inner_clone).await; From 5829dd89b54c0a7d49c8fc238435afff9e7ab449 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 21 Jun 2026 11:52:21 +0000 Subject: [PATCH 13/21] fix: use futures executor to avoid nested runtime in tests --- openless-all/app/src-tauri/Cargo.toml | 1 + .../src-tauri/src/coordinator/hotkey_loops.rs | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 6c3c947b..a36ebd4b 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -40,6 +40,7 @@ tar = "0.4" tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" +futures = "0.3" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream", "system-proxy"] } zip = "2" thiserror = "1" diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 3805d321..07803def 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -12,16 +12,25 @@ use crate::types::HotkeyMode; /// 在可能已有 tokio runtime 的线程上安全地执行异步块。 /// 测试环境中(tokio::test)调用 `async_runtime::block_on` 会 panic, -/// 因此优先尝试 `Handle::current().block_on`。 +/// 而 `tokio::task::block_in_place` 又要求 multi-thread runtime。 +/// 使用 `futures::executor::block_on` 作为通用回退,它不依赖 tokio。 fn block_on_async(f: F) where F: std::future::Future, { if let Ok(handle) = tokio::runtime::Handle::try_current() { - tokio::task::block_in_place(|| handle.block_on(f)); - } else { - async_runtime::block_on(f); + // 尝试用 tokio Handle 直接 block_on;单线程 runtime 上这也会 panic, + // 所以捕获 panic 并回退到 futures executor。 + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + handle.block_on(f) + })); + match result { + Ok(v) => return v, + Err(_) => {} + } } + // 回退:使用 futures executor(不依赖 tokio runtime) + futures::executor::block_on(f) } // ─────────────────────────── hotkey bridging ─────────────────────────── From 5234acfdd1e3796fbd8aac0451997d16541caf57 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 21 Jun 2026 11:58:48 +0000 Subject: [PATCH 14/21] fix: constrain block_on_async Future output to () --- .../app/src-tauri/src/coordinator/hotkey_loops.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 07803def..f7ad15f5 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -16,7 +16,7 @@ use crate::types::HotkeyMode; /// 使用 `futures::executor::block_on` 作为通用回退,它不依赖 tokio。 fn block_on_async(f: F) where - F: std::future::Future, + F: std::future::Future, { if let Ok(handle) = tokio::runtime::Handle::try_current() { // 尝试用 tokio Handle 直接 block_on;单线程 runtime 上这也会 panic, @@ -24,13 +24,12 @@ where let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { handle.block_on(f) })); - match result { - Ok(v) => return v, - Err(_) => {} + if result.is_ok() { + return; } } // 回退:使用 futures executor(不依赖 tokio runtime) - futures::executor::block_on(f) + futures::executor::block_on(f); } // ─────────────────────────── hotkey bridging ─────────────────────────── From e3a6c6a5b23902ce1651370687ba19b89a5a4b06 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 21 Jun 2026 12:05:32 +0000 Subject: [PATCH 15/21] fix: simplify block_on_async to always use futures executor --- .../app/src-tauri/src/coordinator/hotkey_loops.rs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index f7ad15f5..814f0277 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -18,17 +18,8 @@ fn block_on_async(f: F) where F: std::future::Future, { - if let Ok(handle) = tokio::runtime::Handle::try_current() { - // 尝试用 tokio Handle 直接 block_on;单线程 runtime 上这也会 panic, - // 所以捕获 panic 并回退到 futures executor。 - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - handle.block_on(f) - })); - if result.is_ok() { - return; - } - } - // 回退:使用 futures executor(不依赖 tokio runtime) + // 直接优先使用 futures executor,因为它不依赖任何 tokio runtime 状态, + // 可以安全地在已有 runtime 的线程上调用。 futures::executor::block_on(f); } From 9fb62f31dfef0b5a96bb068701a6f680f0807576 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 26 Jun 2026 01:10:15 +0800 Subject: [PATCH 16/21] fix(types): restore side-specific HotkeyTrigger after rebase Rebase dropped leftCommand/leftShift/rightShift from beta while hotkey.ts still references them; sync Cargo.lock for futures dep. Co-authored-by: Cursor --- openless-all/app/src-tauri/Cargo.lock | 18 ++++++++++++++++++ openless-all/app/src/lib/types.ts | 3 +++ 2 files changed, 21 insertions(+) diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 042d732e..2d83d41c 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -1870,6 +1870,21 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1877,6 +1892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1944,6 +1960,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -3957,6 +3974,7 @@ dependencies = [ "evdev", "ferrous-opencc", "foundry-local-sdk", + "futures", "futures-util", "getrandom 0.3.4", "global-hotkey", diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index bec65013..ac18d74f 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -81,6 +81,9 @@ export type HotkeyTrigger = | 'rightControl' | 'leftControl' | 'rightCommand' + | 'leftCommand' + | 'leftShift' + | 'rightShift' | 'fn' | 'rightAlt' | 'mediaPlayPause' From 0331833b46f66cdbe3a8df87075e868b8f0dcfe5 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 26 Jun 2026 01:19:09 +0800 Subject: [PATCH 17/21] fix(types): restore side HotkeyTrigger variants in Rust backend Rebase dropped LeftCommand/LeftShift/RightShift from beta while shortcut_binding still references them. Co-authored-by: Cursor --- openless-all/app/src-tauri/src/types.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index d8ddfb09..99646e66 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -2018,8 +2018,6 @@ impl Default for UserPreferences { android_overlay_cancel_swipe_direction: default_android_overlay_cancel_swipe_direction( ), android_overlay_size_dp: default_android_overlay_size_dp(), - mouse_middle_button_dictation: false, - mouse_side_button_dictation: false, } } } @@ -2192,6 +2190,9 @@ pub enum HotkeyTrigger { RightControl, LeftControl, RightCommand, + LeftCommand, + LeftShift, + RightShift, Fn, RightAlt, // Windows synonym for RightOption MediaPlayPause, @@ -2206,6 +2207,9 @@ impl HotkeyTrigger { HotkeyTrigger::RightControl => "右 Control", HotkeyTrigger::LeftControl => "左 Control", HotkeyTrigger::RightCommand => "右 Command", + HotkeyTrigger::LeftCommand => "左 Command", + HotkeyTrigger::LeftShift => "左 Shift", + HotkeyTrigger::RightShift => "右 Shift", HotkeyTrigger::Fn => "Fn (地球键)", HotkeyTrigger::RightAlt => "右 Alt", HotkeyTrigger::MediaPlayPause => "⏯ Media 播放/暂停", @@ -2299,6 +2303,9 @@ fn legacy_trigger_code(trigger: HotkeyTrigger) -> &'static str { HotkeyTrigger::RightControl => "ControlRight", HotkeyTrigger::LeftControl => "ControlLeft", HotkeyTrigger::RightCommand => "MetaRight", + HotkeyTrigger::LeftCommand => "MetaLeft", + HotkeyTrigger::LeftShift => "ShiftLeft", + HotkeyTrigger::RightShift => "ShiftRight", #[cfg(target_os = "windows")] HotkeyTrigger::Fn => "ControlRight", #[cfg(not(target_os = "windows"))] @@ -2419,6 +2426,9 @@ impl HotkeyCapability { HotkeyTrigger::RightControl, HotkeyTrigger::LeftControl, HotkeyTrigger::RightCommand, + HotkeyTrigger::LeftCommand, + HotkeyTrigger::LeftShift, + HotkeyTrigger::RightShift, HotkeyTrigger::Fn, HotkeyTrigger::Custom, ], @@ -2439,6 +2449,9 @@ impl HotkeyCapability { HotkeyTrigger::RightAlt, HotkeyTrigger::LeftControl, HotkeyTrigger::RightCommand, + HotkeyTrigger::LeftCommand, + HotkeyTrigger::LeftShift, + HotkeyTrigger::RightShift, HotkeyTrigger::MediaPlayPause, HotkeyTrigger::Custom, ], @@ -2461,6 +2474,9 @@ impl HotkeyCapability { HotkeyTrigger::RightAlt, HotkeyTrigger::RightControl, HotkeyTrigger::LeftControl, + HotkeyTrigger::LeftCommand, + HotkeyTrigger::LeftShift, + HotkeyTrigger::RightShift, HotkeyTrigger::Custom, ], requires_accessibility_permission: false, @@ -2468,7 +2484,8 @@ impl HotkeyCapability { supports_side_specific_modifiers: true, explicit_fallback_available: false, status_hint: Some( - "Linux 使用 fcitx5 插件监听热键和提交文字;无需桌面环境额外配置。".into(), + "Linux 使用 fcitx5 插件监听热键和提交文字。鼠标/侧别组合键需 evdev 读取 /dev/input/event*;若无权限请将用户加入 input 组(sudo usermod -aG input $USER)后重新登录。" + .into(), ), } } From 4fa244f188964ad653f93c020178a6424570db14 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 26 Jun 2026 01:25:39 +0800 Subject: [PATCH 18/21] fix(types): default mouse dictation prefs in UserPreferences Co-authored-by: Cursor --- openless-all/app/src-tauri/src/types.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 99646e66..89991da1 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -2018,6 +2018,8 @@ impl Default for UserPreferences { android_overlay_cancel_swipe_direction: default_android_overlay_cancel_swipe_direction( ), android_overlay_size_dp: default_android_overlay_size_dp(), + mouse_middle_button_dictation: false, + mouse_side_button_dictation: false, } } } From 5ac029ded057abc22229f4ce846b7cc0c51b969d Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 26 Jun 2026 01:37:19 +0800 Subject: [PATCH 19/21] fix(tests): implement refresh_mouse_dictation on settings MockWriter Co-authored-by: Cursor --- openless-all/app/src-tauri/src/commands/settings.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 168cd475..0ac4269b 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -594,6 +594,7 @@ mod persist_settings_tests { fn refresh_dictation_hotkey(&self) {} fn refresh_qa_hotkey(&self) {} fn refresh_combo_hotkey(&self) {} + fn refresh_mouse_dictation(&self) {} fn refresh_translation_hotkey(&self) {} fn refresh_switch_style_hotkey(&self) {} fn refresh_open_app_hotkey(&self) {} From 37c1f3e6c6f45e8ab22a4ae62175d3715c0ce34a Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 26 Jun 2026 01:40:27 +0800 Subject: [PATCH 20/21] ci: trigger CI workflow after test mock fix Co-authored-by: Cursor From c98c801e9727f5fc93109d685a40098e06233ec4 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 26 Jun 2026 01:45:06 +0800 Subject: [PATCH 21/21] ci: re-sync PR head for workflow run Co-authored-by: Cursor