diff --git a/clashtui/api/src/output.json b/clashtui/api/src/output.json new file mode 100644 index 0000000..3b62417 --- /dev/null +++ b/clashtui/api/src/output.json @@ -0,0 +1,77 @@ +{ + "downloadTotal": 2668789, + "uploadTotal": 4179, + "connections": [ + { + "id": "2661ce43-17bd-4c23-9c79-8369298c8fb3", + "metadata": { + "network": "tcp", + "type": "HTTPS", + "sourceIP": "127.0.0.1", + "destinationIP": "", + "destinationGeoIP": null, + "destinationIPASN": "", + "sourcePort": "39926", + "destinationPort": "443", + "inboundIP": "127.0.0.1", + "inboundPort": "7890", + "inboundName": "DEFAULT-MIXED", + "inboundUser": "", + "host": "objects.githubusercontent.com", + "dnsMode": "normal", + "uid": 0, + "process": "", + "processPath": "", + "specialProxy": "", + "specialRules": "", + "remoteDestination": "185.199.110.133", + "dscp": 0, + "sniffHost": "" + }, + "upload": 1194, + "download": 2567632, + "start": "2024-06-30T09:20:17.386789854Z", + "chains": [ + "DIRECT" + ], + "rule": "", + "rulePayload": "" + }, + { + "id": "59f12afb-719a-4d0c-a4b8-1ec78f604e9c", + "metadata": { + "network": "tcp", + "type": "HTTPS", + "sourceIP": "127.0.0.1", + "destinationIP": "", + "destinationGeoIP": null, + "destinationIPASN": "", + "sourcePort": "39916", + "destinationPort": "443", + "inboundIP": "127.0.0.1", + "inboundPort": "7890", + "inboundName": "DEFAULT-MIXED", + "inboundUser": "", + "host": "github.com", + "dnsMode": "normal", + "uid": 0, + "process": "", + "processPath": "", + "specialProxy": "", + "specialRules": "", + "remoteDestination": "20.205.243.166", + "dscp": 0, + "sniffHost": "" + }, + "upload": 854, + "download": 7692, + "start": "2024-06-30T09:20:17.374233864Z", + "chains": [ + "DIRECT" + ], + "rule": "", + "rulePayload": "" + } + ], + "memory": 0 +} \ No newline at end of file diff --git a/clashtui/backend/src/backend/impl_conn.rs b/clashtui/backend/src/backend/impl_conn.rs index 47bdfd6..6e9f115 100644 --- a/clashtui/backend/src/backend/impl_conn.rs +++ b/clashtui/backend/src/backend/impl_conn.rs @@ -1,7 +1,7 @@ use super::ClashBackend; impl ClashBackend { - #[cfg(test)] + #[cfg(not(test))] pub fn get_connections(&self) -> Result<(Option>>, (u64, u64)), String> { use crate::utils::bytes_to_readable; Ok(( @@ -12,14 +12,15 @@ impl ClashBackend { "DIRECT".to_string(), "2024-06-30T09:20:17.386789854Z".to_string(), bytes_to_readable(854), - bytes_to_readable(7652) + bytes_to_readable(7652), + "59f12afb-719a-4d0c-a4b8-1ec78f604e9c".to_string() ]; 3 ]), (10000, 0), )) } - #[cfg(not(test))] + #[cfg(test)] pub fn get_connections(&self) -> Result<(Option>>, (u64, u64)), String> { use crate::utils::bytes_to_readable; use api::{Conn, ConnInfo, ConnMetaData}; @@ -40,7 +41,7 @@ impl ClashBackend { .flat_map(|t| t.into_iter()) .map(|c| { let Conn { - id: _, + id, metadata, upload, download, @@ -66,6 +67,7 @@ impl ClashBackend { start, bytes_to_readable(upload), bytes_to_readable(download), + id ] }) .collect(), diff --git a/clashtui/backend/src/utils/mod.rs b/clashtui/backend/src/utils/mod.rs index 61efd8d..fe912b0 100644 --- a/clashtui/backend/src/utils/mod.rs +++ b/clashtui/backend/src/utils/mod.rs @@ -40,6 +40,13 @@ macro_rules! define_enum { } } }; + ($(#[$attr:meta])* + $vis:vis $name: ident, + [$($variant:ident,)*]) => { + define_enum!($(#[$attr:meta])* + $vis:vis $name: ident, + [$($variant:ident),*]) + }; } #[cfg(target_os = "linux")] diff --git a/clashtui/src/tui/app.rs b/clashtui/src/tui/app.rs index db3bf5a..f075acb 100644 --- a/clashtui/src/tui/app.rs +++ b/clashtui/src/tui/app.rs @@ -15,6 +15,7 @@ use crate::tui::{ use crate::utils::{ClashBackend, SharedBackend, SharedState, State}; use super::impl_app::MonkeyPatch; +use super::tabs::ConnctlTab; pub struct App { tabbar: TabBar, @@ -38,6 +39,7 @@ impl App { let tabs: Vec = vec![ Tabs::Profile(ProfileTab::new(util.clone(), state.clone())), Tabs::ClashSrvCtl(ClashSrvCtlTab::new(util.clone(), state.clone())), + Tabs::ConnCtl(ConnctlTab::new(util.clone())), ]; // Init the tabs let tabbar = TabBar::new(tabs.iter().map(|v| v.to_string()).collect()); let statusbar = StatusBar::new(Rc::clone(&state)); @@ -73,7 +75,7 @@ impl App { .expect(backend::const_err::ERR_PATH_UTF_8) )); }; - if is_root::is_root(){ + if is_root::is_root() { self.popup_txt_msg(crate::utils::consts::ROOT_WARNING.to_string()) } err_track @@ -112,6 +114,7 @@ impl App { let mut iter = self.tabs.iter_mut().map(|v| match v { Tabs::Profile(tab) => tab.popup_event(ev), Tabs::ClashSrvCtl(tab) => tab.popup_event(ev), + Tabs::ConnCtl(tab) => tab.popup_event(ev), }); while event_state.is_notconsumed() { match iter.next() { @@ -194,6 +197,7 @@ impl App { let mut iter = self.tabs.iter_mut().map(|v| match v { Tabs::Profile(tab) => tab.event(ev), Tabs::ClashSrvCtl(tab) => Ok(tab.event(ev)?), + Tabs::ConnCtl(tab) => Ok(tab.event(ev)?), }); while event_state.is_notconsumed() { match iter.next() { @@ -210,6 +214,7 @@ impl App { self.tabs.iter_mut().for_each(|v| match v { Tabs::Profile(tab) => tab.late_event(), Tabs::ClashSrvCtl(tab) => tab.late_event(), + Tabs::ConnCtl(tab) => tab.late_event(), }) } @@ -233,6 +238,7 @@ impl App { self.tabs.iter_mut().for_each(|v| match v { Tabs::Profile(tab) => tab.draw(f, tab_chunk), Tabs::ClashSrvCtl(tab) => tab.draw(f, tab_chunk), + Tabs::ConnCtl(tab) => tab.draw(f, tab_chunk), }); self.statusbar.draw(f, chunks[2]); @@ -256,6 +262,7 @@ impl App { .for_each(|(b, v)| match v { Tabs::Profile(tab) => tab.set_visible(b), Tabs::ClashSrvCtl(tab) => tab.set_visible(b), + Tabs::ConnCtl(tab) => tab.set_visible(b), }); } } diff --git a/clashtui/src/tui/tabs/connctl.rs b/clashtui/src/tui/tabs/connctl.rs new file mode 100644 index 0000000..994c044 --- /dev/null +++ b/clashtui/src/tui/tabs/connctl.rs @@ -0,0 +1,252 @@ +use ratatui::prelude as Ra; +use ratatui::widgets as Raw; +use ratatui::{ + layout::Alignment, + text::Text, + widgets::{Cell, Row, Table, TableState}, +}; +use ui::widgets::ConfirmPopup; +use ui::{widgets::MsgPopup, EventState, Visibility}; + +use crate::{msgpopup_methods, tui::utils::Keys, utils::SharedBackend}; +crate::utils::define_enum!( + #[derive(PartialEq, Eq)] + Cop, + [ + Refresh, + PreTerminate, + Terminate, + ShowInfo, + PreTerminateAll, + TerminateAll + ] +); +#[derive(Visibility)] +pub struct ConnctlTab { + is_visible: bool, + + items: Option>>, + state: TableState, + + util: SharedBackend, + msgpopup: ConfirmPopup, + + op: Option, +} + +impl ConnctlTab { + pub fn new(util: SharedBackend) -> Self { + let mut instance = Self { + is_visible: false, + items: None, + state: Default::default(), + util, + msgpopup: Default::default(), + op: None, + }; + instance.refresh(); + instance + } + pub fn refresh(&mut self) { + let vars = match self.util.get_connections() { + Ok(v) => v, + Err(e) => { + self.popup_list_msg(vec![ + "Failed to fetch connection info from clash".to_string(), + format!("ERR message:{e}"), + ]); + return; + } + }; + if let Some(var) = vars.0 { + self.items.replace(var); + } + } + pub fn terminate_conn(&mut self) { + if let Some(index) = self.state.selected() { + let id = self + .items + .as_ref() + .unwrap() + .get(index) + .unwrap() + .get(6) + .unwrap(); + if let Err(e) = self.util.terminate_conn(id) { + self.popup_list_msg(vec![ + "Failed to terminate connection".to_string(), + format!("ERR message:{e}"), + ]); + } + self.refresh(); + } else { + unreachable!("Called without a selected item") + } + } + pub fn terminate_all_conns(&mut self) { + if let Err(e) = self.util.terminate_all_conns() { + self.popup_list_msg(vec![ + "Failed to terminate all connections".to_string(), + format!("ERR message:{e}"), + ]); + } + self.refresh(); + } +} + +impl super::TabEvent for ConnctlTab { + fn draw(&mut self, f: &mut ratatui::prelude::Frame, area: ratatui::prelude::Rect) { + if !self.is_visible { + return; + } + use ratatui::prelude::Constraint; + const BAR: [&str; 6] = ["Name", "Url", "Type", "Start Time", "Recvived", "Send"]; + let cur_width = f.size().width; + let col_styles = [ + Alignment::Left, + Alignment::Left, + Alignment::Center, + Alignment::Left, + Alignment::Center, + Alignment::Center, + ]; + let header = Row::new(BAR.into_iter().map(|s| Text::from(s).centered())); + let rows: Vec = self + .items + .iter() + .flat_map(|i| i.iter()) + .map(|l| { + Row::new( + l.iter() + .take(6) + .zip(col_styles) + .map(|(s, a)| Text::from(s.as_str()).alignment(a)), + ) + }) + .collect(); + let tabs = Table::new( + rows, + [ + Constraint::Length(cur_width / 5), + Constraint::Length(cur_width / 5), + Constraint::Length(cur_width / 10), + Constraint::Length(cur_width / 5), + Constraint::Length(cur_width / 10), + Constraint::Length(cur_width / 10), + ], + ) + .header(header); + + f.render_stateful_widget( + tabs.block( + Raw::Block::default() + .borders(Raw::Borders::ALL) + .title("Connections"), + ) + .highlight_symbol("█"), + area, + &mut self.state, + ); + + self.msgpopup.draw(f, area); + } + + fn popup_event(&mut self, ev: &ui::event::Event) -> Result { + if !self.is_visible { + return Ok(EventState::NotConsumed); + } + let event_state = self.msgpopup.event(ev)?; + if let ui::event::Event::Key(key) = ev { + if key.code == ui::event::KeyCode::Enter { + if self.op.as_ref().is_some_and(|p| *p == Cop::ShowInfo) { + self.op.replace(Cop::PreTerminate); + self.msgpopup + .popup_confirm("Sure to terminate this connection?".to_owned()); + return Ok(EventState::WorkDone); + } else { + return Ok(EventState::NotConsumed); + } + } + } + match event_state { + EventState::Yes => { + if self.op.as_ref().is_some_and(|p| *p == Cop::PreTerminate) { + self.op.replace(Cop::Terminate); + Ok(EventState::WorkDone) + } else { + Ok(EventState::NotConsumed) + } + } + EventState::Cancel => { + self.op.take(); + Ok(EventState::WorkDone) + } + _ => Ok(event_state), + } + } + + fn event(&mut self, ev: &ui::event::Event) -> Result { + if !self.is_visible { + return Ok(EventState::NotConsumed); + } + + if let ui::event::Event::Key(key) = ev { + if key.kind != ui::event::KeyEventKind::Press { + return Ok(EventState::NotConsumed); + } + match key.code.into() { + Keys::Up => self.previous(), + Keys::Down => self.next(), + Keys::ConnRefresh => { + self.op.replace(Cop::Refresh); + } + Keys::Select => { + self.popup_list_msg(vec!["in dev".to_string()]); + self.op.replace(Cop::ShowInfo); + } + Keys::Search => todo!(), + _ => return Ok(EventState::NotConsumed), + } + return Ok(EventState::WorkDone); + } + return Ok(EventState::NotConsumed); + } + + fn late_event(&mut self) {} +} +impl ConnctlTab { + pub fn next(&mut self) { + if let Some(items) = self.items.as_ref() { + let i = match self.state.selected() { + Some(i) => { + if i >= items.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.state.select(Some(i)); + //self.scroll_state = self.scroll_state.position(i * ITEM_HEIGHT); + } + } + + pub fn previous(&mut self) { + if let Some(items) = self.items.as_ref() { + let i = match self.state.selected() { + Some(i) => { + if i == 0 { + items.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + self.state.select(Some(i)); + //self.scroll_state = self.scroll_state.position(i * ITEM_HEIGHT); + } + } +} +msgpopup_methods!(ConnctlTab); diff --git a/clashtui/src/tui/tabs/mod.rs b/clashtui/src/tui/tabs/mod.rs index 1cb5ba0..f5cfc0f 100644 --- a/clashtui/src/tui/tabs/mod.rs +++ b/clashtui/src/tui/tabs/mod.rs @@ -1,14 +1,17 @@ #![allow(refining_impl_trait)] mod clashsrvctl; +mod connctl; mod profile; mod profile_input; pub use clashsrvctl::ClashSrvCtlTab; +pub use connctl::ConnctlTab; pub use profile::ProfileTab; pub enum Tabs { Profile(ProfileTab), ClashSrvCtl(ClashSrvCtlTab), + ConnCtl(ConnctlTab), } impl std::fmt::Display for Tabs { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -19,6 +22,7 @@ impl std::fmt::Display for Tabs { match self { Tabs::Profile(_) => symbols::PROFILE.to_string(), Tabs::ClashSrvCtl(_) => symbols::CLASHSRVCTL.to_string(), + Tabs::ConnCtl(_) => "Connections Control".to_string(), } ) } diff --git a/clashtui/src/tui/tabs/profile.rs b/clashtui/src/tui/tabs/profile.rs index 4f27752..13bd87a 100644 --- a/clashtui/src/tui/tabs/profile.rs +++ b/clashtui/src/tui/tabs/profile.rs @@ -87,7 +87,7 @@ impl ProfileTab { } else { self.state.borrow_mut().set_profile(profile_name.clone()); self.confirm_popup - .popup_msg("Extra proxy provider?".to_owned()); + .popup_confirm("Extra proxy provider?".to_owned()); self.op.replace(PTOp::PreTrim); } }; @@ -106,7 +106,7 @@ impl ProfileTab { } else { msg.push("Update and selected".to_string()); self.confirm_popup - .popup_msg("Extra proxy provider?".to_owned()); + .popup_confirm("Extra proxy provider?".to_owned()); self.op.replace(PTOp::PreTrim); } } else { @@ -257,13 +257,13 @@ impl super::TabEvent for ProfileTab { Keys::ProfileUpdate => { self.popup_txt_msg("Updating...".to_string()); self.op.replace(PTOp::PreUpdate); - self.confirm_popup.popup_msg("Update with proxy?".to_owned()); + self.confirm_popup.popup_confirm("Update with proxy?".to_owned()); EventState::WorkDone } Keys::ProfileUpdateAll => { self.popup_txt_msg("Updating...".to_string()); self.op.replace(PTOp::PreUpdateAll); - self.confirm_popup.popup_msg("Update with proxy?".to_owned()); + self.confirm_popup.popup_confirm("Update with proxy?".to_owned()); EventState::WorkDone } Keys::ProfileImport => { @@ -272,7 +272,7 @@ impl super::TabEvent for ProfileTab { } Keys::ProfileDelete => { self.confirm_popup - .popup_msg("`y` to Delete, `Esc` to cancel".to_string()); + .popup_confirm("`y` to Delete, `Esc` to cancel".to_string()); self.op.replace(PTOp::PreDelete); EventState::WorkDone } diff --git a/clashtui/src/tui/utils/key_list.rs b/clashtui/src/tui/utils/key_list.rs index 8d6f881..be65277 100644 --- a/clashtui/src/tui/utils/key_list.rs +++ b/clashtui/src/tui/utils/key_list.rs @@ -1,5 +1,5 @@ use ui::event::{KeyCode, KeyEvent}; -#[derive(PartialEq)] +#[derive(PartialEq, Clone, Copy)] pub enum Keys { ProfileSwitch, ProfileUpdate, @@ -10,6 +10,7 @@ pub enum Keys { ProfileInfo, ProfileNoPp, // no proxy provider TemplateSwitch, + ConnRefresh, Edit, Preview, @@ -20,6 +21,7 @@ pub enum Keys { Select, Esc, Tab, + Search, SoftRestart, LogCat, @@ -43,6 +45,7 @@ impl From for Keys { KeyCode::Enter => Keys::Select, KeyCode::Esc => Keys::Esc, KeyCode::Tab => Keys::Tab, + KeyCode::Char('/') => Keys::Search, // ## Profile Tab shortcuts KeyCode::Char('p') => Keys::ProfileSwitch, // Not Global shortcuts @@ -61,6 +64,9 @@ impl From for Keys { KeyCode::Char('n') => Keys::ProfileInfo, KeyCode::Char('m') => Keys::ProfileNoPp, + // ## Profile Tab shortcuts + KeyCode::Char('r') => Keys::ConnRefresh, + // ## Global Shortcuts (As much as possible use uppercase. And Others as much as possible use lowcase to avoid conflicts with global shortcuts.) KeyCode::Char('q') => Keys::AppQuit, // Exiting is a common operation, and most software also exits with "q", so let's use "q". KeyCode::Char('R') => Keys::SoftRestart, diff --git a/clashtui/ui/src/widgets/confirm_popup.rs b/clashtui/ui/src/widgets/confirm_popup.rs index c033dec..7e1c3a4 100644 --- a/clashtui/ui/src/widgets/confirm_popup.rs +++ b/clashtui/ui/src/widgets/confirm_popup.rs @@ -57,8 +57,34 @@ impl ConfirmPopup { self.0.draw(f, _area); } - pub fn popup_msg(&mut self, confirm_str: String) { + pub fn popup_confirm(&mut self, confirm_str: String) { self.0.push_txt_msg(confirm_str); + self.should_confirm(true); self.0.show(); } } + +impl ConfirmPopup { + pub fn show(&mut self) { + self.0.show() + } + pub fn hide(&mut self) { + self.0.hide() + } + pub fn push_txt_msg(&mut self, msg: String) { + if self.0.is_visible() && self.1 { + panic!("Overriding one confirm msg") + } + self.0.push_txt_msg(msg); + self.should_confirm(false); + self.0.show() + } + pub fn push_list_msg(&mut self, msg: impl IntoIterator) { + if self.0.is_visible() && self.1 { + panic!("Overriding one confirm msg") + } + self.0.push_list_msg(msg); + self.should_confirm(false); + self.0.show() + } +}