This commit is contained in:
zhongluofeng
2026-07-30 09:09:29 +08:00
parent 74902c4cec
commit f452322aad
32 changed files with 4586 additions and 115 deletions
+738
View File
@@ -0,0 +1,738 @@
//! 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);
}