//! 托盘常驻:图标 + 菜单[运行态(纯展示)/显示主窗口/退出]。关窗口不退出,托盘退出才真正结束进程。 //! //! - 主窗口 CloseRequested 被 lib.rs 拦截(prevent_close + hide),进程常驻托盘 //! - 托盘菜单「显示主窗口」唤起 / 「退出」app.exit(0) 真正结束 //! - TrayHandle 经 app.manage 保活,防托盘图标被 GC 移除 //! - P1 bg 化:运行态菜单项 + tooltip 随 ConvState 迁移刷新(guard.transition 挂钩 update_tray_runtime) //! - i18n:文案按系统 locale 取中/英双语静态映射(locale 判定一次缓存),无 Rust i18n 机制的最小实现 use std::sync::OnceLock; use tauri::{ AppHandle, Manager, menu::{Menu, MenuItem}, tray::TrayIconBuilder, }; use crate::state::AppState; /// 托盘文案双语映射(系统 locale 判定一次,进程内缓存;判定失败回退中文——应用主语言)。 /// 菜单项与运行态文本分两处消费,映射同源防两处漂移。 struct TrayTexts { status_idle: &'static str, status_running: &'static str, show: &'static str, quit: &'static str, tooltip_running: &'static str, } /// 按系统 locale 取托盘文案(判定一次缓存)。zh* → 中文,其余(含判定失败)→ 中文/英文 /// 各取其一:locale 明确非 zh 用英文,判定失败(None)回退中文(应用主语言)。 fn tray_texts() -> &'static TrayTexts { static TEXTS: OnceLock = OnceLock::new(); TEXTS.get_or_init(|| { let zh = sys_locale::get_locale() .map(|l| l.to_ascii_lowercase().starts_with("zh")) // None = 系统locale不可得,回退中文(应用默认语言),与首次安装体验一致 .unwrap_or(true); if zh { TrayTexts { status_idle: "无运行中会话", status_running: "运行中 {} 个会话", show: "显示主窗口", quit: "退出", tooltip_running: "DevFlow (运行中 {} 个会话)", } } else { TrayTexts { status_idle: "No running sessions", status_running: "{} session(s) running", show: "Show Main Window", quit: "Quit", tooltip_running: "DevFlow ({} session(s) running)", } } }) } /// managed state 保活,防 GC 移除托盘图标。 /// tray 持 TrayIcon 本体(保活为主,update_tray_runtime 借用刷 tooltip); /// status 为运行态纯展示项句柄,供 update_tray_runtime 刷文本。 pub struct TrayHandle { /// TrayIcon 本体,主要职责是经 manage 保活防 GC。 pub tray: tauri::tray::TrayIcon, /// 运行态菜单项(enabled=false 纯展示),文本由 update_tray_runtime 随会话运行数刷新。 pub status: MenuItem, } /// 创建托盘:图标 + 菜单(运行态/显示主窗口/退出)。 pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { let t = tray_texts(); // 运行态纯展示项:enabled=false 置灰不响应点击,文本由 update_tray_runtime 维护 let status = MenuItem::with_id(app, "status", t.status_idle, false, None::<&str>)?; let show = MenuItem::with_id(app, "show", t.show, true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", t.quit, true, None::<&str>)?; let menu = Menu::with_items(app, &[&status, &show, &quit])?; // 复用窗口图标(tauri.conf.json bundle.icon 已配置),缺则 panic 提示配置 let icon = app .default_window_icon() .cloned() .expect("窗口图标缺失(tauri.conf.json icons 已配置)"); let tray = TrayIconBuilder::with_id("devflow-tray") .icon(icon) .tooltip("DevFlow (MCP server running)") .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(|app, event| match event.id().as_ref() { "show" => show_main(app), "quit" => app.exit(0), // 真正退出 _ => {} }) .build(app)?; app.manage(TrayHandle { tray, status }); Ok(()) } /// 同步托盘运行态:状态项文本 + tooltip 反映当前运行中 AI 会话数。 /// /// 挂钩点:guard.rs GeneratingGuard::transition(每次 ConvState 迁移必经且已持 app_handle)。 /// set_text/set_tooltip 均为同步 API,本函数保持同步,同步上下文直调不引入 await。 /// AppState / TrayHandle 任一未注入(setup 早期)静默跳过。 pub fn update_tray_runtime(app: &AppHandle) { let (Some(handle), Some(state)) = (app.try_state::(), app.try_state::()) else { return; }; let t = tray_texts(); let n = state.conv_states.active_convs().len(); let text = if n > 0 { t.status_running.replace("{}", &n.to_string()) } else { t.status_idle.to_string() }; let tooltip = if n > 0 { t.tooltip_running.replace("{}", &n.to_string()) } else { "DevFlow (MCP server running)".to_string() }; let _ = handle.status.set_text(text); let _ = handle.tray.set_tooltip(Some(tooltip)); } /// 唤起主窗口:show + unminimize + set_focus(单实例二次启动 / 托盘菜单共用)。 pub(crate) fn show_main(app: &AppHandle) { if let Some(w) = app.get_webview_window("main") { let _ = w.show(); let _ = w.unminimize(); let _ = w.set_focus(); } }