From cb751052a423c017e75cf0379adb12848e3f0fae Mon Sep 17 00:00:00 2001 From: LokHsu <71050409+LokHsu@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:11:07 +0800 Subject: [PATCH] feat(tray): add a per-group latency test (#8173) * feat(tray): add a per-group latency test The tray's proxy group submenus offered no way to test a group: refreshing the delays shown there meant opening the dashboard and running a test from the proxy page. Add a "Test Latency" entry at the head of each group submenu. It measures the group through the proxy page's own view, so member resolution and provider attribution match the page exactly: provider nodes are health checked on their provider, anything else goes through the proxy delay endpoint, and members the page cannot resolve are skipped. Nested groups are therefore tested through their currently selected node, as the page does. The menu is rebuilt when the run finishes. The test URL follows the page's chain -- the group's own test URL, then the configured default, then the built-in one -- including the page's validity check, so a group with a malformed URL falls back where the page falls back. A group test started from the app rebuilds the tray as well, so the two views do not drift. Co-Authored-By: Claude * fix(tray): keep latency tests and displayed results consistent * fix(tray): isolate latency histories and preserve usable menus * fix(tray): keep settings usable when proxy reads fail * fix(tray): show selected node latency for proxy groups and display timeouts as T/O Co-authored-by: wonfen * fix(tray): refresh proxy latency on menu interaction --------- Co-authored-by: Claude Co-authored-by: wonfen --- Changelog.md | 7 + crates/clash-verge-i18n/locales/ar.yml | 2 + crates/clash-verge-i18n/locales/de.yml | 2 + crates/clash-verge-i18n/locales/en.yml | 2 + crates/clash-verge-i18n/locales/es.yml | 2 + crates/clash-verge-i18n/locales/fa.yml | 2 + crates/clash-verge-i18n/locales/id.yml | 2 + crates/clash-verge-i18n/locales/jp.yml | 2 + crates/clash-verge-i18n/locales/ko.yml | 2 + crates/clash-verge-i18n/locales/ru.yml | 2 + crates/clash-verge-i18n/locales/tr.yml | 2 + crates/clash-verge-i18n/locales/tt.yml | 2 + crates/clash-verge-i18n/locales/zh.yml | 2 + crates/clash-verge-i18n/locales/zhtw.yml | 2 + src-tauri/src/cmd/proxy.rs | 16 +- src-tauri/src/config/prfitem.rs | 5 + src-tauri/src/config/profiles.rs | 1 + src-tauri/src/core/proxy_view.rs | 75 ++- src-tauri/src/core/tray/latency.rs | 156 ++++++ src-tauri/src/core/tray/menu_def.rs | 1 + src-tauri/src/core/tray/mod.rs | 493 +++++++++++++----- src/components/home/current-proxy-card.tsx | 23 + src/components/proxy/proxy-group-tools.tsx | 17 +- src/components/proxy/proxy-groups.tsx | 8 + src/components/proxy/proxy-head.tsx | 17 +- src/components/proxy/proxy-test-url-input.tsx | 55 ++ src/components/proxy/use-filter-sort.test.ts | 17 +- src/components/proxy/use-head-state.ts | 78 ++- src/hooks/use-profiles.ts | 7 +- src/hooks/use-proxy-delay-state.ts | 21 +- src/services/delay.test.ts | 137 +++++ src/services/delay.ts | 96 ++-- src/services/proxy-test-url.test.ts | 103 ++++ src/services/proxy-test-url.ts | 101 ++++ src/types/global.d.ts | 1 + src/types/proxy-view.ts | 2 + src/utils/network.ts | 13 + 37 files changed, 1251 insertions(+), 225 deletions(-) create mode 100644 src-tauri/src/core/tray/latency.rs create mode 100644 src/components/proxy/proxy-test-url-input.tsx create mode 100644 src/services/proxy-test-url.test.ts create mode 100644 src/services/proxy-test-url.ts diff --git a/Changelog.md b/Changelog.md index 62a5a1b29..64ac74ab7 100644 --- a/Changelog.md +++ b/Changelog.md @@ -3,6 +3,8 @@
🐞 修复问题 +- 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题 +- 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题 - 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题 - 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题 - 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题 @@ -22,11 +24,16 @@ - 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题 +**🖥️/🍎 Windows/macOS** + +- 修复 Windows/macOS 打开系统托盘菜单时,节点延迟与自动选择状态未刷新的问题 +
✨ 新增功能 +- 新增系统托盘代理组中的「测试延迟」菜单项
diff --git a/crates/clash-verge-i18n/locales/ar.yml b/crates/clash-verge-i18n/locales/ar.yml index 0441ea1be..4fa132513 100644 --- a/crates/clash-verge-i18n/locales/ar.yml +++ b/crates/clash-verge-i18n/locales/ar.yml @@ -68,6 +68,8 @@ tray: global: عام profiles: الملفات التعريفية proxies: وكلاء + testLatency: اختبار التأخير + testingLatency: جارٍ اختبار التأخير… systemProxy: وكيل النظام tunMode: وضع TUN closeAllConnections: إغلاق كل الاتصالات diff --git a/crates/clash-verge-i18n/locales/de.yml b/crates/clash-verge-i18n/locales/de.yml index 6bd24bd95..d31a24f07 100644 --- a/crates/clash-verge-i18n/locales/de.yml +++ b/crates/clash-verge-i18n/locales/de.yml @@ -68,6 +68,8 @@ tray: global: Global profiles: Profile proxies: Proxy + testLatency: Latenz testen + testingLatency: Latenz wird getestet… systemProxy: Systemproxy tunMode: TUN-Modus closeAllConnections: Alle Verbindungen schließen diff --git a/crates/clash-verge-i18n/locales/en.yml b/crates/clash-verge-i18n/locales/en.yml index 4a32fdc65..82aa2eb64 100644 --- a/crates/clash-verge-i18n/locales/en.yml +++ b/crates/clash-verge-i18n/locales/en.yml @@ -68,6 +68,8 @@ tray: global: Global profiles: Profiles proxies: Proxies + testLatency: Test Latency + testingLatency: Testing latency… systemProxy: System Proxy tunMode: TUN Mode closeAllConnections: Close All Connections diff --git a/crates/clash-verge-i18n/locales/es.yml b/crates/clash-verge-i18n/locales/es.yml index 87f115333..4689a3b35 100644 --- a/crates/clash-verge-i18n/locales/es.yml +++ b/crates/clash-verge-i18n/locales/es.yml @@ -68,6 +68,8 @@ tray: global: Global profiles: Perfiles proxies: Proxies + testLatency: Probar latencia + testingLatency: Probando latencia… systemProxy: Proxy del sistema tunMode: Modo TUN closeAllConnections: Cerrar todas las conexiones diff --git a/crates/clash-verge-i18n/locales/fa.yml b/crates/clash-verge-i18n/locales/fa.yml index 8c538a4a7..6925a3834 100644 --- a/crates/clash-verge-i18n/locales/fa.yml +++ b/crates/clash-verge-i18n/locales/fa.yml @@ -68,6 +68,8 @@ tray: global: سراسری profiles: پروفایل‌ها proxies: پروکسی‌ها + testLatency: آزمایش تأخیر + testingLatency: در حال آزمایش تأخیر… systemProxy: پروکسی سیستم tunMode: حالت TUN closeAllConnections: بستن همه اتصال‌ها diff --git a/crates/clash-verge-i18n/locales/id.yml b/crates/clash-verge-i18n/locales/id.yml index 00c4788be..ef9e39414 100644 --- a/crates/clash-verge-i18n/locales/id.yml +++ b/crates/clash-verge-i18n/locales/id.yml @@ -68,6 +68,8 @@ tray: global: Global profiles: Profil proxies: Proksi + testLatency: Uji Latensi + testingLatency: Menguji latensi… systemProxy: Proksi Sistem tunMode: Mode TUN closeAllConnections: Tutup Semua Koneksi diff --git a/crates/clash-verge-i18n/locales/jp.yml b/crates/clash-verge-i18n/locales/jp.yml index eea0c41d1..723cb3fb3 100644 --- a/crates/clash-verge-i18n/locales/jp.yml +++ b/crates/clash-verge-i18n/locales/jp.yml @@ -68,6 +68,8 @@ tray: global: グローバル profiles: プロファイル proxies: プロキシ + testLatency: 遅延テスト + testingLatency: 遅延をテスト中… systemProxy: システムプロキシ tunMode: TUN モード closeAllConnections: すべての接続を閉じる diff --git a/crates/clash-verge-i18n/locales/ko.yml b/crates/clash-verge-i18n/locales/ko.yml index 7166e8c72..ef15f6517 100644 --- a/crates/clash-verge-i18n/locales/ko.yml +++ b/crates/clash-verge-i18n/locales/ko.yml @@ -68,6 +68,8 @@ tray: global: 글로벌 profiles: 프로필 proxies: 프록시 + testLatency: 지연 테스트 + testingLatency: 지연 테스트 중… systemProxy: 시스템 프록시 tunMode: TUN 모드 closeAllConnections: 모든 연결 닫기 diff --git a/crates/clash-verge-i18n/locales/ru.yml b/crates/clash-verge-i18n/locales/ru.yml index 2c931fa1d..990920f72 100644 --- a/crates/clash-verge-i18n/locales/ru.yml +++ b/crates/clash-verge-i18n/locales/ru.yml @@ -68,6 +68,8 @@ tray: global: Глобальный profiles: Профили proxies: Прокси + testLatency: Проверить задержку + testingLatency: Проверка задержки… systemProxy: Системный прокси tunMode: Режим TUN closeAllConnections: Закрыть все соединения diff --git a/crates/clash-verge-i18n/locales/tr.yml b/crates/clash-verge-i18n/locales/tr.yml index cf3c666a4..7587ab2fe 100644 --- a/crates/clash-verge-i18n/locales/tr.yml +++ b/crates/clash-verge-i18n/locales/tr.yml @@ -68,6 +68,8 @@ tray: global: Küresel profiles: Profiller proxies: Vekil'ler + testLatency: Gecikmeyi Test Et + testingLatency: Gecikme test ediliyor… systemProxy: Sistem Vekil'i tunMode: TUN Modu closeAllConnections: Tüm Bağlantıları Kapat diff --git a/crates/clash-verge-i18n/locales/tt.yml b/crates/clash-verge-i18n/locales/tt.yml index 4067dfdd7..a9d8fcb3d 100644 --- a/crates/clash-verge-i18n/locales/tt.yml +++ b/crates/clash-verge-i18n/locales/tt.yml @@ -68,6 +68,8 @@ tray: global: Глобаль profiles: Профильләр proxies: Проксилар + testLatency: Кичектерүне тикшерү + testingLatency: Кичектерү тикшерелә… systemProxy: Системалы прокси tunMode: TUN режимы closeAllConnections: Барлык тоташуларны ябу diff --git a/crates/clash-verge-i18n/locales/zh.yml b/crates/clash-verge-i18n/locales/zh.yml index 8774cbefb..ca2afd3ce 100644 --- a/crates/clash-verge-i18n/locales/zh.yml +++ b/crates/clash-verge-i18n/locales/zh.yml @@ -68,6 +68,8 @@ tray: global: 全局 profiles: 订阅 proxies: 代理 + testLatency: 测试延迟 + testingLatency: 正在测试延迟… systemProxy: 系统代理 tunMode: TUN 模式 closeAllConnections: 关闭所有连接 diff --git a/crates/clash-verge-i18n/locales/zhtw.yml b/crates/clash-verge-i18n/locales/zhtw.yml index da73ba929..bebc5c058 100644 --- a/crates/clash-verge-i18n/locales/zhtw.yml +++ b/crates/clash-verge-i18n/locales/zhtw.yml @@ -68,6 +68,8 @@ tray: global: 全域 profiles: 訂閱 proxies: 代理 + testLatency: 測試延遲 + testingLatency: 正在測試延遲… systemProxy: 系統代理 tunMode: 虛擬網路介面卡模式 closeAllConnections: 關閉所有連線 diff --git a/src-tauri/src/cmd/proxy.rs b/src-tauri/src/cmd/proxy.rs index d91ae9f4e..f22c38e9b 100644 --- a/src-tauri/src/cmd/proxy.rs +++ b/src-tauri/src/cmd/proxy.rs @@ -105,20 +105,32 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap CmdResult { + proxy_view(None).await +} + +pub(crate) async fn proxy_view(provider_timeout: Option) -> CmdResult { let runtime = Config::runtime().await; let latest_runtime = runtime.latest_arc(); let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref()); let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref()); let mihomo = Handle::mihomo(); - let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),); + let (proxies, providers) = tokio::join!(mihomo.get_proxies(), async { + match provider_timeout { + Some(timeout) => tokio::time::timeout(timeout, mihomo.get_proxy_providers()) + .await + .ok() + .and_then(Result::ok), + None => mihomo.get_proxy_providers().await.ok(), + } + }); let proxies = proxies.stringify_err()?; Ok(ProxyViewBuilder::build(ProxyViewInput { runtime_group_order, group_scopes, proxies, - providers: providers.ok(), + providers, })) } diff --git a/src-tauri/src/config/prfitem.rs b/src-tauri/src/config/prfitem.rs index f7c84de55..d09e14ed0 100644 --- a/src-tauri/src/config/prfitem.rs +++ b/src-tauri/src/config/prfitem.rs @@ -48,6 +48,9 @@ pub struct PrfItem { #[serde(skip_serializing_if = "Option::is_none")] pub selected: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub latency_test_urls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub extra: Option, @@ -217,6 +220,7 @@ impl PrfItem { desc: Some(desc), file: Some(file), url: None, + latency_test_urls: None, selected: None, extra: None, option: Some(PrfOption { @@ -396,6 +400,7 @@ impl PrfItem { desc: desc.cloned(), file: Some(file), url: Some(url.as_str().into()), + latency_test_urls: None, selected: None, extra, option: Some(PrfOption { diff --git a/src-tauri/src/config/profiles.rs b/src-tauri/src/config/profiles.rs index 30f1c9c1b..793619e7d 100644 --- a/src-tauri/src/config/profiles.rs +++ b/src-tauri/src/config/profiles.rs @@ -248,6 +248,7 @@ impl IProfiles { patch!(each, item, file); patch!(each, item, url); patch!(each, item, selected); + patch!(each, item, latency_test_urls); patch!(each, item, extra); patch!(each, item, updated); patch!(each, item, option); diff --git a/src-tauri/src/core/proxy_view.rs b/src-tauri/src/core/proxy_view.rs index 4506bd0ce..9d9db585c 100644 --- a/src-tauri/src/core/proxy_view.rs +++ b/src-tauri/src/core/proxy_view.rs @@ -1,8 +1,8 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use serde::Serialize; use tauri_plugin_mihomo::models::{ - DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType, + DelayHistory, Extra, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType, }; pub struct ProxyViewInput { @@ -75,6 +75,7 @@ pub struct ProxyGroupView { #[serde(skip_serializing_if = "Option::is_none")] pub test_url: Option, pub history: Vec, + pub extra: HashMap, #[serde(flatten)] pub capabilities: ProxyCapabilities, pub members: Vec, @@ -89,6 +90,7 @@ pub struct ProxyNodeView { pub proxy_type: ProxyType, pub alive: bool, pub history: Vec, + pub extra: HashMap, #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -254,7 +256,7 @@ impl ProxyViewBuilder { } else { ProxyViewProviderState::Unavailable }; - let (mut core_groups, core_nodes) = partition_core(proxies); + let (mut core_groups, core_nodes) = partition_core(proxies, providers.is_some()); let (mut records, core_node_ids) = build_core_records(core_nodes); let (providers, provider_candidates) = build_provider_records(providers, &mut records); let resolver = MemberResolver { @@ -287,14 +289,14 @@ impl ProxyViewBuilder { } } -fn partition_core(proxies: Proxies) -> (BTreeMap, BTreeMap) { +fn partition_core(proxies: Proxies, providers_available: bool) -> (BTreeMap, BTreeMap) { let mut groups = BTreeMap::new(); let mut nodes = BTreeMap::new(); for (name, proxy) in proxies.proxies { if proxy.all.is_some() { groups.insert(name, proxy); - } else { + } else if !providers_available || proxy.provider_name.is_empty() { nodes.insert(name, proxy); } } @@ -402,6 +404,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro test_url, alive, history, + extra, udp, xudp, tfo, @@ -435,6 +438,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro icon, test_url, history, + extra, capabilities: ProxyCapabilities { udp, xudp, @@ -454,6 +458,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo test_url, alive, history, + extra, udp, xudp, tfo, @@ -469,6 +474,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo proxy_type, alive, history, + extra, id, hidden, icon, @@ -522,3 +528,62 @@ fn build_standalone(core_node_ids: &BTreeMap) -> Vec { ); standalone } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn provider_echo_in_proxies_does_not_override_group_membership() -> anyhow::Result<()> { + let view = ProxyViewBuilder::build(ProxyViewInput { + runtime_group_order: vec!["A".into(), "B".into()], + group_scopes: ["A", "B"] + .into_iter() + .map(|name| { + ( + name.into(), + GroupScope { + providers: vec![name.into()], + exclude_types: vec![], + }, + ) + }) + .collect(), + proxies: serde_json::from_value(json!({"proxies": { + "A": {"all": ["node"], "type": "Selector"}, + "B": {"all": ["node"], "type": "Selector"}, + "node": {"name": "node", "provider-name": "A", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 32}]} + }}))?, + providers: Some(serde_json::from_value(json!({"providers": { + "A": {"vehicleType": "Inline", "proxies": [{"name": "node", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 32}]}]}, + "B": {"vehicleType": "Inline", "proxies": [{"name": "node", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 123}]}]} + }}))?), + }); + for (group, expected) in view.groups.iter().zip([32, 123]) { + let ProxyMemberRef::Node { record_id, .. } = &group.members[0] else { + anyhow::bail!("Expected node") + }; + let record = &view.records[record_id]; + assert_eq!(record.history.last().map(|entry| entry.delay), Some(expected)); + assert!( + matches!(&record.source, ProxyNodeSource::Provider { provider_name, .. } if provider_name == &group.name) + ); + } + Ok(()) + } + #[test] + fn unavailable_provider_keeps_core_echo_interactable() -> anyhow::Result<()> { + let view = ProxyViewBuilder::build(ProxyViewInput { + runtime_group_order: vec!["A".into()], + group_scopes: BTreeMap::new(), + proxies: serde_json::from_value(json!({"proxies": { + "A": {"all": ["node"], "type": "Selector"}, + "node": {"name": "node", "provider-name": "provider"} + }}))?, + providers: None, + }); + assert!(matches!(&view.groups[0].members[0], ProxyMemberRef::Node { .. })); + Ok(()) + } +} diff --git a/src-tauri/src/core/tray/latency.rs b/src-tauri/src/core/tray/latency.rs new file mode 100644 index 000000000..5c1106ede --- /dev/null +++ b/src-tauri/src/core/tray/latency.rs @@ -0,0 +1,156 @@ +use super::Tray; +use crate::{ + Type, cmd, + config::Config, + core::{ + handle, + notify::{Refresh, announce}, + proxy_view::{ProxyMemberRef, ProxyNodeSource}, + }, + logging, +}; + +const TRAY_DELAY_TEST_CONCURRENCY: usize = 10; +const TRAY_DEFAULT_LATENCY_URL: &str = "http://cp.cloudflare.com/generate_204"; + +fn is_latency_test_url(raw: &str) -> bool { + let raw = raw.trim(); + raw.split_once("://") + .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) + && tauri::Url::parse(raw).is_ok_and(|url| url.has_host() && matches!(url.scheme(), "http" | "https")) +} + +pub(super) fn test_url( + group: &crate::core::proxy_view::ProxyGroupView, + verge: &crate::config::IVerge, + profiles: &crate::config::IProfiles, +) -> String { + let custom_url = profiles + .current + .as_ref() + .and_then(|uid| profiles.get_item(uid).ok()) + .and_then(|profile| profile.latency_test_urls.as_ref()) + .and_then(|urls| urls.get(group.name.as_str())) + .map(|url| url.as_str()) + .filter(|url| !url.trim().is_empty()); + let group_url = group.test_url.as_deref().filter(|url| !url.trim().is_empty()); + let default_url = verge + .default_latency_test + .as_deref() + .filter(|url| !url.trim().is_empty()); + custom_url + .or(group_url) + .or(default_url) + .filter(|&url| is_latency_test_url(url)) + .unwrap_or(TRAY_DEFAULT_LATENCY_URL) + .trim() + .to_owned() +} + +static RUNNING: parking_lot::Mutex> = + parking_lot::Mutex::new(std::collections::BTreeSet::new()); + +pub(super) fn is_running(group_name: &str) -> bool { + RUNNING.lock().contains(group_name) +} + +pub(super) async fn test_proxy_group_delay(group_name: &str) { + if !RUNNING.lock().insert(group_name.to_owned()) { + return; + } + Tray::global().refresh_latency_item(group_name); + let _running = scopeguard::guard(group_name, |name| { + RUNNING.lock().remove(name); + Tray::global().refresh_latency_item(name); + }); + use futures::{StreamExt as _, stream}; + + let view = match cmd::proxy::get_proxy_view().await { + Ok(view) => view, + Err(err) => { + logging!(warn, Type::Tray, "Failed to read proxies for tray latency test: {err}"); + return; + } + }; + let verge = Config::verge().await.latest_arc(); + + let group = view + .groups + .iter() + .chain(view.global.iter()) + .find(|group| group.name.as_str() == group_name); + let Some(group) = group else { + logging!(warn, Type::Tray, "Tray latency test group not found: {group_name}"); + return; + }; + + let profiles = Config::profiles().await.latest_arc(); + let url = test_url(group, &verge, &profiles); + let timeout = verge + .default_latency_timeout + .filter(|timeout| *timeout > 0) + .unwrap_or(10000) as u32; + + let mut targets: Vec<(std::string::String, Option)> = Vec::with_capacity(group.members.len()); + for member in &group.members { + match member { + ProxyMemberRef::Node { name, record_id } => match view.records.get(record_id).map(|node| &node.source) { + Some(ProxyNodeSource::Provider { + provider_name, + proxy_name, + }) => targets.push((proxy_name.clone(), Some(provider_name.clone()))), + _ => targets.push((name.clone(), None)), + }, + ProxyMemberRef::Group { name } => targets.push((name.clone(), None)), + ProxyMemberRef::Unresolved { .. } => {} + } + } + let mihomo = handle::Handle::mihomo(); + + stream::iter(targets.into_iter().map(move |(member, provider)| { + let url = url.clone(); + async move { + let result = match provider { + Some(provider_name) => { + mihomo + .healthcheck_node_in_provider(&provider_name, &member, &url, timeout) + .await + } + None => mihomo.delay_proxy_by_name(&member, &url, timeout).await, + }; + if let Err(err) = result { + logging!(debug, Type::Tray, "Tray latency test failed: {member} - {err}"); + } + } + })) + .buffer_unordered(TRAY_DELAY_TEST_CONCURRENCY) + .for_each(|()| std::future::ready(())) + .await; + + if let Err(err) = cmd::proxy::sync_tray_proxy_selection().await { + logging!(warn, Type::Tray, "Failed to refresh tray after latency test: {err}"); + } + + announce(Refresh::Proxies); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn latency_urls_require_a_valid_http_host_and_port() { + for invalid in [ + "http://?", + "http://host:invalid", + "http://bad host/", + "ftp://localhost", + "http:localhost", + ] { + assert!(!is_latency_test_url(invalid), "{invalid}"); + } + for valid in ["http://localhost/", " https://example.com/ ", "HTTP://localhost:8080/"] { + assert!(is_latency_test_url(valid), "{valid}"); + } + } +} diff --git a/src-tauri/src/core/tray/menu_def.rs b/src-tauri/src/core/tray/menu_def.rs index 7012ce219..6c9641773 100644 --- a/src-tauri/src/core/tray/menu_def.rs +++ b/src-tauri/src/core/tray/menu_def.rs @@ -32,6 +32,7 @@ define_menu! { outbound_modes => OUTBOUND_MODES, "tray_outbound_modes", "tray.outboundModes", profiles => PROFILES, "tray_profiles", "tray.profiles", proxies => PROXIES, "tray_proxies", "tray.proxies", + test_latency => TEST_LATENCY, "tray_test_latency", "tray.testLatency", system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy", tun_mode => TUN_MODE, "tray_tun_mode", "tray.tunMode", close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "tray.closeAllConnections", diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index 326f51fa0..8348b67a0 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -1,5 +1,6 @@ use crate::config::{IProfilePreview, IVerge}; use crate::core::lightweight; +use crate::core::proxy_view::{ProxyGroupView, ProxyMemberRef, ProxyNodeSource, ProxyViewProviderState, ProxyViewV1}; use crate::core::tray::menu_def::TrayAction; use crate::process::AsyncHandler; use crate::singleton; @@ -14,7 +15,6 @@ use crate::{ use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter}; use clash_verge_logging::logging_error; use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; -use tauri_plugin_mihomo::models::Proxies; use tokio::fs; use super::handle; @@ -28,6 +28,7 @@ use tauri::{ menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, }; +mod latency; mod menu_def; #[cfg(target_os = "macos")] mod speed_task; @@ -55,8 +56,25 @@ enum IconKind { Tun, } +struct LatencyMenuItem { + item: MenuItem, + running: bool, + proxies: Vec, + provider_state: ProxyViewProviderState, +} + +struct ProxyLatencyItem { + item: CheckMenuItem, + name: std::string::String, + provider_name: Option, + text: std::string::String, +} + pub struct Tray { limiter: SystemLimiter, + proxy_hover_limiter: SystemLimiter, + menu_update: tokio::sync::Mutex<()>, + latency_items: parking_lot::Mutex>, #[cfg(target_os = "macos")] speed_controller: speed_task::TraySpeedController, } @@ -130,6 +148,9 @@ impl Default for Tray { fn default() -> Self { Self { limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock), + proxy_hover_limiter: Limiter::new(Duration::from_secs(1), SystemClock), + menu_update: tokio::sync::Mutex::new(()), + latency_items: parking_lot::Mutex::new(HashMap::new()), #[cfg(target_os = "macos")] speed_controller: speed_task::TraySpeedController::new(), } @@ -195,6 +216,7 @@ impl Tray { } async fn update_menu_internal(&self, app_handle: &AppHandle, include_proxy_groups: bool) -> Result<()> { + let _update = self.menu_update.lock().await; let Some(tray) = app_handle.tray_by_id(TRAY_ID) else { logging!(warn, Type::Tray, "Failed to update tray menu: tray not found"); return Ok(()); @@ -219,29 +241,133 @@ impl Tray { let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default(); let is_lightweight_mode = is_in_lightweight_mode(); - logging_error!( - Type::Tray, - tray.set_menu(Some( - create_tray_menu( - app_handle, - Some(mode.as_str()), - *system_proxy, - *tun_mode, - tun_mode_available, - profiles_preview, - TrayMenuOptions { - is_lightweight_mode, - include_proxy_groups, - }, - ) - .await?, - )) - ); + let (menu, latency_items) = create_tray_menu( + app_handle, + Some(mode.as_str()), + *system_proxy, + *tun_mode, + tun_mode_available, + profiles_preview, + TrayMenuOptions { + is_lightweight_mode, + include_proxy_groups, + }, + ) + .await?; + tray.set_menu(Some(menu))?; + let groups: Vec<_> = latency_items.keys().cloned().collect(); + *self.latency_items.lock() = latency_items; + for group in groups { + self.refresh_latency_item(&group); + } logging!(debug, Type::Tray, "托盘菜单更新成功"); Ok(()) } + async fn refresh_proxy_items(&'static self) { + let Ok(update) = self.menu_update.try_lock() else { + return; + }; + if self.latency_items.lock().is_empty() || handle::Handle::global().is_exiting() { + return; + } + let Ok(Ok(view)) = tokio::time::timeout( + Duration::from_secs(1), + cmd::proxy::proxy_view(Some(Duration::from_millis(750))), + ) + .await + else { + return; + }; + if self + .latency_items + .lock() + .values() + .any(|entry| entry.provider_state != view.provider_state) + { + // Provider fallback changes member identities; rebuild through the existing queue. + drop(update); + logging_error!(Type::Tray, cmd::proxy::sync_tray_proxy_selection().await); + return; + } + let verge = Config::verge().await.latest_arc(); + let profiles = Config::profiles().await.latest_arc(); + logging_error!( + Type::Tray, + handle::Handle::app_handle().run_on_main_thread(move || { + // Keep rebuilding serialized until these handles have received their snapshot. + let _update = update; + let mut items = self.latency_items.lock(); + for (group_name, entry) in items.iter_mut() { + let Some(group) = find_group(&view, group_name) else { + continue; + }; + let url = latency::test_url(group, &verge, &profiles); + let mut members = HashMap::with_capacity(group.members.len()); + for member in &group.members { + members + .entry((member_name(member), member_provider(&view, member))) + .or_insert(member); + } + for proxy in &mut entry.proxies { + let Some(member) = members.get(&(proxy.name.as_str(), proxy.provider_name.as_deref())) else { + continue; + }; + let text = proxy_item_text(&view, member, &url, &verge); + let checked = group.now.as_ref() == Some(&proxy.name); + if text != proxy.text { + match proxy.item.set_text(&text) { + Ok(()) => proxy.text = text, + Err(err) => logging!(warn, Type::Tray, "Failed to refresh proxy latency: {err}"), + } + } + match proxy.item.is_checked() { + Ok(current) if current != checked => { + logging_error!(Type::Tray, proxy.item.set_checked(checked)) + } + Err(err) => logging!(warn, Type::Tray, "Failed to read proxy selection: {err}"), + _ => {} + } + } + } + }) + ); + } + + fn refresh_latency_item(&self, group_name: &str) { + let group_name = group_name.to_owned(); + logging_error!( + Type::Tray, + handle::Handle::app_handle().run_on_main_thread(move || { + Self::global().refresh_latency_item_on_main_thread(&group_name); + }) + ); + } + + fn refresh_latency_item_on_main_thread(&self, group_name: &str) { + let running = latency::is_running(group_name); + let item = { + let mut items = self.latency_items.lock(); + items.get_mut(group_name).and_then(|entry| { + if entry.running == running { + return None; + } + entry.running = running; + Some(entry.item.clone()) + }) + }; + if let Some(item) = item { + let label = if running { + clash_verge_i18n::t!("tray.testingLatency") + } else { + clash_verge_i18n::t!("tray.testLatency") + }; + logging_error!(Type::Tray, item.set_text(label)); + logging_error!(Type::Tray, item.set_enabled(!running)); + } + } + pub async fn update_icon(&self, verge: &IVerge) -> Result<()> { if handle::Handle::global().is_exiting() { logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新"); @@ -457,96 +583,175 @@ fn create_profile_menu_item( .collect() } +const UNTESTED_DELAY_TEXT: &str = "-ms"; +const TIMEOUT_DELAY_TEXT: &str = "T/O"; +const TRAY_GROUP_DEPTH_LIMIT: usize = 8; + +const fn member_name(member: &ProxyMemberRef) -> &str { + match member { + ProxyMemberRef::Node { name, .. } + | ProxyMemberRef::Group { name } + | ProxyMemberRef::Unresolved { name, .. } => name.as_str(), + } +} + +fn find_group<'a>(view: &'a ProxyViewV1, name: &str) -> Option<&'a ProxyGroupView> { + view.groups + .iter() + .chain(view.global.iter()) + .find(|group| group.name.as_str() == name) +} + +/// The delay recorded for `url`, falling back to the node's latest measurement under any URL, so a +/// node the core tested on its own still shows a value. +fn node_delay(view: &ProxyViewV1, record_id: &str, url: &str) -> Option { + view.records + .get(record_id) + .map(|node| node.extra.get(url).map_or(&node.history, |extra| &extra.history)) + .and_then(|history| history.last()) + .map(|history| history.delay) +} + +/// A group row shows the delay of the node the group currently selects. The core never writes a +/// history for the group itself, so `now` is followed until the chain reaches a node. +fn selected_member_delay(view: &ProxyViewV1, group_name: &str, url: &str) -> Option { + let mut current = group_name; + for _ in 0..TRAY_GROUP_DEPTH_LIMIT { + let Some(group) = find_group(view, current) else { break }; + let Some(now) = group.now.as_deref().filter(|now| !now.is_empty() && *now != current) else { + break; + }; + let Some(member) = group.members.iter().find(|member| member_name(member) == now) else { + break; + }; + match member { + ProxyMemberRef::Node { record_id, .. } => return node_delay(view, record_id, url), + ProxyMemberRef::Group { name } => current = name.as_str(), + ProxyMemberRef::Unresolved { .. } => break, + } + } + find_group(view, group_name) + .map(|group| group.extra.get(url).map_or(&group.history, |extra| &extra.history)) + .and_then(|history| history.last()) + .map(|history| history.delay) +} + +fn member_provider<'a>(view: &'a ProxyViewV1, member: &ProxyMemberRef) -> Option<&'a str> { + let ProxyMemberRef::Node { record_id, .. } = member else { + return None; + }; + match &view.records.get(record_id)?.source { + ProxyNodeSource::Provider { provider_name, .. } => Some(provider_name), + ProxyNodeSource::Core { .. } => None, + } +} + +fn proxy_item_text(view: &ProxyViewV1, member: &ProxyMemberRef, url: &str, verge: &IVerge) -> std::string::String { + let timeout = verge + .default_latency_timeout + .filter(|timeout| *timeout > 0) + .unwrap_or(10000); + let delay = match member { + ProxyMemberRef::Node { record_id, .. } => node_delay(view, record_id, url), + ProxyMemberRef::Group { name } => selected_member_delay(view, name, url), + ProxyMemberRef::Unresolved { .. } => None, + }; + let delay_text = delay.map_or_else( + || UNTESTED_DELAY_TEXT.to_owned(), + |delay| { + if delay == 0 || u32::from(delay) >= timeout as u32 { + TIMEOUT_DELAY_TEXT.to_owned() + } else { + format!("{delay}ms") + } + }, + ); + format!("{} | {delay_text}", member_name(member)) +} + fn create_subcreate_proxy_menu_item( app_handle: &AppHandle, proxy_mode: &str, - proxy_group_order_map: Option>, - proxy_nodes_data: Option, + view: Option, + latency_items: &mut HashMap, + texts: &MenuTexts, + verge: &IVerge, + profiles: &crate::config::IProfiles, ) -> Vec> { - let proxy_submenus: Vec> = { - let mut submenus: Vec<(String, usize, Submenu)> = 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(); - - 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); - } + let Some(view) = view else { return Vec::new() }; + view.groups + .iter() + .chain(view.global.iter()) + .filter_map(|group| { + if group.hidden.unwrap_or_default() || (proxy_mode == "global") != (group.name == "GLOBAL") { + return None; } - } - - 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), - } - }); - } - - submenus.into_iter().map(|(_, _, submenu)| submenu).collect() - }; - proxy_submenus + let url = latency::test_url(group, verge, profiles); + let proxies: Vec = group + .members + .iter() + .filter_map(|member| { + let name = member_name(member); + let text = proxy_item_text(&view, member, &url, verge); + let checked = group.now.as_deref() == Some(name); + CheckMenuItem::with_id( + app_handle, + format!("proxy_{}_{}", group.name, name), + &text, + true, + checked, + None::<&str>, + ) + .map(|item| ProxyLatencyItem { + item, + name: name.to_owned(), + provider_name: member_provider(&view, member).map(str::to_owned), + text, + }) + .map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {e}")) + .ok() + }) + .collect(); + let group_items: Vec<_> = proxies.iter().map(|proxy| proxy.item.clone()).collect(); + if group_items.is_empty() { + return None; + } + let running = latency::is_running(&group.name); + let test_item = MenuItem::with_id( + app_handle, + format!("{}_{}", MenuIds::TEST_LATENCY, group.name), + if running { + clash_verge_i18n::t!("tray.testingLatency") + } else { + texts.test_latency.clone() + }, + !running, + None::<&str>, + ) + .ok()?; + latency_items.insert( + group.name.clone(), + LatencyMenuItem { + item: test_item.clone(), + running, + proxies, + provider_state: view.provider_state, + }, + ); + let separator = PredefinedMenuItem::separator(app_handle).ok()?; + let mut items: Vec<&dyn IsMenuItem> = vec![&test_item, &separator]; + items.extend(group_items.iter().map(|item| item as &dyn IsMenuItem)); + Submenu::with_id_and_items( + app_handle, + format!("proxy_group_{}", group.name), + &group.name, + true, + &items, + ) + .map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy group submenu: {e}")) + .ok() + }) + .collect() } fn create_proxy_menu_item( @@ -593,54 +798,38 @@ async fn create_tray_menu( tun_mode_available: bool, profiles_preview: Vec>, options: TrayMenuOptions, -) -> Result> { +) -> Result<(tauri::menu::Menu, HashMap)> { let current_proxy_mode = mode.unwrap_or(""); let mut verge_settings = Config::verge().await.latest_arc(); let fetch_proxy_groups = options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable"); - // TODO: should update tray menu again when it was timeout error - let (proxy_nodes_data, runtime_proxy_groups_order) = if fetch_proxy_groups { - let proxy_nodes_data = - tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies()) - .await - .map_or(None, |res| res.ok()); - - let runtime = Config::runtime().await.latest_arc(); - let runtime_proxy_groups_order = runtime.config.as_ref().map(|config| { - config - .get("proxy-groups") - .and_then(|groups| groups.as_sequence()) - .map(|groups| { - groups - .iter() - .filter_map(|group| group.get("name")) - .filter_map(|name| name.as_str()) - .enumerate() - .map(|(index, name)| (name.into(), index)) - .collect::>() - }) - .unwrap_or_default() - }); - - (proxy_nodes_data, runtime_proxy_groups_order) + // Proxy data must remain optional so a stopped Core cannot roll back unrelated settings. + let proxy_view = if fetch_proxy_groups { + tokio::time::timeout( + Duration::from_millis(1000), + cmd::proxy::proxy_view(Some(Duration::from_millis(750))), + ) + .await + .ok() + .and_then(Result::ok) } else { - (None, None) + None }; if fetch_proxy_groups { verge_settings = Config::verge().await.latest_arc(); } + let profiles_config = Config::profiles().await.latest_arc(); + let tray_proxy_groups_display_mode = verge_settings .tray_proxy_groups_display_mode .as_deref() .unwrap_or("default"); let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable"; - let proxy_group_order_map = runtime_proxy_groups_order; - let show_outbound_modes_inline = verge_settings.tray_inline_outbound_modes.unwrap_or(false); let version = env!("CARGO_PKG_VERSION"); @@ -720,9 +909,17 @@ async fn create_tray_menu( &profile_menu_items_refs, )?; + let mut latency_items = HashMap::new(); let (proxies_menu, inline_proxy_items) = if include_proxy_groups { - let proxy_sub_menus = - create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data); + let proxy_sub_menus = create_subcreate_proxy_menu_item( + app_handle, + current_proxy_mode, + proxy_view, + &mut latency_items, + &texts, + &verge_settings, + &profiles_config, + ); match tray_proxy_groups_display_mode { "default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?, @@ -866,10 +1063,25 @@ async fn create_tray_menu( ]); let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?; - Ok(menu) + Ok((menu, latency_items)) } fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) { + let refresh_proxies = match tray_event { + TrayIconEvent::Enter { .. } => Tray::global().proxy_hover_limiter.check(), + TrayIconEvent::Click { + button: MouseButton::Left | MouseButton::Right, + button_state: MouseButtonState::Down, + .. + } => true, + _ => false, + }; + if refresh_proxies { + AsyncHandler::spawn(|| async { + Tray::global().refresh_proxy_items().await; + }); + } + if matches!( tray_event, TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. } @@ -983,6 +1195,15 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) { }; feat::toggle_proxy_profile(profile_index.into()).await; } + id if id.starts_with(MenuIds::TEST_LATENCY) => { + let group_name = id + .strip_prefix(MenuIds::TEST_LATENCY) + .and_then(|rest| rest.strip_prefix('_')); + if let Some(group_name) = group_name { + logging!(info, Type::Tray, "Tray menu: test group latency {}", group_name); + latency::test_proxy_group_delay(group_name).await; + } + } id if id.starts_with("proxy_") => { let rest = match id.strip_prefix("proxy_") { Some(r) => r, diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index e9768b7e4..5e1ec2cdf 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -47,7 +47,10 @@ import { useCoreDataStatus, useProxiesData, } from '@/providers/app-data-context' +import { syncTrayProxySelection } from '@/services/cmds' import delayManager from '@/services/delay' +import { mutate } from '@/services/mutate' +import { profileTestUrls } from '@/services/proxy-test-url' import { findCurrentGroupMember, getRecord, @@ -675,6 +678,20 @@ export const CurrentProxyCard = () => { [proxyView], ) + useEffect(() => { + if (!selectedGroup) return + const customUrl = currentProfile + ? profileTestUrls(currentProfile)[selectedGroup.name] + : undefined + delayManager.setUrl( + selectedGroup.name, + customUrl?.trim() || + selectedGroup.testUrl?.trim() || + verge?.default_latency_test?.trim() || + 'http://cp.cloudflare.com/generate_204', + ) + }, [selectedGroup, currentProfile, verge?.default_latency_test]) + const unsortedProxyOptions = useMemo( () => optionsForGroup(selectedGroup), [optionsForGroup, selectedGroup], @@ -907,6 +924,12 @@ export const CurrentProxyCard = () => { } refreshProxy() + mutate(() => syncTrayProxySelection(), { + id: 'sync-tray-selection', + errorNotice: false, + }).catch((error) => { + console.error('[CurrentProxyCard] Failed to sync tray latency:', error) + }) }) const proxyOptions = useMemo( diff --git a/src/components/proxy/proxy-group-tools.tsx b/src/components/proxy/proxy-group-tools.tsx index 44cc8a3da..194b1cde4 100644 --- a/src/components/proxy/proxy-group-tools.tsx +++ b/src/components/proxy/proxy-group-tools.tsx @@ -9,7 +9,7 @@ import VisibilityOffRounded from '@mui/icons-material/VisibilityOffRounded' import VisibilityRounded from '@mui/icons-material/VisibilityRounded' import WifiTetheringOffRounded from '@mui/icons-material/WifiTetheringOffRounded' import WifiTetheringRounded from '@mui/icons-material/WifiTetheringRounded' -import { Box, IconButton, type SxProps, TextField } from '@mui/material' +import { Box, IconButton, type SxProps } from '@mui/material' import { useDebounceFn } from 'ahooks' import { memo, useEffect, useRef } from 'react' import { flushSync } from 'react-dom' @@ -18,10 +18,11 @@ import { useTranslation } from 'react-i18next' import { useVerge } from '@/hooks/use-verge' import delayManager from '@/services/delay' import { showNotice } from '@/services/notice-service' -import { isValidUrl } from '@/utils/network' +import { isValidLatencyTestUrl } from '@/utils/network' import { BaseSearchBox, type SearchState } from '../base' +import { ProxyTestUrlInput } from './proxy-test-url-input' import type { ProxySortType } from './use-filter-sort' import type { HeadState } from './use-head-state' @@ -69,7 +70,10 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) { const inputRef = useRef(null) useEffect(() => { - delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl) + delayManager.setUrl( + groupName, + testUrl?.trim() || url?.trim() || defaultLatencyUrl, + ) }, [groupName, testUrl, defaultLatencyUrl, url]) // 过滤输入是高频操作,且每次都会触发整组代理的重新过滤/排序与虚拟列表重渲染, @@ -112,7 +116,8 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) { )} {textState === 'url' && ( - onHeadState({ testUrl: e.target.value })} + onCommit={(testUrl) => onHeadState({ testUrl })} sx={{ flex: '1 1 auto', input: { py: 0.65, px: 1 } }} /> )} @@ -176,7 +181,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) { if (testUrl?.trim() && textState !== 'filter') { onHeadState({ textState: 'url' }) } - if (testUrl?.trim() && !isValidUrl(testUrl)) { + if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) { showNotice.warning('proxies.feedback.warnings.invalidTestUrl') return } diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index 2489b2ab0..0b4fa96b1 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -23,7 +23,9 @@ import { useProfiles } from '@/hooks/use-profiles' import { useProxySelection } from '@/hooks/use-proxy-selection' import { useVerge } from '@/hooks/use-verge' import { useProxiesData, useSystemData } from '@/providers/app-data-context' +import { syncTrayProxySelection } from '@/services/cmds' import delayManager from '@/services/delay' +import { mutate } from '@/services/mutate' import { isInteractableMember, resolveMember, @@ -133,6 +135,12 @@ function useProxyRenderState( console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error) } finally { onProxies() + mutate(() => syncTrayProxySelection(), { + id: 'sync-tray-selection', + errorNotice: false, + }).catch((error) => { + console.error('[ProxyGroups] Failed to sync tray latency:', error) + }) } }), ) diff --git a/src/components/proxy/proxy-head.tsx b/src/components/proxy/proxy-head.tsx index 4db54f36f..4fa9aefc7 100644 --- a/src/components/proxy/proxy-head.tsx +++ b/src/components/proxy/proxy-head.tsx @@ -11,7 +11,7 @@ import { SortByAlphaRounded, SortRounded, } from '@mui/icons-material' -import { Box, IconButton, TextField, type SxProps } from '@mui/material' +import { Box, IconButton, type SxProps } from '@mui/material' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -20,8 +20,9 @@ import { useVerge } from '@/hooks/use-verge' import delayManager from '@/services/delay' import { showNotice } from '@/services/notice-service' import { debugLog } from '@/utils/debug' -import { isValidUrl } from '@/utils/network' +import { isValidLatencyTestUrl } from '@/utils/network' +import { ProxyTestUrlInput } from './proxy-test-url-input' import type { ProxySortType } from './use-filter-sort' import type { HeadState } from './use-head-state' @@ -72,7 +73,10 @@ export const ProxyHead = ({ 'http://cp.cloudflare.com/generate_204' useEffect(() => { - delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl) + delayManager.setUrl( + groupName, + testUrl?.trim() || url?.trim() || defaultLatencyUrl, + ) }, [groupName, testUrl, defaultLatencyUrl, url]) return ( @@ -97,7 +101,7 @@ export const ProxyHead = ({ debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`) onHeadState({ textState: 'url' }) } - if (testUrl?.trim() && !isValidUrl(testUrl)) { + if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) { showNotice.warning('proxies.feedback.warnings.invalidTestUrl') return } @@ -188,7 +192,8 @@ export const ProxyHead = ({ )} {textState === 'url' && ( - onHeadState({ testUrl: e.target.value })} + onCommit={(testUrl) => onHeadState({ testUrl })} sx={{ ml: 0.5, flex: '1 1 auto', input: { py: 0.65, px: 1 } }} /> )} diff --git a/src/components/proxy/proxy-test-url-input.tsx b/src/components/proxy/proxy-test-url-input.tsx new file mode 100644 index 000000000..287e2f0f5 --- /dev/null +++ b/src/components/proxy/proxy-test-url-input.tsx @@ -0,0 +1,55 @@ +import { TextField, type TextFieldProps } from '@mui/material' +import { useEffect, useEffectEvent, useRef, useState } from 'react' + +import { useProfiles } from '@/hooks/use-profiles' + +type Props = Omit & { + value: string + onCommit: (url: string) => void +} + +export const ProxyTestUrlInput = ({ value, name, ...props }: Props) => { + const { profiles } = useProfiles() + return ( + + ) +} + +const DraftTestUrlInput = ({ value, onCommit, ...props }: Props) => { + const [draft, setDraft] = useState(null) + const pendingRef = useRef(null) + const commitOnUnmount = useEffectEvent(() => { + if (pendingRef.current !== null && pendingRef.current !== value) + onCommit(pendingRef.current) + }) + useEffect(() => () => commitOnUnmount(), []) + + return ( + { + pendingRef.current = event.target.value + setDraft(event.target.value) + }} + onBlur={() => { + const next = pendingRef.current + pendingRef.current = null + setDraft(null) + if (next !== null && next !== value) onCommit(next) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + const input = event.target as HTMLInputElement + input.blur() + } + }} + /> + ) +} diff --git a/src/components/proxy/use-filter-sort.test.ts b/src/components/proxy/use-filter-sort.test.ts index 2f8d0d46e..08730cd5e 100644 --- a/src/components/proxy/use-filter-sort.test.ts +++ b/src/components/proxy/use-filter-sort.test.ts @@ -13,11 +13,18 @@ const node = (memberIndex: number, delay: number, name = `${memberIndex}`) => kind: 'node', ref: { kind: 'node', name, recordId: `${memberIndex}` }, node: { + recordId: `${memberIndex}`, history: [{ delay }], + extra: { + 'http://cp.cloudflare.com/generate_204': { + alive: true, + history: [{ time: new Date(0).toISOString(), delay }], + }, + }, source: { kind: 'provider', providerName: `${memberIndex}` }, }, }, - }) as ResolvedMemberOccurrence + }) as unknown as ResolvedMemberOccurrence afterEach(() => vi.restoreAllMocks()) @@ -36,7 +43,9 @@ test('matches the previous comparator for cached, fallback and sentinel delays', for (const cached of [false, true]) { const group = `sort-${cached}` if (cached) { - values.forEach((delay, i) => delayManager.setDelay(`${i}`, group, delay)) + values.forEach((delay, i) => + delayManager.setDelay(list[i].member, group, delay), + ) } for (const timeout of [10000, 20, 0, NaN]) { const expected = list @@ -66,7 +75,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne node(3, 25), ] const cachedOrder = [list[0], list[2], list[1], list[3]] - delayManager.setDelay('0', 'expiry', 1) + delayManager.setDelay(list[0].member, 'expiry', 1) const get = vi.spyOn(delayManager, 'getDelayFix') expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder) expect(get).toHaveBeenCalledTimes(list.length) @@ -80,7 +89,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne list[3], list[0], ]) - delayManager.setDelay('0', 'expiry', 2) + delayManager.setDelay(list[0].member, 'expiry', 2) expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder) get.mockClear() filterSort([], 'expiry', '', 1) diff --git a/src/components/proxy/use-head-state.ts b/src/components/proxy/use-head-state.ts index 5d30c5e1c..d536564cc 100644 --- a/src/components/proxy/use-head-state.ts +++ b/src/components/proxy/use-head-state.ts @@ -1,6 +1,10 @@ import { useCallback, useEffect, useReducer } from 'react' import { useProfiles } from '@/hooks/use-profiles' +import { + profileTestUrls, + commitProfileTestUrl, +} from '@/services/proxy-test-url' import { ProxySortType } from './use-filter-sort' @@ -32,22 +36,45 @@ export const DEFAULT_STATE: HeadState = { } type HeadStateAction = - | { type: 'reset' } - | { type: 'replace'; payload: Record } + | { type: 'reset'; profile: string } + | { type: 'replace'; profile: string; payload: Record } | { type: 'update'; groupName: string; patch: Partial } + | { type: 'urls'; urls: Record } + +interface LoadedHeadState { + profile: string | null + heads: Record +} function headStateReducer( - state: Record, + state: LoadedHeadState, action: HeadStateAction, -): Record { +): LoadedHeadState { switch (action.type) { case 'reset': - return {} + return { profile: action.profile, heads: {} } case 'replace': - return action.payload + return { profile: action.profile, heads: action.payload } + case 'urls': { + let heads = state.heads + for (const [name, testUrl] of Object.entries(action.urls)) { + if (heads[name]?.testUrl === testUrl) continue + heads = { + ...heads, + [name]: { ...DEFAULT_STATE, ...heads[name], testUrl }, + } + } + return heads === state.heads ? state : { ...state, heads } + } case 'update': { - const prev = state[action.groupName] || DEFAULT_STATE - return { ...state, [action.groupName]: { ...prev, ...action.patch } } + const prev = state.heads[action.groupName] || DEFAULT_STATE + return { + ...state, + heads: { + ...state.heads, + [action.groupName]: { ...prev, ...action.patch }, + }, + } } default: return state @@ -55,30 +82,42 @@ function headStateReducer( } export function useHeadStateNew() { - const { profiles } = useProfiles() + const { profiles, current: profile } = useProfiles() const current = profiles?.current || '' - const [state, dispatch] = useReducer(headStateReducer, {}) + const [{ profile: loadedProfile, heads: state }, dispatch] = useReducer( + headStateReducer, + { profile: null, heads: {} }, + ) useEffect(() => { + const urls = profile ? profileTestUrls(profile) : {} + if (loadedProfile === current) { + dispatch({ type: 'urls', urls }) + return + } try { const data = JSON.parse( - localStorage.getItem(HEAD_STATE_KEY)!, + localStorage.getItem(HEAD_STATE_KEY) || '{}', ) as HeadStateStorage - const value = data[current] || {} + const value = data?.[current] || {} + for (const [name, testUrl] of Object.entries(urls)) { + value[name] = { ...DEFAULT_STATE, ...value[name], testUrl } + } if (value && typeof value === 'object') { - dispatch({ type: 'replace', payload: value }) + dispatch({ type: 'replace', profile: current, payload: value }) } else { - dispatch({ type: 'reset' }) + dispatch({ type: 'reset', profile: current }) } } catch { - dispatch({ type: 'reset' }) + dispatch({ type: 'reset', profile: current }) } - }, [current]) + }, [current, loadedProfile, profile]) useEffect(() => { + if (loadedProfile !== current) return const timer = setTimeout(() => { try { const item = localStorage.getItem(HEAD_STATE_KEY) @@ -94,13 +133,16 @@ export function useHeadStateNew() { }) return () => clearTimeout(timer) - }, [state, current]) + }, [state, current, loadedProfile]) const setHeadState = useCallback( (groupName: string, obj: Partial) => { dispatch({ type: 'update', groupName, patch: obj }) + if (current && obj.testUrl !== undefined) { + void commitProfileTestUrl(current, groupName, obj.testUrl) + } }, - [], + [current], ) return [state, setHeadState] as const diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index 306013afa..f4416bd76 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -1,7 +1,8 @@ -import { useCallback, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds' import { mutate } from '@/services/mutate' +import { migrateProfileTestUrls } from '@/services/proxy-test-url' import { fetchCacheData, setCacheData, useQuery } from '@/services/query-client' import { debugLog } from '@/utils/debug' @@ -34,6 +35,10 @@ export const useProfiles = () => { refetchInterval: false, }) + useEffect(() => { + if (profiles) migrateProfileTestUrls(profiles) + }, [profiles]) + const refetchRef = useRef(refetch) refetchRef.current = refetch const mutateProfiles = useCallback(async () => { diff --git a/src/hooks/use-proxy-delay-state.ts b/src/hooks/use-proxy-delay-state.ts index f8a07f299..8b999edba 100644 --- a/src/hooks/use-proxy-delay-state.ts +++ b/src/hooks/use-proxy-delay-state.ts @@ -34,7 +34,6 @@ export function useProxyDelayState( groupName: string, ): UseProxyDelayState { const name = member.ref.name - const details = memberDetails(member) const unresolved = member.kind === 'unresolved' const isPreset = unresolved || PRESET_PROXY_NAMES.includes(name) const [delayState, setDelayState] = useReducer(identity, INITIAL_DELAY) @@ -43,18 +42,18 @@ export function useProxyDelayState( useEffect(() => { if (isPreset) return - delayManager.setListener(name, groupName, setDelayState) + delayManager.setListener(member, groupName, setDelayState) return () => { - delayManager.removeListener(name, groupName) + delayManager.removeListener(member, groupName) } - }, [name, groupName, isPreset]) + }, [member, groupName, isPreset]) const updateDelay = useCallback(() => { if (unresolved) { setDelayState(INITIAL_DELAY) return } - const cachedUpdate = delayManager.getDelayUpdate(name, groupName) + const cachedUpdate = delayManager.getDelayUpdate(member, groupName) if (cachedUpdate) { setDelayState({ ...cachedUpdate }) return @@ -67,7 +66,9 @@ export function useProxyDelayState( } let updatedAt = 0 - const history = details?.history + const history = + delayManager.getHistory(member, groupName) ?? + memberDetails(member)?.history if (history && history.length > 0) { const lastRecord = history[history.length - 1] const parsed = Date.parse(lastRecord.time) @@ -77,16 +78,18 @@ export function useProxyDelayState( } setDelayState({ delay: fallbackDelay, updatedAt }) - }, [details?.history, groupName, member, name, unresolved]) + }, [groupName, member, unresolved]) useEffect(() => { updateDelay() - }, [updateDelay]) + return delayManager.addGroupListener(groupName, updateDelay) + }, [groupName, updateDelay]) const onDelay = useLockFn(async () => { if (!isInteractableMember(member)) return setDelayState({ delay: -2, updatedAt: Date.now() }) - setDelayState(await delayManager.checkDelay(member, groupName, timeout)) + await delayManager.checkDelay(member, groupName, timeout) + updateDelay() }) return { diff --git a/src/services/delay.test.ts b/src/services/delay.test.ts index a4d7c92fd..5f6a51afe 100644 --- a/src/services/delay.test.ts +++ b/src/services/delay.test.ts @@ -59,3 +59,140 @@ describe('group delay completion', () => { stop() }) }) + +test('uses newer core history after a tray test without hiding an active page test', () => { + const member = node('tray-refresh') + if (member.kind !== 'node') throw new Error('Expected node') + const cached = delayManager.setDelay(member, 'tray-group', 50) + member.node.extra = { + [delayManager.getUrl('tray-group')]: { + alive: true, + history: [ + { time: new Date(cached.updatedAt + 1000).toISOString(), delay: 300 }, + ], + }, + } + + expect(delayManager.getDelayFix(member, 'tray-group')).toBe(300) + expect(delayManager.getDelayUpdate(member, 'tray-group')?.delay).toBe(300) + + delayManager.setDelay(member, 'tray-group', -2) + expect(delayManager.getDelayFix(member, 'tray-group')).toBe(-2) +}) + +test('latency URL validation rejects malformed hosts and ports and trims valid URLs', () => { + for (const url of [ + 'http://?', + 'http://host:invalid', + 'http://bad host/', + 'ftp://localhost', + 'http:localhost', + ]) { + delayManager.setUrl('url-validation', url) + expect(delayManager.getUrl('url-validation')).toBe( + 'http://cp.cloudflare.com/generate_204', + ) + } + delayManager.setUrl('url-validation', ' HTTP://localhost:8080/ ') + expect(delayManager.getUrl('url-validation')).toBe('HTTP://localhost:8080/') +}) + +test('another URL cannot replace a group measurement, but a newer matching URL can', () => { + const member = node('url-isolation') + if (member.kind !== 'node') throw new Error('Expected node') + delayManager.setUrl('url-a', 'http://localhost/a') + const cached = delayManager.setDelay(member, 'url-a', 50) + const newer = new Date(cached.updatedAt + 1000).toISOString() + Object.assign(member.node, { + history: [{ time: newer, delay: 126 }], + extra: { + 'http://localhost/a': { + alive: true, + history: [ + { time: new Date(cached.updatedAt - 1000).toISOString(), delay: 49 }, + ], + }, + 'http://localhost/b': { + alive: true, + history: [{ time: newer, delay: 126 }], + }, + }, + }) + expect(delayManager.getDelayFix(member, 'url-a')).toBe(50) + Object.assign(member.node, { + extra: { + 'http://localhost/a': { + alive: true, + history: [{ time: newer, delay: 75 }], + }, + }, + }) + expect(delayManager.getDelayFix(member, 'url-a')).toBe(75) +}) + +test('same-named provider members keep separate measurements and listeners', async () => { + const first = node('same-provider-name') + const second = node('same-provider-name') + if (first.kind !== 'node' || second.kind !== 'node') + throw new Error('Expected nodes') + first.node.recordId = 'provider-a:N' + first.node.source = { kind: 'provider', providerName: 'a', proxyName: 'N' } + second.node.recordId = 'provider-b:N' + second.node.source = { kind: 'provider', providerName: 'b', proxyName: 'N' } + const { healthcheckNodeInProvider } = await import('tauri-plugin-mihomo-api') + vi.mocked(healthcheckNodeInProvider) + .mockResolvedValueOnce({ delay: 40 }) + .mockResolvedValueOnce({ delay: 125 }) + const firstListener = vi.fn() + const secondListener = vi.fn() + delayManager.setListener(first, 'provider-collision', firstListener) + delayManager.setListener(second, 'provider-collision', secondListener) + await delayManager.checkListDelay([first, second], 'provider-collision', 5000) + await flush() + expect(firstListener).toHaveBeenLastCalledWith( + expect.objectContaining({ delay: 40 }), + ) + expect(secondListener).toHaveBeenLastCalledWith( + expect.objectContaining({ delay: 125 }), + ) + delayManager.removeListener(first, 'provider-collision') + delayManager.removeListener(second, 'provider-collision') + expect(delayManager.getDelayFix(first, 'provider-collision')).toBe(40) + expect(delayManager.getDelayFix(second, 'provider-collision')).toBe(125) +}) + +test.each(['core', 'provider'] as const)( + 'cached %s delays follow source identity after record IDs move', + (kind) => { + const original = node(`stable-${kind}`) + const replacement = node(`replacement-${kind}`) + if (original.kind !== 'node' || replacement.kind !== 'node') + throw new Error('Expected nodes') + const source = (proxyName: string) => + kind === 'core' + ? { kind, proxyName } + : { kind, providerName: 'subscription', proxyName } + original.node.source = source(original.ref.name) + replacement.node.source = source(replacement.ref.name) + original.node.recordId = 'position:0' + replacement.node.recordId = 'position:0' + delayManager.setDelay(original, `reorder-${kind}`, 40) + expect(delayManager.getDelayFix(replacement, `reorder-${kind}`)).toBe(-1) + original.node.recordId = 'position:1' + expect(delayManager.getDelayFix(original, `reorder-${kind}`)).toBe(40) + }, +) + +test('unmeasured selectors show automatic history without replacing a manual URL result', () => { + const member = node('selector-member') + if (member.kind !== 'node') throw new Error('Expected node') + member.node.history = [ + { time: new Date(Date.now() + 1000).toISOString(), delay: 126 }, + ] + member.node.extra = { + 'http://localhost/automatic': { alive: true, history: member.node.history }, + } + expect(delayManager.getDelayFix(member, 'selector')).toBe(126) + delayManager.setDelay(member, 'selector', 50) + expect(delayManager.getDelayFix(member, 'selector')).toBe(50) +}) diff --git a/src/services/delay.ts b/src/services/delay.ts index 18602faaf..323809b67 100644 --- a/src/services/delay.ts +++ b/src/services/delay.ts @@ -12,13 +12,23 @@ import { } from '@/types/proxy-view' import { debugLog } from '@/utils/debug' import { classifyDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay' -import { isValidUrl } from '@/utils/network' +import { isValidLatencyTestUrl } from '@/utils/network' export type DelaySnapshot = { of: (member: ResolvedProxyMember) => number } -const hashKey = (name: string, group: string) => `${group ?? ''}::${name}` +const hashKey = (member: ResolvedProxyMember, group: string) => { + if (member.kind !== 'node') + return JSON.stringify([group, member.kind, member.ref.name]) + const source = member.node.source + return JSON.stringify([ + group, + source.kind, + source.kind === 'provider' ? source.providerName : null, + source.proxyName, + ]) +} export interface DelayUpdate { delay: number @@ -29,7 +39,7 @@ export interface DelayUpdate { const CACHE_TTL = 30 * 60 * 1000 class DelayManager { - private cache = new Map() + private cache = new Map() private urlMap = new Map() private listenerMap = new Map void>() @@ -153,13 +163,13 @@ class DelayManager { } setUrl(group: string, url: string) { - if (!isValidUrl(url)) { - debugLog(`[DelayManager] 拒绝无效测试URL,组: ${group}, URL: ${url}`) + const previous = this.getUrl(group) + if (isValidLatencyTestUrl(url)) { + this.urlMap.set(group, url.trim()) + } else { this.urlMap.delete(group) - return } - debugLog(`[DelayManager] 设置测试URL,组: ${group}, URL: ${url}`) - this.urlMap.set(group, url) + if (previous !== this.getUrl(group)) this.queueGroupNotification(group) } getUrl(group: string) { @@ -171,16 +181,16 @@ class DelayManager { } setListener( - name: string, + member: ResolvedProxyMember, group: string, listener: (update: DelayUpdate) => void, ) { - const key = hashKey(name, group) + const key = hashKey(member, group) this.listenerMap.set(key, listener) } - removeListener(name: string, group: string) { - const key = hashKey(name, group) + removeListener(member: ResolvedProxyMember, group: string) { + const key = hashKey(member, group) this.listenerMap.delete(key) } @@ -199,14 +209,14 @@ class DelayManager { } setDelay( - name: string, + member: ResolvedProxyMember, group: string, delay: number, - meta?: { elapsed?: number }, + meta?: { elapsed?: number; url?: string }, ): DelayUpdate { - const key = hashKey(name, group) + const key = hashKey(member, group) debugLog( - `[DelayManager] 设置延迟,代理: ${name}, 组: ${group}, 延迟: ${delay}`, + `[DelayManager] 设置延迟,代理: ${member.ref.name}, 组: ${group}, 延迟: ${delay}`, ) const update: DelayUpdate = { delay, @@ -214,7 +224,9 @@ class DelayManager { updatedAt: Date.now(), } - this.cache.set(key, update) + const url = meta?.url ?? this.getUrl(group) + if (url !== this.getUrl(group)) return update + this.cache.set(key, { ...update, url }) const queue = this.pendingItemUpdates.get(key) if (queue) { @@ -227,9 +239,16 @@ class DelayManager { return update } - getDelayUpdate(name: string, group: string) { - const key = hashKey(name, group) - const entry = this.cache.get(key) + getDelayUpdate(member: ResolvedProxyMember, group: string) { + const history = this.getHistory(member, group) + const key = hashKey(member, group) + const cached = this.cache.get(key) + const entry = cached?.url === this.getUrl(group) ? cached : undefined + const latest = history?.at(-1) + const updatedAt = latest ? Date.parse(latest.time) : 0 + if (latest && entry?.delay !== -2 && updatedAt > (entry?.updatedAt ?? 0)) { + return { delay: latest.delay || 1e6, updatedAt } + } if (!entry) return undefined if (Date.now() - entry.updatedAt > CACHE_TTL) { @@ -240,23 +259,25 @@ class DelayManager { return { ...entry } } - getDelay(name: string, group: string) { - const update = this.getDelayUpdate(name, group) + getDelay(member: ResolvedProxyMember, group: string) { + const update = this.getDelayUpdate(member, group) return update ? update.delay : -1 } + getHistory(member: ResolvedProxyMember, group: string) { + return memberDetails(member)?.extra?.[this.getUrl(group)]?.history + } + getDelayFix(member: ResolvedProxyMember, group: string) { if (member.kind === 'unresolved') return -1 - const details = memberDetails(member) - const name = member.ref.name - const update = this.getDelayUpdate(name, group) + const history = + this.getHistory(member, group) ?? memberDetails(member)?.history + const update = this.getDelayUpdate(member, group) if (update && (update.delay >= 0 || update.delay === -2)) { return update.delay } - if (details?.history && details.history.length > 0) { - return details.history[details.history.length - 1].delay || 1e6 - } + if (history?.length) return history[history.length - 1].delay || 1e6 return -1 } @@ -298,12 +319,12 @@ class DelayManager { `[DelayManager] 开始测试延迟,代理: ${name}, 组: ${group}, 超时: ${timeout}ms`, ) - this.setDelay(name, group, -2) + const url = this.getUrl(group) + this.setDelay(member, group, -2, { url }) const startTime = Date.now() try { - const url = this.getUrl(group) debugLog(`[DelayManager] 调用API测试延迟,代理: ${name}, URL: ${url}`) const timeoutPromise = new Promise((resolve) => { @@ -324,14 +345,14 @@ class DelayManager { const elapsed = elapsedTime debugLog(`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`) - return this.setDelay(name, group, delay, { elapsed }) + return this.setDelay(member, group, delay, { elapsed, url }) } catch (error) { await new Promise((resolve) => setTimeout(resolve, 500)) console.error(`[DelayManager] 延迟测试出错,代理: ${name}`, error) const delay = 1e6 // error const elapsed = Date.now() - startTime - return this.setDelay(name, group, delay, { elapsed }) + return this.setDelay(member, group, delay, { elapsed, url }) } } @@ -344,10 +365,9 @@ class DelayManager { debugLog( `[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`, ) - const names = proxies.map((member) => member.ref.name) this.activeBatches.set(group, (this.activeBatches.get(group) ?? 0) + 1) - names.forEach((name) => { - this.setDelay(name, group, -2) + proxies.forEach((member) => { + this.setDelay(member, group, -2) }) let index = 0 @@ -359,7 +379,7 @@ class DelayManager { const currName = currMember.ref.name try { - this.setDelay(currName, group, -2) + this.setDelay(currMember, group, -2) // Stagger requests so a batch does not hit the core at once. if (index > 1) { @@ -375,13 +395,13 @@ class DelayManager { `[DelayManager] 批量测试单个代理出错,代理: ${currName}`, error, ) - this.setDelay(currName, group, 1e6) + this.setDelay(currMember, group, 1e6) } return help() } - const actualConcurrency = Math.min(concurrency, names.length, 10) + const actualConcurrency = Math.min(concurrency, proxies.length, 10) debugLog(`[DelayManager] 实际并发数: ${actualConcurrency}`) const promiseList: Promise[] = [] diff --git a/src/services/proxy-test-url.test.ts b/src/services/proxy-test-url.test.ts new file mode 100644 index 000000000..e1e348f11 --- /dev/null +++ b/src/services/proxy-test-url.test.ts @@ -0,0 +1,103 @@ +import { afterEach, expect, test, vi } from 'vitest' + +vi.mock('@/services/cmds', () => ({ + getProfiles: vi.fn(), + patchProfile: vi.fn().mockResolvedValue(undefined), + syncTrayProxySelection: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() })) +vi.mock('@/services/notice-service', () => ({ showNotice: { error: vi.fn() } })) + +import { + getProfiles, + patchProfile, + syncTrayProxySelection, +} from '@/services/cmds' +import { revalidateQueries } from '@/services/query-client' + +import { + profileTestUrls, + saveProfileTestUrls, + commitProfileTestUrl, +} from './proxy-test-url' + +afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() +}) + +test('committed edits retain their profile and merge with fresh saved URLs', async () => { + vi.mocked(getProfiles).mockResolvedValue({ + current: 'new-profile', + items: [ + { + uid: 'old-profile', + latency_test_urls: { untouched: 'https://example.com/' }, + }, + ], + }) + await commitProfileTestUrl('old-profile', 'A', ' http://localhost/new ') + expect(patchProfile).toHaveBeenCalledExactlyOnceWith('old-profile', { + latency_test_urls: { + untouched: 'https://example.com/', + A: 'http://localhost/new', + }, + }) + expect(revalidateQueries).toHaveBeenCalledWith([['getProfiles']]) + expect(syncTrayProxySelection).toHaveBeenCalledOnce() +}) + +test('migration cannot overwrite a previously saved or cleared override', async () => { + vi.mocked(getProfiles).mockResolvedValue({ + items: [{ uid: 'p', latency_test_urls: { A: '' } }], + }) + await saveProfileTestUrls('p', { A: 'http://localhost/stale' }, true) + expect(patchProfile).not.toHaveBeenCalled() +}) + +test('pending edits survive a reload and older save acknowledgements', async () => { + vi.useFakeTimers() + const profile = { + uid: 'pending-profile', + latency_test_urls: { A: 'http://localhost/old' }, + } + vi.mocked(getProfiles).mockResolvedValue({ items: [profile] }) + let finish!: () => void + vi.mocked(patchProfile).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + const first = commitProfileTestUrl(profile.uid, 'A', 'http://localhost/new') + expect(profileTestUrls(profile).A).toBe('http://localhost/new') + await vi.advanceTimersByTimeAsync(300) + expect(profileTestUrls(profile).A).toBe('http://localhost/new') + const second = commitProfileTestUrl( + profile.uid, + 'A', + 'http://localhost/newest', + ) + finish() + await first + expect(profileTestUrls(profile).A).toBe('http://localhost/newest') + await second + expect(patchProfile).toHaveBeenLastCalledWith(profile.uid, { + latency_test_urls: { A: 'http://localhost/newest' }, + }) +}) + +test('a failed save preserves the committed input across profile refreshes', async () => { + const profile = { + uid: 'failed-profile', + latency_test_urls: { A: 'http://localhost/old' }, + } + vi.mocked(getProfiles).mockResolvedValue({ items: [profile] }) + vi.mocked(patchProfile).mockRejectedValueOnce(new Error('save failed')) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + await commitProfileTestUrl(profile.uid, 'A', 'http://localhost/new') + expect(profileTestUrls(profile).A).toBe('http://localhost/new') + await commitProfileTestUrl(profile.uid, 'A', 'http://localhost/new') + expect(profileTestUrls(profile).A).toBe('http://localhost/old') + log.mockRestore() +}) diff --git a/src/services/proxy-test-url.ts b/src/services/proxy-test-url.ts new file mode 100644 index 000000000..acd597aba --- /dev/null +++ b/src/services/proxy-test-url.ts @@ -0,0 +1,101 @@ +import { + getProfiles, + patchProfile, + syncTrayProxySelection, +} from '@/services/cmds' +import { mutate } from '@/services/mutate' + +export function readLegacyTestUrls(uid: string): Record { + try { + const states = JSON.parse(localStorage.getItem('proxy-head-state') || '{}')[ + uid + ] + return Object.fromEntries( + Object.entries(states || {}).flatMap(([name, state]) => { + const url = (state as { testUrl?: string }).testUrl + return typeof url === 'string' && url.trim() ? [[name, url.trim()]] : [] + }), + ) + } catch { + return {} + } +} + +export async function saveProfileTestUrls( + uid: string, + urls: Record, + migrateOnly = false, +) { + return mutate( + async () => { + const profiles = await getProfiles() + const profile = profiles.items?.find((item) => item.uid === uid) + if (!profile || (migrateOnly && profile.latency_test_urls !== undefined)) + return + await patchProfile(uid, { + latency_test_urls: { + ...(profile.latency_test_urls ?? readLegacyTestUrls(uid)), + ...urls, + }, + }) + await syncTrayProxySelection().catch((error) => { + console.error( + 'Failed to refresh tray after saving latency test URL:', + error, + ) + }) + }, + { id: `patch-profile:${uid}`, revalidate: [['getProfiles']] }, + ) +} + +const migrating = new Set() + +export function migrateProfileTestUrls(profiles: IProfilesConfig) { + for (const profile of profiles.items || []) { + if (profile.latency_test_urls !== undefined || migrating.has(profile.uid)) + continue + const urls = readLegacyTestUrls(profile.uid) + if (!Object.keys(urls).length) continue + migrating.add(profile.uid) + void saveProfileTestUrls(profile.uid, urls, true) + .catch((error) => { + console.error('Failed to migrate latency test URLs:', error) + }) + .finally(() => migrating.delete(profile.uid)) + } +} + +const pending = new Map>() + +export async function commitProfileTestUrl( + uid: string, + group: string, + url: string, +) { + const edit = { url: url.trim() } + const urls = { ...pending.get(uid), [group]: edit } + pending.set(uid, urls) + try { + await saveProfileTestUrls(uid, { [group]: edit.url }) + const current = pending.get(uid) + if (current?.[group] === edit) { + delete current[group] + if (!Object.keys(current).length) pending.delete(uid) + } + } catch (error) { + console.error('Failed to save latency test URL:', error) + } +} + +export function profileTestUrls(profile: IProfileItem): Record { + return { + ...profile.latency_test_urls, + ...Object.fromEntries( + Object.entries(pending.get(profile.uid) ?? {}).map(([group, edit]) => [ + group, + edit.url, + ]), + ), + } +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 13da2141e..4083bc95d 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -218,6 +218,7 @@ interface IClashInfo { } interface IProfileItem { + latency_test_urls?: Record uid: string type?: 'local' | 'remote' | 'merge' | 'script' name?: string diff --git a/src/types/proxy-view.ts b/src/types/proxy-view.ts index 3f4522f52..7b41bafc5 100644 --- a/src/types/proxy-view.ts +++ b/src/types/proxy-view.ts @@ -33,6 +33,7 @@ export interface ProxyGroupView extends ProxyCapabilities { icon?: string testUrl?: string history: DelayHistory[] + extra?: Record members: ProxyMemberRef[] } @@ -42,6 +43,7 @@ export interface ProxyNodeView extends ProxyCapabilities { type: string alive: boolean history: DelayHistory[] + extra?: Record id?: string hidden?: boolean icon?: string diff --git a/src/utils/network.ts b/src/utils/network.ts index 647efafc1..99db4a6f9 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -20,6 +20,19 @@ export const isValidUrl = (value: string) => require_tld: false, }) +export const isValidLatencyTestUrl = (value: string) => { + const raw = value.trim() + if (!/^https?:\/\//i.test(raw)) return false + try { + const url = new URL(raw) + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && !!url.hostname + ) + } catch { + return false + } +} + export const isValidPort = (value: string) => isPort(value.trim()) export const normalizeHost = (value: string): string | null => {