From 5e6a696087a5c855f5fd1ab51f4a3f4c6451afc3 Mon Sep 17 00:00:00 2001 From: wonfen Date: Wed, 21 Oct 2026 05:34:49 +0800 Subject: [PATCH] revert(tray): revert per-group latency testing (#8173) This reverts commit cb751052a423c017e75cf0379adb12848e3f0fae. Remove the tray latency test and its dashboard URL persistence and cache changes because the two entry points have diverging behavior. Preserve the subsequent IPC binding migration and remove generated fields, input projections, and locale entries that depended on the reverted feature. --- Changelog.md | 1 + 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/pt.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/profile.rs | 3 - src-tauri/src/cmd/proxy.rs | 16 +- src-tauri/src/config/prfitem.rs | 6 - src-tauri/src/config/profiles.rs | 1 - src-tauri/src/core/proxy_view.rs | 103 +--- src-tauri/src/core/tray/latency.rs | 156 ------ src-tauri/src/core/tray/menu_def.rs | 1 - src-tauri/src/core/tray/mod.rs | 491 +++++------------- 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 | 16 +- src/components/proxy/use-head-state.ts | 78 +-- src/components/proxy/use-render-list.ts | 1 - src/hooks/use-profiles.ts | 7 +- src/hooks/use-proxy-delay-state.ts | 21 +- src/services/bindings.ts | 12 - src/services/cmds.ts | 15 +- src/services/delay.test.ts | 133 ----- src/services/delay.ts | 96 ++-- src/services/proxy-test-url.test.ts | 124 ----- src/services/proxy-test-url.ts | 101 ---- src/utils/network.ts | 13 - 40 files changed, 228 insertions(+), 1315 deletions(-) delete mode 100644 src-tauri/src/core/tray/latency.rs delete mode 100644 src/components/proxy/proxy-test-url-input.tsx delete mode 100644 src/services/proxy-test-url.test.ts delete mode 100644 src/services/proxy-test-url.ts diff --git a/Changelog.md b/Changelog.md index d738083fc..0110ee42a 100644 --- a/Changelog.md +++ b/Changelog.md @@ -3,6 +3,7 @@
🐞 修复问题 +- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口 - 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置 - 修复 首页 网站测速失败后卡片一直显示测试中 - 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新 diff --git a/crates/clash-verge-i18n/locales/ar.yml b/crates/clash-verge-i18n/locales/ar.yml index 81dec23dc..523f49c55 100644 --- a/crates/clash-verge-i18n/locales/ar.yml +++ b/crates/clash-verge-i18n/locales/ar.yml @@ -70,8 +70,6 @@ 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 058f3b3d3..d8accd74b 100644 --- a/crates/clash-verge-i18n/locales/de.yml +++ b/crates/clash-verge-i18n/locales/de.yml @@ -70,8 +70,6 @@ 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 9db8ff3cd..d1b144b94 100644 --- a/crates/clash-verge-i18n/locales/en.yml +++ b/crates/clash-verge-i18n/locales/en.yml @@ -70,8 +70,6 @@ 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 85ad7d95d..119d630d7 100644 --- a/crates/clash-verge-i18n/locales/es.yml +++ b/crates/clash-verge-i18n/locales/es.yml @@ -70,8 +70,6 @@ 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 2a3453405..e5270cebd 100644 --- a/crates/clash-verge-i18n/locales/fa.yml +++ b/crates/clash-verge-i18n/locales/fa.yml @@ -70,8 +70,6 @@ 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 9c037b90d..316b3e523 100644 --- a/crates/clash-verge-i18n/locales/id.yml +++ b/crates/clash-verge-i18n/locales/id.yml @@ -70,8 +70,6 @@ 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 327383e8b..9817405e6 100644 --- a/crates/clash-verge-i18n/locales/jp.yml +++ b/crates/clash-verge-i18n/locales/jp.yml @@ -70,8 +70,6 @@ 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 9fcaa6a79..241d0d327 100644 --- a/crates/clash-verge-i18n/locales/ko.yml +++ b/crates/clash-verge-i18n/locales/ko.yml @@ -70,8 +70,6 @@ tray: global: 글로벌 profiles: 프로필 proxies: 프록시 - testLatency: 지연 테스트 - testingLatency: 지연 테스트 중… systemProxy: 시스템 프록시 tunMode: TUN 모드 closeAllConnections: 모든 연결 닫기 diff --git a/crates/clash-verge-i18n/locales/pt.yml b/crates/clash-verge-i18n/locales/pt.yml index 5999d1b91..2373529ee 100644 --- a/crates/clash-verge-i18n/locales/pt.yml +++ b/crates/clash-verge-i18n/locales/pt.yml @@ -70,8 +70,6 @@ tray: global: Global profiles: Perfis proxies: Proxies - testLatency: Test Latency - testingLatency: Testing latency… systemProxy: Proxy do sistema tunMode: Modo TUN closeAllConnections: Fechar todas as conexões diff --git a/crates/clash-verge-i18n/locales/ru.yml b/crates/clash-verge-i18n/locales/ru.yml index 630366a8f..985b9d1e6 100644 --- a/crates/clash-verge-i18n/locales/ru.yml +++ b/crates/clash-verge-i18n/locales/ru.yml @@ -70,8 +70,6 @@ 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 3ced8c82d..0d0f0c945 100644 --- a/crates/clash-verge-i18n/locales/tr.yml +++ b/crates/clash-verge-i18n/locales/tr.yml @@ -70,8 +70,6 @@ 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 2f067cc07..c136061ae 100644 --- a/crates/clash-verge-i18n/locales/tt.yml +++ b/crates/clash-verge-i18n/locales/tt.yml @@ -70,8 +70,6 @@ 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 dbb40569b..d87c1e2f0 100644 --- a/crates/clash-verge-i18n/locales/zh.yml +++ b/crates/clash-verge-i18n/locales/zh.yml @@ -70,8 +70,6 @@ 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 ccf2ab2ac..b6baa3adb 100644 --- a/crates/clash-verge-i18n/locales/zhtw.yml +++ b/crates/clash-verge-i18n/locales/zhtw.yml @@ -70,8 +70,6 @@ tray: global: 全域 profiles: 訂閱 proxies: 代理 - testLatency: 測試延遲 - testingLatency: 正在測試延遲… systemProxy: 系統代理 tunMode: 虛擬網路介面卡模式 closeAllConnections: 關閉所有連線 diff --git a/src-tauri/src/cmd/profile.rs b/src-tauri/src/cmd/profile.rs index 3448d1502..6b36e0fc9 100644 --- a/src-tauri/src/cmd/profile.rs +++ b/src-tauri/src/cmd/profile.rs @@ -372,8 +372,6 @@ pub struct ProfilePatch { #[specta(type = Option)] pub url: Option, pub selected: Option>, - #[specta(type = Option>)] - pub latency_test_urls: Option>, pub extra: Option, pub updated: Option, pub option: Option, @@ -388,7 +386,6 @@ impl From for PrfItem { file: patch.file, url: patch.url, selected: patch.selected, - latency_test_urls: patch.latency_test_urls, extra: patch.extra, updated: patch.updated, option: patch.option, diff --git a/src-tauri/src/cmd/proxy.rs b/src-tauri/src/cmd/proxy.rs index f20d0b6b6..aa5521833 100644 --- a/src-tauri/src/cmd/proxy.rs +++ b/src-tauri/src/cmd/proxy.rs @@ -108,32 +108,20 @@ 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(), 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, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),); let proxies = proxies.stringify_err()?; Ok(ProxyViewBuilder::build(ProxyViewInput { runtime_group_order, group_scopes, proxies, - providers, + providers: providers.ok(), })) } diff --git a/src-tauri/src/config/prfitem.rs b/src-tauri/src/config/prfitem.rs index 0c99355d5..da8728d21 100644 --- a/src-tauri/src/config/prfitem.rs +++ b/src-tauri/src/config/prfitem.rs @@ -53,10 +53,6 @@ pub struct PrfItem { #[serde(skip_serializing_if = "Option::is_none")] pub selected: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[specta(type = Option>)] - pub latency_test_urls: Option>, - #[serde(skip_serializing_if = "Option::is_none")] pub extra: Option, @@ -233,7 +229,6 @@ impl PrfItem { desc: Some(desc), file: Some(file), url: None, - latency_test_urls: None, selected: None, extra: None, option: Some(PrfOption { @@ -413,7 +408,6 @@ 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 7680fff58..4b149b716 100644 --- a/src-tauri/src/config/profiles.rs +++ b/src-tauri/src/config/profiles.rs @@ -250,7 +250,6 @@ 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 54d488fc0..0fe394280 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, HashMap}; +use std::collections::{BTreeMap, BTreeSet}; -use serde::{Serialize, Serializer, ser::SerializeMap as _}; +use serde::Serialize; use tauri_plugin_mihomo::models::{ - DelayHistory, Extra, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType, + DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType, }; pub struct ProxyViewInput { @@ -57,28 +57,6 @@ pub struct ProxyCapabilities { pub smux: bool, } -#[derive(Serialize, specta::Type)] -struct ProxyLatencyHistory<'a> { - #[specta(type = Vec)] - history: &'a [DelayHistory], -} - -fn serialize_proxy_latency_history( - extra: &HashMap, - serializer: S, -) -> Result { - let mut map = serializer.serialize_map(Some(extra.len()))?; - for (url, extra) in extra { - map.serialize_entry( - url, - &ProxyLatencyHistory { - history: &extra.history, - }, - )?; - } - map.end() -} - #[derive(Debug, PartialEq, Eq, Serialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ProxyGroupView { @@ -98,9 +76,6 @@ pub struct ProxyGroupView { pub test_url: Option, #[specta(type = Vec)] pub history: Vec, - #[serde(serialize_with = "serialize_proxy_latency_history")] - #[specta(type = HashMap>)] - pub extra: HashMap, #[serde(flatten)] pub capabilities: ProxyCapabilities, pub members: Vec, @@ -116,9 +91,6 @@ pub struct ProxyNodeView { pub proxy_type: ProxyType, #[specta(type = Vec)] pub history: Vec, - #[serde(serialize_with = "serialize_proxy_latency_history")] - #[specta(type = HashMap>)] - pub extra: HashMap, #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -284,7 +256,7 @@ impl ProxyViewBuilder { } else { ProxyViewProviderState::Unavailable }; - let (mut core_groups, core_nodes) = partition_core(proxies, providers.is_some()); + let (mut core_groups, core_nodes) = partition_core(proxies); let (mut records, core_node_ids) = build_core_records(core_nodes); let (providers, provider_candidates) = build_provider_records(providers, &mut records); let resolver = MemberResolver { @@ -317,14 +289,14 @@ impl ProxyViewBuilder { } } -fn partition_core(proxies: Proxies, providers_available: bool) -> (BTreeMap, BTreeMap) { +fn partition_core(proxies: Proxies) -> (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 if !providers_available || proxy.provider_name.is_empty() { + } else { nodes.insert(name, proxy); } } @@ -431,7 +403,6 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro now, test_url, history, - extra, udp, xudp, tfo, @@ -464,7 +435,6 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro icon, test_url, history, - extra, capabilities: ProxyCapabilities { udp, xudp, @@ -483,7 +453,6 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo icon, test_url, history, - extra, udp, xudp, tfo, @@ -498,7 +467,6 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo name, proxy_type, history, - extra, id, hidden, icon, @@ -552,62 +520,3 @@ 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 deleted file mode 100644 index 5c1106ede..000000000 --- a/src-tauri/src/core/tray/latency.rs +++ /dev/null @@ -1,156 +0,0 @@ -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 6c9641773..7012ce219 100644 --- a/src-tauri/src/core/tray/menu_def.rs +++ b/src-tauri/src/core/tray/menu_def.rs @@ -32,7 +32,6 @@ 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 221f23aa1..3d7f0aaf5 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -1,6 +1,5 @@ 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; @@ -15,6 +14,7 @@ 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,7 +28,6 @@ use tauri::{ menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, }; -mod latency; mod menu_def; #[cfg(target_os = "macos")] mod speed_task; @@ -56,25 +55,8 @@ 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, } @@ -148,9 +130,6 @@ 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(), } @@ -216,7 +195,6 @@ 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(()); @@ -241,133 +219,29 @@ impl Tray { let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default(); let is_lightweight_mode = is_in_lightweight_mode(); - 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_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?, + )) + ); 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, "应用正在退出,跳过托盘图标更新"); @@ -583,175 +457,96 @@ 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, - view: Option, - latency_items: &mut HashMap, - texts: &MenuTexts, - verge: &IVerge, - profiles: &crate::config::IProfiles, + proxy_group_order_map: Option>, + proxy_nodes_data: Option, ) -> Vec> { - 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; - } - 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, + 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() }) - .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") + .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 { - 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() + logging!(warn, Type::Tray, "Failed to create proxy group submenu: {}", group_name); + } + } + } + + 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 } fn create_proxy_menu_item( @@ -798,38 +593,54 @@ async fn create_tray_menu( tun_mode_available: bool, profiles_preview: Vec>, options: TrayMenuOptions, -) -> Result<(tauri::menu::Menu, HashMap)> { +) -> Result> { 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"); - // 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) + // 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) } 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"); @@ -909,17 +720,9 @@ 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_view, - &mut latency_items, - &texts, - &verge_settings, - &profiles_config, - ); + let proxy_sub_menus = + create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data); match tray_proxy_groups_display_mode { "default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?, @@ -1063,25 +866,10 @@ async fn create_tray_menu( ]); let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?; - Ok((menu, latency_items)) + Ok(menu) } 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 { .. } @@ -1203,15 +991,6 @@ 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 c7145b05f..4b891a623 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -47,10 +47,7 @@ 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, @@ -678,20 +675,6 @@ 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], @@ -924,12 +907,6 @@ 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 194b1cde4..44cc8a3da 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 } from '@mui/material' +import { Box, IconButton, type SxProps, TextField } from '@mui/material' import { useDebounceFn } from 'ahooks' import { memo, useEffect, useRef } from 'react' import { flushSync } from 'react-dom' @@ -18,11 +18,10 @@ import { useTranslation } from 'react-i18next' import { useVerge } from '@/hooks/use-verge' import delayManager from '@/services/delay' import { showNotice } from '@/services/notice-service' -import { isValidLatencyTestUrl } from '@/utils/network' +import { isValidUrl } 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' @@ -70,10 +69,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) { const inputRef = useRef(null) useEffect(() => { - delayManager.setUrl( - groupName, - testUrl?.trim() || url?.trim() || defaultLatencyUrl, - ) + delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl) }, [groupName, testUrl, defaultLatencyUrl, url]) // 过滤输入是高频操作,且每次都会触发整组代理的重新过滤/排序与虚拟列表重渲染, @@ -116,8 +112,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) { )} {textState === 'url' && ( - onHeadState({ testUrl })} + onChange={(e) => onHeadState({ testUrl: e.target.value })} sx={{ flex: '1 1 auto', input: { py: 0.65, px: 1 } }} /> )} @@ -181,7 +176,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) { if (testUrl?.trim() && textState !== 'filter') { onHeadState({ textState: 'url' }) } - if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) { + if (testUrl?.trim() && !isValidUrl(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 28e3b3e06..7a852543b 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -23,9 +23,7 @@ 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, @@ -145,12 +143,6 @@ 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 4fa9aefc7..4db54f36f 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, type SxProps } from '@mui/material' +import { Box, IconButton, TextField, type SxProps } from '@mui/material' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -20,9 +20,8 @@ import { useVerge } from '@/hooks/use-verge' import delayManager from '@/services/delay' import { showNotice } from '@/services/notice-service' import { debugLog } from '@/utils/debug' -import { isValidLatencyTestUrl } from '@/utils/network' +import { isValidUrl } from '@/utils/network' -import { ProxyTestUrlInput } from './proxy-test-url-input' import type { ProxySortType } from './use-filter-sort' import type { HeadState } from './use-head-state' @@ -73,10 +72,7 @@ export const ProxyHead = ({ 'http://cp.cloudflare.com/generate_204' useEffect(() => { - delayManager.setUrl( - groupName, - testUrl?.trim() || url?.trim() || defaultLatencyUrl, - ) + delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl) }, [groupName, testUrl, defaultLatencyUrl, url]) return ( @@ -101,7 +97,7 @@ export const ProxyHead = ({ debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`) onHeadState({ textState: 'url' }) } - if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) { + if (testUrl?.trim() && !isValidUrl(testUrl)) { showNotice.warning('proxies.feedback.warnings.invalidTestUrl') return } @@ -192,8 +188,7 @@ export const ProxyHead = ({ )} {textState === 'url' && ( - onHeadState({ testUrl })} + onChange={(e) => onHeadState({ testUrl: e.target.value })} 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 deleted file mode 100644 index 287e2f0f5..000000000 --- a/src/components/proxy/proxy-test-url-input.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 dd5effc33..2f8d0d46e 100644 --- a/src/components/proxy/use-filter-sort.test.ts +++ b/src/components/proxy/use-filter-sort.test.ts @@ -13,17 +13,11 @@ 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': { - history: [{ time: new Date(0).toISOString(), delay }], - }, - }, source: { kind: 'provider', providerName: `${memberIndex}` }, }, }, - }) as unknown as ResolvedMemberOccurrence + }) as ResolvedMemberOccurrence afterEach(() => vi.restoreAllMocks()) @@ -42,9 +36,7 @@ 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(list[i].member, group, delay), - ) + values.forEach((delay, i) => delayManager.setDelay(`${i}`, group, delay)) } for (const timeout of [10000, 20, 0, NaN]) { const expected = list @@ -74,7 +66,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(list[0].member, 'expiry', 1) + delayManager.setDelay('0', 'expiry', 1) const get = vi.spyOn(delayManager, 'getDelayFix') expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder) expect(get).toHaveBeenCalledTimes(list.length) @@ -88,7 +80,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne list[3], list[0], ]) - delayManager.setDelay(list[0].member, 'expiry', 2) + delayManager.setDelay('0', '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 d536564cc..5d30c5e1c 100644 --- a/src/components/proxy/use-head-state.ts +++ b/src/components/proxy/use-head-state.ts @@ -1,10 +1,6 @@ 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' @@ -36,45 +32,22 @@ export const DEFAULT_STATE: HeadState = { } type HeadStateAction = - | { type: 'reset'; profile: string } - | { type: 'replace'; profile: string; payload: Record } + | { type: 'reset' } + | { type: 'replace'; payload: Record } | { type: 'update'; groupName: string; patch: Partial } - | { type: 'urls'; urls: Record } - -interface LoadedHeadState { - profile: string | null - heads: Record -} function headStateReducer( - state: LoadedHeadState, + state: Record, action: HeadStateAction, -): LoadedHeadState { +): Record { switch (action.type) { case 'reset': - return { profile: action.profile, heads: {} } + return {} case 'replace': - 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 } - } + return action.payload case 'update': { - const prev = state.heads[action.groupName] || DEFAULT_STATE - return { - ...state, - heads: { - ...state.heads, - [action.groupName]: { ...prev, ...action.patch }, - }, - } + const prev = state[action.groupName] || DEFAULT_STATE + return { ...state, [action.groupName]: { ...prev, ...action.patch } } } default: return state @@ -82,42 +55,30 @@ function headStateReducer( } export function useHeadStateNew() { - const { profiles, current: profile } = useProfiles() + const { profiles } = useProfiles() const current = profiles?.current || '' - const [{ profile: loadedProfile, heads: state }, dispatch] = useReducer( - headStateReducer, - { profile: null, heads: {} }, - ) + const [state, dispatch] = useReducer(headStateReducer, {}) 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] || {} - for (const [name, testUrl] of Object.entries(urls)) { - value[name] = { ...DEFAULT_STATE, ...value[name], testUrl } - } + const value = data[current] || {} if (value && typeof value === 'object') { - dispatch({ type: 'replace', profile: current, payload: value }) + dispatch({ type: 'replace', payload: value }) } else { - dispatch({ type: 'reset', profile: current }) + dispatch({ type: 'reset' }) } } catch { - dispatch({ type: 'reset', profile: current }) + dispatch({ type: 'reset' }) } - }, [current, loadedProfile, profile]) + }, [current]) useEffect(() => { - if (loadedProfile !== current) return const timer = setTimeout(() => { try { const item = localStorage.getItem(HEAD_STATE_KEY) @@ -133,16 +94,13 @@ export function useHeadStateNew() { }) return () => clearTimeout(timer) - }, [state, current, loadedProfile]) + }, [state, current]) 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/components/proxy/use-render-list.ts b/src/components/proxy/use-render-list.ts index 826331bde..659e0af4a 100644 --- a/src/components/proxy/use-render-list.ts +++ b/src/components/proxy/use-render-list.ts @@ -116,7 +116,6 @@ const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({ mptcp: false, smux: false, history: [], - extra: {}, members, }) diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index f07fd4ce0..71196bd36 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -1,9 +1,8 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useRef } from 'react' import type { ProfilePatch, ProfileSelection } from '@/services/bindings' 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' @@ -36,10 +35,6 @@ 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 8b999edba..f8a07f299 100644 --- a/src/hooks/use-proxy-delay-state.ts +++ b/src/hooks/use-proxy-delay-state.ts @@ -34,6 +34,7 @@ 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) @@ -42,18 +43,18 @@ export function useProxyDelayState( useEffect(() => { if (isPreset) return - delayManager.setListener(member, groupName, setDelayState) + delayManager.setListener(name, groupName, setDelayState) return () => { - delayManager.removeListener(member, groupName) + delayManager.removeListener(name, groupName) } - }, [member, groupName, isPreset]) + }, [name, groupName, isPreset]) const updateDelay = useCallback(() => { if (unresolved) { setDelayState(INITIAL_DELAY) return } - const cachedUpdate = delayManager.getDelayUpdate(member, groupName) + const cachedUpdate = delayManager.getDelayUpdate(name, groupName) if (cachedUpdate) { setDelayState({ ...cachedUpdate }) return @@ -66,9 +67,7 @@ export function useProxyDelayState( } let updatedAt = 0 - const history = - delayManager.getHistory(member, groupName) ?? - memberDetails(member)?.history + const history = details?.history if (history && history.length > 0) { const lastRecord = history[history.length - 1] const parsed = Date.parse(lastRecord.time) @@ -78,18 +77,16 @@ export function useProxyDelayState( } setDelayState({ delay: fallbackDelay, updatedAt }) - }, [groupName, member, unresolved]) + }, [details?.history, groupName, member, name, unresolved]) useEffect(() => { updateDelay() - return delayManager.addGroupListener(groupName, updateDelay) - }, [groupName, updateDelay]) + }, [updateDelay]) const onDelay = useLockFn(async () => { if (!isInteractableMember(member)) return setDelayState({ delay: -2, updatedAt: Date.now() }) - await delayManager.checkDelay(member, groupName, timeout) - updateDelay() + setDelayState(await delayManager.checkDelay(member, groupName, timeout)) }) return { diff --git a/src/services/bindings.ts b/src/services/bindings.ts index 73e886a69..4a1623bdc 100644 --- a/src/services/bindings.ts +++ b/src/services/bindings.ts @@ -659,7 +659,6 @@ export type PrfItem_Deserialize = { desc?: string | null url?: string | null selected?: PrfSelected[] | null - latency_test_urls?: { [key in string]: string } | null extra?: PrfExtra | null updated?: number | null option?: PrfOption_Deserialize | null @@ -675,7 +674,6 @@ export type PrfItem_Serialize = { desc?: string | null url?: string | null selected?: PrfSelected[] | null - latency_test_urls?: { [key in string]: string } | null extra?: PrfExtra | null updated: number | null option?: PrfOption_Serialize | null @@ -770,7 +768,6 @@ export type ProfilePatch_Deserialize = { file?: string | null url?: string | null selected?: PrfSelected[] | null - latency_test_urls?: { [key in string]: string } | null extra?: PrfExtra | null updated?: number | null option?: PrfOption_Deserialize | null @@ -783,7 +780,6 @@ export type ProfilePatch_Serialize = { file: string | null url: string | null selected: PrfSelected[] | null - latency_test_urls: { [key in string]: string } | null extra: PrfExtra | null updated: number | null option: PrfOption_Serialize | null @@ -818,7 +814,6 @@ export type ProxyGroupView_Deserialize = { icon: string | null testUrl: string | null history: DelayHistory[] - extra: { [key in string]: ProxyLatencyHistory } members: ProxyMemberRef[] } & ProxyCapabilities @@ -831,14 +826,9 @@ export type ProxyGroupView_Serialize = { icon?: string | null testUrl?: string | null history: DelayHistory[] - extra: { [key in string]: ProxyLatencyHistory } members: ProxyMemberRef[] } & ProxyCapabilities -export type ProxyLatencyHistory = { - history: DelayHistory[] -} - export type ProxyMemberRef = | { kind: 'group'; name: string } | { kind: 'node'; name: string; recordId: string } @@ -860,7 +850,6 @@ export type ProxyNodeView_Deserialize = { name: string type: string history: DelayHistory[] - extra: { [key in string]: ProxyLatencyHistory } id: string | null hidden: boolean | null icon: string | null @@ -873,7 +862,6 @@ export type ProxyNodeView_Serialize = { name: string type: string history: DelayHistory[] - extra: { [key in string]: ProxyLatencyHistory } id?: string | null hidden?: boolean | null icon?: string | null diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 001ee3c71..f46377335 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -91,18 +91,8 @@ export async function deleteProfile(index: string) { } export async function patchProfile(index: string, profile: ProfilePatch) { - const { - type, - name, - desc, - file, - url, - selected, - latency_test_urls, - extra, - updated, - option, - } = profile + const { type, name, desc, file, url, selected, extra, updated, option } = + profile return commands.patchProfile(index, { type, name, @@ -110,7 +100,6 @@ export async function patchProfile(index: string, profile: ProfilePatch) { file, url, selected, - latency_test_urls, extra, updated, option, diff --git a/src/services/delay.test.ts b/src/services/delay.test.ts index a26d25dde..a4d7c92fd 100644 --- a/src/services/delay.test.ts +++ b/src/services/delay.test.ts @@ -59,136 +59,3 @@ 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')]: { - 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': { - history: [ - { time: new Date(cached.updatedAt - 1000).toISOString(), delay: 49 }, - ], - }, - 'http://localhost/b': { - history: [{ time: newer, delay: 126 }], - }, - }, - }) - expect(delayManager.getDelayFix(member, 'url-a')).toBe(50) - Object.assign(member.node, { - extra: { - 'http://localhost/a': { - 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': { 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 323809b67..18602faaf 100644 --- a/src/services/delay.ts +++ b/src/services/delay.ts @@ -12,23 +12,13 @@ import { } from '@/types/proxy-view' import { debugLog } from '@/utils/debug' import { classifyDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay' -import { isValidLatencyTestUrl } from '@/utils/network' +import { isValidUrl } from '@/utils/network' export type DelaySnapshot = { of: (member: ResolvedProxyMember) => number } -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, - ]) -} +const hashKey = (name: string, group: string) => `${group ?? ''}::${name}` export interface DelayUpdate { delay: number @@ -39,7 +29,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>() @@ -163,13 +153,13 @@ class DelayManager { } setUrl(group: string, url: string) { - const previous = this.getUrl(group) - if (isValidLatencyTestUrl(url)) { - this.urlMap.set(group, url.trim()) - } else { + if (!isValidUrl(url)) { + debugLog(`[DelayManager] 拒绝无效测试URL,组: ${group}, URL: ${url}`) this.urlMap.delete(group) + return } - if (previous !== this.getUrl(group)) this.queueGroupNotification(group) + debugLog(`[DelayManager] 设置测试URL,组: ${group}, URL: ${url}`) + this.urlMap.set(group, url) } getUrl(group: string) { @@ -181,16 +171,16 @@ class DelayManager { } setListener( - member: ResolvedProxyMember, + name: string, group: string, listener: (update: DelayUpdate) => void, ) { - const key = hashKey(member, group) + const key = hashKey(name, group) this.listenerMap.set(key, listener) } - removeListener(member: ResolvedProxyMember, group: string) { - const key = hashKey(member, group) + removeListener(name: string, group: string) { + const key = hashKey(name, group) this.listenerMap.delete(key) } @@ -209,14 +199,14 @@ class DelayManager { } setDelay( - member: ResolvedProxyMember, + name: string, group: string, delay: number, - meta?: { elapsed?: number; url?: string }, + meta?: { elapsed?: number }, ): DelayUpdate { - const key = hashKey(member, group) + const key = hashKey(name, group) debugLog( - `[DelayManager] 设置延迟,代理: ${member.ref.name}, 组: ${group}, 延迟: ${delay}`, + `[DelayManager] 设置延迟,代理: ${name}, 组: ${group}, 延迟: ${delay}`, ) const update: DelayUpdate = { delay, @@ -224,9 +214,7 @@ class DelayManager { updatedAt: Date.now(), } - const url = meta?.url ?? this.getUrl(group) - if (url !== this.getUrl(group)) return update - this.cache.set(key, { ...update, url }) + this.cache.set(key, update) const queue = this.pendingItemUpdates.get(key) if (queue) { @@ -239,16 +227,9 @@ class DelayManager { return update } - 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 } - } + getDelayUpdate(name: string, group: string) { + const key = hashKey(name, group) + const entry = this.cache.get(key) if (!entry) return undefined if (Date.now() - entry.updatedAt > CACHE_TTL) { @@ -259,25 +240,23 @@ class DelayManager { return { ...entry } } - getDelay(member: ResolvedProxyMember, group: string) { - const update = this.getDelayUpdate(member, group) + getDelay(name: string, group: string) { + const update = this.getDelayUpdate(name, 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 history = - this.getHistory(member, group) ?? memberDetails(member)?.history - const update = this.getDelayUpdate(member, group) + const details = memberDetails(member) + const name = member.ref.name + const update = this.getDelayUpdate(name, group) if (update && (update.delay >= 0 || update.delay === -2)) { return update.delay } - if (history?.length) return history[history.length - 1].delay || 1e6 + if (details?.history && details.history.length > 0) { + return details.history[details.history.length - 1].delay || 1e6 + } return -1 } @@ -319,12 +298,12 @@ class DelayManager { `[DelayManager] 开始测试延迟,代理: ${name}, 组: ${group}, 超时: ${timeout}ms`, ) - const url = this.getUrl(group) - this.setDelay(member, group, -2, { url }) + this.setDelay(name, group, -2) const startTime = Date.now() try { + const url = this.getUrl(group) debugLog(`[DelayManager] 调用API测试延迟,代理: ${name}, URL: ${url}`) const timeoutPromise = new Promise((resolve) => { @@ -345,14 +324,14 @@ class DelayManager { const elapsed = elapsedTime debugLog(`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`) - return this.setDelay(member, group, delay, { elapsed, url }) + return this.setDelay(name, group, delay, { elapsed }) } 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(member, group, delay, { elapsed, url }) + return this.setDelay(name, group, delay, { elapsed }) } } @@ -365,9 +344,10 @@ 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) - proxies.forEach((member) => { - this.setDelay(member, group, -2) + names.forEach((name) => { + this.setDelay(name, group, -2) }) let index = 0 @@ -379,7 +359,7 @@ class DelayManager { const currName = currMember.ref.name try { - this.setDelay(currMember, group, -2) + this.setDelay(currName, group, -2) // Stagger requests so a batch does not hit the core at once. if (index > 1) { @@ -395,13 +375,13 @@ class DelayManager { `[DelayManager] 批量测试单个代理出错,代理: ${currName}`, error, ) - this.setDelay(currMember, group, 1e6) + this.setDelay(currName, group, 1e6) } return help() } - const actualConcurrency = Math.min(concurrency, proxies.length, 10) + const actualConcurrency = Math.min(concurrency, names.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 deleted file mode 100644 index 4f2969c88..000000000 --- a/src/services/proxy-test-url.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -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', - type: null, - name: null, - file: null, - updated: null, - 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', - type: null, - name: null, - file: null, - updated: null, - 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', - type: null, - name: null, - file: null, - updated: null, - 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(null) - }), - ) - 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', - type: null, - name: null, - file: null, - updated: null, - 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 deleted file mode 100644 index 059b632fe..000000000 --- a/src/services/proxy-test-url.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { - getProfiles, - patchProfile, - syncTrayProxySelection, -} from '@/services/cmds' -import { mutate } from '@/services/mutate' - -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 || []) { - const uid = profile.uid - if (!uid) continue - if (profile.latency_test_urls !== undefined || migrating.has(uid)) continue - const urls = readLegacyTestUrls(uid) - if (!Object.keys(urls).length) continue - migrating.add(uid) - void saveProfileTestUrls(uid, urls, true) - .catch((error) => { - console.error('Failed to migrate latency test URLs:', error) - }) - .finally(() => migrating.delete(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( - (profile.uid ? pending.get(profile.uid) : undefined) ?? {}, - ).map(([group, edit]) => [group, edit.url]), - ), - } -} diff --git a/src/utils/network.ts b/src/utils/network.ts index 99db4a6f9..647efafc1 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -20,19 +20,6 @@ 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 => {