Files
Thing/src-tauri/src/tray_menu.rs
T
2026-07-30 09:09:29 +08:00

739 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Mutex<Vec<(String, String)>>> = OnceLock::new();
fn proxy_node_map() -> &'static Mutex<Vec<(String, String)>> {
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::<ProcessManager>() {
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::<ProcessManager>() {
if let Some(info) = pm.get_status("monitor") {
if matches!(info.status, ProcessStatus::Running) {
return true;
}
}
}
if let Some(monitor) = app.try_state::<MonitorKernel>() {
if monitor.is_elevated() {
return true;
}
}
false
}
// ===== 从 mihomo /proxies 响应中提取主选择组及其节点 =====
/// 返回 (组名, 有效节点列表, 当前节点)
fn parse_main_group(proxies_response: &serde_json::Value) -> Option<(String, Vec<String>, 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<String> = 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<u32> {
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<u32>)>, String)> {
let mihomo = app.state::<MihomoManager>();
let proxies = mihomo.get_proxies().await.ok()?;
let (group, nodes, now) = parse_main_group(&proxies)?;
let node_delays: Vec<(String, Option<u32>)> = 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<u32>)>, String)>,
) -> Result<Menu<tauri::Wry>, 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<tauri::Wry>> = 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<tauri::Wry>> = 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<tauri::Wry>> = 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<u32>)>, String)>,
) -> Result<(Vec<MenuItem<tauri::Wry>>, Option<PredefinedMenuItem<tauri::Wry>>), 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<MenuItem<tauri::Wry>> = 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::<MihomoManager>();
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::<MihomoManager>();
let pm = app.state::<ProcessManager>();
// 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::<MihomoManager>();
let pm = app.state::<ProcessManager>();
// 关闭系统代理
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::<MihomoManager>();
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::<MonitorKernel>();
let pm = app.state::<ProcessManager>();
// 停止当前 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::<MihomoManager>() {
mihomo.cleanup_on_exit();
}
if let Some(engine) = app.try_state::<DownloadEngine>() {
engine.cleanup_on_exit();
}
if let Some(monitor) = app.try_state::<MonitorKernel>() {
tauri::async_runtime::block_on(monitor.cleanup_on_exit(app));
}
if let Some(clip) = app.try_state::<ClipboardManager>() {
clip.stop();
}
if let Some(pm) = app.try_state::<ProcessManager>() {
pm.stop_all();
}
app.exit(0);
}