From 1bb35c9190bd036423bac73ab472e431b93198ac Mon Sep 17 00:00:00 2001 From: baiqing Date: Sat, 9 May 2026 16:08:31 +0800 Subject: [PATCH] fix(commands): wrap tray refresh in run_on_main_thread to avoid macOS UI freeze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 安全。 --- openless-all/app/src-tauri/src/commands.rs | 31 +++++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index c82ca838..723d9ea6 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -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::(); + 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(())