mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-01 20:22:19 +08:00
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:
@@ -44,6 +44,7 @@ export default defineConfig([
|
||||
`vite.config.mts`,
|
||||
'scripts/*.mjs',
|
||||
'src/polyfills/*.js',
|
||||
'tests/*.mjs',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 组件中实现),此处无需额外处理。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -42,12 +42,11 @@ export const useLayoutEvents = (
|
||||
register(
|
||||
addListener('verge://refresh-clash-config', () => {
|
||||
revalidateKeys([
|
||||
'getProxies',
|
||||
'getProxyView',
|
||||
'getVersion',
|
||||
'getClashConfig',
|
||||
'getClashMode',
|
||||
'getRuntimeConfig',
|
||||
'getProxyProviders',
|
||||
'getRules',
|
||||
'getRuleProviders',
|
||||
])
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user