//! tray_menu.rs — 自定义系统托盘菜单(WebviewWindow 弹窗) //! //! 使用独立 WebviewWindow 替代原生菜单,实现与应用一致的视觉风格(Mica/Acrylic + 深色主题)。 //! - 右键托盘:显示自定义菜单窗口(在鼠标位置弹出) //! - 左键托盘:显示主窗口 //! - 菜单窗口失焦:自动隐藏 //! //! 前端通过监听 `tray-menu-show` 事件获取菜单状态并渲染, //! 点击菜单项时调用 `tray_menu_action` 命令执行对应操作。 use serde::Serialize; use std::sync::Mutex; use std::time::{Duration, Instant}; use tauri::{ AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, tray::{MouseButton, TrayIconBuilder, TrayIconEvent}, window::{Effect, EffectsBuilder}, }; /// 记录窗口最后显示时间,用于失焦防抖(避免显示瞬间因焦点未稳定而被立即隐藏) static LAST_SHOW_TIME: Mutex> = Mutex::new(None); /// 保存最近一次右键时计算出的定位参数(物理坐标),供 `tray_menu_ready` 使用 /// (x, tray_top, wa_top, wa_bottom, scale) static LAST_MENU_LAYOUT: Mutex> = Mutex::new(None); use crate::win32_util::{get_work_area, get_work_area_at_point, get_dpi_for_point}; use crate::mihomo_manager::{MihomoManager, is_pseudo_node}; use crate::monitor_kernel::MonitorKernel; use crate::process_manager::{ProcessManager, ProcessStatus}; /// 托盘菜单窗口标签 pub const TRAY_MENU_LABEL: &str = "tray-menu"; // ===== 状态数据结构(发送给前端) ===== #[derive(Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct ProxyNodeInfo { pub name: String, pub delay: Option, pub is_current: bool, } #[derive(Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct TrayMenuState { pub proxy_running: bool, pub monitor_running: bool, pub proxy_group: Option, pub proxy_nodes: Vec, pub proxy_current: Option, } // ===== 状态查询 ===== 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; } 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)) } // ===== 获取菜单状态 ===== pub async fn get_tray_menu_state(app: &AppHandle) -> TrayMenuState { let proxy_running = is_proxy_running(app); let monitor_running = is_monitor_running(app); let (proxy_group, proxy_nodes, proxy_current) = if proxy_running { match fetch_proxy_nodes(app).await { Some((group, nodes, now)) => { let node_infos: Vec = nodes .iter() .map(|(name, delay)| ProxyNodeInfo { name: name.clone(), delay: *delay, is_current: name == &now, }) .collect(); (Some(group), node_infos, if now.is_empty() { None } else { Some(now) }) } None => (None, vec![], None), } } else { (None, vec![], None) }; TrayMenuState { proxy_running, monitor_running, proxy_group, proxy_nodes, proxy_current, } } // ===== 显示/隐藏托盘菜单窗口 ===== /// 托盘菜单窗口尺寸(逻辑像素) /// 宽度需与前端 TrayMenu.vue 的 MENU_WIDTH 保持一致,避免 resize 时右对齐错位 const MENU_W: f64 = 220.0; const MENU_H: f64 = 420.0; /// 预创建隐藏的托盘菜单窗口(在应用启动时调用)。 /// 窗口隐藏渲染,前端 onMounted 应用主题后保持隐藏状态,右键托盘时直接 show 即无延迟。 pub fn precreate_tray_menu_window(app: &AppHandle) { if app.get_webview_window(TRAY_MENU_LABEL).is_some() { return; } // 默认放在屏幕右下角(托盘常见位置附近),首次显示时会重新定位 let (screen_w, screen_h) = get_work_area().unwrap_or((1920.0, 1080.0)); let x = (screen_w as f64) - MENU_W - 20.0; let y = (screen_h as f64) - MENU_H - 60.0; let win = match WebviewWindowBuilder::new( app, TRAY_MENU_LABEL, WebviewUrl::App("index.html#tray-menu".into()), ) .title("Thing 菜单") .inner_size(MENU_W, MENU_H) .position(x, y) .decorations(false) .transparent(true) .shadow(true) .always_on_top(true) .skip_taskbar(true) .resizable(false) .visible(false) .focused(false) .effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build()) .build() { Ok(w) => w, Err(e) => { crate::logger::log_error("tray", &format!("预创建菜单窗口失败: {}", e)); return; } }; // 监听窗口失焦:自动隐藏(带 300ms 防抖,避免显示瞬间焦点未稳定而被立即隐藏) let win_handle = win.clone(); win.on_window_event(move |event| { if let tauri::WindowEvent::Focused(false) = event { let should_hide = { let t = LAST_SHOW_TIME.lock().unwrap_or_else(|e| e.into_inner()); match *t { Some(time) => time.elapsed() > Duration::from_millis(300), None => true, } }; if should_hide { let _ = win_handle.hide(); } } }); crate::logger::log_info("tray", "菜单窗口已预创建(隐藏渲染)"); } /// 右键托盘时调用:计算定位参数、发送状态给前端,但不立即显示窗口。 /// 窗口等待前端测量内容高度后调用 `tray_menu_ready` 才显示,确保底部精确对齐托盘图标。 /// /// `cursor_pos`: 事件报告的鼠标物理坐标;`tray_rect`: 托盘图标区域(物理像素)。 pub fn show_tray_menu(app: &AppHandle, cursor_pos: (f64, f64), tray_rect: (f64, f64, f64, f64)) { let (mx, my) = (cursor_pos.0, cursor_pos.1); let tray_top = tray_rect.1; // 获取光标所在显示器的工作区(物理像素) let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(mx as i32, my as i32) .unwrap_or((0, 0, 1920, 1040)); // 光标所在显示器的 DPI:菜单宽度按物理像素换算 let dpi = get_dpi_for_point(mx as i32, my as i32).unwrap_or(96); let scale = dpi as f64 / 96.0; let menu_w_px = MENU_W * scale; // 水平:菜单左边缘对齐鼠标 X(向右延伸),超出右边界则左移(物理坐标) let x = mx.max(wa_left as f64).min(wa_right as f64 - menu_w_px); // 保存布局参数(全部物理坐标 + scale,供 tray_menu_ready 换算前端上报的逻辑高度) { let mut layout = LAST_MENU_LAYOUT.lock().unwrap_or_else(|e| e.into_inner()); *layout = Some((x, tray_top, wa_top as f64, wa_bottom as f64, scale)); } // 记录显示时间,用于失焦防抖 { let mut t = LAST_SHOW_TIME.lock().unwrap_or_else(|e| e.into_inner()); *t = Some(Instant::now()); } // 确保窗口存在(兖底) if app.get_webview_window(TRAY_MENU_LABEL).is_none() { precreate_tray_menu_window(app); } // 发送状态给前端(前端测量内容高度后调用 tray_menu_ready 显示窗口) let app_clone = app.clone(); tauri::async_runtime::spawn(async move { let state = get_tray_menu_state(&app_clone).await; let _ = app_clone.emit(crate::constants::events::TRAY_MENU_SHOW, state); }); } /// 隐藏托盘菜单窗口(不销毁,保留复用) pub fn hide_tray_menu(app: &AppHandle) { if let Some(win) = app.get_webview_window(TRAY_MENU_LABEL) { let _ = win.hide(); } } /// 刷新菜单状态并发送给前端 async fn refresh_and_emit_state(app: &AppHandle) { let state = get_tray_menu_state(app).await; let _ = app.emit(crate::constants::events::TRAY_MENU_STATE_UPDATED, state); } // ===== Tauri 命令 ===== /// 执行菜单项动作(统一入口) #[tauri::command] pub async fn tray_menu_action( action: String, payload: Option, app: AppHandle, ) -> Result<(), String> { match action.as_str() { "proxy_enable" => { if let Err(e) = enable_proxy(&app).await { crate::logger::log_error("tray", &format!("开启代理失败: {}", e)); send_notification(&app, "代理启动失败", &e); } } "proxy_disable" => { if let Err(e) = disable_proxy(&app).await { crate::logger::log_error("tray", &format!("关闭代理失败: {}", e)); send_notification(&app, "代理关闭失败", &e); } } "proxy_refresh" => { test_all_delays(&app).await; } "proxy_select_node" => { if let Some(name) = payload .as_ref() .and_then(|p| p.get("name")) .and_then(|n| n.as_str()) { if let Some(group) = get_proxy_group(&app).await { select_proxy_node(&app, &group, name).await; } } } "osd_toggle" => { let _ = app.emit(crate::constants::events::TRAY_TOGGLE_OSD, ()); } "kernel_restart" => { if let Err(e) = restart_kernel(&app).await { crate::logger::log_error("tray", &format!("重启 Kernel 失败: {}", e)); send_notification(&app, "Kernel 重启失败", &e); } } "download_new" => { if let Some(window) = app.get_webview_window(crate::constants::windows::MAIN) { window.show().ok(); window.set_focus().ok(); } let _ = app.emit(crate::constants::events::TRAY_NEW_DOWNLOAD, ()); hide_tray_menu(&app); } "settings" => { if let Some(window) = app.get_webview_window(crate::constants::windows::MAIN) { window.show().ok(); window.set_focus().ok(); } let _ = app.emit(crate::constants::events::TRAY_OPEN_SETTINGS, ()); hide_tray_menu(&app); } "quit" => { hide_tray_menu(&app); quit_cleanup(&app); } _ => {} } // 刷新状态并发送给前端(quit 除外,quit 后进程已退出) if action != "quit" { refresh_and_emit_state(&app).await; } Ok(()) } /// 隐藏托盘菜单窗口(前端调用) #[tauri::command] pub async fn tray_menu_hide(app: AppHandle) -> Result<(), String> { hide_tray_menu(&app); Ok(()) } /// 前端测量内容高度后调用:调整窗口大小、精确定位、然后显示。 /// 这样窗口从一开始就是正确尺寸和位置,不会出现间隙。 #[tauri::command] pub async fn tray_menu_ready(content_height: f64, app: AppHandle) -> Result<(), String> { let win = app.get_webview_window(TRAY_MENU_LABEL) .ok_or("tray-menu window not found")?; let (x, tray_top, wa_top, wa_bottom, scale) = { let layout = LAST_MENU_LAYOUT.lock().unwrap_or_else(|e| e.into_inner()); layout.unwrap_or((0.0, 1040.0, 0.0, 1040.0, 1.0)) }; // 将内容高度限制在合理范围内(前端上报为逻辑像素) let h = content_height.max(100.0).min(520.0); // 调整窗口尺寸(物理像素,与物理坐标定位保持一致,避免混合 DPI 换算偏移) let win_w_px = MENU_W * scale; let win_h_px = h * scale; let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize { width: win_w_px as u32, height: win_h_px as u32, })); // 垂直:菜单下边缘紧贴托盘图标顶部(向上弹出,物理坐标) let y = (tray_top - win_h_px).max(wa_top).min(wa_bottom - win_h_px); let pos = tauri::Position::Physical(tauri::PhysicalPosition { x: x as i32, y: y as i32, }); let _ = win.set_position(pos); let _ = win.show(); let _ = win.set_focus(); Ok(()) } /// 获取当前代理组名 async fn get_proxy_group(app: &AppHandle) -> Option { let mihomo = app.state::(); let proxies = mihomo.get_proxies().await.ok()?; let (group, _, _) = parse_main_group(&proxies)?; Some(group) } // ===== 代理开启/关闭 ===== 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( app, "代理已开启", &format!("当前节点: {} ({}ms)", name, delay), ); } None => { send_notification(app, "代理已开启", "所有节点均超时,未自动选择"); } } } else { send_notification(app, "代理已开启", "无可用节点"); } // 5. 开启系统代理 mihomo.enable_system_proxy()?; 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(app, "代理已关闭", "mihomo 已停止,系统代理已清除"); 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 } /// 并行测试所有节点延迟(更新 mihomo 内部 history) async fn test_all_delays(app: &AppHandle) { let mihomo = app.state::(); let proxies = match mihomo.get_proxies().await { Ok(p) => p, Err(_) => return, }; let (_group, nodes, _now) = match parse_main_group(&proxies) { Some(g) => g, None => return, }; 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; } // ===== 选择代理节点并测试延迟 ===== 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(app, "节点已切换", &format!("{}\n延迟: {}", name, delay_text)); } Err(e) => { crate::logger::log_error("tray", &format!("切换节点失败: {}", e)); send_notification(app, "切换节点失败", &e); } } } // ===== 等待 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(app, "Kernel 已重启", "ThingHK 监控内核已重新启动"); Ok(()) } // ===== Windows 通知 ===== fn send_notification(app: &AppHandle, title: &str, message: &str) { use tauri_plugin_notification::NotificationExt; let _ = app .notification() .builder() .title(title) .body(message) .show(); } // ===== 退出入口 ===== /// 托盘菜单"退出"调用。 /// 资源清理统一收敛到 RunEvent::ExitRequested(覆盖所有退出路径),此处仅请求退出, /// 避免清理逻辑双份执行(monitor 的 cleanup 涉及子进程/网络,重复执行有竞态风险)。 pub fn quit_cleanup(app: &AppHandle) { app.exit(0); } // ===== 创建托盘 ===== /// 创建自定义托盘(在 setup 中调用) /// 不使用原生菜单,改用 WebviewWindow 弹窗实现自定义风格。 pub fn create_tray_menu(app: &AppHandle) -> Result<(), tauri::Error> { let _tray = TrayIconBuilder::with_id("main") .icon(app.default_window_icon().unwrap().clone()) .tooltip("Thing") .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(crate::constants::windows::MAIN) { window.show().ok(); window.set_focus().ok(); } } TrayIconEvent::Click { button: MouseButton::Right, position, rect, .. } => { // 右键:显示自定义菜单窗口(使用事件中的精确坐标) let cursor = (position.x, position.y); // 从 Rect 的 Position/Size 枚举中提取物理像素值 let (rx, ry) = match rect.position { tauri::Position::Physical(p) => (p.x as f64, p.y as f64), tauri::Position::Logical(p) => (p.x, p.y), }; let (_rw, rh) = match rect.size { tauri::Size::Physical(s) => (s.width as f64, s.height as f64), tauri::Size::Logical(s) => (s.width, s.height), }; let tray_r = (rx, ry, _rw, rh); show_tray_menu(&app, cursor, tray_r); } _ => {} } }) .build(app)?; // 预创建隐藏的菜单窗口,让前端提前渲染主题和内容,右键时直接 show 无延迟 precreate_tray_menu_window(app); Ok(()) }