fix(commands): wrap tray refresh in run_on_main_thread to avoid macOS UI freeze (#380)

set_settings 和 set_default_polish_mode 是同步 Tauri commands,跑在 IPC handler
线程。它们直接调 refresh_tray_microphone_menu,里面 tray.set_menu(...) 改 macOS
NSStatusItem 必须在主线程上做。从 IPC 线程直调会触发 macOS dispatch queue
死锁,导致用户改任何偏好开关后整个 UI 永久卡死、所有按键无响应。

修复:把两处 tray 刷新都包到 app.run_on_main_thread(move || ...) dispatch 到主线程,
IPC 线程立即返回不阻塞。这跟 lib.rs:558 里 start_tray_microphone_watcher 已经
在用的 pattern 完全一致(issue #169 stop_qa_hotkey_listener 同样用法)。

防御性扫描结论:整个项目里 tray.set_menu() 只在 refresh_tray_microphone_menu
(lib.rs:518)一处出现,所有 5 个调用点已确认安全:
  - lib.rs:207 tray hover event:tray 事件本身就在主线程
  - lib.rs:559 麦克风设备 watcher:已有 run_on_main_thread
  - lib.rs:605 tray 菜单事件:本身在主线程
  - commands.rs:160 set_settings:本提交修复
  - commands.rs:944 set_default_polish_mode:本提交修复

其它 sync IPC commands(set_dictation/qa/translation/switch_style/open_app_hotkey)
通过 coord.refresh_*_hotkey() 间接刷新,那些函数内部已包了 run_on_main_thread;
不碰 AppKit 的 commands 安全。

Co-authored-by: baiqing <lbx12309@icloud.com>
This commit is contained in:
TRIPandbaiqing authored and GitHub committed 2026-05-09 16:29:45 +08:00
1 parent feadbfa70b
commit 14bfa5f749
1 file changed
+24 -7
+24 -7
View File
@@ -150,10 +150,22 @@ pub fn set_settings(
// 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时
// 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。
persist_settings(&*coord, prefs.clone())?;
if let Err(err) = crate::refresh_tray_microphone_menu(&app) {
log::warn!("[tray] refresh microphone menu after settings save failed: {err}");
sync_tray_microphone_selection(&tray_microphones.lock(), &prefs.microphone_device_name);
}
// refresh_tray_microphone_menu 内部会调用 NSStatusItem.set_menu,必须在主线程上跑。
// set_settings 本身是同步 Tauri command,在 IPC handler 线程上执行;从这里直接调
// 会触发 macOS 主线程断言或在 dispatch 队列上死锁,导致整个 UI 无响应(用户改
// 偏好后所有按键都没反应即此根因)。dispatch 到主线程后立即返回,IPC 线程不阻塞。
let app_for_main = app.clone();
let prefs_for_main = prefs.clone();
let _ = app.run_on_main_thread(move || {
if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) {
log::warn!("[tray] refresh microphone menu after settings save failed: {err}");
let tray_state = app_for_main.state::<TrayMicrophoneMenuState>();
sync_tray_microphone_selection(&tray_state.lock(), &prefs_for_main.microphone_device_name);
}
});
// 抑制 unused 警告:tray_microphones 现在改在闭包里通过 app.state 取,
// 但函数签名保留 State 入参,以便 Tauri 在调用前注入。
let _ = tray_microphones;
let _ = app.emit("prefs:changed", &prefs);
Ok(())
}
@@ -929,9 +941,14 @@ pub fn set_default_polish_mode(
let mut prefs = coord.prefs().get();
prefs.default_mode = mode;
coord.prefs().set(prefs.clone()).map_err(|e| e.to_string())?;
if let Err(err) = crate::refresh_tray_microphone_menu(&app) {
log::warn!("[tray] refresh style menu after polish mode IPC change failed: {err}");
}
// 跟 set_settings 同样:refresh_tray_microphone_menu 里 tray.set_menu 改 NSStatusItem,
// 必须主线程;这里是同步 Tauri command 跑在 IPC 线程,直调会让 macOS 死锁。
let app_for_main = app.clone();
let _ = app.run_on_main_thread(move || {
if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) {
log::warn!("[tray] refresh style menu after polish mode IPC change failed: {err}");
}
});
let _ = app.emit("prefs:changed", &prefs);
let _ = app.emit_to("main", "prefs:changed", &prefs);
Ok(())