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 => {