diff --git a/Cargo.lock b/Cargo.lock index 411c72a..cfa5cc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -473,6 +473,7 @@ dependencies = [ "smol", "surf", "toml", + "urlencoding", ] [[package]] @@ -2429,6 +2430,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 3cfa151..f938b2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,4 @@ anyhow = "1" dirs = "6" chrono = { version = "0.4", features = ["serde"] } crossterm = "0.29" +urlencoding = "2" diff --git a/SUMMARY.md b/SUMMARY.md index e4a09ab..b323924 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -5,6 +5,7 @@ A fullscreen terminal UI for controlling the [Clash](https://github.com/Dreamacr ## Features - **Proxy selector** -- browse proxy groups in a two-pane layout, switch active proxies +- **Delay testing** -- test latency for a single proxy or all proxies in a group (concurrent) - **Connections viewer** -- view active connections in a sortable table, close individual or all connections - **Configurable keybindings** -- customize all shortcuts via TOML config - **Periodic auto-refresh** -- configurable refresh interval (default 2s) @@ -18,6 +19,8 @@ Config file: `~/.config/clash_tui/config.toml` clash_api_url = "http://127.0.0.1:9090" clash_api_secret = "your-secret" # optional refresh_interval = 2 # seconds +test_delay_url = "http://www.gstatic.com/generate_204" +test_delay_timeout = 5000 # milliseconds [keybindings] quit = "q" @@ -31,6 +34,8 @@ select = "Enter" close_connection = "d" close_all_connections = "shift+d" refresh = "r" +test_delay = "t" +test_delay_all = "shift+t" ``` All keybindings and settings are optional and use the defaults shown above. @@ -59,6 +64,8 @@ CLI arguments override config file values. | `Left` | Switch to groups pane | | `Right` | Switch to members pane| | `Enter` | Select proxy | +| `t` | Test delay (selected) | +| `T` | Test delay (all) | | `d` | Close connection | | `D` | Close all connections | | `r` | Force refresh | diff --git a/src/clash.rs b/src/clash.rs index 44985a1..a858699 100644 --- a/src/clash.rs +++ b/src/clash.rs @@ -168,6 +168,28 @@ impl ClashClient { .map_err(|e| anyhow!("failed to close all connections: {}", e))?; Ok(()) } + + pub async fn test_delay(&self, name: &str, timeout: u64, url: &str) -> Result { + let request_url = format!( + "{}/proxies/{}/delay?timeout={}&url={}", + self.base_url, + urlencoding::encode(name), + timeout, + urlencoding::encode(url), + ); + let mut req = surf::get(&request_url); + if let Some(ref auth) = self.auth_header() { + req = req.header("Authorization", auth.as_str()); + } + let resp: serde_json::Value = req + .recv_json() + .await + .map_err(|e| anyhow!("delay test failed for {}: {}", name, e))?; + let delay = resp["delay"] + .as_u64() + .ok_or_else(|| anyhow!("unexpected delay response for {}", name))?; + Ok(delay) + } } pub fn format_bytes(bytes: u64) -> String { diff --git a/src/config.rs b/src/config.rs index ffa64f7..0ddad53 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,6 +8,8 @@ pub struct AppConfig { pub clash_api_url: String, pub clash_api_secret: Option, pub refresh_interval: u64, + pub test_delay_url: String, + pub test_delay_timeout: u64, pub keybindings: KeybindingsConfig, } @@ -17,6 +19,8 @@ impl Default for AppConfig { clash_api_url: "http://127.0.0.1:9090".to_string(), clash_api_secret: None, refresh_interval: 2, + test_delay_url: "http://www.gstatic.com/generate_204".to_string(), + test_delay_timeout: 5000, keybindings: KeybindingsConfig::default(), } } @@ -36,6 +40,8 @@ pub struct KeybindingsConfig { pub close_connection: String, pub close_all_connections: String, pub refresh: String, + pub test_delay: String, + pub test_delay_all: String, } impl Default for KeybindingsConfig { @@ -52,6 +58,8 @@ impl Default for KeybindingsConfig { close_connection: "d".to_string(), close_all_connections: "shift+d".to_string(), refresh: "r".to_string(), + test_delay: "t".to_string(), + test_delay_all: "shift+t".to_string(), } } } diff --git a/src/keybindings.rs b/src/keybindings.rs index 52909bc..db1ca08 100644 --- a/src/keybindings.rs +++ b/src/keybindings.rs @@ -87,6 +87,8 @@ pub struct Keybindings { pub close_connection: KeyBinding, pub close_all_connections: KeyBinding, pub refresh: KeyBinding, + pub test_delay: KeyBinding, + pub test_delay_all: KeyBinding, } impl Keybindings { @@ -103,6 +105,8 @@ impl Keybindings { close_connection: KeyBinding::parse(&cfg.close_connection)?, close_all_connections: KeyBinding::parse(&cfg.close_all_connections)?, refresh: KeyBinding::parse(&cfg.refresh)?, + test_delay: KeyBinding::parse(&cfg.test_delay)?, + test_delay_all: KeyBinding::parse(&cfg.test_delay_all)?, }) } } diff --git a/src/main.rs b/src/main.rs index 2136a1b..71fa895 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,8 @@ pub struct AppContext { pub client: clash::ClashClient, pub keybindings: keybindings::Keybindings, pub refresh_interval: u64, + pub test_delay_url: String, + pub test_delay_timeout: u64, } fn main() -> anyhow::Result<()> { @@ -44,6 +46,8 @@ fn main() -> anyhow::Result<()> { client, keybindings, refresh_interval: config.refresh_interval, + test_delay_url: config.test_delay_url, + test_delay_timeout: config.test_delay_timeout, }; smol::block_on( diff --git a/src/proxy_view.rs b/src/proxy_view.rs index f4e2fae..c2f1b84 100644 --- a/src/proxy_view.rs +++ b/src/proxy_view.rs @@ -1,4 +1,5 @@ use iocraft::prelude::*; +use std::collections::HashSet; use std::time::Duration; use crate::clash::ProxyItem; @@ -16,6 +17,8 @@ enum FocusPane { Members, } +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + #[component] pub fn ProxyView(mut hooks: Hooks) -> impl Into> { let ctx = hooks.use_context::(); @@ -25,11 +28,15 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into> { let mut selected_group = hooks.use_state(|| 0usize); let mut selected_member = hooks.use_state(|| 0usize); let mut focus_pane = hooks.use_state(|| FocusPane::Groups); + let testing = hooks.use_state(HashSet::::new); + let mut spinner_frame = hooks.use_state(|| 0usize); // Clone everything from context before any closures that need Send let client = ctx.client.clone(); let kb = ctx.keybindings.clone(); let refresh_interval = ctx.refresh_interval; + let test_delay_url = ctx.test_delay_url.clone(); + let test_delay_timeout = ctx.test_delay_timeout; let load = hooks.use_async_handler(move |_: ()| { let client = client.clone(); @@ -64,8 +71,23 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into> { } }); + // Spinner animation when testing + let testing_snapshot = testing; + hooks.use_future(async move { + loop { + smol::Timer::after(Duration::from_millis(80)).await; + let current = testing_snapshot.read(); + if !current.is_empty() { + spinner_frame.set((spinner_frame.get() + 1) % SPINNER_FRAMES.len()); + } + } + }); + let client_for_select = ctx.client.clone(); let load_select = load.clone(); + let client_for_delay = ctx.client.clone(); + let load_after_delay = load.clone(); + let testing_state = testing; hooks.use_terminal_events(move |event| { if let TerminalEvent::Key(key_event) = event { let state_read = state.read(); @@ -82,6 +104,66 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into> { .map(|m| m.len()) .unwrap_or(0); + // Test delay: single member + if focus_pane.get() == FocusPane::Members + && kb.test_delay.matches(&key_event) + && current_members > 0 + { + let members = groups[current_group].all.as_deref().unwrap_or(&[]); + let member_name = members[selected_member.get().min(current_members - 1)].clone(); + let client = client_for_delay.clone(); + let load = load_after_delay.clone(); + let mut ts = testing_state; + let url = test_delay_url.clone(); + let timeout = test_delay_timeout; + ts.write().insert(member_name.clone()); + smol::spawn(async move { + let _ = client.test_delay(&member_name, timeout, &url).await; + smol::Timer::after(Duration::from_millis(300)).await; + ts.write().remove(&member_name); + load(()); + }) + .detach(); + return; + } + + // Test delay: all members in current group + if focus_pane.get() == FocusPane::Members + && kb.test_delay_all.matches(&key_event) + && current_members > 0 + { + let members = groups[current_group].all.as_deref().unwrap_or(&[]); + let client = client_for_delay.clone(); + let load = load_after_delay.clone(); + let mut ts = testing_state; + let url = test_delay_url.clone(); + let timeout = test_delay_timeout; + for name in members.iter() { + ts.write().insert(name.clone()); + } + let names: Vec = members.to_vec(); + smol::spawn(async move { + // Test all concurrently + let mut tasks = Vec::new(); + for name in &names { + let c = client.clone(); + let n = name.clone(); + let u = url.clone(); + let mut ts2 = ts; + tasks.push(smol::spawn(async move { + let _ = c.test_delay(&n, timeout, &u).await; + ts2.write().remove(&n); + })); + } + for t in tasks { + let _ = t.await; + } + load(()); + }) + .detach(); + return; + } + match focus_pane.get() { FocusPane::Groups => { if kb.up.matches(&key_event) { @@ -138,6 +220,9 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into> { let left_width = if width > 20 { width * 3 / 10 } else { 10 }; let right_width = width.saturating_sub(left_width); + let testing_read = testing.read(); + let frame = spinner_frame.get(); + let state_read = state.read(); match &*state_read { DataState::Init | DataState::Loading => { @@ -257,18 +342,21 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into> { }; let marker = if is_focused { ">" } else { " " }; - let delay_str = groups - .iter() - .find(|g| g.name == *member) - .and_then(|g| g.latest_delay()) - .map(|d| { - if d == 0 { - "timeout".to_string() - } else { - format!("{}ms", d) - } - }) - .unwrap_or_default(); + let is_testing = testing_read.contains(member.as_str()); + + let (delay_text, delay_color) = if is_testing { + (format!("{} testing", SPINNER_FRAMES[frame]), Color::Yellow) + } else { + let delay = groups + .iter() + .find(|g| g.name == *member) + .and_then(|g| g.latest_delay()); + match delay { + Some(0) => ("timeout".to_string(), Color::Red), + Some(d) => (format!("{}ms", d), delay_color_for(d)), + None => (String::new(), Color::DarkGrey), + } + }; element! { View( @@ -277,7 +365,7 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into> { padding_left: 1, ) { Text(content: format!("{}{} ", marker, member), color: if is_current { Color::Green } else if is_selected { Color::White } else { Color::Grey }) - Text(content: format!("({})", delay_str), color: Color::DarkGrey) + Text(content: format!("({})", delay_text), color: delay_color) #(if is_current { element! { Text(content: " <--", color: Color::Green) }.into_any() } else { @@ -296,7 +384,7 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into> { border_color: Color::DarkGrey, padding_left: 1, ) { - Text(content: "[Enter] Select [Up/Down] Navigate [Left/Right] Switch Pane", color: Color::DarkGrey) + Text(content: "[Enter] Select [t] Test [T] Test All [Up/Down] Navigate", color: Color::DarkGrey) } } } @@ -304,3 +392,15 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into> { } } } + +fn delay_color_for(delay: u64) -> Color { + if delay == 0 { + Color::Red + } else if delay < 200 { + Color::Green + } else if delay < 500 { + Color::Yellow + } else { + Color::Red + } +}