diff --git a/clashctl-core/src/api.rs b/clashctl-core/src/api.rs index 5309b03..576cfda 100644 --- a/clashctl-core/src/api.rs +++ b/clashctl-core/src/api.rs @@ -4,15 +4,17 @@ use std::{ time::Duration, }; -use log::{debug, trace}; +use log::{debug, trace, warn}; use serde::de::DeserializeOwned; use serde_json::{from_str, json}; use ureq::{Agent, Request}; use url::Url; use crate::{ - model::{Config, Connections, Delay, Log, Proxies, Proxy, Rules, Traffic, Version}, Error, Result, + model::{ + Config, Connections, Delay, Log, Proxies, Proxy, ProxyProviders, Rules, Traffic, Version, + }, }; trait Convert { @@ -233,6 +235,30 @@ impl Clash { self.get("proxies") } + /// Get Mihomo proxy-provider information. + pub fn get_proxy_providers(&self) -> Result { + self.get("providers/proxies") + } + + /// Get proxies, including nodes that are only exposed through + /// `/providers/proxies`. + /// + /// Older controllers may not implement the provider endpoint. Its failure + /// is non-fatal: the complete top-level `/proxies` response is returned. + pub fn get_proxies_with_providers(&self) -> Result { + let proxies = self.get_proxies()?; + match self.get_proxy_providers() { + Ok(proxy_providers) => Ok(proxies.merge_proxy_providers(proxy_providers)), + Err(error) => { + warn!( + "Could not retrieve proxy providers; using /proxies response only: {}", + error + ); + Ok(proxies) + } + } + } + /// Get rules information pub fn get_rules(&self) -> Result { self.get("rules") @@ -300,6 +326,47 @@ impl Clash { } } +#[cfg(test)] +mod tests { + use std::{ + io::{Read as _, Write as _}, + net::TcpListener, + thread, + }; + + use super::Clash; + + #[test] + fn provider_endpoint_failure_falls_back_to_top_level_proxies() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + for (index, stream) in listener.incoming().take(2).enumerate() { + let mut stream = stream.unwrap(); + let mut request = [0; 1024]; + stream.read(&mut request).unwrap(); + let response = if index == 0 { + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: \ + close\r\n\r\n{\"proxies\":{\"Group\":{\"type\":\"Selector\",\"history\":[],\"\ + all\":[\"provider-node\"],\"now\":\"provider-node\"}}}" + } else { + "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + }; + stream.write_all(response.as_bytes()).unwrap(); + } + }); + let clash = Clash::builder(format!("http://{}", address)) + .unwrap() + .build(); + + let proxies = clash.get_proxies_with_providers().unwrap(); + + assert!(proxies.contains_key("Group")); + assert!(!proxies.contains_key("provider-node")); + server.join().unwrap(); + } +} + pub struct LongHaul { reader: BufReader>, ty: PhantomData, diff --git a/clashctl-core/src/model/proxy.rs b/clashctl-core/src/model/proxy.rs index 7b24224..508b746 100644 --- a/clashctl-core/src/model/proxy.rs +++ b/clashctl-core/src/model/proxy.rs @@ -1,5 +1,7 @@ -use std::collections::HashMap; -use std::ops::Deref; +use std::{ + collections::{BTreeMap, HashMap}, + ops::Deref, +}; use serde::{Deserialize, Serialize}; @@ -11,6 +13,17 @@ pub struct Proxies { } impl Proxies { + /// Add proxies returned by Mihomo proxy providers without replacing the + /// canonical entries returned by `/proxies`. + pub fn merge_proxy_providers(mut self, proxy_providers: ProxyProviders) -> Self { + for provider in proxy_providers.providers.into_values() { + for proxy in provider.proxies { + self.proxies.entry(proxy.name).or_insert(proxy.proxy); + } + } + self + } + pub fn normal(&self) -> impl Iterator { self.iter().filter(|(_, x)| x.proxy_type.is_normal()) } @@ -28,8 +41,29 @@ impl Proxies { } } +/// Response returned by Mihomo's `/providers/proxies` endpoint. +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)] +pub struct ProxyProviders { + /// A `BTreeMap` makes duplicate names across providers resolve in a + /// deterministic provider order when merged. + pub providers: BTreeMap, +} + +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)] +pub struct ProxyProvider { + pub proxies: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct ProviderProxy { + pub name: String, + #[serde(flatten)] + pub proxy: Proxy, +} + impl Deref for Proxies { type Target = HashMap; + fn deref(&self) -> &Self::Target { &self.proxies } @@ -181,3 +215,97 @@ fn test_proxies() { vec!["test_c"] ); } + +#[cfg(test)] +mod provider_tests { + use serde_json::from_str; + + use super::*; + + fn proxy(proxy_type: ProxyType) -> Proxy { + Proxy { + proxy_type, + history: vec![], + udp: None, + all: None, + now: None, + } + } + + #[test] + fn deserializes_and_merges_provider_only_proxies() { + let providers: ProxyProviders = from_str( + r#"{"providers":{"subscription":{"proxies":[{"name":"provider-node","type":"Shadowsocks","history":[]}]}}}"#, + ) + .unwrap(); + let merged = Proxies::default().merge_proxy_providers(providers); + + assert_eq!(merged["provider-node"].proxy_type, ProxyType::Shadowsocks); + } + + #[test] + fn top_level_proxy_takes_precedence_over_provider_proxy() { + let providers = ProxyProviders { + providers: BTreeMap::from([( + "subscription".to_owned(), + ProxyProvider { + proxies: vec![ProviderProxy { + name: "duplicate".to_owned(), + proxy: proxy(ProxyType::Shadowsocks), + }], + }, + )]), + }; + let top_level = Proxies { + proxies: HashMap::from([("duplicate".to_owned(), proxy(ProxyType::Direct))]), + }; + + let merged = top_level.merge_proxy_providers(providers); + + assert_eq!(merged["duplicate"].proxy_type, ProxyType::Direct); + } + + #[test] + fn first_provider_in_deterministic_order_wins_duplicate_names() { + let providers = ProxyProviders { + providers: BTreeMap::from([ + ( + "a-provider".to_owned(), + ProxyProvider { + proxies: vec![ProviderProxy { + name: "duplicate".to_owned(), + proxy: proxy(ProxyType::Shadowsocks), + }], + }, + ), + ( + "z-provider".to_owned(), + ProxyProvider { + proxies: vec![ProviderProxy { + name: "duplicate".to_owned(), + proxy: proxy(ProxyType::Trojan), + }], + }, + ), + ]), + }; + + let merged = Proxies::default().merge_proxy_providers(providers); + + assert_eq!(merged["duplicate"].proxy_type, ProxyType::Shadowsocks); + } + + #[test] + fn empty_provider_response_preserves_proxy_map() { + let top_level = Proxies { + proxies: HashMap::from([("node".to_owned(), proxy(ProxyType::Trojan))]), + }; + + assert_eq!( + top_level + .clone() + .merge_proxy_providers(ProxyProviders::default()), + top_level + ); + } +} diff --git a/clashctl/src/command/proxy.rs b/clashctl/src/command/proxy.rs index 951cb01..b0e8068 100644 --- a/clashctl/src/command/proxy.rs +++ b/clashctl/src/command/proxy.rs @@ -4,11 +4,11 @@ use clap::{Parser, Subcommand}; use clashctl_core::{model::ProxyType, strum::VariantNames}; use log::{error, info, warn}; use owo_colors::OwoColorize; -use requestty::{prompt_one, Answer, ListItem, Question}; +use requestty::{Answer, ListItem, Question, prompt_one}; use crate::{ - interactive::{Flags, ProxySortBy, SortOrder}, RenderList, Result, + interactive::{Flags, ProxySortBy, SortOrder}, }; // use crate::{Result}; @@ -101,11 +101,11 @@ impl ProxySubcommand { match self { ProxySubcommand::List(opt) => { - let proxies = clash.get_proxies()?; + let proxies = clash.get_proxies_with_providers()?; proxies.render_list(opt); } ProxySubcommand::Use => { - let proxies = clash.get_proxies()?; + let proxies = clash.get_proxies_with_providers()?; let mut groups = proxies .iter() .filter(|(_, p)| p.proxy_type.is_selector()) @@ -127,7 +127,8 @@ impl ProxySubcommand { }; let proxy = clash.get_proxy(&group_selected)?; - // all / now only occurs when proxy_type is [`ProxyType::Selector`] + // all / now only occurs when proxy_type is + // [`ProxyType::Selector`] let members = proxy.all.unwrap(); let now = proxy.now.unwrap(); let cur_index = members.iter().position(|x| x == &now).unwrap(); diff --git a/clashctl/src/ui/components/proxy/tree.rs b/clashctl/src/ui/components/proxy/tree.rs index 1265375..5c56cd4 100644 --- a/clashctl/src/ui/components/proxy/tree.rs +++ b/clashctl/src/ui/components/proxy/tree.rs @@ -10,7 +10,7 @@ use tui::{ use crate::{ components::{Footer, FooterItem, MovableListManage, ProxyGroup, ProxyItem}, interactive::{EndlessSelf, ProxySort, Sortable}, - ui::{help_footer, tagged_footer, Action, Coord, ListEvent, Wrap}, + ui::{Action, Coord, ListEvent, Wrap, help_footer, tagged_footer}, }; // TODO Proxy tree furthur functions @@ -156,7 +156,8 @@ impl<'a> ProxyTree<'a> { } pub fn replace_with(&mut self, mut new_tree: ProxyTree<'a>) -> &mut Self { - // let map = HashMap::<_, _, RandomState>::from_iter(self.groups.iter().map(|x| + // let map = HashMap::<_, _, + // RandomState>::from_iter(self.groups.iter().map(|x| // (&x.name, x))); let old_groups = &self.groups; let current_group = self.groups.get(self.cursor); @@ -198,23 +199,19 @@ impl<'a> From for ProxyTree<'a> { .expect("ProxyGroup should have member vec"); let mut members = Vec::with_capacity(all.len()); for x in all.iter() { - let member = ( - x.as_str(), - val.get(x) - .to_owned() - .expect("Group member should be in all proxies"), - ) - .into(); - members.push(member); + if let Some(proxy) = val.get(x) { + members.push((x.as_str(), proxy).into()); + } + } + + if members.is_empty() { + continue; } - // if group.now.is_some then it must be in all proxies - // So use map & expect instead of Option#and_then - let current = group.now.as_ref().map(|name| { + let current = group.now.as_ref().and_then(|name| { members .iter() .position(|item: &ProxyItem| &item.name == name) - .expect("Group member should be in all proxies") }); ret.groups.push(ProxyGroup { @@ -231,6 +228,155 @@ impl<'a> From for ProxyTree<'a> { } } +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, HashMap}; + + use clashctl_core::model::{ProviderProxy, Proxy, ProxyProvider, ProxyProviders, ProxyType}; + use crossterm::event::KeyCode; + + use super::*; + + fn proxy(proxy_type: ProxyType, all: Option>, now: Option<&str>) -> Proxy { + Proxy { + proxy_type, + history: vec![], + udp: None, + all: all.map(|members| members.into_iter().map(str::to_owned).collect()), + now: now.map(str::to_owned), + } + } + + fn proxies(entries: Vec<(&str, Proxy)>) -> Proxies { + Proxies { + proxies: entries + .into_iter() + .map(|(name, proxy)| (name.to_owned(), proxy)) + .collect::>(), + } + } + + #[test] + fn skips_members_missing_from_proxy_map() { + let tree = ProxyTree::from(proxies(vec![ + ( + "Group", + proxy( + ProxyType::Selector, + Some(vec!["valid", "missing"]), + Some("valid"), + ), + ), + ("valid", proxy(ProxyType::Shadowsocks, None, None)), + ])); + + assert_eq!(tree.groups.len(), 1); + assert_eq!(tree.groups[0].members.len(), 1); + assert_eq!(tree.groups[0].members[0].name, "valid"); + assert_eq!(tree.groups[0].current, Some(0)); + } + + #[test] + fn clears_current_when_current_member_is_missing() { + let tree = ProxyTree::from(proxies(vec![ + ( + "Group", + proxy(ProxyType::Selector, Some(vec!["valid"]), Some("missing")), + ), + ("valid", proxy(ProxyType::Shadowsocks, None, None)), + ])); + + assert_eq!(tree.groups[0].current, None); + assert_eq!(tree.groups[0].cursor, 0); + } + + #[test] + fn skips_groups_with_no_available_members() { + let tree = ProxyTree::from(proxies(vec![( + "Empty", + proxy(ProxyType::Selector, Some(vec!["missing"]), Some("missing")), + )])); + + assert!(tree.groups.is_empty()); + } + + #[test] + fn keeps_valid_groups_selectable() { + let mut tree = ProxyTree::from(proxies(vec![ + ( + "Group", + proxy(ProxyType::Selector, Some(vec!["one", "two"]), Some("one")), + ), + ("one", proxy(ProxyType::Shadowsocks, None, None)), + ("two", proxy(ProxyType::Trojan, None, None)), + ])); + + tree.hold(); + tree.handle(ListEvent { + fast: false, + code: KeyCode::Down, + }); + let action = tree.handle(ListEvent { + fast: false, + code: KeyCode::Enter, + }); + + assert!(matches!( + action, + Some(Action::ApplySelection { group, proxy }) if group == "Group" && proxy == "two" + )); + } + + #[test] + fn provider_resolved_group_keeps_current_member_and_can_switch() { + let top_level = proxies(vec![( + "Group", + proxy( + ProxyType::Selector, + Some(vec!["provider-one", "provider-two"]), + Some("provider-one"), + ), + )]); + let providers = ProxyProviders { + providers: BTreeMap::from([( + "subscription".to_owned(), + ProxyProvider { + proxies: vec![ + ProviderProxy { + name: "provider-one".to_owned(), + proxy: proxy(ProxyType::Shadowsocks, None, None), + }, + ProviderProxy { + name: "provider-two".to_owned(), + proxy: proxy(ProxyType::Trojan, None, None), + }, + ], + }, + )]), + }; + let mut tree = ProxyTree::from(top_level.merge_proxy_providers(providers)); + + assert_eq!(tree.groups.len(), 1); + assert_eq!(tree.groups[0].current, Some(0)); + assert_eq!(tree.groups[0].members.len(), 2); + + tree.hold(); + tree.handle(ListEvent { + fast: false, + code: KeyCode::Down, + }); + let action = tree.handle(ListEvent { + fast: false, + code: KeyCode::Enter, + }); + + assert!(matches!( + action, + Some(Action::ApplySelection { group, proxy }) if group == "Group" && proxy == "provider-two" + )); + } +} + impl<'a> MovableListManage for ProxyTree<'a> { fn sort(&mut self) -> &mut Self { let method = self.sort_method; diff --git a/clashctl/src/ui/servo.rs b/clashctl/src/ui/servo.rs index 7c637e9..ae10cc0 100644 --- a/clashctl/src/ui/servo.rs +++ b/clashctl/src/ui/servo.rs @@ -1,6 +1,6 @@ use std::{ sync::mpsc::{Receiver, Sender}, - thread::{scope, JoinHandle}, + thread::{JoinHandle, scope}, time::Duration, }; @@ -12,9 +12,9 @@ use rayon::prelude::*; use crate::{ interactive::Flags, ui::{ + Action, TuiOpt, TuiResult, event::{Event, UpdateEvent}, utils::{Interval, Pulse}, - Action, TuiOpt, TuiResult, }, }; @@ -84,7 +84,9 @@ fn req_job(_opt: &TuiOpt, _flags: &Flags, tx: Sender, clash: &Clash) -> T tx.send(Event::Update(UpdateEvent::Rules(clash.get_rules()?)))?; } if proxies_pulse.tick() { - tx.send(Event::Update(UpdateEvent::Proxies(clash.get_proxies()?)))?; + tx.send(Event::Update(UpdateEvent::Proxies( + clash.get_proxies_with_providers()?, + )))?; } if config_pulse.tick() { tx.send(Event::Update(UpdateEvent::Config(clash.get_configs()?)))?; @@ -150,13 +152,17 @@ fn action_job( } tx.send(Event::Update(UpdateEvent::ProxyTestLatencyDone))?; - tx.send(Event::Update(UpdateEvent::Proxies(clash.get_proxies()?)))?; + tx.send(Event::Update(UpdateEvent::Proxies( + clash.get_proxies_with_providers()?, + )))?; } Action::ApplySelection { group, proxy } => { let _ = clash .set_proxygroup_selected(&group, &proxy) .map_err(|e| warn!("{:?}", e)); - tx.send(Event::Update(UpdateEvent::Proxies(clash.get_proxies()?)))?; + tx.send(Event::Update(UpdateEvent::Proxies( + clash.get_proxies_with_providers()?, + )))?; } } }