Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1443,7 +1443,8 @@ impl Coordinator {
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
#[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);
Expand Down
11 changes: 11 additions & 0 deletions openless-all/app/src-tauri/src/coordinator/capsule_focus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
40 changes: 34 additions & 6 deletions openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Inner>, 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<Inner>) -> 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<Inner>) {
let mut attempts: u32 = 0;
let capability = HotkeyMonitor::capability();
Expand Down Expand Up @@ -54,7 +80,8 @@ pub(super) fn hotkey_supervisor_loop(inner: Arc<Inner>) {
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
#[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);
Expand Down Expand Up @@ -278,7 +305,10 @@ pub(super) fn update_coding_agent_hotkey_binding_now(inner: &Arc<Inner>) {
return;
}
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
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!(
Expand Down Expand Up @@ -362,7 +392,6 @@ pub(super) fn less_computer_modifier_bridge_loop(inner: Arc<Inner>, rx: mpsc::Re
handle_less_computer_released(&inner_cloned).await
});
}
HotkeyEvent::Cancelled => cancel_session(&inner_cloned),
HotkeyEvent::TranslationModifierPressed | HotkeyEvent::QaShortcutPressed => {}
}
}
Expand Down Expand Up @@ -1036,9 +1065,8 @@ pub(super) fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEve
handle_released_edge(&inner_cloned, at).await;
});
}
HotkeyEvent::Cancelled => {
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)
Expand Down
88 changes: 74 additions & 14 deletions openless-all/app/src-tauri/src/hotkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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,
Expand Down Expand Up @@ -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<HotkeyEvent>,
cancel_tx: Sender<()>,
) -> Result<Self, HotkeyInstallError> {
Ok(Self {
adapter: platform::start_adapter(binding, tx)?,
adapter: platform::start_adapter(binding, tx, cancel_tx)?,
})
}

Expand Down Expand Up @@ -185,6 +194,28 @@ fn send_or_log(tx: &Sender<HotkeyEvent>, 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<T> = mpsc::Sender<Result<T, HotkeyInstallError>>;

struct ListenerThread<T> {
Expand All @@ -195,13 +226,14 @@ struct ListenerThread<T> {
fn start_listener_thread<T, F>(
binding: HotkeyBinding,
tx: Sender<HotkeyEvent>,
cancel_tx: Sender<()>,
thread_name: &str,
startup_timeout_message: &'static str,
run_listen_loop: F,
) -> Result<ListenerThread<T>, HotkeyInstallError>
where
T: Send + 'static,
F: FnOnce(Arc<Shared>, Sender<HotkeyEvent>, StartupTx<T>) + Send + 'static,
F: FnOnce(Arc<Shared>, Sender<HotkeyEvent>, Sender<()>, StartupTx<T>) + Send + 'static,
{
let shared = Arc::new(Shared {
binding: RwLock::new(binding),
Expand All @@ -217,7 +249,7 @@ where
let (status_tx, status_rx) = mpsc::channel::<Result<T, HotkeyInstallError>>();
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)) {
Expand Down Expand Up @@ -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<HotkeyEvent>,
cancel_tx: Sender<()>,
) -> Result<Box<dyn HotkeyAdapter>, HotkeyInstallError> {
let listener = start_listener_thread(
binding,
tx,
cancel_tx,
"openless-hotkey-mac-event-tap",
"hotkey hook 启动超时",
run_listen_loop,
Expand Down Expand Up @@ -452,6 +486,8 @@ mod platform {
struct CallbackContext {
shared: Arc<Shared>,
tx: Sender<HotkeyEvent>,
/// 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<MacShutdownHandles>,
Expand All @@ -463,6 +499,7 @@ mod platform {
fn run_listen_loop(
shared: Arc<Shared>,
tx: Sender<HotkeyEvent>,
cancel_tx: Sender<()>,
status_tx: StartupTx<Arc<MacShutdownHandles>>,
) {
let mask: CgEventMask = (1u64 << FLAGS_CHANGED)
Expand All @@ -475,6 +512,7 @@ mod platform {
let context = Box::into_raw(Box::new(CallbackContext {
shared,
tx,
cancel_tx,
handles: Arc::clone(&handles),
}));

Expand Down Expand Up @@ -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) };
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -691,10 +734,12 @@ mod platform {

fn callback_context(shared: Arc<Shared>) -> (CallbackContext, mpsc::Receiver<HotkeyEvent>) {
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),
Expand Down Expand Up @@ -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};

Expand Down Expand Up @@ -813,10 +858,12 @@ mod platform {
pub fn start_adapter(
binding: HotkeyBinding,
tx: Sender<HotkeyEvent>,
cancel_tx: Sender<()>,
) -> Result<Box<dyn HotkeyAdapter>, HotkeyInstallError> {
let listener = start_listener_thread(
binding,
tx,
cancel_tx,
"openless-hotkey-win-ll-hook",
"Windows hotkey hook 启动超时",
run_listen_loop,
Expand Down Expand Up @@ -866,17 +913,25 @@ mod platform {
struct CallbackContext {
shared: Arc<Shared>,
tx: Sender<HotkeyEvent>,
/// Esc 专用通道,见模块注释——不与 tx 挤同一条串行 bridge。
cancel_tx: Sender<()>,
hook: std::sync::Mutex<Option<HHOOK>>,
}

unsafe impl Send for CallbackContext {}
unsafe impl Sync for CallbackContext {}

fn run_listen_loop(shared: Arc<Shared>, tx: Sender<HotkeyEvent>, status_tx: StartupTx<u32>) {
fn run_listen_loop(
shared: Arc<Shared>,
tx: Sender<HotkeyEvent>,
cancel_tx: Sender<()>,
status_tx: StartupTx<u32>,
) {
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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1103,10 +1160,12 @@ mod platform {

fn callback_context(shared: Arc<Shared>) -> (CallbackContext, mpsc::Receiver<HotkeyEvent>) {
let (tx, rx) = mpsc::channel();
let (cancel_tx, _cancel_rx) = mpsc::channel();
(
CallbackContext {
shared,
tx,
cancel_tx,
hook: std::sync::Mutex::new(None),
},
rx,
Expand Down Expand Up @@ -1276,6 +1335,7 @@ mod platform {
pub fn start_adapter(
_binding: HotkeyBinding,
_tx: Sender<HotkeyEvent>,
_cancel_tx: Sender<()>,
) -> Result<Box<dyn HotkeyAdapter>, HotkeyInstallError> {
log::info!("[hotkey] Linux — fcitx5 plugin handles hotkeys");
Ok(Box::new(PlaceholderAdapter { _tx }))
Expand Down
5 changes: 4 additions & 1 deletion openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HotkeyEvent>,
_cancel_tx: Sender<()>,
) -> Result<Self, HotkeyInstallError> {
Err(HotkeyInstallError {
code: "unavailable".into(),
Expand Down
Loading