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.
This commit is contained in:
wonfen
2026-10-21 05:37:18 +08:00
parent 27d8a71d41
commit 5e6a696087
40 changed files with 228 additions and 1315 deletions
+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: 關閉所有連線
-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",
+135 -356
View File
@@ -1,6 +1,5 @@
use crate::config::{IProfilePreview, IVerge};
use crate::core::lightweight;
use crate::core::proxy_view::{ProxyGroupView, ProxyMemberRef, ProxyNodeSource, ProxyViewProviderState, ProxyViewV1};
use crate::core::tray::menu_def::TrayAction;
use crate::process::AsyncHandler;
use crate::singleton;
@@ -15,6 +14,7 @@ use crate::{
use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter};
use clash_verge_logging::logging_error;
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
use tauri_plugin_mihomo::models::Proxies;
use tokio::fs;
use super::handle;
@@ -28,7 +28,6 @@ use tauri::{
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
};
mod latency;
mod menu_def;
#[cfg(target_os = "macos")]
mod speed_task;
@@ -56,25 +55,8 @@ enum IconKind {
Tun,
}
struct LatencyMenuItem {
item: MenuItem<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 +130,6 @@ impl Default for Tray {
fn default() -> Self {
Self {
limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock),
proxy_hover_limiter: Limiter::new(Duration::from_secs(1), SystemClock),
menu_update: tokio::sync::Mutex::new(()),
latency_items: parking_lot::Mutex::new(HashMap::new()),
#[cfg(target_os = "macos")]
speed_controller: speed_task::TraySpeedController::new(),
}
@@ -216,7 +195,6 @@ impl Tray {
}
async fn update_menu_internal(&self, app_handle: &AppHandle, include_proxy_groups: bool) -> Result<()> {
let _update = self.menu_update.lock().await;
let Some(tray) = app_handle.tray_by_id(TRAY_ID) else {
logging!(warn, Type::Tray, "Failed to update tray menu: tray not found");
return Ok(());
@@ -241,133 +219,29 @@ impl Tray {
let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default();
let is_lightweight_mode = is_in_lightweight_mode();
let (menu, latency_items) = create_tray_menu(
app_handle,
Some(mode.as_str()),
*system_proxy,
*tun_mode,
tun_mode_available,
profiles_preview,
TrayMenuOptions {
is_lightweight_mode,
include_proxy_groups,
},
)
.await?;
tray.set_menu(Some(menu))?;
let groups: Vec<_> = latency_items.keys().cloned().collect();
*self.latency_items.lock() = latency_items;
for group in groups {
self.refresh_latency_item(&group);
}
logging_error!(
Type::Tray,
tray.set_menu(Some(
create_tray_menu(
app_handle,
Some(mode.as_str()),
*system_proxy,
*tun_mode,
tun_mode_available,
profiles_preview,
TrayMenuOptions {
is_lightweight_mode,
include_proxy_groups,
},
)
.await?,
))
);
logging!(debug, Type::Tray, "托盘菜单更新成功");
Ok(())
}
async fn refresh_proxy_items(&'static self) {
let Ok(update) = self.menu_update.try_lock() else {
return;
};
if self.latency_items.lock().is_empty() || handle::Handle::global().is_exiting() {
return;
}
let Ok(Ok(view)) = tokio::time::timeout(
Duration::from_secs(1),
cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
)
.await
else {
return;
};
if self
.latency_items
.lock()
.values()
.any(|entry| entry.provider_state != view.provider_state)
{
// Provider fallback changes member identities; rebuild through the existing queue.
drop(update);
logging_error!(Type::Tray, cmd::proxy::sync_tray_proxy_selection().await);
return;
}
let verge = Config::verge().await.latest_arc();
let profiles = Config::profiles().await.latest_arc();
logging_error!(
Type::Tray,
handle::Handle::app_handle().run_on_main_thread(move || {
// Keep rebuilding serialized until these handles have received their snapshot.
let _update = update;
let mut items = self.latency_items.lock();
for (group_name, entry) in items.iter_mut() {
let Some(group) = find_group(&view, group_name) else {
continue;
};
let url = latency::test_url(group, &verge, &profiles);
let mut members = HashMap::with_capacity(group.members.len());
for member in &group.members {
members
.entry((member_name(member), member_provider(&view, member)))
.or_insert(member);
}
for proxy in &mut entry.proxies {
let Some(member) = members.get(&(proxy.name.as_str(), proxy.provider_name.as_deref())) else {
continue;
};
let text = proxy_item_text(&view, member, &url, &verge);
let checked = group.now.as_ref() == Some(&proxy.name);
if text != proxy.text {
match proxy.item.set_text(&text) {
Ok(()) => proxy.text = text,
Err(err) => logging!(warn, Type::Tray, "Failed to refresh proxy latency: {err}"),
}
}
match proxy.item.is_checked() {
Ok(current) if current != checked => {
logging_error!(Type::Tray, proxy.item.set_checked(checked))
}
Err(err) => logging!(warn, Type::Tray, "Failed to read proxy selection: {err}"),
_ => {}
}
}
}
})
);
}
fn refresh_latency_item(&self, group_name: &str) {
let group_name = group_name.to_owned();
logging_error!(
Type::Tray,
handle::Handle::app_handle().run_on_main_thread(move || {
Self::global().refresh_latency_item_on_main_thread(&group_name);
})
);
}
fn refresh_latency_item_on_main_thread(&self, group_name: &str) {
let running = latency::is_running(group_name);
let item = {
let mut items = self.latency_items.lock();
items.get_mut(group_name).and_then(|entry| {
if entry.running == running {
return None;
}
entry.running = running;
Some(entry.item.clone())
})
};
if let Some(item) = item {
let label = if running {
clash_verge_i18n::t!("tray.testingLatency")
} else {
clash_verge_i18n::t!("tray.testLatency")
};
logging_error!(Type::Tray, item.set_text(label));
logging_error!(Type::Tray, item.set_enabled(!running));
}
}
pub async fn update_icon(&self, verge: &IVerge) -> Result<()> {
if handle::Handle::global().is_exiting() {
logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新");
@@ -583,175 +457,96 @@ fn create_profile_menu_item(
.collect()
}
const UNTESTED_DELAY_TEXT: &str = "-ms";
const TIMEOUT_DELAY_TEXT: &str = "T/O";
const TRAY_GROUP_DEPTH_LIMIT: usize = 8;
const fn member_name(member: &ProxyMemberRef) -> &str {
match member {
ProxyMemberRef::Node { name, .. }
| ProxyMemberRef::Group { name }
| ProxyMemberRef::Unresolved { name, .. } => name.as_str(),
}
}
fn find_group<'a>(view: &'a ProxyViewV1, name: &str) -> Option<&'a ProxyGroupView> {
view.groups
.iter()
.chain(view.global.iter())
.find(|group| group.name.as_str() == name)
}
/// The delay recorded for `url`, falling back to the node's latest measurement under any URL, so a
/// node the core tested on its own still shows a value.
fn node_delay(view: &ProxyViewV1, record_id: &str, url: &str) -> Option<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(
@@ -798,38 +593,54 @@ async fn create_tray_menu(
tun_mode_available: bool,
profiles_preview: Vec<IProfilePreview<'_>>,
options: TrayMenuOptions,
) -> Result<(tauri::menu::Menu<Wry>, HashMap<std::string::String, LatencyMenuItem>)> {
) -> Result<tauri::menu::Menu<Wry>> {
let current_proxy_mode = mode.unwrap_or("");
let mut verge_settings = Config::verge().await.latest_arc();
let fetch_proxy_groups =
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable");
// Proxy data must remain optional so a stopped Core cannot roll back unrelated settings.
let proxy_view = if fetch_proxy_groups {
tokio::time::timeout(
Duration::from_millis(1000),
cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
)
.await
.ok()
.and_then(Result::ok)
// TODO: should update tray menu again when it was timeout error
let (proxy_nodes_data, runtime_proxy_groups_order) = if fetch_proxy_groups {
let proxy_nodes_data =
tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies())
.await
.map_or(None, |res| res.ok());
let runtime = Config::runtime().await.latest_arc();
let runtime_proxy_groups_order = runtime.config.as_ref().map(|config| {
config
.get("proxy-groups")
.and_then(|groups| groups.as_sequence())
.map(|groups| {
groups
.iter()
.filter_map(|group| group.get("name"))
.filter_map(|name| name.as_str())
.enumerate()
.map(|(index, name)| (name.into(), index))
.collect::<HashMap<String, usize>>()
})
.unwrap_or_default()
});
(proxy_nodes_data, runtime_proxy_groups_order)
} else {
None
(None, None)
};
if fetch_proxy_groups {
verge_settings = Config::verge().await.latest_arc();
}
let profiles_config = Config::profiles().await.latest_arc();
let tray_proxy_groups_display_mode = verge_settings
.tray_proxy_groups_display_mode
.as_deref()
.unwrap_or("default");
let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable";
let proxy_group_order_map = runtime_proxy_groups_order;
let show_outbound_modes_inline = verge_settings.tray_inline_outbound_modes.unwrap_or(false);
let version = env!("CARGO_PKG_VERSION");
@@ -909,17 +720,9 @@ async fn create_tray_menu(
&profile_menu_items_refs,
)?;
let mut latency_items = HashMap::new();
let (proxies_menu, inline_proxy_items) = if include_proxy_groups {
let proxy_sub_menus = create_subcreate_proxy_menu_item(
app_handle,
current_proxy_mode,
proxy_view,
&mut latency_items,
&texts,
&verge_settings,
&profiles_config,
);
let proxy_sub_menus =
create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data);
match tray_proxy_groups_display_mode {
"default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?,
@@ -1063,25 +866,10 @@ async fn create_tray_menu(
]);
let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?;
Ok((menu, latency_items))
Ok(menu)
}
fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) {
let refresh_proxies = match tray_event {
TrayIconEvent::Enter { .. } => Tray::global().proxy_hover_limiter.check(),
TrayIconEvent::Click {
button: MouseButton::Left | MouseButton::Right,
button_state: MouseButtonState::Down,
..
} => true,
_ => false,
};
if refresh_proxies {
AsyncHandler::spawn(|| async {
Tray::global().refresh_proxy_items().await;
});
}
if matches!(
tray_event,
TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. }
@@ -1203,15 +991,6 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
};
feat::toggle_proxy_profile(profile_index.into()).await;
}
id if id.starts_with(MenuIds::TEST_LATENCY) => {
let group_name = id
.strip_prefix(MenuIds::TEST_LATENCY)
.and_then(|rest| rest.strip_prefix('_'));
if let Some(group_name) = group_name {
logging!(info, Type::Tray, "Tray menu: test group latency {}", group_name);
latency::test_proxy_group_delay(group_name).await;
}
}
id if id.starts_with("proxy_") => {
let rest = match id.strip_prefix("proxy_") {
Some(r) => r,
@@ -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 => {