diff --git a/eslint.config.ts b/eslint.config.ts index 3bdcad168..c7bd663ae 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -44,6 +44,7 @@ export default defineConfig([ `vite.config.mts`, 'scripts/*.mjs', 'src/polyfills/*.js', + 'tests/*.mjs', ], }, }, diff --git a/src-tauri/src/cmd/proxy.rs b/src-tauri/src/cmd/proxy.rs index 327d7cc6a..2a9d5b7e4 100644 --- a/src-tauri/src/cmd/proxy.rs +++ b/src-tauri/src/cmd/proxy.rs @@ -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 { + 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 { + 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()] + ); + } +} diff --git a/src-tauri/src/cmd/runtime.rs b/src-tauri/src/cmd/runtime.rs index 1671b2b6e..b9c88471d 100644 --- a/src-tauri/src/cmd/runtime.rs +++ b/src-tauri/src/cmd/runtime.rs @@ -12,28 +12,6 @@ pub async fn get_runtime_config() -> CmdResult> { Ok(Config::runtime().await.latest_arc().config.clone()) } -/// 获取运行时代理组顺序 -#[tauri::command] -pub async fn get_runtime_proxy_group_order() -> CmdResult> { - 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 { diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index d2bc839ff..a776f59f9 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -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; diff --git a/src-tauri/src/core/proxy_view.rs b/src-tauri/src/core/proxy_view.rs new file mode 100644 index 000000000..d0cf989aa --- /dev/null +++ b/src-tauri/src/core/proxy_view.rs @@ -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, + pub proxies: Proxies, + pub providers: Option, +} + +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, + pub direct: Option, + pub groups: Vec, + pub records: BTreeMap, + pub standalone: Vec, + pub providers: Vec, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub fixed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hidden: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub test_url: Option, + pub history: Vec, + #[serde(flatten)] + pub capabilities: ProxyCapabilities, + pub members: Vec, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hidden: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub test_url: Option, + #[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub subscription_info: Option, + pub proxy_record_ids: Vec, +} + +#[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, + core_node_ids: &'a BTreeMap, + provider_candidates: &'a BTreeMap>, + 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, BTreeMap) { + let mut groups = BTreeMap::new(); + let mut nodes = BTreeMap::new(); + + for (name, proxy) in proxies.proxies { + if proxy.all.is_some() { + groups.insert(name, proxy); + } else { + nodes.insert(name, proxy); + } + } + + (groups, nodes) +} + +fn build_core_records( + core_nodes: BTreeMap, +) -> (BTreeMap, BTreeMap) { + 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, + records: &mut BTreeMap, +) -> (Vec, BTreeMap>) { + let providers = providers + .map(|providers| providers.providers.into_iter().collect::>()) + .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, + runtime_group_order: &[String], + resolver: &MemberResolver<'_>, +) -> (Vec, 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) -> Vec { + let mut standalone = ["DIRECT", "REJECT"] + .into_iter() + .filter_map(|name| core_node_ids.get(name).cloned()) + .collect::>(); + 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) -> 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::>(), + ["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::>(), + ["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::>(), + ["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::>(), + ["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::>(), + ["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()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fc19b303f..6b050c095 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index 8425e1007..b14ba9e3a 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -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 - } - selection: { - group: string - proxy: string - } - displayProxy: any - } - - const [state, setState] = useState({ - proxyData: { - groups: [], - records: {}, - }, - selection: { - group: '', - proxy: '', - }, - displayProxy: null, - }) + const [selectedGroupName, setSelectedGroupName] = useState('') const autoCheckInProgressRef = useRef(false) const latestTimeoutRef = useRef( verge?.default_latency_timeout || 10000, ) - const latestProxyRecordRef = useRef(null) + const latestProxyMemberRef = useRef(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() - - 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) - : [] - ) as Array - 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(() => { + 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) => { 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) => { 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: , 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 | 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 ( - {selected} + {selectedProxyName} { // 延迟测试 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} > @@ -885,7 +665,7 @@ export const CurrentProxyCard = () => { > {isCoreDataPending ? ( - ) : currentProxy ? ( + ) : currentProxy || (!isDirectMode && selectedGroup) ? ( {/* 代理节点信息显示 */} { > - {currentProxy.name} + {currentProxy?.name ?? + t('home.components.currentProxy.labels.noActiveNode')} { color="text.secondary" sx={{ mr: 1 }} > - {currentProxy.type} + {currentProxy?.type} {isGlobalMode && ( { /> )} {/* 节点特性 */} - {currentProxy.udp && ( + {currentProxy?.udp && ( )} - {currentProxy.tfo && ( + {currentProxy?.tfo && ( )} - {currentProxy.xudp && ( + {currentProxy?.xudp && ( )} - {currentProxy.mptcp && ( + {currentProxy?.mptcp && ( )} - {currentProxy.smux && ( + {currentProxy?.smux && ( )} @@ -971,12 +752,12 @@ 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 ( { }} > - {proxy.name} + {option.member.ref.name} - + {interactable && ( + + )} ) })} diff --git a/src/components/proxy/provider-button.tsx b/src/components/proxy/provider-button.tsx index 8f067ed26..a4b263310 100644 --- a/src/components/proxy/provider-button.tsx +++ b/src/components/proxy/provider-button.tsx @@ -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>({}) - - 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={} 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')} @@ -180,170 +183,168 @@ export const ProviderButton = () => { - {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 ( - { - const bgcolor = mode === 'light' ? '#ffffff' : '#24252f' - const hoverColor = - mode === 'light' - ? alpha(primary.main, 0.1) - : alpha(primary.main, 0.2) + return ( + { + 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, - }, - } - }, - ]} - > - - - {key} - - {provider.proxies.length} - - - {provider.vehicleType} - - - - - {t('shared.labels.updateAt')}: - {time.fromNow()} - - + return { + backgroundColor: bgcolor, + '&:hover': { + backgroundColor: hoverColor, + }, } - secondary={ - <> - {/* 订阅信息 */} - {hasSubInfo && ( - <> - - - {parseTraffic(upload + download)} /{' '} - {parseTraffic(total)} - - - {parseExpire(expire)} - - - - {/* 进度条 */} - 0 ? 1 : 0, - }} - /> - - )} - - } - /> - - - { - updateProvider(key) - }} - disabled={isUpdating} + }, + ]} + > + - - - - - ) - })} + + {key} + + {provider.proxyRecordIds.length} + + + {provider.vehicleType} + + + + + {t('shared.labels.updateAt')}: + {updatedAt?.fromNow() ?? '-'} + + + } + secondary={ + <> + {/* 订阅信息 */} + {hasSubInfo && ( + <> + + + {parseTraffic(upload + download)} /{' '} + {parseTraffic(total)} + + + {parseExpire(expire)} + + + + {/* 进度条 */} + 0 ? 1 : 0, + }} + /> + + )} + + } + /> + + + { + 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')} + > + + + + + ) + })} diff --git a/src/components/proxy/proxy-chain-model.ts b/src/components/proxy/proxy-chain-model.ts new file mode 100644 index 000000000..e18d4ab7a --- /dev/null +++ b/src/components/proxy/proxy-chain-model.ts @@ -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, + } + }) diff --git a/src/components/proxy/proxy-chain.tsx b/src/components/proxy/proxy-chain.tsx index 0b77d5921..a96eaa63d 100644 --- a/src/components/proxy/proxy-chain.tsx +++ b/src/components/proxy/proxy-chain.tsx @@ -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, }} > { 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 ( - {proxyChain.length > 0 && ( + {currentProxyChain.length > 0 && ( { @@ -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 = ({ - {proxyChain.length === 1 + {currentProxyChain.length === 1 ? t('proxies.page.chain.minimumNodesHint') || '链式代理至少需要2个节点,请再添加一个节点。' : t('proxies.page.chain.instruction') || @@ -577,7 +580,7 @@ export const ProxyChain = ({ - {proxyChain.length === 0 ? ( + {currentProxyChain.length === 0 ? ( proxy.id)} + items={currentProxyChain.map((proxy) => proxy.id)} strategy={verticalListSortingStrategy} > - {proxyChain.map((proxy, index) => ( + {currentProxyChain.map((proxy, index) => ( 1 + index === currentProxyChain.length - 1 && + currentProxyChain.length > 1 } onRemove={handleRemoveProxy} /> - {index < proxyChain.length - 1 && ( + {index < currentProxyChain.length - 1 && ( - {group.type} · {group.all?.length ?? 0} 节点 + {group.type} · {group.members.length} 节点 @@ -243,7 +239,7 @@ function ProxyVirtualList({ onLocation: (group: any) => void onCheckAll: (groupName: string) => void onHeadState: (groupName: string, patch: Partial) => 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(() => { @@ -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) 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) { { 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(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 } diff --git a/src/components/proxy/proxy-item-mini.tsx b/src/components/proxy/proxy-item-mini.tsx index 447685252..ea3ab097b 100644 --- a/src/components/proxy/proxy-item-mini.tsx +++ b/src/components/proxy/proxy-item-mini.tsx @@ -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 ( 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) => { }, ]} > - + { whiteSpace: 'nowrap', }} > - {proxy.name} + {name} {showType && ( @@ -98,7 +109,7 @@ export const ProxyItemMini = (props: Props) => { marginTop: '4px', }} > - {proxy.now && ( + {now && ( { marginRight: '8px', }} > - {proxy.now} + {now} )} - {!!proxy.provider && ( + {!!provider && ( - {proxy.provider} + {provider} )} - {proxy.type} + {type} - {proxy.udp && ( + {!unresolved && details?.udp && ( UDP )} - {proxy.xudp && ( + {!unresolved && details?.xudp && ( XUDP )} - {proxy.tfo && ( + {!unresolved && details?.tfo && ( TFO )} - {proxy.mptcp && ( + {!unresolved && details?.mptcp && ( MPTCP )} - {proxy.smux && ( + {!unresolved && details?.smux && ( SMUX @@ -154,18 +165,18 @@ export const ProxyItemMini = (props: Props) => { - {delayValue === -2 && ( + {!unresolved && delayValue === -2 && ( )} - {delayValue !== -2 && ( + {!unresolved && delayValue !== -2 && ( { e.preventDefault() e.stopPropagation() - onDelay() + void onDelay() }} sx={({ palette }) => ({ display: 'none', // hover 时显示 @@ -176,14 +187,14 @@ export const ProxyItemMini = (props: Props) => { )} - {delayValue >= 0 && ( + {!unresolved && delayValue >= 0 && ( // 显示延迟 { 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)} )} - {proxy.type !== 'Direct' && + {!unresolved && + type !== 'Direct' && delayValue !== -2 && delayValue < 0 && selected && ( @@ -204,10 +216,10 @@ export const ProxyItemMini = (props: Props) => { /> )} - {group.fixed && group.fixed === proxy.name && ( + {!unresolved && group.fixed && group.fixed === name && ( // 展示 fixed 状态 - 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) => { 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) => { ]} > { color: 'text.primary', }} > - {proxy.name} - {showType && proxy.now && ` - ${proxy.now}`} + {name} + {showType && now && ` - ${now}`} - {showType && !!proxy.provider && ( - {proxy.provider} + {showType && !!provider && {provider}} + {showType && {type}} + {!unresolved && showType && details?.udp && ( + UDP + )} + {!unresolved && showType && details?.xudp && ( + XUDP + )} + {!unresolved && showType && details?.tfo && ( + TFO + )} + {!unresolved && showType && details?.mptcp && ( + MPTCP + )} + {!unresolved && showType && details?.smux && ( + SMUX )} - {showType && {proxy.type}} - {showType && proxy.udp && UDP} - {showType && proxy.xudp && XUDP} - {showType && proxy.tfo && TFO} - {showType && proxy.mptcp && MPTCP} - {showType && proxy.smux && SMUX} } /> @@ -119,19 +141,19 @@ export const ProxyItem = (props: Props) => { display: isPreset ? 'none' : '', }} > - {delayValue === -2 && ( + {!unresolved && delayValue === -2 && ( )} - {delayValue !== -2 && ( + {!unresolved && delayValue !== -2 && ( { e.preventDefault() e.stopPropagation() - onDelay(proxy.provider) + void onDelay() }} sx={({ palette }) => ({ display: 'none', // hover 时显示 @@ -142,14 +164,14 @@ export const ProxyItem = (props: Props) => { )} - {delayValue > 0 && ( + {!unresolved && delayValue > 0 && ( // 显示延迟 { 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) => { )} - {delayValue !== -2 && delayValue <= 0 && selected && ( + {!unresolved && delayValue !== -2 && delayValue <= 0 && selected && ( // 展示已选择的 icon ) => 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) => ( 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) { > @@ -239,11 +240,11 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) { return ( 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} ) } diff --git a/src/components/proxy/use-filter-sort.ts b/src/components/proxy/use-filter-sort.ts index c3cafa919..30bf39694 100644 --- a/src/components/proxy/use-filter-sort.ts +++ b/src/components/proxy/use-filter-sort.ts @@ -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 diff --git a/src/components/proxy/use-render-list.ts b/src/components/proxy/use-render-list.ts index 60811e937..1e202c6cb 100644 --- a/src/components/proxy/use-render-list.ts +++ b/src/components/proxy/use-render-list.ts @@ -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 = (list: T[], size: number): T[][] => { - return list.reduce((acc, item) => { +const groupOccurrences = (list: T[], size: number): T[][] => + list.reduce((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>(new Map()) const prevListRef = useRef([]) - // 处理渲染列表 - const renderList: IRenderItem[] = useMemo(() => { - if (!proxiesData) return [] + const renderList = useMemo(() => { + 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 组件中实现),此处无需额外处理。 diff --git a/src/components/setting/mods/tunnels-viewer.tsx b/src/components/setting/mods/tunnels-viewer.tsx index 03f8b38ad..71b0ec9f0 100644 --- a/src/components/setting/mods/tunnels-viewer.tsx +++ b/src/components/setting/mods/tunnels-viewer.tsx @@ -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((_, 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({ localAddr: '', localPort: '', targetAddr: '', targetPort: '', network: 'tcp+udp', group: '', - proxy: '', + proxy: null, }) const [draftTunnels, setDraftTunnels] = useState([]) @@ -64,7 +100,7 @@ export const TunnelsViewer = forwardRef((_, ref) => { targetPort: '', network: 'tcp+udp', group: '', - proxy: '', + proxy: null, })) setDraftTunnels(() => clash?.tunnels ?? []) setOpen(true) @@ -92,21 +128,61 @@ export const TunnelsViewer = forwardRef((_, ref) => { }) }, [draftTunnels]) - const { proxies } = useProxiesData() + const { proxyView } = useProxiesData() - const proxyGroups = useMemo(() => { - return proxies?.groups ?? [] - }, [proxies]) + const proxyGroups = useMemo(() => proxyView?.groups ?? [], [proxyView]) const groupNames = useMemo( () => proxyGroups.map((group) => group.name), [proxyGroups], ) - const proxyOptions = useMemo(() => { + const proxyOptions = useMemo(() => { + 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((_, 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((_, 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((_, 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((_, ref) => { diff --git a/src/hooks/use-proxy-delay-state.ts b/src/hooks/use-proxy-delay-state.ts index cce0ce76f..f8a07f299 100644 --- a/src/hooks/use-proxy-delay-state.ts +++ b/src/hooks/use-proxy-delay-state.ts @@ -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 + onDelay: () => Promise } 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 { diff --git a/src/pages/_layout/hooks/use-layout-events.ts b/src/pages/_layout/hooks/use-layout-events.ts index c7eacb95c..fe34f5a1a 100644 --- a/src/pages/_layout/hooks/use-layout-events.ts +++ b/src/pages/_layout/hooks/use-layout-events.ts @@ -42,12 +42,11 @@ export const useLayoutEvents = ( register( addListener('verge://refresh-clash-config', () => { revalidateKeys([ - 'getProxies', + 'getProxyView', 'getVersion', 'getClashConfig', 'getClashMode', 'getRuntimeConfig', - 'getProxyProviders', 'getRules', 'getRuleProviders', ]) diff --git a/src/providers/app-data-context.ts b/src/providers/app-data-context.ts index 890ff1919..e21882252 100644 --- a/src/providers/app-data-context.ts +++ b/src/providers/app-data-context.ts @@ -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 - isProxiesPending: boolean + proxyView: ProxyViewV1 | undefined + isProxyViewPending: boolean } export interface RulesContextType { @@ -37,13 +33,12 @@ export interface CoreDataStatusContextType { } export interface RefreshersContextType { - refreshProxy: () => Promise - refreshClashConfig: () => Promise - refreshRules: () => Promise - refreshSysproxy: () => Promise - refreshProxyProviders: () => Promise - refreshRuleProviders: () => Promise - refreshAll: () => Promise + refreshProxy: () => Promise + refreshClashConfig: () => Promise + refreshRules: () => Promise + refreshSysproxy: () => Promise + refreshRuleProviders: () => Promise + refreshAll: () => Promise } export const ProxiesContext = createContext(null) @@ -65,18 +60,8 @@ const useCtx = (ctx: Context, hookName: string): T => { return v } -export const useProxiesData = () => { - const { proxies, proxyProviders, isProxiesPending } = useCtx( - ProxiesContext, - 'useProxiesData', - ) - - return { - proxies, - proxyProviders: proxyProviders as Record, - isProxiesPending, - } -} +export const useProxiesData = (): ProxiesContextType => + useCtx(ProxiesContext, 'useProxiesData') export const useRulesData = () => { const { rules, ruleProviders } = useCtx(RulesContext, 'useRulesData') diff --git a/src/providers/app-data-provider.tsx b/src/providers/app-data-provider.tsx index 19f6fe4fe..21765d284 100644 --- a/src/providers/app-data-provider.tsx +++ b/src/providers/app-data-provider.tsx @@ -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, ], diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 096516e71..1552a9a66 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -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('get_runtime_config') } -async function getRuntimeProxyGroupOrder() { - return invoke('get_runtime_proxy_group_order') -} - export async function getRuntimeYaml() { return invoke('get_runtime_yaml') } @@ -129,128 +125,12 @@ export async function syncTrayProxySelection() { return invoke('sync_tray_proxy_selection') } -export async function calcuProxies(): Promise<{ - global: IProxyGroupItem - direct: IProxyItem - groups: IProxyGroupItem[] - records: Record - 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 { + const view = await invoke('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, - 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() { diff --git a/src/services/delay.ts b/src/services/delay.ts index 3adb47c84..dd6f25d12 100644 --- a/src/services/delay.ts +++ b/src/services/delay.ts @@ -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 { + 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 => { - 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) } diff --git a/src/types/proxy-view.ts b/src/types/proxy-view.ts new file mode 100644 index 000000000..1f87bff84 --- /dev/null +++ b/src/types/proxy-view.ts @@ -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 + 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 + group: ProxyGroupView + } + | { + kind: 'node' + ref: Extract + node: ProxyNodeView + } + | { + kind: 'unresolved' + ref: Extract + } + +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 +} diff --git a/tests/proxy-view.test.mjs b/tests/proxy-view.test.mjs new file mode 100644 index 000000000..d7d0fc921 --- /dev/null +++ b/tests/proxy-view.test.mjs @@ -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, + ) +})