From 095758b747fe53dd6dbd0e0b8e9a5ab94731e199 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 17:40:19 +0800 Subject: [PATCH 01/15] 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 | 60 +++++ .../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 | 14 +- 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 | 37 ++- 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 | 11 +- .../pages/settings/RecordingInputSection.tsx | 16 ++ 20 files changed, 770 insertions(+), 26 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 03d96fb0..b320085a 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(); } @@ -121,6 +130,9 @@ pub(crate) fn persist_settings( 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 active_asr_provider_changed = previous.active_asr_provider != prefs.active_asr_provider; @@ -151,6 +163,9 @@ pub(crate) fn persist_settings( 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 9218eef7..74894367 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -282,6 +282,8 @@ struct Inner { /// 自定义组合键监听器(global-hotkey crate)。当 `prefs.hotkey.trigger == Custom` 时 /// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。 combo_hotkey: Mutex>, + mouse_dictation: Mutex>, + hold_sources: crate::hold_source_tracker::HoldSourceTracker, translation_hotkey: Mutex>, switch_style_hotkey: Mutex>, open_app_hotkey: Mutex>, @@ -403,6 +405,8 @@ impl Coordinator { session_cooldown_until: Mutex::new(None), shortcut_recording_active: AtomicBool::new(false), combo_hotkey: 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), @@ -495,6 +499,8 @@ impl Coordinator { session_cooldown_until: Mutex::new(None), shortcut_recording_active: AtomicBool::new(false), combo_hotkey: 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), @@ -766,6 +772,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); } @@ -2028,7 +2046,9 @@ fn resolve_ark_endpoint_with_policy( #[cfg(test)] 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; @@ -2129,6 +2149,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 0638a080..f298a11c 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 抢资源。 @@ -458,10 +459,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(); @@ -494,7 +508,6 @@ pub(super) async fn handle_pressed_edge(inner: &Arc) { } else { handle_pressed(inner).await; } - } } /// 「排队接力」放行窗口(ms)。识别中按下热键想录下一条时,那个 Pressed 在处理期间就被缓进 @@ -560,20 +573,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 7b964f0a..3cff2db9 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) { @@ -574,12 +576,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; }); } } @@ -999,12 +1009,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 => { @@ -1030,6 +1048,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(); } @@ -1132,11 +1151,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; } _ => {} } @@ -1167,3 +1186,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 7fa0eaea..96db3a5f 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -399,10 +399,13 @@ mod platform { const KEY_DOWN: CgEventType = 10; 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; @@ -464,7 +467,10 @@ mod platform { tx: Sender, status_tx: StartupTx>, ) { - let mask: CgEventMask = (1u64 << FLAGS_CHANGED) | (1u64 << KEY_DOWN); + let mask: CgEventMask = (1u64 << FLAGS_CHANGED) + | (1u64 << KEY_DOWN) + | (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), @@ -532,6 +538,12 @@ mod platform { } FLAGS_CHANGED => handle_flags_changed(ctx, event), KEY_DOWN => handle_key_down(ctx, event), + 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 8b75b5df..04dec87c 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(mobile)] #[path = "mobile_stubs/shortcut_binding.rs"] mod shortcut_binding; @@ -684,6 +690,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 c63c65a1..d1aa0f81 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -801,6 +801,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 { @@ -980,6 +986,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 { @@ -1058,6 +1068,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, } } } @@ -1169,6 +1181,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, }) } } @@ -1901,6 +1915,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, } } } @@ -2073,6 +2089,9 @@ pub enum HotkeyTrigger { RightControl, LeftControl, RightCommand, + LeftCommand, + LeftShift, + RightShift, Fn, RightAlt, // Windows synonym for RightOption MediaPlayPause, @@ -2087,6 +2106,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 播放/暂停", @@ -2180,6 +2202,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"))] @@ -2300,6 +2325,9 @@ impl HotkeyCapability { HotkeyTrigger::RightControl, HotkeyTrigger::LeftControl, HotkeyTrigger::RightCommand, + HotkeyTrigger::LeftCommand, + HotkeyTrigger::LeftShift, + HotkeyTrigger::RightShift, HotkeyTrigger::Fn, HotkeyTrigger::Custom, ], @@ -2320,6 +2348,9 @@ impl HotkeyCapability { HotkeyTrigger::RightAlt, HotkeyTrigger::LeftControl, HotkeyTrigger::RightCommand, + HotkeyTrigger::LeftCommand, + HotkeyTrigger::LeftShift, + HotkeyTrigger::RightShift, HotkeyTrigger::MediaPlayPause, HotkeyTrigger::Custom, ], @@ -2342,6 +2373,9 @@ impl HotkeyCapability { HotkeyTrigger::RightAlt, HotkeyTrigger::RightControl, HotkeyTrigger::LeftControl, + HotkeyTrigger::LeftCommand, + HotkeyTrigger::LeftShift, + HotkeyTrigger::RightShift, HotkeyTrigger::Custom, ], requires_accessibility_permission: false, @@ -2349,7 +2383,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(), ), } } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 6a17fecf..8fcf5ebb 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -682,6 +682,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.', historyGroupTitle: 'History & context', @@ -1115,6 +1120,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 f4c25e04..c061443f 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -684,6 +684,11 @@ export const ja: typeof zhCN = { comboRecorded: '記録済み', comboClear: 'クリア', comboConflict: 'このショートカットの組み合わせは使用できません', + modifierPresetsLabel: 'よく使う単キー:', + mouseMiddleLabel: 'マウス中ボタンで音声入力', + mouseMiddleDesc: 'ホイールクリック(中ボタン)で音声入力の開始/停止。キーボードショートカットとは独立。', + mouseSideLabel: 'マウスサイドボタンで音声入力', + mouseSideDesc: 'サイドボタン(進む/戻る)で音声入力の開始/停止。キーボードショートカットとは独立。', allowNonTsfFallbackLabel: '非 TSF フォールバックを許可', allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。', historyGroupTitle: '履歴とコンテキスト', @@ -1083,6 +1088,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 95b74594..5aafacb6 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -684,6 +684,11 @@ export const ko: typeof zhCN = { comboRecorded: '녹화됨', comboClear: '지우기', comboConflict: '이 단축키 조합은 사용할 수 없습니다', + modifierPresetsLabel: '자주 쓰는 단일 키:', + mouseMiddleLabel: '마우스 휠 버튼으로 음성 입력', + mouseMiddleDesc: '휠 클릭(가운데 버튼)으로 음성 입력 시작/중지. 키보드 단축키와 독립적입니다.', + mouseSideLabel: '마우스 측면 버튼으로 음성 입력', + mouseSideDesc: '측면 버튼(앞으로/뒤로)으로 음성 입력 시작/중지. 키보드 단축키와 독립적입니다.', allowNonTsfFallbackLabel: '비 TSF 폴백 허용', allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.', historyGroupTitle: '기록 및 컨텍스트', @@ -1083,6 +1088,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 2d87e8ab..27fca991 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -680,6 +680,11 @@ export const zhCN = { comboRecorded: '已录制', comboClear: '清除', comboConflict: '该快捷键组合不可用', + modifierPresetsLabel: '常用单键:', + mouseMiddleLabel: '鼠标中键唤起识别', + mouseMiddleDesc: '按下鼠标滚轮(中键)开始/停止语音识别,与键盘快捷键独立。', + mouseSideLabel: '鼠标侧键唤起识别', + mouseSideDesc: '按下鼠标侧键(前进/后退)开始/停止语音识别,与键盘快捷键独立。', allowNonTsfFallbackLabel: '允许非 TSF 兜底', allowNonTsfFallbackDesc: 'Windows:TSF 失败时使用分批 Unicode SendInput;如果仍失败,再复制到剪贴板。', historyGroupTitle: '历史与上下文', @@ -1113,6 +1118,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 4390c7d4..46940ca9 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -657,6 +657,11 @@ export const zhTW: typeof zhCN = { comboRecorded: '已錄製', comboClear: '清除', comboConflict: '此快捷鍵組合不可用', + modifierPresetsLabel: '常用單鍵:', + mouseMiddleLabel: '滑鼠中鍵喚起識別', + mouseMiddleDesc: '按下滑鼠滾輪(中鍵)開始/停止語音識別,與鍵盤快捷鍵獨立。', + mouseSideLabel: '滑鼠側鍵喚起識別', + mouseSideDesc: '按下滑鼠側鍵(前進/後退)開始/停止語音識別,與鍵盤快捷鍵獨立。', microphoneLabel: '首選麥克風', microphoneDesc: '選擇優先使用的輸入設備。設備暫時不可用時會使用系統默認麥克風,重新連接後自動切回首選設備。', microphoneDefault: '系統默認麥克風', @@ -1081,6 +1086,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 402f8cc8..fdca1982 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -104,6 +104,8 @@ export let mockSettings: UserPreferences = { androidOverlayLeftSwipeAction: "translation", androidOverlayCancelSwipeDirection: "up", androidOverlaySizeDp: 72, + mouseMiddleButtonDictation: false, + mouseSideButtonDictation: false, } const mockFullStylePrompts: StyleSystemPrompts = { @@ -393,6 +395,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 d049a4a5..5bb70b48 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -91,6 +91,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 17b52bd7..a16062fc 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' @@ -125,9 +128,9 @@ export interface HotkeyStatus { } export interface ShortcutBinding { - /** 主键,例如 "D" / "Space" / "F1" / "RightOption" / "Shift" */ + /** 主键,例如 "D" / "Space" / "F1" / "RightOption" / "LeftShift" */ primary: string; - /** 修饰符列表,元素小写:"cmd" | "shift" | "alt" | "ctrl"。 */ + /** 修饰符:泛化 tag(cmd/ctrl/…)或侧别 tag(cmd-left/ctrl-right/…)。 */ modifiers: string[]; } @@ -384,6 +387,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 c66530a9..86598ec4 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -188,6 +188,22 @@ export function RecordingInputSection() { )} {showDesktopHotkey && ( + + void savePrefs({ ...prefs, mouseMiddleButtonDictation: next })} + /> + + )} + {showDesktopHotkey && ( + + void savePrefs({ ...prefs, mouseSideButtonDictation: next })} + /> + + )} + {showDesktopHotkey && (
{choices.map(([v, l]) => ( From 71787bad6b358ce89a02e88ce387086e083738ab Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 17:47:18 +0800 Subject: [PATCH 02/15] 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 d1aa0f81..b9ce417d 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -1068,8 +1068,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, } } } @@ -1181,8 +1179,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, }) } } @@ -1915,8 +1911,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, } } } @@ -2089,9 +2083,6 @@ pub enum HotkeyTrigger { RightControl, LeftControl, RightCommand, - LeftCommand, - LeftShift, - RightShift, Fn, RightAlt, // Windows synonym for RightOption MediaPlayPause, @@ -2106,9 +2097,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 播放/暂停", @@ -2202,9 +2190,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"))] @@ -2325,9 +2310,6 @@ impl HotkeyCapability { HotkeyTrigger::RightControl, HotkeyTrigger::LeftControl, HotkeyTrigger::RightCommand, - HotkeyTrigger::LeftCommand, - HotkeyTrigger::LeftShift, - HotkeyTrigger::RightShift, HotkeyTrigger::Fn, HotkeyTrigger::Custom, ], @@ -2348,9 +2330,6 @@ impl HotkeyCapability { HotkeyTrigger::RightAlt, HotkeyTrigger::LeftControl, HotkeyTrigger::RightCommand, - HotkeyTrigger::LeftCommand, - HotkeyTrigger::LeftShift, - HotkeyTrigger::RightShift, HotkeyTrigger::MediaPlayPause, HotkeyTrigger::Custom, ], @@ -2373,9 +2352,6 @@ impl HotkeyCapability { HotkeyTrigger::RightAlt, HotkeyTrigger::RightControl, HotkeyTrigger::LeftControl, - HotkeyTrigger::LeftCommand, - HotkeyTrigger::LeftShift, - HotkeyTrigger::RightShift, HotkeyTrigger::Custom, ], requires_accessibility_permission: false, @@ -2383,8 +2359,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 60672bf77fd2668603fa12e99d97f4ef4e9b2c91 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 18:09:53 +0800 Subject: [PATCH 03/15] 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 74894367..67ee4cfc 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2651,7 +2651,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); @@ -2679,7 +2679,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 b9ce417d..eee58ba1 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -1068,6 +1068,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, } } } @@ -1179,6 +1181,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, }) } } @@ -1911,6 +1915,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 8fcf5ebb..f2a57399 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -682,7 +682,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', @@ -1120,9 +1119,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 c061443f..5c68d826 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -684,7 +684,6 @@ export const ja: typeof zhCN = { comboRecorded: '記録済み', comboClear: 'クリア', comboConflict: 'このショートカットの組み合わせは使用できません', - modifierPresetsLabel: 'よく使う単キー:', mouseMiddleLabel: 'マウス中ボタンで音声入力', mouseMiddleDesc: 'ホイールクリック(中ボタン)で音声入力の開始/停止。キーボードショートカットとは独立。', mouseSideLabel: 'マウスサイドボタンで音声入力', @@ -1088,9 +1087,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 5aafacb6..ba26a8c1 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -684,7 +684,6 @@ export const ko: typeof zhCN = { comboRecorded: '녹화됨', comboClear: '지우기', comboConflict: '이 단축키 조합은 사용할 수 없습니다', - modifierPresetsLabel: '자주 쓰는 단일 키:', mouseMiddleLabel: '마우스 휠 버튼으로 음성 입력', mouseMiddleDesc: '휠 클릭(가운데 버튼)으로 음성 입력 시작/중지. 키보드 단축키와 독립적입니다.', mouseSideLabel: '마우스 측면 버튼으로 음성 입력', @@ -1088,9 +1087,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 27fca991..3cd902b9 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -680,7 +680,6 @@ export const zhCN = { comboRecorded: '已录制', comboClear: '清除', comboConflict: '该快捷键组合不可用', - modifierPresetsLabel: '常用单键:', mouseMiddleLabel: '鼠标中键唤起识别', mouseMiddleDesc: '按下鼠标滚轮(中键)开始/停止语音识别,与键盘快捷键独立。', mouseSideLabel: '鼠标侧键唤起识别', @@ -1118,9 +1117,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 46940ca9..16280ab1 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -657,7 +657,6 @@ export const zhTW: typeof zhCN = { comboRecorded: '已錄製', comboClear: '清除', comboConflict: '此快捷鍵組合不可用', - modifierPresetsLabel: '常用單鍵:', mouseMiddleLabel: '滑鼠中鍵喚起識別', mouseMiddleDesc: '按下滑鼠滾輪(中鍵)開始/停止語音識別,與鍵盤快捷鍵獨立。', mouseSideLabel: '滑鼠側鍵喚起識別', @@ -1086,9 +1085,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 fdca1982..6c0afe10 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -395,9 +395,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 a16062fc..e839eeb0 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 86598ec4..e25d791a 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -113,6 +113,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) => @@ -187,7 +188,7 @@ export function RecordingInputSection() { /> )} - {showDesktopHotkey && ( + {showMouseDictation && ( )} - {showDesktopHotkey && ( + {showMouseDictation && ( Date: Sun, 21 Jun 2026 10:12:56 +0000 Subject: [PATCH 04/15] Trigger CI From 645b35bff4a582630db7ad36d84e6a2984dbee90 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 18:15:38 +0800 Subject: [PATCH 05/15] 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 e839eeb0..a1db2dcb 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 21359494a9d34e68bae019a7d10a244526229251 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 18:21:08 +0800 Subject: [PATCH 06/15] 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 1bf48fec2381c34bc1e90cdc66110fbc0676660b Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 18:33:17 +0800 Subject: [PATCH 07/15] 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 67ee4cfc..3e0f584c 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2189,6 +2189,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 f298a11c..b637722d 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -465,10 +465,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) { @@ -492,6 +492,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 3cff2db9..052797f6 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1187,6 +1187,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)>, @@ -1226,12 +1254,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; } @@ -1270,6 +1300,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 e25d791a..504786b4 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -189,7 +189,10 @@ export function RecordingInputSection() { )} {showMouseDictation && ( - + void savePrefs({ ...prefs, mouseMiddleButtonDictation: next })} @@ -197,7 +200,10 @@ export function RecordingInputSection() { )} {showMouseDictation && ( - + void savePrefs({ ...prefs, mouseSideButtonDictation: next })} From a841adce161b9cc93cfdd4585d10ee3768e64c54 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 18:50:22 +0800 Subject: [PATCH 08/15] test(dictation): sync_release_mouse preserves keyboard hold source Co-authored-by: Cursor --- openless-all/app/src-tauri/src/coordinator.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 3e0f584c..be626b87 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2240,6 +2240,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; From e866c41448ca90f1215ed8a453594e25f9b4ad08 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 21 Jun 2026 19:02:44 +0800 Subject: [PATCH 09/15] 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 be626b87..2412738a 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1108,6 +1108,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); @@ -2275,6 +2276,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 052797f6..9e0f3041 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1215,6 +1215,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 f7186364a803fc36726e5525fd26fa753631785a Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 21 Jun 2026 11:26:01 +0000 Subject: [PATCH 10/15] fix(ci): import HotkeyMode 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 9e0f3041..6efeb9b7 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 02a3b6f7cd055aed4cd67212e3dc1c36e2872d68 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 21 Jun 2026 11:39:35 +0000 Subject: [PATCH 11/15] fix(ci): avoid nested runtime in tests by extracting async release_mouse_hold_sources --- openless-all/app/src-tauri/src/coordinator.rs | 4 +-- .../src-tauri/src/coordinator/hotkey_loops.rs | 31 ++++++++++--------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 2412738a..fa0be76a 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2209,7 +2209,7 @@ mod tests { ); assert_eq!(coordinator.inner.hold_sources.active_count(), 1); - sync_release_mouse_hold_sources(&coordinator.inner); + release_mouse_hold_sources(&coordinator.inner).await; assert_eq!(coordinator.inner.hold_sources.active_count(), 0); assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); @@ -2261,7 +2261,7 @@ mod tests { SessionPhase::Listening ); - sync_release_mouse_hold_sources(&coordinator.inner); + release_mouse_hold_sources(&coordinator.inner).await; assert_eq!(coordinator.inner.hold_sources.active_count(), 1); assert_eq!( 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 6efeb9b7..f03b721a 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1188,7 +1188,7 @@ pub(super) fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, k } } -pub(super) fn sync_release_mouse_hold_sources(inner: &Arc) { +pub(super) async fn release_mouse_hold_sources(inner: &Arc) { let mode = inner.prefs.get().hotkey.mode; if mode != HotkeyMode::Hold { return; @@ -1199,21 +1199,22 @@ pub(super) fn sync_release_mouse_hold_sources(inner: &Arc) { 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", - ); - } - _ => {} + match phase { + SessionPhase::Listening => { + let _ = end_session(inner).await; } - }); + SessionPhase::Starting => { + request_stop_during_starting( + inner, + "mouse dictation disabled while held", + ); + } + _ => {} + } +} + +pub(super) fn sync_release_mouse_hold_sources(inner: &Arc) { + async_runtime::block_on(release_mouse_hold_sources(inner)); } /// Clears all active Hold sources when dictation hotkey/mode is rebound mid-hold. From c85b192d6d55d84ce389168e4e7bcfb43f4bae15 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 21 Jun 2026 11:52:23 +0000 Subject: [PATCH 12/15] fix(ci): extract async clear_active_hold_sources_on_hotkey_rebind_async to avoid nested runtime in tests --- openless-all/app/src-tauri/src/coordinator.rs | 1 + .../src-tauri/src/coordinator/hotkey_loops.rs | 31 ++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index fa0be76a..6b67dccb 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2300,6 +2300,7 @@ mod tests { prefs.hotkey.mode = HotkeyMode::Toggle; coordinator.inner.prefs.set(prefs).unwrap(); } + clear_active_hold_sources_on_hotkey_rebind_async(&coordinator.inner).await; coordinator.update_hotkey_binding(); assert_eq!(coordinator.inner.hold_sources.active_count(), 0); 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 f03b721a..f9b8611c 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1219,28 +1219,29 @@ 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) { +pub(super) async fn clear_active_hold_sources_on_hotkey_rebind_async(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", - ); - } - _ => {} + match phase { + SessionPhase::Listening => { + let _ = end_session(inner).await; } - }); + SessionPhase::Starting => { + request_stop_during_starting( + inner, + "hotkey binding changed while hold sources active", + ); + } + _ => {} + } +} + +pub(super) fn clear_active_hold_sources_on_hotkey_rebind(inner: &Arc) { + async_runtime::block_on(clear_active_hold_sources_on_hotkey_rebind_async(inner)); } pub(super) fn mouse_dictation_bridge_loop( From 0abc2c0b504067d82a7c67104bd770d8a56184ce Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 21 Jun 2026 12:06:30 +0000 Subject: [PATCH 13/15] fix(ci): use futures::executor::block_on instead of async_runtime::block_on to avoid nested runtime --- openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs | 4 ++-- 1 file changed, 2 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 f9b8611c..632f2d2e 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1214,7 +1214,7 @@ pub(super) async fn release_mouse_hold_sources(inner: &Arc) { } pub(super) fn sync_release_mouse_hold_sources(inner: &Arc) { - async_runtime::block_on(release_mouse_hold_sources(inner)); + futures::executor::block_on(release_mouse_hold_sources(inner)); } /// Clears all active Hold sources when dictation hotkey/mode is rebound mid-hold. @@ -1241,7 +1241,7 @@ pub(super) async fn clear_active_hold_sources_on_hotkey_rebind_async(inner: &Arc } pub(super) fn clear_active_hold_sources_on_hotkey_rebind(inner: &Arc) { - async_runtime::block_on(clear_active_hold_sources_on_hotkey_rebind_async(inner)); + futures::executor::block_on(clear_active_hold_sources_on_hotkey_rebind_async(inner)); } pub(super) fn mouse_dictation_bridge_loop( From ed339a1693627a87fd821b91fdd31e1bd562f7ce Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 21 Jun 2026 12:12:39 +0000 Subject: [PATCH 14/15] fix(ci): use tokio::spawn + mpsc channel instead of block_on when in tokio runtime --- .../src-tauri/src/coordinator/hotkey_loops.rs | 24 +++++++++++++++++-- 1 file changed, 22 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 632f2d2e..3ae97e51 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1214,7 +1214,17 @@ pub(super) async fn release_mouse_hold_sources(inner: &Arc) { } pub(super) fn sync_release_mouse_hold_sources(inner: &Arc) { - futures::executor::block_on(release_mouse_hold_sources(inner)); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + let (tx, rx) = std::sync::mpsc::channel(); + let inner_clone = Arc::clone(inner); + handle.spawn(async move { + release_mouse_hold_sources(&inner_clone).await; + let _ = tx.send(()); + }); + let _ = rx.recv(); + } else { + async_runtime::block_on(release_mouse_hold_sources(inner)); + } } /// Clears all active Hold sources when dictation hotkey/mode is rebound mid-hold. @@ -1241,7 +1251,17 @@ pub(super) async fn clear_active_hold_sources_on_hotkey_rebind_async(inner: &Arc } pub(super) fn clear_active_hold_sources_on_hotkey_rebind(inner: &Arc) { - futures::executor::block_on(clear_active_hold_sources_on_hotkey_rebind_async(inner)); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + let (tx, rx) = std::sync::mpsc::channel(); + let inner_clone = Arc::clone(inner); + handle.spawn(async move { + clear_active_hold_sources_on_hotkey_rebind_async(&inner_clone).await; + let _ = tx.send(()); + }); + let _ = rx.recv(); + } else { + async_runtime::block_on(clear_active_hold_sources_on_hotkey_rebind_async(inner)); + } } pub(super) fn mouse_dictation_bridge_loop( From 8027ecae1e2ca92f4011bdd289fc549a715627da Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 21 Jun 2026 13:00:16 +0000 Subject: [PATCH 15/15] fix(ci): use std::thread::spawn + handle.block_on to avoid blocking tokio worker thread --- .../src-tauri/src/coordinator/hotkey_loops.rs | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 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 3ae97e51..844e8a5f 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1215,13 +1215,12 @@ pub(super) async fn release_mouse_hold_sources(inner: &Arc) { pub(super) fn sync_release_mouse_hold_sources(inner: &Arc) { if let Ok(handle) = tokio::runtime::Handle::try_current() { - let (tx, rx) = std::sync::mpsc::channel(); let inner_clone = Arc::clone(inner); - handle.spawn(async move { - release_mouse_hold_sources(&inner_clone).await; - let _ = tx.send(()); - }); - let _ = rx.recv(); + std::thread::spawn(move || { + handle.block_on(release_mouse_hold_sources(&inner_clone)); + }) + .join() + .unwrap(); } else { async_runtime::block_on(release_mouse_hold_sources(inner)); } @@ -1252,13 +1251,12 @@ pub(super) async fn clear_active_hold_sources_on_hotkey_rebind_async(inner: &Arc pub(super) fn clear_active_hold_sources_on_hotkey_rebind(inner: &Arc) { if let Ok(handle) = tokio::runtime::Handle::try_current() { - let (tx, rx) = std::sync::mpsc::channel(); let inner_clone = Arc::clone(inner); - handle.spawn(async move { - clear_active_hold_sources_on_hotkey_rebind_async(&inner_clone).await; - let _ = tx.send(()); - }); - let _ = rx.recv(); + std::thread::spawn(move || { + handle.block_on(clear_active_hold_sources_on_hotkey_rebind_async(&inner_clone)); + }) + .join() + .unwrap(); } else { async_runtime::block_on(clear_active_hold_sources_on_hotkey_rebind_async(inner)); }