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