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
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
63 changes: 50 additions & 13 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,12 @@ 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}");
}
}

type StartupTx<T> = mpsc::Sender<Result<T, HotkeyInstallError>>;

struct ListenerThread<T> {
Expand All @@ -195,13 +210,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 +233,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 +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<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 +470,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 +483,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 +496,7 @@ mod platform {
let context = Box::into_raw(Box::new(CallbackContext {
shared,
tx,
cancel_tx,
handles: Arc::clone(&handles),
}));

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

Expand Down Expand Up @@ -691,10 +713,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 +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};

Expand Down Expand Up @@ -813,10 +837,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 +892,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,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;
}

Expand Down Expand Up @@ -1103,10 +1137,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 +1312,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
2 changes: 1 addition & 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,7 +11,6 @@ use crate::types::{
pub enum HotkeyEvent {
Pressed { at: Instant },
Released { at: Instant },
Cancelled,
TranslationModifierPressed,
QaShortcutPressed,
}
Expand All @@ -22,6 +21,7 @@ impl HotkeyMonitor {
pub fn start(
_binding: HotkeyBinding,
_tx: Sender<HotkeyEvent>,
_cancel_tx: Sender<()>,
) -> Result<Self, HotkeyInstallError> {
Err(HotkeyInstallError {
code: "unavailable".into(),
Expand Down
Loading