//! 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, cursor_y, screen_top, screen_bottom, scale) static LAST_MENU_LAYOUT: Mutex> = Mutex::new(None); use crate::win32_util::{get_work_area, get_monitor_bounds_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 system_proxy: 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)) } // ===== 获取菜单状态 ===== /// 基础状态(不含代理节点)。托盘菜单显示不应受 mihomo API 慢/卡死影响, /// 因此先秒发基础状态让菜单立即出现,代理节点随后异步补充。 fn get_base_tray_state(app: &AppHandle) -> TrayMenuState { let proxy_running = is_proxy_running(app); let monitor_running = is_monitor_running(app); // 读取 Windows 注册表中的真实系统代理状态(不依赖 settings.json 缓存) let system_proxy = crate::mihomo_manager::get_system_proxy_windows(); TrayMenuState { proxy_running, system_proxy, monitor_running, proxy_group: None, proxy_nodes: vec![], proxy_current: None, } } /// 完整状态(含代理节点)。可能因调用 mihomo /proxies 而耗时(最长 10s)。 async fn get_full_tray_state(app: &AppHandle) -> TrayMenuState { let proxy_running = is_proxy_running(app); let monitor_running = is_monitor_running(app); // 读取 Windows 注册表中的真实系统代理状态(不依赖 settings.json 缓存) let system_proxy = crate::mihomo_manager::get_system_proxy_windows(); 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, system_proxy, monitor_running, proxy_group, proxy_nodes, proxy_current, } } pub async fn get_tray_menu_state(app: &AppHandle) -> TrayMenuState { get_full_tray_state(app).await } // ===== 显示/隐藏托盘菜单窗口 ===== /// 托盘菜单窗口尺寸(逻辑像素) /// 宽度需与前端 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`: 事件报告的鼠标物理坐标。 pub fn show_tray_menu(app: &AppHandle, cursor_pos: (f64, f64)) { let (mx, my) = (cursor_pos.0, cursor_pos.1); // 获取光标所在显示器的完整边界(含任务栏,物理像素)。 // 托盘图标位于任务栏上,菜单需在鼠标位置弹出,因此用屏幕边界而非工作区做 clamp, // 否则会被工作区底部(任务栏顶部)截断,导致菜单整体被推到任务栏上方。 let (scr_left, scr_top, scr_right, scr_bottom) = get_monitor_bounds_at_point(mx as i32, my as i32) .unwrap_or((0, 0, 1920, 1080)); // 光标所在显示器的 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(scr_left as f64).min(scr_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, my, scr_top as f64, scr_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); } // 1) 立即发送基础状态(不含代理节点,纯本地查询),菜单秒开—— // 前端测量内容高度后调用 tray_menu_ready 显示窗口。 let base = get_base_tray_state(app); let _ = app.emit(crate::constants::events::TRAY_MENU_SHOW, base); // 2) 异步拉取完整状态(含 mihomo /proxies 节点),到达后经 TRAY_MENU_STATE_UPDATED // 补充给前端(前端在窗口可见时重新测量调整尺寸)。 // mihomo API 慢/挂起时不再阻塞菜单显示;超时 5s 放弃(保持基础状态)。 let app_clone = app.clone(); tauri::async_runtime::spawn(async move { let state = match tokio::time::timeout( Duration::from_millis(5000), get_full_tray_state(&app_clone), ) .await { Ok(s) => s, Err(_) => return, }; let _ = app_clone.emit(crate::constants::events::TRAY_MENU_STATE_UPDATED, 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 的 set_focus 在 Windows 上受前台锁定限制,主窗口被其他应用遮挡时无法到前台; /// 改用原生 SetForegroundWindow + BringWindowToTop(模拟 Alt 键重置前台锁定)。 pub fn focus_main_window(app: &AppHandle) { if let Some(window) = app.get_webview_window(crate::constants::windows::MAIN) { let _ = window.show(); let _ = window.unminimize(); match window.hwnd() { Ok(hwnd) => crate::win32_util::force_foreground(hwnd.0 as isize), Err(_) => { window.set_focus().ok(); } } } } // ===== 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; } } } "system_proxy_toggle" => { // 根据注册表当前真实状态切换(不依赖 settings.json 缓存,避免与主界面不同步) let mihomo = app.state::(); if crate::mihomo_manager::get_system_proxy_windows() { if let Err(e) = mihomo.disable_system_proxy() { crate::logger::log_error("tray", &format!("关闭系统代理失败: {}", e)); send_notification(&app, "系统代理关闭失败", &e); } } else { if let Err(e) = mihomo.enable_system_proxy() { crate::logger::log_error("tray", &format!("开启系统代理失败: {}", e)); send_notification(&app, "系统代理开启失败", &e); } } } "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" => { focus_main_window(&app); let _ = app.emit(crate::constants::events::TRAY_NEW_DOWNLOAD, ()); hide_tray_menu(&app); } "settings" => { focus_main_window(&app); let _ = app.emit(crate::constants::events::TRAY_OPEN_SETTINGS, ()); hide_tray_menu(&app); } "quit" => { hide_tray_menu(&app); quit_cleanup(&app); } _ => {} } // 按动作类型选择性刷新,避免无关动作(OSD 开关/跳转设置)也全量请求 mihomo API: // - 代理相关动作:全量刷新(节点列表/延迟可能已变化) // - Kernel/OSD:仅刷新本地基础状态(monitor 运行状态) // - 跳转/退出:不刷新(quit 后进程已退出) match action.as_str() { "proxy_enable" | "proxy_disable" | "proxy_refresh" | "proxy_select_node" | "system_proxy_toggle" => { refresh_and_emit_state(&app).await; } "kernel_restart" | "osd_toggle" => { let state = get_base_tray_state(&app); let _ = app.emit(crate::constants::events::TRAY_MENU_STATE_UPDATED, state); } _ => {} } 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, cursor_y, scr_top, scr_bottom, scale) = { let layout = LAST_MENU_LAYOUT.lock().unwrap_or_else(|e| e.into_inner()); layout.unwrap_or((0.0, 1040.0, 0.0, 1080.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, })); // 垂直:菜单底部对齐鼠标点击位置(向上弹出,物理坐标)。 // 用屏幕边界而非工作区 clamp,使菜单贴近/覆盖任务栏上的鼠标位置, // 而不是被工作区底部(任务栏顶部)截断后整体出现在任务栏上方。 let y = (cursor_y - win_h_px).max(scr_top).min(scr_bottom - win_h_px); let pos = tauri::Position::Physical(tauri::PhysicalPosition { x: x as i32, y: y as i32, }); let _ = win.set_position(pos); // 以实际显示时刻重置失焦防抖起点:右键到此处可能间隔较久(前端测量 + 基础状态), // 若沿用右键时刻,WebView2 显示瞬间的焦点抖动会被误判为失焦而立即隐藏菜单。 { let mut t = LAST_SHOW_TIME.lock().unwrap_or_else(|e| e.into_inner()); *t = Some(Instant::now()); } 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 settings = mihomo.load_settings(); match crate::mihomo_manager::pick_best(&mihomo, &settings).await { Ok(Some((group, name, delay, now))) => { // 与自动切换一样,仅当当前节点不是最优时才切换 if name != now { let _ = mihomo.select_proxy(&group, &name).await; } send_notification(app, "代理已开启", &format!("当前节点: {} ({}ms)", name, delay)); } Ok(None) | Err(_) => { // 区分「无可用节点」与「全部超时」两种情况 let unavailable = { let proxies = mihomo.get_proxies().await.ok(); let group_name: Option = if !settings.auto_switch_group.is_empty() { Some(settings.auto_switch_group.clone()) } else { proxies .as_ref() .and_then(|p| p.get("proxies")) .and_then(|m| m.as_object()) .and_then(|map| { let mut names = map.iter().filter(|(_, v)| { v.get("type").and_then(|t| t.as_str()) == Some("Selector") }); names.next().map(|(n, _)| n.clone()) }) }; let empty = group_name.map(|g| { proxies .as_ref() .and_then(|p| p.get("proxies")) .and_then(|m| m.as_object()) .and_then(|map| map.get(&g)) .and_then(|v| v.get("all")) .and_then(|v| v.as_array()) .map(|a| a.is_empty()) .unwrap_or(true) }).unwrap_or(true); empty }; if unavailable { send_notification(app, "代理已开启", "无可用节点"); } else { send_notification(app, "代理已开启", "所有候选节点均超时,未自动选择"); } } } // 3. 开启系统代理 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(()) } // ===== 择优选择节点 ===== /// 并行测试所有节点延迟(更新 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, .. } => { // 左键:显示主窗口(强制置前,绕过前台锁定) focus_main_window(&app); } TrayIconEvent::Click { button: MouseButton::Right, position, .. } => { // 右键:显示自定义菜单窗口(使用事件中的鼠标坐标) let cursor = (position.x, position.y); show_tray_menu(&app, cursor); } _ => {} } }) .build(app)?; // 预创建隐藏的菜单窗口,让前端提前渲染主题和内容,右键时直接 show 无延迟 precreate_tray_menu_window(app); Ok(()) }