refactor(coordinator): 抽出 hotkey_supervisors / voice_agent_hotkeys 子模块
机械式行为保持拆分:从 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
17e69f217a
commit
90e74b84d2
3 files changed
+1279
-1250
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -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<Inner>) {
|
||||
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::<HotkeyEvent>();
|
||||
#[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<Inner>) {
|
||||
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::<QaHotkeyEvent>();
|
||||
let (init_tx, init_rx) = mpsc::sync_channel::<Result<QaHotkeyMonitor, QaHotkeyError>>(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<Inner>, rx: mpsc::Receiver<QaHotkeyEvent>) {
|
||||
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<Inner>) {
|
||||
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::<ComboHotkeyEvent>();
|
||||
let (init_tx, init_rx) =
|
||||
mpsc::sync_channel::<Result<ComboHotkeyMonitor, ComboHotkeyError>>(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<Inner>, rx: mpsc::Receiver<ComboHotkeyEvent>) {
|
||||
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<Inner>) {
|
||||
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::<ComboHotkeyEvent>();
|
||||
let (init_tx, init_rx) =
|
||||
mpsc::sync_channel::<Result<ComboHotkeyMonitor, ComboHotkeyError>>(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<Inner>,
|
||||
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::<ComboHotkeyEvent>();
|
||||
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<Inner>,
|
||||
rx: mpsc::Receiver<ComboHotkeyEvent>,
|
||||
) {
|
||||
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<Inner>, 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::<ComboHotkeyEvent>();
|
||||
let (init_tx, init_rx) =
|
||||
mpsc::sync_channel::<Result<ComboHotkeyMonitor, ComboHotkeyError>>(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<Inner>,
|
||||
rx: mpsc::Receiver<ComboHotkeyEvent>,
|
||||
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<Inner>, 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<Inner>) {
|
||||
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<crate::types::StylePack> =
|
||||
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<Inner>) {
|
||||
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<Inner>) {
|
||||
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<Inner>, 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<Inner>,
|
||||
kind: ActionHotkeyKind,
|
||||
) -> &Mutex<Option<ComboHotkeyMonitor>> {
|
||||
match kind {
|
||||
ActionHotkeyKind::SwitchStyle => &inner.switch_style_hotkey,
|
||||
ActionHotkeyKind::OpenApp => &inner.open_app_hotkey,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn action_hotkey_binding(
|
||||
inner: &Arc<Inner>,
|
||||
kind: ActionHotkeyKind,
|
||||
) -> Option<crate::types::ShortcutBinding> {
|
||||
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<Inner>) -> Option<String> {
|
||||
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<Inner>) {
|
||||
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<Inner>,
|
||||
) -> (
|
||||
Option<crate::types::HotkeyTrigger>,
|
||||
Option<crate::types::HotkeyTrigger>,
|
||||
) {
|
||||
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<Inner>) {
|
||||
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<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
|
||||
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>) {
|
||||
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<Inner>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<Inner>) {
|
||||
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<Inner>) {
|
||||
#[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::<HotkeyEvent>();
|
||||
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::<ComboHotkeyEvent>();
|
||||
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<crate::types::HotkeyBinding> {
|
||||
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<Inner>,
|
||||
rx: mpsc::Receiver<HotkeyEvent>,
|
||||
) {
|
||||
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<Inner>,
|
||||
rx: mpsc::Receiver<ComboHotkeyEvent>,
|
||||
) {
|
||||
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<Inner>) {
|
||||
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<Inner>) {
|
||||
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>) {
|
||||
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<Inner>) {
|
||||
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<Inner>) {
|
||||
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<String> = 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));
|
||||
}
|
||||
Reference in new issue
Block a user