托盘调整
This commit is contained in:
+360
-386
@@ -1,46 +1,53 @@
|
||||
//! tray_menu.rs — 自定义系统托盘右键菜单
|
||||
//! tray_menu.rs — 自定义系统托盘菜单(WebviewWindow 弹窗)
|
||||
//!
|
||||
//! 菜单结构:
|
||||
//! 代理 ▸
|
||||
//! 开启代理 (启动mihomo + 择优选节点 + 系统代理 + Windows通知)
|
||||
//! 关闭代理
|
||||
//! ---
|
||||
//! 选择代理 ▸
|
||||
//! 刷新节点列表
|
||||
//! ---
|
||||
//! 节点1 (120ms)
|
||||
//! 节点2 (超时)
|
||||
//! ...
|
||||
//! ---
|
||||
//! 切换 OSD
|
||||
//! 重启 Kernel
|
||||
//! ---
|
||||
//! 新建下载
|
||||
//! ---
|
||||
//! 常规设置
|
||||
//! 退出
|
||||
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
//! 使用独立 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,
|
||||
menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu},
|
||||
tray::{MouseButton, TrayIconBuilder, TrayIconEvent, TrayIconId},
|
||||
AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder,
|
||||
tray::{MouseButton, TrayIconBuilder, TrayIconEvent},
|
||||
window::{Effect, EffectsBuilder},
|
||||
};
|
||||
|
||||
/// 记录窗口最后显示时间,用于失焦防抖(避免显示瞬间因焦点未稳定而被立即隐藏)
|
||||
static LAST_SHOW_TIME: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
|
||||
use crate::clipboard::popup::{get_cursor_pos, get_work_area};
|
||||
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};
|
||||
|
||||
// ===== 全局状态:代理节点菜单项映射 =====
|
||||
/// 托盘菜单窗口标签
|
||||
pub const TRAY_MENU_LABEL: &str = "tray-menu";
|
||||
|
||||
/// 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()))
|
||||
#[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>,
|
||||
}
|
||||
|
||||
// ===== 伪节点过滤(与前端 ProxyModule 保持一致) =====
|
||||
@@ -107,7 +114,6 @@ fn is_monitor_running(app: &AppHandle) -> bool {
|
||||
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)| {
|
||||
@@ -120,7 +126,6 @@ fn parse_main_group(proxies_response: &serde_json::Value) -> Option<(String, Vec
|
||||
return None;
|
||||
}
|
||||
|
||||
// 优先选择名为 PROXY / Proxy / 节点选择 / 代理 的组
|
||||
let preferred = selector_groups
|
||||
.iter()
|
||||
.find(|name| matches!(**name, "PROXY" | "Proxy" | "节点选择" | "代理"))
|
||||
@@ -156,9 +161,8 @@ fn parse_node_delay(proxies_response: &serde_json::Value, name: &str) -> Option<
|
||||
.map(|d| d as u32)
|
||||
}
|
||||
|
||||
// ===== 菜单构建 =====
|
||||
// ===== 获取代理节点数据(异步,调用 mihomo API) =====
|
||||
|
||||
/// 获取代理节点数据(异步,调用 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()?;
|
||||
@@ -170,224 +174,226 @@ async fn fetch_proxy_nodes(app: &AppHandle) -> Option<(String, Vec<(String, Opti
|
||||
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) {
|
||||
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_nodes = if proxy_running {
|
||||
fetch_proxy_nodes(app).await
|
||||
|
||||
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
|
||||
(None, vec![], 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));
|
||||
}
|
||||
TrayMenuState {
|
||||
proxy_running,
|
||||
monitor_running,
|
||||
proxy_group,
|
||||
proxy_nodes,
|
||||
proxy_current,
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 菜单事件处理 =====
|
||||
// ===== 显示/隐藏托盘菜单窗口 =====
|
||||
|
||||
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);
|
||||
/// 托盘菜单窗口尺寸(逻辑像素)
|
||||
const MENU_W: f64 = 260.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) => {
|
||||
eprintln!("[tray-menu] 预创建菜单窗口失败: {}", 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();
|
||||
match *t {
|
||||
Some(time) => time.elapsed() > Duration::from_millis(300),
|
||||
None => true,
|
||||
}
|
||||
});
|
||||
};
|
||||
if should_hide {
|
||||
let _ = win_handle.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
eprintln!("[tray-menu] 菜单窗口已预创建(隐藏渲染)");
|
||||
}
|
||||
|
||||
/// 在鼠标当前位置显示托盘菜单窗口。
|
||||
/// 窗口不存在则创建并显示,已存在则移动到鼠标位置并显示。
|
||||
pub fn show_tray_menu(app: &AppHandle) {
|
||||
let (mx, my) = match get_cursor_pos() {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// GetCursorPos 在 Per-Monitor DPI V2 下返回物理像素,直接用作 PhysicalPosition
|
||||
let win = app.get_webview_window(TRAY_MENU_LABEL);
|
||||
let scale = win.as_ref()
|
||||
.and_then(|w| w.scale_factor().ok())
|
||||
.unwrap_or(1.0);
|
||||
|
||||
let w_phys = (MENU_W * scale) as i32;
|
||||
let h_phys = (MENU_H * scale) as i32;
|
||||
|
||||
// 托盘位于屏幕右下角,菜单始终出现在鼠标左上方
|
||||
let x = (mx - w_phys).max(0);
|
||||
let y = (my - h_phys - 8).max(0);
|
||||
|
||||
// 记录显示时间,用于失焦防抖
|
||||
{
|
||||
let mut t = LAST_SHOW_TIME.lock().unwrap();
|
||||
*t = Some(Instant::now());
|
||||
}
|
||||
|
||||
// 窗口已存在(预创建或之前显示过):移动 + 显示 + 发送状态
|
||||
if let Some(win) = win {
|
||||
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y }));
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
let app_clone = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = get_tray_menu_state(&app_clone).await;
|
||||
let _ = app_clone.emit("tray-menu-show", state);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 兜底:窗口未预创建(理论上不会走到,因为 create_tray_menu 已预创建)
|
||||
precreate_tray_menu_window(app);
|
||||
if let Some(win) = app.get_webview_window(TRAY_MENU_LABEL) {
|
||||
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y }));
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
let app_clone = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = get_tray_menu_state(&app_clone).await;
|
||||
let _ = app_clone.emit("tray-menu-show", state);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 预创建窗口完成后由前端调用(仅触发状态推送,不显示窗口——预创建模式下窗口保持隐藏)。
|
||||
pub fn show_window(app: &AppHandle) {
|
||||
if app.get_webview_window(TRAY_MENU_LABEL).is_some() {
|
||||
let app_clone = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = get_tray_menu_state(&app_clone).await;
|
||||
let _ = app_clone.emit("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("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 {
|
||||
eprintln!("[tray] 开启代理失败: {}", e);
|
||||
send_notification(&app, "代理启动失败", &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);
|
||||
}
|
||||
});
|
||||
if let Err(e) = disable_proxy(&app).await {
|
||||
eprintln!("[tray] 关闭代理失败: {}", e);
|
||||
send_notification(&app, "代理关闭失败", &e);
|
||||
}
|
||||
}
|
||||
"proxy_refresh" => {
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// 先重建菜单(保留旧数据),然后并行测试延迟并再次重建
|
||||
rebuild_menu(&app).await;
|
||||
test_all_delays_and_rebuild(&app).await;
|
||||
});
|
||||
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("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);
|
||||
}
|
||||
});
|
||||
if let Err(e) = restart_kernel(&app).await {
|
||||
eprintln!("[tray] 重启 Kernel 失败: {}", e);
|
||||
send_notification(&app, "Kernel 重启失败", &e);
|
||||
}
|
||||
}
|
||||
"download_new" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
@@ -395,6 +401,7 @@ fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) {
|
||||
window.set_focus().ok();
|
||||
}
|
||||
let _ = app.emit("tray:new-download", ());
|
||||
hide_tray_menu(&app);
|
||||
}
|
||||
"settings" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
@@ -402,58 +409,43 @@ fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) {
|
||||
window.set_focus().ok();
|
||||
}
|
||||
let _ = app.emit("tray:open-settings", ());
|
||||
hide_tray_menu(&app);
|
||||
}
|
||||
"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);
|
||||
}
|
||||
});
|
||||
hide_tray_menu(&app);
|
||||
quit_cleanup(&app);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从全局映射中获取 (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);
|
||||
}
|
||||
// 刷新状态并发送给前端(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(())
|
||||
}
|
||||
|
||||
/// 显示已创建的菜单窗口(前端 onMounted 后调用)
|
||||
#[tauri::command]
|
||||
pub async fn tray_menu_show_window(app: AppHandle) -> Result<(), String> {
|
||||
show_window(&app);
|
||||
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)
|
||||
}
|
||||
|
||||
// ===== 代理开启/关闭 =====
|
||||
@@ -485,24 +477,22 @@ async fn enable_proxy(app: &AppHandle) -> Result<(), String> {
|
||||
match &best {
|
||||
Some((name, delay)) => {
|
||||
send_notification(
|
||||
app,
|
||||
"代理已开启",
|
||||
&format!("当前节点: {} ({}ms)", name, delay),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
send_notification("代理已开启", "所有节点均超时,未自动选择");
|
||||
send_notification(app, "代理已开启", "所有节点均超时,未自动选择");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
send_notification("代理已开启", "无可用节点");
|
||||
send_notification(app, "代理已开启", "无可用节点");
|
||||
}
|
||||
|
||||
// 5. 开启系统代理
|
||||
mihomo.enable_system_proxy()?;
|
||||
|
||||
// 6. 重建菜单
|
||||
rebuild_menu(app).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -516,10 +506,7 @@ async fn disable_proxy(app: &AppHandle) -> Result<(), String> {
|
||||
// 停止 mihomo
|
||||
pm.stop("proxy")?;
|
||||
|
||||
send_notification("代理已关闭", "mihomo 已停止,系统代理已清除");
|
||||
|
||||
// 重建菜单
|
||||
rebuild_menu(app).await;
|
||||
send_notification(app, "代理已关闭", "mihomo 已停止,系统代理已清除");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -547,14 +534,12 @@ async fn test_and_select_best(
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -562,21 +547,18 @@ async fn test_and_select_best(
|
||||
best
|
||||
}
|
||||
|
||||
/// 测试所有节点延迟后重建菜单
|
||||
async fn test_all_delays_and_rebuild(app: &AppHandle) {
|
||||
/// 并行测试所有节点延迟(更新 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;
|
||||
}
|
||||
Err(_) => return,
|
||||
};
|
||||
let (_group, nodes, _) = match parse_main_group(&proxies) {
|
||||
let (_group, nodes, _now) = match parse_main_group(&proxies) {
|
||||
Some(g) => g,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// 并行测试延迟(更新 mihomo 内部 history)
|
||||
use futures_util::future::join_all;
|
||||
let futures: Vec<_> = nodes
|
||||
.iter()
|
||||
@@ -587,9 +569,30 @@ async fn test_all_delays_and_rebuild(app: &AppHandle) {
|
||||
})
|
||||
.collect();
|
||||
join_all(futures).await;
|
||||
}
|
||||
|
||||
// 再次获取 proxies(此时 history 已更新)并重建菜单
|
||||
rebuild_menu(app).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) => {
|
||||
eprintln!("[tray] 切换节点失败: {}", e);
|
||||
send_notification(app, "切换节点失败", &e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 等待 mihomo API 就绪 =====
|
||||
@@ -621,103 +624,25 @@ async fn restart_kernel(app: &AppHandle) -> Result<(), String> {
|
||||
// 重新启动
|
||||
monitor.start_with_subscription(app).await?;
|
||||
|
||||
send_notification("Kernel 已重启", "ThingHK 监控内核已重新启动");
|
||||
rebuild_menu(app).await;
|
||||
send_notification(app, "Kernel 已重启", "ThingHK 监控内核已重新启动");
|
||||
|
||||
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();
|
||||
fn send_notification(app: &AppHandle, title: &str, message: &str) {
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
let _ = app
|
||||
.notification()
|
||||
.builder()
|
||||
.title(title)
|
||||
.body(message)
|
||||
.show();
|
||||
}
|
||||
|
||||
#[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();
|
||||
@@ -725,8 +650,18 @@ pub fn quit_cleanup(app: &AppHandle) {
|
||||
if let Some(engine) = app.try_state::<DownloadEngine>() {
|
||||
engine.cleanup_on_exit();
|
||||
}
|
||||
// monitor.cleanup_on_exit 是 async,在 tokio worker 线程中直接 block_on 会 panic,
|
||||
// 放到独立 OS 线程执行 block_on,避免嵌套 runtime。
|
||||
if let Some(monitor) = app.try_state::<MonitorKernel>() {
|
||||
tauri::async_runtime::block_on(monitor.cleanup_on_exit(app));
|
||||
let app_clone = app.clone();
|
||||
let monitor_clone = monitor.inner().clone();
|
||||
std::thread::spawn(move || {
|
||||
tauri::async_runtime::block_on(async move {
|
||||
monitor_clone.cleanup_on_exit(&app_clone).await;
|
||||
});
|
||||
})
|
||||
.join()
|
||||
.ok();
|
||||
}
|
||||
if let Some(clip) = app.try_state::<ClipboardManager>() {
|
||||
clip.stop();
|
||||
@@ -736,3 +671,42 @@ 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("main") {
|
||||
window.show().ok();
|
||||
window.set_focus().ok();
|
||||
}
|
||||
}
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Right,
|
||||
..
|
||||
} => {
|
||||
// 右键:显示自定义菜单窗口
|
||||
show_tray_menu(&app);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// 预创建隐藏的菜单窗口,让前端提前渲染主题和内容,右键时直接 show 无延迟
|
||||
precreate_tray_menu_window(app);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user