Compare commits

..
Author SHA1 Message Date
Tunglies d492e37202 perf(tray): build menu in one main-thread dispatch
Menu APIs always execute on the main thread; called from a worker each
one pays a blocking round-trip (channel + event-loop hop + recv), so
rebuilding a proxy menu issued 200+ such round-trips in a row while the
caller waits, and the cost grows when the event loop is busy with
webview work.

Gather the async inputs (verge/clash/profiles snapshots, proxies and
runtime group order) on the caller's task, then construct every menu
item and attach it inside a single run_on_main_thread closure where
each call runs inline. refresh_proxy_items used the same shape on the
old base; the rebuild path now matches it. Measured on a standalone
tauri bench (5 groups x 40 nodes): a rebuild drops from ~6.2ms to
~1.6ms on an idle event loop and from ~11.4ms to ~1.6ms with the loop
busy (8ms work per 16ms frame).
2026-10-21 06:48:13 +08:00
wonfen 5e6a696087 revert(tray): revert per-group latency testing (#8173)
This reverts commit cb751052a4.

Remove the tray latency test and its dashboard URL persistence and cache
changes because the two entry points have diverging behavior.

Preserve the subsequent IPC binding migration and remove generated fields,
input projections, and locale entries that depended on the reverted feature.
2026-10-21 05:37:18 +08:00
43 changed files with 282 additions and 1384 deletions
Generated
+11 -34
View File
@@ -167,9 +167,9 @@ dependencies = [
[[package]]
name = "arc-swap"
version = "1.9.3"
version = "1.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e"
checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
dependencies = [
"rustversion",
]
@@ -1131,7 +1131,7 @@ dependencies = [
"regex",
"reqwest 0.13.5",
"reqwest_dav",
"rfd 0.17.2",
"rfd",
"runas",
"rustls",
"scopeguard",
@@ -1175,7 +1175,7 @@ dependencies = [
"windows-service",
"windows-sys 0.61.2",
"winreg 0.56.0",
"zip 9.0.5",
"zip 9.0.4",
]
[[package]]
@@ -5912,29 +5912,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "rfd"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220"
dependencies = [
"block2",
"dispatch2",
"js-sys",
"libc",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"percent-encoding",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -7442,7 +7419,7 @@ checksum = "daf9a5c92e39bdd84f22f6be9230e39d0c4e140345f71ffe6058dca4f71127cf"
dependencies = [
"log",
"raw-window-handle",
"rfd 0.16.0",
"rfd",
"serde",
"serde_json",
"tauri",
@@ -8099,9 +8076,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.54.0"
version = "1.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
dependencies = [
"bytes",
"libc",
@@ -9114,9 +9091,9 @@ dependencies = [
[[package]]
name = "webpki-roots"
version = "1.0.10"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
@@ -10176,9 +10153,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.0.5"
version = "9.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e"
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
dependencies = [
"aes",
"bzip2",
+1 -1
View File
@@ -67,7 +67,7 @@ parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.104"
chrono = "0.4.45"
criterion = { version = "0.8.2", features = ["async_tokio"] }
tokio = { version = "1.54.0", features = [
tokio = { version = "1.53.3", features = [
"rt-multi-thread",
"macros",
"time",
+1
View File
@@ -3,6 +3,7 @@
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
- 修复 首页 网站测速失败后卡片一直显示测试中
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
-2
View File
@@ -70,8 +70,6 @@ tray:
global: عام
profiles: الملفات التعريفية
proxies: وكلاء
testLatency: اختبار التأخير
testingLatency: جارٍ اختبار التأخير…
systemProxy: وكيل النظام
tunMode: وضع TUN
closeAllConnections: إغلاق كل الاتصالات
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Global
profiles: Profile
proxies: Proxy
testLatency: Latenz testen
testingLatency: Latenz wird getestet…
systemProxy: Systemproxy
tunMode: TUN-Modus
closeAllConnections: Alle Verbindungen schließen
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Global
profiles: Profiles
proxies: Proxies
testLatency: Test Latency
testingLatency: Testing latency…
systemProxy: System Proxy
tunMode: TUN Mode
closeAllConnections: Close All Connections
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Global
profiles: Perfiles
proxies: Proxies
testLatency: Probar latencia
testingLatency: Probando latencia…
systemProxy: Proxy del sistema
tunMode: Modo TUN
closeAllConnections: Cerrar todas las conexiones
-2
View File
@@ -70,8 +70,6 @@ tray:
global: سراسری
profiles: پروفایل‌ها
proxies: پروکسی‌ها
testLatency: آزمایش تأخیر
testingLatency: در حال آزمایش تأخیر…
systemProxy: پروکسی سیستم
tunMode: حالت TUN
closeAllConnections: بستن همه اتصال‌ها
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Global
profiles: Profil
proxies: Proksi
testLatency: Uji Latensi
testingLatency: Menguji latensi…
systemProxy: Proksi Sistem
tunMode: Mode TUN
closeAllConnections: Tutup Semua Koneksi
-2
View File
@@ -70,8 +70,6 @@ tray:
global: グローバル
profiles: プロファイル
proxies: プロキシ
testLatency: 遅延テスト
testingLatency: 遅延をテスト中…
systemProxy: システムプロキシ
tunMode: TUN モード
closeAllConnections: すべての接続を閉じる
-2
View File
@@ -70,8 +70,6 @@ tray:
global: 글로벌
profiles: 프로필
proxies: 프록시
testLatency: 지연 테스트
testingLatency: 지연 테스트 중…
systemProxy: 시스템 프록시
tunMode: TUN 모드
closeAllConnections: 모든 연결 닫기
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Global
profiles: Perfis
proxies: Proxies
testLatency: Test Latency
testingLatency: Testing latency…
systemProxy: Proxy do sistema
tunMode: Modo TUN
closeAllConnections: Fechar todas as conexões
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Глобальный
profiles: Профили
proxies: Прокси
testLatency: Проверить задержку
testingLatency: Проверка задержки…
systemProxy: Системный прокси
tunMode: Режим TUN
closeAllConnections: Закрыть все соединения
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Küresel
profiles: Profiller
proxies: Vekil'ler
testLatency: Gecikmeyi Test Et
testingLatency: Gecikme test ediliyor…
systemProxy: Sistem Vekil'i
tunMode: TUN Modu
closeAllConnections: Tüm Bağlantıları Kapat
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Глобаль
profiles: Профильләр
proxies: Проксилар
testLatency: Кичектерүне тикшерү
testingLatency: Кичектерү тикшерелә…
systemProxy: Системалы прокси
tunMode: TUN режимы
closeAllConnections: Барлык тоташуларны ябу
-2
View File
@@ -70,8 +70,6 @@ tray:
global: 全局
profiles: 订阅
proxies: 代理
testLatency: 测试延迟
testingLatency: 正在测试延迟…
systemProxy: 系统代理
tunMode: TUN 模式
closeAllConnections: 关闭所有连接
-2
View File
@@ -70,8 +70,6 @@ tray:
global: 全域
profiles: 訂閱
proxies: 代理
testLatency: 測試延遲
testingLatency: 正在測試延遲…
systemProxy: 系統代理
tunMode: 虛擬網路介面卡模式
closeAllConnections: 關閉所有連線
+4 -4
View File
@@ -59,7 +59,7 @@ bitflags = { workspace = true }
socket2 = { workspace = true }
warp = { version = "0.4.3", features = ["server"] }
open = "5.4.4"
rfd = { version = "0.17.2", default-features = false, features = ["common-controls-v6"] }
rfd = { version = "0.16.0", default-features = false, features = ["common-controls-v6"] }
dirs = "7.0.0"
dunce = "1.0.5"
nanoid = "0.5"
@@ -79,7 +79,7 @@ tauri-plugin-fs = "2.6.0"
tauri-plugin-process = "2.4.0"
tauri-plugin-deep-link = "2.6.1"
tauri-plugin-window-state = "2.5.0"
zip = "9.0.5"
zip = "9.0.4"
flate2 = "1.1.10"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" }
@@ -101,10 +101,10 @@ async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
"client",
] }
arc-swap = "1.9.3"
arc-swap = "1.9.2"
tokio-rustls = "0.26.6"
rustls = { version = "0.23.46", features = ["ring"] }
webpki-roots = "1.0.10"
webpki-roots = "1.0.9"
dark-light = "3.0.0"
bytes = "1.12.1"
tokio-util = { version = "0.7.20", features = ["time"] }
-3
View File
@@ -372,8 +372,6 @@ pub struct ProfilePatch {
#[specta(type = Option<std::string::String>)]
pub url: Option<String>,
pub selected: Option<Vec<crate::config::PrfSelected>>,
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
pub extra: Option<crate::config::PrfExtra>,
pub updated: Option<usize>,
pub option: Option<PrfOption>,
@@ -388,7 +386,6 @@ impl From<ProfilePatch> for PrfItem {
file: patch.file,
url: patch.url,
selected: patch.selected,
latency_test_urls: patch.latency_test_urls,
extra: patch.extra,
updated: patch.updated,
option: patch.option,
+2 -14
View File
@@ -108,32 +108,20 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope
#[tauri::command]
#[specta::specta]
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
proxy_view(None).await
}
pub(crate) async fn proxy_view(provider_timeout: Option<std::time::Duration>) -> CmdResult<ProxyViewV1> {
let runtime = Config::runtime().await;
let latest_runtime = runtime.latest_arc();
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref());
let mihomo = Handle::mihomo();
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), async {
match provider_timeout {
Some(timeout) => tokio::time::timeout(timeout, mihomo.get_proxy_providers())
.await
.ok()
.and_then(Result::ok),
None => mihomo.get_proxy_providers().await.ok(),
}
});
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),);
let proxies = proxies.stringify_err()?;
Ok(ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order,
group_scopes,
proxies,
providers,
providers: providers.ok(),
}))
}
-6
View File
@@ -53,10 +53,6 @@ pub struct PrfItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub selected: Option<Vec<PrfSelected>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<PrfExtra>,
@@ -233,7 +229,6 @@ impl PrfItem {
desc: Some(desc),
file: Some(file),
url: None,
latency_test_urls: None,
selected: None,
extra: None,
option: Some(PrfOption {
@@ -413,7 +408,6 @@ impl PrfItem {
desc: desc.cloned(),
file: Some(file),
url: Some(url.as_str().into()),
latency_test_urls: None,
selected: None,
extra,
option: Some(PrfOption {
-1
View File
@@ -250,7 +250,6 @@ impl IProfiles {
patch!(each, item, file);
patch!(each, item, url);
patch!(each, item, selected);
patch!(each, item, latency_test_urls);
patch!(each, item, extra);
patch!(each, item, updated);
patch!(each, item, option);
+6 -97
View File
@@ -1,8 +1,8 @@
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::collections::{BTreeMap, BTreeSet};
use serde::{Serialize, Serializer, ser::SerializeMap as _};
use serde::Serialize;
use tauri_plugin_mihomo::models::{
DelayHistory, Extra, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
};
pub struct ProxyViewInput {
@@ -57,28 +57,6 @@ pub struct ProxyCapabilities {
pub smux: bool,
}
#[derive(Serialize, specta::Type)]
struct ProxyLatencyHistory<'a> {
#[specta(type = Vec<crate::ipc::DelayHistory>)]
history: &'a [DelayHistory],
}
fn serialize_proxy_latency_history<S: Serializer>(
extra: &HashMap<String, Extra>,
serializer: S,
) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(extra.len()))?;
for (url, extra) in extra {
map.serialize_entry(
url,
&ProxyLatencyHistory {
history: &extra.history,
},
)?;
}
map.end()
}
#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ProxyGroupView {
@@ -98,9 +76,6 @@ pub struct ProxyGroupView {
pub test_url: Option<String>,
#[specta(type = Vec<crate::ipc::DelayHistory>)]
pub history: Vec<DelayHistory>,
#[serde(serialize_with = "serialize_proxy_latency_history")]
#[specta(type = HashMap<String, ProxyLatencyHistory<'static>>)]
pub extra: HashMap<String, Extra>,
#[serde(flatten)]
pub capabilities: ProxyCapabilities,
pub members: Vec<ProxyMemberRef>,
@@ -116,9 +91,6 @@ pub struct ProxyNodeView {
pub proxy_type: ProxyType,
#[specta(type = Vec<crate::ipc::DelayHistory>)]
pub history: Vec<DelayHistory>,
#[serde(serialize_with = "serialize_proxy_latency_history")]
#[specta(type = HashMap<String, ProxyLatencyHistory<'static>>)]
pub extra: HashMap<String, Extra>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -284,7 +256,7 @@ impl ProxyViewBuilder {
} else {
ProxyViewProviderState::Unavailable
};
let (mut core_groups, core_nodes) = partition_core(proxies, providers.is_some());
let (mut core_groups, core_nodes) = partition_core(proxies);
let (mut records, core_node_ids) = build_core_records(core_nodes);
let (providers, provider_candidates) = build_provider_records(providers, &mut records);
let resolver = MemberResolver {
@@ -317,14 +289,14 @@ impl ProxyViewBuilder {
}
}
fn partition_core(proxies: Proxies, providers_available: bool) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
fn partition_core(proxies: Proxies) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
let mut groups = BTreeMap::new();
let mut nodes = BTreeMap::new();
for (name, proxy) in proxies.proxies {
if proxy.all.is_some() {
groups.insert(name, proxy);
} else if !providers_available || proxy.provider_name.is_empty() {
} else {
nodes.insert(name, proxy);
}
}
@@ -431,7 +403,6 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
now,
test_url,
history,
extra,
udp,
xudp,
tfo,
@@ -464,7 +435,6 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
icon,
test_url,
history,
extra,
capabilities: ProxyCapabilities {
udp,
xudp,
@@ -483,7 +453,6 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
icon,
test_url,
history,
extra,
udp,
xudp,
tfo,
@@ -498,7 +467,6 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
name,
proxy_type,
history,
extra,
id,
hidden,
icon,
@@ -552,62 +520,3 @@ fn build_standalone(core_node_ids: &BTreeMap<String, String>) -> Vec<String> {
);
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(())
}
}
-156
View File
@@ -1,156 +0,0 @@
use super::Tray;
use crate::{
Type, cmd,
config::Config,
core::{
handle,
notify::{Refresh, announce},
proxy_view::{ProxyMemberRef, ProxyNodeSource},
},
logging,
};
const TRAY_DELAY_TEST_CONCURRENCY: usize = 10;
const TRAY_DEFAULT_LATENCY_URL: &str = "http://cp.cloudflare.com/generate_204";
fn is_latency_test_url(raw: &str) -> bool {
let raw = raw.trim();
raw.split_once("://")
.is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"))
&& tauri::Url::parse(raw).is_ok_and(|url| url.has_host() && matches!(url.scheme(), "http" | "https"))
}
pub(super) fn test_url(
group: &crate::core::proxy_view::ProxyGroupView,
verge: &crate::config::IVerge,
profiles: &crate::config::IProfiles,
) -> String {
let custom_url = profiles
.current
.as_ref()
.and_then(|uid| profiles.get_item(uid).ok())
.and_then(|profile| profile.latency_test_urls.as_ref())
.and_then(|urls| urls.get(group.name.as_str()))
.map(|url| url.as_str())
.filter(|url| !url.trim().is_empty());
let group_url = group.test_url.as_deref().filter(|url| !url.trim().is_empty());
let default_url = verge
.default_latency_test
.as_deref()
.filter(|url| !url.trim().is_empty());
custom_url
.or(group_url)
.or(default_url)
.filter(|&url| is_latency_test_url(url))
.unwrap_or(TRAY_DEFAULT_LATENCY_URL)
.trim()
.to_owned()
}
static RUNNING: parking_lot::Mutex<std::collections::BTreeSet<String>> =
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<std::string::String>)> = 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}");
}
}
}
-1
View File
@@ -32,7 +32,6 @@ define_menu! {
outbound_modes => OUTBOUND_MODES, "tray_outbound_modes", "tray.outboundModes",
profiles => PROFILES, "tray_profiles", "tray.profiles",
proxies => PROXIES, "tray_proxies", "tray.proxies",
test_latency => TEST_LATENCY, "tray_test_latency", "tray.testLatency",
system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy",
tun_mode => TUN_MODE, "tray_tun_mode", "tray.tunMode",
close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "tray.closeAllConnections",
+173 -386
View File
@@ -1,6 +1,5 @@
use crate::config::{IProfilePreview, IVerge};
use crate::config::{IProfilePreview, IProfiles, IVerge};
use crate::core::lightweight;
use crate::core::proxy_view::{ProxyGroupView, ProxyMemberRef, ProxyNodeSource, ProxyViewProviderState, ProxyViewV1};
use crate::core::tray::menu_def::TrayAction;
use crate::process::AsyncHandler;
use crate::singleton;
@@ -15,6 +14,7 @@ use crate::{
use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter};
use clash_verge_logging::logging_error;
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
use tauri_plugin_mihomo::models::Proxies;
use tokio::fs;
use super::handle;
@@ -28,7 +28,6 @@ use tauri::{
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
};
mod latency;
mod menu_def;
#[cfg(target_os = "macos")]
mod speed_task;
@@ -42,7 +41,10 @@ const TRAY_CLICK_DEBOUNCE_MS: u64 = 300;
pub const TRAY_ID: &str = "clash-verge-rev-tray";
#[derive(Clone, Copy)]
struct TrayMenuOptions {
struct TrayMenuOptions<'a> {
mode: Option<&'a str>,
system_proxy_enabled: bool,
tun_mode_enabled: bool,
is_lightweight_mode: bool,
include_proxy_groups: bool,
}
@@ -56,25 +58,8 @@ enum IconKind {
Tun,
}
struct LatencyMenuItem {
item: MenuItem<Wry>,
running: bool,
proxies: Vec<ProxyLatencyItem>,
provider_state: ProxyViewProviderState,
}
struct ProxyLatencyItem {
item: CheckMenuItem<Wry>,
name: std::string::String,
provider_name: Option<std::string::String>,
text: std::string::String,
}
pub struct Tray {
limiter: SystemLimiter,
proxy_hover_limiter: SystemLimiter,
menu_update: tokio::sync::Mutex<()>,
latency_items: parking_lot::Mutex<HashMap<std::string::String, LatencyMenuItem>>,
#[cfg(target_os = "macos")]
speed_controller: speed_task::TraySpeedController,
}
@@ -148,9 +133,6 @@ impl Default for Tray {
fn default() -> Self {
Self {
limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock),
proxy_hover_limiter: Limiter::new(Duration::from_secs(1), SystemClock),
menu_update: tokio::sync::Mutex::new(()),
latency_items: parking_lot::Mutex::new(HashMap::new()),
#[cfg(target_os = "macos")]
speed_controller: speed_task::TraySpeedController::new(),
}
@@ -216,16 +198,14 @@ 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(());
};
let verge = Config::verge().await.latest_arc();
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
let tun_mode_available = crate::core::runstate::RUN_STATE.state().tun_capable();
let system_proxy = verge.enable_system_proxy.unwrap_or(false);
let tun_mode = verge.enable_tun_mode.unwrap_or(false);
let mode = {
Config::clash()
.await
@@ -236,138 +216,78 @@ impl Tray {
.unwrap_or("rule")
.to_owned()
};
let profiles_config = Config::profiles().await;
let profiles_arc = profiles_config.latest_arc();
let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default();
let is_lightweight_mode = is_in_lightweight_mode();
let (menu, latency_items) = create_tray_menu(
app_handle,
Some(mode.as_str()),
*system_proxy,
*tun_mode,
tun_mode_available,
profiles_preview,
TrayMenuOptions {
is_lightweight_mode,
include_proxy_groups,
},
)
.await?;
tray.set_menu(Some(menu))?;
let groups: Vec<_> = latency_items.keys().cloned().collect();
*self.latency_items.lock() = latency_items;
for group in groups {
self.refresh_latency_item(&group);
}
let fetch_proxy_groups =
include_proxy_groups && verge.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::<HashMap<String, usize>>()
})
.unwrap_or_default()
});
(proxy_nodes_data, runtime_proxy_groups_order)
} else {
(None, None)
};
let verge = if fetch_proxy_groups {
Config::verge().await.latest_arc()
} else {
verge
};
let profiles = Config::profiles().await.latest_arc();
let (tx, rx) = tokio::sync::oneshot::channel();
let dispatch_handle = app_handle.clone();
app_handle
.run_on_main_thread(move || {
let _ = tx.send((|| -> Result<()> {
let menu = build_tray_menu(
&dispatch_handle,
TrayMenuOptions {
mode: Some(mode.as_str()),
system_proxy_enabled: system_proxy,
tun_mode_enabled: tun_mode,
is_lightweight_mode,
include_proxy_groups,
},
&verge,
&profiles,
runtime_proxy_groups_order,
proxy_nodes_data,
)?;
logging_error!(Type::Tray, tray.set_menu(Some(menu)));
Ok(())
})());
})
.map_err(|e| anyhow::anyhow!("Failed to dispatch tray menu update: {e}"))?;
rx.await
.map_err(|_| anyhow::anyhow!("Tray menu update returned no result"))??;
logging!(debug, Type::Tray, "托盘菜单更新成功");
Ok(())
}
async fn refresh_proxy_items(&'static self) {
let Ok(update) = self.menu_update.try_lock() else {
return;
};
if self.latency_items.lock().is_empty() || handle::Handle::global().is_exiting() {
return;
}
let Ok(Ok(view)) = tokio::time::timeout(
Duration::from_secs(1),
cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
)
.await
else {
return;
};
if self
.latency_items
.lock()
.values()
.any(|entry| entry.provider_state != view.provider_state)
{
// Provider fallback changes member identities; rebuild through the existing queue.
drop(update);
logging_error!(Type::Tray, cmd::proxy::sync_tray_proxy_selection().await);
return;
}
let verge = Config::verge().await.latest_arc();
let profiles = Config::profiles().await.latest_arc();
logging_error!(
Type::Tray,
handle::Handle::app_handle().run_on_main_thread(move || {
// Keep rebuilding serialized until these handles have received their snapshot.
let _update = update;
let mut items = self.latency_items.lock();
for (group_name, entry) in items.iter_mut() {
let Some(group) = find_group(&view, group_name) else {
continue;
};
let url = latency::test_url(group, &verge, &profiles);
let mut members = HashMap::with_capacity(group.members.len());
for member in &group.members {
members
.entry((member_name(member), member_provider(&view, member)))
.or_insert(member);
}
for proxy in &mut entry.proxies {
let Some(member) = members.get(&(proxy.name.as_str(), proxy.provider_name.as_deref())) else {
continue;
};
let text = proxy_item_text(&view, member, &url, &verge);
let checked = group.now.as_ref() == Some(&proxy.name);
if text != proxy.text {
match proxy.item.set_text(&text) {
Ok(()) => proxy.text = text,
Err(err) => logging!(warn, Type::Tray, "Failed to refresh proxy latency: {err}"),
}
}
match proxy.item.is_checked() {
Ok(current) if current != checked => {
logging_error!(Type::Tray, proxy.item.set_checked(checked))
}
Err(err) => logging!(warn, Type::Tray, "Failed to read proxy selection: {err}"),
_ => {}
}
}
}
})
);
}
fn refresh_latency_item(&self, group_name: &str) {
let group_name = group_name.to_owned();
logging_error!(
Type::Tray,
handle::Handle::app_handle().run_on_main_thread(move || {
Self::global().refresh_latency_item_on_main_thread(&group_name);
})
);
}
fn refresh_latency_item_on_main_thread(&self, group_name: &str) {
let running = latency::is_running(group_name);
let item = {
let mut items = self.latency_items.lock();
items.get_mut(group_name).and_then(|entry| {
if entry.running == running {
return None;
}
entry.running = running;
Some(entry.item.clone())
})
};
if let Some(item) = item {
let label = if running {
clash_verge_i18n::t!("tray.testingLatency")
} else {
clash_verge_i18n::t!("tray.testLatency")
};
logging_error!(Type::Tray, item.set_text(label));
logging_error!(Type::Tray, item.set_enabled(!running));
}
}
pub async fn update_icon(&self, verge: &IVerge) -> Result<()> {
if handle::Handle::global().is_exiting() {
logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新");
@@ -583,175 +503,96 @@ fn create_profile_menu_item(
.collect()
}
const UNTESTED_DELAY_TEXT: &str = "-ms";
const TIMEOUT_DELAY_TEXT: &str = "T/O";
const TRAY_GROUP_DEPTH_LIMIT: usize = 8;
const fn member_name(member: &ProxyMemberRef) -> &str {
match member {
ProxyMemberRef::Node { name, .. }
| ProxyMemberRef::Group { name }
| ProxyMemberRef::Unresolved { name, .. } => name.as_str(),
}
}
fn find_group<'a>(view: &'a ProxyViewV1, name: &str) -> Option<&'a ProxyGroupView> {
view.groups
.iter()
.chain(view.global.iter())
.find(|group| group.name.as_str() == name)
}
/// The delay recorded for `url`, falling back to the node's latest measurement under any URL, so a
/// node the core tested on its own still shows a value.
fn node_delay(view: &ProxyViewV1, record_id: &str, url: &str) -> Option<u16> {
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<u16> {
let mut current = group_name;
for _ in 0..TRAY_GROUP_DEPTH_LIMIT {
let Some(group) = find_group(view, current) else { break };
let Some(now) = group.now.as_deref().filter(|now| !now.is_empty() && *now != current) else {
break;
};
let Some(member) = group.members.iter().find(|member| member_name(member) == now) else {
break;
};
match member {
ProxyMemberRef::Node { record_id, .. } => return node_delay(view, record_id, url),
ProxyMemberRef::Group { name } => current = name.as_str(),
ProxyMemberRef::Unresolved { .. } => break,
}
}
find_group(view, group_name)
.map(|group| group.extra.get(url).map_or(&group.history, |extra| &extra.history))
.and_then(|history| history.last())
.map(|history| history.delay)
}
fn member_provider<'a>(view: &'a ProxyViewV1, member: &ProxyMemberRef) -> Option<&'a str> {
let ProxyMemberRef::Node { record_id, .. } = member else {
return None;
};
match &view.records.get(record_id)?.source {
ProxyNodeSource::Provider { provider_name, .. } => Some(provider_name),
ProxyNodeSource::Core { .. } => None,
}
}
fn proxy_item_text(view: &ProxyViewV1, member: &ProxyMemberRef, url: &str, verge: &IVerge) -> std::string::String {
let timeout = verge
.default_latency_timeout
.filter(|timeout| *timeout > 0)
.unwrap_or(10000);
let delay = match member {
ProxyMemberRef::Node { record_id, .. } => node_delay(view, record_id, url),
ProxyMemberRef::Group { name } => selected_member_delay(view, name, url),
ProxyMemberRef::Unresolved { .. } => None,
};
let delay_text = delay.map_or_else(
|| UNTESTED_DELAY_TEXT.to_owned(),
|delay| {
if delay == 0 || u32::from(delay) >= timeout as u32 {
TIMEOUT_DELAY_TEXT.to_owned()
} else {
format!("{delay}ms")
}
},
);
format!("{} | {delay_text}", member_name(member))
}
fn create_subcreate_proxy_menu_item(
app_handle: &AppHandle,
proxy_mode: &str,
view: Option<ProxyViewV1>,
latency_items: &mut HashMap<std::string::String, LatencyMenuItem>,
texts: &MenuTexts,
verge: &IVerge,
profiles: &crate::config::IProfiles,
proxy_group_order_map: Option<HashMap<String, usize>>,
proxy_nodes_data: Option<Proxies>,
) -> Vec<Submenu<Wry>> {
let Some(view) = view else { return Vec::new() };
view.groups
.iter()
.chain(view.global.iter())
.filter_map(|group| {
if group.hidden.unwrap_or_default() || (proxy_mode == "global") != (group.name == "GLOBAL") {
return None;
}
let url = latency::test_url(group, verge, profiles);
let proxies: Vec<ProxyLatencyItem> = group
.members
.iter()
.filter_map(|member| {
let name = member_name(member);
let text = proxy_item_text(&view, member, &url, verge);
let checked = group.now.as_deref() == Some(name);
CheckMenuItem::with_id(
app_handle,
format!("proxy_{}_{}", group.name, name),
&text,
true,
checked,
None::<&str>,
)
.map(|item| ProxyLatencyItem {
item,
name: name.to_owned(),
provider_name: member_provider(&view, member).map(str::to_owned),
text,
let proxy_submenus: Vec<Submenu<Wry>> = {
let mut submenus: Vec<(String, usize, Submenu<Wry>)> = 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<CheckMenuItem<Wry>> = all_proxies
.iter()
.filter_map(|proxy_str| {
let is_selected = *proxy_str == now_proxy;
let item_id = format!("proxy_{}_{}", group_name, proxy_str);
let delay_text = proxy_nodes_data
.proxies
.get(proxy_str)
.and_then(|h| h.history.last())
.map(|h| match h.delay {
0 => "-ms".into(),
delay if delay >= 10000 => "-ms".into(),
_ => format!("{}ms", h.delay),
})
.unwrap_or_else(|| "-ms".into());
let display_text = format!("{} | {}", proxy_str, delay_text);
CheckMenuItem::with_id(app_handle, item_id, display_text, true, is_selected, None::<&str>)
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {}", e))
.ok()
})
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {e}"))
.ok()
})
.collect();
let group_items: Vec<_> = proxies.iter().map(|proxy| proxy.item.clone()).collect();
if group_items.is_empty() {
return None;
}
let running = latency::is_running(&group.name);
let test_item = MenuItem::with_id(
app_handle,
format!("{}_{}", MenuIds::TEST_LATENCY, group.name),
if running {
clash_verge_i18n::t!("tray.testingLatency")
.collect();
if group_items.is_empty() {
continue;
}
let group_display_name = group_name.to_string();
let group_items_refs: Vec<&dyn IsMenuItem<Wry>> =
group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>).collect();
if let Ok(submenu) = Submenu::with_id_and_items(
app_handle,
format!("proxy_group_{}", group_name),
group_display_name,
true,
&group_items_refs,
) {
let insertion_index = submenus.len();
submenus.push((group_name.into(), insertion_index, submenu));
} else {
texts.test_latency.clone()
},
!running,
None::<&str>,
)
.ok()?;
latency_items.insert(
group.name.clone(),
LatencyMenuItem {
item: test_item.clone(),
running,
proxies,
provider_state: view.provider_state,
},
);
let separator = PredefinedMenuItem::separator(app_handle).ok()?;
let mut items: Vec<&dyn IsMenuItem<Wry>> = vec![&test_item, &separator];
items.extend(group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>));
Submenu::with_id_and_items(
app_handle,
format!("proxy_group_{}", group.name),
&group.name,
true,
&items,
)
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy group submenu: {e}"))
.ok()
})
.collect()
logging!(warn, Type::Tray, "Failed to create proxy group submenu: {}", group_name);
}
}
}
if let Some(order_map) = proxy_group_order_map.as_ref() {
submenus.sort_by(|(name_a, original_index_a, _), (name_b, original_index_b, _)| {
match (order_map.get(name_a), order_map.get(name_b)) {
(Some(index_a), Some(index_b)) => index_a.cmp(index_b),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => original_index_a.cmp(original_index_b),
}
});
}
submenus.into_iter().map(|(_, _, submenu)| submenu).collect()
};
proxy_submenus
}
fn create_proxy_menu_item(
@@ -790,39 +631,16 @@ fn create_proxy_menu_item(
Ok((proxies_submenu, inline_proxy_items))
}
async fn create_tray_menu(
fn build_tray_menu(
app_handle: &AppHandle,
mode: Option<&str>,
system_proxy_enabled: bool,
tun_mode_enabled: bool,
tun_mode_available: bool,
profiles_preview: Vec<IProfilePreview<'_>>,
options: TrayMenuOptions,
) -> Result<(tauri::menu::Menu<Wry>, HashMap<std::string::String, LatencyMenuItem>)> {
let current_proxy_mode = mode.unwrap_or("");
let mut verge_settings = Config::verge().await.latest_arc();
let fetch_proxy_groups =
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable");
// Proxy data must remain optional so a stopped Core cannot roll back unrelated settings.
let proxy_view = if fetch_proxy_groups {
tokio::time::timeout(
Duration::from_millis(1000),
cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
)
.await
.ok()
.and_then(Result::ok)
} else {
None
};
if fetch_proxy_groups {
verge_settings = Config::verge().await.latest_arc();
}
let profiles_config = Config::profiles().await.latest_arc();
options: TrayMenuOptions<'_>,
verge_settings: &IVerge,
profiles_config: &IProfiles,
proxy_group_order_map: Option<HashMap<String, usize>>,
proxy_nodes_data: Option<Proxies>,
) -> Result<tauri::menu::Menu<Wry>> {
let current_proxy_mode = options.mode.unwrap_or("");
let tun_mode_available = crate::core::runstate::RUN_STATE.state().tun_capable();
let tray_proxy_groups_display_mode = verge_settings
.tray_proxy_groups_display_mode
@@ -836,7 +654,8 @@ async fn create_tray_menu(
let hotkeys = create_hotkeys(&verge_settings.hotkeys);
let profile_menu_items: Vec<CheckMenuItem<Wry>> = create_profile_menu_item(app_handle, profiles_preview)?;
let profile_menu_items: Vec<CheckMenuItem<Wry>> =
create_profile_menu_item(app_handle, profiles_config.profiles_preview().unwrap_or_default())?;
let texts = MenuTexts::new();
let profile_menu_items_refs: Vec<&dyn IsMenuItem<Wry>> = profile_menu_items
@@ -909,17 +728,9 @@ async fn create_tray_menu(
&profile_menu_items_refs,
)?;
let mut latency_items = HashMap::new();
let (proxies_menu, inline_proxy_items) = if include_proxy_groups {
let proxy_sub_menus = create_subcreate_proxy_menu_item(
app_handle,
current_proxy_mode,
proxy_view,
&mut latency_items,
&texts,
&verge_settings,
&profiles_config,
);
let proxy_sub_menus =
create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data);
match tray_proxy_groups_display_mode {
"default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?,
@@ -935,7 +746,7 @@ async fn create_tray_menu(
MenuIds::SYSTEM_PROXY,
&texts.system_proxy,
true,
system_proxy_enabled,
options.system_proxy_enabled,
hotkeys.get("toggle_system_proxy").copied(),
)?;
@@ -944,7 +755,7 @@ async fn create_tray_menu(
MenuIds::TUN_MODE,
&texts.tun_mode,
tun_mode_available,
tun_mode_enabled,
options.tun_mode_enabled,
hotkeys.get("toggle_tun_mode").copied(),
)?;
@@ -1063,25 +874,10 @@ async fn create_tray_menu(
]);
let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?;
Ok((menu, latency_items))
Ok(menu)
}
fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) {
let refresh_proxies = match tray_event {
TrayIconEvent::Enter { .. } => Tray::global().proxy_hover_limiter.check(),
TrayIconEvent::Click {
button: MouseButton::Left | MouseButton::Right,
button_state: MouseButtonState::Down,
..
} => true,
_ => false,
};
if refresh_proxies {
AsyncHandler::spawn(|| async {
Tray::global().refresh_proxy_items().await;
});
}
if matches!(
tray_event,
TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. }
@@ -1203,15 +999,6 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
};
feat::toggle_proxy_profile(profile_index.into()).await;
}
id if id.starts_with(MenuIds::TEST_LATENCY) => {
let group_name = id
.strip_prefix(MenuIds::TEST_LATENCY)
.and_then(|rest| rest.strip_prefix('_'));
if let Some(group_name) = group_name {
logging!(info, Type::Tray, "Tray menu: test group latency {}", group_name);
latency::test_proxy_group_delay(group_name).await;
}
}
id if id.starts_with("proxy_") => {
let rest = match id.strip_prefix("proxy_") {
Some(r) => r,
@@ -47,10 +47,7 @@ import {
useCoreDataStatus,
useProxiesData,
} from '@/providers/app-data-context'
import { syncTrayProxySelection } from '@/services/cmds'
import delayManager from '@/services/delay'
import { mutate } from '@/services/mutate'
import { profileTestUrls } from '@/services/proxy-test-url'
import {
findCurrentGroupMember,
getRecord,
@@ -678,20 +675,6 @@ export const CurrentProxyCard = () => {
[proxyView],
)
useEffect(() => {
if (!selectedGroup) return
const customUrl = currentProfile
? profileTestUrls(currentProfile)[selectedGroup.name]
: undefined
delayManager.setUrl(
selectedGroup.name,
customUrl?.trim() ||
selectedGroup.testUrl?.trim() ||
verge?.default_latency_test?.trim() ||
'http://cp.cloudflare.com/generate_204',
)
}, [selectedGroup, currentProfile, verge?.default_latency_test])
const unsortedProxyOptions = useMemo(
() => optionsForGroup(selectedGroup),
[optionsForGroup, selectedGroup],
@@ -924,12 +907,6 @@ export const CurrentProxyCard = () => {
}
refreshProxy()
mutate(() => syncTrayProxySelection(), {
id: 'sync-tray-selection',
errorNotice: false,
}).catch((error) => {
console.error('[CurrentProxyCard] Failed to sync tray latency:', error)
})
})
const proxyOptions = useMemo(
+6 -11
View File
@@ -9,7 +9,7 @@ import VisibilityOffRounded from '@mui/icons-material/VisibilityOffRounded'
import VisibilityRounded from '@mui/icons-material/VisibilityRounded'
import WifiTetheringOffRounded from '@mui/icons-material/WifiTetheringOffRounded'
import WifiTetheringRounded from '@mui/icons-material/WifiTetheringRounded'
import { Box, IconButton, type SxProps } from '@mui/material'
import { Box, IconButton, type SxProps, TextField } from '@mui/material'
import { useDebounceFn } from 'ahooks'
import { memo, useEffect, useRef } from 'react'
import { flushSync } from 'react-dom'
@@ -18,11 +18,10 @@ import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service'
import { isValidLatencyTestUrl } from '@/utils/network'
import { isValidUrl } from '@/utils/network'
import { BaseSearchBox, type SearchState } from '../base'
import { ProxyTestUrlInput } from './proxy-test-url-input'
import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state'
@@ -70,10 +69,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
delayManager.setUrl(
groupName,
testUrl?.trim() || url?.trim() || defaultLatencyUrl,
)
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)
}, [groupName, testUrl, defaultLatencyUrl, url])
// 过滤输入是高频操作,且每次都会触发整组代理的重新过滤/排序与虚拟列表重渲染,
@@ -116,8 +112,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
)}
{textState === 'url' && (
<ProxyTestUrlInput
name={groupName}
<TextField
inputRef={inputRef}
autoComplete="new-password"
hiddenLabel
@@ -130,7 +125,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
e.preventDefault()
e.stopPropagation()
}}
onCommit={(testUrl) => onHeadState({ testUrl })}
onChange={(e) => onHeadState({ testUrl: e.target.value })}
sx={{ flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
/>
)}
@@ -181,7 +176,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
if (testUrl?.trim() && textState !== 'filter') {
onHeadState({ textState: 'url' })
}
if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) {
if (testUrl?.trim() && !isValidUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
-8
View File
@@ -23,9 +23,7 @@ import { useProfiles } from '@/hooks/use-profiles'
import { useProxySelection } from '@/hooks/use-proxy-selection'
import { useVerge } from '@/hooks/use-verge'
import { useProxiesData, useSystemData } from '@/providers/app-data-context'
import { syncTrayProxySelection } from '@/services/cmds'
import delayManager from '@/services/delay'
import { mutate } from '@/services/mutate'
import {
isInteractableMember,
resolveMember,
@@ -145,12 +143,6 @@ function useProxyRenderState(
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error)
} finally {
onProxies()
mutate(() => syncTrayProxySelection(), {
id: 'sync-tray-selection',
errorNotice: false,
}).catch((error) => {
console.error('[ProxyGroups] Failed to sync tray latency:', error)
})
}
}),
)
+6 -11
View File
@@ -11,7 +11,7 @@ import {
SortByAlphaRounded,
SortRounded,
} from '@mui/icons-material'
import { Box, IconButton, type SxProps } from '@mui/material'
import { Box, IconButton, TextField, type SxProps } from '@mui/material'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -20,9 +20,8 @@ import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service'
import { debugLog } from '@/utils/debug'
import { isValidLatencyTestUrl } from '@/utils/network'
import { isValidUrl } from '@/utils/network'
import { ProxyTestUrlInput } from './proxy-test-url-input'
import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state'
@@ -73,10 +72,7 @@ export const ProxyHead = ({
'http://cp.cloudflare.com/generate_204'
useEffect(() => {
delayManager.setUrl(
groupName,
testUrl?.trim() || url?.trim() || defaultLatencyUrl,
)
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)
}, [groupName, testUrl, defaultLatencyUrl, url])
return (
@@ -101,7 +97,7 @@ export const ProxyHead = ({
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
onHeadState({ textState: 'url' })
}
if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) {
if (testUrl?.trim() && !isValidUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
@@ -192,8 +188,7 @@ export const ProxyHead = ({
)}
{textState === 'url' && (
<ProxyTestUrlInput
name={groupName}
<TextField
autoComplete="new-password"
autoFocus={autoFocus}
hiddenLabel
@@ -202,7 +197,7 @@ export const ProxyHead = ({
size="small"
variant="outlined"
placeholder={t('proxies.page.placeholders.delayCheckUrl')}
onCommit={(testUrl) => onHeadState({ testUrl })}
onChange={(e) => onHeadState({ testUrl: e.target.value })}
sx={{ ml: 0.5, flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
/>
)}
@@ -1,55 +0,0 @@
import { TextField, type TextFieldProps } from '@mui/material'
import { useEffect, useEffectEvent, useRef, useState } from 'react'
import { useProfiles } from '@/hooks/use-profiles'
type Props = Omit<TextFieldProps, 'value' | 'onChange'> & {
value: string
onCommit: (url: string) => void
}
export const ProxyTestUrlInput = ({ value, name, ...props }: Props) => {
const { profiles } = useProfiles()
return (
<DraftTestUrlInput
key={JSON.stringify([profiles?.current, name])}
name={name}
value={value}
{...props}
/>
)
}
const DraftTestUrlInput = ({ value, onCommit, ...props }: Props) => {
const [draft, setDraft] = useState<string | null>(null)
const pendingRef = useRef<string | null>(null)
const commitOnUnmount = useEffectEvent(() => {
if (pendingRef.current !== null && pendingRef.current !== value)
onCommit(pendingRef.current)
})
useEffect(() => () => commitOnUnmount(), [])
return (
<TextField
{...props}
value={draft ?? value}
onChange={(event) => {
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()
}
}}
/>
)
}
+4 -12
View File
@@ -13,17 +13,11 @@ const node = (memberIndex: number, delay: number, name = `${memberIndex}`) =>
kind: 'node',
ref: { kind: 'node', name, recordId: `${memberIndex}` },
node: {
recordId: `${memberIndex}`,
history: [{ delay }],
extra: {
'http://cp.cloudflare.com/generate_204': {
history: [{ time: new Date(0).toISOString(), delay }],
},
},
source: { kind: 'provider', providerName: `${memberIndex}` },
},
},
}) as unknown as ResolvedMemberOccurrence
}) as ResolvedMemberOccurrence
afterEach(() => vi.restoreAllMocks())
@@ -42,9 +36,7 @@ test('matches the previous comparator for cached, fallback and sentinel delays',
for (const cached of [false, true]) {
const group = `sort-${cached}`
if (cached) {
values.forEach((delay, i) =>
delayManager.setDelay(list[i].member, group, delay),
)
values.forEach((delay, i) => delayManager.setDelay(`${i}`, group, delay))
}
for (const timeout of [10000, 20, 0, NaN]) {
const expected = list
@@ -74,7 +66,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne
node(3, 25),
]
const cachedOrder = [list[0], list[2], list[1], list[3]]
delayManager.setDelay(list[0].member, 'expiry', 1)
delayManager.setDelay('0', 'expiry', 1)
const get = vi.spyOn(delayManager, 'getDelayFix')
expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder)
expect(get).toHaveBeenCalledTimes(list.length)
@@ -88,7 +80,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne
list[3],
list[0],
])
delayManager.setDelay(list[0].member, 'expiry', 2)
delayManager.setDelay('0', 'expiry', 2)
expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder)
get.mockClear()
filterSort([], 'expiry', '', 1)
+18 -60
View File
@@ -1,10 +1,6 @@
import { useCallback, useEffect, useReducer } from 'react'
import { useProfiles } from '@/hooks/use-profiles'
import {
profileTestUrls,
commitProfileTestUrl,
} from '@/services/proxy-test-url'
import { ProxySortType } from './use-filter-sort'
@@ -36,45 +32,22 @@ export const DEFAULT_STATE: HeadState = {
}
type HeadStateAction =
| { type: 'reset'; profile: string }
| { type: 'replace'; profile: string; payload: Record<string, HeadState> }
| { type: 'reset' }
| { type: 'replace'; payload: Record<string, HeadState> }
| { type: 'update'; groupName: string; patch: Partial<HeadState> }
| { type: 'urls'; urls: Record<string, string> }
interface LoadedHeadState {
profile: string | null
heads: Record<string, HeadState>
}
function headStateReducer(
state: LoadedHeadState,
state: Record<string, HeadState>,
action: HeadStateAction,
): LoadedHeadState {
): Record<string, HeadState> {
switch (action.type) {
case 'reset':
return { profile: action.profile, heads: {} }
return {}
case 'replace':
return { profile: action.profile, heads: action.payload }
case 'urls': {
let heads = state.heads
for (const [name, testUrl] of Object.entries(action.urls)) {
if (heads[name]?.testUrl === testUrl) continue
heads = {
...heads,
[name]: { ...DEFAULT_STATE, ...heads[name], testUrl },
}
}
return heads === state.heads ? state : { ...state, heads }
}
return action.payload
case 'update': {
const prev = state.heads[action.groupName] || DEFAULT_STATE
return {
...state,
heads: {
...state.heads,
[action.groupName]: { ...prev, ...action.patch },
},
}
const prev = state[action.groupName] || DEFAULT_STATE
return { ...state, [action.groupName]: { ...prev, ...action.patch } }
}
default:
return state
@@ -82,42 +55,30 @@ function headStateReducer(
}
export function useHeadStateNew() {
const { profiles, current: profile } = useProfiles()
const { profiles } = useProfiles()
const current = profiles?.current || ''
const [{ profile: loadedProfile, heads: state }, dispatch] = useReducer(
headStateReducer,
{ profile: null, heads: {} },
)
const [state, dispatch] = useReducer(headStateReducer, {})
useEffect(() => {
const urls = profile ? profileTestUrls(profile) : {}
if (loadedProfile === current) {
dispatch({ type: 'urls', urls })
return
}
try {
const data = JSON.parse(
localStorage.getItem(HEAD_STATE_KEY) || '{}',
localStorage.getItem(HEAD_STATE_KEY)!,
) as HeadStateStorage
const value = data?.[current] || {}
for (const [name, testUrl] of Object.entries(urls)) {
value[name] = { ...DEFAULT_STATE, ...value[name], testUrl }
}
const value = data[current] || {}
if (value && typeof value === 'object') {
dispatch({ type: 'replace', profile: current, payload: value })
dispatch({ type: 'replace', payload: value })
} else {
dispatch({ type: 'reset', profile: current })
dispatch({ type: 'reset' })
}
} catch {
dispatch({ type: 'reset', profile: current })
dispatch({ type: 'reset' })
}
}, [current, loadedProfile, profile])
}, [current])
useEffect(() => {
if (loadedProfile !== current) return
const timer = setTimeout(() => {
try {
const item = localStorage.getItem(HEAD_STATE_KEY)
@@ -133,16 +94,13 @@ export function useHeadStateNew() {
})
return () => clearTimeout(timer)
}, [state, current, loadedProfile])
}, [state, current])
const setHeadState = useCallback(
(groupName: string, obj: Partial<HeadState>) => {
dispatch({ type: 'update', groupName, patch: obj })
if (current && obj.testUrl !== undefined) {
void commitProfileTestUrl(current, groupName, obj.testUrl)
}
},
[current],
[],
)
return [state, setHeadState] as const
-1
View File
@@ -116,7 +116,6 @@ const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({
mptcp: false,
smux: false,
history: [],
extra: {},
members,
})
+1 -6
View File
@@ -1,9 +1,8 @@
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useRef } from 'react'
import type { ProfilePatch, ProfileSelection } from '@/services/bindings'
import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { migrateProfileTestUrls } from '@/services/proxy-test-url'
import { fetchCacheData, setCacheData, useQuery } from '@/services/query-client'
import { debugLog } from '@/utils/debug'
@@ -36,10 +35,6 @@ export const useProfiles = () => {
refetchInterval: false,
})
useEffect(() => {
if (profiles) migrateProfileTestUrls(profiles)
}, [profiles])
const refetchRef = useRef(refetch)
refetchRef.current = refetch
const mutateProfiles = useCallback(async () => {
+9 -12
View File
@@ -34,6 +34,7 @@ export function useProxyDelayState(
groupName: string,
): UseProxyDelayState {
const name = member.ref.name
const details = memberDetails(member)
const unresolved = member.kind === 'unresolved'
const isPreset = unresolved || PRESET_PROXY_NAMES.includes(name)
const [delayState, setDelayState] = useReducer(identity, INITIAL_DELAY)
@@ -42,18 +43,18 @@ export function useProxyDelayState(
useEffect(() => {
if (isPreset) return
delayManager.setListener(member, groupName, setDelayState)
delayManager.setListener(name, groupName, setDelayState)
return () => {
delayManager.removeListener(member, groupName)
delayManager.removeListener(name, groupName)
}
}, [member, groupName, isPreset])
}, [name, groupName, isPreset])
const updateDelay = useCallback(() => {
if (unresolved) {
setDelayState(INITIAL_DELAY)
return
}
const cachedUpdate = delayManager.getDelayUpdate(member, groupName)
const cachedUpdate = delayManager.getDelayUpdate(name, groupName)
if (cachedUpdate) {
setDelayState({ ...cachedUpdate })
return
@@ -66,9 +67,7 @@ export function useProxyDelayState(
}
let updatedAt = 0
const history =
delayManager.getHistory(member, groupName) ??
memberDetails(member)?.history
const history = details?.history
if (history && history.length > 0) {
const lastRecord = history[history.length - 1]
const parsed = Date.parse(lastRecord.time)
@@ -78,18 +77,16 @@ export function useProxyDelayState(
}
setDelayState({ delay: fallbackDelay, updatedAt })
}, [groupName, member, unresolved])
}, [details?.history, groupName, member, name, unresolved])
useEffect(() => {
updateDelay()
return delayManager.addGroupListener(groupName, updateDelay)
}, [groupName, updateDelay])
}, [updateDelay])
const onDelay = useLockFn(async () => {
if (!isInteractableMember(member)) return
setDelayState({ delay: -2, updatedAt: Date.now() })
await delayManager.checkDelay(member, groupName, timeout)
updateDelay()
setDelayState(await delayManager.checkDelay(member, groupName, timeout))
})
return {
-12
View File
@@ -659,7 +659,6 @@ export type PrfItem_Deserialize = {
desc?: string | null
url?: string | null
selected?: PrfSelected[] | null
latency_test_urls?: { [key in string]: string } | null
extra?: PrfExtra | null
updated?: number | null
option?: PrfOption_Deserialize | null
@@ -675,7 +674,6 @@ export type PrfItem_Serialize = {
desc?: string | null
url?: string | null
selected?: PrfSelected[] | null
latency_test_urls?: { [key in string]: string } | null
extra?: PrfExtra | null
updated: number | null
option?: PrfOption_Serialize | null
@@ -770,7 +768,6 @@ export type ProfilePatch_Deserialize = {
file?: string | null
url?: string | null
selected?: PrfSelected[] | null
latency_test_urls?: { [key in string]: string } | null
extra?: PrfExtra | null
updated?: number | null
option?: PrfOption_Deserialize | null
@@ -783,7 +780,6 @@ export type ProfilePatch_Serialize = {
file: string | null
url: string | null
selected: PrfSelected[] | null
latency_test_urls: { [key in string]: string } | null
extra: PrfExtra | null
updated: number | null
option: PrfOption_Serialize | null
@@ -818,7 +814,6 @@ export type ProxyGroupView_Deserialize = {
icon: string | null
testUrl: string | null
history: DelayHistory[]
extra: { [key in string]: ProxyLatencyHistory }
members: ProxyMemberRef[]
} & ProxyCapabilities
@@ -831,14 +826,9 @@ export type ProxyGroupView_Serialize = {
icon?: string | null
testUrl?: string | null
history: DelayHistory[]
extra: { [key in string]: ProxyLatencyHistory }
members: ProxyMemberRef[]
} & ProxyCapabilities
export type ProxyLatencyHistory = {
history: DelayHistory[]
}
export type ProxyMemberRef =
| { kind: 'group'; name: string }
| { kind: 'node'; name: string; recordId: string }
@@ -860,7 +850,6 @@ export type ProxyNodeView_Deserialize = {
name: string
type: string
history: DelayHistory[]
extra: { [key in string]: ProxyLatencyHistory }
id: string | null
hidden: boolean | null
icon: string | null
@@ -873,7 +862,6 @@ export type ProxyNodeView_Serialize = {
name: string
type: string
history: DelayHistory[]
extra: { [key in string]: ProxyLatencyHistory }
id?: string | null
hidden?: boolean | null
icon?: string | null
+2 -13
View File
@@ -91,18 +91,8 @@ export async function deleteProfile(index: string) {
}
export async function patchProfile(index: string, profile: ProfilePatch) {
const {
type,
name,
desc,
file,
url,
selected,
latency_test_urls,
extra,
updated,
option,
} = profile
const { type, name, desc, file, url, selected, extra, updated, option } =
profile
return commands.patchProfile(index, {
type,
name,
@@ -110,7 +100,6 @@ export async function patchProfile(index: string, profile: ProfilePatch) {
file,
url,
selected,
latency_test_urls,
extra,
updated,
option,
-133
View File
@@ -59,136 +59,3 @@ describe('group delay completion', () => {
stop()
})
})
test('uses newer core history after a tray test without hiding an active page test', () => {
const member = node('tray-refresh')
if (member.kind !== 'node') throw new Error('Expected node')
const cached = delayManager.setDelay(member, 'tray-group', 50)
member.node.extra = {
[delayManager.getUrl('tray-group')]: {
history: [
{ time: new Date(cached.updatedAt + 1000).toISOString(), delay: 300 },
],
},
}
expect(delayManager.getDelayFix(member, 'tray-group')).toBe(300)
expect(delayManager.getDelayUpdate(member, 'tray-group')?.delay).toBe(300)
delayManager.setDelay(member, 'tray-group', -2)
expect(delayManager.getDelayFix(member, 'tray-group')).toBe(-2)
})
test('latency URL validation rejects malformed hosts and ports and trims valid URLs', () => {
for (const url of [
'http://?',
'http://host:invalid',
'http://bad host/',
'ftp://localhost',
'http:localhost',
]) {
delayManager.setUrl('url-validation', url)
expect(delayManager.getUrl('url-validation')).toBe(
'http://cp.cloudflare.com/generate_204',
)
}
delayManager.setUrl('url-validation', ' HTTP://localhost:8080/ ')
expect(delayManager.getUrl('url-validation')).toBe('HTTP://localhost:8080/')
})
test('another URL cannot replace a group measurement, but a newer matching URL can', () => {
const member = node('url-isolation')
if (member.kind !== 'node') throw new Error('Expected node')
delayManager.setUrl('url-a', 'http://localhost/a')
const cached = delayManager.setDelay(member, 'url-a', 50)
const newer = new Date(cached.updatedAt + 1000).toISOString()
Object.assign(member.node, {
history: [{ time: newer, delay: 126 }],
extra: {
'http://localhost/a': {
history: [
{ time: new Date(cached.updatedAt - 1000).toISOString(), delay: 49 },
],
},
'http://localhost/b': {
history: [{ time: newer, delay: 126 }],
},
},
})
expect(delayManager.getDelayFix(member, 'url-a')).toBe(50)
Object.assign(member.node, {
extra: {
'http://localhost/a': {
history: [{ time: newer, delay: 75 }],
},
},
})
expect(delayManager.getDelayFix(member, 'url-a')).toBe(75)
})
test('same-named provider members keep separate measurements and listeners', async () => {
const first = node('same-provider-name')
const second = node('same-provider-name')
if (first.kind !== 'node' || second.kind !== 'node')
throw new Error('Expected nodes')
first.node.recordId = 'provider-a:N'
first.node.source = { kind: 'provider', providerName: 'a', proxyName: 'N' }
second.node.recordId = 'provider-b:N'
second.node.source = { kind: 'provider', providerName: 'b', proxyName: 'N' }
const { healthcheckNodeInProvider } = await import('tauri-plugin-mihomo-api')
vi.mocked(healthcheckNodeInProvider)
.mockResolvedValueOnce({ delay: 40 })
.mockResolvedValueOnce({ delay: 125 })
const firstListener = vi.fn()
const secondListener = vi.fn()
delayManager.setListener(first, 'provider-collision', firstListener)
delayManager.setListener(second, 'provider-collision', secondListener)
await delayManager.checkListDelay([first, second], 'provider-collision', 5000)
await flush()
expect(firstListener).toHaveBeenLastCalledWith(
expect.objectContaining({ delay: 40 }),
)
expect(secondListener).toHaveBeenLastCalledWith(
expect.objectContaining({ delay: 125 }),
)
delayManager.removeListener(first, 'provider-collision')
delayManager.removeListener(second, 'provider-collision')
expect(delayManager.getDelayFix(first, 'provider-collision')).toBe(40)
expect(delayManager.getDelayFix(second, 'provider-collision')).toBe(125)
})
test.each(['core', 'provider'] as const)(
'cached %s delays follow source identity after record IDs move',
(kind) => {
const original = node(`stable-${kind}`)
const replacement = node(`replacement-${kind}`)
if (original.kind !== 'node' || replacement.kind !== 'node')
throw new Error('Expected nodes')
const source = (proxyName: string) =>
kind === 'core'
? { kind, proxyName }
: { kind, providerName: 'subscription', proxyName }
original.node.source = source(original.ref.name)
replacement.node.source = source(replacement.ref.name)
original.node.recordId = 'position:0'
replacement.node.recordId = 'position:0'
delayManager.setDelay(original, `reorder-${kind}`, 40)
expect(delayManager.getDelayFix(replacement, `reorder-${kind}`)).toBe(-1)
original.node.recordId = 'position:1'
expect(delayManager.getDelayFix(original, `reorder-${kind}`)).toBe(40)
},
)
test('unmeasured selectors show automatic history without replacing a manual URL result', () => {
const member = node('selector-member')
if (member.kind !== 'node') throw new Error('Expected node')
member.node.history = [
{ time: new Date(Date.now() + 1000).toISOString(), delay: 126 },
]
member.node.extra = {
'http://localhost/automatic': { history: member.node.history },
}
expect(delayManager.getDelayFix(member, 'selector')).toBe(126)
delayManager.setDelay(member, 'selector', 50)
expect(delayManager.getDelayFix(member, 'selector')).toBe(50)
})
+38 -58
View File
@@ -12,23 +12,13 @@ import {
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
import { classifyDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay'
import { isValidLatencyTestUrl } from '@/utils/network'
import { isValidUrl } from '@/utils/network'
export type DelaySnapshot = {
of: (member: ResolvedProxyMember) => number
}
const hashKey = (member: ResolvedProxyMember, group: string) => {
if (member.kind !== 'node')
return JSON.stringify([group, member.kind, member.ref.name])
const source = member.node.source
return JSON.stringify([
group,
source.kind,
source.kind === 'provider' ? source.providerName : null,
source.proxyName,
])
}
const hashKey = (name: string, group: string) => `${group ?? ''}::${name}`
export interface DelayUpdate {
delay: number
@@ -39,7 +29,7 @@ export interface DelayUpdate {
const CACHE_TTL = 30 * 60 * 1000
class DelayManager {
private cache = new Map<string, DelayUpdate & { url: string }>()
private cache = new Map<string, DelayUpdate>()
private urlMap = new Map<string, string>()
private listenerMap = new Map<string, (update: DelayUpdate) => void>()
@@ -163,13 +153,13 @@ class DelayManager {
}
setUrl(group: string, url: string) {
const previous = this.getUrl(group)
if (isValidLatencyTestUrl(url)) {
this.urlMap.set(group, url.trim())
} else {
if (!isValidUrl(url)) {
debugLog(`[DelayManager] 拒绝无效测试URL,组: ${group}, URL: ${url}`)
this.urlMap.delete(group)
return
}
if (previous !== this.getUrl(group)) this.queueGroupNotification(group)
debugLog(`[DelayManager] 设置测试URL,组: ${group}, URL: ${url}`)
this.urlMap.set(group, url)
}
getUrl(group: string) {
@@ -181,16 +171,16 @@ class DelayManager {
}
setListener(
member: ResolvedProxyMember,
name: string,
group: string,
listener: (update: DelayUpdate) => void,
) {
const key = hashKey(member, group)
const key = hashKey(name, group)
this.listenerMap.set(key, listener)
}
removeListener(member: ResolvedProxyMember, group: string) {
const key = hashKey(member, group)
removeListener(name: string, group: string) {
const key = hashKey(name, group)
this.listenerMap.delete(key)
}
@@ -209,14 +199,14 @@ class DelayManager {
}
setDelay(
member: ResolvedProxyMember,
name: string,
group: string,
delay: number,
meta?: { elapsed?: number; url?: string },
meta?: { elapsed?: number },
): DelayUpdate {
const key = hashKey(member, group)
const key = hashKey(name, group)
debugLog(
`[DelayManager] 设置延迟,代理: ${member.ref.name}, 组: ${group}, 延迟: ${delay}`,
`[DelayManager] 设置延迟,代理: ${name}, 组: ${group}, 延迟: ${delay}`,
)
const update: DelayUpdate = {
delay,
@@ -224,9 +214,7 @@ class DelayManager {
updatedAt: Date.now(),
}
const url = meta?.url ?? this.getUrl(group)
if (url !== this.getUrl(group)) return update
this.cache.set(key, { ...update, url })
this.cache.set(key, update)
const queue = this.pendingItemUpdates.get(key)
if (queue) {
@@ -239,16 +227,9 @@ class DelayManager {
return update
}
getDelayUpdate(member: ResolvedProxyMember, group: string) {
const history = this.getHistory(member, group)
const key = hashKey(member, group)
const cached = this.cache.get(key)
const entry = cached?.url === this.getUrl(group) ? cached : undefined
const latest = history?.at(-1)
const updatedAt = latest ? Date.parse(latest.time) : 0
if (latest && entry?.delay !== -2 && updatedAt > (entry?.updatedAt ?? 0)) {
return { delay: latest.delay || 1e6, updatedAt }
}
getDelayUpdate(name: string, group: string) {
const key = hashKey(name, group)
const entry = this.cache.get(key)
if (!entry) return undefined
if (Date.now() - entry.updatedAt > CACHE_TTL) {
@@ -259,25 +240,23 @@ class DelayManager {
return { ...entry }
}
getDelay(member: ResolvedProxyMember, group: string) {
const update = this.getDelayUpdate(member, group)
getDelay(name: string, group: string) {
const update = this.getDelayUpdate(name, group)
return update ? update.delay : -1
}
getHistory(member: ResolvedProxyMember, group: string) {
return memberDetails(member)?.extra?.[this.getUrl(group)]?.history
}
getDelayFix(member: ResolvedProxyMember, group: string) {
if (member.kind === 'unresolved') return -1
const history =
this.getHistory(member, group) ?? memberDetails(member)?.history
const update = this.getDelayUpdate(member, group)
const details = memberDetails(member)
const name = member.ref.name
const update = this.getDelayUpdate(name, group)
if (update && (update.delay >= 0 || update.delay === -2)) {
return update.delay
}
if (history?.length) return history[history.length - 1].delay || 1e6
if (details?.history && details.history.length > 0) {
return details.history[details.history.length - 1].delay || 1e6
}
return -1
}
@@ -319,12 +298,12 @@ class DelayManager {
`[DelayManager] 开始测试延迟,代理: ${name}, 组: ${group}, 超时: ${timeout}ms`,
)
const url = this.getUrl(group)
this.setDelay(member, group, -2, { url })
this.setDelay(name, group, -2)
const startTime = Date.now()
try {
const url = this.getUrl(group)
debugLog(`[DelayManager] 调用API测试延迟,代理: ${name}, URL: ${url}`)
const timeoutPromise = new Promise<ProxyDelay>((resolve) => {
@@ -345,14 +324,14 @@ class DelayManager {
const elapsed = elapsedTime
debugLog(`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`)
return this.setDelay(member, group, delay, { elapsed, url })
return this.setDelay(name, group, delay, { elapsed })
} catch (error) {
await new Promise((resolve) => setTimeout(resolve, 500))
console.error(`[DelayManager] 延迟测试出错,代理: ${name}`, error)
const delay = 1e6 // error
const elapsed = Date.now() - startTime
return this.setDelay(member, group, delay, { elapsed, url })
return this.setDelay(name, group, delay, { elapsed })
}
}
@@ -365,9 +344,10 @@ class DelayManager {
debugLog(
`[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`,
)
const names = proxies.map((member) => member.ref.name)
this.activeBatches.set(group, (this.activeBatches.get(group) ?? 0) + 1)
proxies.forEach((member) => {
this.setDelay(member, group, -2)
names.forEach((name) => {
this.setDelay(name, group, -2)
})
let index = 0
@@ -379,7 +359,7 @@ class DelayManager {
const currName = currMember.ref.name
try {
this.setDelay(currMember, group, -2)
this.setDelay(currName, group, -2)
// Stagger requests so a batch does not hit the core at once.
if (index > 1) {
@@ -395,13 +375,13 @@ class DelayManager {
`[DelayManager] 批量测试单个代理出错,代理: ${currName}`,
error,
)
this.setDelay(currMember, group, 1e6)
this.setDelay(currName, group, 1e6)
}
return help()
}
const actualConcurrency = Math.min(concurrency, proxies.length, 10)
const actualConcurrency = Math.min(concurrency, names.length, 10)
debugLog(`[DelayManager] 实际并发数: ${actualConcurrency}`)
const promiseList: Promise<void>[] = []
-124
View File
@@ -1,124 +0,0 @@
import { afterEach, expect, test, vi } from 'vitest'
vi.mock('@/services/cmds', () => ({
getProfiles: vi.fn(),
patchProfile: vi.fn().mockResolvedValue(undefined),
syncTrayProxySelection: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() }))
vi.mock('@/services/notice-service', () => ({ showNotice: { error: vi.fn() } }))
import {
getProfiles,
patchProfile,
syncTrayProxySelection,
} from '@/services/cmds'
import { revalidateQueries } from '@/services/query-client'
import {
profileTestUrls,
saveProfileTestUrls,
commitProfileTestUrl,
} from './proxy-test-url'
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
test('committed edits retain their profile and merge with fresh saved URLs', async () => {
vi.mocked(getProfiles).mockResolvedValue({
current: 'new-profile',
items: [
{
uid: 'old-profile',
type: null,
name: null,
file: null,
updated: null,
latency_test_urls: { untouched: 'https://example.com/' },
},
],
})
await commitProfileTestUrl('old-profile', 'A', ' http://localhost/new ')
expect(patchProfile).toHaveBeenCalledExactlyOnceWith('old-profile', {
latency_test_urls: {
untouched: 'https://example.com/',
A: 'http://localhost/new',
},
})
expect(revalidateQueries).toHaveBeenCalledWith([['getProfiles']])
expect(syncTrayProxySelection).toHaveBeenCalledOnce()
})
test('migration cannot overwrite a previously saved or cleared override', async () => {
vi.mocked(getProfiles).mockResolvedValue({
items: [
{
uid: 'p',
type: null,
name: null,
file: null,
updated: null,
latency_test_urls: { A: '' },
},
],
})
await saveProfileTestUrls('p', { A: 'http://localhost/stale' }, true)
expect(patchProfile).not.toHaveBeenCalled()
})
test('pending edits survive a reload and older save acknowledgements', async () => {
vi.useFakeTimers()
const profile = {
uid: 'pending-profile',
type: null,
name: null,
file: null,
updated: null,
latency_test_urls: { A: 'http://localhost/old' },
}
vi.mocked(getProfiles).mockResolvedValue({ items: [profile] })
let finish!: () => void
vi.mocked(patchProfile).mockImplementationOnce(
() =>
new Promise<null>((resolve) => {
finish = () => resolve(null)
}),
)
const first = commitProfileTestUrl(profile.uid, 'A', 'http://localhost/new')
expect(profileTestUrls(profile).A).toBe('http://localhost/new')
await vi.advanceTimersByTimeAsync(300)
expect(profileTestUrls(profile).A).toBe('http://localhost/new')
const second = commitProfileTestUrl(
profile.uid,
'A',
'http://localhost/newest',
)
finish()
await first
expect(profileTestUrls(profile).A).toBe('http://localhost/newest')
await second
expect(patchProfile).toHaveBeenLastCalledWith(profile.uid, {
latency_test_urls: { A: 'http://localhost/newest' },
})
})
test('a failed save preserves the committed input across profile refreshes', async () => {
const profile = {
uid: 'failed-profile',
type: null,
name: null,
file: null,
updated: null,
latency_test_urls: { A: 'http://localhost/old' },
}
vi.mocked(getProfiles).mockResolvedValue({ items: [profile] })
vi.mocked(patchProfile).mockRejectedValueOnce(new Error('save failed'))
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
await commitProfileTestUrl(profile.uid, 'A', 'http://localhost/new')
expect(profileTestUrls(profile).A).toBe('http://localhost/new')
await commitProfileTestUrl(profile.uid, 'A', 'http://localhost/new')
expect(profileTestUrls(profile).A).toBe('http://localhost/old')
log.mockRestore()
})
-101
View File
@@ -1,101 +0,0 @@
import {
getProfiles,
patchProfile,
syncTrayProxySelection,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
function readLegacyTestUrls(uid: string): Record<string, string> {
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<string, string>,
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<string>()
export function migrateProfileTestUrls(profiles: IProfilesConfig) {
for (const profile of profiles.items || []) {
const uid = profile.uid
if (!uid) continue
if (profile.latency_test_urls !== undefined || migrating.has(uid)) continue
const urls = readLegacyTestUrls(uid)
if (!Object.keys(urls).length) continue
migrating.add(uid)
void saveProfileTestUrls(uid, urls, true)
.catch((error) => {
console.error('Failed to migrate latency test URLs:', error)
})
.finally(() => migrating.delete(uid))
}
}
const pending = new Map<string, Record<string, { url: string }>>()
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<string, string> {
return {
...profile.latency_test_urls,
...Object.fromEntries(
Object.entries(
(profile.uid ? pending.get(profile.uid) : undefined) ?? {},
).map(([group, edit]) => [group, edit.url]),
),
}
}
-13
View File
@@ -20,19 +20,6 @@ export const isValidUrl = (value: string) =>
require_tld: false,
})
export const isValidLatencyTestUrl = (value: string) => {
const raw = value.trim()
if (!/^https?:\/\//i.test(raw)) return false
try {
const url = new URL(raw)
return (
(url.protocol === 'http:' || url.protocol === 'https:') && !!url.hostname
)
} catch {
return false
}
}
export const isValidPort = (value: string) => isPort(value.trim())
export const normalizeHost = (value: string): string | null => {