From edcdda76e3a0658ec8372f943e00be55e003de2c Mon Sep 17 00:00:00 2001 From: jisongniu <529058747@qq.com> Date: Thu, 23 Jul 2026 15:47:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(hotkey):=20Esc=20=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E6=94=B9=E8=B5=B0=E7=8B=AC=E7=AB=8B=E9=80=9A=E9=81=93=E2=80=94?= =?UTF-8?q?=E2=80=94=E4=BF=AE=E5=A4=8D=E8=BD=AC=E5=86=99/=E6=B6=A6?= =?UTF-8?q?=E8=89=B2=E6=9C=9F=E9=97=B4=E6=8C=89=20Esc=20=E5=81=9C=E4=B8=8D?= =?UTF-8?q?=E4=B8=8B=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #798 给 end_session 加了「转写 vs 取消」的 select! 赛跑,但取消信号本身 送不进来:hotkey bridge 线程为修 #468/#475 的 latch 竞态改成了串行 block_on,Hold 松手后 end_session 在 bridge 线程上同步跑完整段转写 + 润色,期间 Esc 的 HotkeyEvent::Cancelled 只能在同一条 channel 里排队。 流程跑完后才被取出,此时 phase 已回 Idle,cancel 变 no-op —— 用户按 Esc 毫无反应,必须干等转写完成。听写键为修饰键(默认 fn 等)时必现; 组合键用户不受影响(按键事件走 ComboHotkeyMonitor 的独立线程),这也是 #798 验证时没暴露的原因。 修法:把 Cancelled 从 HotkeyEvent 枚举拆出,Esc 在 tap/hook 回调里改发 独立的 Sender<()>,由专用 esc-cancel-bridge 线程消费并调 cancel_session (纯同步快路径,不 await)。cancelled 旗标即时置位,end_session 的 select! 赛跑与润色流的 should_cancel 立即命中。macOS CGEventTap 与 Windows WH_KEYBOARD_LL 同步修改;Less Computer 修饰键 monitor 的 Esc 转发同样迁移。 Co-Authored-By: Claude Fable 5 --- openless-all/app/src-tauri/src/coordinator.rs | 3 +- .../src-tauri/src/coordinator/hotkey_loops.rs | 40 ++++++++++-- openless-all/app/src-tauri/src/hotkey.rs | 63 +++++++++++++++---- .../app/src-tauri/src/mobile_stubs/hotkey.rs | 2 +- 4 files changed, 87 insertions(+), 21 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 868f8c97c..7b118b69a 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1443,7 +1443,8 @@ impl Coordinator { let (tx, rx) = mpsc::channel::(); #[cfg(target_os = "linux")] let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone()); - match HotkeyMonitor::start(binding, tx) { + let cancel_tx = spawn_esc_cancel_bridge(&self.inner); + match HotkeyMonitor::start(binding, tx, cancel_tx) { Ok(monitor) => { let adapter = monitor.kind(); *self.inner.hotkey.lock() = Some(monitor); 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 9cca912cb..804eb6f0a 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -9,6 +9,32 @@ use super::*; // ─────────────────────────── hotkey bridging ─────────────────────────── +/// Esc 取消专用消费线程。为什么不并入 `hotkey_bridge_loop`:bridge 为修 #468/#475 +/// 的 latch 竞态把 Pressed/Released 改成了串行 block_on —— Hold 松手后 `end_session` +/// 会在 bridge 线程上同步跑完整段转写 + 润色,期间 bridge 无法 recv。若 Esc 与其同 +/// 队列,取消事件只能排队等流程跑完(此时 phase 已回 Idle,cancel 变 no-op),#798 +/// 在 `end_session` 里的 select! 取消赛跑永远等不到 `cancelled` 旗标 ——「转写 / 润色 +/// 中按 Esc 停不下来」。独立通道 + 本线程保证 `cancel_session` 随到随执行(它是纯同步 +/// 快路径:置旗标 + 清资源,不 await)。 +pub(super) fn esc_cancel_bridge_loop(inner: Arc, rx: mpsc::Receiver<()>) { + while rx.recv().is_ok() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + cancel_session(&inner); + } +} + +pub(super) fn spawn_esc_cancel_bridge(inner: &Arc) -> mpsc::Sender<()> { + let (cancel_tx, cancel_rx) = mpsc::channel::<()>(); + let bridge_inner = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-esc-cancel-bridge".into()) + .spawn(move || esc_cancel_bridge_loop(bridge_inner, cancel_rx)) + .ok(); + cancel_tx +} + pub(super) fn hotkey_supervisor_loop(inner: Arc) { let mut attempts: u32 = 0; let capability = HotkeyMonitor::capability(); @@ -54,7 +80,8 @@ pub(super) fn hotkey_supervisor_loop(inner: Arc) { let (tx, rx) = mpsc::channel::(); #[cfg(target_os = "linux")] let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone()); - match HotkeyMonitor::start(binding, tx) { + let cancel_tx = spawn_esc_cancel_bridge(&inner); + match HotkeyMonitor::start(binding, tx, cancel_tx) { Ok(monitor) => { let adapter = monitor.kind(); *inner.hotkey.lock() = Some(monitor); @@ -278,7 +305,10 @@ pub(super) fn update_coding_agent_hotkey_binding_now(inner: &Arc) { return; } let (tx, rx) = mpsc::channel::(); - match HotkeyMonitor::start(modifier_binding, tx) { + // Less Computer 的独立 tap 也转发 Esc 取消(与主 monitor 双保险; + // cancel_session 幂等,重复触发无害)。 + let cancel_tx = spawn_esc_cancel_bridge(inner); + match HotkeyMonitor::start(modifier_binding, tx, cancel_tx) { Ok(monitor) => { *inner.coding_agent_modifier_hotkey.lock() = Some(monitor); log::info!( @@ -362,7 +392,6 @@ pub(super) fn less_computer_modifier_bridge_loop(inner: Arc, rx: mpsc::Re handle_less_computer_released(&inner_cloned).await }); } - HotkeyEvent::Cancelled => cancel_session(&inner_cloned), HotkeyEvent::TranslationModifierPressed | HotkeyEvent::QaShortcutPressed => {} } } @@ -1036,9 +1065,8 @@ pub(super) fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver { - cancel_session(&inner_cloned); - } + // Esc 取消不在此枚举里:走独立的 esc_cancel_bridge_loop,避免被上面 + // Released → end_session 的同步转写流程堵在队列里(见该函数注释)。 HotkeyEvent::TranslationModifierPressed => { let translation_hotkey = inner_cloned.prefs.get().translation_hotkey; if is_builtin_translation_shift(&translation_hotkey) diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index abc66fb9a..45a5bf3da 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -8,6 +8,12 @@ //! - Linux:fcitx5 插件提供热键事件(DBus 信号 `DictationKeyEvent`)。 //! //! 仅产出"边沿"事件,toggle vs hold 由 Coordinator 解释。 +//! +//! Esc(取消)**不走** `HotkeyEvent` 通道,而是独立的 `Sender<()>`:Pressed/Released +//! 的 bridge 线程为了修 #468/#475 的 latch 竞态改成了串行 block_on,Released 会在 +//! bridge 线程上同步跑完整个转写 + 润色流程 —— 若 Esc 与它们同队列,Processing 期间 +//! 的取消事件只能排队等流程跑完,#798 的 select! 取消赛跑永远等不到 cancelled 旗标 +//! (「转写中按 Esc 停不下来」)。独立通道 + 专用消费线程保证取消随到随处理。 use std::sync::atomic::AtomicBool; use std::sync::mpsc::{self, Sender}; @@ -23,7 +29,6 @@ use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyCapability, HotkeyIns pub enum HotkeyEvent { Pressed { at: Instant }, Released { at: Instant }, - Cancelled, /// Shift(或未来配置项指定的修饰键)按下边沿。可在录音过程中任何时刻产生; /// 上层据此切换到翻译输出管线。详见 issue #4。 TranslationModifierPressed, @@ -131,12 +136,16 @@ impl HotkeyMonitor { /// Spawn the listener thread and **wait synchronously** for it to confirm /// the OS-level hook installed so the caller can surface an actual adapter /// status instead of silently dropping events. + /// + /// `cancel_tx`:Esc 按下即发一个 `()`。独立于 `tx`,见模块注释——不能与 + /// Pressed/Released 挤同一条串行 bridge,否则 Processing 期间取消排不上队。 pub fn start( binding: HotkeyBinding, tx: Sender, + cancel_tx: Sender<()>, ) -> Result { Ok(Self { - adapter: platform::start_adapter(binding, tx)?, + adapter: platform::start_adapter(binding, tx, cancel_tx)?, }) } @@ -185,6 +194,12 @@ fn send_or_log(tx: &Sender, evt: HotkeyEvent) { } } +fn send_cancel_or_log(tx: &Sender<()>) { + if let Err(e) = tx.send(()) { + log::warn!("[hotkey] 取消事件发送失败: {e}"); + } +} + type StartupTx = mpsc::Sender>; struct ListenerThread { @@ -195,13 +210,14 @@ struct ListenerThread { fn start_listener_thread( binding: HotkeyBinding, tx: Sender, + cancel_tx: Sender<()>, thread_name: &str, startup_timeout_message: &'static str, run_listen_loop: F, ) -> Result, HotkeyInstallError> where T: Send + 'static, - F: FnOnce(Arc, Sender, StartupTx) + Send + 'static, + F: FnOnce(Arc, Sender, Sender<()>, StartupTx) + Send + 'static, { let shared = Arc::new(Shared { binding: RwLock::new(binding), @@ -217,7 +233,7 @@ where let (status_tx, status_rx) = mpsc::channel::>(); std::thread::Builder::new() .name(thread_name.into()) - .spawn(move || run_listen_loop(thread_shared, tx, status_tx)) + .spawn(move || run_listen_loop(thread_shared, tx, cancel_tx, status_tx)) .map_err(|e| install_error("spawn_failed", format!("hotkey 线程启动失败: {e}")))?; match status_rx.recv_timeout(Duration::from_secs(3)) { @@ -284,19 +300,21 @@ mod platform { use std::sync::Arc; use super::{ - install_error, reset_shared_held_state, send_or_log, start_listener_thread, - update_shared_binding, update_shared_modifier_shortcuts, HotkeyAdapter, HotkeyEvent, - Shared, StartupTx, + install_error, reset_shared_held_state, send_cancel_or_log, send_or_log, + start_listener_thread, update_shared_binding, update_shared_modifier_shortcuts, + HotkeyAdapter, HotkeyEvent, Shared, StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; pub fn start_adapter( binding: HotkeyBinding, tx: Sender, + cancel_tx: Sender<()>, ) -> Result, HotkeyInstallError> { let listener = start_listener_thread( binding, tx, + cancel_tx, "openless-hotkey-mac-event-tap", "hotkey hook 启动超时", run_listen_loop, @@ -452,6 +470,8 @@ mod platform { struct CallbackContext { shared: Arc, tx: Sender, + /// Esc 专用通道,见模块注释——不与 tx 挤同一条串行 bridge。 + cancel_tx: Sender<()>, /// 与 MacHotkeyAdapter 共享的 (tap, runloop) refs。tap re-enable on /// TAP_DISABLED_BY_TIMEOUT 走 handles.tap;adapter shutdown 也走这两个 lock。 handles: Arc, @@ -463,6 +483,7 @@ mod platform { fn run_listen_loop( shared: Arc, tx: Sender, + cancel_tx: Sender<()>, status_tx: StartupTx>, ) { let mask: CgEventMask = (1u64 << FLAGS_CHANGED) @@ -475,6 +496,7 @@ mod platform { let context = Box::into_raw(Box::new(CallbackContext { shared, tx, + cancel_tx, handles: Arc::clone(&handles), })); @@ -632,7 +654,7 @@ mod platform { fn handle_key_down(ctx: &CallbackContext, event: CgEventRef) { let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; if keycode == ESC_KEYCODE { - send_or_log(&ctx.tx, HotkeyEvent::Cancelled); + send_cancel_or_log(&ctx.cancel_tx); } } @@ -691,10 +713,12 @@ mod platform { fn callback_context(shared: Arc) -> (CallbackContext, mpsc::Receiver) { let (tx, rx) = mpsc::channel(); + let (cancel_tx, _cancel_rx) = mpsc::channel(); ( CallbackContext { shared, tx, + cancel_tx, handles: Arc::new(MacShutdownHandles { tap: std::sync::Mutex::new(None), runloop: std::sync::Mutex::new(None), @@ -783,9 +807,9 @@ mod platform { }; use super::{ - install_error, reset_shared_held_state, send_or_log, start_listener_thread, - update_shared_binding, update_shared_modifier_shortcuts, HotkeyAdapter, HotkeyEvent, - Shared, StartupTx, + install_error, reset_shared_held_state, send_cancel_or_log, send_or_log, + start_listener_thread, update_shared_binding, update_shared_modifier_shortcuts, + HotkeyAdapter, HotkeyEvent, Shared, StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; @@ -813,10 +837,12 @@ mod platform { pub fn start_adapter( binding: HotkeyBinding, tx: Sender, + cancel_tx: Sender<()>, ) -> Result, HotkeyInstallError> { let listener = start_listener_thread( binding, tx, + cancel_tx, "openless-hotkey-win-ll-hook", "Windows hotkey hook 启动超时", run_listen_loop, @@ -866,17 +892,25 @@ mod platform { struct CallbackContext { shared: Arc, tx: Sender, + /// Esc 专用通道,见模块注释——不与 tx 挤同一条串行 bridge。 + cancel_tx: Sender<()>, hook: std::sync::Mutex>, } unsafe impl Send for CallbackContext {} unsafe impl Sync for CallbackContext {} - fn run_listen_loop(shared: Arc, tx: Sender, status_tx: StartupTx) { + fn run_listen_loop( + shared: Arc, + tx: Sender, + cancel_tx: Sender<()>, + status_tx: StartupTx, + ) { let thread_id = unsafe { GetCurrentThreadId() }; let context = Box::into_raw(Box::new(CallbackContext { shared, tx, + cancel_tx, hook: std::sync::Mutex::new(None), })); HOOK_CONTEXT.store(context, AtomicOrdering::SeqCst); @@ -953,7 +987,7 @@ mod platform { fn dispatch_keyboard_event(ctx: &CallbackContext, vk_code: u32, message: usize) -> bool { if vk_code == VK_ESCAPE && (message == WM_KEYDOWN || message == WM_SYSKEYDOWN) { - send_or_log(&ctx.tx, HotkeyEvent::Cancelled); + send_cancel_or_log(&ctx.cancel_tx); return false; } @@ -1103,10 +1137,12 @@ mod platform { fn callback_context(shared: Arc) -> (CallbackContext, mpsc::Receiver) { let (tx, rx) = mpsc::channel(); + let (cancel_tx, _cancel_rx) = mpsc::channel(); ( CallbackContext { shared, tx, + cancel_tx, hook: std::sync::Mutex::new(None), }, rx, @@ -1276,6 +1312,7 @@ mod platform { pub fn start_adapter( _binding: HotkeyBinding, _tx: Sender, + _cancel_tx: Sender<()>, ) -> Result, HotkeyInstallError> { log::info!("[hotkey] Linux — fcitx5 plugin handles hotkeys"); Ok(Box::new(PlaceholderAdapter { _tx })) diff --git a/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs b/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs index ff2b44b88..60ce10351 100644 --- a/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs +++ b/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs @@ -11,7 +11,6 @@ use crate::types::{ pub enum HotkeyEvent { Pressed { at: Instant }, Released { at: Instant }, - Cancelled, TranslationModifierPressed, QaShortcutPressed, } @@ -22,6 +21,7 @@ impl HotkeyMonitor { pub fn start( _binding: HotkeyBinding, _tx: Sender, + _cancel_tx: Sender<()>, ) -> Result { Err(HotkeyInstallError { code: "unavailable".into(),