diff --git a/openless-all/app/src-tauri/src/asr/local/cache.rs b/openless-all/app/src-tauri/src/asr/local/cache.rs index 0402c420..8bca56d5 100644 --- a/openless-all/app/src-tauri/src/asr/local/cache.rs +++ b/openless-all/app/src-tauri/src/asr/local/cache.rs @@ -67,7 +67,10 @@ impl LocalAsrCache { slot.take(); } } - log::info!("[local-asr cache] loading {model_id} from {}", model_dir.display()); + log::info!( + "[local-asr cache] loading {model_id} from {}", + model_dir.display() + ); let engine = Arc::new(QwenAsrEngine::load(model_dir)?); let mut slot = self.inner.lock(); *slot = Some(CachedEngine { @@ -116,7 +119,10 @@ impl LocalAsrCache { #[cfg(target_os = "macos")] { if let Some(cached) = self.inner.lock().take() { - log::info!("[local-asr cache] release engine {} on demand", cached.model_id); + log::info!( + "[local-asr cache] release engine {} on demand", + cached.model_id + ); } } } diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index 5a6fc91d..1fc92730 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -134,13 +134,20 @@ fn keep_file(path: &str) -> bool { return false; } let lower = path.to_ascii_lowercase(); - if lower.ends_with(".md") || lower.ends_with(".png") || lower.ends_with(".jpg") - || lower.ends_with(".jpeg") || lower.ends_with(".gif") || lower.ends_with(".svg") + if lower.ends_with(".md") + || lower.ends_with(".png") + || lower.ends_with(".jpg") + || lower.ends_with(".jpeg") + || lower.ends_with(".gif") + || lower.ends_with(".svg") { return false; } let ext = lower.rsplit('.').next().unwrap_or(""); - matches!(ext, "json" | "safetensors" | "txt" | "bin" | "model" | "tiktoken") + matches!( + ext, + "json" | "safetensors" | "txt" | "bin" | "model" | "tiktoken" + ) } #[derive(Debug, Clone, Serialize)] @@ -205,7 +212,10 @@ impl DownloadManager { flag.store(true, Ordering::SeqCst); log::info!("[local-asr] cancel requested for {}", model_id.as_str()); } else { - log::info!("[local-asr] cancel requested for {} but no active download", model_id.as_str()); + log::info!( + "[local-asr] cancel requested for {} but no active download", + model_id.as_str() + ); } } @@ -289,9 +299,8 @@ async fn run_download( } } - let in_flight_bytes: Arc> = Arc::new( - info.files.iter().map(|_| AtomicU64::new(0)).collect() - ); + let in_flight_bytes: Arc> = + Arc::new(info.files.iter().map(|_| AtomicU64::new(0)).collect()); let already_done_bytes: u64 = info .files .iter() @@ -480,7 +489,13 @@ async fn download_one( // 远端文件 ≤ 一个 chunk 大小:直接单 chunk,不走 sparse + idx if total_size <= CHUNK_SIZE { let result = chunk_with_retry( - client, url, &partial, 0, total_size - 1, &cancel, &on_progress, + client, + url, + &partial, + 0, + total_size - 1, + &cancel, + &on_progress, ) .await; if cancel.load(Ordering::SeqCst) { @@ -499,7 +514,8 @@ async fn download_one( let done_set = read_idx(&idx_path); // 3. 预先把 .partial 撑到最终大小(sparse 文件,holes = 零字节) - if !partial.exists() || std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0) != total_size { + if !partial.exists() || std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0) != total_size + { let f = std::fs::OpenOptions::new() .write(true) .create(true) @@ -630,7 +646,10 @@ fn read_idx(path: &Path) -> HashSet { fn append_idx(path: &Path, idx: usize) -> std::io::Result<()> { use std::io::Write; - let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; writeln!(f, "{idx}") } @@ -657,7 +676,17 @@ async fn chunk_with_retry( if cancel.load(Ordering::SeqCst) { anyhow::bail!("cancelled"); } - match try_download_range_append(client, url, partial, range_start, range_end, cancel, on_progress).await { + match try_download_range_append( + client, + url, + partial, + range_start, + range_end, + cancel, + on_progress, + ) + .await + { Ok(()) => return Ok(()), Err(e) => { let msg = format!("{e:#}"); @@ -673,7 +702,8 @@ async fn chunk_with_retry( } } } - Err(last_err.unwrap_or_else(|| anyhow::anyhow!("chunk failed after {PER_CHUNK_ATTEMPTS} attempts"))) + Err(last_err + .unwrap_or_else(|| anyhow::anyhow!("chunk failed after {PER_CHUNK_ATTEMPTS} attempts"))) } async fn try_download_range_append( @@ -687,12 +717,19 @@ async fn try_download_range_append( ) -> Result<()> { let mut req = client.get(url); req = req.header("Range", format!("bytes={range_start}-{range_end}")); - let resp = req.send().await.with_context(|| format!("HTTP GET {url} failed"))?; + let resp = req + .send() + .await + .with_context(|| format!("HTTP GET {url} failed"))?; let status = resp.status(); if status.as_u16() != 200 && status.as_u16() != 206 { anyhow::bail!("HTTP {status} for {url}"); } - let effective_start = if status.as_u16() == 200 { 0 } else { range_start }; + let effective_start = if status.as_u16() == 200 { + 0 + } else { + range_start + }; // 截断 partial 到本次 attempt 的起点,再 seek 写入。 // 老 append 实现的 bug:若上一次 attempt 已写了部分字节后失败,retry 拿到的还是 @@ -744,7 +781,18 @@ async fn chunk_with_retry_seek( if cancel.load(Ordering::SeqCst) { anyhow::bail!("cancelled"); } - match try_download_range_seek(client, url, partial, range_start, range_end, cancel, bytes_in_file, on_progress).await { + match try_download_range_seek( + client, + url, + partial, + range_start, + range_end, + cancel, + bytes_in_file, + on_progress, + ) + .await + { Ok(()) => return Ok(()), Err(e) => { let msg = format!("{e:#}"); @@ -760,7 +808,11 @@ async fn chunk_with_retry_seek( } } } - Err(last_err.unwrap_or_else(|| anyhow::anyhow!("chunk [{range_start}-{range_end}] failed after {PER_CHUNK_ATTEMPTS} attempts"))) + Err(last_err.unwrap_or_else(|| { + anyhow::anyhow!( + "chunk [{range_start}-{range_end}] failed after {PER_CHUNK_ATTEMPTS} attempts" + ) + })) } async fn try_download_range_seek( @@ -806,7 +858,8 @@ async fn try_download_range_seek( } let bytes = chunk.context("read stream chunk failed")?; file.write_all(&bytes).await.context("write chunk failed")?; - let new_total = bytes_in_file.fetch_add(bytes.len() as u64, Ordering::Relaxed) + bytes.len() as u64; + let new_total = + bytes_in_file.fetch_add(bytes.len() as u64, Ordering::Relaxed) + bytes.len() as u64; on_progress(new_total); } file.flush().await.ok(); diff --git a/openless-all/app/src-tauri/src/asr/local/local_provider.rs b/openless-all/app/src-tauri/src/asr/local/local_provider.rs index 4fe0bd3f..5f9b0b78 100644 --- a/openless-all/app/src-tauri/src/asr/local/local_provider.rs +++ b/openless-all/app/src-tauri/src/asr/local/local_provider.rs @@ -68,10 +68,11 @@ impl LocalQwenAsr { // 走 Tauri 持有的 runtime handle,不依赖调用方上下文(虽然这里目前都在 async 路径上调, // 但保持一致更稳)。 let engine = Arc::clone(&self.engine); - let text = tauri::async_runtime::spawn_blocking(move || engine.transcribe_stream(&samples_f32)) - .await - .context("transcribe spawn_blocking join 失败")? - .context("qwen_transcribe_stream 失败")?; + let text = + tauri::async_runtime::spawn_blocking(move || engine.transcribe_stream(&samples_f32)) + .await + .context("transcribe spawn_blocking join 失败")? + .context("qwen_transcribe_stream 失败")?; // 解绑回调,避免 idle 期 C 端任何后续触发。 self.engine.set_token_handler::(None); diff --git a/openless-all/app/src-tauri/src/asr/local/mod.rs b/openless-all/app/src-tauri/src/asr/local/mod.rs index 1aca579f..52fd1051 100644 --- a/openless-all/app/src-tauri/src/asr/local/mod.rs +++ b/openless-all/app/src-tauri/src/asr/local/mod.rs @@ -16,10 +16,10 @@ mod qwen_engine; #[cfg(target_os = "macos")] mod qwen_ffi; -#[cfg(target_os = "macos")] -pub use qwen_engine::QwenAsrEngine; #[cfg(target_os = "macos")] pub use local_provider::LocalQwenAsr; +#[cfg(target_os = "macos")] +pub use qwen_engine::QwenAsrEngine; pub use download::{DownloadManager, Mirror}; pub use models::{ModelId, ModelStatus}; diff --git a/openless-all/app/src-tauri/src/asr/local/qwen_engine.rs b/openless-all/app/src-tauri/src/asr/local/qwen_engine.rs index 0b298fd8..a6f3242e 100644 --- a/openless-all/app/src-tauri/src/asr/local/qwen_engine.rs +++ b/openless-all/app/src-tauri/src/asr/local/qwen_engine.rs @@ -79,9 +79,8 @@ impl QwenAsrEngine { /// 批式转写:一次性给完整音频(mono f32 16kHz)。 pub fn transcribe_audio(&self, samples: &[f32]) -> Result { // SAFETY: samples 在调用期间存活;返回是 C `malloc` 出的字符串。 - let raw = unsafe { - qwen_transcribe_audio(self.ctx, samples.as_ptr(), samples.len() as i32) - }; + let raw = + unsafe { qwen_transcribe_audio(self.ctx, samples.as_ptr(), samples.len() as i32) }; if raw.is_null() { anyhow::bail!("qwen_transcribe_audio 返回 NULL"); } @@ -95,9 +94,8 @@ impl QwenAsrEngine { /// 流式转写:内部按 2s chunk 切片,token 通过 `set_token_handler` 注册的 /// 回调实时吐出;返回值是最终完整文本。 pub fn transcribe_stream(&self, samples: &[f32]) -> Result { - let raw = unsafe { - qwen_transcribe_stream(self.ctx, samples.as_ptr(), samples.len() as i32) - }; + let raw = + unsafe { qwen_transcribe_stream(self.ctx, samples.as_ptr(), samples.len() as i32) }; if raw.is_null() { anyhow::bail!("qwen_transcribe_stream 返回 NULL"); } diff --git a/openless-all/app/src-tauri/src/asr/local/test_run.rs b/openless-all/app/src-tauri/src/asr/local/test_run.rs index c7e3ec10..07759394 100644 --- a/openless-all/app/src-tauri/src/asr/local/test_run.rs +++ b/openless-all/app/src-tauri/src/asr/local/test_run.rs @@ -50,21 +50,18 @@ pub async fn run_test(model_id: ModelId) -> Result { // qwen_load 是同步阻塞调用且较慢(数秒);扔到 spawn_blocking 不阻塞 tokio runtime。 let load_start = Instant::now(); let dir_for_blocking = dir.clone(); - let engine = tauri::async_runtime::spawn_blocking(move || { - load_engine(&dir_for_blocking) - }) - .await - .map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??; + let engine = tauri::async_runtime::spawn_blocking(move || load_engine(&dir_for_blocking)) + .await + .map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??; let load_ms = load_start.elapsed().as_millis() as u64; // transcribe_audio 也是阻塞 + 重活,同样扔到 blocking pool。 let trans_start = Instant::now(); let engine_clone = Arc::clone(&engine); - let text = tauri::async_runtime::spawn_blocking(move || { - engine_clone.transcribe_audio(&samples) - }) - .await - .map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??; + let text = + tauri::async_runtime::spawn_blocking(move || engine_clone.transcribe_audio(&samples)) + .await + .map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??; let transcribe_ms = trans_start.elapsed().as_millis() as u64; Ok(TestResult { @@ -113,13 +110,17 @@ fn decode_wav_16k_mono(bytes: &[u8]) -> Result> { if body_start + 16 > bytes.len() { anyhow::bail!("fmt chunk 越界"); } - let format = u16::from_le_bytes(bytes[body_start..body_start + 2].try_into().unwrap()); + let format = + u16::from_le_bytes(bytes[body_start..body_start + 2].try_into().unwrap()); if format != 1 { anyhow::bail!("只支持 PCM(format=1),当前 format={format}"); } - channels = u16::from_le_bytes(bytes[body_start + 2..body_start + 4].try_into().unwrap()); - sample_rate = u32::from_le_bytes(bytes[body_start + 4..body_start + 8].try_into().unwrap()); - bits_per_sample = u16::from_le_bytes(bytes[body_start + 14..body_start + 16].try_into().unwrap()); + channels = + u16::from_le_bytes(bytes[body_start + 2..body_start + 4].try_into().unwrap()); + sample_rate = + u32::from_le_bytes(bytes[body_start + 4..body_start + 8].try_into().unwrap()); + bits_per_sample = + u16::from_le_bytes(bytes[body_start + 14..body_start + 16].try_into().unwrap()); } b"data" => { data_offset = body_start; diff --git a/openless-all/app/src-tauri/src/combo_hotkey.rs b/openless-all/app/src-tauri/src/combo_hotkey.rs index 68244825..1e69681b 100644 --- a/openless-all/app/src-tauri/src/combo_hotkey.rs +++ b/openless-all/app/src-tauri/src/combo_hotkey.rs @@ -74,10 +74,13 @@ impl ComboHotkeyMonitor { .register(hotkey) .map_err(|e| ComboHotkeyError::RegisterFailed(e.to_string()))?; + // runtime 已按 hotkey id 分发;这里保留 id 检查作为防线, + // 避免未来误接回进程级事件流后串到其他快捷键。 + let hotkey_id = registered.hotkey().id(); let tx_for_thread = tx.clone(); std::thread::Builder::new() .name("openless-combo-hotkey-forward".into()) - .spawn(move || forward_loop(rx, tx_for_thread)) + .spawn(move || forward_loop(hotkey_id, rx, tx_for_thread)) .map_err(|e| ComboHotkeyError::RegisterFailed(format!("spawn forward thread: {e}")))?; Ok(Self { @@ -102,11 +105,12 @@ impl ComboHotkeyMonitor { let (registered, rx) = runtime .register(next) .map_err(|e| ComboHotkeyError::RegisterFailed(e.to_string()))?; + let hotkey_id = registered.hotkey().id(); std::thread::Builder::new() .name("openless-combo-hotkey-forward".into()) .spawn({ let tx = self.inner.tx.clone(); - move || forward_loop(rx, tx) + move || forward_loop(hotkey_id, rx, tx) }) .map_err(|e| ComboHotkeyError::RegisterFailed(format!("spawn forward thread: {e}")))?; *current = Some(registered); @@ -120,8 +124,11 @@ impl Drop for ComboHotkeyMonitor { } } -fn forward_loop(rx: Receiver, tx: Sender) { +fn forward_loop(hotkey_id: u32, rx: Receiver, tx: Sender) { while let Ok(event) = rx.recv() { + if event.id() != hotkey_id { + continue; + } let combo_event = match event.state() { HotKeyState::Pressed => ComboHotkeyEvent::Pressed, HotKeyState::Released => ComboHotkeyEvent::Released, @@ -225,4 +232,36 @@ mod tests { Err(ComboHotkeyError::UnsupportedKey(_)) )); } + + #[test] + fn forward_loop_ignores_unrelated_hotkey_ids() { + let (event_tx, event_rx) = std::sync::mpsc::channel(); + let (out_tx, out_rx) = std::sync::mpsc::channel(); + + event_tx + .send(GlobalHotKeyEvent { + id: 7, + state: HotKeyState::Pressed, + }) + .unwrap(); + event_tx + .send(GlobalHotKeyEvent { + id: 8, + state: HotKeyState::Released, + }) + .unwrap(); + event_tx + .send(GlobalHotKeyEvent { + id: 8, + state: HotKeyState::Pressed, + }) + .unwrap(); + drop(event_tx); + + forward_loop(8, event_rx, out_tx); + + assert!(matches!(out_rx.recv().unwrap(), ComboHotkeyEvent::Released)); + assert!(matches!(out_rx.recv().unwrap(), ComboHotkeyEvent::Pressed)); + assert!(out_rx.try_recv().is_err()); + } } diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index f692d01d..013061ea 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -95,7 +95,15 @@ impl SettingsWriter for Arc { } } -fn persist_settings(coord: &T, prefs: UserPreferences) -> Result<(), String> { +fn persist_settings( + coord: &T, + mut prefs: UserPreferences, +) -> Result<(), String> { + sync_dictation_hotkey_legacy_fields(&mut prefs); + reject_dictation_translation_hotkey_overlap( + &prefs.dictation_hotkey, + &prefs.translation_hotkey, + )?; coord.write_settings(prefs)?; coord.refresh_dictation_hotkey(); coord.refresh_qa_hotkey(); @@ -166,7 +174,10 @@ pub fn set_credential(account: String, value: String) -> Result<(), String> { } #[tauri::command] -pub fn set_active_asr_provider(coord: CoordinatorState<'_>, provider: String) -> Result<(), String> { +pub fn set_active_asr_provider( + coord: CoordinatorState<'_>, + provider: String, +) -> Result<(), String> { CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string())?; // 切到本地 ASR → 后台预加载模型,下次按 hotkey 时不必等数秒。 if provider == crate::asr::local::PROVIDER_ID { @@ -769,7 +780,9 @@ pub fn set_dictation_hotkey( if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() { reject_dictation_qa_hotkey_overlap(&binding, qa_hotkey)?; } + reject_dictation_translation_hotkey_overlap(&binding, &prefs.translation_hotkey)?; prefs.dictation_hotkey = binding; + sync_dictation_hotkey_legacy_fields(&mut prefs); coord.prefs().set(prefs).map_err(|e| e.to_string())?; coord.update_hotkey_binding(); coord.update_combo_hotkey_binding(); @@ -782,10 +795,18 @@ pub fn set_translation_hotkey( binding: ShortcutBinding, ) -> Result<(), String> { crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; - let mut prefs = coord.prefs().get(); + let previous = coord.prefs().get(); + reject_dictation_translation_hotkey_overlap(&previous.dictation_hotkey, &binding)?; + let mut prefs = previous.clone(); prefs.translation_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; - coord.update_translation_hotkey_binding(); + if let Err(e) = coord.try_update_translation_hotkey_binding() { + if let Err(rollback_err) = coord.prefs().set(previous) { + log::warn!("[commands] 回滚翻译快捷键失败: {rollback_err}"); + } + coord.update_translation_hotkey_binding(); + return Err(e); + } Ok(()) } @@ -850,9 +871,10 @@ pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> R if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() { reject_dictation_qa_hotkey_overlap(&shortcut, qa_hotkey)?; } + reject_dictation_translation_hotkey_overlap(&shortcut, &prefs.translation_hotkey)?; prefs.custom_combo_hotkey = Some(binding); prefs.dictation_hotkey = shortcut; - prefs.hotkey.trigger = crate::types::HotkeyTrigger::Custom; + sync_dictation_hotkey_legacy_fields(&mut prefs); coord.prefs().set(prefs).map_err(|e| e.to_string())?; coord.update_hotkey_binding(); coord.update_combo_hotkey_binding(); @@ -866,6 +888,24 @@ fn reject_bare_shift_dictation_shortcut(binding: &ShortcutBinding) -> Result<(), Ok(()) } +fn sync_dictation_hotkey_legacy_fields(prefs: &mut UserPreferences) { + if let Some(trigger) = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey) + { + prefs.hotkey.trigger = trigger; + prefs.custom_combo_hotkey = None; + return; + } + prefs.hotkey.trigger = crate::types::HotkeyTrigger::Custom; + prefs.custom_combo_hotkey = if prefs.dictation_hotkey.primary.trim().is_empty() { + None + } else { + Some(ComboBinding { + primary: prefs.dictation_hotkey.primary.clone(), + modifiers: prefs.dictation_hotkey.modifiers.clone(), + }) + }; +} + fn reject_dictation_qa_hotkey_overlap( dictation: &ShortcutBinding, qa: &ShortcutBinding, @@ -876,6 +916,16 @@ fn reject_dictation_qa_hotkey_overlap( Ok(()) } +fn reject_dictation_translation_hotkey_overlap( + dictation: &ShortcutBinding, + translation: &ShortcutBinding, +) -> Result<(), String> { + if shortcut_bindings_overlap(dictation, translation) { + return Err("翻译快捷键不能和听写快捷键相同".into()); + } + Ok(()) +} + fn shortcut_bindings_overlap(left: &ShortcutBinding, right: &ShortcutBinding) -> bool { let left_legacy = crate::shortcut_binding::legacy_modifier_trigger(left); let right_legacy = crate::shortcut_binding::legacy_modifier_trigger(right); @@ -923,7 +973,10 @@ pub fn local_asr_get_settings(coord: CoordinatorState<'_>) -> LocalAsrSettings { } #[tauri::command] -pub fn local_asr_set_active_model(coord: CoordinatorState<'_>, model_id: String) -> Result<(), String> { +pub fn local_asr_set_active_model( + coord: CoordinatorState<'_>, + model_id: String, +) -> Result<(), String> { if ModelId::from_str(&model_id).is_none() { return Err(format!("unknown model id: {model_id}")); } @@ -1151,7 +1204,7 @@ mod tests { .unwrap() .clone() .expect("settings saved"); - assert_eq!(saved.hotkey.trigger, prefs.hotkey.trigger); + assert_eq!(saved.hotkey.trigger, HotkeyTrigger::RightOption); assert_eq!(saved.hotkey.mode, prefs.hotkey.mode); assert_eq!( saved.qa_hotkey.unwrap().primary, @@ -1162,6 +1215,79 @@ mod tests { assert_eq!(*writer.combo_refreshes.lock().unwrap(), 1); } + #[test] + fn sync_dictation_hotkey_sets_modifier_trigger_and_clears_combo() { + let mut prefs = UserPreferences { + hotkey: HotkeyBinding { + trigger: HotkeyTrigger::Custom, + mode: HotkeyMode::Toggle, + }, + custom_combo_hotkey: Some(ComboBinding { + primary: "D".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }), + dictation_hotkey: ShortcutBinding { + primary: "RightControl".into(), + modifiers: vec![], + }, + ..Default::default() + }; + + super::sync_dictation_hotkey_legacy_fields(&mut prefs); + + assert_eq!(prefs.hotkey.trigger, HotkeyTrigger::RightControl); + assert!(prefs.custom_combo_hotkey.is_none()); + } + + #[test] + fn sync_dictation_hotkey_sets_custom_trigger_and_combo_binding() { + let mut prefs = UserPreferences { + hotkey: HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Toggle, + }, + dictation_hotkey: ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }, + ..Default::default() + }; + + super::sync_dictation_hotkey_legacy_fields(&mut prefs); + + assert_eq!(prefs.hotkey.trigger, HotkeyTrigger::Custom); + let combo = prefs.custom_combo_hotkey.expect("combo binding saved"); + assert_eq!(combo.primary, "D"); + assert_eq!( + combo.modifiers, + vec!["cmd".to_string(), "shift".to_string()] + ); + } + + #[test] + fn sync_dictation_hotkey_clears_empty_custom_binding() { + let mut prefs = UserPreferences { + hotkey: HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Toggle, + }, + custom_combo_hotkey: Some(ComboBinding { + primary: "D".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }), + dictation_hotkey: ShortcutBinding { + primary: " ".into(), + modifiers: vec!["cmd".into()], + }, + ..Default::default() + }; + + super::sync_dictation_hotkey_legacy_fields(&mut prefs); + + assert_eq!(prefs.hotkey.trigger, HotkeyTrigger::Custom); + assert!(prefs.custom_combo_hotkey.is_none()); + } + #[test] fn validate_combo_hotkey_rejects_bare_shift() { let result = super::validate_combo_hotkey(ComboBinding { @@ -1229,6 +1355,72 @@ mod tests { assert!(super::reject_dictation_qa_hotkey_overlap(&dictation, &qa).is_ok()); } + #[test] + fn dictation_translation_overlap_rejects_same_modifier_only_binding() { + let binding = ShortcutBinding { + primary: "RightControl".into(), + modifiers: vec![], + }; + + assert_eq!( + super::reject_dictation_translation_hotkey_overlap(&binding, &binding), + Err("翻译快捷键不能和听写快捷键相同".into()) + ); + } + + #[test] + fn dictation_translation_overlap_rejects_same_combo_binding() { + let dictation = ShortcutBinding { + primary: "T".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }; + let translation = ShortcutBinding { + primary: "T".into(), + modifiers: vec!["control".into(), "shift".into()], + }; + + assert_eq!( + super::reject_dictation_translation_hotkey_overlap(&dictation, &translation), + Err("翻译快捷键不能和听写快捷键相同".into()) + ); + } + + #[test] + fn dictation_translation_overlap_allows_distinct_bindings() { + let dictation = ShortcutBinding { + primary: "RightControl".into(), + modifiers: vec![], + }; + let translation = ShortcutBinding { + primary: "Shift".into(), + modifiers: vec![], + }; + + assert!( + super::reject_dictation_translation_hotkey_overlap(&dictation, &translation).is_ok() + ); + } + + #[test] + fn persist_settings_rejects_dictation_translation_overlap() { + let writer = FakeSettingsWriter::default(); + let binding = ShortcutBinding { + primary: "RightControl".into(), + modifiers: vec![], + }; + let prefs = UserPreferences { + dictation_hotkey: binding.clone(), + translation_hotkey: binding, + ..Default::default() + }; + + assert_eq!( + persist_settings(&writer, prefs), + Err("翻译快捷键不能和听写快捷键相同".into()) + ); + assert!(writer.saved.lock().unwrap().is_none()); + } + #[tokio::test] async fn fetch_provider_models_omits_authorization_when_api_key_is_empty() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index d55e7ccf..aa018bdb 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -268,12 +268,16 @@ impl Coordinator { let inner = Arc::clone(&self.inner); tauri::async_runtime::spawn(async move { let prefs = inner.prefs.get(); - let model_id = match crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) { - Some(m) => m, - None => return, - }; + let model_id = + match crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) { + Some(m) => m, + None => return, + }; if !crate::asr::local::models::is_downloaded(model_id) { - log::info!("[coord] local ASR preload skipped: model {} not downloaded", model_id.as_str()); + log::info!( + "[coord] local ASR preload skipped: model {} not downloaded", + model_id.as_str() + ); return; } let dir = match crate::asr::local::models::model_dir(model_id) { @@ -404,14 +408,14 @@ impl Coordinator { pub fn update_combo_hotkey_binding(&self) { let prefs = self.inner.prefs.get(); if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() { - // 不是 Custom → drop combo monitor(如果有的话) + // 修饰键单键由 HotkeyMonitor 处理,组合键 monitor 要释放。 take_combo_hotkey_on_main_thread(&self.inner); - log::info!("[coord] combo hotkey 已关闭(trigger != Custom)"); + log::info!("[coord] combo hotkey 已关闭(modifier-only)"); return; } let binding = prefs.dictation_hotkey.clone(); - if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() { - // trigger == Custom 但没绑定 → drop monitor + if is_unconfigured_shortcut(&binding) { + // Custom 但没录到有效主键:清掉旧 monitor,避免旧快捷键继续生效。 take_combo_hotkey_on_main_thread(&self.inner); log::info!("[coord] combo hotkey 已关闭(无绑定)"); return; @@ -524,6 +528,12 @@ impl Coordinator { } pub fn update_translation_hotkey_binding(&self) { + if let Err(e) = self.try_update_translation_hotkey_binding() { + log::warn!("[coord] update translation hotkey binding 失败: {e}"); + } + } + + pub fn try_update_translation_hotkey_binding(&self) -> Result<(), String> { let prefs = self.inner.prefs.get(); if is_builtin_translation_shift(&prefs.translation_hotkey) || crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey).is_some() @@ -531,36 +541,24 @@ impl Coordinator { take_translation_hotkey_on_main_thread(&self.inner); self.update_modifier_shortcut_bindings(); log::info!("[coord] translation hotkey uses modifier-only listener"); - return; + return Ok(()); } self.update_modifier_shortcut_bindings(); let app = self.inner.app.lock().clone(); let Some(app) = app else { - log::warn!("[coord] update translation hotkey binding: AppHandle 未 bind,跳过"); - return; + return Err("AppHandle 未 bind,无法注册翻译快捷键".into()); }; let inner_clone = Arc::clone(&self.inner); let binding_for_main = prefs.translation_hotkey.clone(); + let (result_tx, result_rx) = mpsc::sync_channel::>(1); let _ = app.run_on_main_thread(move || { - if let Some(monitor) = inner_clone.translation_hotkey.lock().as_ref() { - if let Err(e) = monitor.update_binding(binding_for_main.clone()) { - log::warn!("[coord] update translation hotkey binding 失败: {e}"); - } - return; - } - let (tx, rx) = mpsc::channel::(); - match ComboHotkeyMonitor::start(binding_for_main, tx) { - Ok(monitor) => { - *inner_clone.translation_hotkey.lock() = Some(monitor); - let bridge_inner = Arc::clone(&inner_clone); - std::thread::Builder::new() - .name("openless-translation-hotkey-bridge".into()) - .spawn(move || translation_hotkey_bridge_loop(bridge_inner, rx)) - .ok(); - } - Err(e) => log::warn!("[coord] update translation hotkey binding 失败: {e}"), - } + let result = update_translation_hotkey_on_main_thread(inner_clone, binding_for_main); + let _ = result_tx.send(result.map_err(|e| e.to_string())); }); + match result_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(result) => result, + Err(_) => Err("注册翻译快捷键超时".into()), + } } pub fn update_switch_style_hotkey_binding(&self) { @@ -961,6 +959,11 @@ fn combo_hotkey_supervisor_loop(inner: Arc) { } 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)); @@ -1111,6 +1114,24 @@ fn translation_hotkey_supervisor_loop(inner: Arc) { } } +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) { @@ -1323,6 +1344,10 @@ fn is_modifier_only_shortcut(binding: &crate::types::ShortcutBinding) -> bool { || 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", @@ -2862,7 +2887,9 @@ fn schedule_local_asr_release(inner: &Arc) { } #[cfg(target_os = "macos")] -async fn build_local_qwen3(inner: &Arc) -> anyhow::Result> { +async fn build_local_qwen3( + inner: &Arc, +) -> anyhow::Result> { let prefs = inner.prefs.get(); let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) .ok_or_else(|| anyhow::anyhow!("未知本地模型 id: {}", prefs.local_asr_active_model))?; diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 99266901..2447813f 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -451,7 +451,11 @@ impl PreferencesStore { let path = dir.join(PREFERENCES_FILE); let prefs = if path.exists() { read_or_default::(&path).unwrap_or_else(|e| { - log::warn!("[prefs] load {} failed, using defaults: {}", path.display(), e); + log::warn!( + "[prefs] load {} failed, using defaults: {}", + path.display(), + e + ); UserPreferences::default() }) } else { @@ -756,7 +760,8 @@ mod tests { #[test] fn vocab_presets_roundtrip_json_file() { - let tmp: PathBuf = std::env::temp_dir().join(format!("openless-test-{}", uuid::Uuid::new_v4())); + let tmp: PathBuf = + std::env::temp_dir().join(format!("openless-test-{}", uuid::Uuid::new_v4())); fs::create_dir_all(&tmp).expect("create temp dir"); // Linux path helper uses XDG_DATA_HOME first. unsafe { @@ -775,7 +780,10 @@ mod tests { let loaded = list_vocab_presets().expect("list presets"); assert_eq!(loaded.custom.len(), 1); assert_eq!(loaded.custom[0].id, "test"); - assert_eq!(loaded.custom[0].phrases, vec!["PR".to_string(), "CI".to_string()]); + assert_eq!( + loaded.custom[0].phrases, + vec!["PR".to_string(), "CI".to_string()] + ); assert_eq!(loaded.disabled_builtin_preset_ids, vec!["chef".to_string()]); let _ = fs::remove_dir_all(&tmp); } diff --git a/openless-all/app/src-tauri/src/qa_hotkey.rs b/openless-all/app/src-tauri/src/qa_hotkey.rs index 798e2f0c..28635587 100644 --- a/openless-all/app/src-tauri/src/qa_hotkey.rs +++ b/openless-all/app/src-tauri/src/qa_hotkey.rs @@ -78,12 +78,13 @@ impl QaHotkeyMonitor { .register(hotkey) .map_err(|e| QaHotkeyError::RegisterFailed(e.to_string()))?; - // 启动转发线程:消费 global-hotkey 的进程级 channel,filter id 后投递到上层 tx。 - // global-hotkey 用 crossbeam_channel,自带超时 recv,便于优雅退出。 + // 启动转发线程:runtime 已按 hotkey id 分发;这里保留 id 检查作为防线, + // 避免未来误接回进程级事件流后串到其他快捷键。 + let hotkey_id = registered.hotkey().id(); let tx_for_thread = tx.clone(); std::thread::Builder::new() .name("openless-qa-hotkey-forward".into()) - .spawn(move || forward_loop(rx, tx_for_thread)) + .spawn(move || forward_loop(hotkey_id, rx, tx_for_thread)) .map_err(|e| QaHotkeyError::RegisterFailed(format!("spawn forward thread: {e}")))?; Ok(Self { @@ -108,12 +109,13 @@ impl QaHotkeyMonitor { let (registered, rx) = runtime .register(next) .map_err(|e| QaHotkeyError::RegisterFailed(e.to_string()))?; + let hotkey_id = registered.hotkey().id(); // Keep event forwarding alive for the replacement registration. std::thread::Builder::new() .name("openless-qa-hotkey-forward".into()) .spawn({ let tx = self.inner.tx.clone(); - move || forward_loop(rx, tx) + move || forward_loop(hotkey_id, rx, tx) }) .map_err(|e| QaHotkeyError::RegisterFailed(format!("spawn forward thread: {e}")))?; *current = Some(registered); @@ -127,8 +129,11 @@ impl Drop for QaHotkeyMonitor { } } -fn forward_loop(rx: Receiver, tx: Sender) { +fn forward_loop(hotkey_id: u32, rx: Receiver, tx: Sender) { while let Ok(event) = rx.recv() { + if event.id() != hotkey_id { + continue; + } if !matches!(event.state(), HotKeyState::Pressed) { continue; } @@ -217,4 +222,35 @@ mod tests { assert!(parsed.mods.contains(Modifiers::SUPER)); } } + + #[test] + fn forward_loop_ignores_unrelated_hotkey_ids() { + let (event_tx, event_rx) = std::sync::mpsc::channel(); + let (out_tx, out_rx) = std::sync::mpsc::channel(); + + event_tx + .send(GlobalHotKeyEvent { + id: 41, + state: HotKeyState::Pressed, + }) + .unwrap(); + event_tx + .send(GlobalHotKeyEvent { + id: 42, + state: HotKeyState::Released, + }) + .unwrap(); + event_tx + .send(GlobalHotKeyEvent { + id: 42, + state: HotKeyState::Pressed, + }) + .unwrap(); + drop(event_tx); + + forward_loop(42, event_rx, out_tx); + + assert!(matches!(out_rx.recv().unwrap(), QaHotkeyEvent::Pressed)); + assert!(out_rx.try_recv().is_err()); + } } diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index 4057abdb..b558fc9d 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -115,14 +115,15 @@ fn run_audio_thread( startup_tx: Sender>, runtime_error_tx: Sender, ) { - let (stream, state) = match build_input_stream(consumer, level_handler, runtime_error_tx.clone()) { - Ok(s) => s, - Err(err) => { - // 启动失败:通知主线程后即退出。 - let _ = startup_tx.send(Err(err)); - return; - } - }; + let (stream, state) = + match build_input_stream(consumer, level_handler, runtime_error_tx.clone()) { + Ok(s) => s, + Err(err) => { + // 启动失败:通知主线程后即退出。 + let _ = startup_tx.send(Err(err)); + return; + } + }; if let Err(err) = stream.play() { let _ = startup_tx.send(Err(RecorderError::EngineFailed(format!("play: {err}")))); @@ -133,9 +134,9 @@ fn run_audio_thread( let _ = startup_tx.send(Ok(())); // 启动 liveness watchdog 线程:检测录音回调是否静默停止 - const WATCHDOG_CHECK_INTERVAL_MS: u64 = 1000; // 每秒检查一次 - const CALLBACK_TIMEOUT_SECS: u64 = 3; // 3 秒没有回调视为异常 - const FIRST_CALLBACK_DEADLINE_SECS: u64 = 5; // 5 秒内必须收到首次回调 + const WATCHDOG_CHECK_INTERVAL_MS: u64 = 1000; // 每秒检查一次 + const CALLBACK_TIMEOUT_SECS: u64 = 3; // 3 秒没有回调视为异常 + const FIRST_CALLBACK_DEADLINE_SECS: u64 = 5; // 5 秒内必须收到首次回调 let stop_flag_for_watchdog = Arc::clone(&stop_flag); let state_for_watchdog = Arc::clone(&state); @@ -160,10 +161,11 @@ fn run_audio_thread( "[recorder] watchdog: 录音回调已停止 {} 秒,触发错误恢复", elapsed.as_secs() ); - let _ = runtime_error_tx_for_watchdog.send(RecorderError::EngineFailed( - format!("录音回调静默停止 {} 秒", elapsed.as_secs()) - )); - break; // 只报告一次 + let _ = + runtime_error_tx_for_watchdog.send(RecorderError::EngineFailed( + format!("录音回调静默停止 {} 秒", elapsed.as_secs()), + )); + break; // 只报告一次 } } None => { @@ -174,10 +176,11 @@ fn run_audio_thread( "[recorder] watchdog: {} 秒内未收到首次回调,触发错误恢复", elapsed.as_secs() ); - let _ = runtime_error_tx_for_watchdog.send(RecorderError::EngineFailed( - format!("录音启动后 {} 秒内未收到回调", elapsed.as_secs()) - )); - break; // 只报告一次 + let _ = + runtime_error_tx_for_watchdog.send(RecorderError::EngineFailed( + format!("录音启动后 {} 秒内未收到回调", elapsed.as_secs()), + )); + break; // 只报告一次 } } } diff --git a/openless-all/app/src-tauri/src/windows_ime_ipc.rs b/openless-all/app/src-tauri/src/windows_ime_ipc.rs index b5585bf0..79f6ae5d 100644 --- a/openless-all/app/src-tauri/src/windows_ime_ipc.rs +++ b/openless-all/app/src-tauri/src/windows_ime_ipc.rs @@ -392,10 +392,7 @@ mod tests { #[test] fn submit_timeout_covers_native_async_commit_path() { - assert!( - IME_SUBMIT_TIMEOUT - > Duration::from_millis(IME_NATIVE_ASYNC_COMMIT_TIMEOUT_MS) - ); + assert!(IME_SUBMIT_TIMEOUT > Duration::from_millis(IME_NATIVE_ASYNC_COMMIT_TIMEOUT_MS)); } #[test]