perf(proxy): consolidate proxy data into a single IPC (#7542)

* feat(proxy): build deterministic proxy view

* feat(proxy): expose ordered proxy view command

* feat(proxy): add proxy view client contract

* fix(lint): allow Node test files

* feat(proxy): switch UI to ordered proxy view

* fix(proxy): harden ordered proxy view consumers

* fix(proxy): compare chain duplicates by current id

* fix(proxy): release mihomo guard after fetch

* fix(proxy): preserve semantic selections across refresh

* fix(proxy): block global chain without GLOBAL

* fix(proxy): handle proxy view edge states
This commit is contained in:
wonfen
2026-07-21 00:11:59 +08:00
committed by GitHub
parent fd70d4c62b
commit 304f5bade9
26 changed files with 2737 additions and 1459 deletions
+1
View File
@@ -44,6 +44,7 @@ export default defineConfig([
`vite.config.mts`,
'scripts/*.mjs',
'src/polyfills/*.js',
'tests/*.mjs',
],
},
},
+77 -3
View File
@@ -1,12 +1,58 @@
use super::CmdResult;
use crate::core::tray::Tray;
use crate::process::AsyncHandler;
use crate::{
cmd::StringifyErr as _,
config::Config,
core::{
handle::Handle,
proxy_view::{ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
tray::Tray,
},
process::AsyncHandler,
};
use clash_verge_logging::{Type, logging};
use std::sync::atomic::{AtomicBool, Ordering};
use serde_yaml_ng::Mapping;
use std::{
collections::HashSet,
sync::atomic::{AtomicBool, Ordering},
};
static TRAY_SYNC_RUNNING: AtomicBool = AtomicBool::new(false);
static TRAY_SYNC_PENDING: AtomicBool = AtomicBool::new(false);
fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
let mut seen = HashSet::new();
config
.and_then(|config| config.get("proxy-groups"))
.and_then(|groups| groups.as_sequence())
.into_iter()
.flatten()
.filter_map(|group| group.get("name"))
.filter_map(|name| name.as_str())
.filter(|name| !name.is_empty() && *name != "GLOBAL")
.filter(|name| seen.insert((*name).to_owned()))
.map(str::to_owned)
.collect()
}
#[tauri::command]
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
let runtime = Config::runtime().await;
let committed_runtime = runtime.data_arc();
let runtime_group_order = runtime_group_order(committed_runtime.config.as_ref());
let mihomo = Handle::mihomo().await;
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),);
drop(mihomo);
let proxies = proxies.stringify_err()?;
Ok(ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order,
proxies,
providers: providers.ok(),
}))
}
/// 同步托盘和GUI的代理选择状态
#[tauri::command]
pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
@@ -50,3 +96,31 @@ async fn run_tray_sync_loop() {
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use serde_yaml_ng::Value;
use super::runtime_group_order;
#[test]
fn runtime_order_keeps_first_non_empty_non_global_name() {
let config: Value = serde_yaml_ng::from_str(
r#"
proxy-groups:
- name: Beta
- name: ""
- name: GLOBAL
- name: " Alpha "
- name: Beta
"#,
)
.expect("parse runtime");
assert_eq!(
runtime_group_order(config.as_mapping()),
["Beta".to_owned(), " Alpha ".to_owned()]
);
}
}
-22
View File
@@ -12,28 +12,6 @@ pub async fn get_runtime_config() -> CmdResult<Option<Mapping>> {
Ok(Config::runtime().await.latest_arc().config.clone())
}
/// 获取运行时代理组顺序
#[tauri::command]
pub async fn get_runtime_proxy_group_order() -> CmdResult<Vec<String>> {
let runtime = Config::runtime().await;
let runtime = runtime.latest_arc();
Ok(runtime
.config
.as_ref()
.and_then(|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())
.map(String::from)
.collect()
})
.unwrap_or_default())
}
/// 获取运行时YAML配置
#[tauri::command]
pub async fn get_runtime_yaml() -> CmdResult<String> {
+1
View File
@@ -5,6 +5,7 @@ pub mod hotkey;
pub mod logger;
pub mod manager;
mod notification;
pub mod proxy_view;
pub mod service;
pub mod sysopt;
pub mod timer;
+780
View File
@@ -0,0 +1,780 @@
use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use tauri_plugin_mihomo::models::{
DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
};
pub struct ProxyViewInput {
pub runtime_group_order: Vec<String>,
pub proxies: Proxies,
pub providers: Option<ProxyProviders>,
}
pub struct ProxyViewBuilder;
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyViewV1 {
pub schema_version: u8,
pub order_source: ProxyViewOrderSource,
pub provider_state: ProxyViewProviderState,
pub global: Option<ProxyGroupView>,
pub direct: Option<String>,
pub groups: Vec<ProxyGroupView>,
pub records: BTreeMap<String, ProxyNodeView>,
pub standalone: Vec<String>,
pub providers: Vec<ProxyProviderView>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ProxyViewOrderSource {
Runtime,
Fallback,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ProxyViewProviderState {
Ready,
Unavailable,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
pub struct ProxyCapabilities {
pub udp: bool,
pub xudp: bool,
pub tfo: bool,
pub mptcp: bool,
pub smux: bool,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyGroupView {
pub name: String,
#[serde(rename = "type")]
pub proxy_type: ProxyType,
pub alive: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub now: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fixed: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hidden: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub test_url: Option<String>,
pub history: Vec<DelayHistory>,
#[serde(flatten)]
pub capabilities: ProxyCapabilities,
pub members: Vec<ProxyMemberRef>,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyNodeView {
pub record_id: String,
pub name: String,
#[serde(rename = "type")]
pub proxy_type: ProxyType,
pub alive: bool,
pub history: Vec<DelayHistory>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hidden: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub test_url: Option<String>,
#[serde(flatten)]
pub capabilities: ProxyCapabilities,
pub source: ProxyNodeSource,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum ProxyNodeSource {
Core {
#[serde(rename = "proxyName")]
proxy_name: String,
},
Provider {
#[serde(rename = "providerName")]
provider_name: String,
#[serde(rename = "proxyName")]
proxy_name: String,
},
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum ProxyMemberRef {
Group {
name: String,
},
Node {
name: String,
#[serde(rename = "recordId")]
record_id: String,
},
Unresolved {
name: String,
reason: ProxyMemberUnresolvedReason,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub enum ProxyMemberUnresolvedReason {
#[serde(rename = "missing")]
Missing,
#[serde(rename = "ambiguous")]
Ambiguous,
#[serde(rename = "provider-unavailable")]
ProviderUnavailable,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyProviderView {
pub name: String,
pub vehicle_type: ProxyProviderVehicleType,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subscription_info: Option<ProxySubscriptionInfo>,
pub proxy_record_ids: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub enum ProxyProviderVehicleType {
#[serde(rename = "HTTP")]
Http,
#[serde(rename = "File")]
File,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
pub struct ProxySubscriptionInfo {
pub upload: i64,
pub download: i64,
pub total: i64,
pub expire: i64,
}
struct MemberResolver<'a> {
group_names: BTreeSet<String>,
core_node_ids: &'a BTreeMap<String, String>,
provider_candidates: &'a BTreeMap<String, Vec<String>>,
provider_available: bool,
}
impl MemberResolver<'_> {
fn resolve(&self, name: String) -> ProxyMemberRef {
if self.group_names.contains(&name) {
ProxyMemberRef::Group { name }
} else if let Some(record_id) = self.core_node_ids.get(&name) {
ProxyMemberRef::Node {
name,
record_id: record_id.clone(),
}
} else if !self.provider_available {
ProxyMemberRef::Unresolved {
name,
reason: ProxyMemberUnresolvedReason::ProviderUnavailable,
}
} else {
match self.provider_candidates.get(&name).map(Vec::as_slice) {
Some([record_id]) => ProxyMemberRef::Node {
name,
record_id: record_id.clone(),
},
None => ProxyMemberRef::Unresolved {
name,
reason: ProxyMemberUnresolvedReason::Missing,
},
Some(_) => ProxyMemberRef::Unresolved {
name,
reason: ProxyMemberUnresolvedReason::Ambiguous,
},
}
}
}
}
impl ProxyViewBuilder {
pub fn build(input: ProxyViewInput) -> ProxyViewV1 {
let ProxyViewInput {
runtime_group_order,
proxies,
providers,
} = input;
let provider_state = if providers.is_some() {
ProxyViewProviderState::Ready
} else {
ProxyViewProviderState::Unavailable
};
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 {
group_names: core_groups.keys().cloned().collect(),
core_node_ids: &core_node_ids,
provider_candidates: &provider_candidates,
provider_available: provider_state == ProxyViewProviderState::Ready,
};
let global = core_groups
.remove("GLOBAL")
.map(|proxy| build_group("GLOBAL".to_owned(), proxy, &resolver));
let (groups, order_source) = build_ordered_groups(core_groups, &runtime_group_order, &resolver);
let direct = core_node_ids.get("DIRECT").cloned();
let standalone = build_standalone(&core_node_ids);
ProxyViewV1 {
schema_version: 1,
order_source,
provider_state,
global,
direct,
groups,
records,
standalone,
providers,
}
}
}
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 {
nodes.insert(name, proxy);
}
}
(groups, nodes)
}
fn build_core_records(
core_nodes: BTreeMap<String, Proxy>,
) -> (BTreeMap<String, ProxyNodeView>, BTreeMap<String, String>) {
let mut records = BTreeMap::new();
let mut ids = BTreeMap::new();
for (index, (name, proxy)) in core_nodes.into_iter().enumerate() {
let record_id = format!("c:{index}");
ids.insert(name.clone(), record_id.clone());
records.insert(
record_id.clone(),
build_node(
record_id,
name.clone(),
proxy,
ProxyNodeSource::Core { proxy_name: name },
),
);
}
(records, ids)
}
fn build_provider_records(
providers: Option<ProxyProviders>,
records: &mut BTreeMap<String, ProxyNodeView>,
) -> (Vec<ProxyProviderView>, BTreeMap<String, Vec<String>>) {
let providers = providers
.map(|providers| providers.providers.into_iter().collect::<BTreeMap<_, _>>())
.unwrap_or_default();
let mut views = Vec::new();
let mut candidates = BTreeMap::new();
for (provider_name, provider) in providers {
let ProxyProvider {
vehicle_type,
proxies,
updated_at,
subscription_info,
..
} = provider;
let vehicle_type = match vehicle_type {
VehicleType::HTTP => ProxyProviderVehicleType::Http,
VehicleType::File => ProxyProviderVehicleType::File,
_ => continue,
};
let provider_index = views.len();
let mut proxy_record_ids = Vec::new();
for (member_index, proxy) in proxies.into_iter().enumerate() {
let record_id = format!("p:{provider_index}:{member_index}");
let proxy_name = proxy.name.clone();
let node_name = proxy.name.clone();
candidates
.entry(proxy_name.clone())
.or_insert_with(Vec::new)
.push(record_id.clone());
records.insert(
record_id.clone(),
build_node(
record_id.clone(),
node_name,
proxy,
ProxyNodeSource::Provider {
provider_name: provider_name.clone(),
proxy_name,
},
),
);
proxy_record_ids.push(record_id);
}
views.push(ProxyProviderView {
name: provider_name,
vehicle_type,
updated_at,
subscription_info: subscription_info.map(|subscription_info| ProxySubscriptionInfo {
upload: subscription_info.upload,
download: subscription_info.download,
total: subscription_info.total,
expire: subscription_info.expire,
}),
proxy_record_ids,
});
}
(views, candidates)
}
fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> ProxyGroupView {
let Proxy {
all,
fixed,
hidden,
icon,
now,
test_url,
alive,
history,
udp,
xudp,
tfo,
mptcp,
smux,
proxy_type,
..
} = proxy;
ProxyGroupView {
name,
proxy_type,
alive,
now,
fixed,
hidden,
icon,
test_url,
history,
capabilities: ProxyCapabilities {
udp,
xudp,
tfo,
mptcp,
smux,
},
members: all
.unwrap_or_default()
.into_iter()
.map(|name| resolver.resolve(name))
.collect(),
}
}
fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSource) -> ProxyNodeView {
let Proxy {
id,
hidden,
icon,
test_url,
alive,
history,
udp,
xudp,
tfo,
mptcp,
smux,
proxy_type,
..
} = proxy;
ProxyNodeView {
record_id,
name,
proxy_type,
alive,
history,
id,
hidden,
icon,
test_url,
capabilities: ProxyCapabilities {
udp,
xudp,
tfo,
mptcp,
smux,
},
source,
}
}
fn build_ordered_groups(
mut core_groups: BTreeMap<String, Proxy>,
runtime_group_order: &[String],
resolver: &MemberResolver<'_>,
) -> (Vec<ProxyGroupView>, ProxyViewOrderSource) {
let mut selected = BTreeSet::new();
let mut names = Vec::new();
for name in runtime_group_order {
if core_groups.contains_key(name) && selected.insert(name.clone()) {
names.push(name.clone());
}
}
let order_source = if names.is_empty() {
ProxyViewOrderSource::Fallback
} else {
ProxyViewOrderSource::Runtime
};
names.extend(core_groups.keys().filter(|name| !selected.contains(*name)).cloned());
let groups = names
.into_iter()
.filter_map(|name| {
core_groups
.remove(&name)
.map(|proxy| build_group(name, proxy, resolver))
})
.collect();
(groups, order_source)
}
fn build_standalone(core_node_ids: &BTreeMap<String, String>) -> Vec<String> {
let mut standalone = ["DIRECT", "REJECT"]
.into_iter()
.filter_map(|name| core_node_ids.get(name).cloned())
.collect::<Vec<_>>();
standalone.extend(
core_node_ids
.iter()
.filter(|(name, _)| name.as_str() != "DIRECT" && name.as_str() != "REJECT")
.map(|(_, record_id)| record_id.clone()),
);
standalone
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use std::collections::HashMap;
use tauri_plugin_mihomo::models::{
Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, SubScriptionInfo, VehicleType,
};
use super::{
ProxyMemberRef, ProxyMemberUnresolvedReason, ProxyNodeSource, ProxyViewBuilder, ProxyViewInput,
ProxyViewOrderSource, ProxyViewProviderState,
};
fn node(name: &str) -> Proxy {
Proxy {
name: name.to_owned(),
proxy_type: ProxyType::Shadowsocks,
alive: true,
udp: true,
..Proxy::default()
}
}
fn group(name: &str, members: &[&str]) -> Proxy {
Proxy {
name: format!("ignored-{name}"),
proxy_type: ProxyType::Selector,
alive: true,
all: Some(members.iter().map(|name| (*name).to_owned()).collect()),
..Proxy::default()
}
}
fn ordered_input(reverse_insert: bool) -> ProxyViewInput {
let entries = [
("Zulu", group("Zulu", &["node-b", "node-b"])),
("Alpha", group("Alpha", &["node-a"])),
("node-b", node("wrong-node-b")),
("node-a", node("wrong-node-a")),
("A-before-direct", node("wrong-before-direct")),
("DIRECT", node("wrong-direct")),
("REJECT", node("wrong-reject")),
];
let mut proxies = HashMap::new();
if reverse_insert {
proxies.extend(entries.into_iter().rev().map(|(name, proxy)| (name.to_owned(), proxy)));
} else {
proxies.extend(entries.into_iter().map(|(name, proxy)| (name.to_owned(), proxy)));
}
ProxyViewInput {
runtime_group_order: vec!["Zulu".into(), "Alpha".into()],
proxies: Proxies { proxies },
providers: None,
}
}
fn providers_fixture(entries: Vec<(&str, VehicleType, &[&str])>) -> ProxyProviders {
let providers = entries
.into_iter()
.map(|(provider_key, vehicle_type, names)| {
let provider = ProxyProvider {
name: format!("ignored-{provider_key}"),
vehicle_type,
proxies: names.iter().map(|name| node(name)).collect(),
..ProxyProvider::default()
};
(provider_key.to_owned(), provider)
})
.collect();
ProxyProviders { providers }
}
fn input_with_members_and_duplicate_providers() -> ProxyViewInput {
let proxies = HashMap::from([("Group".to_owned(), group("ignored", &["duplicate"]))]);
ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies { proxies },
providers: Some(providers_fixture(vec![
("a", VehicleType::HTTP, &["duplicate"]),
("b", VehicleType::File, &["duplicate"]),
])),
}
}
fn input_with_provider_member(providers: Option<ProxyProviders>) -> ProxyViewInput {
let proxies = HashMap::from([("Group".to_owned(), group("ignored", &["provider-only"]))]);
ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies { proxies },
providers,
}
}
fn input_with_provider_duplicates() -> ProxyViewInput {
ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies::default(),
providers: Some(providers_fixture(vec![
("z-provider", VehicleType::HTTP, &["z-node"]),
("unsupported", VehicleType::Compatible, &["ignored"]),
("a-provider", VehicleType::File, &["same", "same"]),
])),
}
}
fn serde_input_with_provider() -> ProxyViewInput {
let provider = ProxyProvider {
name: "ignored-object-name".to_owned(),
vehicle_type: VehicleType::HTTP,
updated_at: None,
subscription_info: Some(SubScriptionInfo {
upload: 1,
download: 2,
total: 3,
expire: 4,
}),
..ProxyProvider::default()
};
ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies::default(),
providers: Some(ProxyProviders {
providers: HashMap::from([("provider-key".to_owned(), provider)]),
}),
}
}
#[test]
fn ordering_and_core_records_are_deterministic() {
let first = ProxyViewBuilder::build(ordered_input(false));
let second = ProxyViewBuilder::build(ordered_input(true));
assert_eq!(first, second);
assert_eq!(first.order_source, ProxyViewOrderSource::Runtime);
assert_eq!(
first.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
["Zulu", "Alpha"]
);
assert_eq!(first.direct.as_deref(), Some("c:1"));
assert_eq!(first.standalone, ["c:1", "c:2", "c:0", "c:3", "c:4"]);
assert_eq!(first.records["c:0"].name, "A-before-direct");
assert_eq!(first.records["c:1"].name, "DIRECT");
assert_eq!(first.records["c:4"].name, "node-b");
assert_eq!(first.groups[0].members.len(), 2);
assert!(matches!(
&first.groups[0].members[0],
ProxyMemberRef::Node { name, .. } if name == "node-b"
));
assert_eq!(first.groups[0].members[0], first.groups[0].members[1]);
}
#[test]
fn fallback_and_unmatched_groups_are_stable() {
let mut input = ordered_input(false);
input.runtime_group_order = vec![];
let view = ProxyViewBuilder::build(input);
assert_eq!(view.order_source, ProxyViewOrderSource::Fallback);
assert_eq!(
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
["Alpha", "Zulu"]
);
let mut input = ordered_input(false);
input.runtime_group_order = vec!["Missing".into()];
let view = ProxyViewBuilder::build(input);
assert_eq!(view.order_source, ProxyViewOrderSource::Fallback);
assert_eq!(
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
["Alpha", "Zulu"]
);
let mut input = ordered_input(false);
input.runtime_group_order = vec!["Zulu".into()];
let view = ProxyViewBuilder::build(input);
assert_eq!(
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
["Zulu", "Alpha"]
);
}
#[test]
fn global_and_member_resolution_follow_the_required_priority() {
let mut proxies = HashMap::new();
proxies.insert(
"GLOBAL".into(),
group("wrong", &["Nested", "core", "provider-only", "missing"]),
);
proxies.insert("Nested".into(), group("wrong", &[]));
proxies.insert("core".into(), node("wrong"));
let providers = providers_fixture(vec![
(
"a",
VehicleType::HTTP,
&["Nested", "core", "provider-only", "duplicate"],
),
("b", VehicleType::File, &["duplicate"]),
]);
let view = ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies { proxies },
providers: Some(providers),
});
let members = &view.global.as_ref().expect("GLOBAL").members;
assert!(matches!(members[0], ProxyMemberRef::Group { ref name } if name == "Nested"));
assert!(matches!(members[1], ProxyMemberRef::Node { ref name, .. } if name == "core"));
assert!(matches!(members[2], ProxyMemberRef::Node { ref name, .. } if name == "provider-only"));
assert!(matches!(
members[3],
ProxyMemberRef::Unresolved {
reason: ProxyMemberUnresolvedReason::Missing,
..
}
));
assert!(!view.groups.iter().any(|group| group.name == "GLOBAL"));
}
#[test]
fn ambiguous_and_provider_unavailable_are_not_reported_as_missing() {
let ready = ProxyViewBuilder::build(input_with_members_and_duplicate_providers());
assert!(matches!(
ready.groups[0].members[0],
ProxyMemberRef::Unresolved {
reason: ProxyMemberUnresolvedReason::Ambiguous,
..
}
));
let unavailable = ProxyViewBuilder::build(input_with_provider_member(None));
assert!(matches!(
unavailable.groups[0].members[0],
ProxyMemberRef::Unresolved {
reason: ProxyMemberUnresolvedReason::ProviderUnavailable,
..
}
));
assert_eq!(unavailable.provider_state, ProxyViewProviderState::Unavailable);
assert!(unavailable.providers.is_empty());
}
#[test]
fn providers_preserve_identity_duplicates_and_member_order() {
let view = ProxyViewBuilder::build(input_with_provider_duplicates());
assert_eq!(
view.providers
.iter()
.map(|provider| provider.name.as_str())
.collect::<Vec<_>>(),
["a-provider", "z-provider"]
);
assert_eq!(view.providers[0].proxy_record_ids, ["p:0:0", "p:0:1"]);
assert_eq!(view.records["p:0:0"].name, "same");
assert_eq!(view.records["p:0:1"].name, "same");
assert_eq!(
view.records["p:0:0"].source,
ProxyNodeSource::Provider {
provider_name: "a-provider".into(),
proxy_name: "same".into(),
}
);
}
#[test]
fn a_successful_response_with_only_unsupported_providers_is_ready_and_empty() {
let view = ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies::default(),
providers: Some(providers_fixture(vec![(
"unsupported",
VehicleType::Compatible,
&["ignored"],
)])),
});
assert_eq!(view.provider_state, ProxyViewProviderState::Ready);
assert!(view.providers.is_empty());
}
#[test]
fn serde_matches_the_v1_wire_contract() {
let view = ProxyViewBuilder::build(serde_input_with_provider());
let json = serde_json::to_value(view).expect("serialize view");
assert_eq!(json["schemaVersion"], 1);
assert_eq!(json["orderSource"], "fallback");
assert_eq!(json["providerState"], "ready");
assert!(json["global"].is_null());
assert!(json["direct"].is_null());
assert!(json["providers"][0].get("vehicleType").is_some());
assert!(json["providers"][0].get("updatedAt").is_none());
assert_eq!(json["providers"][0]["subscriptionInfo"]["upload"], 1);
assert!(json["providers"][0]["subscriptionInfo"].get("Upload").is_none());
}
}
+1 -1
View File
@@ -163,7 +163,7 @@ mod app_init {
cmd::get_clash_mode,
cmd::change_clash_core,
cmd::get_runtime_config,
cmd::get_runtime_proxy_group_order,
cmd::get_proxy_view,
cmd::get_runtime_yaml,
cmd::get_runtime_exists,
cmd::get_runtime_logs,
+220 -437
View File
@@ -31,7 +31,6 @@ import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { delayGroup } from 'tauri-plugin-mihomo-api'
import { EnhancedCard } from '@/components/home/enhanced-card'
import { useProfiles } from '@/hooks/use-profiles'
@@ -42,14 +41,21 @@ import {
useClashConfigData,
useCoreDataStatus,
useProxiesData,
useRulesData,
} from '@/providers/app-data-context'
import delayManager from '@/services/delay'
import {
findCurrentGroupMember,
getRecord,
isInteractableMember,
memberDetails,
resolveMember,
type ProxyGroupView,
type ResolvedProxyMember,
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
// 本地存储的键名
const STORAGE_KEY_GROUP = 'clash-verge-selected-proxy-group'
const STORAGE_KEY_PROXY = 'clash-verge-selected-proxy'
const STORAGE_KEY_SORT_TYPE = 'clash-verge-proxy-sort-type'
const AUTO_CHECK_DEFAULT_INTERVAL_MINUTES = 5
@@ -57,7 +63,8 @@ const AUTO_CHECK_INITIAL_DELAY_MS = 100
// 代理节点信息接口
interface ProxyOption {
name: string
memberIndex: number
member: ResolvedProxyMember
}
// 排序类型: 默认 | 按延迟 | 按字母
@@ -111,9 +118,8 @@ export const CurrentProxyCard = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const theme = useTheme()
const { proxies } = useProxiesData()
const { proxyView } = useProxiesData()
const { clashConfig } = useClashConfigData()
const { rules } = useRulesData()
const { refreshProxy } = useAppRefreshers()
const { isCoreDataPending } = useCoreDataStatus()
const { verge } = useVerge()
@@ -194,322 +200,164 @@ export const CurrentProxyCard = () => {
})
const [delaySortRefresh, setDelaySortRefresh] = useState(0)
const normalizePolicyName = useCallback(
(value?: string | null) => (typeof value === 'string' ? value.trim() : ''),
[],
)
const matchPolicyName = useMemo(() => {
if (!Array.isArray(rules)) return ''
for (let index = rules.length - 1; index >= 0; index -= 1) {
const rule = rules[index]
if (!rule) continue
if (
typeof rule?.type === 'string' &&
rule.type.toUpperCase() === 'MATCH'
) {
const policy = normalizePolicyName(rule.proxy)
if (policy) {
return policy
}
}
}
return ''
}, [rules, normalizePolicyName])
type ProxyGroupOption = {
name: string
now: string
all: string[]
type?: string
}
type ProxyState = {
proxyData: {
groups: ProxyGroupOption[]
records: Record<string, any>
}
selection: {
group: string
proxy: string
}
displayProxy: any
}
const [state, setState] = useState<ProxyState>({
proxyData: {
groups: [],
records: {},
},
selection: {
group: '',
proxy: '',
},
displayProxy: null,
})
const [selectedGroupName, setSelectedGroupName] = useState('')
const autoCheckInProgressRef = useRef(false)
const latestTimeoutRef = useRef<number>(
verge?.default_latency_timeout || 10000,
)
const latestProxyRecordRef = useRef<any | null>(null)
const latestProxyMemberRef = useRef<ResolvedProxyMember | null>(null)
useEffect(() => {
latestTimeoutRef.current = verge?.default_latency_timeout || 10000
}, [verge?.default_latency_timeout])
useEffect(() => {
if (!state.selection.proxy) {
latestProxyRecordRef.current = null
return
}
latestProxyRecordRef.current =
state.proxyData.records?.[state.selection.proxy] || null
}, [state.selection.proxy, state.proxyData.records])
const selectableGroups = useMemo(() => {
if (!proxyView) return []
return proxyView.groups.filter(
(group) =>
!group.hidden &&
(group.type === 'Selector' || group.type === 'URLTest'),
)
}, [proxyView])
// 初始化选择的组
useEffect(() => {
if (!proxies) return
const getPrimaryGroupName = () => {
if (!proxies?.groups?.length) return ''
const primaryKeywords = [
'auto',
'select',
'proxy',
'节点选择',
'自动选择',
]
const primaryGroup =
proxies.groups.find((group: { name: string }) =>
primaryKeywords.some((keyword) =>
group.name.toLowerCase().includes(keyword.toLowerCase()),
),
) ||
proxies.groups.filter((g: { name: string }) => g.name !== 'GLOBAL')[0]
return primaryGroup?.name || ''
}
const primaryGroupName = getPrimaryGroupName()
// 根据模式确定初始组
if (isGlobalMode) {
setState((prev) => ({
...prev,
selection: {
...prev.selection,
group: 'GLOBAL',
},
}))
} else if (isDirectMode) {
setState((prev) => ({
...prev,
selection: {
...prev.selection,
group: 'DIRECT',
},
}))
} else {
const savedGroup = readProfileScopedItem(STORAGE_KEY_GROUP)
setState((prev) => ({
...prev,
selection: {
...prev.selection,
group: savedGroup || primaryGroupName || '',
},
}))
}
}, [isGlobalMode, isDirectMode, proxies, readProfileScopedItem])
// 监听代理数据变化,更新状态
useEffect(() => {
if (!proxies) return
setState((prev) => {
const groupsMap = new Map<string, ProxyGroupOption>()
const registerGroup = (group: any, fallbackName?: string) => {
if (!group && !fallbackName) return
const rawName =
typeof group?.name === 'string' && group.name.length > 0
? group.name
: fallbackName
const name = normalizePolicyName(rawName)
if (!name || groupsMap.has(name)) return
const rawAll = (
Array.isArray(group?.all)
? (group.all as Array<string | { name?: string }>)
: []
) as Array<string | { name?: string }>
const allNames = rawAll
.map((item) =>
typeof item === 'string'
? normalizePolicyName(item)
: normalizePolicyName(item?.name),
)
.filter((value): value is string => value.length > 0)
const uniqueAll = Array.from(new Set(allNames))
if (uniqueAll.length === 0) return
groupsMap.set(name, {
name,
now: normalizePolicyName(group?.now),
all: uniqueAll,
type: group?.type,
})
}
if (matchPolicyName) {
const matchGroup =
proxies.groups?.find(
(g: { name: string }) => g.name === matchPolicyName,
) ||
(proxies.global?.name === matchPolicyName ? proxies.global : null) ||
proxies.records?.[matchPolicyName]
registerGroup(matchGroup, matchPolicyName)
}
;(proxies.groups || [])
.filter(
(g: { type?: string; hidden?: boolean }) =>
!g?.hidden && (g?.type === 'Selector' || g?.type === 'URLTest'),
)
.forEach((selectableGroup: any) => {
registerGroup(selectableGroup)
})
const filteredGroups = Array.from(groupsMap.values())
let newProxy = ''
let newDisplayProxy = null
let newGroup = prev.selection.group
if (isDirectMode) {
newGroup = 'DIRECT'
newProxy = 'DIRECT'
newDisplayProxy = proxies.records?.DIRECT || { name: 'DIRECT' }
} else if (isGlobalMode && proxies.global) {
newGroup = 'GLOBAL'
newProxy = proxies.global.now || ''
newDisplayProxy = proxies.records?.[newProxy] || null
} else {
const currentGroup = filteredGroups.find(
(g: { name: string }) => g.name === prev.selection.group,
)
if (!currentGroup && filteredGroups.length > 0) {
const firstGroup = filteredGroups[0]
if (firstGroup) {
newGroup = firstGroup.name
newProxy = firstGroup.now || firstGroup.all[0] || ''
newDisplayProxy = proxies.records?.[newProxy] || null
if (!isGlobalMode && !isDirectMode) {
writeProfileScopedItem(STORAGE_KEY_GROUP, newGroup)
if (newProxy) {
writeProfileScopedItem(STORAGE_KEY_PROXY, newProxy)
}
}
}
} else if (currentGroup) {
newProxy = currentGroup.now || currentGroup.all[0] || ''
newDisplayProxy = proxies.records?.[newProxy] || null
}
}
return {
proxyData: {
groups: filteredGroups,
records: proxies.records || {},
},
selection: {
group: newGroup,
proxy: newProxy,
},
displayProxy: newDisplayProxy,
}
})
const selectedGroup = useMemo<ProxyGroupView | null>(() => {
if (!proxyView || isDirectMode) return null
if (isGlobalMode) return proxyView.global
return (
selectableGroups.find(({ name }) => name === selectedGroupName) ?? null
)
}, [
proxies,
isGlobalMode,
isDirectMode,
writeProfileScopedItem,
normalizePolicyName,
matchPolicyName,
isGlobalMode,
proxyView,
selectableGroups,
selectedGroupName,
])
// 处理代理组变更
const optionsForGroup = useCallback(
(group: ProxyGroupView | null): ProxyOption[] =>
proxyView && group
? group.members.map((member, memberIndex) => ({
memberIndex,
member: resolveMember(proxyView, member),
}))
: [],
[proxyView],
)
const unsortedProxyOptions = useMemo(
() => optionsForGroup(selectedGroup),
[optionsForGroup, selectedGroup],
)
useEffect(() => {
if (!proxyView) return
if (isDirectMode) {
setSelectedGroupName('DIRECT')
return
}
if (isGlobalMode) {
setSelectedGroupName(proxyView.global?.name ?? 'GLOBAL')
return
}
const savedGroup = readProfileScopedItem(STORAGE_KEY_GROUP)
const primaryKeywords = ['auto', 'select', 'proxy', '节点选择', '自动选择']
const primaryGroup =
selectableGroups.find((group) =>
primaryKeywords.some((keyword) =>
group.name.toLowerCase().includes(keyword.toLowerCase()),
),
) ?? selectableGroups[0]
const nextGroup = selectableGroups.some(
({ name }) => name === selectedGroupName,
)
? selectedGroupName
: selectableGroups.some(({ name }) => name === savedGroup)
? savedGroup!
: (primaryGroup?.name ?? '')
if (nextGroup !== selectedGroupName) {
setSelectedGroupName(nextGroup)
if (nextGroup) writeProfileScopedItem(STORAGE_KEY_GROUP, nextGroup)
}
}, [
isDirectMode,
isGlobalMode,
proxyView,
readProfileScopedItem,
selectableGroups,
selectedGroupName,
writeProfileScopedItem,
])
const currentOption = useMemo(() => {
if (!proxyView) return undefined
if (isDirectMode) {
const node =
proxyView.direct == null
? undefined
: getRecord(proxyView, proxyView.direct)
return node
? ({
memberIndex: 0,
member: {
kind: 'node',
ref: { kind: 'node', name: node.name, recordId: node.recordId },
node,
},
} satisfies ProxyOption)
: undefined
}
return selectedGroup
? findCurrentGroupMember(proxyView, selectedGroup)
: undefined
}, [isDirectMode, proxyView, selectedGroup])
latestProxyMemberRef.current = currentOption?.member ?? null
const handleGroupChange = useCallback(
(event: SelectChangeEvent<string>) => {
if (isGlobalMode || isDirectMode) return
const newGroup = event.target.value
writeProfileScopedItem(STORAGE_KEY_GROUP, newGroup)
setState((prev) => {
const group = prev.proxyData.groups.find(
(g: { name: string }) => g.name === newGroup,
)
if (group) {
return {
...prev,
selection: {
group: newGroup,
proxy: group.now,
},
displayProxy: prev.proxyData.records[group.now] || null,
}
}
return {
...prev,
selection: {
...prev.selection,
group: newGroup,
},
}
})
const newGroupName = event.target.value
setSelectedGroupName(newGroupName)
writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName)
},
[isGlobalMode, isDirectMode, writeProfileScopedItem],
[isDirectMode, isGlobalMode, writeProfileScopedItem],
)
// 处理代理节点变更
const optionValue = (option: ProxyOption) =>
`${option.memberIndex}:${
option.member.kind === 'node'
? option.member.node.recordId
: option.member.ref.name
}`
const handleProxyChange = useCallback(
(event: SelectChangeEvent<string>) => {
if (isDirectMode) return
const newProxy = event.target.value
const currentGroup = state.selection.group
const previousProxy = state.selection.proxy
setState((prev: ProxyState) => ({
...prev,
selection: {
...prev.selection,
proxy: newProxy,
},
displayProxy: prev.proxyData.records[newProxy] || null,
}))
if (!isGlobalMode && !isDirectMode) {
writeProfileScopedItem(STORAGE_KEY_PROXY, newProxy)
const option = unsortedProxyOptions.find(
(candidate) => optionValue(candidate) === event.target.value,
)
if (!selectedGroup || !option || !isInteractableMember(option.member)) {
return
}
const skipConfigSave = isGlobalMode || isDirectMode
handleSelectChange(currentGroup, previousProxy, skipConfigSave)(event)
const previousProxy = selectedGroup.now
const nextName = option.member.ref.name
handleSelectChange(
selectedGroup.name,
previousProxy,
isGlobalMode,
)({
target: { value: nextName },
})
},
[
handleSelectChange,
isDirectMode,
isGlobalMode,
state.selection,
handleSelectChange,
writeProfileScopedItem,
selectedGroup,
unsortedProxyOptions,
],
)
@@ -518,20 +366,18 @@ export const CurrentProxyCard = () => {
navigate('/proxies')
}, [navigate])
// 获取要显示的代理节点
const currentProxy = useMemo(() => {
return state.displayProxy
}, [state.displayProxy])
const currentMember = currentOption?.member
const currentProxy = currentMember ? memberDetails(currentMember) : undefined
const selectedProxyName = currentMember?.ref.name ?? ''
// 获取当前节点的延迟(增加非空校验)
const currentDelay =
currentProxy && state.selection.group
? delayManager.getDelayFix(currentProxy, state.selection.group)
currentMember && selectedGroupName
? delayManager.getDelayFix(currentMember, selectedGroupName)
: -1
// 信号图标(增加非空校验)
const signalInfo =
currentProxy && state.selection.group
currentProxy && selectedGroupName
? getSignalIcon(currentDelay)
: { icon: <SignalNone />, text: '未初始化', color: 'text.secondary' }
@@ -539,13 +385,13 @@ export const CurrentProxyCard = () => {
if (autoCheckInProgressRef.current) return
if (isDirectMode) return
const groupName = state.selection.group
const proxyName = state.selection.proxy
const groupName = selectedGroupName
const proxyName = selectedProxyName
if (!groupName || !proxyName) return
const proxyRecord = latestProxyRecordRef.current
if (!proxyRecord) {
const proxyMember = latestProxyMemberRef.current
if (!proxyMember || !isInteractableMember(proxyMember)) {
debugLog(
`[CurrentProxyCard] 自动延迟检测跳过,组: ${groupName}, 节点: ${proxyName} 未找到`,
)
@@ -560,12 +406,7 @@ export const CurrentProxyCard = () => {
debugLog(
`[CurrentProxyCard] 自动检测当前节点延迟,组: ${groupName}, 节点: ${proxyName}`,
)
await delayManager.checkDelay(
proxyRecord.name,
groupName,
timeout,
proxyRecord.provider,
)
await delayManager.checkDelay(proxyMember, groupName, timeout)
} catch (error) {
console.error(
`[CurrentProxyCard] 自动检测当前节点延迟失败,组: ${groupName}, 节点: ${proxyName}`,
@@ -581,15 +422,15 @@ export const CurrentProxyCard = () => {
}, [
isDirectMode,
refreshProxy,
state.selection.group,
state.selection.proxy,
selectedGroupName,
selectedProxyName,
sortType,
])
useEffect(() => {
if (isDirectMode) return
if (!autoDelayEnabled) return
if (!state.selection.group || !state.selection.proxy) return
if (!selectedGroupName || !selectedProxyName) return
let disposed = false
let intervalTimer: ReturnType<typeof setTimeout> | null = null
@@ -617,23 +458,22 @@ export const CurrentProxyCard = () => {
checkCurrentProxyDelay,
autoDelayIntervalMs,
isDirectMode,
state.selection.group,
state.selection.proxy,
selectedGroupName,
selectedProxyName,
autoDelayEnabled,
])
// 自定义渲染选择框中的值
const renderProxyValue = (selected: string) => {
if (!selected || !state.proxyData.records[selected]) return selected
const renderProxyValue = () => {
if (!currentMember) return selectedProxyName
const delayValue = delayManager.getDelayFix(
state.proxyData.records[selected],
state.selection.group,
currentMember,
selectedGroupName,
)
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography noWrap>{selected}</Typography>
<Typography noWrap>{selectedProxyName}</Typography>
<Chip
size="small"
label={delayManager.formatDelay(delayValue)}
@@ -652,50 +492,24 @@ export const CurrentProxyCard = () => {
// 延迟测试
const handleCheckDelay = useLockFn(async () => {
const groupName = state.selection.group
const groupName = selectedGroupName
if (!groupName || isDirectMode) return
debugLog(`[CurrentProxyCard] 开始测试所有延迟,组: ${groupName}`)
const timeout = verge?.default_latency_timeout || 10000
// 获取当前组的所有代理
const delayProxies: IProxyItem[] = []
const interactable = unsortedProxyOptions
.map(({ member }) => member)
.filter(isInteractableMember)
.filter(({ ref }) => ref.name !== 'DIRECT' && ref.name !== 'REJECT')
if (isGlobalMode && proxies?.global) {
// 全局模式
const allProxies = proxies.global.all
.filter((p: any) => {
const name = typeof p === 'string' ? p : p.name
return name !== 'DIRECT' && name !== 'REJECT'
})
.map((p: any) => (typeof p === 'string' ? p : p.name))
allProxies.forEach((name: string) => {
const proxy = state.proxyData.records[name]
delayProxies.push(proxy)
})
} else {
// 规则模式
const group = state.proxyData.groups.find((g) => g.name === groupName)
if (group) {
group.all.forEach((name: string) => {
const proxy = state.proxyData.records[name]
delayProxies.push(proxy)
})
}
}
// 测试全部节点
if (delayProxies.length > 0) {
if (interactable.length > 0) {
const url = delayManager.getUrl(groupName)
debugLog(`[CurrentProxyCard] 测试URL: ${url}, 超时: ${timeout}ms`)
try {
await Promise.race([
delayManager.checkListDelay(delayProxies, groupName, timeout),
delayGroup(groupName, url, timeout),
])
await delayManager.checkListDelay(interactable, groupName, timeout)
debugLog(`[CurrentProxyCard] 延迟测试完成,组: ${groupName}`)
} catch (error) {
console.error(
@@ -716,10 +530,6 @@ export const CurrentProxyCard = () => {
const sortWithLatency = (proxiesToSort: ProxyOption[]) => {
if (!proxiesToSort || sortType === 0) return proxiesToSort
if (!state.proxyData.records || !state.selection.group) {
return proxiesToSort
}
const list = [...proxiesToSort]
if (sortType === 1) {
@@ -740,64 +550,34 @@ export const CurrentProxyCard = () => {
}
list.sort((a, b) => {
const recordA = state.proxyData.records[a.name]
const recordB = state.proxyData.records[b.name]
const [ar, av] = recordA
? categorizeDelay(
delayManager.getDelayFix(recordA, state.selection.group),
)
: [6, Number.MAX_SAFE_INTEGER]
const [br, bv] = recordB
? categorizeDelay(
delayManager.getDelayFix(recordB, state.selection.group),
)
: [6, Number.MAX_SAFE_INTEGER]
const [ar, av] = categorizeDelay(
delayManager.getDelayFix(a.member, selectedGroupName),
)
const [br, bv] = categorizeDelay(
delayManager.getDelayFix(b.member, selectedGroupName),
)
if (ar !== br) return ar - br
if (av !== bv) return av - bv
return refreshTick >= 0 ? a.name.localeCompare(b.name) : 0
return refreshTick >= 0
? a.member.ref.name.localeCompare(b.member.ref.name)
: 0
})
} else {
list.sort((a, b) => a.name.localeCompare(b.name))
list.sort((a, b) => a.member.ref.name.localeCompare(b.member.ref.name))
}
return list
}
if (isDirectMode) {
return [{ name: 'DIRECT' }]
return []
}
if (isGlobalMode && proxies?.global) {
const options = proxies.global.all
.filter((p: any) => {
const name = typeof p === 'string' ? p : p.name
return name !== 'DIRECT' && name !== 'REJECT'
})
.map((p: any) => ({
name: typeof p === 'string' ? p : p.name,
}))
return sortWithLatency(options)
}
// 规则模式
const group = state.selection.group
? state.proxyData.groups.find((g) => g.name === state.selection.group)
: null
if (group) {
const options = group.all.map((name) => ({ name }))
return sortWithLatency(options)
}
return []
return sortWithLatency(unsortedProxyOptions)
}, [
isDirectMode,
isGlobalMode,
proxies,
state.proxyData,
state.selection.group,
unsortedProxyOptions,
selectedGroupName,
sortType,
delaySortRefresh,
defaultLatencyTimeout,
@@ -856,7 +636,7 @@ export const CurrentProxyCard = () => {
size="small"
color="inherit"
onClick={handleCheckDelay}
disabled={isDirectMode}
disabled={isDirectMode || unsortedProxyOptions.length === 0}
>
<NetworkCheckRounded />
</IconButton>
@@ -885,7 +665,7 @@ export const CurrentProxyCard = () => {
>
{isCoreDataPending ? (
<Box sx={{ py: 4, height: 24 }} />
) : currentProxy ? (
) : currentProxy || (!isDirectMode && selectedGroup) ? (
<Box>
{/* 代理节点信息显示 */}
<Box
@@ -902,7 +682,8 @@ export const CurrentProxyCard = () => {
>
<Box>
<Typography variant="body1" sx={{ fontWeight: 'medium' }}>
{currentProxy.name}
{currentProxy?.name ??
t('home.components.currentProxy.labels.noActiveNode')}
</Typography>
<Box
@@ -913,7 +694,7 @@ export const CurrentProxyCard = () => {
color="text.secondary"
sx={{ mr: 1 }}
>
{currentProxy.type}
{currentProxy?.type}
</Typography>
{isGlobalMode && (
<Chip
@@ -932,19 +713,19 @@ export const CurrentProxyCard = () => {
/>
)}
{/* 节点特性 */}
{currentProxy.udp && (
{currentProxy?.udp && (
<Chip size="small" label="UDP" variant="outlined" />
)}
{currentProxy.tfo && (
{currentProxy?.tfo && (
<Chip size="small" label="TFO" variant="outlined" />
)}
{currentProxy.xudp && (
{currentProxy?.xudp && (
<Chip size="small" label="XUDP" variant="outlined" />
)}
{currentProxy.mptcp && (
{currentProxy?.mptcp && (
<Chip size="small" label="MPTCP" variant="outlined" />
)}
{currentProxy.smux && (
{currentProxy?.smux && (
<Chip size="small" label="SMUX" variant="outlined" />
)}
</Box>
@@ -971,12 +752,12 @@ export const CurrentProxyCard = () => {
</InputLabel>
<Select
labelId="proxy-group-select-label"
value={state.selection.group}
value={selectedGroupName}
onChange={handleGroupChange}
label={t('home.components.currentProxy.labels.group')}
disabled={isGlobalMode || isDirectMode}
>
{state.proxyData.groups.map((group) => (
{selectableGroups.map((group) => (
<MenuItem key={group.name} value={group.name}>
{group.name}
</MenuItem>
@@ -991,7 +772,7 @@ export const CurrentProxyCard = () => {
</InputLabel>
<Select
labelId="proxy-select-label"
value={state.selection.proxy}
value={currentOption ? optionValue(currentOption) : ''}
onChange={handleProxyChange}
label={t('home.components.currentProxy.labels.proxy')}
disabled={isDirectMode}
@@ -1008,19 +789,19 @@ export const CurrentProxyCard = () => {
>
{isDirectMode
? null
: proxyOptions.map((proxy) => {
const delayValue =
state.proxyData.records[proxy.name] &&
state.selection.group
? delayManager.getDelayFix(
state.proxyData.records[proxy.name],
state.selection.group,
)
: -1
: proxyOptions.map((option) => {
const interactable = isInteractableMember(option.member)
const delayValue = interactable
? delayManager.getDelayFix(
option.member,
selectedGroupName,
)
: -1
return (
<MenuItem
key={proxy.name}
value={proxy.name}
key={optionValue(option)}
value={optionValue(option)}
disabled={!interactable}
sx={{
display: 'flex',
justifyContent: 'space-between',
@@ -1030,18 +811,20 @@ export const CurrentProxyCard = () => {
}}
>
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
{proxy.name}
{option.member.ref.name}
</Typography>
<Chip
size="small"
label={delayManager.formatDelay(delayValue)}
color={convertDelayColor(delayValue)}
sx={{
minWidth: '60px',
height: '22px',
flexShrink: 0,
}}
/>
{interactable && (
<Chip
size="small"
label={delayManager.formatDelay(delayValue)}
color={convertDelayColor(delayValue)}
sx={{
minWidth: '60px',
height: '22px',
flexShrink: 0,
}}
/>
)}
</MenuItem>
)
})}
+172 -171
View File
@@ -18,7 +18,7 @@ import {
} from '@mui/material'
import { useLockFn } from 'ahooks'
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { updateProxyProvider } from 'tauri-plugin-mihomo-api'
@@ -48,16 +48,11 @@ const parseExpire = (expire?: number) => {
export const ProviderButton = () => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const { proxyProviders } = useProxiesData()
const { refreshProxy, refreshProxyProviders } = useAppRefreshers()
const { proxyView } = useProxiesData()
const { refreshProxy } = useAppRefreshers()
const [updating, setUpdating] = useState<Record<string, boolean>>({})
useEffect(() => {
refreshProxyProviders().catch(() => {})
}, [refreshProxyProviders])
// 检查是否有提供者
const hasProviders = Object.keys(proxyProviders || {}).length > 0
const providers = proxyView?.providers ?? []
const providerUnavailable = proxyView?.providerState === 'unavailable'
// 更新单个代理提供者
const updateProvider = useLockFn(async (name: string) => {
@@ -69,7 +64,6 @@ export const ProviderButton = () => {
// 刷新数据
await refreshProxy()
await refreshProxyProviders()
showNotice.success(
'proxies.feedback.notifications.provider.updateSuccess',
@@ -92,7 +86,7 @@ export const ProviderButton = () => {
const updateAllProviders = useLockFn(async () => {
try {
// 获取所有provider的名称
const allProviders = Object.keys(proxyProviders || {})
const allProviders = providers.map(({ name }) => name)
if (allProviders.length === 0) {
showNotice.info('proxies.feedback.notifications.provider.none')
return
@@ -122,7 +116,6 @@ export const ProviderButton = () => {
// 刷新数据
await refreshProxy()
await refreshProxyProviders()
showNotice.success('proxies.feedback.notifications.provider.allUpdated')
} catch (err) {
@@ -139,7 +132,7 @@ export const ProviderButton = () => {
setOpen(false)
}
if (!hasProviders) return null
if (providers.length === 0 && !providerUnavailable) return null
return (
<>
@@ -148,7 +141,17 @@ export const ProviderButton = () => {
size="small"
startIcon={<StorageOutlined />}
onClick={() => setOpen(true)}
sx={{ mr: 1 }}
disabled={providerUnavailable}
color={providerUnavailable ? 'warning' : 'primary'}
sx={{
mr: 1,
...(providerUnavailable && {
'&.Mui-disabled': {
color: 'warning.main',
borderColor: 'warning.main',
},
}),
}}
>
{t('proxies.page.provider.title')}
</Button>
@@ -180,170 +183,168 @@ export const ProviderButton = () => {
<DialogContent>
<List sx={{ py: 0, minHeight: 250 }}>
{Object.entries(proxyProviders || {})
.sort()
.map(([key, item]) => {
const provider = item
const time = dayjs(provider.updatedAt)
const isUpdating = updating[key]
{providers.map((provider) => {
const key = provider.name
const updatedAt = provider.updatedAt
? dayjs(provider.updatedAt)
: null
const isUpdating = updating[key]
// 订阅信息
const sub = provider.subscriptionInfo
const hasSubInfo = !!sub
const upload = sub?.Upload || 0
const download = sub?.Download || 0
const total = sub?.Total || 0
const expire = sub?.Expire || 0
// 订阅信息
const sub = provider.subscriptionInfo
const hasSubInfo = !!sub
const upload = sub?.upload || 0
const download = sub?.download || 0
const total = sub?.total || 0
const expire = sub?.expire || 0
// 流量使用进度
const progress =
total > 0
? Math.min(
Math.round(((download + upload) * 100) / total) + 1,
100,
)
: 0
// 流量使用进度
const progress =
total > 0
? Math.min(
Math.round(((download + upload) * 100) / total) + 1,
100,
)
: 0
return (
<ListItem
key={key}
sx={[
{
p: 0,
mb: '8px',
borderRadius: 2,
overflow: 'hidden',
transition: 'all 0.2s',
},
({ palette: { mode, primary } }) => {
const bgcolor = mode === 'light' ? '#ffffff' : '#24252f'
const hoverColor =
mode === 'light'
? alpha(primary.main, 0.1)
: alpha(primary.main, 0.2)
return (
<ListItem
key={key}
sx={[
{
p: 0,
mb: '8px',
borderRadius: 2,
overflow: 'hidden',
transition: 'all 0.2s',
},
({ palette: { mode, primary } }) => {
const bgcolor = mode === 'light' ? '#ffffff' : '#24252f'
const hoverColor =
mode === 'light'
? alpha(primary.main, 0.1)
: alpha(primary.main, 0.2)
return {
backgroundColor: bgcolor,
'&:hover': {
backgroundColor: hoverColor,
},
}
},
]}
>
<ListItemText
sx={{ px: 2, py: 1 }}
primary={
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography
variant="subtitle1"
component="div"
noWrap
title={key}
sx={{ display: 'flex', alignItems: 'center' }}
>
<span style={{ marginRight: '8px' }}>{key}</span>
<TypeBox component="span">
{provider.proxies.length}
</TypeBox>
<TypeBox component="span">
{provider.vehicleType}
</TypeBox>
</Typography>
<Typography
variant="body2"
color="text.secondary"
noWrap
>
<small>{t('shared.labels.updateAt')}: </small>
{time.fromNow()}
</Typography>
</Box>
return {
backgroundColor: bgcolor,
'&:hover': {
backgroundColor: hoverColor,
},
}
secondary={
<>
{/* 订阅信息 */}
{hasSubInfo && (
<>
<Box
sx={{
mb: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span
title={t('shared.labels.usedTotal') as string}
>
{parseTraffic(upload + download)} /{' '}
{parseTraffic(total)}
</span>
<span
title={
t('shared.labels.expireTime') as string
}
>
{parseExpire(expire)}
</span>
</Box>
{/* 进度条 */}
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 6,
borderRadius: 3,
opacity: total > 0 ? 1 : 0,
}}
/>
</>
)}
</>
}
/>
<Divider orientation="vertical" flexItem />
<Box
sx={{
width: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<IconButton
size="small"
color="primary"
onClick={() => {
updateProvider(key)
}}
disabled={isUpdating}
},
]}
>
<ListItemText
sx={{ px: 2, py: 1 }}
primary={
<Box
sx={{
animation: isUpdating
? 'spin 1s linear infinite'
: 'none',
'@keyframes spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
title={t('proxies.page.provider.actions.update')}
aria-label={t('proxies.page.provider.actions.update')}
>
<RefreshRounded />
</IconButton>
</Box>
</ListItem>
)
})}
<Typography
variant="subtitle1"
component="div"
noWrap
title={key}
sx={{ display: 'flex', alignItems: 'center' }}
>
<span style={{ marginRight: '8px' }}>{key}</span>
<TypeBox component="span">
{provider.proxyRecordIds.length}
</TypeBox>
<TypeBox component="span">
{provider.vehicleType}
</TypeBox>
</Typography>
<Typography
variant="body2"
color="text.secondary"
noWrap
>
<small>{t('shared.labels.updateAt')}: </small>
{updatedAt?.fromNow() ?? '-'}
</Typography>
</Box>
}
secondary={
<>
{/* 订阅信息 */}
{hasSubInfo && (
<>
<Box
sx={{
mb: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span
title={t('shared.labels.usedTotal') as string}
>
{parseTraffic(upload + download)} /{' '}
{parseTraffic(total)}
</span>
<span
title={t('shared.labels.expireTime') as string}
>
{parseExpire(expire)}
</span>
</Box>
{/* 进度条 */}
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 6,
borderRadius: 3,
opacity: total > 0 ? 1 : 0,
}}
/>
</>
)}
</>
}
/>
<Divider orientation="vertical" flexItem />
<Box
sx={{
width: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<IconButton
size="small"
color="primary"
onClick={() => {
updateProvider(key)
}}
disabled={isUpdating}
sx={{
animation: isUpdating
? 'spin 1s linear infinite'
: 'none',
'@keyframes spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
}}
title={t('proxies.page.provider.actions.update')}
aria-label={t('proxies.page.provider.actions.update')}
>
<RefreshRounded />
</IconButton>
</Box>
</ListItem>
)
})}
</List>
</DialogContent>
+36
View File
@@ -0,0 +1,36 @@
import {
getRecord,
rebindNode,
type ProxyNodeView,
type ProxyViewV1,
} from '@/types/proxy-view'
export interface ProxyChainItem {
id: string
name: string
recordId?: string
source?: ProxyNodeView['source']
type?: string
delay?: number
}
export const rebindProxyChainItems = (
items: readonly ProxyChainItem[],
candidates: readonly ProxyNodeView[],
proxyView: ProxyViewV1,
): ProxyChainItem[] =>
items.map((item) => {
const rebound = rebindNode(candidates, {
name: item.name,
source: item.source,
})
const record =
rebound === undefined ? undefined : getRecord(proxyView, rebound.recordId)
return {
...item,
recordId: rebound?.recordId,
source: rebound?.source ?? item.source,
type: rebound?.type ?? item.type,
delay: record?.history.at(-1)?.delay,
}
})
+94 -90
View File
@@ -42,16 +42,18 @@ import {
} from 'tauri-plugin-mihomo-api'
import { TooltipIcon } from '@/components/base'
import { useRuntimeConfig } from '@/hooks/use-clash'
import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context'
import { updateProxyChainConfigInRuntime } from '@/services/cmds'
import {
selectGlobalChainNodes,
selectRuleChainMembers,
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
interface ProxyChainItem {
id: string
name: string
type?: string
delay?: number
}
import { rebindProxyChainItems, type ProxyChainItem } from './proxy-chain-model'
type RuntimeConfigWithProxySequence = IConfigData & { proxies?: unknown }
interface ParsedChainConfig {
proxies?: Array<{
@@ -147,6 +149,7 @@ const SortableItem = ({
: `1px solid ${theme.palette.divider}`,
boxShadow: isDragging ? theme.shadows[4] : theme.shadows[1],
transition: 'box-shadow 0.2s, background-color 0.2s',
opacity: proxy.recordId === undefined ? 0.55 : undefined,
}}
>
<Box
@@ -254,47 +257,78 @@ export const ProxyChain = ({
const theme = useTheme()
const { t } = useTranslation()
const chainWarning = t('proxies.page.chain.warning')
const { proxies } = useProxiesData()
const { proxyView } = useProxiesData()
const { refreshProxy } = useAppRefreshers()
const { data: runtimeConfig } = useRuntimeConfig(true)
const [isConnecting, setIsConnecting] = useState(false)
const markUnsavedChanges = useCallback(() => {
onMarkUnsavedChanges?.()
}, [onMarkUnsavedChanges])
const candidates = useMemo(() => {
if (!proxyView) return []
if (mode === 'rule' && selectedGroup) {
return selectRuleChainMembers(proxyView, selectedGroup).flatMap(
({ member }) => (member.kind === 'node' ? [member.node] : []),
)
}
if (!runtimeConfig) return []
const runtimeProxies = (
runtimeConfig as RuntimeConfigWithProxySequence | null
)?.proxies
return selectGlobalChainNodes(proxyView, runtimeProxies)
}, [mode, proxyView, runtimeConfig, selectedGroup])
const currentProxyChain = useMemo(
() =>
proxyView
? rebindProxyChainItems(proxyChain, candidates, proxyView)
: proxyChain.map((item) => ({
...item,
recordId: undefined,
delay: undefined,
})),
[candidates, proxyChain, proxyView],
)
const isConnected = useMemo(() => {
if (!proxies || proxyChain.length < 2) {
if (!proxyView || currentProxyChain.length === 0) {
return false
}
const lastNode = proxyChain[proxyChain.length - 1]
const lastNode = currentProxyChain[currentProxyChain.length - 1]
if (localStorage.getItem('proxy-chain-exit-node') === lastNode.name) {
return true
}
if (currentProxyChain.length < 2) return false
if (mode === 'global') {
return proxies.global?.now === lastNode.name
return proxyView.global?.now === lastNode.name
}
if (!selectedGroup || !Array.isArray(proxies.groups)) {
if (!selectedGroup) {
return false
}
const proxyChainGroup = proxies.groups.find(
(group: { name: string }) => group.name === selectedGroup,
const proxyChainGroup = proxyView.groups.find(
(group) => group.name === selectedGroup,
)
return proxyChainGroup?.now === lastNode.name
}, [proxies, proxyChain, mode, selectedGroup])
}, [proxyView, currentProxyChain, mode, selectedGroup])
// 监听链的变化,但排除从配置加载的情况
const chainLengthRef = useRef(proxyChain.length)
const chainLengthRef = useRef(currentProxyChain.length)
useEffect(() => {
// 只有当链长度发生变化且不是初始加载时,才标记为未保存
if (
chainLengthRef.current !== proxyChain.length &&
chainLengthRef.current !== currentProxyChain.length &&
chainLengthRef.current !== 0
) {
markUnsavedChanges()
}
chainLengthRef.current = proxyChain.length
}, [proxyChain.length, markUnsavedChanges])
chainLengthRef.current = currentProxyChain.length
}, [currentProxyChain.length, markUnsavedChanges])
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -310,23 +344,27 @@ export const ProxyChain = ({
const { active, over } = event
if (active.id !== over?.id) {
const oldIndex = proxyChain.findIndex((item) => item.id === active.id)
const newIndex = proxyChain.findIndex((item) => item.id === over?.id)
const oldIndex = currentProxyChain.findIndex(
(item) => item.id === active.id,
)
const newIndex = currentProxyChain.findIndex(
(item) => item.id === over?.id,
)
onUpdateChain(arrayMove(proxyChain, oldIndex, newIndex))
onUpdateChain(arrayMove(currentProxyChain, oldIndex, newIndex))
markUnsavedChanges()
}
},
[proxyChain, onUpdateChain, markUnsavedChanges],
[currentProxyChain, onUpdateChain, markUnsavedChanges],
)
const handleRemoveProxy = useCallback(
(id: string) => {
const newChain = proxyChain.filter((item) => item.id !== id)
const newChain = currentProxyChain.filter((item) => item.id !== id)
onUpdateChain(newChain)
markUnsavedChanges()
},
[proxyChain, onUpdateChain, markUnsavedChanges],
[currentProxyChain, onUpdateChain, markUnsavedChanges],
)
const handleConnect = useCallback(async () => {
@@ -344,9 +382,9 @@ export const ProxyChain = ({
try {
await selectNodeForGroup(targetGroup, 'DIRECT')
} catch {
if (proxyChain.length >= 1) {
if (currentProxyChain.length >= 1) {
try {
await selectNodeForGroup(targetGroup, proxyChain[0].name)
await selectNodeForGroup(targetGroup, currentProxyChain[0].name)
} catch {
// ignore
}
@@ -371,7 +409,15 @@ export const ProxyChain = ({
return
}
if (proxyChain.length < 2) {
if (mode === 'global' && proxyView?.global === null) {
alert(t('proxies.page.chain.connectFailed') || '连接链式代理失败')
return
}
if (
currentProxyChain.length < 2 ||
currentProxyChain.some(({ recordId }) => !recordId)
) {
alert(t('proxies.page.chain.minimumNodes') || '链式代理至少需要2个节点')
return
}
@@ -379,13 +425,13 @@ export const ProxyChain = ({
setIsConnecting(true)
try {
// 第一步:保存链式代理配置
const chainProxies = proxyChain.map((node) => node.name)
const chainProxies = currentProxyChain.map((node) => node.name)
debugLog('Saving chain config:', chainProxies)
await updateProxyChainConfigInRuntime(chainProxies)
debugLog('Chain configuration saved successfully')
// 第二步:连接到代理链的最后一个节点
const lastNode = proxyChain[proxyChain.length - 1]
const lastNode = currentProxyChain[currentProxyChain.length - 1]
debugLog(`Connecting to proxy chain, last node: ${lastNode.name}`)
// 根据模式确定使用的代理组名称
@@ -409,23 +455,16 @@ export const ProxyChain = ({
setIsConnecting(false)
}
}, [
proxyChain,
currentProxyChain,
isConnected,
t,
refreshProxy,
mode,
proxyView,
selectedGroup,
onUpdateChain,
])
const proxyChainRef = useRef(proxyChain)
const onUpdateChainRef = useRef(onUpdateChain)
useEffect(() => {
proxyChainRef.current = proxyChain
onUpdateChainRef.current = onUpdateChain
}, [proxyChain, onUpdateChain])
// 处理链式代理配置数据
useEffect(() => {
if (chainConfigData) {
@@ -443,47 +482,6 @@ export const ProxyChain = ({
}
}, [chainConfigData, onUpdateChain])
// 定时更新延迟数据
useEffect(() => {
if (!proxies?.records) return
const updateDelays = () => {
const currentChain = proxyChainRef.current
if (currentChain.length === 0) return
const updatedChain = currentChain.map((item) => {
const proxyRecord = proxies.records[item.name]
if (
proxyRecord &&
proxyRecord.history &&
proxyRecord.history.length > 0
) {
const latestDelay =
proxyRecord.history[proxyRecord.history.length - 1].delay
return { ...item, delay: latestDelay }
}
return item
})
// 只有在延迟数据确实发生变化时才更新
const hasChanged = updatedChain.some(
(item, index) => item.delay !== currentChain[index]?.delay,
)
if (hasChanged) {
onUpdateChainRef.current(updatedChain)
}
}
// 立即更新一次延迟
updateDelays()
// 设置定时器,每5秒更新一次延迟
const interval = setInterval(updateDelays, 5000)
return () => clearInterval(interval)
}, [proxies?.records]) // 只依赖proxies.records
return (
<Paper
elevation={1}
@@ -512,7 +510,7 @@ export const ProxyChain = ({
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{proxyChain.length > 0 && (
{currentProxyChain.length > 0 && (
<IconButton
size="small"
onClick={() => {
@@ -542,15 +540,20 @@ export const ProxyChain = ({
onClick={handleConnect}
disabled={
isConnecting ||
proxyChain.length < 2 ||
(mode !== 'global' && !selectedGroup)
(!isConnected &&
(currentProxyChain.length < 2 ||
currentProxyChain.some(
({ recordId }) => recordId === undefined,
) ||
(mode === 'global' && proxyView?.global === null) ||
(mode !== 'global' && !selectedGroup)))
}
color={isConnected ? 'error' : 'success'}
sx={{
minWidth: 90,
}}
title={
proxyChain.length < 2
!isConnected && currentProxyChain.length < 2
? t('proxies.page.chain.minimumNodes') ||
'链式代理至少需要2个节点'
: undefined
@@ -566,10 +569,10 @@ export const ProxyChain = ({
</Box>
<Alert
severity={proxyChain.length === 1 ? 'warning' : 'info'}
severity={currentProxyChain.length === 1 ? 'warning' : 'info'}
sx={{ mb: 2 }}
>
{proxyChain.length === 1
{currentProxyChain.length === 1
? t('proxies.page.chain.minimumNodesHint') ||
'链式代理至少需要2个节点,请再添加一个节点。'
: t('proxies.page.chain.instruction') ||
@@ -577,7 +580,7 @@ export const ProxyChain = ({
</Alert>
<Box sx={{ flex: 1, overflow: 'auto' }}>
{proxyChain.length === 0 ? (
{currentProxyChain.length === 0 ? (
<Box
sx={{
display: 'flex',
@@ -596,7 +599,7 @@ export const ProxyChain = ({
onDragEnd={handleDragEnd}
>
<SortableContext
items={proxyChain.map((proxy) => proxy.id)}
items={currentProxyChain.map((proxy) => proxy.id)}
strategy={verticalListSortingStrategy}
>
<Box
@@ -606,18 +609,19 @@ export const ProxyChain = ({
p: 1,
}}
>
{proxyChain.map((proxy, index) => (
{currentProxyChain.map((proxy, index) => (
<Box key={proxy.id}>
<SortableItem
proxy={proxy}
index={index}
isFirst={index === 0}
isLast={
index === proxyChain.length - 1 && proxyChain.length > 1
index === currentProxyChain.length - 1 &&
currentProxyChain.length > 1
}
onRemove={handleRemoveProxy}
/>
{index < proxyChain.length - 1 && (
{index < currentProxyChain.length - 1 && (
<Box
sx={{
display: 'flex',
+68 -30
View File
@@ -21,24 +21,24 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { useProxiesData } from '@/providers/app-data-context'
import { updateProxyChainConfigInRuntime } from '@/services/cmds'
import {
isInteractableMember,
type ProxyGroupView,
type ResolvedProxyMember,
} from '@/types/proxy-view'
import { ScrollTopButton } from '../layout/scroll-top-button'
import { ProxyChain } from './proxy-chain'
import { type ProxyChainItem, rebindProxyChainItems } from './proxy-chain-model'
import { ProxyRender } from './proxy-render'
import type { HeadState } from './use-head-state'
import type { IRenderItem } from './use-render-list'
// ---- Types ----
interface ProxyChainItem {
id: string
name: string
type?: string
delay?: number
}
type VirtualListItem = {
key: Key
index: number
@@ -46,11 +46,7 @@ type VirtualListItem = {
end: number
}
interface ProxyGroupOption {
name: string
type: string
all?: unknown[]
}
type ProxyGroupOption = ProxyGroupView
// ---- Props ----
@@ -201,7 +197,7 @@ function GroupSelectMenu({
{group.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{group.type} · {group.all?.length ?? 0} 节点
{group.type} · {group.members.length} 节点
</Typography>
</Box>
</MenuItem>
@@ -243,7 +239,7 @@ function ProxyVirtualList({
onLocation: (group: any) => void
onCheckAll: (groupName: string) => void
onHeadState: (groupName: string, patch: Partial<HeadState>) => void
onChangeProxy: (group: IProxyGroupItem, proxy: IProxyItem) => void
onChangeProxy: (group: ProxyGroupView, member: ResolvedProxyMember) => void
}) {
const theme = useTheme()
const stickyBackground =
@@ -316,6 +312,7 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
onGroupSelect,
onScrollToTop,
} = props
const { proxyView } = useProxiesData()
// Chain-specific state
const [proxyChain, setProxyChain] = useState<ProxyChainItem[]>(() => {
@@ -330,13 +327,44 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
return []
})
const candidateNodes = useMemo(
() =>
renderList.flatMap((item) => {
const occurrences = item.memberCol ?? (item.member ? [item.member] : [])
return occurrences.flatMap(({ member }) =>
member.kind === 'node' ? [member.node] : [],
)
}),
[renderList],
)
const currentProxyChain = useMemo(
() =>
proxyView
? rebindProxyChainItems(proxyChain, candidateNodes, proxyView)
: proxyChain.map((item) => ({
...item,
recordId: undefined,
delay: undefined,
})),
[candidateNodes, proxyChain, proxyView],
)
useEffect(() => {
if (proxyChain.length > 0) {
localStorage.setItem('proxy-chain-items', JSON.stringify(proxyChain))
if (currentProxyChain.length > 0) {
const persistedChain = currentProxyChain.map(
({ id, name, type, delay }) => ({
id,
name,
type,
delay,
}),
)
localStorage.setItem('proxy-chain-items', JSON.stringify(persistedChain))
} else {
localStorage.removeItem('proxy-chain-items')
}
}, [proxyChain])
}, [currentProxyChain])
const [ruleMenuAnchor, setRuleMenuAnchor] = useState<null | HTMLElement>(null)
const [duplicateWarning, setDuplicateWarning] = useState<{
@@ -349,7 +377,7 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
if (!activeSelectedGroup) return null
return (
availableGroups.find(
(group: any) => group.name === activeSelectedGroup,
(group: ProxyGroupView) => group.name === activeSelectedGroup,
) ?? null
)
}, [activeSelectedGroup, availableGroups])
@@ -381,11 +409,19 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
}, [])
const handleChangeProxy = useCallback(
(_group: IProxyGroupItem, proxy: IProxyItem) => {
// 使用函数式更新来避免状态延迟问题
(_group: ProxyGroupView, member: ResolvedProxyMember) => {
if (!isInteractableMember(member) || member.kind !== 'node') return
const { node } = member
setProxyChain((prev) => {
// 检查是否已经存在相同名称的代理,防止重复添加
if (prev.some((item) => item.name === proxy.name)) {
const current = proxyView
? rebindProxyChainItems(prev, candidateNodes, proxyView)
: prev
if (
current.some(
(item) =>
item.recordId !== undefined && item.recordId === node.recordId,
)
) {
const warningMessage = t('proxies.page.chain.duplicateNode')
setDuplicateWarning({
open: true,
@@ -396,21 +432,23 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
// 安全获取延迟数据,如果没有延迟数据则设为 undefined
const delay =
proxy.history && proxy.history.length > 0
? proxy.history[proxy.history.length - 1].delay
node.history.length > 0
? node.history[node.history.length - 1].delay
: undefined
const chainItem: ProxyChainItem = {
id: `${proxy.name}_${Date.now()}`,
name: proxy.name,
type: proxy.type,
id: `${node.name}_${Date.now()}`,
name: node.name,
recordId: node.recordId,
source: node.source,
type: node.type,
delay,
}
return [...prev, chainItem]
return [...current, chainItem]
})
},
[t],
[candidateNodes, proxyView, t],
)
// Render virtual list for chain mode
@@ -455,7 +493,7 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
<Box sx={{ width: '400px', minWidth: '300px' }}>
<ProxyChain
proxyChain={proxyChain}
proxyChain={currentProxyChain}
onUpdateChain={setProxyChain}
chainConfigData={chainConfigData}
mode={mode}
+37 -43
View File
@@ -11,7 +11,6 @@ import {
useRef,
useState,
} from 'react'
import { delayGroup } from 'tauri-plugin-mihomo-api'
import {
BaseEmpty,
@@ -22,9 +21,13 @@ import {
import { useProxySelection } from '@/hooks/use-proxy-selection'
import { useVerge } from '@/hooks/use-verge'
import { useProxiesData } from '@/providers/app-data-context'
import { calcuProxies } from '@/services/cmds'
import delayManager from '@/services/delay'
import { useQuery } from '@/services/query-client'
import {
isInteractableMember,
resolveMember,
type ProxyGroupView,
type ResolvedProxyMember,
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
import {
@@ -58,6 +61,7 @@ function useProxyRenderState(
activeSelectedGroup: string | null,
) {
const { verge } = useVerge()
const { proxyView } = useProxiesData()
const { renderList, onProxies, onHeadState } = useRenderList(
mode,
isChainMode,
@@ -88,28 +92,27 @@ function useProxyRenderState(
useLockFn(async (groupName: string) => {
debugLog(`[ProxyGroups] 开始测试所有延迟,组: ${groupName}`)
const proxies = renderList
.filter(
(e) => e.group?.name === groupName && (e.type === 2 || e.type === 4),
)
.flatMap((e) => e.proxyCol || e.proxy!)
.filter(Boolean)
const group =
proxyView?.groups.find(({ name }) => name === groupName) ??
(proxyView?.global?.name === groupName ? proxyView.global : undefined)
const occurrences =
proxyView && group
? group.members.map((member, memberIndex) => ({
memberIndex,
member: resolveMember(proxyView, member),
}))
: []
const interactable = occurrences
.map(({ member }) => member)
.filter(isInteractableMember)
debugLog(`[ProxyGroups] 找到代理数量: ${proxies.length}`)
debugLog(`[ProxyGroups] 找到代理数量: ${interactable.length}`)
const url = delayManager.getUrl(groupName)
debugLog(`[ProxyGroups] 测试URL: ${url}, 超时: ${timeout}ms`)
try {
await Promise.race([
delayManager.checkListDelay(proxies, groupName, timeout),
delayGroup(groupName, url, timeout).then((result) => {
debugLog(
`[ProxyGroups] getGroupProxyDelays返回结果数量:`,
Object.keys(result || {}).length,
)
}), // 查询group delays 将清除fixed(不关注调用结果)
])
await delayManager.checkListDelay(interactable, groupName, timeout)
debugLog(`[ProxyGroups] 延迟测试完成,组: ${groupName}`)
} catch (error) {
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error)
@@ -170,16 +173,16 @@ function ChainProxyGroups(props: {
chainConfigData?: string | null
}) {
const { mode, chainConfigData } = props
const { proxies: proxiesData } = useProxiesData()
const { proxyView } = useProxiesData()
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
const availableGroups = useMemo(() => {
const groups = proxiesData?.groups
const groups = proxyView?.groups
if (!groups) return []
return groups.filter(
(group: any) => group.type === 'Selector' || group.type === 'URLTest',
(group) => group.type === 'Selector' || group.type === 'URLTest',
)
}, [proxiesData?.groups])
}, [proxyView?.groups])
const defaultRuleGroup = useMemo(() => {
if (mode === 'rule' && availableGroups.length > 0) {
@@ -297,16 +300,16 @@ function ChainProxyGroups(props: {
scrollTopRef.current = 0
}, [])
const handleLocation = useStableCallback((group: IProxyGroupItem) => {
const handleLocation = useStableCallback((group: ProxyGroupView) => {
if (!group) return
const { name, now } = group
const index = renderList.findIndex(
(item) =>
item.group?.name === name &&
((item.type === 2 && item.proxy?.name === now) ||
((item.type === 2 && item.member?.member.ref.name === now) ||
(item.type === 4 &&
item.proxyCol?.some((proxy) => proxy.name === now))),
item.memberCol?.some(({ member }) => member.ref.name === now))),
)
if (index >= 0) {
@@ -449,24 +452,26 @@ function NormalProxyGroups(props: { mode: string }) {
})
const handleChangeProxy = useCallback(
(group: IProxyGroupItem, proxy: IProxyItem) => {
(group: ProxyGroupView, member: ResolvedProxyMember) => {
if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return
if (!isInteractableMember(member)) return
handleProxyGroupChange(group, proxy)
handleProxyGroupChange(group, { name: member.ref.name })
},
[handleProxyGroupChange],
)
// 滚到对应的节点
const handleLocation = useStableCallback((group: IProxyGroupItem) => {
const handleLocation = useStableCallback((group: ProxyGroupView) => {
if (!group) return
const { name, now } = group
const index = renderList.findIndex(
(e) =>
e.group?.name === name &&
((e.type === 2 && e.proxy?.name === now) ||
(e.type === 4 && e.proxyCol?.some((p) => p.name === now))),
((e.type === 2 && e.member?.member.ref.name === now) ||
(e.type === 4 &&
e.memberCol?.some(({ member }) => member.ref.name === now))),
)
if (index >= 0) {
@@ -503,7 +508,7 @@ function NormalProxyGroups(props: { mode: string }) {
// 点击代理组改变展开状态,先滚动到sticky的代理组位置,再收起展开状态
const handleGroupToggle = useCallback(
async (group: IProxyGroupItem) => {
async (group: ProxyGroupView) => {
const index = renderList.findIndex(
(item) => item.type === 0 && item.group.name === group.name,
)
@@ -594,17 +599,6 @@ function NormalProxyGroups(props: { mode: string }) {
export const ProxyGroups = (props: Props) => {
const { mode, isChainMode = false, chainConfigData } = props
// Drive 3s polling on the shared TQ cache; data is read via granular context below
useQuery({
queryKey: ['getProxies'],
queryFn: calcuProxies,
refetchInterval: 3000,
refetchIntervalInBackground: false,
staleTime: 1500,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
if (mode === 'direct') {
return <BaseEmpty textKey="proxies.page.messages.directMode" />
}
+42 -30
View File
@@ -5,32 +5,46 @@ import { useTranslation } from 'react-i18next'
import { BaseLoading } from '@/components/base'
import { useProxyDelayState } from '@/hooks/use-proxy-delay-state'
import delayManager from '@/services/delay'
import {
memberDetails,
providerNameOf,
type ProxyGroupView,
type ResolvedProxyMember,
} from '@/types/proxy-view'
interface Props {
group: IProxyGroupItem
proxy: IProxyItem
group: ProxyGroupView
member: ResolvedProxyMember
selected: boolean
showType?: boolean
onClick?: (name: string) => void
onClick?: (member: ResolvedProxyMember) => void
}
// 多列布局
export const ProxyItemMini = (props: Props) => {
const { group, proxy, selected, showType = true, onClick } = props
const { group, member, selected, showType = true, onClick } = props
const details = memberDetails(member)
const unresolved = member.kind === 'unresolved'
const name = member.ref.name
const type = unresolved ? member.ref.reason : (details?.type ?? '')
const provider =
member.kind === 'node' ? providerNameOf(member.node) : undefined
const now = member.kind === 'group' ? member.group.now : undefined
const { t } = useTranslation()
// -1/<=0 为不显示,-2 为 loading
const { delayValue, isPreset, timeout, onDelay } = useProxyDelayState(
proxy,
member,
group.name,
)
return (
<ListItemButton
dense
selected={selected}
onClick={() => onClick?.(proxy.name)}
disabled={unresolved}
selected={!unresolved && selected}
onClick={unresolved ? undefined : () => onClick?.(member)}
sx={[
{
height: 56,
@@ -70,10 +84,7 @@ export const ProxyItemMini = (props: Props) => {
},
]}
>
<Box
title={`${proxy.name}\n${proxy.now ?? ''}`}
sx={{ overflow: 'hidden' }}
>
<Box title={`${name}\n${now ?? ''}`} sx={{ overflow: 'hidden' }}>
<Typography
variant="body2"
component="div"
@@ -86,7 +97,7 @@ export const ProxyItemMini = (props: Props) => {
whiteSpace: 'nowrap',
}}
>
{proxy.name}
{name}
</Typography>
{showType && (
@@ -98,7 +109,7 @@ export const ProxyItemMini = (props: Props) => {
marginTop: '4px',
}}
>
{proxy.now && (
{now && (
<Typography
variant="body2"
component="div"
@@ -112,38 +123,38 @@ export const ProxyItemMini = (props: Props) => {
marginRight: '8px',
}}
>
{proxy.now}
{now}
</Typography>
)}
{!!proxy.provider && (
{!!provider && (
<TypeBox color="text.secondary" component="span">
{proxy.provider}
{provider}
</TypeBox>
)}
<TypeBox color="text.secondary" component="span">
{proxy.type}
{type}
</TypeBox>
{proxy.udp && (
{!unresolved && details?.udp && (
<TypeBox color="text.secondary" component="span">
UDP
</TypeBox>
)}
{proxy.xudp && (
{!unresolved && details?.xudp && (
<TypeBox color="text.secondary" component="span">
XUDP
</TypeBox>
)}
{proxy.tfo && (
{!unresolved && details?.tfo && (
<TypeBox color="text.secondary" component="span">
TFO
</TypeBox>
)}
{proxy.mptcp && (
{!unresolved && details?.mptcp && (
<TypeBox color="text.secondary" component="span">
MPTCP
</TypeBox>
)}
{proxy.smux && (
{!unresolved && details?.smux && (
<TypeBox color="text.secondary" component="span">
SMUX
</TypeBox>
@@ -154,18 +165,18 @@ export const ProxyItemMini = (props: Props) => {
<Box
sx={{ ml: 0.5, color: 'primary.main', display: isPreset ? 'none' : '' }}
>
{delayValue === -2 && (
{!unresolved && delayValue === -2 && (
<Widget>
<BaseLoading />
</Widget>
)}
{delayValue !== -2 && (
{!unresolved && delayValue !== -2 && (
<Widget
className="the-check"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onDelay()
void onDelay()
}}
sx={({ palette }) => ({
display: 'none', // hover 时显示
@@ -176,14 +187,14 @@ export const ProxyItemMini = (props: Props) => {
</Widget>
)}
{delayValue >= 0 && (
{!unresolved && delayValue >= 0 && (
// 显示延迟
<Widget
className="the-delay"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onDelay(proxy.provider)
void onDelay()
}}
sx={({ palette }) => ({
color: delayManager.formatDelayColor(delayValue, timeout),
@@ -193,7 +204,8 @@ export const ProxyItemMini = (props: Props) => {
{delayManager.formatDelay(delayValue, timeout)}
</Widget>
)}
{proxy.type !== 'Direct' &&
{!unresolved &&
type !== 'Direct' &&
delayValue !== -2 &&
delayValue < 0 &&
selected && (
@@ -204,10 +216,10 @@ export const ProxyItemMini = (props: Props) => {
/>
)}
</Box>
{group.fixed && group.fixed === proxy.name && (
{!unresolved && group.fixed && group.fixed === name && (
// 展示 fixed 状态
<span
className={proxy.name === group.now ? 'the-pin' : 'the-unpin'}
className={name === group.now ? 'the-pin' : 'the-unpin'}
title={
group.type === 'URLTest'
? t('proxies.page.labels.delayCheckReset')
+46 -24
View File
@@ -14,14 +14,20 @@ import {
import { BaseLoading } from '@/components/base'
import { useProxyDelayState } from '@/hooks/use-proxy-delay-state'
import delayManager from '@/services/delay'
import {
memberDetails,
providerNameOf,
type ProxyGroupView,
type ResolvedProxyMember,
} from '@/types/proxy-view'
interface Props {
group: IProxyGroupItem
proxy: IProxyItem
group: ProxyGroupView
member: ResolvedProxyMember
selected: boolean
showType?: boolean
sx?: SxProps<Theme>
onClick?: (name: string) => void
onClick?: (member: ResolvedProxyMember) => void
}
const Widget = styled(Box)(() => ({
@@ -43,11 +49,18 @@ const TypeBox = styled('span')(({ theme }) => ({
}))
export const ProxyItem = (props: Props) => {
const { group, proxy, selected, showType = true, sx, onClick } = props
const { group, member, selected, showType = true, sx, onClick } = props
const details = memberDetails(member)
const unresolved = member.kind === 'unresolved'
const name = member.ref.name
const type = unresolved ? member.ref.reason : (details?.type ?? '')
const provider =
member.kind === 'node' ? providerNameOf(member.node) : undefined
const now = member.kind === 'group' ? member.group.now : undefined
// -1/<=0 为不显示,-2 为 loading
const { delayValue, isPreset, timeout, onDelay } = useProxyDelayState(
proxy,
member,
group.name,
)
@@ -55,8 +68,9 @@ export const ProxyItem = (props: Props) => {
<ListItem sx={sx}>
<ListItemButton
dense
selected={selected}
onClick={() => onClick?.(proxy.name)}
disabled={unresolved}
selected={!unresolved && selected}
onClick={unresolved ? undefined : () => onClick?.(member)}
sx={[
{ borderRadius: 1 },
({ palette: { mode, primary } }) => {
@@ -85,7 +99,7 @@ export const ProxyItem = (props: Props) => {
]}
>
<ListItemText
title={proxy.name}
title={name}
secondary={
<>
<Box
@@ -96,18 +110,26 @@ export const ProxyItem = (props: Props) => {
color: 'text.primary',
}}
>
{proxy.name}
{showType && proxy.now && ` - ${proxy.now}`}
{name}
{showType && now && ` - ${now}`}
</Box>
{showType && !!proxy.provider && (
<TypeBox>{proxy.provider}</TypeBox>
{showType && !!provider && <TypeBox>{provider}</TypeBox>}
{showType && <TypeBox>{type}</TypeBox>}
{!unresolved && showType && details?.udp && (
<TypeBox>UDP</TypeBox>
)}
{!unresolved && showType && details?.xudp && (
<TypeBox>XUDP</TypeBox>
)}
{!unresolved && showType && details?.tfo && (
<TypeBox>TFO</TypeBox>
)}
{!unresolved && showType && details?.mptcp && (
<TypeBox>MPTCP</TypeBox>
)}
{!unresolved && showType && details?.smux && (
<TypeBox>SMUX</TypeBox>
)}
{showType && <TypeBox>{proxy.type}</TypeBox>}
{showType && proxy.udp && <TypeBox>UDP</TypeBox>}
{showType && proxy.xudp && <TypeBox>XUDP</TypeBox>}
{showType && proxy.tfo && <TypeBox>TFO</TypeBox>}
{showType && proxy.mptcp && <TypeBox>MPTCP</TypeBox>}
{showType && proxy.smux && <TypeBox>SMUX</TypeBox>}
</>
}
/>
@@ -119,19 +141,19 @@ export const ProxyItem = (props: Props) => {
display: isPreset ? 'none' : '',
}}
>
{delayValue === -2 && (
{!unresolved && delayValue === -2 && (
<Widget>
<BaseLoading />
</Widget>
)}
{delayValue !== -2 && (
{!unresolved && delayValue !== -2 && (
<Widget
className="the-check"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onDelay(proxy.provider)
void onDelay()
}}
sx={({ palette }) => ({
display: 'none', // hover 时显示
@@ -142,14 +164,14 @@ export const ProxyItem = (props: Props) => {
</Widget>
)}
{delayValue > 0 && (
{!unresolved && delayValue > 0 && (
// 显示延迟
<Widget
className="the-delay"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onDelay(proxy.provider)
void onDelay()
}}
sx={({ palette }) => ({
color: delayManager.formatDelayColor(delayValue, timeout),
@@ -160,7 +182,7 @@ export const ProxyItem = (props: Props) => {
</Widget>
)}
{delayValue !== -2 && delayValue <= 0 && selected && (
{!unresolved && delayValue !== -2 && delayValue <= 0 && selected && (
// 展示已选择的 icon
<CheckCircleOutlineRounded
className="the-icon"
+16 -15
View File
@@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next'
import { useIconCache } from '@/hooks/use-icon-cache'
import { useVerge } from '@/hooks/use-verge'
import { useThemeMode } from '@/services/states'
import type { ResolvedProxyMember } from '@/types/proxy-view'
import { ProxyGroupTools } from './proxy-group-tools'
import { ProxyHead } from './proxy-head'
@@ -36,7 +37,7 @@ interface RenderProps {
onHeadState: (groupName: string, patch: Partial<HeadState>) => void
onChangeProxy: (
group: IRenderItem['group'],
proxy: IRenderItem['proxy'] & { name: string },
member: ResolvedProxyMember,
) => void
onGroupToggle?: (group: IRenderItem['group']) => void
}
@@ -53,7 +54,7 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
onGroupToggle,
isChainMode: _ = false,
} = props
const { type, group, headState, proxy, proxyCol } = item
const { type, group, headState, member, memberCol } = item
const { verge } = useVerge()
const enable_group_icon = verge?.enable_group_icon ?? true
const mode = useThemeMode()
@@ -66,22 +67,22 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
})
const showType = headState?.showType
const proxyColItemsMemo = useMemo(() => {
if (type !== 4 || !proxyCol) {
const memberColItemsMemo = useMemo(() => {
if (type !== 4 || !memberCol) {
return null
}
return proxyCol.map((proxyItem) => (
return memberCol.map((occurrence) => (
<ProxyItemMini
key={`${item.key}-${proxyItem?.name ?? 'unknown'}`}
key={`${item.key}-${occurrence.memberIndex}`}
group={group}
proxy={proxyItem}
selected={group.now === proxyItem?.name}
member={occurrence.member}
selected={group.now === occurrence.member.ref.name}
showType={showType}
onClick={() => onChangeProxy(group, proxyItem)}
onClick={(nextMember) => onChangeProxy(group, nextMember)}
/>
))
}, [type, proxyCol, item.key, group, showType, onChangeProxy])
}, [type, memberCol, item.key, group, showType, onChangeProxy])
if (type === 0) {
return (
@@ -198,7 +199,7 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
>
<Chip
size="small"
label={`${group.all.length}`}
label={`${group.members.length}`}
sx={{
mr: 1,
backgroundColor: (theme) =>
@@ -239,11 +240,11 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
return (
<ProxyItem
group={group}
proxy={proxy!}
selected={group.now === proxy?.name}
member={member!.member}
selected={group.now === member?.member.ref.name}
showType={headState?.showType}
sx={{ py: 0, pl: 2 }}
onClick={() => onChangeProxy(group, proxy!)}
onClick={(nextMember) => onChangeProxy(group, nextMember)}
/>
)
}
@@ -278,7 +279,7 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
gridTemplateColumns: `repeat(${item.col! || 2}, 1fr)`,
}}
>
{proxyColItemsMemo}
{memberColItemsMemo}
</Box>
)
}
+15 -10
View File
@@ -1,6 +1,9 @@
import delayManager from '@/services/delay'
import { memberDetails } from '@/types/proxy-view'
import { compileStringMatcher } from '@/utils/search-matcher'
import type { ResolvedMemberOccurrence } from './use-render-list'
// default | delay | alphabet
export type ProxySortType = 0 | 1 | 2
@@ -11,7 +14,7 @@ export type ProxySearchState = {
}
export function filterSort(
proxies: IProxyItem[],
proxies: ResolvedMemberOccurrence[],
groupName: string,
filterText: string,
sortType: ProxySortType,
@@ -34,7 +37,7 @@ const regex2 = /type=(.*)/i
* according to the regular conditions
*/
function filterProxies(
proxies: IProxyItem[],
proxies: ResolvedMemberOccurrence[],
groupName: string,
filterText: string,
searchState?: ProxySearchState,
@@ -49,8 +52,8 @@ function filterProxies(
const value =
symbol2 === 'error' ? 1e5 : symbol2 === 'timeout' ? 3000 : +symbol2
return proxies.filter((p) => {
const delay = delayManager.getDelayFix(p, groupName)
return proxies.filter(({ member }) => {
const delay = delayManager.getDelayFix(member, groupName)
if (delay < 0) return false
if (symbol === '=' && symbol2 === 'error') return delay >= 1e5
@@ -66,7 +69,9 @@ function filterProxies(
const res2 = regex2.exec(query)
if (res2) {
const type = res2[1].toLowerCase()
return proxies.filter((p) => p.type.toLowerCase().includes(type))
return proxies.filter(({ member }) =>
(memberDetails(member)?.type ?? '').toLowerCase().includes(type),
)
}
const {
@@ -81,14 +86,14 @@ function filterProxies(
})
if (!compiled.isValid) return []
return proxies.filter((p) => compiled.matcher(p.name))
return proxies.filter(({ member }) => compiled.matcher(member.ref.name))
}
/**
* sort the proxy
*/
function sortProxies(
proxies: IProxyItem[],
proxies: ResolvedMemberOccurrence[],
groupName: string,
sortType: ProxySortType,
latencyTimeout?: number,
@@ -117,8 +122,8 @@ function sortProxies(
}
list.sort((a, b) => {
const ad = delayManager.getDelayFix(a, groupName)
const bd = delayManager.getDelayFix(b, groupName)
const ad = delayManager.getDelayFix(a.member, groupName)
const bd = delayManager.getDelayFix(b.member, groupName)
const [ar, av] = categorizeDelay(ad)
const [br, bv] = categorizeDelay(bd)
@@ -126,7 +131,7 @@ function sortProxies(
return av - bv
})
} else {
list.sort((a, b) => a.name.localeCompare(b.name))
list.sort((a, b) => a.member.ref.name.localeCompare(b.member.ref.name))
}
return list
+168 -338
View File
@@ -4,6 +4,15 @@ import { useRuntimeConfig } from '@/hooks/use-clash'
import { useVerge } from '@/hooks/use-verge'
import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context'
import delayManager from '@/services/delay'
import {
isInteractableMember,
resolveMember,
selectGlobalChainNodes,
selectRuleChainMembers,
type ProxyGroupView,
type ProxyViewV1,
type ResolvedProxyMember,
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
import { filterSort } from './use-filter-sort'
@@ -14,386 +23,225 @@ import {
} from './use-head-state'
import { useWindowWidth } from './use-window-width'
// 定义代理项接口
interface IProxyItem {
name: string
type: string
udp: boolean
xudp: boolean
tfo: boolean
mptcp: boolean
smux: boolean
history: {
time: string
delay: number
}[]
provider?: string
testUrl?: string
[key: string]: any // 添加索引签名以适应其他可能的属性
export interface ResolvedMemberOccurrence {
memberIndex: number
member: ResolvedProxyMember
}
// 代理组类型
type ProxyGroup = {
name: string
type: string
udp: boolean
xudp: boolean
tfo: boolean
mptcp: boolean
smux: boolean
history: {
time: string
delay: number
}[]
now: string
all: IProxyItem[]
hidden?: boolean
icon?: string
testUrl?: string
provider?: string
}
type ProxyGroup = ProxyGroupView
export interface IRenderItem {
// 组 | head | item | empty | item col
type: 0 | 1 | 2 | 3 | 4
key: string
group: ProxyGroup
proxy?: IProxyItem
member?: ResolvedMemberOccurrence
memberCol?: ResolvedMemberOccurrence[]
col?: number
proxyCol?: IProxyItem[]
headState?: HeadState
// 新增支持图标和其他元数据
icon?: string
provider?: string
testUrl?: string
}
type GroupCache = {
now: string
all: IProxyItem[]
now: string | undefined
members: ProxyGroupView['members']
headState: HeadState
col: number
latencyTimeout: number | undefined
items: IRenderItem[]
}
// 优化列布局计算
type RuntimeConfigWithProxySequence = IConfigData & { proxies?: unknown }
const resolveOccurrences = (view: ProxyViewV1, group: ProxyGroupView) =>
group.members.map((member, memberIndex) => ({
memberIndex,
member: resolveMember(view, member),
}))
const memberKey = (
group: ProxyGroupView,
occurrence: ResolvedMemberOccurrence,
) => {
const { memberIndex, member } = occurrence
const identity =
member.kind === 'node' ? member.node.recordId : member.ref.name
return `${group.name}:${memberIndex}:${identity}`
}
const calculateColumns = (width: number, configCol: number): number => {
if (configCol > 0 && configCol < 6) return configCol
if (width > 1920) return 5
if (width > 1450) return 4
if (width > 1024) return 3
if (width > 900) return 2
if (width >= 600) return 2
return 1
}
// 优化分组逻辑
const groupProxies = <T = any>(list: T[], size: number): T[][] => {
return list.reduce((acc, item) => {
const groupOccurrences = <T>(list: T[], size: number): T[][] =>
list.reduce<T[][]>((acc, item) => {
const lastGroup = acc[acc.length - 1]
if (!lastGroup || lastGroup.length >= size) {
acc.push([item])
} else {
lastGroup.push(item)
}
if (!lastGroup || lastGroup.length >= size) acc.push([item])
else lastGroup.push(item)
return acc
}, [] as T[][])
}
}, [])
const CHAIN_DELAY_GROUP = 'chain-mode'
const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({
name: CHAIN_DELAY_GROUP,
type: 'Selector',
alive: true,
udp: false,
xudp: false,
tfo: false,
mptcp: false,
smux: false,
history: [],
members,
})
export const useRenderList = (
mode: string,
isChainMode?: boolean,
selectedGroup?: string | null,
) => {
// 使用全局数据提供者
const { proxies: proxiesData } = useProxiesData()
const { proxyView } = useProxiesData()
const { refreshProxy } = useAppRefreshers()
const { verge } = useVerge()
const { width } = useWindowWidth()
const [headStates, setHeadState] = useHeadStateNew()
const latencyTimeout = verge?.default_latency_timeout
// 获取运行时配置用于链式代理模式
const { data: runtimeConfig } = useRuntimeConfig(!!isChainMode)
const runtimeProxies = (
runtimeConfig as RuntimeConfigWithProxySequence | null
)?.proxies
// 计算列数
const col = useMemo(
() => calculateColumns(width, verge?.proxy_layout_column || 6),
[width, verge?.proxy_layout_column],
)
// 确保代理数据加载
useEffect(() => {
if (!proxiesData) return
const { groups, proxies } = proxiesData
if (
(mode === 'rule' && !groups.length) ||
(mode === 'global' && proxies.length < 2)
) {
const handle = setTimeout(() => refreshProxy(), 500)
return () => clearTimeout(handle)
const chainOccurrences = useMemo(() => {
if (!proxyView || !isChainMode) return []
if (mode === 'rule' && selectedGroup) {
return selectRuleChainMembers(proxyView, selectedGroup)
}
}, [proxiesData, mode, refreshProxy])
// 链式代理模式节点自动计算延迟
useEffect(() => {
if (!isChainMode || !runtimeConfig) return
const allProxies: IProxyItem[] = Object.values(
(runtimeConfig as any).proxies || {},
if (!runtimeConfig) return []
return selectGlobalChainNodes(proxyView, runtimeProxies).map(
(node, memberIndex) => ({
memberIndex,
member: {
kind: 'node' as const,
ref: {
kind: 'node' as const,
name: node.name,
recordId: node.recordId,
},
node,
},
}),
)
if (allProxies.length === 0) return
}, [
isChainMode,
mode,
proxyView,
runtimeConfig,
runtimeProxies,
selectedGroup,
])
// 设置组监听器,当有延迟更新时自动刷新
const groupListener = () => {
debugLog('[ChainMode] 延迟更新,刷新UI')
refreshProxy()
}
const chainOccurrencesRef = useRef(chainOccurrences)
chainOccurrencesRef.current = chainOccurrences
const chainDelayGroup =
mode === 'rule' && selectedGroup ? selectedGroup : CHAIN_DELAY_GROUP
const chainDelayKey = chainOccurrences
.map(({ member }) => {
if (member.kind !== 'node') return `${member.kind}:${member.ref.name}`
const source = member.node.source
return source.kind === 'provider'
? `provider:${source.providerName}:${source.proxyName}`
: `core:${source.proxyName}`
})
.join('\u0000')
delayManager.setGroupListener('chain-mode', groupListener)
useEffect(() => {
if (!isChainMode || !chainDelayKey) return
const interactable = chainOccurrencesRef.current
.map(({ member }) => member)
.filter(isInteractableMember)
if (interactable.length === 0) return
const calculateDelays = async () => {
try {
const timeout = verge?.default_latency_timeout || 10000
debugLog(`[ChainMode] 开始计算 ${allProxies.length} 个节点的延迟`)
// 使用 delayManager 计算延迟,每个节点计算完成后会自动触发监听器刷新界面
delayManager.checkListDelay(allProxies, 'chain-mode', timeout)
} catch (error) {
console.error('Failed to calculate delays for chain mode:', error)
}
}
// 延迟执行避免阻塞
const handle = setTimeout(calculateDelays, 100)
const handle = setTimeout(() => {
const timeout = verge?.default_latency_timeout || 10000
debugLog(`[ChainMode] 开始计算 ${interactable.length} 个节点的延迟`)
void delayManager.checkListDelay(interactable, chainDelayGroup, timeout)
}, 100)
return () => {
clearTimeout(handle)
// 清理组监听器
delayManager.removeGroupListener('chain-mode')
}
}, [isChainMode, runtimeConfig, verge?.default_latency_timeout, refreshProxy])
}, [
chainDelayGroup,
chainDelayKey,
isChainMode,
verge?.default_latency_timeout,
])
const groupCacheRef = useRef<Map<string, GroupCache>>(new Map())
const prevListRef = useRef<IRenderItem[]>([])
// 处理渲染列表
const renderList: IRenderItem[] = useMemo(() => {
if (!proxiesData) return []
const renderList = useMemo<IRenderItem[]>(() => {
if (!proxyView) return []
// 链式代理模式下,显示代理组和其节点
if (isChainMode && runtimeConfig && mode === 'rule') {
// 使用正常的规则模式代理组
const allGroups = proxiesData.groups.length
? proxiesData.groups
: [proxiesData.global!]
// 如果选择了特定代理组,只显示该组的节点
if (selectedGroup) {
const targetGroup = allGroups.find((g: any) => g.name === selectedGroup)
if (targetGroup) {
const proxies = filterSort(
targetGroup.all,
targetGroup.name,
'',
0,
latencyTimeout,
)
if (col > 1) {
return groupProxies(proxies, col).map((proxyCol, colIndex) => ({
type: 4,
key: `chain-col-${selectedGroup}-${colIndex}`,
group: targetGroup,
headState: DEFAULT_STATE,
col,
proxyCol,
provider: proxyCol[0]?.provider,
}))
} else {
return proxies.map((proxy) => ({
type: 2,
key: `chain-${selectedGroup}-${proxy!.name}`,
group: targetGroup,
proxy,
headState: DEFAULT_STATE,
provider: proxy.provider,
}))
}
}
return []
}
// 如果没有选择特定组,显示第一个组的节点(如果有组的话)
if (allGroups.length > 0) {
const firstGroup = allGroups[0]
const proxies = filterSort(
firstGroup.all,
firstGroup.name,
'',
0,
latencyTimeout,
)
if (col > 1) {
return groupProxies(proxies, col).map((proxyCol, colIndex) => ({
type: 4,
key: `chain-col-first-${colIndex}`,
group: firstGroup,
headState: DEFAULT_STATE,
col,
proxyCol,
provider: proxyCol[0]?.provider,
}))
} else {
return proxies.map((proxy) => ({
type: 2,
key: `chain-first-${proxy!.name}`,
group: firstGroup,
proxy,
headState: DEFAULT_STATE,
provider: proxy.provider,
}))
}
}
// 如果没有组,显示所有节点
const allProxies: IProxyItem[] = allGroups.flatMap(
(group: any) => group.all,
if (isChainMode) {
const selected =
mode === 'rule'
? proxyView.groups.find(({ name }) => name === selectedGroup)
: undefined
const group = selected ?? virtualGroup([])
const occurrences = filterSort(
chainOccurrences,
selected?.name ?? CHAIN_DELAY_GROUP,
'',
0,
latencyTimeout,
)
// 为每个节点获取延迟信息
const proxiesWithDelay = allProxies.map((proxy) => {
const delay = delayManager.getDelay(proxy.name, 'chain-mode')
return {
...proxy,
// 如果delayManager有延迟数据,更新history
history:
delay >= 0
? [{ time: new Date().toISOString(), delay }]
: proxy.history || [],
}
})
// 创建一个虚拟的组来容纳所有节点
const virtualGroup: ProxyGroup = {
name: 'All Proxies',
type: 'Selector',
udp: false,
xudp: false,
tfo: false,
mptcp: false,
smux: false,
history: [],
now: '',
all: proxiesWithDelay,
}
if (col > 1) {
return groupProxies(proxiesWithDelay, col).map(
(proxyCol, colIndex) => ({
type: 4,
key: `chain-col-all-${colIndex}`,
group: virtualGroup,
headState: DEFAULT_STATE,
col,
proxyCol,
provider: proxyCol[0]?.provider,
}),
)
} else {
return proxiesWithDelay.map((proxy) => ({
type: 2,
key: `chain-all-${proxy.name}`,
group: virtualGroup,
proxy,
return groupOccurrences(occurrences, col).map((memberCol) => ({
type: 4,
key: `chain-col:${memberKey(group, memberCol[0])}`,
group,
headState: DEFAULT_STATE,
provider: proxy.provider,
col,
memberCol,
}))
}
return occurrences.map((member) => ({
type: 2,
key: `chain:${memberKey(group, member)}`,
group,
member,
headState: DEFAULT_STATE,
}))
}
// 链式代理模式下的其他模式(如global)仍显示所有节点
if (isChainMode && runtimeConfig) {
// 从运行时配置直接获取 proxies 列表 (需要类型断言)
const allProxies: IProxyItem[] = Object.values(
(runtimeConfig as any).proxies || {},
)
// 为每个节点获取延迟信息
const proxiesWithDelay = allProxies.map((proxy) => {
const delay = delayManager.getDelay(proxy.name, 'chain-mode')
return {
...proxy,
// 如果delayManager有延迟数据,更新history
history:
delay >= 0
? [{ time: new Date().toISOString(), delay }]
: proxy.history || [],
}
})
// 创建一个虚拟的组来容纳所有节点
const virtualGroup: ProxyGroup = {
name: 'All Proxies',
type: 'Selector',
udp: false,
xudp: false,
tfo: false,
mptcp: false,
smux: false,
history: [],
now: '',
all: proxiesWithDelay,
}
// 返回节点列表(不显示组头)
if (col > 1) {
return groupProxies(proxiesWithDelay, col).map(
(proxyCol, colIndex) => ({
type: 4,
key: `chain-col-${colIndex}`,
group: virtualGroup,
headState: DEFAULT_STATE,
col,
proxyCol,
provider: proxyCol[0]?.provider,
}),
)
} else {
return proxiesWithDelay.map((proxy) => ({
type: 2,
key: `chain-${proxy.name}`,
group: virtualGroup,
proxy,
headState: DEFAULT_STATE,
provider: proxy.provider,
}))
}
}
// 正常模式的渲染逻辑
const useRule = mode === 'rule' || mode === 'script'
const renderGroups =
useRule && proxiesData.groups.length
? proxiesData.groups
: [proxiesData.global!]
const renderGroups = useRule
? proxyView.groups
: proxyView.global === null
? []
: [proxyView.global]
const cache = groupCacheRef.current
let anyChanged = false
const retList = renderGroups.flatMap((group: ProxyGroup) => {
const retList = renderGroups.flatMap((group) => {
const headState = headStates[group.name] || DEFAULT_STATE
const cached = cache.get(group.name)
if (
cached &&
cached.now === group.now &&
cached.all === group.all &&
cached.members === group.members &&
cached.headState === headState &&
cached.col === col &&
cached.latencyTimeout === latencyTimeout
@@ -413,9 +261,9 @@ export const useRenderList = (
},
]
if (headState?.open || !useRule) {
const proxies = filterSort(
group.all,
if (headState.open || !useRule) {
const occurrences = filterSort(
resolveOccurrences(proxyView, group),
group.name,
headState.filterText,
headState.sortType,
@@ -426,45 +274,30 @@ export const useRenderList = (
useRegularExpression: headState.filterUseRegularExpression,
},
)
// 全局模式下,添加组头
if (!useRule) {
ret.push({
type: 1,
key: `head-${group.name}`,
group,
headState,
})
ret.push({ type: 1, key: `head-${group.name}`, group, headState })
}
if (!proxies.length) {
ret.push({
type: 3,
key: `empty-${group.name}`,
group,
headState,
})
if (occurrences.length === 0) {
ret.push({ type: 3, key: `empty-${group.name}`, group, headState })
} else if (col > 1) {
ret.push(
...groupProxies(proxies, col).map((proxyCol, colIndex) => ({
...groupOccurrences(occurrences, col).map((memberCol) => ({
type: 4 as const,
key: `col-${group.name}-${proxyCol[0].name}-${colIndex}`,
key: `col:${memberKey(group, memberCol[0])}`,
group,
headState,
col,
proxyCol,
provider: proxyCol[0].provider,
memberCol,
})),
)
} else {
ret.push(
...proxies.map((proxy) => ({
...occurrences.map((member) => ({
type: 2 as const,
key: `${group.name}-${proxy!.name}`,
key: memberKey(group, member),
group,
proxy,
member,
headState,
provider: proxy.provider,
})),
)
}
@@ -472,7 +305,7 @@ export const useRenderList = (
cache.set(group.name, {
now: group.now,
all: group.all,
members: group.members,
headState,
col,
latencyTimeout,
@@ -483,22 +316,21 @@ export const useRenderList = (
const filtered = !useRule
? retList.slice(1)
: retList.filter((item: IRenderItem) => !item.group.hidden)
: retList.filter((item) => !item.group.hidden)
if (!anyChanged && prevListRef.current.length === filtered.length) {
return prevListRef.current
}
prevListRef.current = filtered
return filtered
}, [
headStates,
proxiesData,
mode,
chainOccurrences,
col,
headStates,
isChainMode,
runtimeConfig,
selectedGroup,
latencyTimeout,
mode,
proxyView,
selectedGroup,
])
return {
@@ -508,5 +340,3 @@ export const useRenderList = (
currentColumns: col,
}
}
// 优化建议:如有大数据量,建议用虚拟滚动(已在 ProxyGroups 组件中实现),此处无需额外处理。
+144 -23
View File
@@ -1,3 +1,4 @@
/* eslint-disable @eslint-react/set-state-in-effect */
import { Delete, ExpandLess, ExpandMore } from '@mui/icons-material'
import {
Button,
@@ -11,7 +12,14 @@ import {
Select,
MenuItem,
} from '@mui/material'
import { forwardRef, useImperativeHandle, useState, useMemo } from 'react'
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { BaseDialog } from '@/components/base'
@@ -19,6 +27,14 @@ import { useClash } from '@/hooks/use-clash'
import { useProxiesData } from '@/providers/app-data-context'
import { isPortInUse } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import {
isInteractableMember,
rebindMemberOccurrence,
resolveMember,
toMemberOccurrenceBinding,
type ProxyMemberOccurrenceBinding,
type ResolvedProxyMember,
} from '@/types/proxy-view'
import {
formatHostPort,
isValidPort,
@@ -38,20 +54,40 @@ interface TunnelEntry {
proxy?: string
}
interface TunnelProxyOption {
memberIndex: number
member: ResolvedProxyMember
}
interface TunnelFormValues {
localAddr: string
localPort: string
targetAddr: string
targetPort: string
network: string
group: string
proxy: ProxyMemberOccurrenceBinding | null
}
const proxyOptionToken = ({ memberIndex, member }: TunnelProxyOption) =>
`${memberIndex}:${
member.kind === 'node' ? member.node.recordId : member.ref.name
}`
export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
const { t } = useTranslation()
const { clash, mutateClash, patchClash } = useClash()
const [open, setOpen] = useState(false)
const [expanded, setExpanded] = useState(false)
const [values, setValues] = useState({
const [values, setValues] = useState<TunnelFormValues>({
localAddr: '',
localPort: '',
targetAddr: '',
targetPort: '',
network: 'tcp+udp',
group: '',
proxy: '',
proxy: null,
})
const [draftTunnels, setDraftTunnels] = useState<TunnelEntry[]>([])
@@ -64,7 +100,7 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
targetPort: '',
network: 'tcp+udp',
group: '',
proxy: '',
proxy: null,
}))
setDraftTunnels(() => clash?.tunnels ?? [])
setOpen(true)
@@ -92,21 +128,61 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
})
}, [draftTunnels])
const { proxies } = useProxiesData()
const { proxyView } = useProxiesData()
const proxyGroups = useMemo<IProxyGroupItem[]>(() => {
return proxies?.groups ?? []
}, [proxies])
const proxyGroups = useMemo(() => proxyView?.groups ?? [], [proxyView])
const groupNames = useMemo<string[]>(
() => proxyGroups.map((group) => group.name),
[proxyGroups],
)
const proxyOptions = useMemo<IProxyItem[]>(() => {
const proxyOptions = useMemo<TunnelProxyOption[]>(() => {
if (!proxyView) return []
const group = proxyGroups.find((item) => item.name === values.group)
return group?.all ?? []
}, [proxyGroups, values.group])
return (
group?.members.map((member, memberIndex) => ({
memberIndex,
member: resolveMember(proxyView, member),
})) ?? []
)
}, [proxyGroups, proxyView, values.group])
const proxyMembers = useMemo(
() => proxyOptions.map(({ member }) => member),
[proxyOptions],
)
const selectedProxyOption = useMemo(
() =>
values.proxy
? rebindMemberOccurrence(proxyMembers, values.proxy)
: undefined,
[proxyMembers, values.proxy],
)
const selectedProxyToken = selectedProxyOption
? proxyOptionToken(selectedProxyOption)
: ''
const selectedGroupExists =
!values.group || proxyGroups.some(({ name }) => name === values.group)
const latestProxyStateRef = useRef({
binding: values.proxy,
options: proxyOptions,
})
latestProxyStateRef.current = {
binding: values.proxy,
options: proxyOptions,
}
useEffect(() => {
if (selectedGroupExists && (!values.proxy || selectedProxyOption)) return
setValues((current) => {
if (current.group !== values.group || current.proxy !== values.proxy) {
return current
}
return selectedGroupExists
? { ...current, proxy: null }
: { ...current, group: '', proxy: null }
})
}, [selectedGroupExists, selectedProxyOption, values.group, values.proxy])
const handleSave = async () => {
try {
@@ -120,8 +196,7 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
}
const handleAdd = async () => {
const { localAddr, localPort, targetAddr, targetPort, network, proxy } =
values
const { localAddr, localPort, targetAddr, targetPort, network } = values
// 基础非空校验
if (!localAddr || !localPort || !targetAddr || !targetPort) {
@@ -172,12 +247,26 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
return
}
const latestProxyState = latestProxyStateRef.current
const latestMembers = latestProxyState.options.map(({ member }) => member)
const latestSelectedProxy = latestProxyState.binding
? rebindMemberOccurrence(latestMembers, latestProxyState.binding)
: undefined
if (latestProxyState.binding && !latestSelectedProxy) {
showNotice.error(
'settings.sections.clash.form.fields.tunnels.messages.incomplete',
)
return
}
// 构造新 entry
const entry: TunnelEntry = {
network: network === 'tcp+udp' ? ['tcp', 'udp'] : [network],
address: formatHostPort(localHost, localPort),
target: formatHostPort(targetHost, targetPort),
...(proxy ? { proxy } : {}),
...(latestSelectedProxy
? { proxy: latestSelectedProxy.member.ref.name }
: {}),
}
// 写入配置 + 清空输入
@@ -395,12 +484,27 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
onChange={(e) => {
const nextGroup = e.target.value as string
const group = proxyGroups.find((g) => g.name === nextGroup)
const firstProxy = group?.all?.[0].name ?? ''
const options =
proxyView && group
? group.members.map((member, memberIndex) => ({
memberIndex,
member: resolveMember(proxyView, member),
}))
: []
const firstProxy = options.find(({ member }) =>
isInteractableMember(member),
)
const members = options.map(({ member }) => member)
setValues((v) => ({
...v,
group: nextGroup,
proxy: firstProxy, // 组切换时自动选第一条节点
proxy: firstProxy
? (toMemberOccurrenceBinding(
members,
firstProxy.memberIndex,
) ?? null)
: null,
}))
}}
>
@@ -437,22 +541,39 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
<Select
size="small"
sx={{ width: 200, '> div': { py: '7.5px' } }}
value={values.proxy}
value={selectedProxyToken}
displayEmpty
onChange={(e) =>
onChange={(e) => {
const token = e.target.value as string
const option = proxyOptions.find(
(candidate) => proxyOptionToken(candidate) === token,
)
setValues((v) => ({
...v,
proxy: e.target.value as string,
proxy: option
? (toMemberOccurrenceBinding(
proxyMembers,
option.memberIndex,
) ?? null)
: null,
}))
}
}}
disabled={!values.group} // 没选组就禁用
>
<MenuItem value="">
{t('settings.sections.clash.form.fields.tunnels.default')}
</MenuItem>
{proxyOptions.map((node) => (
<MenuItem key={node.name} value={node.name}>
{node.name}
{proxyOptions.map(({ memberIndex, member }) => (
<MenuItem
key={
member.kind === 'node'
? `${memberIndex}:${member.node.recordId}`
: `${memberIndex}:${member.ref.name}`
}
value={proxyOptionToken({ memberIndex, member })}
disabled={!isInteractableMember(member)}
>
{member.ref.name}
</MenuItem>
))}
</Select>
+25 -20
View File
@@ -3,6 +3,11 @@ import { useCallback, useEffect, useReducer } from 'react'
import { useVerge } from '@/hooks/use-verge'
import delayManager, { type DelayUpdate } from '@/services/delay'
import {
isInteractableMember,
memberDetails,
type ResolvedProxyMember,
} from '@/types/proxy-view'
const PRESET_PROXY_NAMES = [
'DIRECT',
@@ -21,42 +26,48 @@ export interface UseProxyDelayState {
delayValue: number
isPreset: boolean
timeout: number
onDelay: (providerName?: string) => Promise<void>
onDelay: () => Promise<void>
}
export function useProxyDelayState(
proxy: IProxyItem,
member: ResolvedProxyMember,
groupName: string,
): UseProxyDelayState {
const isPreset = PRESET_PROXY_NAMES.includes(proxy.name)
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)
const { verge } = useVerge()
const timeout = verge?.default_latency_timeout || 10000
useEffect(() => {
if (isPreset) return
delayManager.setListener(proxy.name, groupName, setDelayState)
delayManager.setListener(name, groupName, setDelayState)
return () => {
delayManager.removeListener(proxy.name, groupName)
delayManager.removeListener(name, groupName)
}
}, [proxy.name, groupName, isPreset])
}, [name, groupName, isPreset])
const updateDelay = useCallback(() => {
if (!proxy) return
const cachedUpdate = delayManager.getDelayUpdate(proxy.name, groupName)
if (unresolved) {
setDelayState(INITIAL_DELAY)
return
}
const cachedUpdate = delayManager.getDelayUpdate(name, groupName)
if (cachedUpdate) {
setDelayState({ ...cachedUpdate })
return
}
const fallbackDelay = delayManager.getDelayFix(proxy, groupName)
const fallbackDelay = delayManager.getDelayFix(member, groupName)
if (fallbackDelay === -1) {
setDelayState({ delay: -1, updatedAt: 0 })
return
}
let updatedAt = 0
const history = proxy.history
const history = details?.history
if (history && history.length > 0) {
const lastRecord = history[history.length - 1]
const parsed = Date.parse(lastRecord.time)
@@ -66,22 +77,16 @@ export function useProxyDelayState(
}
setDelayState({ delay: fallbackDelay, updatedAt })
}, [proxy, groupName])
}, [details?.history, groupName, member, name, unresolved])
useEffect(() => {
updateDelay()
}, [updateDelay])
const onDelay = useLockFn(async (providerName?: string) => {
const onDelay = useLockFn(async () => {
if (!isInteractableMember(member)) return
setDelayState({ delay: -2, updatedAt: Date.now() })
setDelayState(
await delayManager.checkDelay(
proxy.name,
groupName,
timeout,
providerName,
),
)
setDelayState(await delayManager.checkDelay(member, groupName, timeout))
})
return {
+1 -2
View File
@@ -42,12 +42,11 @@ export const useLayoutEvents = (
register(
addListener('verge://refresh-clash-config', () => {
revalidateKeys([
'getProxies',
'getProxyView',
'getVersion',
'getClashConfig',
'getClashMode',
'getRuntimeConfig',
'getProxyProviders',
'getRules',
'getRuleProviders',
])
+13 -28
View File
@@ -1,15 +1,11 @@
import { Context, createContext, use } from 'react'
import {
BaseConfig,
ProxyProvider,
Rule,
RuleProvider,
} from 'tauri-plugin-mihomo-api'
import { BaseConfig, Rule, RuleProvider } from 'tauri-plugin-mihomo-api'
import type { ProxyViewV1 } from '@/types/proxy-view'
export interface ProxiesContextType {
proxies: any
proxyProviders: Record<string, ProxyProvider | undefined>
isProxiesPending: boolean
proxyView: ProxyViewV1 | undefined
isProxyViewPending: boolean
}
export interface RulesContextType {
@@ -37,13 +33,12 @@ export interface CoreDataStatusContextType {
}
export interface RefreshersContextType {
refreshProxy: () => Promise<any>
refreshClashConfig: () => Promise<any>
refreshRules: () => Promise<any>
refreshSysproxy: () => Promise<any>
refreshProxyProviders: () => Promise<any>
refreshRuleProviders: () => Promise<any>
refreshAll: () => Promise<any>
refreshProxy: () => Promise<unknown>
refreshClashConfig: () => Promise<unknown>
refreshRules: () => Promise<unknown>
refreshSysproxy: () => Promise<unknown>
refreshRuleProviders: () => Promise<unknown>
refreshAll: () => Promise<unknown>
}
export const ProxiesContext = createContext<ProxiesContextType | null>(null)
@@ -65,18 +60,8 @@ const useCtx = <T>(ctx: Context<T | null>, hookName: string): T => {
return v
}
export const useProxiesData = () => {
const { proxies, proxyProviders, isProxiesPending } = useCtx(
ProxiesContext,
'useProxiesData',
)
return {
proxies,
proxyProviders: proxyProviders as Record<string, ProxyProvider>,
isProxiesPending,
}
}
export const useProxiesData = (): ProxiesContextType =>
useCtx(ProxiesContext, 'useProxiesData')
export const useRulesData = () => {
const { rules, ruleProviders } = useCtx(RulesContext, 'useRulesData')
+16 -26
View File
@@ -8,9 +8,8 @@ import {
import { useVerge } from '@/hooks/use-verge'
import {
calcuProxies,
calcuProxyProviders,
getAppUptime,
getProxyView,
getRunningMode,
getSystemProxy,
} from '@/services/cmds'
@@ -56,12 +55,14 @@ export const AppDataProvider = ({
const { verge } = useVerge()
const {
data: proxiesData,
isPending: isProxiesPending,
refetch: _refetchProxy,
data: proxyView,
isPending: isProxyViewPending,
refetch: _refetchProxyView,
} = useQuery({
queryKey: ['getProxies'],
queryFn: calcuProxies,
queryKey: ['getProxyView'],
queryFn: getProxyView,
refetchInterval: 3000,
refetchIntervalInBackground: false,
...TQ_MIHOMO,
})
@@ -75,13 +76,6 @@ export const AppDataProvider = ({
...TQ_MIHOMO,
})
const { data: proxyProviders, refetch: _refetchProxyProviders } = useQuery({
queryKey: ['getProxyProviders'],
queryFn: calcuProxyProviders,
...TQ_MIHOMO,
revalidateOnMount: false,
})
const { data: ruleProviders, refetch: _refetchRuleProviders } = useQuery({
queryKey: ['getRuleProviders'],
queryFn: getRuleProviders,
@@ -115,11 +109,10 @@ export const AppDataProvider = ({
retry: 1,
})
const refreshProxy = useStableFn(_refetchProxy)
const refreshProxy = useStableFn(_refetchProxyView)
const refreshClashConfig = useStableFn(_refetchClashConfig)
const refreshRules = useStableFn(_refetchRules)
const refreshSysproxy = useStableFn(_refetchSysproxy)
const refreshProxyProviders = useStableFn(_refetchProxyProviders)
const refreshRuleProviders = useStableFn(_refetchRuleProviders)
useEffect(() => {
@@ -205,7 +198,6 @@ export const AppDataProvider = ({
refreshClashConfig(),
refreshRules(),
refreshSysproxy(),
refreshProxyProviders(),
refreshRuleProviders(),
])
}, [
@@ -213,17 +205,15 @@ export const AppDataProvider = ({
refreshClashConfig,
refreshRules,
refreshSysproxy,
refreshProxyProviders,
refreshRuleProviders,
])
const proxiesValue = useMemo(
() => ({
proxies: proxiesData,
proxyProviders: proxyProviders || {},
isProxiesPending,
proxyView,
isProxyViewPending,
}),
[proxiesData, proxyProviders, isProxiesPending],
[proxyView, isProxyViewPending],
)
const rulesValue = useMemo(
@@ -283,8 +273,10 @@ export const AppDataProvider = ({
const uptimeValue = useMemo(() => ({ uptime: uptimeData || 0 }), [uptimeData])
const coreDataStatusValue = useMemo(
() => ({ isCoreDataPending: isProxiesPending || isClashConfigPending }),
[isProxiesPending, isClashConfigPending],
() => ({
isCoreDataPending: isProxyViewPending || isClashConfigPending,
}),
[isProxyViewPending, isClashConfigPending],
)
const refreshersValue = useMemo(
@@ -293,7 +285,6 @@ export const AppDataProvider = ({
refreshClashConfig,
refreshRules,
refreshSysproxy,
refreshProxyProviders,
refreshRuleProviders,
refreshAll,
}),
@@ -302,7 +293,6 @@ export const AppDataProvider = ({
refreshClashConfig,
refreshRules,
refreshSysproxy,
refreshProxyProviders,
refreshRuleProviders,
refreshAll,
],
+6 -126
View File
@@ -1,8 +1,8 @@
import { invoke } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { getProxies, getProxyProviders } from 'tauri-plugin-mihomo-api'
import { showNotice } from '@/services/notice-service'
import type { ProxyViewV1 } from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
export async function copyClashEnv() {
@@ -93,10 +93,6 @@ export async function getRuntimeConfig() {
return invoke<IConfigData | null>('get_runtime_config')
}
async function getRuntimeProxyGroupOrder() {
return invoke<string[]>('get_runtime_proxy_group_order')
}
export async function getRuntimeYaml() {
return invoke<string | null>('get_runtime_yaml')
}
@@ -129,128 +125,12 @@ export async function syncTrayProxySelection() {
return invoke<void>('sync_tray_proxy_selection')
}
export async function calcuProxies(): Promise<{
global: IProxyGroupItem
direct: IProxyItem
groups: IProxyGroupItem[]
records: Record<string, IProxyItem>
proxies: IProxyItem[]
}> {
const [proxyResponse, providerResponse, runtimeGroupOrder] =
await Promise.all([
getProxies(),
calcuProxyProviders(),
getRuntimeProxyGroupOrder(),
])
const proxyRecord = proxyResponse.proxies
const providerRecord = providerResponse
// provider name map
const providerMap = Object.fromEntries(
Object.entries(providerRecord).flatMap(([provider, item]) =>
item!.proxies.map((p) => [p.name, { ...p, provider }]),
),
)
// compatible with proxy-providers
const generateItem = (name: string) => {
if (proxyRecord[name]) return proxyRecord[name]
if (providerMap[name]) return providerMap[name]
return {
name,
type: 'unknown',
udp: false,
xudp: false,
tfo: false,
mptcp: false,
smux: false,
history: [],
}
export async function getProxyView(): Promise<ProxyViewV1> {
const view = await invoke<ProxyViewV1>('get_proxy_view')
if (view.schemaVersion !== 1) {
throw new Error('Unsupported proxy view schema: ' + view.schemaVersion)
}
const { GLOBAL: global, DIRECT: direct, REJECT: reject } = proxyRecord
let groups: IProxyGroupItem[] = Object.values(proxyRecord).reduce<
IProxyGroupItem[]
>((acc, each) => {
if (each?.name !== 'GLOBAL' && each?.all) {
acc.push({
...each,
all: each.all!.map((item) => generateItem(item)),
})
}
return acc
}, [])
if (global?.all) {
const globalGroups: IProxyGroupItem[] = global.all.reduce<
IProxyGroupItem[]
>((acc, name) => {
if (proxyRecord[name]?.all) {
acc.push({
...proxyRecord[name],
all: proxyRecord[name].all!.map((item) => generateItem(item)),
})
}
return acc
}, [])
const globalNames = new Set(globalGroups.map((each) => each.name))
groups = groups
.filter((group) => {
return !globalNames.has(group.name)
})
.concat(globalGroups)
}
const groupOrder = new Map(
runtimeGroupOrder.map((name, index) => [name, index]),
)
groups.sort((a, b) => {
const aIndex = groupOrder.get(a.name) ?? Number.MAX_SAFE_INTEGER
const bIndex = groupOrder.get(b.name) ?? Number.MAX_SAFE_INTEGER
if (aIndex !== bIndex) return aIndex - bIndex
if (a.name < b.name) return -1
if (a.name > b.name) return 1
return 0
})
const proxies = [direct, reject].concat(
Object.values(proxyRecord).filter(
(p) => !p?.all?.length && p?.name !== 'DIRECT' && p?.name !== 'REJECT',
),
)
const _global = {
...global,
all: global?.all?.map((item) => generateItem(item)) || [],
}
// 原本的 records 拥有所有节点信息,新版本内核需要将 provider 的节点信息合并到 records 中, 同时兼容旧版本数据
const records = { ...proxyRecord, ...providerMap }
return {
global: _global as IProxyGroupItem,
direct: direct as IProxyItem,
groups,
records: records as Record<string, IProxyItem>,
proxies: (proxies as IProxyItem[]) ?? [],
}
}
export async function calcuProxyProviders() {
const providers = await getProxyProviders()
return Object.fromEntries(
Object.entries(providers.providers)
.sort()
.filter(
([_, item]) =>
item?.vehicleType === 'HTTP' || item?.vehicleType === 'File',
),
)
return view
}
export async function getClashLogs() {
+30 -20
View File
@@ -4,6 +4,12 @@ import {
type ProxyDelay,
} from 'tauri-plugin-mihomo-api'
import {
memberDetails,
providerNameOf,
type InteractableProxyMember,
type ResolvedProxyMember,
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
const hashKey = (name: string, group: string) => `${group ?? ''}::${name}`
@@ -185,19 +191,18 @@ class DelayManager {
return update ? update.delay : -1
}
/// 暂时修复provider的节点延迟排序的问题
getDelayFix(proxy: IProxyItem, group: string) {
if (!proxy.provider) {
const update = this.getDelayUpdate(proxy.name, group)
if (update && (update.delay >= 0 || update.delay === -2)) {
return update.delay
}
getDelayFix(member: ResolvedProxyMember, group: string) {
if (member.kind === 'unresolved') return -1
const details = memberDetails(member)
const name = member.ref.name
const update = this.getDelayUpdate(name, group)
if (update && (update.delay >= 0 || update.delay === -2)) {
return update.delay
}
// 添加 history 属性的安全检查
if (proxy.history && proxy.history.length > 0) {
if (details?.history && details.history.length > 0) {
// 0ms以error显示
return proxy.history[proxy.history.length - 1].delay || 1e6
return details.history[details.history.length - 1].delay || 1e6
}
return -1
}
@@ -215,11 +220,17 @@ class DelayManager {
}
async checkDelay(
name: string,
member: InteractableProxyMember,
group: string,
timeout: number,
providerName?: string,
): Promise<DelayUpdate> {
const name = member.ref.name
const providerName =
member.kind === 'node' ? providerNameOf(member.node) : undefined
const apiName =
member.kind === 'node' && member.node.source.kind === 'provider'
? member.node.source.proxyName
: name
debugLog(
`[DelayManager] 开始测试延迟,代理: ${name}, 组: ${group}, 超时: ${timeout}ms`,
)
@@ -240,7 +251,7 @@ class DelayManager {
// 使用Promise.race来实现超时控制
const result = await Promise.race([
this.unifiedDelayCheck(name, url, timeout, providerName),
this.unifiedDelayCheck(apiName, url, timeout, providerName),
timeoutPromise,
])
@@ -267,7 +278,7 @@ class DelayManager {
}
async checkListDelay(
proxies: IProxyItem[],
proxies: InteractableProxyMember[],
group: string,
timeout: number,
concurrency = 36,
@@ -275,7 +286,7 @@ class DelayManager {
debugLog(
`[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`,
)
const names = proxies.map((p) => p.name)
const names = proxies.map((member) => member.ref.name)
// 设置正在延迟测试中
names.forEach((name) => {
this.setDelay(name, group, -2)
@@ -286,10 +297,9 @@ class DelayManager {
const listener = this.groupListenerMap.get(group)
const help = async (): Promise<void> => {
const currProxy = proxies[index++]
if (!currProxy) return
const currName = currProxy.name
const currProviderName = currProxy.provider
const currMember = proxies[index++]
if (!currMember) return
const currName = currMember.ref.name
try {
// 确保API调用前状态为测试中
@@ -303,7 +313,7 @@ class DelayManager {
)
}
await this.checkDelay(currName, group, timeout, currProviderName)
await this.checkDelay(currMember, group, timeout)
if (listener) {
this.queueGroupNotification(group)
}
+307
View File
@@ -0,0 +1,307 @@
export interface ProxyViewV1 {
schemaVersion: 1
orderSource: 'runtime' | 'fallback'
providerState: 'ready' | 'unavailable'
global: ProxyGroupView | null
direct: string | null
groups: ProxyGroupView[]
records: Record<string, ProxyNodeView>
standalone: string[]
providers: ProxyProviderView[]
}
export interface ProxyCapabilities {
udp: boolean
xudp: boolean
tfo: boolean
mptcp: boolean
smux: boolean
}
export interface DelayHistory {
time: string
delay: number
}
export interface ProxyGroupView extends ProxyCapabilities {
name: string
type: string
alive: boolean
now?: string
fixed?: string
hidden?: boolean
icon?: string
testUrl?: string
history: DelayHistory[]
members: ProxyMemberRef[]
}
export interface ProxyNodeView extends ProxyCapabilities {
recordId: string
name: string
type: string
alive: boolean
history: DelayHistory[]
id?: string
hidden?: boolean
icon?: string
testUrl?: string
source:
| { kind: 'core'; proxyName: string }
| { kind: 'provider'; providerName: string; proxyName: string }
}
export type ProxyMemberUnresolvedReason =
| 'missing'
| 'ambiguous'
| 'provider-unavailable'
export type ProxyMemberRef =
| { kind: 'group'; name: string }
| { kind: 'node'; name: string; recordId: string }
| {
kind: 'unresolved'
name: string
reason: ProxyMemberUnresolvedReason
}
export interface ProxyProviderView {
name: string
vehicleType: 'HTTP' | 'File'
updatedAt?: string
subscriptionInfo?: ProxySubscriptionInfo
proxyRecordIds: string[]
}
export interface ProxySubscriptionInfo {
upload: number
download: number
total: number
expire: number
}
export type ResolvedProxyMember =
| {
kind: 'group'
ref: Extract<ProxyMemberRef, { kind: 'group' }>
group: ProxyGroupView
}
| {
kind: 'node'
ref: Extract<ProxyMemberRef, { kind: 'node' }>
node: ProxyNodeView
}
| {
kind: 'unresolved'
ref: Extract<ProxyMemberRef, { kind: 'unresolved' }>
}
export type InteractableProxyMember = Exclude<
ResolvedProxyMember,
{ kind: 'unresolved' }
>
export interface ProxyNodeBinding {
name: string
source?: ProxyNodeView['source']
}
export type ProxyMemberOccurrenceBinding =
| { kind: 'group'; name: string; occurrence: number }
| {
kind: 'node'
name: string
source: ProxyNodeView['source']
occurrence: number
}
export interface InteractableProxyMemberOccurrence {
memberIndex: number
member: InteractableProxyMember
}
export const getRecord = (view: ProxyViewV1, recordId: string) =>
view.records[recordId]
export const findGroup = (view: ProxyViewV1, name: string) =>
view.global?.name === name
? view.global
: view.groups.find((group) => group.name === name)
export function resolveMember(
view: ProxyViewV1,
member: ProxyMemberRef,
): ResolvedProxyMember {
if (member.kind === 'unresolved') {
return { kind: 'unresolved', ref: member }
}
if (member.kind === 'group') {
const group = findGroup(view, member.name)
if (!group) throw new Error('Proxy view group not found: ' + member.name)
return { kind: 'group', ref: member, group }
}
const node = getRecord(view, member.recordId)
if (!node) {
throw new Error('Proxy view record not found: ' + member.recordId)
}
return { kind: 'node', ref: member, node }
}
export const isInteractableMember = (
member: ResolvedProxyMember,
): member is InteractableProxyMember => member.kind !== 'unresolved'
export const memberDetails = (member: ResolvedProxyMember) =>
member.kind === 'node'
? member.node
: member.kind === 'group'
? member.group
: undefined
export function findCurrentGroupMember(
view: ProxyViewV1,
group: ProxyGroupView,
): InteractableProxyMemberOccurrence | undefined {
if (!group.now) return undefined
for (const [memberIndex, memberRef] of group.members.entries()) {
const member = resolveMember(view, memberRef)
if (isInteractableMember(member) && member.ref.name === group.now) {
return { memberIndex, member }
}
}
return undefined
}
export const providerNameOf = (node: ProxyNodeView) =>
node.source.kind === 'provider' ? node.source.providerName : undefined
export const resolveStandaloneNodes = (view: ProxyViewV1) =>
view.standalone.map((recordId) => {
const node = getRecord(view, recordId)
if (!node) throw new Error('Proxy view record not found: ' + recordId)
return node
})
export const selectRuntimeStandaloneNodes = (
view: ProxyViewV1,
runtimeProxies: unknown,
) => {
const runtimeProxyNames = new Set(
(Array.isArray(runtimeProxies) ? runtimeProxies : []).flatMap((proxy) => {
const name =
typeof proxy === 'object' && proxy !== null && 'name' in proxy
? proxy.name
: undefined
return typeof name === 'string' && name.length > 0 ? [name] : []
}),
)
return resolveStandaloneNodes(view).filter(
(node) =>
node.source.kind === 'core' &&
runtimeProxyNames.has(node.source.proxyName),
)
}
export const selectGlobalChainNodes = (
view: ProxyViewV1,
runtimeProxies: unknown,
) =>
view.global === null ? [] : selectRuntimeStandaloneNodes(view, runtimeProxies)
export const selectRuleChainMembers = (
view: ProxyViewV1,
groupName: string,
): Array<{ memberIndex: number; member: ResolvedProxyMember }> => {
const group = view.groups.find(({ name }) => name === groupName)
if (!group) return []
return group.members.flatMap((memberRef, memberIndex) => {
const member = resolveMember(view, memberRef)
return member.kind === 'group' ? [] : [{ memberIndex, member }]
})
}
export const toNodeBinding = (node: ProxyNodeView): ProxyNodeBinding => ({
name: node.name,
source: node.source,
})
const sameSource = (
left: ProxyNodeView['source'],
right: ProxyNodeView['source'],
) =>
left.kind === right.kind &&
left.proxyName === right.proxyName &&
(left.kind === 'core' ||
(right.kind === 'provider' && left.providerName === right.providerName))
const matchesMemberBinding = (
member: ResolvedProxyMember,
binding: ProxyMemberOccurrenceBinding,
) =>
member.kind === binding.kind &&
member.ref.name === binding.name &&
(member.kind !== 'node' ||
(binding.kind === 'node' && sameSource(member.node.source, binding.source)))
export function toMemberOccurrenceBinding(
members: readonly ResolvedProxyMember[],
memberIndex: number,
): ProxyMemberOccurrenceBinding | undefined {
const member = members[memberIndex]
if (!member || !isInteractableMember(member)) return undefined
const binding: ProxyMemberOccurrenceBinding =
member.kind === 'node'
? {
kind: 'node',
name: member.ref.name,
source: member.node.source,
occurrence: 0,
}
: { kind: 'group', name: member.ref.name, occurrence: 0 }
for (let index = 0; index < memberIndex; index += 1) {
if (matchesMemberBinding(members[index], binding)) {
binding.occurrence += 1
}
}
return binding
}
export function rebindMemberOccurrence(
members: readonly ResolvedProxyMember[],
binding: ProxyMemberOccurrenceBinding,
): InteractableProxyMemberOccurrence | undefined {
let occurrence = 0
for (const [memberIndex, member] of members.entries()) {
if (
!isInteractableMember(member) ||
!matchesMemberBinding(member, binding)
) {
continue
}
if (occurrence === binding.occurrence) return { memberIndex, member }
occurrence += 1
}
return undefined
}
export function rebindNode(
candidates: readonly ProxyNodeView[],
binding: ProxyNodeBinding,
) {
const matches = candidates.filter(
(node) =>
node.name === binding.name &&
(binding.source === undefined || sameSource(node.source, binding.source)),
)
const unique = new Map(matches.map((node) => [node.recordId, node]))
return unique.size === 1 ? unique.values().next().value : undefined
}
+421
View File
@@ -0,0 +1,421 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
findCurrentGroupMember,
findGroup,
getRecord,
isInteractableMember,
memberDetails,
providerNameOf,
rebindMemberOccurrence,
rebindNode,
resolveMember,
selectGlobalChainNodes,
selectRuleChainMembers,
selectRuntimeStandaloneNodes,
toMemberOccurrenceBinding,
toNodeBinding,
} from '../src/types/proxy-view.ts'
const node = {
recordId: 'p:0:0',
name: 'provider-node',
type: 'Shadowsocks',
alive: true,
history: [],
udp: true,
xudp: false,
tfo: false,
mptcp: false,
smux: false,
source: {
kind: 'provider',
providerName: 'provider-key',
proxyName: 'provider-node',
},
}
const group = {
name: 'Group',
type: 'Selector',
alive: true,
history: [],
udp: true,
xudp: false,
tfo: false,
mptcp: false,
smux: false,
members: [{ kind: 'node', name: node.name, recordId: node.recordId }],
}
const view = {
schemaVersion: 1,
orderSource: 'runtime',
providerState: 'ready',
global: null,
direct: null,
groups: [group],
records: { [node.recordId]: node },
standalone: [],
providers: [],
}
test('resolves nodes only through recordId and preserves provider identity', () => {
const resolved = resolveMember(view, group.members[0])
assert.equal(resolved.kind, 'node')
assert.equal(getRecord(view, 'p:0:0'), node)
assert.equal(memberDetails(resolved), node)
assert.equal(providerNameOf(node), 'provider-key')
assert.equal(isInteractableMember(resolved), true)
})
test('finds group refs without constructing a name-keyed records map', () => {
const resolved = resolveMember(view, { kind: 'group', name: 'Group' })
assert.equal(resolved.kind, 'group')
assert.equal(findGroup(view, 'Group'), group)
assert.equal(memberDetails(resolved), group)
})
test('keeps unresolved members visible but non-interactable', () => {
const resolved = resolveMember(view, {
kind: 'unresolved',
name: 'unknown',
reason: 'provider-unavailable',
})
assert.deepEqual(resolved, {
kind: 'unresolved',
ref: {
kind: 'unresolved',
name: 'unknown',
reason: 'provider-unavailable',
},
})
assert.equal(isInteractableMember(resolved), false)
assert.equal(memberDetails(resolved), undefined)
})
test('rebinds a node semantically when its response-scoped id moves', () => {
const previous = resolveMember(view, group.members[0])
assert.equal(previous.kind, 'node')
const movedNode = { ...node, recordId: 'p:1:0' }
const movedGroup = {
...group,
members: [
{ kind: 'node', name: movedNode.name, recordId: movedNode.recordId },
],
}
const movedView = {
...view,
groups: [movedGroup],
records: { [movedNode.recordId]: movedNode },
}
const movedCandidates = movedGroup.members.map((member) => {
const resolved = resolveMember(movedView, member)
assert.equal(resolved.kind, 'node')
return resolved.node
})
assert.equal(
rebindNode(movedCandidates, toNodeBinding(previous.node))?.recordId,
'p:1:0',
)
assert.equal(
rebindNode(movedCandidates, { name: 'provider-node' })?.recordId,
'p:1:0',
)
const duplicate = { ...movedNode, recordId: 'p:1:1' }
assert.equal(
rebindNode([movedNode, duplicate], toNodeBinding(previous.node)),
undefined,
)
const sameNameOtherSource = {
...movedNode,
recordId: 'p:2:0',
source: { ...movedNode.source, providerName: 'other-provider' },
}
assert.equal(
rebindNode([movedNode, sameNameOtherSource], {
name: 'provider-node',
}),
undefined,
)
})
test('keeps a runtime core node that is absent from GLOBAL as standalone', () => {
const runtimeOnly = {
...node,
recordId: 'c:0',
name: 'runtime-only',
source: { kind: 'core', proxyName: 'runtime-only' },
}
const excluded = {
...runtimeOnly,
recordId: 'c:1',
name: 'not-in-runtime',
source: { kind: 'core', proxyName: 'not-in-runtime' },
}
const standaloneView = {
...view,
global: { ...group, name: 'GLOBAL', members: [] },
records: { 'c:0': runtimeOnly, 'c:1': excluded },
standalone: ['c:0', 'c:1'],
}
assert.deepEqual(
selectRuntimeStandaloneNodes(standaloneView, [{ name: 'runtime-only' }]),
[runtimeOnly],
)
assert.deepEqual(
selectGlobalChainNodes(standaloneView, [{ name: 'runtime-only' }]),
[runtimeOnly],
)
})
test('does not expose global-chain candidates without GLOBAL', () => {
const runtimeOnly = {
...node,
recordId: 'c:0',
name: 'runtime-only',
source: { kind: 'core', proxyName: 'runtime-only' },
}
const globalMissingView = {
...view,
global: null,
records: { 'c:0': runtimeOnly },
standalone: ['c:0'],
}
assert.deepEqual(
selectGlobalChainNodes(globalMissingView, [{ name: 'runtime-only' }]),
[],
)
})
test('does not fall back to global nodes when a selected rule group disappears', () => {
assert.deepEqual(selectRuleChainMembers(view, 'removed-group'), [])
})
test('uses the current backend group selection after ids and now change', () => {
const previousNode = {
...node,
recordId: 'p:0:0',
name: 'previous-node',
source: { ...node.source, proxyName: 'previous-node' },
}
const nextNode = {
...node,
recordId: 'p:0:1',
name: 'next-node',
source: { ...node.source, proxyName: 'next-node' },
}
const firstGroup = {
...group,
now: previousNode.name,
members: [
{
kind: 'node',
name: previousNode.name,
recordId: previousNode.recordId,
},
{ kind: 'node', name: nextNode.name, recordId: nextNode.recordId },
],
}
const firstView = {
...view,
groups: [firstGroup],
records: {
[previousNode.recordId]: previousNode,
[nextNode.recordId]: nextNode,
},
}
assert.equal(
findCurrentGroupMember(firstView, firstGroup).member.node.recordId,
previousNode.recordId,
)
const movedPrevious = { ...previousNode, recordId: 'p:1:1' }
const movedNext = { ...nextNode, recordId: 'p:1:0' }
const refreshedGroup = {
...firstGroup,
now: movedNext.name,
members: [
{ kind: 'node', name: movedNext.name, recordId: movedNext.recordId },
{
kind: 'node',
name: movedPrevious.name,
recordId: movedPrevious.recordId,
},
],
}
const refreshedView = {
...firstView,
groups: [refreshedGroup],
records: {
[movedNext.recordId]: movedNext,
[movedPrevious.recordId]: movedPrevious,
},
}
const current = findCurrentGroupMember(refreshedView, refreshedGroup)
assert.equal(current.member.ref.name, movedNext.name)
assert.equal(current.member.node.recordId, movedNext.recordId)
const unresolvedGroup = {
...refreshedGroup,
now: 'unresolved-node',
members: [
{
kind: 'unresolved',
name: 'unresolved-node',
reason: 'provider-unavailable',
},
...refreshedGroup.members,
],
}
assert.equal(
findCurrentGroupMember(
{ ...refreshedView, groups: [unresolvedGroup] },
unresolvedGroup,
),
undefined,
)
assert.equal(
findCurrentGroupMember(refreshedView, {
...refreshedGroup,
now: undefined,
}),
undefined,
)
assert.equal(
findCurrentGroupMember(refreshedView, {
...refreshedGroup,
now: 'missing-node',
}),
undefined,
)
})
test('rebinds a semantic member occurrence across id and index rotation', () => {
const firstDuplicate = node
const secondDuplicate = { ...node, recordId: 'p:0:1' }
const firstMembers = [
resolveMember(view, group.members[0]),
{
kind: 'group',
ref: { kind: 'group', name: group.name },
group,
},
{
kind: 'node',
ref: {
kind: 'node',
name: secondDuplicate.name,
recordId: secondDuplicate.recordId,
},
node: secondDuplicate,
},
]
const binding = toMemberOccurrenceBinding(firstMembers, 2)
assert.deepEqual(binding, {
kind: 'node',
name: node.name,
source: node.source,
occurrence: 1,
})
const groupBinding = toMemberOccurrenceBinding(firstMembers, 1)
assert.deepEqual(groupBinding, {
kind: 'group',
name: group.name,
occurrence: 0,
})
const movedFirst = { ...firstDuplicate, recordId: 'p:1:0' }
const movedSecond = { ...secondDuplicate, recordId: 'p:1:1' }
const refreshedMembers = [
{
kind: 'group',
ref: { kind: 'group', name: group.name },
group,
},
{
kind: 'node',
ref: {
kind: 'node',
name: movedFirst.name,
recordId: movedFirst.recordId,
},
node: movedFirst,
},
{
kind: 'unresolved',
ref: { kind: 'unresolved', name: node.name, reason: 'ambiguous' },
},
{
kind: 'node',
ref: {
kind: 'node',
name: movedSecond.name,
recordId: movedSecond.recordId,
},
node: movedSecond,
},
]
const rebound = rebindMemberOccurrence(refreshedMembers, binding)
assert.equal(rebound.memberIndex, 3)
assert.equal(rebound.member.node.recordId, movedSecond.recordId)
assert.equal(
rebindMemberOccurrence(refreshedMembers, groupBinding).memberIndex,
0,
)
})
test('rejects missing, unresolved, or missing duplicate occurrences', () => {
const firstDuplicate = resolveMember(view, group.members[0])
const secondNode = { ...node, recordId: 'p:0:1' }
const secondDuplicate = {
kind: 'node',
ref: {
kind: 'node',
name: secondNode.name,
recordId: secondNode.recordId,
},
node: secondNode,
}
const binding = toMemberOccurrenceBinding(
[firstDuplicate, secondDuplicate],
1,
)
assert.equal(
rebindMemberOccurrence(
[
{
kind: 'unresolved',
ref: { kind: 'unresolved', name: node.name, reason: 'ambiguous' },
},
],
binding,
),
undefined,
)
assert.equal(rebindMemberOccurrence([firstDuplicate], binding), undefined)
assert.equal(
toMemberOccurrenceBinding(
[
{
kind: 'unresolved',
ref: { kind: 'unresolved', name: node.name, reason: 'missing' },
},
],
0,
),
undefined,
)
})