748 lines
27 KiB
Rust
748 lines
27 KiB
Rust
//! 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, cursor_y, screen_top, screen_bottom, scale)
|
||
static LAST_MENU_LAYOUT: Mutex<Option<(f64, f64, f64, f64, f64)>> = 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<u32>,
|
||
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<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))
|
||
}
|
||
|
||
// ===== 获取菜单状态 =====
|
||
|
||
/// 基础状态(不含代理节点)。托盘菜单显示不应受 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<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,
|
||
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<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;
|
||
}
|
||
}
|
||
}
|
||
"system_proxy_toggle" => {
|
||
// 根据注册表当前真实状态切换(不依赖 settings.json 缓存,避免与主界面不同步)
|
||
let mihomo = app.state::<MihomoManager>();
|
||
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<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 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<String> = 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::<MihomoManager>();
|
||
let pm = app.state::<ProcessManager>();
|
||
|
||
// 关闭系统代理
|
||
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::<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,
|
||
..
|
||
} => {
|
||
// 左键:显示主窗口(强制置前,绕过前台锁定)
|
||
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(())
|
||
}
|