//! tray_menu.rs — 自定义系统托盘右键菜单 //! //! 菜单结构: //! 代理 ▸ //! 开启代理 (启动mihomo + 择优选节点 + 系统代理 + Windows通知) //! 关闭代理 //! --- //! 选择代理 ▸ //! 刷新节点列表 //! --- //! 节点1 (120ms) //! 节点2 (超时) //! ... //! --- //! 切换 OSD //! 重启 Kernel //! --- //! 新建下载 //! --- //! 常规设置 //! 退出 use std::sync::{Mutex, OnceLock}; use tauri::{ AppHandle, Emitter, Manager, menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}, tray::{MouseButton, TrayIconBuilder, TrayIconEvent, TrayIconId}, }; use crate::clipboard::ClipboardManager; use crate::download_engine::DownloadEngine; use crate::mihomo_manager::MihomoManager; use crate::monitor_kernel::MonitorKernel; use crate::process_manager::{ProcessManager, ProcessStatus}; // ===== 全局状态:代理节点菜单项映射 ===== /// menu_id → (group, node_name) static PROXY_NODE_MAP: OnceLock>> = OnceLock::new(); fn proxy_node_map() -> &'static Mutex> { PROXY_NODE_MAP.get_or_init(|| Mutex::new(Vec::new())) } // ===== 伪节点过滤(与前端 ProxyModule 保持一致) ===== const PSEUDO_KEYWORDS: &[&str] = &[ "DIRECT", "REJECT", "PASS", "COMPATIBLE", "流量", "套餐", "到期", "续费", "官网", "网站", "刷新", "更新", "⭐", "★", "☆", "✕", "✖", "×", ]; fn is_pseudo_node(name: &str) -> bool { let upper = name.trim().to_uppercase(); if upper == "DIRECT" || upper == "REJECT" || upper == "PASS" || upper == "GLOBAL" { return true; } PSEUDO_KEYWORDS.iter().any(|kw| name.contains(kw)) } // ===== 状态查询 ===== fn is_proxy_running(app: &AppHandle) -> bool { if let Some(pm) = app.try_state::() { if let Some(info) = pm.get_status("proxy") { return matches!(info.status, ProcessStatus::Running); } } false } fn is_monitor_running(app: &AppHandle) -> bool { if let Some(pm) = app.try_state::() { if let Some(info) = pm.get_status("monitor") { if matches!(info.status, ProcessStatus::Running) { return true; } } } if let Some(monitor) = app.try_state::() { if monitor.is_elevated() { return true; } } false } // ===== 从 mihomo /proxies 响应中提取主选择组及其节点 ===== /// 返回 (组名, 有效节点列表, 当前节点) fn parse_main_group(proxies_response: &serde_json::Value) -> Option<(String, Vec, String)> { let proxies = proxies_response.get("proxies")?.as_object()?; // 收集所有可选择组 let selector_groups: Vec<&str> = proxies .iter() .filter(|(_, v)| { v.get("type").and_then(|t| t.as_str()).map(|t| t == "Selector").unwrap_or(false) }) .map(|(k, _)| k.as_str()) .collect(); if selector_groups.is_empty() { return None; } // 优先选择名为 PROXY / Proxy / 节点选择 / 代理 的组 let preferred = selector_groups .iter() .find(|name| matches!(**name, "PROXY" | "Proxy" | "节点选择" | "代理")) .copied(); let group_name = preferred.unwrap_or(selector_groups[0]); let group = &proxies[group_name]; let now = group.get("now").and_then(|n| n.as_str()).unwrap_or("").to_string(); let all: Vec = group .get("all") .and_then(|a| a.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) .filter(|n| !is_pseudo_node(n)) .collect() }) .unwrap_or_default(); Some((group_name.to_string(), all, now)) } /// 从 /proxies 响应中读取某节点最近一次延迟 fn parse_node_delay(proxies_response: &serde_json::Value, name: &str) -> Option { proxies_response .get("proxies") .and_then(|p| p.get(name)) .and_then(|n| n.get("history")) .and_then(|h| h.as_array()) .and_then(|arr| arr.first()) .and_then(|first| first.get("delay")) .and_then(|d| d.as_u64()) .map(|d| d as u32) } // ===== 菜单构建 ===== /// 获取代理节点数据(异步,调用 mihomo API) async fn fetch_proxy_nodes(app: &AppHandle) -> Option<(String, Vec<(String, Option)>, String)> { let mihomo = app.state::(); let proxies = mihomo.get_proxies().await.ok()?; let (group, nodes, now) = parse_main_group(&proxies)?; let node_delays: Vec<(String, Option)> = nodes .iter() .map(|name| (name.clone(), parse_node_delay(&proxies, name))) .collect(); Some((group, node_delays, now)) } fn build_menu( app: &AppHandle, proxy_running: bool, monitor_running: bool, proxy_nodes: Option<(String, Vec<(String, Option)>, String)>, ) -> Result, tauri::Error> { // ---------- 代理子菜单 ---------- let enable_proxy = MenuItem::with_id( app, "proxy_enable", "开启代理", !proxy_running, None::<&str>, )?; let disable_proxy = MenuItem::with_id( app, "proxy_disable", "关闭代理", proxy_running, None::<&str>, )?; let proxy_sep = PredefinedMenuItem::separator(app)?; // 选择代理子菜单 let (node_menu_items, node_sep) = build_proxy_select_items(app, proxy_running, &proxy_nodes)?; // "刷新节点列表"项(仅代理运行时创建,提前声明以保证引用生命周期) let refresh = if proxy_running { Some(MenuItem::with_id(app, "proxy_refresh", "🔄 刷新节点列表", true, None::<&str>)?) } else { None }; // 组装选择代理子菜单 let mut select_refs: Vec<&dyn IsMenuItem> = Vec::new(); if !proxy_running { // 只有一个 hint 项 for item in &node_menu_items { select_refs.push(item); } } else { if let Some(ref r) = refresh { select_refs.push(r); } if let Some(ref sep) = node_sep { select_refs.push(sep); } for item in &node_menu_items { select_refs.push(item); } } let select_proxy = Submenu::with_items(app, "选择代理", proxy_running, &select_refs)?; // 组装代理子菜单 let proxy_refs: Vec<&dyn IsMenuItem> = vec![&enable_proxy, &disable_proxy, &proxy_sep, &select_proxy]; let proxy_submenu = Submenu::with_items(app, "代理", true, &proxy_refs)?; // ---------- 分隔线 ---------- let sep1 = PredefinedMenuItem::separator(app)?; // ---------- OSD + Kernel ---------- let osd_toggle = MenuItem::with_id(app, "osd_toggle", "切换 OSD", true, None::<&str>)?; let kernel_restart = MenuItem::with_id( app, "kernel_restart", "重启 Kernel", monitor_running, None::<&str>, )?; // ---------- 分隔线 ---------- let sep2 = PredefinedMenuItem::separator(app)?; // ---------- 新建下载 ---------- let download_new = MenuItem::with_id(app, "download_new", "新建下载", true, None::<&str>)?; // ---------- 分隔线 ---------- let sep3 = PredefinedMenuItem::separator(app)?; // ---------- 系统菜单 ---------- let settings = MenuItem::with_id(app, "settings", "常规设置", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; // ---------- 组装主菜单 ---------- let main_refs: Vec<&dyn IsMenuItem> = vec![ &proxy_submenu, &sep1, &osd_toggle, &kernel_restart, &sep2, &download_new, &sep3, &settings, &quit, ]; Menu::with_items(app, &main_refs) } /// 构建代理节点菜单项(返回 MenuItem 列表 + 可选分隔线) fn build_proxy_select_items( app: &AppHandle, proxy_running: bool, proxy_nodes: &Option<(String, Vec<(String, Option)>, String)>, ) -> Result<(Vec>, Option>), tauri::Error> { if !proxy_running { let hint = MenuItem::with_id(app, "proxy_noop", "代理未运行", false, None::<&str>)?; return Ok((vec![hint], None)); } let (group, nodes, now) = match proxy_nodes { Some(data) => data, None => { let hint = MenuItem::with_id(app, "proxy_loading", "加载中…", false, None::<&str>)?; return Ok((vec![hint], None)); } }; if nodes.is_empty() { let hint = MenuItem::with_id(app, "proxy_noop", "无可用节点", false, None::<&str>)?; return Ok((vec![hint], None)); } let sep = PredefinedMenuItem::separator(app)?; // 清空并重建全局映射 { let mut map = proxy_node_map().lock().unwrap(); map.clear(); } let mut items: Vec> = Vec::new(); // 限制节点数量避免菜单过长 for (idx, (name, delay)) in nodes.iter().take(30).enumerate() { let delay_text = match delay { Some(0) => "(超时)".to_string(), Some(d) => format!("({}ms)", d), None => "(未测试)".to_string(), }; let is_current = name == now; let prefix = if is_current { "✓ " } else { " " }; let text = format!("{}{} {}", prefix, name, delay_text); let menu_id = format!("proxy_node_{}", idx); let item = MenuItem::with_id(app, &menu_id, &text, true, None::<&str>)?; // 记录映射 { let mut map = proxy_node_map().lock().unwrap(); map.push((group.clone(), name.clone())); } items.push(item); } Ok((items, Some(sep))) } // ===== 异步菜单重建 ===== async fn rebuild_menu(app: &AppHandle) { let proxy_running = is_proxy_running(app); let monitor_running = is_monitor_running(app); let proxy_nodes = if proxy_running { fetch_proxy_nodes(app).await } else { None }; if let Ok(menu) = build_menu(app, proxy_running, monitor_running, proxy_nodes) { if let Some(tray) = app.tray_by_id(&TrayIconId::new("main")) { let _ = tray.set_menu(Some(menu)); } } } // ===== 菜单事件处理 ===== fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) { let id = event.id().as_ref().to_string(); match id.as_str() { "proxy_enable" => { let app = app.clone(); tauri::async_runtime::spawn(async move { if let Err(e) = enable_proxy(&app).await { eprintln!("[tray] 开启代理失败: {}", e); send_notification("代理启动失败", &e); } }); } "proxy_disable" => { let app = app.clone(); tauri::async_runtime::spawn(async move { if let Err(e) = disable_proxy(&app).await { eprintln!("[tray] 关闭代理失败: {}", e); send_notification("代理关闭失败", &e); } }); } "proxy_refresh" => { let app = app.clone(); tauri::async_runtime::spawn(async move { // 先重建菜单(保留旧数据),然后并行测试延迟并再次重建 rebuild_menu(&app).await; test_all_delays_and_rebuild(&app).await; }); } "osd_toggle" => { let _ = app.emit("tray:toggle-osd", ()); } "kernel_restart" => { let app = app.clone(); tauri::async_runtime::spawn(async move { if let Err(e) = restart_kernel(&app).await { eprintln!("[tray] 重启 Kernel 失败: {}", e); send_notification("Kernel 重启失败", &e); } }); } "download_new" => { if let Some(window) = app.get_webview_window("main") { window.show().ok(); window.set_focus().ok(); } let _ = app.emit("tray:new-download", ()); } "settings" => { if let Some(window) = app.get_webview_window("main") { window.show().ok(); window.set_focus().ok(); } let _ = app.emit("tray:open-settings", ()); } "quit" => { quit_cleanup(app); } id_str if id_str.starts_with("proxy_node_") => { let idx: usize = id_str .strip_prefix("proxy_node_") .and_then(|s| s.parse().ok()) .unwrap_or(usize::MAX); let app = app.clone(); tauri::async_runtime::spawn(async move { if let Some((group, name)) = group_name_from_map(idx) { select_proxy_node(&app, &group, &name).await; } else { eprintln!("[tray] 节点索引无效: {}", idx); } }); } _ => {} } } /// 从全局映射中获取 (group, name) fn group_name_from_map(idx: usize) -> Option<(String, String)> { let map = proxy_node_map().lock().unwrap(); map.get(idx).cloned() } /// 选择代理节点并测试延迟 async fn select_proxy_node(app: &AppHandle, group: &str, name: &str) { let mihomo = app.state::(); match mihomo.select_proxy(group, name).await { Ok(()) => { // 测试延迟 let delay = mihomo .test_delay(name, "https://www.gstatic.com/generate_204", 5000) .await .ok(); let delay_text = match delay { Some(0) => "超时".to_string(), Some(d) => format!("{}ms", d), None => "未测速".to_string(), }; send_notification("节点已切换", &format!("{}\n延迟: {}", name, delay_text)); // 重建菜单 rebuild_menu(app).await; } Err(e) => { eprintln!("[tray] 切换节点失败: {}", e); send_notification("切换节点失败", &e); } } } // ===== 代理开启/关闭 ===== async fn enable_proxy(app: &AppHandle) -> Result<(), String> { let mihomo = app.state::(); let pm = app.state::(); // 1. 启动 mihomo(如果未运行) if !is_proxy_running(app) { let params = mihomo.prepare_for_start(app)?; pm.start(params)?; // 等待 API 就绪 if !wait_for_mihomo_api(&mihomo, 10_000).await { return Err("mihomo API 启动超时".into()); } } // 2. 获取节点列表 let proxies = mihomo.get_proxies().await?; let (group, nodes, _now) = parse_main_group(&proxies) .ok_or_else(|| "无法解析代理组".to_string())?; if !nodes.is_empty() { // 3. 并行测试所有节点延迟 let best = test_and_select_best(&mihomo, &group, &nodes).await; // 4. 发送通知 match &best { Some((name, delay)) => { send_notification( "代理已开启", &format!("当前节点: {} ({}ms)", name, delay), ); } None => { send_notification("代理已开启", "所有节点均超时,未自动选择"); } } } else { send_notification("代理已开启", "无可用节点"); } // 5. 开启系统代理 mihomo.enable_system_proxy()?; // 6. 重建菜单 rebuild_menu(app).await; Ok(()) } async fn disable_proxy(app: &AppHandle) -> Result<(), String> { let mihomo = app.state::(); let pm = app.state::(); // 关闭系统代理 mihomo.disable_system_proxy()?; // 停止 mihomo pm.stop("proxy")?; send_notification("代理已关闭", "mihomo 已停止,系统代理已清除"); // 重建菜单 rebuild_menu(app).await; Ok(()) } // ===== 择优选择节点 ===== /// 并行测试所有节点延迟,选择最低延迟节点 async fn test_and_select_best( mihomo: &MihomoManager, group: &str, nodes: &[String], ) -> Option<(String, u32)> { use futures_util::future::join_all; let futures: Vec<_> = nodes .iter() .map(|name| async move { let delay = mihomo .test_delay(name, "https://www.gstatic.com/generate_204", 5000) .await .ok(); (name.clone(), delay) }) .collect(); let results = join_all(futures).await; // 找出有效延迟中最低的 let best = results .into_iter() .filter_map(|(name, delay)| delay.map(|d| (name, d))) .filter(|(_, d)| *d > 0) .min_by_key(|(_, d)| *d); // 选择最优节点 if let Some((ref name, _)) = best { let _ = mihomo.select_proxy(group, name).await; } best } /// 测试所有节点延迟后重建菜单 async fn test_all_delays_and_rebuild(app: &AppHandle) { let mihomo = app.state::(); let proxies = match mihomo.get_proxies().await { Ok(p) => p, Err(_) => { return; } }; let (_group, nodes, _) = match parse_main_group(&proxies) { Some(g) => g, None => return, }; // 并行测试延迟(更新 mihomo 内部 history) use futures_util::future::join_all; let futures: Vec<_> = nodes .iter() .map(|name| async { let _ = mihomo .test_delay(name, "https://www.gstatic.com/generate_204", 5000) .await; }) .collect(); join_all(futures).await; // 再次获取 proxies(此时 history 已更新)并重建菜单 rebuild_menu(app).await; } // ===== 等待 mihomo API 就绪 ===== async fn wait_for_mihomo_api(mihomo: &MihomoManager, timeout_ms: u64) -> bool { let start = std::time::Instant::now(); while start.elapsed().as_millis() < timeout_ms as u128 { if mihomo.get_version().await.is_ok() { return true; } tokio::time::sleep(std::time::Duration::from_millis(500)).await; } false } // ===== 重启 Kernel ===== async fn restart_kernel(app: &AppHandle) -> Result<(), String> { let monitor = app.state::(); let pm = app.state::(); // 停止当前 Kernel monitor.stop_subscription(app).await; let _ = pm.stop("monitor"); // 等待端口释放 tokio::time::sleep(std::time::Duration::from_millis(800)).await; // 重新启动 monitor.start_with_subscription(app).await?; send_notification("Kernel 已重启", "ThingHK 监控内核已重新启动"); rebuild_menu(app).await; Ok(()) } // ===== Windows 通知 ===== #[cfg(windows)] fn send_notification(title: &str, message: &str) { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x08000000; // 转义 PowerShell 单引号 let title_escaped = title.replace('\'', "''"); let msg_escaped = message.replace('\'', "''").replace('\n', "`n"); let script = format!( "[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')|Out-Null;\ $n=New-Object System.Windows.Forms.NotifyIcon;\ $n.Icon=[System.Drawing.SystemIcons]::Information;\ $n.Visible=$true;\ $n.BalloonTipTitle='{}';\ $n.BalloonTipText='{}';\ $n.ShowBalloonTip(5000);\ Start-Sleep -Seconds 6;\ $n.Dispose()", title_escaped, msg_escaped ); let _ = std::process::Command::new("powershell") .args([ "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", &script, ]) .creation_flags(CREATE_NO_WINDOW) .spawn(); } #[cfg(not(windows))] fn send_notification(_title: &str, _message: &str) { // 非 Windows 平台不显示通知 } // ===== 创建托盘 ===== /// 创建自定义托盘菜单(在 setup 中调用) pub fn create_tray_menu(app: &AppHandle) -> Result<(), tauri::Error> { let proxy_running = is_proxy_running(app); let monitor_running = is_monitor_running(app); // 初始菜单(不含代理节点,异步获取后替换) let menu = build_menu(app, proxy_running, monitor_running, None)?; let _tray = TrayIconBuilder::with_id("main") .icon(app.default_window_icon().unwrap().clone()) .tooltip("Thing") .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(handle_menu_event) .on_tray_icon_event(|tray, event| { let app = tray.app_handle().clone(); match event { TrayIconEvent::Click { button: MouseButton::Left, .. } => { if let Some(window) = app.get_webview_window("main") { window.show().ok(); window.set_focus().ok(); } } TrayIconEvent::Enter { .. } => { // 鼠标进入托盘区域时预刷新菜单 tauri::async_runtime::spawn(async move { rebuild_menu(&app).await; }); } _ => {} } }) .build(app)?; // 异步获取代理节点并重建菜单 let app_clone = app.clone(); tauri::async_runtime::spawn(async move { rebuild_menu(&app_clone).await; }); Ok(()) } /// 退出应用时调用(与 quit_app 命令共享清理逻辑) pub fn quit_cleanup(app: &AppHandle) { if let Some(mihomo) = app.try_state::() { mihomo.cleanup_on_exit(); } if let Some(engine) = app.try_state::() { engine.cleanup_on_exit(); } if let Some(monitor) = app.try_state::() { tauri::async_runtime::block_on(monitor.cleanup_on_exit(app)); } if let Some(clip) = app.try_state::() { clip.stop(); } if let Some(pm) = app.try_state::() { pm.stop_all(); } app.exit(0); }