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 1/2] =?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(), From 5323d42e585ec90ea752d6d7fab05e2fa5e4f1a9 Mon Sep 17 00:00:00 2001 From: jisongniu <529058747@qq.com> Date: Fri, 24 Jul 2026 12:54:56 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(hotkey):=20=E4=BC=9A=E8=AF=9D=E6=BF=80?= =?UTF-8?q?=E6=B4=BB=E6=9C=9F=E9=97=B4=20Esc=20=E7=94=B1=20OpenLess=20?= =?UTF-8?q?=E7=8B=AC=E5=8D=A0=E2=80=94=E2=80=94=E4=B8=8D=E5=86=8D=E9=80=8F?= =?UTF-8?q?=E4=BC=A0=E5=AE=BF=E4=B8=BB=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 胶囊转圈时按 Esc,取消会话的同时宿主应用也收到了这个键——一次按键双重 生效(如顺带取消了 Claude 正在生成的回复)。参照输入法的交互模型:组合窗 激活时 Esc 只取消候选词、宿主应用收不到;胶囊显示进行中的会话时同理, Esc 的语义就是「取消它」,应当被独占消费。 实现:hotkey.rs 新增进程级 ESC_EXCLUSIVE 旗标,由 emit_capsule(所有会话 状态变化的单一出口)维护——胶囊为 Recording/Transcribing/Polishing 且 dictation phase 非 Idle 时置位,终止帧清除。phase 条件排除 QA 会话:QA 的 Esc 由聚焦的浮窗窗口处理,吞键反而会挡掉。置位期间 macOS active tap 对 Esc keydown 返回 null 删除事件,Windows 底层钩子返回 LRESULT(1)—— Windows 本就吞听写触发键,Esc 是补齐同一语义。keyup 不吞:宿主应用几乎 都在 keydown 响应 Esc,孤儿 keyup 无害。 依赖 #853(独立取消通道):没有它,Processing 期间吞掉的 Esc 无法真正 触发取消,会变成「吃掉按键但什么都不做」。 Co-Authored-By: Claude Fable 5 --- .../src/coordinator/capsule_focus.rs | 11 +++++++ openless-all/app/src-tauri/src/hotkey.rs | 29 +++++++++++++++++-- .../app/src-tauri/src/mobile_stubs/hotkey.rs | 3 ++ 3 files changed, 40 insertions(+), 3 deletions(-) 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 2398433c4..8ffe7ca27 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/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 45a5bf3da..d03615e8d 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -200,6 +200,22 @@ fn send_cancel_or_log(tx: &Sender<()>) { } } +/// 会话激活期间(胶囊显示录音/转写/润色中)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 { @@ -300,7 +316,7 @@ mod platform { use std::sync::Arc; use super::{ - install_error, reset_shared_held_state, send_cancel_or_log, send_or_log, + 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, }; @@ -560,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) }; @@ -807,7 +828,7 @@ mod platform { }; use super::{ - install_error, reset_shared_held_state, send_cancel_or_log, send_or_log, + 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, }; @@ -988,7 +1009,9 @@ 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_cancel_or_log(&ctx.cancel_tx); - return false; + // 会话激活期间独占消费 Esc(返回 true → LRESULT(1) 吞掉),宿主应用收不到, + // 避免「取消会话」与宿主 Esc 语义双重生效。见 esc_exclusive 注释。 + return esc_exclusive(); } let pressed = matches!(message, WM_KEYDOWN | WM_SYSKEYDOWN); 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 60ce10351..3ad26c60a 100644 --- a/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs +++ b/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs @@ -15,6 +15,9 @@ pub enum HotkeyEvent { QaShortcutPressed, } +/// Mobile 无全局键盘监听,Esc 独占为 no-op。 +pub fn set_esc_exclusive(_active: bool) {} + pub struct HotkeyMonitor; impl HotkeyMonitor {