Files
Thing/src-tauri/src/tray_menu.rs
T
2026-08-06 10:33:16 +08:00

686 lines
23 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 — 自定义系统托盘菜单(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<Option<Instant>> = Mutex::new(None);
/// 保存最近一次右键时计算出的定位参数(物理坐标),供 `tray_menu_ready` 使用
/// (x, tray_top, wa_top, wa_bottom, scale)
static LAST_MENU_LAYOUT: Mutex<Option<(f64, f64, f64, f64, f64)>> = 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<u32>,
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<String>,
pub proxy_nodes: Vec<ProxyNodeInfo>,
pub proxy_current: Option<String>,
}
// ===== 状态查询 =====
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;
}
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))
}
// ===== 获取菜单状态 =====
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<ProxyNodeInfo> = 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<serde_json::Value>,
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<String> {
let mihomo = app.state::<MihomoManager>();
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::<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(
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::<MihomoManager>();
let pm = app.state::<ProcessManager>();
// 关闭系统代理
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::<MihomoManager>();
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::<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(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::<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(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(())
}