From f566a564afe31507168487daa5dddaf33b5f60f2 Mon Sep 17 00:00:00 2001 From: Kasserrr Date: Thu, 28 Aug 2025 01:25:09 +0800 Subject: [PATCH] add proxy memu in tray --- src-tauri/src/cmd/proxy.rs | 17 ++ src-tauri/src/core/tray/mod.rs | 252 +++++++++++++++++++-- src-tauri/src/lib.rs | 1 + src/components/home/current-proxy-card.tsx | 9 +- src/components/proxy/proxy-groups.tsx | 11 + src/providers/app-data-provider.tsx | 80 ++++++- src/services/cmds.ts | 4 + 7 files changed, 345 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/cmd/proxy.rs b/src-tauri/src/cmd/proxy.rs index 1b72627f..c5da4132 100644 --- a/src-tauri/src/cmd/proxy.rs +++ b/src-tauri/src/cmd/proxy.rs @@ -45,3 +45,20 @@ pub async fn get_providers_proxies() -> CmdResult { .await; Ok((*value).clone()) } + +/// 同步托盘和GUI的代理选择状态 +#[tauri::command] +pub async fn sync_tray_proxy_selection() -> CmdResult<()> { + use crate::core::tray::Tray; + + match Tray::global().update_menu().await { + Ok(_) => { + logging!(info, Type::Cmd, "Tray proxy selection synced successfully"); + Ok(()) + } + Err(e) => { + logging!(error, Type::Cmd, "Failed to sync tray proxy selection: {e}"); + Err(e.to_string()) + } + } +} diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index 6ed15184..0ccf0b72 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -1,5 +1,6 @@ use once_cell::sync::OnceCell; use tauri::tray::TrayIconBuilder; +use tauri::Emitter; #[cfg(target_os = "macos")] pub mod speed_rate; use crate::ipc::Rate; @@ -8,6 +9,7 @@ use crate::{ cmd, config::Config, feat, logging, + ipc::IpcManager, module::lightweight::is_in_lightweight_mode, singleton_lazy, utils::{dirs::find_target_icons, i18n::t, resolve::VERSION}, @@ -68,6 +70,7 @@ pub struct Tray { } impl TrayState { + pub async fn get_common_tray_icon() -> (bool, Vec) { let verge = Config::verge().await.latest_ref().clone(); let is_common_tray_icon = verge.common_tray_icon.unwrap_or(false); @@ -136,6 +139,7 @@ impl TrayState { include_bytes!("../../../icons/tray-icon-sys.ico").to_vec(), ) } + } pub async fn get_tun_tray_icon() -> (bool, Vec) { @@ -171,8 +175,10 @@ impl TrayState { ) } } + } + impl Default for Tray { fn default() -> Self { Tray { @@ -257,6 +263,7 @@ impl Tray { } async fn update_menu_internal(&self, app_handle: &AppHandle) -> Result<()> { + let verge = Config::verge().await.latest_ref().clone(); let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false); let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false); @@ -277,6 +284,15 @@ impl Tray { .unwrap_or_default(); let is_lightweight_mode = is_in_lightweight_mode(); + // 获取代理节点 + let proxy_nodes_data = match IpcManager::global().get_proxies().await { + Ok(data) => data, + Err(e) => { + log::warn!(target: "app", "获取代理节点数据失败: {}", e); + serde_json::Value::Object(serde_json::Map::new()) + } + }; + match app_handle.tray_by_id("main") { Some(tray) => { let _ = tray.set_menu(Some( @@ -287,6 +303,7 @@ impl Tray { *tun_mode, profile_uid_and_name, is_lightweight_mode, + proxy_nodes_data, ) .await?, )); @@ -298,6 +315,7 @@ impl Tray { Ok(()) } } + } /// 更新托盘图标 @@ -555,6 +573,7 @@ impl Tray { Ok(()) } + } async fn create_tray_menu( @@ -564,8 +583,24 @@ async fn create_tray_menu( tun_mode_enabled: bool, profile_uid_and_name: Vec<(String, String)>, is_lightweight_mode: bool, + proxy_nodes_data: serde_json::Value, ) -> Result> { let mode = mode.unwrap_or(""); + + // 获取当前配置文件的选中代理组信息 + let current_profile_selected = { + let profiles = Config::profiles().await; + let profiles = profiles.latest_ref(); + if let Some(current_profile_uid) = profiles.get_current() { + if let Ok(profile) = profiles.get_item(¤t_profile_uid) { + profile.selected.clone().unwrap_or_default() + } else { + Vec::new() + } + } else { + Vec::new() + } + }; let unknown_version = String::from("unknown"); let version = VERSION.get().unwrap_or(&unknown_version); @@ -614,12 +649,113 @@ async fn create_tray_menu( results.into_iter().collect::, _>>()? }; + // 创建代理组子菜单结构 + let proxy_submenus: Vec> = { + let mut submenus = Vec::new(); + + // 解析代理组数据,创建分层菜单结构 + if let Some(proxies) = proxy_nodes_data.get("proxies").and_then(|v| v.as_object()) { + + for (group_name, group_data) in proxies.iter() { + let group_type_wrap = group_data.get("type").and_then(|v| v.as_str()); + let all_proxies_wrap = group_data.get("all").and_then(|v| v.as_array()); + + if group_type_wrap.is_none() || all_proxies_wrap.is_none() { + continue; + } + + let group_type = group_type_wrap.unwrap(); + let all_proxies = all_proxies_wrap.unwrap(); + + // 在全局模式下只显示GLOBAL组,在规则模式下显示所有Selector类型的代理组 + let should_show_group = if mode == "global" { + group_name == "GLOBAL" + } else { + group_type == "Selector" && group_name != "GLOBAL" + }; + + if !should_show_group { + continue; + } + + let now_proxy = group_data.get("now").and_then(|v| v.as_str()).unwrap_or(""); + + // 为每个代理组创建子菜单项 + let mut group_items = Vec::new(); + for proxy_name in all_proxies.iter() { + if let Some(proxy_str) = proxy_name.as_str() { + let is_selected = proxy_str == now_proxy; + let item_id = format!("proxy_{}_{}", group_name, proxy_str); + + match CheckMenuItem::with_id( + app_handle, + item_id, + proxy_str, // 只显示节点名,不显示组名前缀 + true, + is_selected, + None::<&str>, + ) { + Ok(item) => group_items.push(item), + Err(e) => log::warn!(target: "app", "创建代理菜单项失败: {}", e), + } + } + } + + // 创建代理组子菜单 + if !group_items.is_empty() { + let group_items_refs: Vec<&dyn IsMenuItem> = group_items + .iter() + .map(|item| item as &dyn IsMenuItem) + .collect(); + + // 判断当前代理组是否为真正在使用中的组 + let is_group_active = if mode == "global" { + // 全局模式下,只有GLOBAL组才能显示勾选 + group_name == "GLOBAL" && !now_proxy.is_empty() + } else if mode == "direct" { + // 直连模式下,不显示任何勾选 + false + } else { + // 规则模式下:只对用户在当前配置中手动选择过的代理组显示勾选 + // 这些组表示用户真正关心和使用的代理组 + let is_user_selected = current_profile_selected.iter().any(|selected| { + selected.name.as_deref() == Some(group_name) + }); + is_user_selected && !now_proxy.is_empty() + }; + + // 如果组处于活动状态,在组名前添加勾选标记 + let group_display_name = if is_group_active { + format!("✓ {}", group_name) + } else { + group_name.to_string() + }; + + match Submenu::with_id_and_items( + app_handle, + format!("proxy_group_{}", group_name), + group_display_name, // 使用带勾选标记的组名 + true, + &group_items_refs, + ) { + Ok(submenu) => submenus.push(submenu), + Err(e) => log::warn!(target: "app", "创建代理组子菜单失败: {}", e), + } + } + } + } + + submenus + }; + + // Pre-fetch all localized strings let dashboard_text = t("Dashboard").await; let rule_mode_text = t("Rule Mode").await; let global_mode_text = t("Global Mode").await; let direct_mode_text = t("Direct Mode").await; let profiles_text = t("Profiles").await; + let proxies_text = t("Proxies").await; let system_proxy_text = t("System Proxy").await; let tun_mode_text = t("TUN Mode").await; let lightweight_mode_text = t("LightWeight Mode").await; @@ -683,6 +819,24 @@ async fn create_tray_menu( &profile_menu_items_refs, )?; + // 创建代理主菜单(包含所有代理组子菜单) + let proxies_submenu = if !proxy_submenus.is_empty() { + let proxy_submenu_refs: Vec<&dyn IsMenuItem> = proxy_submenus + .iter() + .map(|submenu| submenu as &dyn IsMenuItem) + .collect(); + + Some(Submenu::with_id_and_items( + app_handle, + "proxies", + proxies_text, + true, + &proxy_submenu_refs, + )?) + } else { + None + }; + let system_proxy = &CheckMenuItem::with_id( app_handle, "system_proxy", @@ -780,26 +934,37 @@ async fn create_tray_menu( let separator = &PredefinedMenuItem::separator(app_handle)?; + // 动态构建菜单项 + let mut menu_items: Vec<&dyn IsMenuItem> = vec![ + open_window, + separator, + rule_mode, + global_mode, + direct_mode, + separator, + profiles, + ]; + + // 如果有代理节点,添加代理节点菜单 + if let Some(ref proxies_menu) = proxies_submenu { + menu_items.push(proxies_menu); + } + + menu_items.extend_from_slice(&[ + separator, + system_proxy as &dyn IsMenuItem, + tun_mode as &dyn IsMenuItem, + separator, + lighteweight_mode as &dyn IsMenuItem, + copy_env as &dyn IsMenuItem, + open_dir as &dyn IsMenuItem, + more as &dyn IsMenuItem, + separator, + quit as &dyn IsMenuItem, + ]); + let menu = tauri::menu::MenuBuilder::new(app_handle) - .items(&[ - open_window, - separator, - rule_mode, - global_mode, - direct_mode, - separator, - profiles, - separator, - system_proxy, - tun_mode, - separator, - lighteweight_mode, - copy_env, - open_dir, - more, - separator, - quit, - ]) + .items(&menu_items) .build()?; Ok(menu) } @@ -873,6 +1038,55 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) { let profile_index = &id["profiles_".len()..]; feat::toggle_proxy_profile(profile_index.into()).await; // Await async function } + id if id.starts_with("proxy_") => { + // 处理代理节点切换: proxy_{group_name}_{proxy_name} + let parts: Vec<&str> = id.splitn(3, '_').collect(); + + if parts.len() == 3 && parts[0] == "proxy" { + let group_name = parts[1]; + let proxy_name = parts[2]; + + // 获取当前clash配置模式 + let current_mode = { + Config::clash() + .await + .latest_ref() + .0 + .get("mode") + .map(|val| val.as_str().unwrap_or("rule")) + .unwrap_or("rule") + .to_owned() + }; + + // 使用 IPC 管理器切换代理节点 + match IpcManager::global().update_proxy(group_name, proxy_name).await { + Ok(_) => { + log::info!(target: "app", "代理节点切换成功: {} -> {} (模式: {}, 目标组: {})", group_name, proxy_name, current_mode, group_name); + + println!("代理节点切换成功: {} -> {} (模式: {}, 目标组: {})", group_name, proxy_name, current_mode, group_name); + // 立即刷新托盘菜单 + if let Err(e) = Tray::global().update_menu().await { + log::warn!(target: "app", "立即更新托盘菜单失败: {e}"); + } + + // 发出前端刷新事件,确保GUI及时更新 + if let Some(app_handle) = handle::Handle::global().app_handle() { + + // 先强制刷新代理缓存,然后发送GUI刷新事件 + let _ = app_handle.emit("verge://force-refresh-proxies", ()); + // 发送GUI刷新事件 + let _ = app_handle.emit("verge://refresh-proxy-config", ()); + let _ = app_handle.emit("verge://refresh-clash-config", ()); + + log::debug!(target: "app", "托盘代理切换事件已发送到前端"); + } + } + Err(e) => { + log::error!(target: "app", "代理节点切换失败: {} -> {}, 错误: {}", group_name, proxy_name, e); + } + } + } + } _ => {} } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8a686b15..83c2e0a2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -337,6 +337,7 @@ mod app_init { cmd::get_proxies, cmd::force_refresh_proxies, cmd::get_providers_proxies, + cmd::sync_tray_proxy_selection, cmd::save_dns_config, cmd::apply_dns_config, cmd::check_dns_config_exists, diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index 23622862..e47a20ab 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -29,7 +29,7 @@ import { } from "@mui/icons-material"; import { useNavigate } from "react-router-dom"; import { EnhancedCard } from "@/components/home/enhanced-card"; -import { updateProxy, deleteConnection } from "@/services/cmds"; +import { updateProxy, deleteConnection, syncTrayProxySelection } from "@/services/cmds"; import delayManager from "@/services/delay"; import { useVerge } from "@/hooks/use-verge"; import { useAppData } from "@/providers/app-data-provider"; @@ -342,6 +342,13 @@ export const CurrentProxyCard = () => { }); } + // 同步托盘菜单状态 + try { + await syncTrayProxySelection(); + } catch (syncError) { + console.warn("Failed to sync tray proxy selection:", syncError); + } + // 延长刷新延迟时间 setTimeout(() => { refreshProxy(); diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index b2d723e6..a951d98f 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -7,6 +7,7 @@ import { updateProxy, deleteConnection, getGroupProxyDelays, + syncTrayProxySelection, } from "@/services/cmds"; import { forceRefreshProxies } from "@/services/cmds"; import { useProfiles } from "@/hooks/use-profiles"; @@ -341,6 +342,8 @@ export const ProxyGroups = (props: Props) => { if (!["Selector", "URLTest", "Fallback"].includes(group.type)) return; const { name, now } = group; + console.log(`[ProxyGroups] GUI代理切换: ${name} -> ${proxy.name}`); + await updateProxy(name, proxy.name); await forceRefreshProxies(); @@ -372,6 +375,14 @@ export const ProxyGroups = (props: Props) => { current.selected[index] = { name, now: proxy.name }; } await patchCurrent({ selected: current.selected }); + + // 同步托盘菜单状态 + try { + await syncTrayProxySelection(); + console.log(`[ProxyGroups] 托盘状态同步成功: ${name} -> ${proxy.name}`); + } catch (error) { + console.warn("Failed to sync tray proxy selection:", error); + } }, ); diff --git a/src/providers/app-data-provider.tsx b/src/providers/app-data-provider.tsx index 4962648b..f2f89d1f 100644 --- a/src/providers/app-data-provider.tsx +++ b/src/providers/app-data-provider.tsx @@ -221,16 +221,78 @@ export const AppDataProvider = ({ } }; - window.addEventListener( - "verge://refresh-clash-config", - handleRefreshClash, - ); + // 监听代理配置刷新事件(托盘代理切换等) + const handleRefreshProxy = () => { + const now = Date.now(); + console.log("[AppDataProvider] 代理配置刷新事件"); - return () => { - window.removeEventListener( - "verge://refresh-clash-config", - handleRefreshClash, - ); + if (now - lastUpdateTime > refreshThrottle) { + lastUpdateTime = now; + + setTimeout(() => { + refreshProxy().catch((e) => + console.warn("[AppDataProvider] 代理刷新失败:", e), + ); + }, 100); + } + }; + + // 监听强制代理刷新事件(托盘代理切换立即刷新) + const handleForceRefreshProxies = () => { + console.log("[AppDataProvider] 强制代理刷新事件"); + + // 立即刷新,无延迟,无防抖 + forceRefreshProxies() + .then(() => { + console.log("[AppDataProvider] 强制刷新代理缓存完成"); + // 强制刷新完成后,立即刷新前端显示 + return refreshProxy(); + }) + .then(() => { + console.log("[AppDataProvider] 前端代理数据刷新完成"); + }) + .catch((e) => { + console.warn("[AppDataProvider] 强制代理刷新失败:", e); + // 如果强制刷新失败,尝试普通刷新 + refreshProxy().catch((e2) => + console.warn("[AppDataProvider] 普通代理刷新也失败:", e2), + ); + }); + }; + + // 使用 Tauri 事件监听器替代 window 事件监听器 + const setupTauriListeners = async () => { + try { + const unlistenClash = await listen("verge://refresh-clash-config", handleRefreshClash); + const unlistenProxy = await listen("verge://refresh-proxy-config", handleRefreshProxy); + const unlistenForceRefresh = await listen("verge://force-refresh-proxies", handleForceRefreshProxies); + + return () => { + unlistenClash(); + unlistenProxy(); + unlistenForceRefresh(); + }; + } catch (error) { + console.warn("[AppDataProvider] 设置 Tauri 事件监听器失败:", error); + + // 降级到 window 事件监听器 + window.addEventListener("verge://refresh-clash-config", handleRefreshClash); + window.addEventListener("verge://refresh-proxy-config", handleRefreshProxy); + window.addEventListener("verge://force-refresh-proxies", handleForceRefreshProxies); + + return () => { + window.removeEventListener("verge://refresh-clash-config", handleRefreshClash); + window.removeEventListener("verge://refresh-proxy-config", handleRefreshProxy); + window.removeEventListener("verge://force-refresh-proxies", handleForceRefreshProxies); + }; + } + }; + + const cleanupTauriListeners = setupTauriListeners(); + + return async () => { + const cleanup = await cleanupTauriListeners; + cleanup(); }; } catch (error) { console.error("[AppDataProvider] 事件监听器设置失败:", error); diff --git a/src/services/cmds.ts b/src/services/cmds.ts index cfc0efb4..ef4e5257 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -143,6 +143,10 @@ export async function updateProxy(group: string, proxy: string) { // console.log(`[API] updateProxy 耗时: ${duration}ms`); } +export async function syncTrayProxySelection() { + return invoke("sync_tray_proxy_selection"); +} + export async function getProxies(): Promise<{ global: IProxyGroupItem; direct: IProxyItem;