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