fix(hotkey): guard shortcut registration conflicts

This commit is contained in:
Leslie Leung committed 2026-05-05 18:23:43 +08:00
1 parent 5426d878e2
commit edb1bc506b
13 files changed
+478 -117

No files matched your search

@@ -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
);
}
}
}
@@ -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<Vec<AtomicU64>> = Arc::new(
info.files.iter().map(|_| AtomicU64::new(0)).collect()
);
let in_flight_bytes: Arc<Vec<AtomicU64>> =
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<usize> {
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();
@@ -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::<fn(&str)>(None);
@@ -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};
@@ -79,9 +79,8 @@ impl QwenAsrEngine {
/// 批式转写:一次性给完整音频(mono f32 16kHz)。
pub fn transcribe_audio(&self, samples: &[f32]) -> Result<String> {
// 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<String> {
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");
}
@@ -50,21 +50,18 @@ pub async fn run_test(model_id: ModelId) -> Result<TestResult> {
// 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<Vec<f32>> {
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;
+42 -3
View File
@@ -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<GlobalHotKeyEvent>, tx: Sender<ComboHotkeyEvent>) {
fn forward_loop(hotkey_id: u32, rx: Receiver<GlobalHotKeyEvent>, tx: Sender<ComboHotkeyEvent>) {
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());
}
}
+199 -7
View File
@@ -95,7 +95,15 @@ impl SettingsWriter for Arc<Coordinator> {
}
}
fn persist_settings<T: SettingsWriter>(coord: &T, prefs: UserPreferences) -> Result<(), String> {
fn persist_settings<T: SettingsWriter>(
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();
+58 -31
View File
@@ -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::<Result<(), String>>(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::<ComboHotkeyEvent>();
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<Inner>) {
}
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<Inner>) {
}
}
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(())
}
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) {
@@ -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<Inner>) {
}
#[cfg(target_os = "macos")]
async fn build_local_qwen3(inner: &Arc<Inner>) -> anyhow::Result<Arc<crate::asr::local::LocalQwenAsr>> {
async fn build_local_qwen3(
inner: &Arc<Inner>,
) -> anyhow::Result<Arc<crate::asr::local::LocalQwenAsr>> {
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))?;
+11 -3
View File
@@ -451,7 +451,11 @@ impl PreferencesStore {
let path = dir.join(PREFERENCES_FILE);
let prefs = if path.exists() {
read_or_default::<UserPreferences>(&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);
}
+41 -5
View File
@@ -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<GlobalHotKeyEvent>, tx: Sender<QaHotkeyEvent>) {
fn forward_loop(hotkey_id: u32, rx: Receiver<GlobalHotKeyEvent>, tx: Sender<QaHotkeyEvent>) {
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());
}
}
+22 -19
View File
@@ -115,14 +115,15 @@ fn run_audio_thread(
startup_tx: Sender<Result<(), RecorderError>>,
runtime_error_tx: Sender<RecorderError>,
) {
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; // 只报告一次
}
}
}
@@ -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]