From 90e74b84d276e9c4b798a9954224e971a66b1cde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Sun, 7 Jun 2026 12:14:16 +0800 Subject: [PATCH] =?UTF-8?q?refactor(coordinator):=20=E6=8A=BD=E5=87=BA=20h?= =?UTF-8?q?otkey=5Fsupervisors=20/=20voice=5Fagent=5Fhotkeys=20=E5=AD=90?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 机械式行为保持拆分:从 coordinator.rs 搬出听写/QA/combo/翻译/action/window 热键的 supervisor/bridge/事件循环,以及 Less Computer 语音 Agent 热键簇。 保持 use super::* 重导出契约(pub(crate) use 子模块::*),Inner/ActionHotkeyKind 随之提升为 pub(crate)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- openless-all/app/src-tauri/src/coordinator.rs | 1259 +---------------- .../src/coordinator/hotkey_supervisors.rs | 920 ++++++++++++ .../src/coordinator/voice_agent_hotkeys.rs | 350 +++++ 3 files changed, 1279 insertions(+), 1250 deletions(-) create mode 100644 openless-all/app/src-tauri/src/coordinator/hotkey_supervisors.rs create mode 100644 openless-all/app/src-tauri/src/coordinator/voice_agent_hotkeys.rs diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index df34baed..5f5db4a2 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -62,8 +62,15 @@ use crate::windows_ime_ipc::ImeSubmitTarget; use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController}; mod dictation; +mod hotkey_supervisors; mod qa; mod resources; +mod voice_agent_hotkeys; + +// glob 重导出:让 dictation.rs/qa.rs/resources.rs/impl 里所有 `super::裸名` +// 引用继续通过父模块解析(拆分前的 `use super::*` 契约)。 +pub(crate) use hotkey_supervisors::*; +pub(crate) use voice_agent_hotkeys::*; #[cfg(test)] use dictation::dictation_error_code; @@ -206,7 +213,7 @@ pub struct Coordinator { inner: Arc, } -struct Inner { +pub(crate) struct Inner { app: Mutex>, history: HistoryStore, prefs: PreferencesStore, @@ -294,7 +301,7 @@ struct Inner { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ActionHotkeyKind { +pub(crate) enum ActionHotkeyKind { SwitchStyle, OpenApp, } @@ -1158,1254 +1165,6 @@ fn raw_mode_uses_llm(style_system_prompt: &str) -> bool { // ─────────────────────────── hotkey bridging ─────────────────────────── -fn hotkey_supervisor_loop(inner: Arc) { - let mut attempts: u32 = 0; - let capability = HotkeyMonitor::capability(); - loop { - if inner.shutdown.load(Ordering::SeqCst) { - return; - } - let prefs = inner.prefs.get(); - - if inner.hotkey.lock().is_some() { - return; - } - // Linux: 启动前检查 fcitx5 插件是否可用 - #[cfg(target_os = "linux")] - if !crate::linux_fcitx::available() { - *inner.hotkey_status.lock() = HotkeyStatus { - adapter: capability.adapter, - state: HotkeyStatusState::Failed, - message: Some("fcitx5 插件不可用 — 请确保 fcitx5 已安装且在运行".into()), - last_error: Some(crate::types::HotkeyInstallError { - code: "fcitx5_unavailable".into(), - message: "fcitx5 插件 DBus 接口无响应".into(), - }), - }; - log::warn!("[hotkey-supervisor] fcitx5 plugin unavailable, retrying..."); - attempts += 1; - std::thread::sleep(std::time::Duration::from_secs(3)); - continue; - } - *inner.hotkey_status.lock() = HotkeyStatus { - adapter: capability.adapter, - state: HotkeyStatusState::Starting, - message: Some(format!("正在安装全局快捷键监听(第 {} 次)", attempts + 1)), - last_error: None, - }; - let trigger = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey) - .unwrap_or(crate::types::HotkeyTrigger::Custom); - let binding = crate::types::HotkeyBinding { - trigger, - mode: prefs.hotkey.mode, - keys: None, - }; - let (tx, rx) = mpsc::channel::(); - #[cfg(target_os = "linux")] - let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone()); - match HotkeyMonitor::start(binding, tx) { - Ok(monitor) => { - let adapter = monitor.kind(); - *inner.hotkey.lock() = Some(monitor); - if let Some(monitor) = inner.hotkey.lock().as_ref() { - let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); - monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); - } - *inner.hotkey_status.lock() = HotkeyStatus { - adapter, - state: HotkeyStatusState::Installed, - message: Some(format!("{} 已安装", adapter.display_name())), - last_error: None, - }; - log::info!( - "[coord] hotkey listener installed (after {} attempt(s))", - attempts + 1 - ); - let inner_clone = Arc::clone(&inner); - std::thread::Builder::new() - .name("openless-hotkey-bridge".into()) - .spawn(move || hotkey_bridge_loop(inner_clone, rx)) - .ok(); - // Linux: 启动 fcitx5 插件信号监听作为热键源。 - #[cfg(target_os = "linux")] - { - let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); - let custom_key = custom_dictation_key_string(&inner); - crate::linux_fcitx::start_dictation_signal_listener( - fcitx_tx, - fcitx_binding.clone(), - qa_trigger, - translation_trigger, - custom_key, - ); - if fcitx_binding.trigger == crate::types::HotkeyTrigger::Custom { - sync_custom_dictation_to_plugin(&inner); - } else { - crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); - } - } - return; - } - Err(e) => { - attempts += 1; - let error_message = e.message.clone(); - *inner.hotkey_status.lock() = HotkeyStatus { - adapter: capability.adapter, - state: HotkeyStatusState::Failed, - message: Some(error_message.clone()), - last_error: Some(e), - }; - if attempts <= 3 || attempts % 10 == 0 { - log::warn!( - "[coord] hotkey listener attempt #{attempts} failed: {}; retrying in 3s", - error_message - ); - } - std::thread::sleep(std::time::Duration::from_secs(3)); - } - } - } -} - -// ─────────────────────────── QA hotkey supervisor ─────────────────────────── - -fn qa_hotkey_supervisor_loop(inner: Arc) { - let mut attempts: u32 = 0; - loop { - if inner.shutdown.load(Ordering::SeqCst) { - return; - } - // 用户已经把 QA 关掉就睡着等 prefs 改动;改动通过 update_qa_hotkey_binding 唤醒。 - let binding = match inner.prefs.get().qa_hotkey.clone() { - Some(b) => b, - None => { - inner.qa_hotkey.lock().take(); - std::thread::sleep(std::time::Duration::from_secs(5)); - continue; - } - }; - if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() { - inner.qa_hotkey.lock().take(); - if let Some(monitor) = inner.hotkey.lock().as_ref() { - let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); - monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); - } - std::thread::sleep(std::time::Duration::from_secs(5)); - continue; - } - - if inner.qa_hotkey.lock().is_some() { - // 已注册成功 → 不重复装;睡 5s 复查( binding 变化由 update 路径手动触发 )。 - std::thread::sleep(std::time::Duration::from_secs(5)); - continue; - } - - // global-hotkey crate 在 macOS 走 Carbon RegisterEventHotKey,要求 manager - // 在主线程构造,否则 register() 看起来 Ok 但事件根本不会派发——这是 issue #118 - // PR #119 第一版漏掉的关键步骤,导致用户按了 hotkey 完全无反应。这里通过 - // run_on_main_thread 把 QaHotkeyMonitor::start 跳到主线程跑,结果再回 channel。 - let app = inner.app.lock().clone(); - let app = match app { - Some(a) => a, - None => { - // 启动期 AppHandle 还没 bind,再等。 - std::thread::sleep(std::time::Duration::from_secs(1)); - continue; - } - }; - - let (tx, rx) = mpsc::channel::(); - let (init_tx, init_rx) = mpsc::sync_channel::>(1); - let binding_for_main = binding.clone(); - let _ = app.run_on_main_thread(move || { - let result = QaHotkeyMonitor::start(binding_for_main, tx); - let _ = init_tx.send(result); - }); - - // run_on_main_thread 是 fire-and-forget;等主线程跑完结果回来。给 5s 上限避免 - // 主线程繁忙时 supervisor 永久阻塞。 - let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { - Ok(r) => r, - Err(_) => { - attempts += 1; - if attempts <= 3 || attempts % 10 == 0 { - log::warn!( - "[coord] QA hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试" - ); - } - std::thread::sleep(std::time::Duration::from_secs(3)); - continue; - } - }; - - match init_result { - Ok(monitor) => { - *inner.qa_hotkey.lock() = Some(monitor); - log::info!( - "[coord] QA hotkey listener installed on main thread (after {} attempt(s))", - attempts + 1 - ); - let inner_clone = Arc::clone(&inner); - std::thread::Builder::new() - .name("openless-qa-hotkey-bridge".into()) - .spawn(move || qa_hotkey_bridge_loop(inner_clone, rx)) - .ok(); - attempts = 0; - } - Err(e) => { - attempts += 1; - if attempts <= 3 || attempts % 10 == 0 { - log::warn!("[coord] QA hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试"); - } - std::thread::sleep(std::time::Duration::from_secs(3)); - } - } - } -} - -fn qa_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { - while let Ok(evt) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { - continue; - } - let inner_cloned = Arc::clone(&inner); - match evt { - QaHotkeyEvent::Pressed => { - async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await }); - } - } - } -} - -// ─────────────────────────── combo hotkey supervisor ─────────────────────────── - -// ─────────────────────── coding agent hotkey supervisor ─────────────────────── - -fn coding_agent_hotkey_supervisor_loop(inner: Arc) { - loop { - if inner.shutdown.load(Ordering::SeqCst) { - return; - } - update_coding_agent_hotkey_binding_now(&inner); - std::thread::sleep(std::time::Duration::from_secs(5)); - } -} - -fn update_coding_agent_hotkey_binding_now(inner: &Arc) { - #[cfg(not(target_os = "macos"))] - { - // Less Computer is intentionally macOS-only for now; keep Windows/Linux hidden and inert. - take_coding_agent_hotkeys_on_main_thread(inner); - return; - } - - #[cfg(target_os = "macos")] - { - let prefs = inner.prefs.get(); - let Some(binding) = prefs.coding_agent_voice_hotkey.clone() else { - take_coding_agent_hotkeys_on_main_thread(inner); - log::info!("[less-computer] hotkey disabled"); - return; - }; - if !prefs.coding_agent_enabled || is_unconfigured_shortcut(&binding) { - take_coding_agent_hotkeys_on_main_thread(inner); - return; - } - - if let Some(modifier_binding) = less_computer_modifier_binding(&binding) { - take_coding_agent_combo_hotkey_on_main_thread(inner); - if let Some(monitor) = inner.coding_agent_modifier_hotkey.lock().as_ref() { - monitor.update_binding(modifier_binding); - return; - } - let (tx, rx) = mpsc::channel::(); - match HotkeyMonitor::start(modifier_binding, tx) { - Ok(monitor) => { - *inner.coding_agent_modifier_hotkey.lock() = Some(monitor); - log::info!( - "[less-computer] modifier hotkey installed ({})", - binding.display_label() - ); - let bridge_inner = Arc::clone(inner); - std::thread::Builder::new() - .name("openless-less-computer-modifier-bridge".into()) - .spawn(move || less_computer_modifier_bridge_loop(bridge_inner, rx)) - .ok(); - } - Err(e) => log::warn!("[less-computer] modifier hotkey install failed: {e}"), - } - return; - } - - inner.coding_agent_modifier_hotkey.lock().take(); - let app = match inner.app.lock().clone() { - Some(app) => app, - None => { - log::warn!("[less-computer] AppHandle 未 bind,跳过组合键注册"); - return; - } - }; - let inner_clone = Arc::clone(inner); - let binding_for_main = binding.clone(); - let _ = app.run_on_main_thread(move || { - if let Some(monitor) = inner_clone.coding_agent_combo_hotkey.lock().as_ref() { - if let Err(e) = monitor.update_binding(binding_for_main.clone()) { - log::warn!("[less-computer] combo hotkey update failed: {e}"); - } - return; - } - let (tx, rx) = mpsc::channel::(); - match ComboHotkeyMonitor::start(binding_for_main.clone(), tx) { - Ok(monitor) => { - *inner_clone.coding_agent_combo_hotkey.lock() = Some(monitor); - log::info!( - "[less-computer] combo hotkey installed ({})", - binding_for_main.display_label() - ); - let bridge_inner = Arc::clone(&inner_clone); - std::thread::Builder::new() - .name("openless-less-computer-combo-bridge".into()) - .spawn(move || less_computer_combo_bridge_loop(bridge_inner, rx)) - .ok(); - } - Err(e) => log::warn!("[less-computer] combo hotkey install failed: {e}"), - } - }); - } -} - -#[cfg(target_os = "macos")] -fn less_computer_modifier_binding( - binding: &crate::types::ShortcutBinding, -) -> Option { - let trigger = crate::shortcut_binding::legacy_modifier_trigger(binding)?; - Some(crate::types::HotkeyBinding { - trigger, - mode: crate::types::HotkeyMode::Hold, - keys: None, - }) -} - -fn less_computer_modifier_bridge_loop(inner: Arc, rx: mpsc::Receiver) { - while let Ok(evt) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { - continue; - } - let inner_cloned = Arc::clone(&inner); - match evt { - HotkeyEvent::Pressed => { - async_runtime::block_on(async { - handle_less_computer_pressed(&inner_cloned).await - }); - } - HotkeyEvent::Released => { - async_runtime::block_on(async { - handle_less_computer_released(&inner_cloned).await - }); - } - HotkeyEvent::Cancelled => cancel_session(&inner_cloned), - HotkeyEvent::TranslationModifierPressed | HotkeyEvent::QaShortcutPressed => {} - } - } -} - -fn less_computer_combo_bridge_loop(inner: Arc, rx: mpsc::Receiver) { - while let Ok(evt) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { - continue; - } - let inner_cloned = Arc::clone(&inner); - match evt { - ComboHotkeyEvent::Pressed => { - async_runtime::block_on(async { - handle_less_computer_pressed(&inner_cloned).await - }); - } - ComboHotkeyEvent::Released => { - async_runtime::block_on(async { - handle_less_computer_released(&inner_cloned).await - }); - } - } - } -} - -async fn handle_less_computer_pressed(inner: &Arc) { - let prefs = inner.prefs.get(); - if !prefs.coding_agent_enabled { - return; - } - if !matches!(inner.state.lock().phase, SessionPhase::Idle) { - log::info!("[less-computer] press ignored: dictation session already active"); - return; - } - if !matches!(inner.qa_state.lock().phase, QaPhase::Idle) { - log::info!("[less-computer] press ignored: QA session active"); - return; - } - - if begin_session(inner).await.is_err() { - return; - } - let started = { - let mut state = inner.state.lock(); - if matches!( - state.phase, - SessionPhase::Starting | SessionPhase::Listening - ) { - state.voice_agent = true; - log::info!( - "[less-computer] voice session started (session={:?})", - state.session_id - ); - true - } else { - false - } - }; - // 一按下键(开始录音)就点亮整屏彩虹描边,贯穿 录音 → 处理 → 出结果,完成/关闭才熄灭。 - if started { - if let Some(app) = inner.app.lock().clone() { - crate::show_less_computer_glow(&app); - } - } -} - -async fn handle_less_computer_released(inner: &Arc) { - let (phase, voice_agent) = { - let state = inner.state.lock(); - (state.phase, state.voice_agent) - }; - if !voice_agent { - return; - } - match phase { - SessionPhase::Listening => { - let _ = end_session(inner).await; - // 收尾后熄灭整屏描边。正常路径 run_voice_agent_transcript 已熄过、这里兜底; - // 空转写/出错路径不进 run_voice_agent_transcript,全靠这里熄,否则描边卡住不灭。 - if let Some(app) = inner.app.lock().clone() { - crate::hide_less_computer_glow(&app); - } - } - SessionPhase::Starting => { - // 握手中松手:排队;正常路径真正收尾在 begin 续流的 end_session → run_voice_agent_transcript 熄灭。 - request_stop_during_starting(inner, "less-computer release edge"); - // 但若初始化失败永远到不了 Listening(不会进 run_voice_agent_transcript), - // 描边会永久卡屏 → 这里兜底熄灭。Listening 分支已有熄灭逻辑,故只在 Starting 加。 - if let Some(app) = inner.app.lock().clone() { - crate::hide_less_computer_glow(&app); - } - } - _ => {} - } -} - -fn take_coding_agent_hotkeys_on_main_thread(inner: &Arc) { - inner.coding_agent_modifier_hotkey.lock().take(); - take_coding_agent_combo_hotkey_on_main_thread(inner); -} - -fn take_coding_agent_combo_hotkey_on_main_thread(inner: &Arc) { - let app = inner.app.lock().clone(); - if let Some(app) = app { - let inner = Arc::clone(inner); - let _ = app.run_on_main_thread(move || { - inner.coding_agent_combo_hotkey.lock().take(); - }); - } else { - inner.coding_agent_combo_hotkey.lock().take(); - } -} - -/// 快取用:抓当前选中文本 → Claude 润色 → 回插(替换选区)。全程胶囊反馈。 -async fn handle_coding_agent_quick(inner: &Arc) { - let prefs = inner.prefs.get(); - if !prefs.coding_agent_enabled { - return; - } - let selection = tauri::async_runtime::spawn_blocking(crate::selection::capture_selection) - .await - .ok() - .flatten(); - let source_text = match selection { - Some(ctx) => ctx.text, - None => { - log::info!("[coding-agent] 快取用:没有选中文本"); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - 0, - Some("请先选中文本,再按快捷键".to_string()), - None, - ); - return; - } - }; - - log::info!( - "[coding-agent] 快取用:润色 {} 字", - source_text.chars().count() - ); - emit_capsule( - inner, - CapsuleState::Polishing, - 0.0, - 0, - Some("Claude 润色中…".to_string()), - None, - ); - - let prompt = format!( - "请润色下面这段文字,使其更通顺自然、表达更清晰,保持原意、语言和事实不变。\ - 直接输出润色后的文本,不要加任何解释、前缀或引号:\n\n{source_text}" - ); - - // 纯文本润色:不需要任何工具 → plan 只读、无 guard、便宜快、最可靠。 - let mut req = crate::coding_agent::CodingAgentRequest::new("quick-polish", prompt); - req.model = prefs - .coding_agent_model - .clone() - .filter(|m| !m.trim().is_empty()) - .or_else(|| Some("sonnet".to_string())); - req.permission_mode = crate::coding_agent::CodingAgentPermissionMode::Plan; - req.allowed_tools = Vec::new(); - req.max_budget_usd = Some(0.2); - req.timeout_secs = 60; - req.session_persistence = false; - - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let run = async_runtime::spawn(async move { - crate::coding_agent::run_claude_agent("claude", req, tx, cancel).await - }); - - let mut final_text = String::new(); - let mut error_msg: Option = None; - while let Some(ev) = rx.recv().await { - match ev { - crate::coding_agent::CodingAgentEvent::Completed { text, .. } => final_text = text, - crate::coding_agent::CodingAgentEvent::Error { message, .. } => { - error_msg = Some(message) - } - _ => {} - } - } - let run_result = run.await; - - let final_text = final_text.trim().to_string(); - if final_text.is_empty() { - let msg = error_msg - .or_else(|| match run_result { - Ok(Err(e)) => Some(e.to_string()), - _ => None, - }) - .unwrap_or_else(|| "Claude 无结果(确认已登录 claude 且额度充足)".to_string()); - log::warn!("[coding-agent] 快取用失败: {msg}"); - emit_capsule(inner, CapsuleState::Error, 0.0, 0, Some(msg), None); - return; - } - - let inserted = final_text.chars().count() as u32; - let inner2 = Arc::clone(inner); - let restore = prefs.restore_clipboard_after_paste; - let paste_shortcut = prefs.paste_shortcut; - let _ = tauri::async_runtime::spawn_blocking(move || { - inner2.inserter.insert(&final_text, restore, paste_shortcut) - }) - .await; - log::info!("[coding-agent] 快取用:已回插 {inserted} 字"); - emit_capsule(inner, CapsuleState::Done, 0.0, 0, None, Some(inserted)); -} - -fn combo_hotkey_supervisor_loop(inner: Arc) { - let mut attempts: u32 = 0; - loop { - if inner.shutdown.load(Ordering::SeqCst) { - return; - } - // 读当前 prefs - let prefs = inner.prefs.get(); - if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() { - // 不是 Custom → 睡着等 prefs 改动 - take_combo_hotkey_on_main_thread(&inner); - std::thread::sleep(std::time::Duration::from_secs(5)); - continue; - } - - let binding = prefs.dictation_hotkey.clone(); - if is_unconfigured_shortcut(&binding) { - take_combo_hotkey_on_main_thread(&inner); - std::thread::sleep(std::time::Duration::from_secs(5)); - continue; - } - - if inner.combo_hotkey.lock().is_some() { - std::thread::sleep(std::time::Duration::from_secs(5)); - continue; - } - - let app = inner.app.lock().clone(); - let app = match app { - Some(a) => a, - None => { - std::thread::sleep(std::time::Duration::from_secs(1)); - continue; - } - }; - - let (tx, rx) = mpsc::channel::(); - let (init_tx, init_rx) = - mpsc::sync_channel::>(1); - let binding_for_main = binding.clone(); - let _ = app.run_on_main_thread(move || { - let result = ComboHotkeyMonitor::start(binding_for_main, tx); - let _ = init_tx.send(result); - }); - - let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { - Ok(r) => r, - Err(_) => { - attempts += 1; - if attempts <= 3 || attempts % 10 == 0 { - log::warn!( - "[coord] combo hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试" - ); - } - std::thread::sleep(std::time::Duration::from_secs(3)); - continue; - } - }; - - match init_result { - Ok(monitor) => { - *inner.combo_hotkey.lock() = Some(monitor); - log::info!( - "[coord] combo hotkey listener installed on main thread (after {} attempt(s))", - attempts + 1 - ); - let inner_clone = Arc::clone(&inner); - std::thread::Builder::new() - .name("openless-combo-hotkey-bridge".into()) - .spawn(move || combo_hotkey_bridge_loop(inner_clone, rx)) - .ok(); - attempts = 0; - } - Err(e) => { - attempts += 1; - if attempts <= 3 || attempts % 10 == 0 { - log::warn!("[coord] combo hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试"); - } - std::thread::sleep(std::time::Duration::from_secs(3)); - } - } - } -} - -fn combo_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { - while let Ok(evt) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { - continue; - } - let inner_cloned = Arc::clone(&inner); - match evt { - // P0 #468/#475: 同 hotkey_bridge_loop —— Pressed/Released 必须串行 await, - // 否则 latch 竞态导致 combo 快捷键二次按键失效。 - ComboHotkeyEvent::Pressed => { - async_runtime::block_on(async { - handle_pressed_edge(&inner_cloned).await; - }); - } - ComboHotkeyEvent::Released => { - async_runtime::block_on(async { - handle_released_edge(&inner_cloned).await; - }); - } - } - } -} - -fn translation_hotkey_supervisor_loop(inner: Arc) { - let mut attempts: u32 = 0; - loop { - if inner.shutdown.load(Ordering::SeqCst) { - return; - } - let binding = inner.prefs.get().translation_hotkey; - if is_builtin_translation_shift(&binding) - || crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() - { - take_translation_hotkey_on_main_thread(&inner); - if let Some(monitor) = inner.hotkey.lock().as_ref() { - let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); - monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); - } - std::thread::sleep(std::time::Duration::from_secs(5)); - continue; - } - - if inner.translation_hotkey.lock().is_some() { - std::thread::sleep(std::time::Duration::from_secs(5)); - continue; - } - - let app = match inner.app.lock().clone() { - Some(a) => a, - None => { - std::thread::sleep(std::time::Duration::from_secs(1)); - continue; - } - }; - - let (tx, rx) = mpsc::channel::(); - let (init_tx, init_rx) = - mpsc::sync_channel::>(1); - let binding_for_main = binding.clone(); - let _ = app.run_on_main_thread(move || { - let result = ComboHotkeyMonitor::start(binding_for_main, tx); - let _ = init_tx.send(result); - }); - - let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { - Ok(r) => r, - Err(_) => { - attempts += 1; - std::thread::sleep(std::time::Duration::from_secs(3)); - continue; - } - }; - - match init_result { - Ok(monitor) => { - *inner.translation_hotkey.lock() = Some(monitor); - let inner_clone = Arc::clone(&inner); - std::thread::Builder::new() - .name("openless-translation-hotkey-bridge".into()) - .spawn(move || translation_hotkey_bridge_loop(inner_clone, rx)) - .ok(); - attempts = 0; - } - Err(e) => { - attempts += 1; - if attempts <= 3 || attempts % 10 == 0 { - log::warn!( - "[coord] translation hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试" - ); - } - std::thread::sleep(std::time::Duration::from_secs(3)); - } - } - } -} - -fn update_translation_hotkey_on_main_thread( - inner: Arc, - binding: crate::types::ShortcutBinding, -) -> Result<(), ComboHotkeyError> { - if let Some(monitor) = inner.translation_hotkey.lock().as_ref() { - return monitor.update_binding(binding); - } - let (tx, rx) = mpsc::channel::(); - let monitor = ComboHotkeyMonitor::start(binding, tx)?; - *inner.translation_hotkey.lock() = Some(monitor); - let bridge_inner = Arc::clone(&inner); - std::thread::Builder::new() - .name("openless-translation-hotkey-bridge".into()) - .spawn(move || translation_hotkey_bridge_loop(bridge_inner, rx)) - .map_err(|e| ComboHotkeyError::RegisterFailed(format!("spawn bridge thread: {e}")))?; - Ok(()) -} - -fn translation_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { - while let Ok(evt) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { - continue; - } - if matches!(evt, ComboHotkeyEvent::Pressed) { - mark_translation_modifier_seen(&inner); - } - } -} - -fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotkeyKind) { - let mut attempts: u32 = 0; - loop { - if inner.shutdown.load(Ordering::SeqCst) { - return; - } - // None = 用户主动停用:反注册并睡着等 prefs 改动(由 update 路径唤醒)。 - let Some(binding) = action_hotkey_binding(&inner, kind) else { - take_action_hotkey_on_main_thread(&inner, kind); - std::thread::sleep(std::time::Duration::from_secs(5)); - continue; - }; - if is_modifier_only_shortcut(&binding) { - take_action_hotkey_on_main_thread(&inner, kind); - std::thread::sleep(std::time::Duration::from_secs(5)); - continue; - } - - if action_hotkey_slot(&inner, kind).lock().is_some() { - std::thread::sleep(std::time::Duration::from_secs(5)); - continue; - } - - let app = match inner.app.lock().clone() { - Some(a) => a, - None => { - std::thread::sleep(std::time::Duration::from_secs(1)); - continue; - } - }; - - let (tx, rx) = mpsc::channel::(); - let (init_tx, init_rx) = - mpsc::sync_channel::>(1); - let binding_for_main = binding.clone(); - let _ = app.run_on_main_thread(move || { - let result = ComboHotkeyMonitor::start(binding_for_main, tx); - let _ = init_tx.send(result); - }); - - let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { - Ok(r) => r, - Err(_) => { - attempts += 1; - if attempts <= 3 || attempts % 10 == 0 { - log::warn!( - "[coord] action hotkey {kind:?} 第 {attempts} 次注册超时;3s 后重试" - ); - } - std::thread::sleep(std::time::Duration::from_secs(3)); - continue; - } - }; - - match init_result { - Ok(monitor) => { - *action_hotkey_slot(&inner, kind).lock() = Some(monitor); - log::info!( - "[coord] action hotkey {kind:?} listener installed after {} attempt(s)", - attempts + 1 - ); - let inner_clone = Arc::clone(&inner); - std::thread::Builder::new() - .name(action_hotkey_bridge_thread_name(kind).into()) - .spawn(move || action_hotkey_bridge_loop(inner_clone, rx, kind)) - .ok(); - attempts = 0; - } - Err(e) => { - attempts += 1; - if attempts <= 3 || attempts % 10 == 0 { - log::warn!( - "[coord] action hotkey {kind:?} 第 {attempts} 次注册失败: {e}; 3s 后重试" - ); - } - std::thread::sleep(std::time::Duration::from_secs(3)); - } - } - } -} - -fn action_hotkey_bridge_loop( - inner: Arc, - rx: mpsc::Receiver, - kind: ActionHotkeyKind, -) { - while let Ok(evt) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { - continue; - } - if matches!(evt, ComboHotkeyEvent::Pressed) { - handle_action_hotkey_pressed(&inner, kind); - } - } -} - -fn handle_action_hotkey_pressed(inner: &Arc, kind: ActionHotkeyKind) { - match kind { - ActionHotkeyKind::SwitchStyle => switch_to_previous_style(inner), - ActionHotkeyKind::OpenApp => { - if let Some(app) = inner.app.lock().clone() { - let app_for_main = app.clone(); - let _ = app.run_on_main_thread(move || { - crate::show_main_window(&app_for_main); - }); - } - } - } -} - -fn switch_to_previous_style(inner: &Arc) { - let mut prefs = inner.prefs.get(); - let packs = match inner.style_packs.list() { - Ok(packs) => packs, - Err(error) => { - log::warn!("[coord] switch style hotkey failed to load style packs: {error}"); - return; - } - }; - let enabled: Vec = - packs.into_iter().filter(|pack| pack.enabled).collect(); - if enabled.len() <= 1 { - log::info!("[coord] switch style hotkey ignored: enabled style count <= 1"); - return; - } - let current_index = enabled - .iter() - .position(|pack| pack.id == prefs.active_style_pack_id) - .unwrap_or(0); - let next_index = if current_index == 0 { - enabled.len() - 1 - } else { - current_index - 1 - }; - prefs.active_style_pack_id = enabled[next_index].id.clone(); - sync_style_pack_preferences(&mut prefs, &enabled); - if let Err(e) = inner.prefs.set(prefs.clone()) { - log::warn!("[coord] switch style hotkey 保存失败: {e}"); - } else { - log::info!( - "[coord] switch style hotkey changed active style pack to {}", - prefs.active_style_pack_id - ); - if let Some(app) = inner.app.lock().clone() { - let _ = app.emit("prefs:changed", &prefs); - let _ = app.emit_to("main", "prefs:changed", &prefs); - let app_for_main = app.clone(); - let _ = app.run_on_main_thread(move || { - if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) { - log::warn!("[tray] refresh style menu after switch style hotkey failed: {err}"); - } - }); - } - } -} - -fn take_combo_hotkey_on_main_thread(inner: &Arc) { - let app = inner.app.lock().clone(); - if let Some(app) = app { - let inner = Arc::clone(inner); - let _ = app.run_on_main_thread(move || { - inner.combo_hotkey.lock().take(); - }); - } else { - inner.combo_hotkey.lock().take(); - } -} - -fn take_translation_hotkey_on_main_thread(inner: &Arc) { - let app = inner.app.lock().clone(); - if let Some(app) = app { - let inner = Arc::clone(inner); - let _ = app.run_on_main_thread(move || { - inner.translation_hotkey.lock().take(); - }); - } else { - inner.translation_hotkey.lock().take(); - } -} - -fn take_action_hotkey_on_main_thread(inner: &Arc, kind: ActionHotkeyKind) { - let app = inner.app.lock().clone(); - if let Some(app) = app { - let inner = Arc::clone(inner); - let _ = app.run_on_main_thread(move || { - action_hotkey_slot(&inner, kind).lock().take(); - }); - } else { - action_hotkey_slot(inner, kind).lock().take(); - } -} - -fn action_hotkey_slot( - inner: &Arc, - kind: ActionHotkeyKind, -) -> &Mutex> { - match kind { - ActionHotkeyKind::SwitchStyle => &inner.switch_style_hotkey, - ActionHotkeyKind::OpenApp => &inner.open_app_hotkey, - } -} - -fn action_hotkey_binding( - inner: &Arc, - kind: ActionHotkeyKind, -) -> Option { - let prefs = inner.prefs.get(); - match kind { - ActionHotkeyKind::SwitchStyle => prefs.switch_style_hotkey, - ActionHotkeyKind::OpenApp => prefs.open_app_hotkey, - } -} - -fn is_modifier_only_shortcut(binding: &crate::types::ShortcutBinding) -> bool { - binding.modifiers.is_empty() - && (binding.primary.eq_ignore_ascii_case("shift") - || crate::shortcut_binding::legacy_modifier_trigger(binding).is_some()) -} - -fn is_unconfigured_shortcut(binding: &crate::types::ShortcutBinding) -> bool { - binding.primary.trim().is_empty() -} - -fn action_hotkey_bridge_thread_name(kind: ActionHotkeyKind) -> &'static str { - match kind { - ActionHotkeyKind::SwitchStyle => "openless-switch-style-hotkey-bridge", - ActionHotkeyKind::OpenApp => "openless-open-app-hotkey-bridge", - } -} - -fn is_builtin_translation_shift(binding: &crate::types::ShortcutBinding) -> bool { - binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") -} - -/// Linux: 从 prefs 读取自定义组合键,同步到 fcitx5 插件。 -#[cfg(target_os = "linux")] -fn custom_dictation_key_string(inner: &Arc) -> Option { - let prefs = inner.prefs.get(); - let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(&prefs.dictation_hotkey); - if key_string.is_empty() { - None - } else { - Some(key_string) - } -} - -#[cfg(target_os = "linux")] -fn sync_custom_dictation_to_plugin(inner: &Arc) { - let prefs = inner.prefs.get(); - let dictation = &prefs.dictation_hotkey; - let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(dictation); - if key_string.is_empty() { - return; - } - match crate::linux_fcitx::set_custom_dictation_trigger(&key_string) { - Ok(()) => log::info!( - "[fcitx] Synced custom dictation trigger '{}' to plugin", - key_string - ), - Err(e) => log::warn!("[fcitx] Failed to sync custom dictation trigger: {e}"), - } -} - -fn modifier_shortcut_triggers( - inner: &Arc, -) -> ( - Option, - Option, -) { - let prefs = inner.prefs.get(); - let qa_trigger = prefs - .qa_hotkey - .as_ref() - .and_then(crate::shortcut_binding::legacy_modifier_trigger); - let translation_trigger = if is_builtin_translation_shift(&prefs.translation_hotkey) { - None - } else { - crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey) - }; - (qa_trigger, translation_trigger) -} - -fn mark_translation_modifier_seen(inner: &Arc) { - let phase = inner.state.lock().phase; - if matches!(phase, SessionPhase::Starting | SessionPhase::Listening) { - inner - .translation_modifier_seen - .store(true, Ordering::SeqCst); - log::info!("[coord] translation modifier seen during {phase:?}"); - } -} - -fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { - while let Ok(evt) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { - continue; - } - let inner_cloned = Arc::clone(&inner); - match evt { - // P0 #468/#475: Pressed/Released 必须串行处理,否则在 Windows 上 WH_KEYBOARD_LL - // 边沿间隔微秒级 → 两个独立 spawn 的 task 被 work-stealing 调度器并行执行 → - // `hotkey_trigger_held` latch 翻转顺序错乱 → 下次按键被静默吞掉 - // (UI 关不掉 / 录音停不下来)。改为 bridge 线程内 block_on 顺序 await, - // recv 的 FIFO 顺序就是 handler 执行顺序。 - // 注意:handle_pressed_edge / handle_released_edge 内部走 .await(含网络 - // 握手),会暂时阻塞本 bridge 线程;Hold 模式短按时 Released 会排队在 channel - // 里直到 begin_session 完成,但 SessionPhase::Starting 已经有 - // request_stop_during_starting 兜底,begin_session 完成进 Listening 后 - // bridge 立刻 recv Released → end_session,行为正确,仅有短暂 stop 延迟。 - HotkeyEvent::Pressed => { - async_runtime::block_on(async { - handle_pressed_edge(&inner_cloned).await; - }); - } - HotkeyEvent::Released => { - async_runtime::block_on(async { - handle_released_edge(&inner_cloned).await; - }); - } - HotkeyEvent::Cancelled => { - cancel_session(&inner_cloned); - } - HotkeyEvent::TranslationModifierPressed => { - let translation_hotkey = inner_cloned.prefs.get().translation_hotkey; - if is_builtin_translation_shift(&translation_hotkey) - || crate::shortcut_binding::legacy_modifier_trigger(&translation_hotkey) - .is_some() - { - mark_translation_modifier_seen(&inner_cloned); - } - } - HotkeyEvent::QaShortcutPressed => { - async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await }); - } - } - } -} - -fn reset_shortcut_held_state(inner: &Arc) { - inner.hotkey_trigger_held.store(false, Ordering::SeqCst); - if let Some(monitor) = inner.hotkey.lock().as_ref() { - monitor.reset_held_state(); - } - let prefs = inner.prefs.get(); - if let Some(binding) = prefs.qa_hotkey.as_ref() { - if crate::shortcut_binding::legacy_modifier_trigger(binding).is_none() { - if let Some(monitor) = inner.qa_hotkey.lock().as_ref() { - if let Err(e) = monitor.update_binding(binding.clone()) { - log::warn!("[coord] reset QA hotkey latch failed: {e}"); - } - } - } - } - if !is_builtin_translation_shift(&prefs.translation_hotkey) - && crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey).is_none() - { - if let Some(monitor) = inner.translation_hotkey.lock().as_ref() { - if let Err(e) = monitor.update_binding(prefs.translation_hotkey.clone()) { - log::warn!("[coord] reset translation hotkey latch failed: {e}"); - } - } - } - if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() { - if !is_modifier_only_shortcut(switch_style) { - if let Some(monitor) = inner.switch_style_hotkey.lock().as_ref() { - if let Err(e) = monitor.update_binding(switch_style.clone()) { - log::warn!("[coord] reset switch-style hotkey latch failed: {e}"); - } - } - } - } - if let Some(open_app) = prefs.open_app_hotkey.as_ref() { - if !is_modifier_only_shortcut(open_app) { - if let Some(monitor) = inner.open_app_hotkey.lock().as_ref() { - if let Err(e) = monitor.update_binding(open_app.clone()) { - log::warn!("[coord] reset open-app hotkey latch failed: {e}"); - } - } - } - } -} - -async fn handle_window_hotkey_event( - inner: &Arc, - event_type: String, - key: String, - code: String, - repeat: bool, -) -> Result<(), String> { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { - return Ok(()); - } - if event_type == "keydown" && key == "Escape" { - // Esc 路由(issue #161):QA 浮窗可见时优先取消 QA(不动 dictation); - // 否则走 dictation 取消通路。之前无条件 cancel_session 导致 QA 浮窗 - // 按 Esc 杀的是 dictation 而 QA 流还在烧 token。 - let qa_active = { - let st = inner.qa_state.lock(); - st.panel_visible || st.phase != QaPhase::Idle - }; - if qa_active { - close_qa_panel(inner); - } else { - cancel_session(inner); - } - return Ok(()); - } - - #[cfg(not(target_os = "windows"))] - { - let _ = (inner, event_type, key, code, repeat); - Ok(()) - } - - #[cfg(target_os = "windows")] - { - if !window_hotkey_fallback_enabled() { - if event_type == "keydown" && !repeat { - log::info!( - "[window-hotkey] ignored because Windows lifecycle owner is the low-level hook" - ); - } - return Ok(()); - } - - let Some(trigger) = - crate::shortcut_binding::legacy_modifier_trigger(&inner.prefs.get().dictation_hotkey) - else { - return Ok(()); - }; - if !window_key_matches_trigger(trigger, &key, &code) { - return Ok(()); - } - - match event_type.as_str() { - "keydown" => { - if repeat { - return Ok(()); - } - log::info!( - "[window-hotkey] pressed trigger={trigger:?} code={code} repeat={repeat}" - ); - handle_pressed_edge(inner).await; - } - "keyup" => { - log::info!("[window-hotkey] released trigger={trigger:?} code={code}"); - handle_released_edge(inner).await; - } - _ => {} - } - Ok(()) - } -} - -fn window_hotkey_fallback_enabled() -> bool { - crate::types::HotkeyCapability::current().explicit_fallback_available -} - -#[cfg(any(target_os = "windows", test))] -fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, key: &str, code: &str) -> bool { - use crate::types::HotkeyTrigger; - - match trigger { - HotkeyTrigger::RightControl => key == "Control" && code == "ControlRight", - HotkeyTrigger::LeftControl => key == "Control" && code == "ControlLeft", - HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => { - (key == "Alt" || key == "AltGraph") && code == "AltRight" - } - HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltLeft", - HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight", - HotkeyTrigger::Fn => key == "Control" && code == "ControlRight", - // MediaPlayPause 走 WH_KEYBOARD_LL,不走 window hotkey fallback - HotkeyTrigger::MediaPlayPause => false, - // Custom 走 global-hotkey crate,不走 window hotkey fallback - HotkeyTrigger::Custom => false, - } -} - // ─────────────────────────── session lifecycle ─────────────────────────── /// QA 录音 runtime error 监听器。镜像 `spawn_recorder_error_monitor` 的语义但走 QA diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_supervisors.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_supervisors.rs new file mode 100644 index 00000000..fabbc9ac --- /dev/null +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_supervisors.rs @@ -0,0 +1,920 @@ +//! 听写 / QA / combo / 翻译 / action / window 热键的 supervisor / bridge / 事件处理循环。 +//! +//! 从 `coordinator.rs` 机械拆出(行为保持,仅可见性提升)。所有自由函数/类型 +//! 仍通过 `use super::*` 看到父模块的 `Inner` / `ActiveAsr` / 兄弟 helper。 + +use super::*; + +pub(crate) fn hotkey_supervisor_loop(inner: Arc) { + let mut attempts: u32 = 0; + let capability = HotkeyMonitor::capability(); + loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } + let prefs = inner.prefs.get(); + + if inner.hotkey.lock().is_some() { + return; + } + // Linux: 启动前检查 fcitx5 插件是否可用 + #[cfg(target_os = "linux")] + if !crate::linux_fcitx::available() { + *inner.hotkey_status.lock() = HotkeyStatus { + adapter: capability.adapter, + state: HotkeyStatusState::Failed, + message: Some("fcitx5 插件不可用 — 请确保 fcitx5 已安装且在运行".into()), + last_error: Some(crate::types::HotkeyInstallError { + code: "fcitx5_unavailable".into(), + message: "fcitx5 插件 DBus 接口无响应".into(), + }), + }; + log::warn!("[hotkey-supervisor] fcitx5 plugin unavailable, retrying..."); + attempts += 1; + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + *inner.hotkey_status.lock() = HotkeyStatus { + adapter: capability.adapter, + state: HotkeyStatusState::Starting, + message: Some(format!("正在安装全局快捷键监听(第 {} 次)", attempts + 1)), + last_error: None, + }; + let trigger = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey) + .unwrap_or(crate::types::HotkeyTrigger::Custom); + let binding = crate::types::HotkeyBinding { + trigger, + mode: prefs.hotkey.mode, + keys: None, + }; + let (tx, rx) = mpsc::channel::(); + #[cfg(target_os = "linux")] + let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone()); + match HotkeyMonitor::start(binding, tx) { + Ok(monitor) => { + let adapter = monitor.kind(); + *inner.hotkey.lock() = Some(monitor); + if let Some(monitor) = inner.hotkey.lock().as_ref() { + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); + monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); + } + *inner.hotkey_status.lock() = HotkeyStatus { + adapter, + state: HotkeyStatusState::Installed, + message: Some(format!("{} 已安装", adapter.display_name())), + last_error: None, + }; + log::info!( + "[coord] hotkey listener installed (after {} attempt(s))", + attempts + 1 + ); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-hotkey-bridge".into()) + .spawn(move || hotkey_bridge_loop(inner_clone, rx)) + .ok(); + // Linux: 启动 fcitx5 插件信号监听作为热键源。 + #[cfg(target_os = "linux")] + { + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); + let custom_key = custom_dictation_key_string(&inner); + crate::linux_fcitx::start_dictation_signal_listener( + fcitx_tx, + fcitx_binding.clone(), + qa_trigger, + translation_trigger, + custom_key, + ); + if fcitx_binding.trigger == crate::types::HotkeyTrigger::Custom { + sync_custom_dictation_to_plugin(&inner); + } else { + crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); + } + } + return; + } + Err(e) => { + attempts += 1; + let error_message = e.message.clone(); + *inner.hotkey_status.lock() = HotkeyStatus { + adapter: capability.adapter, + state: HotkeyStatusState::Failed, + message: Some(error_message.clone()), + last_error: Some(e), + }; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] hotkey listener attempt #{attempts} failed: {}; retrying in 3s", + error_message + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +// ─────────────────────────── QA hotkey supervisor ─────────────────────────── + +pub(crate) fn qa_hotkey_supervisor_loop(inner: Arc) { + let mut attempts: u32 = 0; + loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } + // 用户已经把 QA 关掉就睡着等 prefs 改动;改动通过 update_qa_hotkey_binding 唤醒。 + let binding = match inner.prefs.get().qa_hotkey.clone() { + Some(b) => b, + None => { + inner.qa_hotkey.lock().take(); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + }; + if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() { + inner.qa_hotkey.lock().take(); + if let Some(monitor) = inner.hotkey.lock().as_ref() { + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); + monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); + } + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + if inner.qa_hotkey.lock().is_some() { + // 已注册成功 → 不重复装;睡 5s 复查( binding 变化由 update 路径手动触发 )。 + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + // global-hotkey crate 在 macOS 走 Carbon RegisterEventHotKey,要求 manager + // 在主线程构造,否则 register() 看起来 Ok 但事件根本不会派发——这是 issue #118 + // PR #119 第一版漏掉的关键步骤,导致用户按了 hotkey 完全无反应。这里通过 + // run_on_main_thread 把 QaHotkeyMonitor::start 跳到主线程跑,结果再回 channel。 + let app = inner.app.lock().clone(); + let app = match app { + Some(a) => a, + None => { + // 启动期 AppHandle 还没 bind,再等。 + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + let (tx, rx) = mpsc::channel::(); + let (init_tx, init_rx) = mpsc::sync_channel::>(1); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + let result = QaHotkeyMonitor::start(binding_for_main, tx); + let _ = init_tx.send(result); + }); + + // run_on_main_thread 是 fire-and-forget;等主线程跑完结果回来。给 5s 上限避免 + // 主线程繁忙时 supervisor 永久阻塞。 + let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(r) => r, + Err(_) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] QA hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + }; + + match init_result { + Ok(monitor) => { + *inner.qa_hotkey.lock() = Some(monitor); + log::info!( + "[coord] QA hotkey listener installed on main thread (after {} attempt(s))", + attempts + 1 + ); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-qa-hotkey-bridge".into()) + .spawn(move || qa_hotkey_bridge_loop(inner_clone, rx)) + .ok(); + attempts = 0; + } + Err(e) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!("[coord] QA hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试"); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +pub(crate) fn qa_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + let inner_cloned = Arc::clone(&inner); + match evt { + QaHotkeyEvent::Pressed => { + async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await }); + } + } + } +} + +// ─────────────────────────── combo hotkey supervisor ─────────────────────────── +pub(crate) fn combo_hotkey_supervisor_loop(inner: Arc) { + let mut attempts: u32 = 0; + loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } + // 读当前 prefs + let prefs = inner.prefs.get(); + if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() { + // 不是 Custom → 睡着等 prefs 改动 + take_combo_hotkey_on_main_thread(&inner); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let binding = prefs.dictation_hotkey.clone(); + if is_unconfigured_shortcut(&binding) { + take_combo_hotkey_on_main_thread(&inner); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + if inner.combo_hotkey.lock().is_some() { + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let app = inner.app.lock().clone(); + let app = match app { + Some(a) => a, + None => { + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + let (tx, rx) = mpsc::channel::(); + let (init_tx, init_rx) = + mpsc::sync_channel::>(1); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + let result = ComboHotkeyMonitor::start(binding_for_main, tx); + let _ = init_tx.send(result); + }); + + let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(r) => r, + Err(_) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] combo hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + }; + + match init_result { + Ok(monitor) => { + *inner.combo_hotkey.lock() = Some(monitor); + log::info!( + "[coord] combo hotkey listener installed on main thread (after {} attempt(s))", + attempts + 1 + ); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-combo-hotkey-bridge".into()) + .spawn(move || combo_hotkey_bridge_loop(inner_clone, rx)) + .ok(); + attempts = 0; + } + Err(e) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!("[coord] combo hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试"); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +pub(crate) fn combo_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + let inner_cloned = Arc::clone(&inner); + match evt { + // P0 #468/#475: 同 hotkey_bridge_loop —— Pressed/Released 必须串行 await, + // 否则 latch 竞态导致 combo 快捷键二次按键失效。 + ComboHotkeyEvent::Pressed => { + async_runtime::block_on(async { + handle_pressed_edge(&inner_cloned).await; + }); + } + ComboHotkeyEvent::Released => { + async_runtime::block_on(async { + handle_released_edge(&inner_cloned).await; + }); + } + } + } +} + +pub(crate) fn translation_hotkey_supervisor_loop(inner: Arc) { + let mut attempts: u32 = 0; + loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } + let binding = inner.prefs.get().translation_hotkey; + if is_builtin_translation_shift(&binding) + || crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() + { + take_translation_hotkey_on_main_thread(&inner); + if let Some(monitor) = inner.hotkey.lock().as_ref() { + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); + monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); + } + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + if inner.translation_hotkey.lock().is_some() { + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let app = match inner.app.lock().clone() { + Some(a) => a, + None => { + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + let (tx, rx) = mpsc::channel::(); + let (init_tx, init_rx) = + mpsc::sync_channel::>(1); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + let result = ComboHotkeyMonitor::start(binding_for_main, tx); + let _ = init_tx.send(result); + }); + + let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(r) => r, + Err(_) => { + attempts += 1; + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + }; + + match init_result { + Ok(monitor) => { + *inner.translation_hotkey.lock() = Some(monitor); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-translation-hotkey-bridge".into()) + .spawn(move || translation_hotkey_bridge_loop(inner_clone, rx)) + .ok(); + attempts = 0; + } + Err(e) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] translation hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +pub(crate) fn update_translation_hotkey_on_main_thread( + inner: Arc, + binding: crate::types::ShortcutBinding, +) -> Result<(), ComboHotkeyError> { + if let Some(monitor) = inner.translation_hotkey.lock().as_ref() { + return monitor.update_binding(binding); + } + let (tx, rx) = mpsc::channel::(); + let monitor = ComboHotkeyMonitor::start(binding, tx)?; + *inner.translation_hotkey.lock() = Some(monitor); + let bridge_inner = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-translation-hotkey-bridge".into()) + .spawn(move || translation_hotkey_bridge_loop(bridge_inner, rx)) + .map_err(|e| ComboHotkeyError::RegisterFailed(format!("spawn bridge thread: {e}")))?; + Ok(()) +} + +pub(crate) fn translation_hotkey_bridge_loop( + inner: Arc, + rx: mpsc::Receiver, +) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + if matches!(evt, ComboHotkeyEvent::Pressed) { + mark_translation_modifier_seen(&inner); + } + } +} + +pub(crate) fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotkeyKind) { + let mut attempts: u32 = 0; + loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } + // None = 用户主动停用:反注册并睡着等 prefs 改动(由 update 路径唤醒)。 + let Some(binding) = action_hotkey_binding(&inner, kind) else { + take_action_hotkey_on_main_thread(&inner, kind); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + }; + if is_modifier_only_shortcut(&binding) { + take_action_hotkey_on_main_thread(&inner, kind); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + if action_hotkey_slot(&inner, kind).lock().is_some() { + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let app = match inner.app.lock().clone() { + Some(a) => a, + None => { + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + let (tx, rx) = mpsc::channel::(); + let (init_tx, init_rx) = + mpsc::sync_channel::>(1); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + let result = ComboHotkeyMonitor::start(binding_for_main, tx); + let _ = init_tx.send(result); + }); + + let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(r) => r, + Err(_) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] action hotkey {kind:?} 第 {attempts} 次注册超时;3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + }; + + match init_result { + Ok(monitor) => { + *action_hotkey_slot(&inner, kind).lock() = Some(monitor); + log::info!( + "[coord] action hotkey {kind:?} listener installed after {} attempt(s)", + attempts + 1 + ); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name(action_hotkey_bridge_thread_name(kind).into()) + .spawn(move || action_hotkey_bridge_loop(inner_clone, rx, kind)) + .ok(); + attempts = 0; + } + Err(e) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] action hotkey {kind:?} 第 {attempts} 次注册失败: {e}; 3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +pub(crate) fn action_hotkey_bridge_loop( + inner: Arc, + rx: mpsc::Receiver, + kind: ActionHotkeyKind, +) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + if matches!(evt, ComboHotkeyEvent::Pressed) { + handle_action_hotkey_pressed(&inner, kind); + } + } +} + +pub(crate) fn handle_action_hotkey_pressed(inner: &Arc, kind: ActionHotkeyKind) { + match kind { + ActionHotkeyKind::SwitchStyle => switch_to_previous_style(inner), + ActionHotkeyKind::OpenApp => { + if let Some(app) = inner.app.lock().clone() { + let app_for_main = app.clone(); + let _ = app.run_on_main_thread(move || { + crate::show_main_window(&app_for_main); + }); + } + } + } +} + +pub(crate) fn switch_to_previous_style(inner: &Arc) { + let mut prefs = inner.prefs.get(); + let packs = match inner.style_packs.list() { + Ok(packs) => packs, + Err(error) => { + log::warn!("[coord] switch style hotkey failed to load style packs: {error}"); + return; + } + }; + let enabled: Vec = + packs.into_iter().filter(|pack| pack.enabled).collect(); + if enabled.len() <= 1 { + log::info!("[coord] switch style hotkey ignored: enabled style count <= 1"); + return; + } + let current_index = enabled + .iter() + .position(|pack| pack.id == prefs.active_style_pack_id) + .unwrap_or(0); + let next_index = if current_index == 0 { + enabled.len() - 1 + } else { + current_index - 1 + }; + prefs.active_style_pack_id = enabled[next_index].id.clone(); + sync_style_pack_preferences(&mut prefs, &enabled); + if let Err(e) = inner.prefs.set(prefs.clone()) { + log::warn!("[coord] switch style hotkey 保存失败: {e}"); + } else { + log::info!( + "[coord] switch style hotkey changed active style pack to {}", + prefs.active_style_pack_id + ); + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit("prefs:changed", &prefs); + let _ = app.emit_to("main", "prefs:changed", &prefs); + let app_for_main = app.clone(); + let _ = app.run_on_main_thread(move || { + if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) { + log::warn!("[tray] refresh style menu after switch style hotkey failed: {err}"); + } + }); + } + } +} + +pub(crate) fn take_combo_hotkey_on_main_thread(inner: &Arc) { + let app = inner.app.lock().clone(); + if let Some(app) = app { + let inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + inner.combo_hotkey.lock().take(); + }); + } else { + inner.combo_hotkey.lock().take(); + } +} + +pub(crate) fn take_translation_hotkey_on_main_thread(inner: &Arc) { + let app = inner.app.lock().clone(); + if let Some(app) = app { + let inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + inner.translation_hotkey.lock().take(); + }); + } else { + inner.translation_hotkey.lock().take(); + } +} + +pub(crate) fn take_action_hotkey_on_main_thread(inner: &Arc, kind: ActionHotkeyKind) { + let app = inner.app.lock().clone(); + if let Some(app) = app { + let inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + action_hotkey_slot(&inner, kind).lock().take(); + }); + } else { + action_hotkey_slot(inner, kind).lock().take(); + } +} + +pub(crate) fn action_hotkey_slot( + inner: &Arc, + kind: ActionHotkeyKind, +) -> &Mutex> { + match kind { + ActionHotkeyKind::SwitchStyle => &inner.switch_style_hotkey, + ActionHotkeyKind::OpenApp => &inner.open_app_hotkey, + } +} + +pub(crate) fn action_hotkey_binding( + inner: &Arc, + kind: ActionHotkeyKind, +) -> Option { + let prefs = inner.prefs.get(); + match kind { + ActionHotkeyKind::SwitchStyle => prefs.switch_style_hotkey, + ActionHotkeyKind::OpenApp => prefs.open_app_hotkey, + } +} + +pub(crate) fn is_modifier_only_shortcut(binding: &crate::types::ShortcutBinding) -> bool { + binding.modifiers.is_empty() + && (binding.primary.eq_ignore_ascii_case("shift") + || crate::shortcut_binding::legacy_modifier_trigger(binding).is_some()) +} + +pub(crate) fn is_unconfigured_shortcut(binding: &crate::types::ShortcutBinding) -> bool { + binding.primary.trim().is_empty() +} + +pub(crate) fn action_hotkey_bridge_thread_name(kind: ActionHotkeyKind) -> &'static str { + match kind { + ActionHotkeyKind::SwitchStyle => "openless-switch-style-hotkey-bridge", + ActionHotkeyKind::OpenApp => "openless-open-app-hotkey-bridge", + } +} + +pub(crate) fn is_builtin_translation_shift(binding: &crate::types::ShortcutBinding) -> bool { + binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") +} + +/// Linux: 从 prefs 读取自定义组合键,同步到 fcitx5 插件。 +#[cfg(target_os = "linux")] +pub(crate) fn custom_dictation_key_string(inner: &Arc) -> Option { + let prefs = inner.prefs.get(); + let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(&prefs.dictation_hotkey); + if key_string.is_empty() { + None + } else { + Some(key_string) + } +} + +#[cfg(target_os = "linux")] +pub(crate) fn sync_custom_dictation_to_plugin(inner: &Arc) { + let prefs = inner.prefs.get(); + let dictation = &prefs.dictation_hotkey; + let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(dictation); + if key_string.is_empty() { + return; + } + match crate::linux_fcitx::set_custom_dictation_trigger(&key_string) { + Ok(()) => log::info!( + "[fcitx] Synced custom dictation trigger '{}' to plugin", + key_string + ), + Err(e) => log::warn!("[fcitx] Failed to sync custom dictation trigger: {e}"), + } +} + +pub(crate) fn modifier_shortcut_triggers( + inner: &Arc, +) -> ( + Option, + Option, +) { + let prefs = inner.prefs.get(); + let qa_trigger = prefs + .qa_hotkey + .as_ref() + .and_then(crate::shortcut_binding::legacy_modifier_trigger); + let translation_trigger = if is_builtin_translation_shift(&prefs.translation_hotkey) { + None + } else { + crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey) + }; + (qa_trigger, translation_trigger) +} + +pub(crate) fn mark_translation_modifier_seen(inner: &Arc) { + let phase = inner.state.lock().phase; + if matches!(phase, SessionPhase::Starting | SessionPhase::Listening) { + inner + .translation_modifier_seen + .store(true, Ordering::SeqCst); + log::info!("[coord] translation modifier seen during {phase:?}"); + } +} + +pub(crate) fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + let inner_cloned = Arc::clone(&inner); + match evt { + // P0 #468/#475: Pressed/Released 必须串行处理,否则在 Windows 上 WH_KEYBOARD_LL + // 边沿间隔微秒级 → 两个独立 spawn 的 task 被 work-stealing 调度器并行执行 → + // `hotkey_trigger_held` latch 翻转顺序错乱 → 下次按键被静默吞掉 + // (UI 关不掉 / 录音停不下来)。改为 bridge 线程内 block_on 顺序 await, + // recv 的 FIFO 顺序就是 handler 执行顺序。 + // 注意:handle_pressed_edge / handle_released_edge 内部走 .await(含网络 + // 握手),会暂时阻塞本 bridge 线程;Hold 模式短按时 Released 会排队在 channel + // 里直到 begin_session 完成,但 SessionPhase::Starting 已经有 + // request_stop_during_starting 兜底,begin_session 完成进 Listening 后 + // bridge 立刻 recv Released → end_session,行为正确,仅有短暂 stop 延迟。 + HotkeyEvent::Pressed => { + async_runtime::block_on(async { + handle_pressed_edge(&inner_cloned).await; + }); + } + HotkeyEvent::Released => { + async_runtime::block_on(async { + handle_released_edge(&inner_cloned).await; + }); + } + HotkeyEvent::Cancelled => { + cancel_session(&inner_cloned); + } + HotkeyEvent::TranslationModifierPressed => { + let translation_hotkey = inner_cloned.prefs.get().translation_hotkey; + if is_builtin_translation_shift(&translation_hotkey) + || crate::shortcut_binding::legacy_modifier_trigger(&translation_hotkey) + .is_some() + { + mark_translation_modifier_seen(&inner_cloned); + } + } + HotkeyEvent::QaShortcutPressed => { + async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await }); + } + } + } +} + +pub(crate) fn reset_shortcut_held_state(inner: &Arc) { + inner.hotkey_trigger_held.store(false, Ordering::SeqCst); + if let Some(monitor) = inner.hotkey.lock().as_ref() { + monitor.reset_held_state(); + } + let prefs = inner.prefs.get(); + if let Some(binding) = prefs.qa_hotkey.as_ref() { + if crate::shortcut_binding::legacy_modifier_trigger(binding).is_none() { + if let Some(monitor) = inner.qa_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(binding.clone()) { + log::warn!("[coord] reset QA hotkey latch failed: {e}"); + } + } + } + } + if !is_builtin_translation_shift(&prefs.translation_hotkey) + && crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey).is_none() + { + if let Some(monitor) = inner.translation_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(prefs.translation_hotkey.clone()) { + log::warn!("[coord] reset translation hotkey latch failed: {e}"); + } + } + } + if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() { + if !is_modifier_only_shortcut(switch_style) { + if let Some(monitor) = inner.switch_style_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(switch_style.clone()) { + log::warn!("[coord] reset switch-style hotkey latch failed: {e}"); + } + } + } + } + if let Some(open_app) = prefs.open_app_hotkey.as_ref() { + if !is_modifier_only_shortcut(open_app) { + if let Some(monitor) = inner.open_app_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(open_app.clone()) { + log::warn!("[coord] reset open-app hotkey latch failed: {e}"); + } + } + } + } +} + +pub(crate) async fn handle_window_hotkey_event( + inner: &Arc, + event_type: String, + key: String, + code: String, + repeat: bool, +) -> Result<(), String> { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + return Ok(()); + } + if event_type == "keydown" && key == "Escape" { + // Esc 路由(issue #161):QA 浮窗可见时优先取消 QA(不动 dictation); + // 否则走 dictation 取消通路。之前无条件 cancel_session 导致 QA 浮窗 + // 按 Esc 杀的是 dictation 而 QA 流还在烧 token。 + let qa_active = { + let st = inner.qa_state.lock(); + st.panel_visible || st.phase != QaPhase::Idle + }; + if qa_active { + close_qa_panel(inner); + } else { + cancel_session(inner); + } + return Ok(()); + } + + #[cfg(not(target_os = "windows"))] + { + let _ = (inner, event_type, key, code, repeat); + Ok(()) + } + + #[cfg(target_os = "windows")] + { + if !window_hotkey_fallback_enabled() { + if event_type == "keydown" && !repeat { + log::info!( + "[window-hotkey] ignored because Windows lifecycle owner is the low-level hook" + ); + } + return Ok(()); + } + + let Some(trigger) = + crate::shortcut_binding::legacy_modifier_trigger(&inner.prefs.get().dictation_hotkey) + else { + return Ok(()); + }; + if !window_key_matches_trigger(trigger, &key, &code) { + return Ok(()); + } + + match event_type.as_str() { + "keydown" => { + if repeat { + return Ok(()); + } + log::info!( + "[window-hotkey] pressed trigger={trigger:?} code={code} repeat={repeat}" + ); + handle_pressed_edge(inner).await; + } + "keyup" => { + log::info!("[window-hotkey] released trigger={trigger:?} code={code}"); + handle_released_edge(inner).await; + } + _ => {} + } + Ok(()) + } +} + +pub(crate) fn window_hotkey_fallback_enabled() -> bool { + crate::types::HotkeyCapability::current().explicit_fallback_available +} + +#[cfg(any(target_os = "windows", test))] +pub(crate) fn window_key_matches_trigger( + trigger: crate::types::HotkeyTrigger, + key: &str, + code: &str, +) -> bool { + use crate::types::HotkeyTrigger; + + match trigger { + HotkeyTrigger::RightControl => key == "Control" && code == "ControlRight", + HotkeyTrigger::LeftControl => key == "Control" && code == "ControlLeft", + HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => { + (key == "Alt" || key == "AltGraph") && code == "AltRight" + } + HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltLeft", + HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight", + HotkeyTrigger::Fn => key == "Control" && code == "ControlRight", + // MediaPlayPause 走 WH_KEYBOARD_LL,不走 window hotkey fallback + HotkeyTrigger::MediaPlayPause => false, + // Custom 走 global-hotkey crate,不走 window hotkey fallback + HotkeyTrigger::Custom => false, + } +} diff --git a/openless-all/app/src-tauri/src/coordinator/voice_agent_hotkeys.rs b/openless-all/app/src-tauri/src/coordinator/voice_agent_hotkeys.rs new file mode 100644 index 00000000..90f38f2f --- /dev/null +++ b/openless-all/app/src-tauri/src/coordinator/voice_agent_hotkeys.rs @@ -0,0 +1,350 @@ +//! Less Computer 语音 Agent 热键(coding_agent / less_computer)的 supervisor / +//! bridge / 事件处理。从 `coordinator.rs` 机械拆出(行为保持,仅可见性提升)。 + +use super::*; + +// ─────────────────────── coding agent hotkey supervisor ─────────────────────── + +pub(crate) fn coding_agent_hotkey_supervisor_loop(inner: Arc) { + loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } + update_coding_agent_hotkey_binding_now(&inner); + std::thread::sleep(std::time::Duration::from_secs(5)); + } +} + +pub(crate) fn update_coding_agent_hotkey_binding_now(inner: &Arc) { + #[cfg(not(target_os = "macos"))] + { + // Less Computer is intentionally macOS-only for now; keep Windows/Linux hidden and inert. + take_coding_agent_hotkeys_on_main_thread(inner); + return; + } + + #[cfg(target_os = "macos")] + { + let prefs = inner.prefs.get(); + let Some(binding) = prefs.coding_agent_voice_hotkey.clone() else { + take_coding_agent_hotkeys_on_main_thread(inner); + log::info!("[less-computer] hotkey disabled"); + return; + }; + if !prefs.coding_agent_enabled || is_unconfigured_shortcut(&binding) { + take_coding_agent_hotkeys_on_main_thread(inner); + return; + } + + if let Some(modifier_binding) = less_computer_modifier_binding(&binding) { + take_coding_agent_combo_hotkey_on_main_thread(inner); + if let Some(monitor) = inner.coding_agent_modifier_hotkey.lock().as_ref() { + monitor.update_binding(modifier_binding); + return; + } + let (tx, rx) = mpsc::channel::(); + match HotkeyMonitor::start(modifier_binding, tx) { + Ok(monitor) => { + *inner.coding_agent_modifier_hotkey.lock() = Some(monitor); + log::info!( + "[less-computer] modifier hotkey installed ({})", + binding.display_label() + ); + let bridge_inner = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-less-computer-modifier-bridge".into()) + .spawn(move || less_computer_modifier_bridge_loop(bridge_inner, rx)) + .ok(); + } + Err(e) => log::warn!("[less-computer] modifier hotkey install failed: {e}"), + } + return; + } + + inner.coding_agent_modifier_hotkey.lock().take(); + let app = match inner.app.lock().clone() { + Some(app) => app, + None => { + log::warn!("[less-computer] AppHandle 未 bind,跳过组合键注册"); + return; + } + }; + let inner_clone = Arc::clone(inner); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + if let Some(monitor) = inner_clone.coding_agent_combo_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(binding_for_main.clone()) { + log::warn!("[less-computer] combo hotkey update failed: {e}"); + } + return; + } + let (tx, rx) = mpsc::channel::(); + match ComboHotkeyMonitor::start(binding_for_main.clone(), tx) { + Ok(monitor) => { + *inner_clone.coding_agent_combo_hotkey.lock() = Some(monitor); + log::info!( + "[less-computer] combo hotkey installed ({})", + binding_for_main.display_label() + ); + let bridge_inner = Arc::clone(&inner_clone); + std::thread::Builder::new() + .name("openless-less-computer-combo-bridge".into()) + .spawn(move || less_computer_combo_bridge_loop(bridge_inner, rx)) + .ok(); + } + Err(e) => log::warn!("[less-computer] combo hotkey install failed: {e}"), + } + }); + } +} + +#[cfg(target_os = "macos")] +pub(crate) fn less_computer_modifier_binding( + binding: &crate::types::ShortcutBinding, +) -> Option { + let trigger = crate::shortcut_binding::legacy_modifier_trigger(binding)?; + Some(crate::types::HotkeyBinding { + trigger, + mode: crate::types::HotkeyMode::Hold, + keys: None, + }) +} + +pub(crate) fn less_computer_modifier_bridge_loop( + inner: Arc, + rx: mpsc::Receiver, +) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + let inner_cloned = Arc::clone(&inner); + match evt { + HotkeyEvent::Pressed => { + async_runtime::block_on(async { + handle_less_computer_pressed(&inner_cloned).await + }); + } + HotkeyEvent::Released => { + async_runtime::block_on(async { + handle_less_computer_released(&inner_cloned).await + }); + } + HotkeyEvent::Cancelled => cancel_session(&inner_cloned), + HotkeyEvent::TranslationModifierPressed | HotkeyEvent::QaShortcutPressed => {} + } + } +} + +pub(crate) fn less_computer_combo_bridge_loop( + inner: Arc, + rx: mpsc::Receiver, +) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + let inner_cloned = Arc::clone(&inner); + match evt { + ComboHotkeyEvent::Pressed => { + async_runtime::block_on(async { + handle_less_computer_pressed(&inner_cloned).await + }); + } + ComboHotkeyEvent::Released => { + async_runtime::block_on(async { + handle_less_computer_released(&inner_cloned).await + }); + } + } + } +} + +pub(crate) async fn handle_less_computer_pressed(inner: &Arc) { + let prefs = inner.prefs.get(); + if !prefs.coding_agent_enabled { + return; + } + if !matches!(inner.state.lock().phase, SessionPhase::Idle) { + log::info!("[less-computer] press ignored: dictation session already active"); + return; + } + if !matches!(inner.qa_state.lock().phase, QaPhase::Idle) { + log::info!("[less-computer] press ignored: QA session active"); + return; + } + + if begin_session(inner).await.is_err() { + return; + } + let started = { + let mut state = inner.state.lock(); + if matches!( + state.phase, + SessionPhase::Starting | SessionPhase::Listening + ) { + state.voice_agent = true; + log::info!( + "[less-computer] voice session started (session={:?})", + state.session_id + ); + true + } else { + false + } + }; + // 一按下键(开始录音)就点亮整屏彩虹描边,贯穿 录音 → 处理 → 出结果,完成/关闭才熄灭。 + if started { + if let Some(app) = inner.app.lock().clone() { + crate::show_less_computer_glow(&app); + } + } +} + +pub(crate) async fn handle_less_computer_released(inner: &Arc) { + let (phase, voice_agent) = { + let state = inner.state.lock(); + (state.phase, state.voice_agent) + }; + if !voice_agent { + return; + } + match phase { + SessionPhase::Listening => { + let _ = end_session(inner).await; + // 收尾后熄灭整屏描边。正常路径 run_voice_agent_transcript 已熄过、这里兜底; + // 空转写/出错路径不进 run_voice_agent_transcript,全靠这里熄,否则描边卡住不灭。 + if let Some(app) = inner.app.lock().clone() { + crate::hide_less_computer_glow(&app); + } + } + SessionPhase::Starting => { + // 握手中松手:排队;正常路径真正收尾在 begin 续流的 end_session → run_voice_agent_transcript 熄灭。 + request_stop_during_starting(inner, "less-computer release edge"); + // 但若初始化失败永远到不了 Listening(不会进 run_voice_agent_transcript), + // 描边会永久卡屏 → 这里兜底熄灭。Listening 分支已有熄灭逻辑,故只在 Starting 加。 + if let Some(app) = inner.app.lock().clone() { + crate::hide_less_computer_glow(&app); + } + } + _ => {} + } +} + +pub(crate) fn take_coding_agent_hotkeys_on_main_thread(inner: &Arc) { + inner.coding_agent_modifier_hotkey.lock().take(); + take_coding_agent_combo_hotkey_on_main_thread(inner); +} + +pub(crate) fn take_coding_agent_combo_hotkey_on_main_thread(inner: &Arc) { + let app = inner.app.lock().clone(); + if let Some(app) = app { + let inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + inner.coding_agent_combo_hotkey.lock().take(); + }); + } else { + inner.coding_agent_combo_hotkey.lock().take(); + } +} + +/// 快取用:抓当前选中文本 → Claude 润色 → 回插(替换选区)。全程胶囊反馈。 +pub(crate) async fn handle_coding_agent_quick(inner: &Arc) { + let prefs = inner.prefs.get(); + if !prefs.coding_agent_enabled { + return; + } + let selection = tauri::async_runtime::spawn_blocking(crate::selection::capture_selection) + .await + .ok() + .flatten(); + let source_text = match selection { + Some(ctx) => ctx.text, + None => { + log::info!("[coding-agent] 快取用:没有选中文本"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some("请先选中文本,再按快捷键".to_string()), + None, + ); + return; + } + }; + + log::info!( + "[coding-agent] 快取用:润色 {} 字", + source_text.chars().count() + ); + emit_capsule( + inner, + CapsuleState::Polishing, + 0.0, + 0, + Some("Claude 润色中…".to_string()), + None, + ); + + let prompt = format!( + "请润色下面这段文字,使其更通顺自然、表达更清晰,保持原意、语言和事实不变。\ + 直接输出润色后的文本,不要加任何解释、前缀或引号:\n\n{source_text}" + ); + + // 纯文本润色:不需要任何工具 → plan 只读、无 guard、便宜快、最可靠。 + let mut req = crate::coding_agent::CodingAgentRequest::new("quick-polish", prompt); + req.model = prefs + .coding_agent_model + .clone() + .filter(|m| !m.trim().is_empty()) + .or_else(|| Some("sonnet".to_string())); + req.permission_mode = crate::coding_agent::CodingAgentPermissionMode::Plan; + req.allowed_tools = Vec::new(); + req.max_budget_usd = Some(0.2); + req.timeout_secs = 60; + req.session_persistence = false; + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let run = async_runtime::spawn(async move { + crate::coding_agent::run_claude_agent("claude", req, tx, cancel).await + }); + + let mut final_text = String::new(); + let mut error_msg: Option = None; + while let Some(ev) = rx.recv().await { + match ev { + crate::coding_agent::CodingAgentEvent::Completed { text, .. } => final_text = text, + crate::coding_agent::CodingAgentEvent::Error { message, .. } => { + error_msg = Some(message) + } + _ => {} + } + } + let run_result = run.await; + + let final_text = final_text.trim().to_string(); + if final_text.is_empty() { + let msg = error_msg + .or_else(|| match run_result { + Ok(Err(e)) => Some(e.to_string()), + _ => None, + }) + .unwrap_or_else(|| "Claude 无结果(确认已登录 claude 且额度充足)".to_string()); + log::warn!("[coding-agent] 快取用失败: {msg}"); + emit_capsule(inner, CapsuleState::Error, 0.0, 0, Some(msg), None); + return; + } + + let inserted = final_text.chars().count() as u32; + let inner2 = Arc::clone(inner); + let restore = prefs.restore_clipboard_after_paste; + let paste_shortcut = prefs.paste_shortcut; + let _ = tauri::async_runtime::spawn_blocking(move || { + inner2.inserter.insert(&final_text, restore, paste_shortcut) + }) + .await; + log::info!("[coding-agent] 快取用:已回插 {inserted} 字"); + emit_capsule(inner, CapsuleState::Done, 0.0, 0, None, Some(inserted)); +}