fix(hotkey): guard shortcut registration conflicts
This commit is contained in:
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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user