diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 868f8c97..7b118b69 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/capsule_focus.rs b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs index 2398433c..8ffe7ca2 100644 --- a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs +++ b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs @@ -389,6 +389,17 @@ pub(super) fn emit_capsule( // 在 app 句柄校验之前记录,便于无 GUI 的测试断言「按下热键 → 弹了哪种胶囊」。 // replace 顺带取回上一帧 state,用于判断本次是不是「入场帧」(见下方 defer_capsule_emit)。 let prev_state = inner.last_capsule_state.lock().replace(state); + // Esc 独占窗口:胶囊显示进行中(录音/转写/润色)且确为 dictation 会话(phase 非 + // Idle)时,tap/hook 吞掉 Esc 不透传宿主应用——此刻 Esc 的语义是「取消这个会话」, + // 双重派发会顺带触发宿主应用的 Esc(如取消 Claude 正在生成的回复)。phase 条件排除 + // QA:QA 会话也走胶囊,但它的 Esc 由聚焦的浮窗窗口处理,吞键反而会把它挡掉。 + // 终止帧(Done/Cancelled/Error/Idle)自然清除。emit_capsule 是所有会话状态变化的 + // 单一出口(含 #77 审计保证的全部终止路径),在此维护不会漏路径。 + let esc_exclusive = matches!( + state, + CapsuleState::Recording | CapsuleState::Transcribing | CapsuleState::Polishing + ) && !matches!(inner.state.lock().phase, SessionPhase::Idle); + crate::hotkey::set_esc_exclusive(esc_exclusive); let app_opt = inner.app.lock().clone(); let Some(app) = app_opt else { return }; let translation = inner.translation_modifier_seen.load(Ordering::SeqCst); 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 9cca912c..804eb6f0 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 abc66fb9..d03615e8 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,28 @@ 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}"); + } +} + +/// 会话激活期间(胶囊显示录音/转写/润色中)Esc 由 OpenLess 独占:tap/hook 吞掉 +/// keydown 不透传宿主应用。否则一次 Esc 双重生效——既取消 OpenLess 会话,又触发 +/// 宿主应用自己的 Esc 语义(如取消 Claude 正在生成的回复)。对照输入法的行为: +/// 组合窗激活时 Esc 只取消候选词、宿主应用收不到。keyup 不吞:宿主应用几乎都在 +/// keydown 上响应 Esc,孤儿 keyup 无害,且窗口期内会话结束时吞 up 不吞 down 反而 +/// 会造成不成对。由 coordinator 的 emit_capsule 在胶囊状态变化时更新。 +static ESC_EXCLUSIVE: AtomicBool = AtomicBool::new(false); + +pub fn set_esc_exclusive(active: bool) { + ESC_EXCLUSIVE.store(active, std::sync::atomic::Ordering::SeqCst); +} + +fn esc_exclusive() -> bool { + ESC_EXCLUSIVE.load(std::sync::atomic::Ordering::SeqCst) +} + type StartupTx = mpsc::Sender>; struct ListenerThread { @@ -195,13 +226,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 +249,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 +316,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, + esc_exclusive, 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 +486,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 +499,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 +512,7 @@ mod platform { let context = Box::into_raw(Box::new(CallbackContext { shared, tx, + cancel_tx, handles: Arc::clone(&handles), })); @@ -538,6 +576,11 @@ mod platform { handle_key_down(ctx, event); let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; crate::side_aware_combo::platform::dispatch_keycode(keycode, false, 0, true); + // 会话激活期间独占消费 Esc:返回 null 删除事件(active tap),宿主应用 + // 收不到,避免「取消会话」与宿主 Esc 语义双重生效。见 esc_exclusive 注释。 + if keycode == ESC_KEYCODE && esc_exclusive() { + return std::ptr::null_mut(); + } } KEY_UP => { let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; @@ -632,7 +675,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 +734,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 +828,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, + esc_exclusive, 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 +858,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 +913,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,8 +1008,10 @@ 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); - return false; + send_cancel_or_log(&ctx.cancel_tx); + // 会话激活期间独占消费 Esc(返回 true → LRESULT(1) 吞掉),宿主应用收不到, + // 避免「取消会话」与宿主 Esc 语义双重生效。见 esc_exclusive 注释。 + return esc_exclusive(); } let pressed = matches!(message, WM_KEYDOWN | WM_SYSKEYDOWN); @@ -1103,10 +1160,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 +1335,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 ff2b44b8..3ad26c60 100644 --- a/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs +++ b/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs @@ -11,17 +11,20 @@ use crate::types::{ pub enum HotkeyEvent { Pressed { at: Instant }, Released { at: Instant }, - Cancelled, TranslationModifierPressed, QaShortcutPressed, } +/// Mobile 无全局键盘监听,Esc 独占为 no-op。 +pub fn set_esc_exclusive(_active: bool) {} + pub struct HotkeyMonitor; impl HotkeyMonitor { pub fn start( _binding: HotkeyBinding, _tx: Sender, + _cancel_tx: Sender<()>, ) -> Result { Err(HotkeyInstallError { code: "unavailable".into(),