From bf35539ad89078de708f913c0b73fb8181f51258 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:28:19 +0800 Subject: [PATCH] perf(tray): mutate menu state in place, rebuild only on structure change Rebuilding the tray menu for every state change re-creates every item even when only checkmarks, delay texts or the selected node moved. Keep handles into the attached menu and, inside the same main-thread dispatch, mutate items in place. A signature over topology inputs (display mode, inline flags, mode, language, hotkeys, profile list) gates a full rebuild, and a proxy-only signature change swaps just the group submenus under the persisted Proxies submenu. State-only updates (mode/system proxy/TUN/lightweight switches, profile selection, node selection, delay texts) no longer reconstruct the menu; structural changes still rebuild exactly as before. --- src-tauri/src/core/tray/menu_state.rs | 237 +++++++++++++++++++++++++ src-tauri/src/core/tray/mod.rs | 243 +++++++++++++++----------- 2 files changed, 378 insertions(+), 102 deletions(-) create mode 100644 src-tauri/src/core/tray/menu_state.rs diff --git a/src-tauri/src/core/tray/menu_state.rs b/src-tauri/src/core/tray/menu_state.rs new file mode 100644 index 000000000..48e2dd371 --- /dev/null +++ b/src-tauri/src/core/tray/menu_state.rs @@ -0,0 +1,237 @@ +//! Live handles into the attached tray menu and the reconcile decision: +//! state-only changes mutate items in place, a proxy-only structure change +//! swaps the group submenus, anything else rebuilds. + +use crate::Type; +use crate::config::{IProfiles, IVerge}; +use clash_verge_logging::logging_error; +use parking_lot::Mutex; +use smartstring::alias::String; +use std::collections::HashMap; +use tauri::menu::{CheckMenuItem, IsMenuItem, Submenu}; +use tauri::tray::TrayIcon; +use tauri::{AppHandle, Wry}; +use tauri_plugin_mihomo::models::Proxies; + +use super::{TrayMenuOptions, build_tray_menu, create_subcreate_proxy_menu_item}; + +pub(super) struct ProxyNodeItem { + pub(super) name: String, + pub(super) item: CheckMenuItem, + pub(super) text: String, + pub(super) checked: bool, +} + +pub(super) struct ProxyGroupItems { + pub(super) name: String, + pub(super) nodes: Vec, +} + +pub(super) struct TrayMenuState { + pub(super) mode_items: [CheckMenuItem; 3], + pub(super) system_proxy_item: CheckMenuItem, + pub(super) tun_item: CheckMenuItem, + pub(super) lightweight_item: CheckMenuItem, + pub(super) profile_items: Vec<(String, CheckMenuItem)>, + pub(super) proxies_submenu: Option>, + pub(super) proxy_groups: Vec, + pub(super) signature: String, + pub(super) proxy_signature: Option, +} + +pub(super) fn proxy_delay_text(proxy_nodes_data: &Proxies, proxy_str: &str) -> String { + proxy_nodes_data + .proxies + .get(proxy_str) + .and_then(|h| h.history.last()) + .map(|h| match h.delay { + 0 => "-ms".into(), + delay if delay >= 10000 => "-ms".into(), + _ => format!("{}ms", h.delay).into(), + }) + .unwrap_or_else(|| "-ms".into()) +} + +/// Topology inputs that a rebuild cannot reconcile by mutating items. +pub(super) fn menu_signature(verge: &IVerge, profiles: &IProfiles, mode: &str) -> String { + let hotkeys = verge.hotkeys.as_ref().map(|h| h.join(",")).unwrap_or_default(); + let profiles = profiles + .profiles_preview() + .unwrap_or_default() + .into_iter() + .map(|profile| format!("{}:{}", profile.uid, profile.name)) + .collect::>() + .join(";"); + format!( + "{}|{}|{mode}|{}|{hotkeys}|{profiles}", + verge.tray_proxy_groups_display_mode.as_deref().unwrap_or("default"), + verge.tray_inline_outbound_modes.unwrap_or(false), + verge.language.as_deref().unwrap_or(""), + ) + .into() +} + +/// Group/node identity plus ordering, canonicalized so it is stable across fetches. +pub(super) fn proxy_structure_signature( + proxy_mode: &str, + order_map: Option<&HashMap>, + proxy_nodes_data: &Proxies, +) -> String { + let mut parts: Vec = proxy_nodes_data + .proxies + .iter() + .filter(|(name, data)| { + let global = name.as_str() == "GLOBAL"; + (match proxy_mode { + "global" => global, + _ => !global, + }) && !data.hidden.unwrap_or_default() + }) + .filter_map(|(name, data)| { + data.all.as_ref().map(|all| { + let order = order_map + .and_then(|map| map.get(name.as_str())) + .map_or_else(|| "-".to_owned(), |index| index.to_string()); + format!( + "{}@{order}:{}", + name, + all.iter().map(|node| node.as_str()).collect::>().join(",") + ) + }) + }) + .collect(); + parts.sort(); + parts.join(";").into() +} + +pub(super) struct ReconcileContext<'a> { + pub(super) app_handle: &'a AppHandle, + pub(super) tray: &'a TrayIcon, + pub(super) options: TrayMenuOptions<'a>, + pub(super) verge: &'a IVerge, + pub(super) profiles: &'a IProfiles, + pub(super) fetched_proxies: Option<&'a Proxies>, + pub(super) order_map: Option<&'a HashMap>, + pub(super) menu_signature: &'a str, + pub(super) proxy_signature: Option<&'a str>, + pub(super) fetched: bool, +} + +pub(super) fn reconcile(state_store: &Mutex>, ctx: ReconcileContext<'_>) -> anyhow::Result<()> { + let tun_mode_available = crate::core::runstate::RUN_STATE.state().tun_capable(); + { + let mut attached = state_store.lock(); + if let Some(state) = attached.as_mut().filter(|state| state.signature == ctx.menu_signature) { + let in_place = !ctx.fetched || state.proxy_signature.as_deref() == ctx.proxy_signature; + let swapped = !in_place + && ctx.fetched_proxies.is_some_and(|data| { + state.swap_proxy_section( + ctx.app_handle, + ctx.options.mode.unwrap_or(""), + ctx.order_map, + data, + ctx.proxy_signature, + ) + }); + if in_place || swapped { + state.apply_static(&ctx.options, ctx.profiles, tun_mode_available); + if in_place && let Some(data) = ctx.fetched_proxies { + state.apply_proxy(data); + } + return Ok(()); + } + } + } + + let (menu, mut new_state) = build_tray_menu( + ctx.app_handle, + ctx.options, + ctx.verge, + ctx.profiles, + ctx.order_map, + ctx.fetched_proxies, + )?; + new_state.signature = ctx.menu_signature.into(); + new_state.proxy_signature = if ctx.fetched { + ctx.proxy_signature.map(Into::into) + } else { + None + }; + logging_error!(Type::Tray, ctx.tray.set_menu(Some(menu))); + *state_store.lock() = Some(new_state); + Ok(()) +} + +impl TrayMenuState { + fn apply_static(&self, options: &TrayMenuOptions<'_>, profiles: &IProfiles, tun_mode_available: bool) { + let mode = options.mode.unwrap_or(""); + for (item, name) in self.mode_items.iter().zip(["rule", "global", "direct"]) { + logging_error!(Type::Tray, item.set_checked(mode == name)); + } + logging_error!( + Type::Tray, + self.system_proxy_item.set_checked(options.system_proxy_enabled) + ); + logging_error!(Type::Tray, self.tun_item.set_enabled(tun_mode_available)); + logging_error!(Type::Tray, self.tun_item.set_checked(options.tun_mode_enabled)); + logging_error!( + Type::Tray, + self.lightweight_item.set_checked(options.is_lightweight_mode) + ); + let current = profiles.current.as_deref(); + for (uid, item) in &self.profile_items { + logging_error!(Type::Tray, item.set_checked(current == Some(uid.as_str()))); + } + } + + fn apply_proxy(&mut self, proxy_nodes_data: &Proxies) { + for group in self.proxy_groups.iter_mut() { + let Some(group_data) = proxy_nodes_data.proxies.get(group.name.as_str()) else { + continue; + }; + let now = group_data.now.as_deref().unwrap_or_default(); + for node in group.nodes.iter_mut() { + let text: String = format!( + "{} | {}", + node.name, + proxy_delay_text(proxy_nodes_data, node.name.as_str()) + ) + .into(); + if node.text != text { + logging_error!(Type::Tray, node.item.set_text(&text)); + node.text = text; + } + let checked = node.name == now; + if node.checked != checked { + logging_error!(Type::Tray, node.item.set_checked(checked)); + node.checked = checked; + } + } + } + } + + fn swap_proxy_section( + &mut self, + app_handle: &AppHandle, + proxy_mode: &str, + order_map: Option<&HashMap>, + proxy_nodes_data: &Proxies, + proxy_signature: Option<&str>, + ) -> bool { + let Some(proxies_submenu) = self.proxies_submenu.clone() else { + return false; + }; + let (group_menus, group_state) = + create_subcreate_proxy_menu_item(app_handle, proxy_mode, order_map, proxy_nodes_data); + if group_menus.is_empty() { + return false; + } + while proxies_submenu.remove_at(0).is_ok_and(|item| item.is_some()) {} + let group_menus_refs: Vec<&dyn IsMenuItem> = + group_menus.iter().map(|menu| menu as &dyn IsMenuItem).collect(); + logging_error!(Type::Tray, proxies_submenu.append_items(&group_menus_refs)); + self.proxy_groups = group_state; + self.proxy_signature = proxy_signature.map(Into::into); + true + } +} diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index 15e3ff089..ddf4e1b62 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -13,6 +13,7 @@ use crate::{ }; use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter}; use clash_verge_logging::logging_error; +use parking_lot::Mutex; use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; use tauri_plugin_mihomo::models::Proxies; use tokio::fs; @@ -29,9 +30,14 @@ use tauri::{ }; mod menu_def; +mod menu_state; #[cfg(target_os = "macos")] mod speed_task; use menu_def::{MenuIds, MenuTexts}; +use menu_state::{ + ProxyGroupItems, ProxyNodeItem, ReconcileContext, TrayMenuState, menu_signature, proxy_delay_text, + proxy_structure_signature, reconcile, +}; // TODO: 是否需要将可变菜单抽离存储起来,后续直接更新对应菜单实例,无需重新创建菜单(待考虑) @@ -62,6 +68,7 @@ pub struct Tray { limiter: SystemLimiter, #[cfg(target_os = "macos")] speed_controller: speed_task::TraySpeedController, + menu_state: Mutex>, } impl TrayState { @@ -135,6 +142,7 @@ impl Default for Tray { limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock), #[cfg(target_os = "macos")] speed_controller: speed_task::TraySpeedController::new(), + menu_state: Mutex::new(None), } } } @@ -257,28 +265,36 @@ impl Tray { }; let profiles = Config::profiles().await.latest_arc(); + let menu_signature = menu_signature(&verge, &profiles, &mode); + let proxy_signature = proxy_nodes_data + .as_ref() + .map(|data| proxy_structure_signature(&mode, runtime_proxy_groups_order.as_ref(), data)); + let (tx, rx) = tokio::sync::oneshot::channel(); let dispatch_handle = app_handle.clone(); app_handle .run_on_main_thread(move || { - let _ = tx.send((|| -> Result<()> { - let menu = build_tray_menu( - &dispatch_handle, - TrayMenuOptions { + let _ = tx.send(reconcile( + &Self::global().menu_state, + ReconcileContext { + app_handle: &dispatch_handle, + tray: &tray, + options: TrayMenuOptions { mode: Some(mode.as_str()), system_proxy_enabled: system_proxy, tun_mode_enabled: tun_mode, is_lightweight_mode, include_proxy_groups, }, - &verge, - &profiles, - runtime_proxy_groups_order, - proxy_nodes_data, - )?; - logging_error!(Type::Tray, tray.set_menu(Some(menu))); - Ok(()) - })()); + verge: &verge, + profiles: &profiles, + fetched_proxies: proxy_nodes_data.as_ref(), + order_map: runtime_proxy_groups_order.as_ref(), + menu_signature: &menu_signature, + proxy_signature: proxy_signature.as_deref(), + fetched: fetch_proxy_groups, + }, + )); }) .map_err(|e| anyhow::anyhow!("Failed to dispatch tray menu update: {e}"))?; rx.await @@ -486,7 +502,7 @@ fn create_hotkeys(hotkeys: &Option>) -> HashMap<&str, &str> { fn create_profile_menu_item( app_handle: &AppHandle, profiles_preview: Vec>, -) -> Result>> { +) -> Result)>> { profiles_preview .into_iter() .map(|profile| { @@ -498,6 +514,7 @@ fn create_profile_menu_item( profile.is_current, None::<&str>, ) + .map(|item| (profile.uid.clone(), item)) .map_err(|e| e.into()) }) .collect() @@ -506,93 +523,96 @@ fn create_profile_menu_item( fn create_subcreate_proxy_menu_item( app_handle: &AppHandle, proxy_mode: &str, - proxy_group_order_map: Option>, - proxy_nodes_data: Option, -) -> Vec> { - let proxy_submenus: Vec> = { - let mut submenus: Vec<(String, usize, Submenu)> = Vec::new(); + proxy_group_order_map: Option<&HashMap>, + proxy_nodes_data: &Proxies, +) -> (Vec>, Vec) { + let mut submenus: Vec<(String, usize, Submenu, Vec)> = Vec::new(); - // TODO: 应用启动时,内核还未启动完全,无法获取代理节点信息 - if let Some(proxy_nodes_data) = proxy_nodes_data { - for (group_name, group_data) in proxy_nodes_data.proxies.iter() { - let should_show = match proxy_mode { - "global" => group_name == "GLOBAL", - _ => group_name != "GLOBAL", - } && !group_data.hidden.unwrap_or_default(); + // TODO: 应用启动时,内核还未启动完全,无法获取代理节点信息 + for (group_name, group_data) in proxy_nodes_data.proxies.iter() { + let should_show = match proxy_mode { + "global" => group_name == "GLOBAL", + _ => group_name != "GLOBAL", + } && !group_data.hidden.unwrap_or_default(); - if !should_show { - continue; - } - - let Some(all_proxies) = group_data.all.as_ref() else { - continue; - }; - - let now_proxy = group_data.now.as_deref().unwrap_or_default(); - - let group_items: Vec> = all_proxies - .iter() - .filter_map(|proxy_str| { - let is_selected = *proxy_str == now_proxy; - let item_id = format!("proxy_{}_{}", group_name, proxy_str); - - let delay_text = proxy_nodes_data - .proxies - .get(proxy_str) - .and_then(|h| h.history.last()) - .map(|h| match h.delay { - 0 => "-ms".into(), - delay if delay >= 10000 => "-ms".into(), - _ => format!("{}ms", h.delay), - }) - .unwrap_or_else(|| "-ms".into()); - - let display_text = format!("{} | {}", proxy_str, delay_text); - - CheckMenuItem::with_id(app_handle, item_id, display_text, true, is_selected, None::<&str>) - .map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {}", e)) - .ok() - }) - .collect(); - - if group_items.is_empty() { - continue; - } - - let group_display_name = group_name.to_string(); - - let group_items_refs: Vec<&dyn IsMenuItem> = - group_items.iter().map(|item| item as &dyn IsMenuItem).collect(); - - if let Ok(submenu) = Submenu::with_id_and_items( - app_handle, - format!("proxy_group_{}", group_name), - group_display_name, - true, - &group_items_refs, - ) { - let insertion_index = submenus.len(); - submenus.push((group_name.into(), insertion_index, submenu)); - } else { - logging!(warn, Type::Tray, "Failed to create proxy group submenu: {}", group_name); + if !should_show { + continue; + } + + let Some(all_proxies) = group_data.all.as_ref() else { + continue; + }; + + let now_proxy = group_data.now.as_deref().unwrap_or_default(); + + let mut group_items: Vec> = Vec::with_capacity(all_proxies.len()); + let mut nodes: Vec = Vec::with_capacity(all_proxies.len()); + for proxy_str in all_proxies { + let is_selected = proxy_str == now_proxy; + let item_id = format!("proxy_{}_{}", group_name, proxy_str); + let display_text = format!( + "{} | {}", + proxy_str, + proxy_delay_text(proxy_nodes_data, proxy_str.as_str()) + ); + + match CheckMenuItem::with_id(app_handle, item_id, &display_text, true, is_selected, None::<&str>) { + Ok(item) => { + group_items.push(item.clone()); + nodes.push(ProxyNodeItem { + name: proxy_str.clone().into(), + item, + text: display_text.as_str().into(), + checked: is_selected, + }); } + Err(e) => logging!(warn, Type::Tray, "Failed to create proxy menu item: {}", e), } } - if let Some(order_map) = proxy_group_order_map.as_ref() { - submenus.sort_by(|(name_a, original_index_a, _), (name_b, original_index_b, _)| { - match (order_map.get(name_a), order_map.get(name_b)) { - (Some(index_a), Some(index_b)) => index_a.cmp(index_b), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => original_index_a.cmp(original_index_b), - } - }); + if group_items.is_empty() { + continue; } - submenus.into_iter().map(|(_, _, submenu)| submenu).collect() - }; - proxy_submenus + let group_display_name = group_name.to_string(); + + let group_items_refs: Vec<&dyn IsMenuItem> = + group_items.iter().map(|item| item as &dyn IsMenuItem).collect(); + + if let Ok(submenu) = Submenu::with_id_and_items( + app_handle, + format!("proxy_group_{}", group_name), + group_display_name, + true, + &group_items_refs, + ) { + let insertion_index = submenus.len(); + submenus.push((group_name.into(), insertion_index, submenu, nodes)); + } else { + logging!(warn, Type::Tray, "Failed to create proxy group submenu: {}", group_name); + } + } + + if let Some(order_map) = proxy_group_order_map { + submenus.sort_by(|(name_a, original_index_a, _, _), (name_b, original_index_b, _, _)| { + match (order_map.get(name_a), order_map.get(name_b)) { + (Some(index_a), Some(index_b)) => index_a.cmp(index_b), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => original_index_a.cmp(original_index_b), + } + }); + } + + let mut groups = Vec::with_capacity(submenus.len()); + let group_menus = submenus + .into_iter() + .map(|(name, _, submenu, nodes)| { + groups.push(ProxyGroupItems { name, nodes }); + submenu + }) + .collect(); + (group_menus, groups) } fn create_proxy_menu_item( @@ -636,9 +656,9 @@ fn build_tray_menu( options: TrayMenuOptions<'_>, verge_settings: &IVerge, profiles_config: &IProfiles, - proxy_group_order_map: Option>, - proxy_nodes_data: Option, -) -> Result> { + proxy_group_order_map: Option<&HashMap>, + proxy_nodes_data: Option<&Proxies>, +) -> Result<(tauri::menu::Menu, TrayMenuState)> { let current_proxy_mode = options.mode.unwrap_or(""); let tun_mode_available = crate::core::runstate::RUN_STATE.state().tun_capable(); @@ -654,13 +674,13 @@ fn build_tray_menu( let hotkeys = create_hotkeys(&verge_settings.hotkeys); - let profile_menu_items: Vec> = - create_profile_menu_item(app_handle, profiles_config.profiles_preview().unwrap_or_default())?; + let profiles_preview = profiles_config.profiles_preview().unwrap_or_default(); + let profile_menu_items: Vec<(String, CheckMenuItem)> = create_profile_menu_item(app_handle, profiles_preview)?; let texts = MenuTexts::new(); let profile_menu_items_refs: Vec<&dyn IsMenuItem> = profile_menu_items .iter() - .map(|item| item as &dyn IsMenuItem) + .map(|(_, item)| item as &dyn IsMenuItem) .collect(); let open_window = &MenuItem::with_id( @@ -728,12 +748,20 @@ fn build_tray_menu( &profile_menu_items_refs, )?; - let (proxies_menu, inline_proxy_items) = if include_proxy_groups { - let proxy_sub_menus = + let mut proxy_groups: Vec = Vec::new(); + let mut proxies_submenu_handle: Option> = None; + let (proxies_menu, inline_proxy_items) = if include_proxy_groups && let Some(proxy_nodes_data) = proxy_nodes_data { + let (proxy_sub_menus, groups) = create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data); + proxy_groups = groups; match tray_proxy_groups_display_mode { - "default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?, + "default" => { + let (submenu, inline_items) = + create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?; + proxies_submenu_handle = submenu.clone(); + (submenu, inline_items) + } "inline" => create_proxy_menu_item(app_handle, true, proxy_sub_menus, &texts.proxies)?, _ => (None, Vec::new()), } @@ -874,7 +902,18 @@ fn build_tray_menu( ]); let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?; - Ok(menu) + let state = TrayMenuState { + mode_items: [rule_mode.clone(), global_mode.clone(), direct_mode.clone()], + system_proxy_item: system_proxy.clone(), + tun_item: tun_mode.clone(), + lightweight_item: lightweight_mode.clone(), + profile_items: profile_menu_items, + proxies_submenu: proxies_submenu_handle, + proxy_groups, + signature: String::new(), + proxy_signature: None, + }; + Ok((menu, state)) } fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) {