├── clashctl-core ├── src │ ├── test │ │ ├── mod.rs │ │ └── api.rs │ ├── lib.rs │ ├── model │ │ ├── traffic.rs │ │ ├── config.rs │ │ ├── log.rs │ │ ├── mod.rs │ │ ├── rule.rs │ │ ├── connection.rs │ │ └── proxy.rs │ ├── error.rs │ └── api.rs ├── Cargo.toml └── README.md ├── .clippy.toml ├── rust-toolchain.toml ├── .gitignore ├── clashctl ├── src │ ├── ui │ │ ├── components │ │ │ ├── movable_list │ │ │ │ ├── mod.rs │ │ │ │ ├── item.rs │ │ │ │ ├── widget.rs │ │ │ │ └── state.rs │ │ │ ├── proxy │ │ │ │ ├── mod.rs │ │ │ │ ├── item.rs │ │ │ │ ├── tree_widget.rs │ │ │ │ ├── sort.rs │ │ │ │ └── group.rs │ │ │ ├── mod.rs │ │ │ ├── tabs.rs │ │ │ ├── traffic.rs │ │ │ ├── constants.rs │ │ │ ├── sparkline.rs │ │ │ └── block_footer.rs │ │ ├── action.rs │ │ ├── utils │ │ │ ├── mod.rs │ │ │ ├── pulse.rs │ │ │ ├── coord.rs │ │ │ ├── as_color.rs │ │ │ ├── hms.rs │ │ │ ├── interval.rs │ │ │ ├── ticks_counter.rs │ │ │ ├── wrap.rs │ │ │ ├── tui_logger.rs │ │ │ ├── ext.rs │ │ │ └── helper.rs │ │ ├── tui_opt.rs │ │ ├── pages │ │ │ ├── proxy.rs │ │ │ ├── mod.rs │ │ │ ├── log.rs │ │ │ ├── debug.rs │ │ │ ├── rule.rs │ │ │ ├── config.rs │ │ │ ├── connection.rs │ │ │ └── status.rs │ │ ├── main.rs │ │ ├── mod.rs │ │ ├── error.rs │ │ ├── config.rs │ │ ├── app.rs │ │ ├── event.rs │ │ ├── servo.rs │ │ └── state.rs │ ├── interactive │ │ ├── mod.rs │ │ ├── config_model.rs │ │ ├── error.rs │ │ ├── sort │ │ │ ├── con_sort.rs │ │ │ ├── mod.rs │ │ │ ├── rule_sort.rs │ │ │ └── proxy_sort.rs │ │ ├── flags.rs │ │ └── config.rs │ ├── error.rs │ ├── main.rs │ ├── command │ │ ├── mod.rs │ │ ├── completion.rs │ │ ├── proxy.rs │ │ └── server.rs │ ├── utils.rs │ └── proxy_render.rs └── Cargo.toml ├── .github └── workflows │ ├── todo.yaml │ ├── clippy.yaml │ └── release.yaml ├── Cargo.toml ├── rustfmt.toml ├── justfile ├── .config └── hakari.toml ├── LICENSE └── README.md /clashctl-core/src/test/mod.rs: -------------------------------------------------------------------------------- 1 | mod api; 2 | -------------------------------------------------------------------------------- /.clippy.toml: -------------------------------------------------------------------------------- 1 | enum-variant-size-threshold = 1145 2 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /.vscode 3 | *.profraw 4 | .trigger 5 | 6 | */target 7 | /data -------------------------------------------------------------------------------- /clashctl/src/ui/components/movable_list/mod.rs: -------------------------------------------------------------------------------- 1 | mod_use::mod_use![item, state, widget]; 2 | -------------------------------------------------------------------------------- /clashctl/src/ui/components/proxy/mod.rs: -------------------------------------------------------------------------------- 1 | mod_use::mod_use!(group, item, sort, tree, tree_widget); 2 | -------------------------------------------------------------------------------- /clashctl/src/interactive/mod.rs: -------------------------------------------------------------------------------- 1 | pub use clashctl_core as clashctl; 2 | 3 | mod_use::mod_use![flags, sort, error, config, config_model]; 4 | -------------------------------------------------------------------------------- /clashctl/src/ui/action.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone)] 2 | pub enum Action { 3 | TestLatency { proxies: Vec }, 4 | ApplySelection { group: String, proxy: String }, 5 | } 6 | -------------------------------------------------------------------------------- /clashctl/src/ui/components/mod.rs: -------------------------------------------------------------------------------- 1 | mod_use::mod_use![ 2 | block_footer, 3 | constants, 4 | movable_list, 5 | proxy, 6 | sparkline, 7 | tabs, 8 | traffic 9 | ]; 10 | -------------------------------------------------------------------------------- /clashctl-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | 3 | mod_use::mod_use![api, error]; 4 | 5 | #[cfg(test)] 6 | mod test; 7 | 8 | pub mod model; 9 | 10 | #[cfg(feature = "enum_ext")] 11 | pub use strum; 12 | -------------------------------------------------------------------------------- /clashctl/src/ui/utils/mod.rs: -------------------------------------------------------------------------------- 1 | mod_use::mod_use![ 2 | hms, 3 | ext, 4 | wrap, 5 | coord, 6 | pulse, 7 | helper, 8 | interval, 9 | as_color, 10 | tui_logger 11 | ticks_counter 12 | ]; 13 | -------------------------------------------------------------------------------- /clashctl-core/src/model/traffic.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)] 4 | pub struct Traffic { 5 | pub up: u64, 6 | pub down: u64, 7 | } 8 | -------------------------------------------------------------------------------- /clashctl/src/ui/tui_opt.rs: -------------------------------------------------------------------------------- 1 | use smart_default::SmartDefault; 2 | 3 | #[derive(Debug, SmartDefault, clap::Parser)] 4 | pub struct TuiOpt { 5 | #[clap(default_value = "5")] 6 | #[default = 5.0] 7 | /// Interval between requests 8 | pub interval: f32, 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/todo.yaml: -------------------------------------------------------------------------------- 1 | name: "TODO" 2 | on: ["push"] 3 | jobs: 4 | build: 5 | runs-on: "ubuntu-latest" 6 | steps: 7 | - uses: "actions/checkout@master" 8 | - name: "TODO to Issue" 9 | uses: "alstr/todo-to-issue-action@v4.5" 10 | id: "todo" -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | cargo-features = ["strip"] 2 | 3 | [workspace] 4 | members = ["clashctl*"] 5 | 6 | [profile.release] 7 | lto = true 8 | strip = true 9 | panic = "abort" 10 | opt-level = "z" 11 | codegen-units = 1 12 | 13 | [net] 14 | git-fetch-with-cli = true 15 | -------------------------------------------------------------------------------- /clashctl/src/ui/pages/proxy.rs: -------------------------------------------------------------------------------- 1 | use tui::widgets::Widget; 2 | 3 | use crate::{components::ProxyTreeWidget, define_widget}; 4 | 5 | define_widget!(ProxyPage); 6 | 7 | impl<'a> Widget for ProxyPage<'a> { 8 | fn render(self, area: tui::layout::Rect, buf: &mut tui::buffer::Buffer) { 9 | ProxyTreeWidget::new(&self.state.proxy_tree).render(area, buf); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /clashctl/src/ui/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | 3 | use crate::{ 4 | interactive::Flags, 5 | tui::{main_loop, TuiOpt}, 6 | }; 7 | 8 | #[derive(Debug, clap::Parser)] 9 | struct Opt { 10 | #[clap(flatten)] 11 | opt: TuiOpt, 12 | #[clap(flatten)] 13 | flag: Flags, 14 | } 15 | 16 | fn main() { 17 | let Opt { opt, flag } = Opt::parse(); 18 | if let Err(e) = main_loop(opt, flag) { 19 | eprintln!("{}", e) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | unstable_features = true 2 | 3 | version = "Two" 4 | 5 | group_imports = "StdExternalCrate" 6 | imports_granularity = "Crate" 7 | reorder_imports = true 8 | 9 | wrap_comments = true 10 | normalize_comments = true 11 | 12 | reorder_impl_items = true 13 | condense_wildcard_suffixes = true 14 | enum_discrim_align_threshold = 20 15 | use_field_init_shorthand = true 16 | 17 | format_strings = true 18 | format_code_in_doc_comments = true 19 | format_macro_matchers = true 20 | -------------------------------------------------------------------------------- /clashctl/src/ui/utils/pulse.rs: -------------------------------------------------------------------------------- 1 | pub struct Pulse { 2 | pulse: u64, 3 | counter: u64, 4 | } 5 | 6 | impl Pulse { 7 | #[inline] 8 | pub fn new(pulse: u64) -> Self { 9 | Self { pulse, counter: 0 } 10 | } 11 | 12 | #[inline] 13 | pub fn tick(&mut self) -> bool { 14 | let ret = self.is_pulse(); 15 | self.counter += 1; 16 | ret 17 | } 18 | 19 | #[inline] 20 | pub fn is_pulse(&self) -> bool { 21 | self.counter % self.pulse == 0 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/clippy.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | name: Clippy check 6 | jobs: 7 | clippy_check: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v1 11 | - uses: actions-rs/toolchain@v1 12 | with: 13 | toolchain: nightly 14 | components: clippy 15 | override: true 16 | 17 | - uses: actions-rs/clippy-check@v1 18 | with: 19 | token: ${{ secrets.GITHUB_TOKEN }} 20 | args: --all-features -------------------------------------------------------------------------------- /clashctl/src/ui/utils/coord.rs: -------------------------------------------------------------------------------- 1 | #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] 2 | pub struct Coord { 3 | pub x: usize, 4 | pub y: usize, 5 | pub hold: bool, 6 | } 7 | 8 | impl Coord { 9 | pub fn toggle(&mut self) { 10 | if self.hold { 11 | self.end() 12 | } else { 13 | self.hold() 14 | } 15 | } 16 | 17 | pub fn end(&mut self) { 18 | *self = Self::default() 19 | } 20 | 21 | pub fn hold(&mut self) { 22 | self.hold = true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /clashctl-core/src/model/config.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use super::{Level, Mode}; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 6 | #[serde(rename_all = "kebab-case")] 7 | pub struct Config { 8 | pub port: u64, 9 | pub socks_port: u64, 10 | pub redir_port: u64, 11 | pub tproxy_port: u64, 12 | pub mixed_port: u64, 13 | pub allow_lan: bool, 14 | pub ipv6: bool, 15 | pub mode: Mode, 16 | pub log_level: Level, 17 | pub bind_address: String, 18 | pub authentication: Vec, 19 | } 20 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | alias r := run 2 | alias b := build 3 | alias d := dev 4 | 5 | run *args: 6 | cargo run -p clashctl -- {{ args }} 7 | 8 | reset_terminal: 9 | pkill clashctl && stty sane && stty cooked 10 | 11 | dev: 12 | cargo watch -x 'check -p clashctl > /dev/null 2>&1 ' -s 'touch .trigger' > /dev/null & 13 | cargo watch --no-gitignore -w .trigger -x 'run -p clashctl' 14 | 15 | build: 16 | cargo build --release 17 | 18 | release os: build 19 | #!/usr/bin/env bash 20 | pushd target/release 21 | rm clashctl*.d 22 | mv clashctl-tui* clashctl-tui-{{ os }} 23 | mv clashctl* clashctl-{{ os }} 24 | popd 25 | 26 | test *args: 27 | cargo test -- {{ args }} --nocapture -------------------------------------------------------------------------------- /clashctl/src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod components; 2 | pub mod pages; 3 | 4 | mod_use::mod_use![ 5 | utils, action, app, event, servo, state, error, tui_opt, config 6 | ]; 7 | 8 | macro_rules! define_widget { 9 | ($name:ident) => { 10 | #[derive(Clone, Debug)] 11 | pub struct $name<'a> { 12 | state: &'a $crate::TuiStates<'a>, 13 | _life: ::std::marker::PhantomData<&'a ()>, 14 | } 15 | 16 | impl<'a> $name<'a> { 17 | pub fn new(state: &'a $crate::TuiStates<'a>) -> Self { 18 | Self { 19 | _life: ::std::marker::PhantomData, 20 | state, 21 | } 22 | } 23 | } 24 | }; 25 | } 26 | 27 | pub(crate) use define_widget; 28 | -------------------------------------------------------------------------------- /clashctl-core/src/model/log.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 4 | #[serde(rename_all = "lowercase")] 5 | #[cfg_attr( 6 | feature = "enum_ext", 7 | derive(strum::EnumString, strum::Display, strum::EnumVariantNames), 8 | strum(ascii_case_insensitive, serialize_all = "UPPERCASE") 9 | )] 10 | pub enum Level { 11 | Error, 12 | #[cfg_attr(feature = "enum_ext", strum(serialize = "WARN"))] 13 | Warning, 14 | Info, 15 | Debug, 16 | } 17 | 18 | // TODO Parse log 19 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 20 | pub struct Log { 21 | #[serde(rename = "type")] 22 | pub log_type: Level, 23 | pub payload: String, 24 | } 25 | -------------------------------------------------------------------------------- /clashctl/src/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, thiserror::Error)] 2 | pub enum ErrorKind { 3 | #[error("{0}")] 4 | InteractiveError(#[from] crate::interactive::InteractiveError), 5 | 6 | #[error("{0}")] 7 | TuiError(#[from] crate::ui::TuiError), 8 | 9 | #[error("{0}")] 10 | ClashCtl(#[from] clashctl_core::Error), 11 | 12 | #[error("Requestty error")] 13 | RequesttyError(#[from] requestty::ErrorKind), 14 | } 15 | #[derive(Debug, thiserror::Error)] 16 | #[error(transparent)] 17 | pub struct Error(Box); 18 | 19 | impl From for Error 20 | where 21 | ErrorKind: From, 22 | { 23 | fn from(err: E) -> Self { 24 | Error(Box::new(ErrorKind::from(err))) 25 | } 26 | } 27 | 28 | pub type Result = std::result::Result; 29 | -------------------------------------------------------------------------------- /clashctl/src/interactive/config_model.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use url::Url; 5 | 6 | use crate::{ConSort, ProxySort, RuleSort, Server}; 7 | 8 | #[derive(Serialize, Deserialize, Debug, Default, Clone)] 9 | pub struct ConfigData { 10 | pub servers: Vec, 11 | pub using: Option, 12 | #[serde(default)] 13 | pub tui: TuiConfig, 14 | #[serde(default)] 15 | pub sort: SortsConfig, 16 | } 17 | 18 | #[derive(Serialize, Deserialize, Debug, Default, Clone)] 19 | pub struct TuiConfig { 20 | pub log_file: Option, 21 | } 22 | 23 | #[derive(Serialize, Deserialize, Debug, Default, Clone)] 24 | pub struct SortsConfig { 25 | pub connections: ConSort, 26 | pub rules: RuleSort, 27 | pub proxies: ProxySort, 28 | } 29 | -------------------------------------------------------------------------------- /clashctl/src/main.rs: -------------------------------------------------------------------------------- 1 | mod_use::mod_use![command, proxy_render, utils, error, interactive, ui]; 2 | 3 | pub use clap; 4 | use log::debug; 5 | use ui::main_loop; 6 | 7 | use crate::{clap::Parser, Cmd, Opts}; 8 | 9 | pub fn run() { 10 | let opts = Opts::parse(); 11 | opts.init_logger(); 12 | debug!("Opts: {:#?}", opts); 13 | 14 | if let Err(e) = match opts.cmd { 15 | None => main_loop(Default::default(), opts.flag).map_err(Into::into), 16 | Some(Cmd::Tui(opt)) => main_loop(opt, opts.flag).map_err(Into::into), 17 | Some(Cmd::Proxy(sub)) => sub.handle(&opts.flag), 18 | Some(Cmd::Server(sub)) => sub.handle(&opts.flag), 19 | Some(Cmd::Completion(arg)) => arg.handle(), 20 | } { 21 | eprintln!("{}", e) 22 | } 23 | } 24 | 25 | fn main() { 26 | run() 27 | } 28 | -------------------------------------------------------------------------------- /clashctl/src/ui/utils/as_color.rs: -------------------------------------------------------------------------------- 1 | use tui::style::Color; 2 | 3 | use crate::clashctl::model; 4 | 5 | pub trait AsColor { 6 | fn as_color(&self) -> Color; 7 | } 8 | 9 | impl AsColor for model::Level { 10 | fn as_color(&self) -> Color { 11 | match self { 12 | model::Level::Debug => Color::Gray, 13 | model::Level::Info => Color::Blue, 14 | model::Level::Warning => Color::Yellow, 15 | model::Level::Error => Color::Red, 16 | } 17 | } 18 | } 19 | 20 | impl AsColor for log::Level { 21 | fn as_color(&self) -> Color { 22 | match self { 23 | log::Level::Debug => Color::Gray, 24 | log::Level::Info => Color::Blue, 25 | log::Level::Warn => Color::Yellow, 26 | log::Level::Error => Color::Red, 27 | _ => Color::Gray, 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /clashctl/src/interactive/error.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | #[derive(Debug, thiserror::Error)] 4 | pub enum InteractiveError { 5 | #[error("Clashctl error: {0}")] 6 | ClashCtl(#[from] clashctl_core::Error), 7 | 8 | #[error("Cannot find server")] 9 | ServerNotFound, 10 | 11 | #[error("{0} is not a directory")] 12 | ConfigFileTypeError(PathBuf), 13 | 14 | #[error("Config file cannot be found")] 15 | ConfigFileOpenError, 16 | 17 | #[error("Config file IO error ({0})")] 18 | ConfigFileIoError(std::io::Error), 19 | 20 | #[error("Config file cannot be parsed ({0})")] 21 | ConfigFileFormatError(#[from] ron::error::SpannedError), 22 | 23 | #[error("Config file cannot be generated ({0})")] 24 | ConfigFileGenerateError(#[from] ron::Error), 25 | } 26 | 27 | pub type InteractiveResult = std::result::Result; 28 | -------------------------------------------------------------------------------- /clashctl/src/ui/pages/mod.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | mod connection; 3 | mod debug; 4 | mod log; 5 | mod proxy; 6 | mod rule; 7 | mod status; 8 | 9 | use tui::{layout::Rect, Frame}; 10 | 11 | use crate::{Backend, TuiStates}; 12 | 13 | pub fn route(state: &TuiStates, area: Rect, f: &mut Frame) { 14 | match state.page_index { 15 | 0 => f.render_widget(status::StatusPage::new(state), area), 16 | 1 => f.render_widget(proxy::ProxyPage::new(state), area), 17 | 2 => f.render_widget(rule::RulePage::new(state), area), 18 | 3 => f.render_widget(connection::ConnectionPage::new(state), area), 19 | 4 => f.render_widget(log::LogPage::new(state), area), 20 | 5 => f.render_widget(config::ConfigPage::new(&state.config_state), area), 21 | 6 => f.render_widget(debug::DebugPage::new(state), area), 22 | _ => unreachable!(), 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /clashctl/src/ui/components/movable_list/item.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use tui::text::{Span, Spans}; 4 | 5 | pub trait MovableListItem<'a> { 6 | fn to_spans(&self) -> Spans<'a>; 7 | } 8 | 9 | impl<'a> MovableListItem<'a> for Spans<'a> { 10 | fn to_spans(&self) -> Spans<'a> { 11 | self.to_owned() 12 | } 13 | } 14 | 15 | impl<'a> MovableListItem<'a> for String { 16 | fn to_spans(&self) -> Spans<'a> { 17 | Spans(vec![Span::raw(self.to_owned())]) 18 | } 19 | } 20 | 21 | impl<'a> MovableListItem<'a> for Cow<'a, str> { 22 | fn to_spans(&self) -> Spans<'a> { 23 | Spans(vec![Span::raw(self.clone())]) 24 | } 25 | } 26 | 27 | pub trait MovableListItemExt<'a>: MovableListItem<'a> { 28 | fn width(&self) -> usize { 29 | self.to_spans().width() 30 | } 31 | } 32 | 33 | impl<'a, T> MovableListItemExt<'a> for T where T: MovableListItem<'a> {} 34 | -------------------------------------------------------------------------------- /clashctl/src/ui/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, thiserror::Error)] 2 | pub enum TuiError { 3 | #[error("{0}")] 4 | InteractiveError(#[from] crate::interactive::InteractiveError), 5 | 6 | #[error("Clashctl error: {0}")] 7 | ClashCtl(#[from] crate::clashctl::Error), 8 | 9 | #[error("TUI error")] 10 | TuiError(#[from] std::io::Error), 11 | 12 | #[error("TUI backend error")] 13 | TuiBackendErr, 14 | 15 | #[error("TUI interuptted error")] 16 | TuiInterupttedErr, 17 | 18 | #[error("TUI internal error")] 19 | TuiInternalErr, 20 | 21 | #[error("Set logger error ({0})")] 22 | SetLoggerError(#[from] log::SetLoggerError), 23 | } 24 | 25 | impl From> for TuiError { 26 | fn from(_: std::sync::mpsc::SendError) -> Self { 27 | Self::TuiBackendErr 28 | } 29 | } 30 | 31 | pub type TuiResult = std::result::Result; 32 | -------------------------------------------------------------------------------- /.config/hakari.toml: -------------------------------------------------------------------------------- 1 | # This file contains settings for `cargo hakari`. 2 | # See https://docs.rs/cargo-hakari/*/cargo_hakari/config for a full list of options. 3 | 4 | hakari-package = "workspace-hack" 5 | 6 | # Format for `workspace-hack = ...` lines in other Cargo.tomls. Requires cargo-hakari 0.9.8 or above. 7 | dep-format-version = "2" 8 | 9 | # Setting workspace.resolver = "2" in the root Cargo.toml is HIGHLY recommended. 10 | # Hakari works much better with the new feature resolver. 11 | # For more about the new feature resolver, see: 12 | # https://blog.rust-lang.org/2021/03/25/Rust-1.51.0.html#cargos-new-feature-resolver 13 | resolver = "2" 14 | 15 | # Add triples corresponding to platforms commonly used by developers here. 16 | # https://doc.rust-lang.org/rustc/platform-support.html 17 | platforms = [ 18 | "x86_64-unknown-linux-gnu", 19 | "x86_64-apple-darwin", 20 | "x86_64-pc-windows-msvc", 21 | ] 22 | 23 | # Write out exact versions rather than a semver range. (Defaults to false.) 24 | # exact-versions = true 25 | -------------------------------------------------------------------------------- /clashctl/src/ui/pages/log.rs: -------------------------------------------------------------------------------- 1 | use tui::{ 2 | style::Style, 3 | text::{Span, Spans}, 4 | widgets::Widget, 5 | }; 6 | 7 | use crate::{ 8 | clashctl::model::Log, 9 | components::{MovableList, MovableListItem}, 10 | define_widget, AsColor, 11 | }; 12 | 13 | impl<'a> MovableListItem<'a> for Log { 14 | fn to_spans(&self) -> Spans<'a> { 15 | let color = self.log_type.clone().as_color(); 16 | Spans::from(vec![ 17 | Span::styled( 18 | format!("{:<5}", self.log_type.to_string().to_uppercase()), 19 | Style::default().fg(color), 20 | ), 21 | Span::raw(" "), 22 | Span::raw(self.payload.to_owned()), 23 | ]) 24 | } 25 | } 26 | 27 | define_widget!(LogPage); 28 | 29 | // TODO Pretty print parsed Log 30 | impl<'a> Widget for LogPage<'a> { 31 | fn render(self, area: tui::layout::Rect, buf: &mut tui::buffer::Buffer) { 32 | let list = MovableList::new("Logs", &self.state.log_state); 33 | list.render(area, buf); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest, macos-lates, windows-latest] 14 | runs-on: ${{ matrix.os }} 15 | steps: 16 | - uses: actions/checkout@v1 17 | 18 | - name: Setup toolchain 19 | uses: actions-rs/toolchain@v1 20 | with: 21 | toolchain: nightly 22 | override: true 23 | default: true 24 | 25 | - name: Run cargo build 26 | uses: actions-rs/cargo@v1 27 | with: 28 | command: build 29 | args: --all-features --release 30 | 31 | - name: Rename build artifacts 32 | run: | 33 | pushd target/release 34 | rm clashctl*.d 35 | mv clashctl* clashctl-${{ runner.os }} 36 | popd 37 | - name: Release 38 | uses: softprops/action-gh-release@v1 39 | with: 40 | files: target/release/clashctl* 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 George Miao 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /clashctl/src/ui/components/proxy/item.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | 3 | use crate::clashctl::model::{History, Proxy, ProxyType}; 4 | 5 | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] 6 | pub struct ProxyItem { 7 | pub(super) name: String, 8 | pub(super) proxy_type: ProxyType, 9 | pub(super) history: Option, 10 | pub(super) udp: Option, 11 | pub(super) now: Option, 12 | } 13 | 14 | impl<'a> From<(&'a str, &'a Proxy)> for ProxyItem { 15 | fn from(val: (&'a str, &'a Proxy)) -> Self { 16 | let (name, proxy) = val; 17 | Self { 18 | name: name.to_owned(), 19 | proxy_type: proxy.proxy_type, 20 | history: proxy.history.get(0).cloned(), 21 | udp: proxy.udp, 22 | now: proxy.now.as_ref().map(Into::into), 23 | } 24 | } 25 | } 26 | 27 | impl ProxyItem { 28 | pub fn proxy_type(&self) -> ProxyType { 29 | self.proxy_type 30 | } 31 | 32 | pub fn name(&self) -> &str { 33 | &self.name 34 | } 35 | 36 | pub fn delay(&self) -> Option { 37 | self.history.as_ref().map(|x| x.delay) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /clashctl/src/ui/utils/hms.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write; 2 | 3 | pub trait HMS { 4 | fn as_second(&self) -> i64; 5 | fn hms(&self) -> String { 6 | let mut s = self.as_second(); 7 | let mut neg = false; 8 | let mut written = false; 9 | if s < 0 { 10 | neg = true; 11 | s = -s; 12 | } 13 | let (h, s) = (s / 3600, s % 3600); 14 | let (m, s) = (s / 60, s % 60); 15 | let mut ret = String::with_capacity(10); 16 | if neg { 17 | written = true; 18 | ret.push('-') 19 | }; 20 | if written || h > 0 { 21 | written = true; 22 | write!(ret, "{}h ", h).expect("Cannot write to buf") 23 | } 24 | if written || m > 0 { 25 | write!(ret, "{}m ", m).expect("Cannot write to buf") 26 | } 27 | write!(ret, "{}s", s).expect("Cannot write to buf"); 28 | ret 29 | } 30 | } 31 | 32 | impl HMS for chrono::Duration { 33 | fn as_second(&self) -> i64 { 34 | self.num_seconds() 35 | } 36 | } 37 | 38 | impl HMS for std::time::Duration { 39 | fn as_second(&self) -> i64 { 40 | self.as_secs().try_into().expect("Seconds to big") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /clashctl/src/command/mod.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser, Subcommand}; 2 | use log::LevelFilter; 3 | 4 | use crate::{interactive::Flags, ui::TuiOpt, utils::init_logger}; 5 | 6 | mod_use::mod_use!(completion, proxy, server); 7 | 8 | #[derive(Parser, Debug)] 9 | #[clap( 10 | name = clap::crate_name!(), 11 | author = clap::crate_authors!(), 12 | about = clap::crate_description!(), 13 | version = clap::crate_version!(), 14 | 15 | )] 16 | pub struct Opts { 17 | #[clap(subcommand)] 18 | pub cmd: Option, 19 | #[clap(flatten)] 20 | pub flag: Flags, 21 | } 22 | 23 | #[derive(Subcommand, Debug)] 24 | pub enum Cmd { 25 | #[clap(about = "Open TUI")] 26 | Tui(TuiOpt), 27 | #[clap(subcommand)] 28 | Proxy(ProxySubcommand), 29 | #[clap(subcommand)] 30 | Server(ServerSubcommand), 31 | #[clap(alias = "comp")] 32 | Completion(CompletionArg), 33 | } 34 | 35 | impl Opts { 36 | pub fn init_logger(&self) { 37 | if matches!(self.cmd, Some(Cmd::Tui(_)) | None) { 38 | return; 39 | } 40 | init_logger(match self.flag.verbose { 41 | 0 => Some(LevelFilter::Info), 42 | 1 => Some(LevelFilter::Debug), 43 | 2 => Some(LevelFilter::Trace), 44 | _ => None, 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /clashctl-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "clashctl-core" 3 | description = "Clash RESTful API" 4 | version = "0.4.2" 5 | authors = ["George Miao "] 6 | repository = "https://github.com/George-Miao/clashctl" 7 | license = "MIT" 8 | edition = "2021" 9 | keywords = ["clash", "api"] 10 | categories = ["command-line-utilities"] 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [features] 15 | default = [] 16 | deserialize = ["chrono", "semver"] 17 | enum_ext = ["strum"] 18 | full = ["deserialize", "enum_ext"] 19 | 20 | [dependencies] 21 | cfg-if = "1.0" 22 | mod_use = "0.2.1" 23 | serde_json = "1.0" 24 | thiserror = "1.0" 25 | urlencoding = "2.1" 26 | 27 | log = { version = "0.4", features = ["std"] } 28 | url = { version = "2.2", features = ["serde"] } 29 | serde = { version = "1.0", features = ["derive"] } 30 | ureq = { version = "2.3", default-features = false } 31 | 32 | strum = { version = "~0.24.1", features = ["derive"], optional = true } 33 | chrono = { version = "0.4", features = ["serde"], optional = true } 34 | semver = { version = "1.0", features = ["serde"], optional = true } 35 | 36 | [dev-dependencies] 37 | home = "~0.5.3" 38 | pretty_env_logger = "0.4.0" 39 | -------------------------------------------------------------------------------- /clashctl/src/ui/components/tabs.rs: -------------------------------------------------------------------------------- 1 | use tui::{ 2 | style::{Color, Modifier, Style}, 3 | text::{Span, Spans}, 4 | widgets::{Tabs as TuiTabs, Widget}, 5 | }; 6 | 7 | use crate::ui::{define_widget, utils::get_block, TuiStates}; 8 | 9 | define_widget!(Tabs); 10 | 11 | impl<'a> Widget for Tabs<'a> { 12 | fn render(self, area: tui::layout::Rect, buf: &mut tui::buffer::Buffer) { 13 | let len = TuiStates::TITLES.len(); 14 | let range = if self.state.show_debug { 15 | 0..len 16 | } else { 17 | 0..len - 1 18 | }; 19 | let titles = TuiStates::TITLES[range] 20 | .iter() 21 | .enumerate() 22 | .map(|(i, t)| { 23 | Spans::from(Span::styled( 24 | format!("{} {}", i + 1, t), 25 | Style::default().fg(Color::DarkGray), 26 | )) 27 | }) 28 | .collect(); 29 | let tabs = TuiTabs::new(titles) 30 | .block(get_block("Clashctl")) 31 | .highlight_style( 32 | Style::default() 33 | .fg(Color::White) 34 | .add_modifier(Modifier::BOLD), 35 | ) 36 | .select(self.state.page_index.into()); 37 | tabs.render(area, buf) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /clashctl/src/ui/utils/interval.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | thread::sleep, 3 | time::{Duration, Instant}, 4 | }; 5 | 6 | pub struct Interval { 7 | interval: Duration, 8 | deadline: Option, 9 | } 10 | 11 | impl Interval { 12 | pub fn every(interval: Duration) -> Self { 13 | Self { 14 | interval, 15 | deadline: None, 16 | } 17 | } 18 | 19 | pub fn next_tick(&mut self) -> Duration { 20 | let now = Instant::now(); 21 | if self.deadline.is_none() { 22 | self.deadline = Some(now + self.interval) 23 | } 24 | let deadline = self.deadline.unwrap(); 25 | if now > deadline { 26 | let mut point = deadline; 27 | loop { 28 | point += self.interval; 29 | if point > now { 30 | break point - now; 31 | } 32 | } 33 | } else { 34 | deadline - now 35 | } 36 | } 37 | 38 | pub fn tick(&mut self) { 39 | sleep(self.next_tick()) 40 | } 41 | } 42 | 43 | #[test] 44 | fn test_interval() { 45 | let mut interval = Interval::every(Duration::from_millis(100)); 46 | assert!(interval.next_tick().as_millis().abs_diff(100) < 2); 47 | sleep(Duration::from_millis(50)); 48 | assert!(interval.next_tick().as_millis().abs_diff(50) < 2); 49 | } 50 | -------------------------------------------------------------------------------- /clashctl-core/src/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(thiserror::Error, Debug)] 2 | pub enum ErrorKind { 3 | #[error("Invalid URL format")] 4 | UrlParseError, 5 | 6 | #[error("Error while requesting API ({0})")] 7 | RequestError(#[from] ureq::Error), 8 | 9 | #[error("Broken response from server")] 10 | BadResponseEncoding, 11 | 12 | #[error("Broken response from server ({0})")] 13 | BadResponseFormat(#[from] serde_json::Error), 14 | 15 | #[error("Failed response from server (Code {0})")] 16 | FailedResponse(u16), 17 | 18 | #[error("Other errors ({0})")] 19 | Other(String), 20 | } 21 | 22 | #[derive(Debug, thiserror::Error)] 23 | #[error(transparent)] 24 | pub struct Error(Box); 25 | 26 | impl Error { 27 | pub fn url_parse() -> Self { 28 | Error(Box::new(ErrorKind::UrlParseError)) 29 | } 30 | 31 | pub fn failed_response(status: u16) -> Self { 32 | Error(Box::new(ErrorKind::FailedResponse(status))) 33 | } 34 | 35 | pub fn bad_response_encoding() -> Self { 36 | Error(Box::new(ErrorKind::BadResponseEncoding)) 37 | } 38 | 39 | pub fn other(msg: String) -> Self { 40 | Error(Box::new(ErrorKind::Other(msg))) 41 | } 42 | } 43 | 44 | impl From for Error 45 | where 46 | ErrorKind: From, 47 | { 48 | fn from(err: E) -> Self { 49 | Error(Box::new(ErrorKind::from(err))) 50 | } 51 | } 52 | 53 | pub type Result = std::result::Result; 54 | -------------------------------------------------------------------------------- /clashctl/src/utils.rs: -------------------------------------------------------------------------------- 1 | use clap_complete::Shell; 2 | use env_logger::fmt::Color; 3 | use env_logger::Builder; 4 | use log::{Level, LevelFilter}; 5 | use std::{env, path::PathBuf}; 6 | 7 | pub fn detect_shell() -> Option { 8 | match env::var("SHELL") { 9 | Ok(shell) => PathBuf::from(shell) 10 | .file_name() 11 | .and_then(|name| name.to_str()) 12 | .and_then(|name| name.parse().ok()), 13 | Err(_) => None, 14 | } 15 | } 16 | 17 | pub fn init_logger(level: Option) { 18 | let mut builder = Builder::new(); 19 | 20 | if let Some(lf) = level { 21 | builder.filter_level(lf); 22 | } else if let Ok(s) = ::std::env::var("CLASHCTL_LOG") { 23 | builder.parse_filters(&s); 24 | } else { 25 | builder.filter_level(LevelFilter::Info); 26 | } 27 | 28 | builder.format(|f, record| { 29 | use std::io::Write; 30 | let mut style = f.style(); 31 | 32 | let level = match record.level() { 33 | Level::Trace => style.set_color(Color::Magenta).value("Trace"), 34 | Level::Debug => style.set_color(Color::Blue).value("Debug"), 35 | Level::Info => style.set_color(Color::Green).value(" Info"), 36 | Level::Warn => style.set_color(Color::Yellow).value(" Warn"), 37 | Level::Error => style.set_color(Color::Red).value("Error"), 38 | }; 39 | 40 | writeln!(f, " {} > {}", level, record.args(),) 41 | }); 42 | 43 | builder.init() 44 | } 45 | -------------------------------------------------------------------------------- /clashctl/src/ui/utils/ticks_counter.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::VecDeque, time::Instant}; 2 | 3 | pub struct TicksCounter { 4 | ticks: u64, 5 | time: Instant, 6 | inner: VecDeque, 7 | } 8 | 9 | impl Default for TicksCounter { 10 | fn default() -> Self { 11 | Self { 12 | time: Instant::now(), 13 | ticks: Default::default(), 14 | inner: Default::default(), 15 | } 16 | } 17 | } 18 | 19 | impl TicksCounter { 20 | pub fn new_with_time(time: Instant) -> Self { 21 | Self { 22 | time, 23 | ..Self::default() 24 | } 25 | } 26 | 27 | pub fn new_tick(&mut self) { 28 | self.ticks += 1; 29 | self.inner.push_front( 30 | Instant::now() 31 | .duration_since(self.time) 32 | .as_millis() 33 | .try_into() 34 | .expect( 35 | "Hey anyone who sees this as a panic message. Is the universe still there?", 36 | ), 37 | ); 38 | if self.inner.len() > 128 { 39 | self.inner.drain(64..); 40 | } 41 | } 42 | 43 | pub fn tick_rate(&self) -> Option { 44 | // Ticks per Second 45 | Some( 46 | 20_000.0 47 | / ((self.inner.get(0)? 48 | - self 49 | .inner 50 | .get(20) 51 | .or_else(|| self.inner.back())?) 52 | as f64), 53 | ) 54 | } 55 | 56 | pub fn tick_num(&self) -> u64 { 57 | self.ticks 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /clashctl/src/interactive/sort/con_sort.rs: -------------------------------------------------------------------------------- 1 | use clashctl_core::model::ConnectionWithSpeed; 2 | use serde::{Deserialize, Serialize}; 3 | use smart_default::SmartDefault; 4 | 5 | use crate::{EndlessSelf, OrderBy, SortMethod, SortOrder}; 6 | 7 | #[derive( 8 | Debug, 9 | Clone, 10 | PartialEq, 11 | Eq, 12 | PartialOrd, 13 | Ord, 14 | Serialize, 15 | Deserialize, 16 | SmartDefault, 17 | strum::EnumIter, 18 | )] 19 | #[serde(rename_all = "lowercase")] 20 | enum ConSortBy { 21 | Host, 22 | Down, 23 | Up, 24 | DownSpeed, 25 | UpSpeed, 26 | Chains, 27 | Rule, 28 | #[default] 29 | Time, 30 | Src, 31 | Dest, 32 | Type, 33 | } 34 | 35 | impl SortMethod for ConSortBy { 36 | fn sort_fn(&self, _a: &ConnectionWithSpeed, _b: &ConnectionWithSpeed) -> std::cmp::Ordering { 37 | todo!() 38 | } 39 | } 40 | 41 | impl EndlessSelf for ConSortBy { 42 | fn next_self(&mut self) { 43 | todo!() 44 | } 45 | 46 | fn prev_self(&mut self) { 47 | todo!() 48 | } 49 | } 50 | 51 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] 52 | #[serde(rename_all = "lowercase")] 53 | pub struct ConSort { 54 | by: ConSortBy, 55 | order: SortOrder, 56 | } 57 | 58 | impl EndlessSelf for ConSort { 59 | fn next_self(&mut self) { 60 | todo!() 61 | } 62 | 63 | fn prev_self(&mut self) { 64 | todo!() 65 | } 66 | } 67 | 68 | impl SortMethod for ConSort { 69 | fn sort_fn(&self, a: &ConnectionWithSpeed, b: &ConnectionWithSpeed) -> std::cmp::Ordering { 70 | self.by.sort_fn(a, b).order_by(self.order) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /clashctl-core/src/model/mod.rs: -------------------------------------------------------------------------------- 1 | mod_use::mod_use![config, connection, proxy, rule, traffic]; 2 | 3 | mod log; 4 | use cfg_if::cfg_if; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | pub use self::log::*; 8 | 9 | #[derive(Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Debug, Clone, Copy)] 10 | #[serde(rename_all = "lowercase")] 11 | #[cfg_attr( 12 | feature = "enum_ext", 13 | derive(strum::EnumString, strum::Display, strum::EnumVariantNames), 14 | strum(ascii_case_insensitive, serialize_all = "lowercase") 15 | )] 16 | pub enum Mode { 17 | Global, 18 | Rule, 19 | Direct, 20 | } 21 | 22 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 23 | pub struct Delay { 24 | pub delay: u64, 25 | } 26 | 27 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 28 | #[serde(rename_all = "lowercase")] 29 | pub struct Version { 30 | // Clash Premium only 31 | pub premium: Option, 32 | pub version: VersionPayload, 33 | } 34 | 35 | cfg_if! { 36 | if #[cfg(feature = "deserialize")] { 37 | use chrono::{Utc, DateTime}; 38 | pub type TimeType = DateTime; 39 | } else { 40 | pub type TimeType = String; 41 | } 42 | } 43 | 44 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 45 | #[serde(untagged, rename_all = "lowercase")] 46 | pub enum VersionPayload { 47 | #[cfg(feature = "deserialize")] 48 | SemVer(semver::Version), 49 | Raw(String), 50 | } 51 | 52 | impl ToString for VersionPayload { 53 | fn to_string(&self) -> String { 54 | match self { 55 | #[cfg(feature = "deserialize")] 56 | VersionPayload::SemVer(ver) => ver.to_string(), 57 | VersionPayload::Raw(content) => content.to_owned(), 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /clashctl-core/README.md: -------------------------------------------------------------------------------- 1 | # Clashctl Core 2 | 3 | Lib for interacting with Clash RESTful API. This crate does not contain binary. For more information, check [clashctl](https://github.com/George-Miao/clashctl), a CLI & TUI tool built with this crate. 4 | 5 | ## RESTful API Methods 6 | 7 | Functions of `Clash` 8 | 9 | | Function Name | Method | Endpoint | 10 | | ------------------------- | ------ | ------------------------------------ | 11 | | `get_version` | GET | /logs | 12 | | `get_traffic` | GET | /traffic | 13 | | `get_version` | GET | /version | 14 | | `get_configs` | GET | /config | 15 | | `reload_configs` | PUT | /config | 16 | | **TODO** | PATCH | /config | 17 | | `get_proxies` | GET | /proxies | 18 | | `get_proxy` | GET | /proxies/:name | 19 | | `set_proxygroup_selected` | PUT | /proxies/:name | 20 | | `get_proxy_delay` | GET | /proxies/:name/delay | 21 | | `get_rules` | GET | /rules | 22 | | `get_connections` | GET | /connections | 23 | | `close_connections` | DELETE | /connections | 24 | | `close_one_connection` | DELETE | /connections/:id | 25 | | **TODO** | GET | /providers/proxies | 26 | | **TODO** | GET | /providers/proxies/:name | 27 | | **TODO** | PUT | /providers/proxies/:name | 28 | | **TODO** | GET | /providers/proxies/:name/healthcheck | -------------------------------------------------------------------------------- /clashctl/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "clashctl" 3 | description = "Cli & Tui used to interact with Clash RESTful API" 4 | version = "0.3.3" 5 | authors = ["George Miao "] 6 | repository = "https://github.com/George-Miao/clashctl" 7 | license = "MIT" 8 | edition = "2021" 9 | keywords = ["clash", "api", "cli", "tui"] 10 | categories = ["command-line-utilities"] 11 | default-run = "clashctl" 12 | readme = "../README.md" 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | [dependencies] 16 | mod_use = "0.2.1" 17 | 18 | log = { version = "~0.4", features = ["std"] } 19 | url = { version = "~2.2", features = ["serde"] } 20 | thiserror = { version = "~1.0" } 21 | terminal_size = { version = "~0.2" } 22 | owo-colors = { version = "~3.4" } 23 | env_logger = { version = "~0.9" } 24 | requestty = { version = "~0.4" } 25 | either = { version = "~1.7.0" } 26 | clap = { version = "~3.2.17", features = ["derive", "cargo"] } 27 | clap_complete = { version = "~3.2.4" } 28 | serde = { version = "1.0.145", features = ["derive"] } 29 | strum = { version = "~0.24.1", features = ["derive"] } 30 | home = { version = "~0.5" } 31 | ron = { version = "~0.8" } 32 | tui = { version = "0.19.0", default-features = false, features = ['crossterm'] } 33 | chrono = { version = "0.4", features = ["serde"] } 34 | bytesize = { version = "1.1.0" } 35 | match_any = { version = "1.0.1" } 36 | paste = { version = "1.0.6" } 37 | simple-mutex = { version = "1.1.5" } 38 | unicode-width = { version = "0.1.9" } 39 | once_cell = { version = "1.15.0" } 40 | smart-default = { version = "0.6.0" } 41 | crossterm = { version = "0.25.0" } 42 | rayon = { version = "1.5.3" } 43 | 44 | clashctl-core = { path = "../clashctl-core", features = ["full"] } 45 | tap = "1.0.1" 46 | 47 | [dev-dependencies] 48 | rand = { version = "0.8.5", features = ["small_rng"] } 49 | pretty_env_logger = "0.4.0" 50 | -------------------------------------------------------------------------------- /clashctl/src/ui/components/proxy/tree_widget.rs: -------------------------------------------------------------------------------- 1 | use tui::widgets::{Paragraph, Widget}; 2 | 3 | use crate::{ 4 | components::{FooterWidget, ProxyGroupFocusStatus, ProxyTree}, 5 | get_block, get_focused_block, 6 | }; 7 | 8 | #[derive(Clone, Debug)] 9 | pub struct ProxyTreeWidget<'a> { 10 | state: &'a ProxyTree<'a>, 11 | } 12 | 13 | impl<'a> ProxyTreeWidget<'a> { 14 | pub fn new(state: &'a ProxyTree<'a>) -> Self { 15 | Self { state } 16 | } 17 | } 18 | 19 | impl<'a> Widget for ProxyTreeWidget<'a> { 20 | fn render(self, area: tui::layout::Rect, buf: &mut tui::buffer::Buffer) { 21 | let cursor = &self.state.cursor; 22 | let skip = if self.state.expanded { 23 | *cursor 24 | } else { 25 | cursor.saturating_sub(2) 26 | }; 27 | let text = self 28 | .state 29 | .groups 30 | .iter() 31 | .skip(skip) 32 | .enumerate() 33 | .map(|(i, x)| { 34 | x.get_widget( 35 | area.width as usize, 36 | match (self.state.expanded, *cursor == i + skip) { 37 | (true, true) => ProxyGroupFocusStatus::Expanded, 38 | (false, true) => ProxyGroupFocusStatus::Focused, 39 | _ => ProxyGroupFocusStatus::None, 40 | }, 41 | ) 42 | }) 43 | .reduce(|mut a, b| { 44 | a.extend(b); 45 | a 46 | }) 47 | .unwrap_or_default() 48 | .into_iter() 49 | .take(area.height as usize) 50 | .collect::>(); 51 | 52 | let block = if self.state.expanded { 53 | get_focused_block("Proxies") 54 | } else { 55 | get_block("Proxies") 56 | }; 57 | 58 | let inner = block.inner(area); 59 | 60 | block.render(area, buf); 61 | 62 | Paragraph::new(text).render(inner, buf); 63 | FooterWidget::new(&self.state.footer).render(area, buf); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /clashctl/src/command/completion.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::File, io::Write, path::PathBuf}; 2 | 3 | use clap::{ArgEnum, IntoApp, Parser}; 4 | use clap_complete::{generate, Shell}; 5 | use log::warn; 6 | 7 | // use crate::Result; 8 | use crate::{detect_shell, Opts, Result}; 9 | 10 | #[derive(Parser, Debug)] 11 | #[clap(about = "Generate auto-completion scripts")] 12 | pub struct CompletionArg { 13 | #[clap(possible_values=&[ 14 | "bash", 15 | "elvish", 16 | "fish", 17 | "powershell", 18 | "zsh", 19 | ])] 20 | pub shell: Option, 21 | #[clap( 22 | short, 23 | long, 24 | help = "Output completion script to file, default to STDOUT" 25 | )] 26 | pub output: Option, 27 | } 28 | 29 | impl CompletionArg { 30 | pub fn handle(&self) -> Result<()> { 31 | match self.shell.or_else(detect_shell) { 32 | None => { 33 | warn!("Shell not detected or it's not supported"); 34 | warn!( 35 | "Supported shells: {}", 36 | Shell::value_variants() 37 | .iter() 38 | .map(|x| x.to_string()) 39 | .collect::>() 40 | .join(", ") 41 | ) 42 | } 43 | Some(shell) => { 44 | // let mut out: Box = self 45 | // .output 46 | // .and_then(|x| File::open(x).ok()) 47 | // .or_else(|| std::io::stdout()) 48 | // .unwrap(); 49 | let mut out: Box = match self.output { 50 | Some(ref dir) => Box::new( 51 | File::create(dir) 52 | .unwrap_or_else(|_| panic!("Unable to open {}", dir.display())), 53 | ), 54 | None => Box::new(std::io::stdout()), 55 | }; 56 | generate(shell, &mut Opts::into_app(), "clashctl", &mut out); 57 | } 58 | } 59 | Ok(()) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /clashctl/src/ui/pages/debug.rs: -------------------------------------------------------------------------------- 1 | use tui::{ 2 | layout::{Constraint, Layout}, 3 | widgets::{Paragraph, Widget}, 4 | }; 5 | 6 | use crate::ui::{ 7 | components::{MovableList, MovableListManage}, 8 | define_widget, get_block, get_text_style, HMS, TICK_COUNTER, 9 | }; 10 | 11 | define_widget!(DebugPage); 12 | 13 | impl<'a> Widget for DebugPage<'a> { 14 | fn render(self, area: tui::layout::Rect, buf: &mut tui::buffer::Buffer) { 15 | let layout = Layout::default() 16 | .constraints([Constraint::Length(30), Constraint::Min(0)]) 17 | .direction(tui::layout::Direction::Horizontal) 18 | .split(area); 19 | 20 | let event_num = self.state.debug_state.len(); 21 | 22 | let offset = self.state.debug_state.offset(); 23 | 24 | let mut tick = 0; 25 | let mut tick_rate = None; 26 | 27 | TICK_COUNTER.with(|t| { 28 | let counter = t.borrow(); 29 | tick = counter.tick_num(); 30 | tick_rate = counter.tick_rate(); 31 | }); 32 | 33 | let debug_info = [ 34 | ("Event In Mem:", event_num.to_string()), 35 | ("Event All #:", self.state.all_events_recv.to_string()), 36 | ("Tick #:", tick.to_string()), 37 | ( 38 | "Tick Rate:", 39 | tick_rate.map_or_else(|| "?".to_owned(), |rate| format!("{:.0}", rate)), 40 | ), 41 | ("List offset: ", format!("({}, {})", offset.x, offset.y)), 42 | ("Run time:", self.state.start_time.elapsed().hms()), 43 | ] 44 | .into_iter() 45 | .map(|(title, content)| format!(" {:<15}{:>11} ", title, content)) 46 | .fold(String::with_capacity(255), |mut a, b| { 47 | a.push_str(&b); 48 | a.push('\n'); 49 | a 50 | }); 51 | 52 | let info = Paragraph::new(debug_info) 53 | .block(get_block("Debug Info")) 54 | .style(get_text_style()); 55 | 56 | info.render(layout[0], buf); 57 | MovableList::new("Events", &self.state.debug_state).render(layout[1], buf); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /clashctl/src/ui/utils/wrap.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use tui::text::{Span, Spans}; 4 | 5 | pub trait Wrap: Sized { 6 | fn wrap_by(self, char: char) -> Self; 7 | fn wrapped(self) -> Self { 8 | self.wrap_by(' ') 9 | } 10 | } 11 | 12 | macro_rules! impl_wrap { 13 | ($t:ty) => { 14 | impl Wrap for $t { 15 | fn wrap_by(mut self, char: char) -> Self { 16 | (&mut self).wrap_by(char); 17 | self 18 | } 19 | } 20 | }; 21 | ($t:ty, $life:lifetime) => { 22 | impl<$life> Wrap for $t { 23 | fn wrap_by(mut self, char: char) -> Self { 24 | (&mut self).wrap_by(char); 25 | self 26 | } 27 | } 28 | }; 29 | } 30 | 31 | impl<'a> Wrap for &mut Span<'a> { 32 | fn wrap_by(self, char: char) -> Self { 33 | let content = &mut self.content; 34 | content.wrap_by(char); 35 | self 36 | } 37 | } 38 | 39 | impl_wrap!(Span<'a>, 'a); 40 | 41 | impl<'a> Wrap for &mut Spans<'a> { 42 | fn wrap_by(self, char: char) -> Self { 43 | let inner = &mut self.0; 44 | match inner.len() { 45 | 0 => { 46 | inner.push(Span::raw(String::with_capacity(2).wrap_by(char))); 47 | } 48 | 1 => self.0 = vec![inner[0].to_owned().wrap_by(char)], 49 | _ => { 50 | let first = inner.get_mut(0).unwrap(); 51 | first.content = format!("{}{}", char, first.content).into(); 52 | let last = inner.last_mut().unwrap(); 53 | last.content = format!("{}{}", last.content, char).into(); 54 | } 55 | }; 56 | self 57 | } 58 | } 59 | 60 | impl_wrap!(Spans<'a>, 'a); 61 | 62 | impl Wrap for &mut String { 63 | fn wrap_by(self, char: char) -> Self { 64 | *self = format!("{}{}{}", char, self, char); 65 | self 66 | } 67 | } 68 | impl_wrap!(String); 69 | 70 | impl<'a> Wrap for &mut Cow<'a, str> { 71 | fn wrap_by(self, char: char) -> Self { 72 | *self = format!("{}{}{}", char, self, char).into(); 73 | self 74 | } 75 | } 76 | 77 | impl_wrap!(Cow<'a, str>, 'a); 78 | -------------------------------------------------------------------------------- /clashctl-core/src/model/rule.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)] 6 | // #[serde(rename_all = "UPPERCASE")] 7 | #[cfg_attr( 8 | feature = "enum_ext", 9 | derive( 10 | strum::EnumString, 11 | strum::Display, 12 | strum::AsRefStr, 13 | strum::IntoStaticStr, 14 | strum::EnumVariantNames 15 | ), 16 | strum(ascii_case_insensitive) 17 | )] 18 | pub enum RuleType { 19 | Domain, 20 | DomainSuffix, 21 | DomainKeyword, 22 | GeoIP, 23 | IPCIDR, 24 | SrcIPCIDR, 25 | SrcPort, 26 | DstPort, 27 | Process, 28 | Match, 29 | Direct, 30 | Reject, 31 | #[serde(other)] 32 | Unknown, 33 | } 34 | 35 | #[derive(Serialize, Deserialize, Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] 36 | pub struct Rule { 37 | #[serde(rename = "type")] 38 | pub rule_type: RuleType, 39 | pub payload: String, 40 | pub proxy: String, 41 | } 42 | 43 | #[derive(Serialize, Deserialize, Debug, Clone, Default, Hash, PartialEq, Eq, PartialOrd, Ord)] 44 | pub struct Rules { 45 | pub rules: Vec, 46 | } 47 | 48 | impl Rules { 49 | pub fn most_frequent_proxy(&self) -> Option<&str> { 50 | self.frequency() 51 | .into_iter() 52 | .max_by_key(|(_, v)| *v) 53 | .map(|(k, _)| k) 54 | } 55 | 56 | pub fn frequency(&self) -> HashMap<&str, usize> { 57 | let mut counts = HashMap::new(); 58 | self.rules 59 | .iter() 60 | .filter(|x| x.proxy != "DIRECT" && x.proxy != "REJECT") 61 | .map(|x| x.proxy.as_str()) 62 | .for_each(|item| *counts.entry(item).or_default() += 1); 63 | counts 64 | } 65 | 66 | pub fn owned_frequency(&self) -> HashMap { 67 | let mut counts = HashMap::new(); 68 | self.rules 69 | .iter() 70 | .filter(|x| x.proxy != "DIRECT" && x.proxy != "REJECT") 71 | .map(|x| x.proxy.to_owned()) 72 | .for_each(|item| *counts.entry(item).or_default() += 1); 73 | counts 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /clashctl/src/ui/utils/tui_logger.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::File, io::Write, sync::mpsc::Sender}; 2 | 3 | use log::{LevelFilter, Record}; 4 | use simple_mutex::Mutex; 5 | 6 | use crate::{ui::TuiResult, DiagnosticEvent, Event}; 7 | 8 | pub struct LoggerBuilder { 9 | sender: Sender, 10 | file: Option, 11 | level: LevelFilter, 12 | } 13 | 14 | impl LoggerBuilder { 15 | pub fn new(tx: Sender) -> Self { 16 | Self { 17 | sender: tx, 18 | file: None, 19 | level: LevelFilter::Info, 20 | } 21 | } 22 | 23 | pub fn file(mut self, file: Option) -> Self { 24 | self.file = file; 25 | self 26 | } 27 | 28 | pub fn level(mut self, level: LevelFilter) -> Self { 29 | self.level = level; 30 | self 31 | } 32 | 33 | pub fn build(self) -> Logger { 34 | let inner = LoggerInner { 35 | file: self.file, 36 | sender: self.sender, 37 | }; 38 | Logger { 39 | inner: Mutex::new(inner), 40 | level: self.level, 41 | } 42 | } 43 | 44 | pub fn apply(self) -> TuiResult<()> { 45 | self.build().apply() 46 | } 47 | } 48 | 49 | struct LoggerInner { 50 | sender: Sender, 51 | file: Option, 52 | } 53 | 54 | pub struct Logger { 55 | inner: Mutex, 56 | level: LevelFilter, 57 | } 58 | 59 | impl Logger { 60 | pub fn apply(self) -> TuiResult<()> { 61 | let level = self.level; 62 | Ok(log::set_boxed_logger(Box::new(self)).map(|_| log::set_max_level(level))?) 63 | } 64 | } 65 | 66 | impl log::Log for Logger { 67 | fn enabled(&self, meta: &log::Metadata) -> bool { 68 | meta.level() <= self.level 69 | } 70 | 71 | fn log(&self, record: &Record) { 72 | let level = record.level(); 73 | let content = record.args().to_string(); 74 | let mut inner = self.inner.lock(); 75 | if let Some(ref mut file) = inner.file { 76 | writeln!(file, "{:<5} > {}", level, content).unwrap() 77 | } 78 | inner 79 | .sender 80 | .send(Event::Diagnostic(DiagnosticEvent::Log(level, content))).ok(); 81 | 82 | } 83 | 84 | fn flush(&self) {} 85 | } 86 | -------------------------------------------------------------------------------- /clashctl/src/ui/components/proxy/sort.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | use crate::{ 4 | components::{ProxyGroup, ProxyItem, ProxyTree}, 5 | interactive::{ProxySort, ProxySortBy, SortMethod, SortOrder, Sortable}, 6 | }; 7 | 8 | impl SortMethod for ProxySort { 9 | fn sort_fn(&self, a: &ProxyItem, b: &ProxyItem) -> std::cmp::Ordering { 10 | let cmp = match self.by() { 11 | ProxySortBy::Name => a.name.cmp(&b.name), 12 | ProxySortBy::Type => a.proxy_type.cmp(&b.proxy_type), 13 | ProxySortBy::Delay => { 14 | use Ordering::{Equal as Eq, Greater as Gt, Less as Lt}; 15 | match (a.delay(), b.delay()) { 16 | (None, Some(_)) => Gt, 17 | (Some(_), None) => Lt, 18 | (Some(aa), Some(bb)) => { 19 | if aa == 0 { 20 | Gt 21 | } else if bb == 0 { 22 | Lt 23 | } else { 24 | aa.cmp(&bb) 25 | } 26 | } 27 | (None, None) => Eq, 28 | } 29 | } 30 | }; 31 | if matches!(self.order(), SortOrder::Descendant) { 32 | cmp.reverse() 33 | } else { 34 | cmp 35 | } 36 | } 37 | } 38 | 39 | impl<'a> Sortable<'a, ProxySort> for ProxyGroup<'a> { 40 | type Item<'b> = ProxyItem; 41 | 42 | fn sort_with(&mut self, method: &ProxySort) { 43 | let pointed = &self.members[self.cursor].name.clone(); 44 | let current = self.current.map(|x| self.members[x].name.clone()); 45 | self.members.sort_by(|a, b| method.sort_fn(a, b)); 46 | for (i, ProxyItem { name, .. }) in self.members.iter().enumerate() { 47 | if name == pointed { 48 | self.cursor = i; 49 | } 50 | if let Some(ref x) = current { 51 | if name == x { 52 | self.current = Some(i) 53 | } 54 | } 55 | } 56 | } 57 | } 58 | 59 | impl<'a> Sortable<'a, ProxySort> for ProxyTree<'a> { 60 | type Item<'b> = ProxyItem; 61 | 62 | fn sort_with(&mut self, method: &ProxySort) { 63 | self.groups.iter_mut().for_each(|x| x.sort_with(method)) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /clashctl/src/ui/pages/rule.rs: -------------------------------------------------------------------------------- 1 | use clashctl_core::model::{Rule, RuleType, Rules}; 2 | use tui::{ 3 | style::{Color, Modifier, Style}, 4 | text::{Span, Spans}, 5 | widgets::Widget, 6 | }; 7 | 8 | use crate::{ 9 | components::{MovableList, MovableListItem, MovableListState}, 10 | define_widget, 11 | interactive::RuleSort, 12 | AsColor, 13 | }; 14 | 15 | define_widget!(RulePage); 16 | 17 | impl<'a> Widget for RulePage<'a> { 18 | fn render(self, area: tui::layout::Rect, buf: &mut tui::buffer::Buffer) { 19 | MovableList::new("Rules", &self.state.rule_state).render(area, buf); 20 | } 21 | } 22 | 23 | impl AsColor for RuleType { 24 | fn as_color(&self) -> tui::style::Color { 25 | match self { 26 | RuleType::Domain => Color::Green, 27 | RuleType::DomainSuffix => Color::Green, 28 | RuleType::DomainKeyword => Color::Green, 29 | RuleType::GeoIP => Color::Yellow, 30 | RuleType::IPCIDR => Color::Yellow, 31 | RuleType::SrcIPCIDR => Color::Yellow, 32 | RuleType::SrcPort => Color::Yellow, 33 | RuleType::DstPort => Color::Yellow, 34 | RuleType::Process => Color::Yellow, 35 | RuleType::Match => Color::Blue, 36 | RuleType::Direct => Color::Blue, 37 | RuleType::Reject => Color::Red, 38 | RuleType::Unknown => Color::DarkGray, 39 | } 40 | } 41 | } 42 | 43 | impl<'a> From for MovableListState<'a, Rule, RuleSort> { 44 | fn from(val: Rules) -> Self { 45 | Self::new_with_sort(val.rules, RuleSort::default()) 46 | } 47 | } 48 | 49 | impl<'a> MovableListItem<'a> for Rule { 50 | fn to_spans(&self) -> Spans<'a> { 51 | let type_color = self.rule_type.as_color(); 52 | let name_color = if self.proxy == "DIRECT" || self.proxy == "REJECT" { 53 | Color::DarkGray 54 | } else { 55 | Color::Yellow 56 | }; 57 | let gray = Style::default().fg(Color::DarkGray); 58 | let r_type: &'static str = self.rule_type.into(); 59 | let payload = if self.payload.is_empty() { 60 | "*" 61 | } else { 62 | &self.payload 63 | } 64 | .to_owned(); 65 | let dash: String = "─".repeat(35_usize.saturating_sub(payload.len()) + 2) + " "; 66 | vec![ 67 | Span::styled(format!("{:16}", r_type), Style::default().fg(type_color)), 68 | Span::styled(payload + " ", Style::default().add_modifier(Modifier::BOLD)), 69 | Span::styled(dash, gray), 70 | Span::styled(self.proxy.to_owned(), Style::default().fg(name_color)), 71 | ] 72 | .into() 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /clashctl/src/interactive/flags.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, time::Duration}; 2 | 3 | use clap::Parser; 4 | use clashctl_core::Clash; 5 | use home::home_dir; 6 | use log::debug; 7 | use url::Url; 8 | 9 | use super::{Config, InteractiveError, InteractiveResult}; 10 | 11 | const DEFAULT_TEST_URL: &str = "http://www.gstatic.com/generate_204"; 12 | 13 | #[derive(Clone, Debug, Parser)] 14 | pub struct Flags { 15 | #[clap(short, long, parse(from_occurrences))] 16 | /// Verbosity. Default: INFO, -v DEBUG, -vv TRACE 17 | pub verbose: u8, 18 | 19 | #[clap(short, long, default_value = "2000")] 20 | /// Timeout of requests, in ms 21 | pub timeout: u64, 22 | 23 | #[clap(long, conflicts_with = "config-path")] 24 | /// Path of config directory. Default to ~/.config/clashctl 25 | pub config_dir: Option, 26 | 27 | #[clap(short, long, conflicts_with = "config-dir")] 28 | /// Path of config file. Default to ~/.config/clashctl/config.ron 29 | pub config_path: Option, 30 | 31 | #[clap( 32 | long, 33 | default_value = DEFAULT_TEST_URL, 34 | ) 35 | ] 36 | /// Url for testing proxy endpointes 37 | pub test_url: Url, 38 | } 39 | 40 | impl Default for Flags { 41 | fn default() -> Self { 42 | Self { 43 | verbose: 0, 44 | timeout: 2000, 45 | config_dir: None, 46 | config_path: None, 47 | test_url: Url::parse(DEFAULT_TEST_URL).unwrap(), 48 | } 49 | } 50 | } 51 | 52 | impl Flags { 53 | pub fn get_config(&self) -> InteractiveResult { 54 | if let Some(ref dir) = self.config_path { 55 | return Config::from_dir(dir); 56 | } 57 | let conf_dir = self 58 | .config_dir 59 | .to_owned() 60 | .or_else(|| home_dir().map(|dir| dir.join(".config/clashctl/"))) 61 | .ok_or(InteractiveError::ConfigFileOpenError)?; 62 | 63 | if !conf_dir.exists() { 64 | debug!("Config directory does not exist, creating."); 65 | std::fs::create_dir_all(&conf_dir).map_err(InteractiveError::ConfigFileIoError)?; 66 | } 67 | 68 | if !conf_dir.is_dir() { 69 | Err(InteractiveError::ConfigFileTypeError(conf_dir)) 70 | } else { 71 | debug!("Path to config: {}", conf_dir.display()); 72 | Config::from_dir(conf_dir.join("config.ron")) 73 | } 74 | } 75 | 76 | pub fn connect_server_from_config(&self) -> InteractiveResult { 77 | let config = self.get_config()?; 78 | let server = config 79 | .using_server() 80 | .ok_or(InteractiveError::ServerNotFound)? 81 | .to_owned(); 82 | server.into_clash_with_timeout(Some(Duration::from_millis(self.timeout))) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /clashctl/src/ui/components/traffic.rs: -------------------------------------------------------------------------------- 1 | use bytesize::ByteSize; 2 | use tui::{ 3 | layout::{Constraint, Layout}, 4 | style::{Color, Style}, 5 | symbols::bar::Set, 6 | text::Span, 7 | widgets::Widget, 8 | }; 9 | 10 | use crate::{ 11 | components::{Footer, FooterItem, FooterWidget, Sparkline}, 12 | ui::{define_widget, utils::get_block}, 13 | }; 14 | 15 | pub const DOTS: Set = Set { 16 | empty: " ", 17 | one_eighth: "⡀", 18 | one_quarter: "⣀", 19 | three_eighths: "⣄", 20 | half: "⣤", 21 | five_eighths: "⣦", 22 | three_quarters: "⣶", 23 | seven_eighths: "⣷", 24 | full: "⣿", 25 | }; 26 | 27 | pub const REV_DOTS: Set = Set { 28 | empty: " ", 29 | one_eighth: "⠁", 30 | one_quarter: "⠉", 31 | three_eighths: "⠋", 32 | half: "⠛", 33 | five_eighths: "⠟", 34 | three_quarters: "⠿", 35 | seven_eighths: "⡿", 36 | full: "⣿", 37 | }; 38 | 39 | pub const HALF: Constraint = Constraint::Percentage(50); 40 | 41 | define_widget!(Traffics); 42 | 43 | impl<'a> Widget for Traffics<'a> { 44 | fn render(self, area: tui::layout::Rect, buf: &mut tui::buffer::Buffer) { 45 | let traffic_size = area.width - 2; 46 | 47 | let traffics = self.state.traffics.iter().rev().take(traffic_size.into()); 48 | 49 | let (up, down): (Vec<_>, Vec<_>) = traffics.map(|x| (x.up, x.down)).unzip(); 50 | 51 | let (up_max, down_max) = ( 52 | *up.iter().max().unwrap_or(&100), 53 | *down.iter().max().unwrap_or(&100), 54 | ); 55 | 56 | let title = format!("▲ Max = {}/s", ByteSize(up_max).to_string_as(true)); 57 | 58 | let up_line = Sparkline::default() 59 | .data(&up) 60 | .max(up_max) 61 | .bar_set(DOTS) 62 | .style(Style::default().fg(Color::Green)); 63 | 64 | let down_line = Sparkline::default() 65 | .data(&down) 66 | .max(down_max) 67 | .bar_set(REV_DOTS) 68 | .style(Style::default().fg(Color::White)) 69 | .reversed(true); 70 | 71 | let block = get_block(&title); 72 | 73 | let inner = block.inner(area); 74 | 75 | let layout = Layout::default() 76 | .direction(tui::layout::Direction::Vertical) 77 | .constraints([HALF, HALF]) 78 | .split(inner); 79 | 80 | block.render(area, buf); 81 | up_line.render(layout[0], buf); 82 | down_line.render(layout[1], buf); 83 | 84 | let mut footer = Footer::default(); 85 | footer 86 | .push_left(FooterItem::span(Span::raw(format!( 87 | " ▼ Max = {}/s ", 88 | ByteSize(down_max).to_string_as(true) 89 | )))) 90 | .left_offset(1); 91 | let footer_widget = FooterWidget::new(&footer); 92 | footer_widget.render(area, buf); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /clashctl/src/ui/pages/config.rs: -------------------------------------------------------------------------------- 1 | use tui::{ 2 | layout::Rect, 3 | style::{Color, Modifier, Style}, 4 | widgets::{List, ListItem, Widget}, 5 | }; 6 | 7 | use crate::{get_block, ConfigState}; 8 | 9 | #[derive(Clone, Debug)] 10 | pub struct ConfigPage<'a> { 11 | state: &'a ConfigState, 12 | } 13 | 14 | impl<'a> ConfigPage<'a> { 15 | pub fn new(state: &'a ConfigState) -> Self { 16 | Self { state } 17 | } 18 | } 19 | 20 | enum ConfigListItem<'a> { 21 | Title(&'a str), 22 | Item { label: &'a str, content: String }, 23 | Separator, 24 | Empty, 25 | } 26 | 27 | impl<'a> ConfigListItem<'a> { 28 | pub fn title(title: &'a str) -> impl Iterator { 29 | [ 30 | ConfigListItem::Empty, 31 | ConfigListItem::Title(title), 32 | ConfigListItem::Separator, 33 | ] 34 | .into_iter() 35 | } 36 | 37 | pub fn into_list_item(self, width: u16) -> ListItem<'a> { 38 | match self { 39 | ConfigListItem::Title(title) => ListItem::new(title).style( 40 | Style::default() 41 | .fg(Color::Green) 42 | .add_modifier(Modifier::BOLD), 43 | ), 44 | ConfigListItem::Item { label, content } => ListItem::new(format!( 45 | "{:<15}{:>right$}", 46 | label, 47 | content, 48 | right = (width - 15) as usize 49 | )) 50 | .style(Style::default().fg(Color::White)), 51 | ConfigListItem::Separator => { 52 | ListItem::new(format!("{:- { 55 | ListItem::new(format!("{:width$}", "", width = width as usize)) 56 | } 57 | } 58 | } 59 | } 60 | 61 | impl<'a> Widget for ConfigPage<'a> { 62 | fn render(self, area: tui::layout::Rect, buf: &mut tui::buffer::Buffer) { 63 | let width = area.width.saturating_sub(4).max(10); 64 | let block = get_block("Config"); 65 | let list = ConfigListItem::title("Clash") 66 | .chain(self.state.clash_list().map(|x| ConfigListItem::Item { 67 | label: x.0, 68 | content: x.1, 69 | })) 70 | .chain(ConfigListItem::title("Clashctl")) 71 | .chain(self.state.clashctl_list().map(|x| ConfigListItem::Item { 72 | label: x.0, 73 | content: x.1, 74 | })) 75 | .map(|x| x.into_list_item(width)) 76 | .collect::>(); 77 | let inner = block.inner(area); 78 | let inner = Rect { 79 | x: inner.x + 1, 80 | width: inner.width - 1, 81 | ..inner 82 | }; 83 | block.render(area, buf); 84 | List::new(list).render(inner, buf); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /clashctl-core/src/test/api.rs: -------------------------------------------------------------------------------- 1 | use std::{env, sync::Once}; 2 | 3 | use home::home_dir; 4 | use log::info; 5 | 6 | use crate::Clash; 7 | 8 | static INIT: Once = Once::new(); 9 | 10 | fn init() -> Clash { 11 | INIT.call_once(|| { 12 | if env::var("RUST_LOG").is_err() { 13 | env::set_var("RUST_LOG", "DEBUG") 14 | } 15 | pretty_env_logger::init() 16 | }); 17 | Clash::builder(env::var("PROXY_ADDR").unwrap()) 18 | .unwrap() 19 | .secret(env::var("PROXY_SECRET").ok()) 20 | .build() 21 | } 22 | 23 | #[test] 24 | fn test_proxies() { 25 | let clash = init(); 26 | clash.get_proxies().unwrap(); 27 | } 28 | 29 | #[test] 30 | fn test_rules() { 31 | let clash = init(); 32 | clash.get_rules().unwrap(); 33 | } 34 | 35 | #[test] 36 | fn test_proxy() { 37 | let clash = init(); 38 | let proxies = clash.get_proxies().unwrap(); 39 | let (proxy, _) = proxies.iter().next().unwrap(); 40 | clash.get_proxy(proxy).unwrap(); 41 | } 42 | 43 | #[test] 44 | fn test_proxy_delay() { 45 | let clash = init(); 46 | let proxies = clash.get_proxies().unwrap(); 47 | let (proxy, _) = proxies.iter().find(|x| x.1.proxy_type.is_normal()).unwrap(); 48 | clash 49 | .get_proxy_delay(proxy, "https://static.miao.dev/generate_204", 10000) 50 | .unwrap(); 51 | } 52 | 53 | #[test] 54 | fn test_set_proxy() { 55 | let clash = init(); 56 | let proxies = clash.get_proxies().unwrap(); 57 | if let Some((group, proxy)) = proxies 58 | .iter() 59 | .find(|(_, proxy)| proxy.proxy_type.is_selector()) 60 | { 61 | let all = proxy.all.as_ref().unwrap(); 62 | let member = all.iter().next().unwrap(); 63 | clash.set_proxygroup_selected(group, member).unwrap(); 64 | } 65 | } 66 | 67 | #[test] 68 | fn test_configs() { 69 | let clash = init(); 70 | let default_config_dir = home_dir() 71 | .expect("Home dir should exist") 72 | .join(".config/clash/config.yaml"); 73 | let _path = default_config_dir.to_str().unwrap(); 74 | 75 | clash.get_configs().unwrap(); 76 | // clash.reload_configs(false, path).unwrap(); 77 | // clash.reload_configs(true, path).unwrap(); 78 | } 79 | 80 | #[test] 81 | fn test_traffic() { 82 | let clash = init(); 83 | clash.get_traffic().unwrap().next(); 84 | } 85 | 86 | #[test] 87 | fn test_log() { 88 | let clash = init(); 89 | clash.get_log().unwrap().next(); 90 | } 91 | 92 | #[test] 93 | fn test_connections() { 94 | let clash = init(); 95 | let cons = clash.get_connections().unwrap(); 96 | let res = &cons 97 | .connections 98 | .first() 99 | .expect("Should exist at least one connection") 100 | .id; 101 | clash.close_one_connection(res).unwrap(); 102 | clash.close_connections().unwrap(); 103 | } 104 | 105 | #[test] 106 | fn test_version() { 107 | let clash = init(); 108 | info!("{:#?}", clash.get_version().unwrap()) 109 | } 110 | -------------------------------------------------------------------------------- /clashctl/src/ui/pages/connection.rs: -------------------------------------------------------------------------------- 1 | use bytesize::ByteSize; 2 | use chrono::Utc; 3 | use tui::{ 4 | style::{Color, Modifier, Style}, 5 | text::{Span, Spans}, 6 | widgets::Widget, 7 | }; 8 | 9 | use crate::{ 10 | components::{MovableList, MovableListItem}, 11 | define_widget, 12 | interactive::clashctl::model::ConnectionWithSpeed, 13 | HMS, 14 | }; 15 | 16 | define_widget!(ConnectionPage); 17 | 18 | impl<'a> Widget for ConnectionPage<'a> { 19 | fn render(self, area: tui::layout::Rect, buf: &mut tui::buffer::Buffer) { 20 | MovableList::new("Connections", &self.state.con_state).render(area, buf); 21 | } 22 | } 23 | 24 | impl<'a> MovableListItem<'a> for ConnectionWithSpeed { 25 | fn to_spans(&self) -> Spans<'a> { 26 | let dimmed = Style::default().fg(Color::DarkGray); 27 | let bolded = Style::default().add_modifier(Modifier::BOLD); 28 | let (dl, up) = ( 29 | ByteSize(self.connection.download).to_string_as(true), 30 | ByteSize(self.connection.upload).to_string_as(true), 31 | ); 32 | let (dl_speed, up_speed) = ( 33 | ByteSize(self.download.unwrap_or_default()).to_string_as(true) + "/s", 34 | ByteSize(self.upload.unwrap_or_default()).to_string_as(true) + "/s", 35 | ); 36 | let meta = &self.connection.metadata; 37 | let host = format!("{}:{}", meta.host, meta.destination_port); 38 | 39 | let src = format!("{}:{} ", meta.source_ip, meta.source_port); 40 | let dest = format!( 41 | " {}:{}", 42 | if meta.destination_ip.is_empty() { 43 | "?" 44 | } else { 45 | &meta.destination_ip 46 | }, 47 | meta.source_port 48 | ); 49 | let dash: String = "─".repeat(44_usize.saturating_sub(src.len() + dest.len()).max(1)); 50 | 51 | let time = (Utc::now() - self.connection.start).hms(); 52 | vec![ 53 | Span::styled(format!("{:45}", host), bolded), 54 | // Download size 55 | Span::styled(" ▼ ", dimmed), 56 | Span::raw(format!("{:12}", dl)), 57 | // Download speed 58 | Span::styled(" ⇊ ", dimmed), 59 | Span::raw(format!("{:12}", dl_speed)), 60 | // Upload size 61 | Span::styled(" ▲ ", dimmed), 62 | Span::raw(format!("{:12}", up)), 63 | // Upload Speed 64 | Span::styled(" ⇈ ", dimmed), 65 | Span::raw(format!("{:12}", up_speed)), 66 | // Time 67 | Span::styled(" ⏲ ", dimmed), 68 | Span::raw(format!("{:10}", time)), 69 | // Rule 70 | Span::styled(" ✤ ", dimmed), 71 | Span::raw(format!("{:15}", self.connection.rule)), 72 | // IP 73 | Span::styled(" ⇄ ", dimmed), 74 | Span::raw(src), 75 | Span::styled(dash, dimmed), 76 | Span::raw(dest), 77 | // Chain 78 | Span::styled(" ⟴ ", dimmed), 79 | Span::raw(self.connection.chains.join(" - ")), 80 | ] 81 | .into() 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /clashctl/src/interactive/sort/mod.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use smart_default::SmartDefault; 5 | 6 | mod_use::mod_use![con_sort, proxy_sort, rule_sort]; 7 | 8 | pub trait Sortable<'a, S: SortMethod>> { 9 | type Item<'b>; 10 | fn sort_with(&mut self, method: &S); 11 | } 12 | 13 | pub trait SortMethod { 14 | fn sort_fn(&self, a: &Item, b: &Item) -> Ordering; 15 | } 16 | 17 | pub trait EndlessSelf { 18 | fn next_self(&mut self); 19 | fn prev_self(&mut self); 20 | } 21 | 22 | #[derive( 23 | Debug, 24 | Clone, 25 | Copy, 26 | PartialEq, 27 | Eq, 28 | PartialOrd, 29 | Ord, 30 | Serialize, 31 | Deserialize, 32 | SmartDefault, 33 | strum::EnumString, 34 | strum::Display, 35 | strum::EnumVariantNames, 36 | )] 37 | #[serde(rename_all = "lowercase")] 38 | #[strum(ascii_case_insensitive)] 39 | pub enum SortOrder { 40 | Ascendant, 41 | #[default] 42 | Descendant, 43 | } 44 | 45 | pub trait OrderBy { 46 | fn order_by(self, order: SortOrder) -> Ordering; 47 | } 48 | 49 | impl OrderBy for Ordering { 50 | fn order_by(self, order: SortOrder) -> Ordering { 51 | if matches!(order, SortOrder::Descendant) { 52 | self.reverse() 53 | } else { 54 | self 55 | } 56 | } 57 | } 58 | 59 | #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Debug, Default, Hash)] 60 | pub struct Noop; 61 | 62 | impl Noop { 63 | pub const fn new() -> Self { 64 | Noop 65 | } 66 | } 67 | 68 | impl ToString for Noop { 69 | fn to_string(&self) -> String { 70 | "".into() 71 | } 72 | } 73 | 74 | impl SortMethod for Noop { 75 | #[inline] 76 | fn sort_fn(&self, _: &Item, _: &Item) -> Ordering { 77 | Ordering::Equal 78 | } 79 | } 80 | 81 | impl EndlessSelf for Noop { 82 | fn next_self(&mut self) {} 83 | 84 | fn prev_self(&mut self) {} 85 | } 86 | 87 | impl SortMethod for F 88 | where 89 | F: Fn(&T, &T) -> Ordering, 90 | { 91 | #[inline] 92 | fn sort_fn(&self, a: &T, b: &T) -> Ordering { 93 | self(a, b) 94 | } 95 | } 96 | 97 | impl<'a, T, M> Sortable<'a, M> for Vec 98 | where 99 | M: SortMethod, 100 | { 101 | type Item<'b> = T; 102 | 103 | #[inline] 104 | fn sort_with(&mut self, method: &M) { 105 | self.sort_by(|a, b| method.sort_fn(a, b)) 106 | } 107 | } 108 | 109 | // #[macro_export] 110 | // macro_rules! endless { 111 | // ( $ty:path = $from:ident => $( $to:ident $(=>)? )+ ) => { 112 | // impl EndlessSelf for $ty { 113 | // fn next_self(&mut self) { 114 | // use $ty::*; 115 | // match self { 116 | // endless!( @inner $ty = $from => $( $to => )+ ) 117 | // } 118 | // } 119 | // fn prev_self(&mut self) {} 120 | // } 121 | // }; 122 | // ( @inner $ty:path = $prev:ident => $from:ident => $( $to:ident $(=>)? )+ 123 | // ) => { $ty::$prev => $ty::$from, 124 | // endless!(@inner $ty = $from => $($to =>)+), 125 | // }; 126 | // ( @inner $ty:path = $from:ident => $to:ident ) => { 127 | // $from => $to, 128 | // } 129 | // } 130 | 131 | // endless!( RuleSortBy = Payload => Proxy => Type ); 132 | -------------------------------------------------------------------------------- /clashctl/src/ui/config.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; 2 | 3 | use once_cell::sync::OnceCell; 4 | use smart_default::SmartDefault; 5 | 6 | use crate::interactive::{clashctl::model::Config as ConfigModel, Config, ConfigData}; 7 | 8 | // static CONFIG: OnceCell> = OnceCell::new(); 9 | static CONFIG: OnceCell> = OnceCell::new(); 10 | 11 | static NONE: &str = "N/A"; 12 | 13 | pub fn init_config(config: Config) { 14 | let _ = CONFIG.set(RwLock::new(config)); 15 | } 16 | 17 | pub fn get_config<'a>() -> RwLockReadGuard<'a, Config> { 18 | CONFIG 19 | .get() 20 | .expect("Config is not initialized") 21 | .read() 22 | .unwrap() 23 | } 24 | 25 | pub fn get_config_mut<'a>() -> RwLockWriteGuard<'a, Config> { 26 | CONFIG 27 | .get() 28 | .expect("Config is not initialized") 29 | .write() 30 | .unwrap() 31 | } 32 | 33 | #[derive(Clone, Debug, SmartDefault)] 34 | pub struct ConfigState { 35 | clash: Option, 36 | #[default(_code = "{ get_config().get_inner().clone() }")] 37 | clashctl: ConfigData, 38 | // offset: usize, 39 | } 40 | 41 | impl ConfigState { 42 | pub fn clashctl_list(&self) -> impl Iterator { 43 | let server = self 44 | .clashctl 45 | .using 46 | .as_ref() 47 | .map(|x| x.to_string()) 48 | .unwrap_or_else(|| "N/A".to_owned()); 49 | let log_dir = self 50 | .clashctl 51 | .tui 52 | .log_file 53 | .as_ref() 54 | .and_then(|x| x.to_str()) 55 | .unwrap_or("N/A") 56 | .to_string(); 57 | [("Server", server), ("Log dir", log_dir)].into_iter() 58 | } 59 | 60 | pub fn clash_list(&self) -> impl Iterator { 61 | match self.clash { 62 | Some(ref conf) => vec![ 63 | ("Port", conf.port.to_string()), 64 | ("Socks Port", conf.socks_port.to_string()), 65 | ("Redir Port", conf.redir_port.to_string()), 66 | ("Tproxy Port", conf.tproxy_port.to_string()), 67 | ("Mixed Port", conf.mixed_port.to_string()), 68 | ("Allow Lan", conf.allow_lan.to_string()), 69 | ("Ipv6", conf.ipv6.to_string()), 70 | ("Mode", conf.mode.to_string()), 71 | ("Log Level", conf.log_level.to_string()), 72 | ("Bind_Address", conf.bind_address.to_string()), 73 | ("Authentication", conf.authentication.len().to_string()), 74 | ] 75 | .into_iter(), 76 | None => vec![ 77 | ("Port", NONE.into()), 78 | ("Socks Port", NONE.into()), 79 | ("Redir Port", NONE.into()), 80 | ("Tproxy Port", NONE.into()), 81 | ("Mixed Port", NONE.into()), 82 | ("Allow Lan", NONE.into()), 83 | ("Ipv6", NONE.into()), 84 | ("Mode", NONE.into()), 85 | ("Log Level", NONE.into()), 86 | ("Bind Address", NONE.into()), 87 | ("Authentication", NONE.into()), 88 | ] 89 | .into_iter(), 90 | } 91 | } 92 | 93 | pub fn update_clash(&mut self, config: ConfigModel) { 94 | self.clash = Some(config) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /clashctl/src/ui/components/constants.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use tui::{ 4 | style::{Color, Modifier, Style}, 5 | text::Span, 6 | }; 7 | 8 | pub struct Consts {} 9 | 10 | impl Consts { 11 | pub const PROXY_LATENCY_SIGN: &'static str = "⬤ "; 12 | 13 | pub const NOT_PROXY_SIGN: &'static str = "✪ "; 14 | 15 | pub const NO_LATENCY_SIGN: &'static str = "⊝"; 16 | 17 | pub const FOCUSED_INDICATOR: &'static str = "🮇 "; 18 | 19 | pub const FOCUSED_EXPANDED_INDICATOR: &'static str = "🮇 "; 20 | 21 | pub const UNFOCUSED_INDICATOR: &'static str = " "; 22 | 23 | pub const EXPANDED_FOCUSED_INDICATOR: &'static str = "🮇 ➤"; 24 | 25 | pub const DEFAULT_STYLE: Style = Style { 26 | add_modifier: Modifier::empty(), 27 | sub_modifier: Modifier::empty(), 28 | fg: None, 29 | bg: None, 30 | }; 31 | 32 | pub const PROXY_TYPE_STYLE: Style = Style { 33 | fg: Some(Color::DarkGray), 34 | add_modifier: Modifier::empty(), 35 | ..Self::DEFAULT_STYLE 36 | }; 37 | 38 | pub const NO_LATENCY_STYLE: Style = Style { 39 | fg: Some(Color::DarkGray), 40 | ..Self::DEFAULT_STYLE 41 | }; 42 | 43 | pub const LOW_LATENCY_STYLE: Style = Style { 44 | fg: Some(Color::LightGreen), 45 | ..Self::DEFAULT_STYLE 46 | }; 47 | 48 | pub const MID_LATENCY_STYLE: Style = Style { 49 | fg: Some(Color::LightYellow), 50 | ..Self::DEFAULT_STYLE 51 | }; 52 | 53 | pub const HIGH_LATENCY_STYLE: Style = Style { 54 | fg: Some(Color::LightRed), 55 | ..Self::DEFAULT_STYLE 56 | }; 57 | 58 | pub const DELIMITER_SPAN: Span<'static> = Span { 59 | content: Cow::Borrowed(" "), 60 | style: Self::DEFAULT_STYLE, 61 | }; 62 | 63 | pub const NOT_PROXY_SPAN: Span<'static> = Span { 64 | content: Cow::Borrowed(Self::NOT_PROXY_SIGN), 65 | style: Self::NO_LATENCY_STYLE, 66 | }; 67 | 68 | pub const NO_LATENCY_SPAN: Span<'static> = Span { 69 | content: Cow::Borrowed(Self::PROXY_LATENCY_SIGN), 70 | style: Self::NO_LATENCY_STYLE, 71 | }; 72 | 73 | pub const LOW_LATENCY_SPAN: Span<'static> = Span { 74 | content: Cow::Borrowed(Self::PROXY_LATENCY_SIGN), 75 | style: Self::LOW_LATENCY_STYLE, 76 | }; 77 | 78 | pub const MID_LATENCY_SPAN: Span<'static> = Span { 79 | content: Cow::Borrowed(Self::PROXY_LATENCY_SIGN), 80 | style: Self::MID_LATENCY_STYLE, 81 | }; 82 | 83 | pub const HIGH_LATENCY_SPAN: Span<'static> = Span { 84 | content: Cow::Borrowed(Self::PROXY_LATENCY_SIGN), 85 | style: Self::HIGH_LATENCY_STYLE, 86 | }; 87 | 88 | pub const FOCUSED_INDICATOR_SPAN: Span<'static> = Span { 89 | content: Cow::Borrowed(Self::FOCUSED_INDICATOR), 90 | style: Style { 91 | fg: Some(Color::LightYellow), 92 | ..Self::DEFAULT_STYLE 93 | }, 94 | }; 95 | 96 | pub const UNFOCUSED_INDICATOR_SPAN: Span<'static> = Span { 97 | content: Cow::Borrowed(Self::UNFOCUSED_INDICATOR), 98 | style: Self::DEFAULT_STYLE, 99 | }; 100 | 101 | pub const EXPANDED_INDICATOR_SPAN: Span<'static> = Span { 102 | content: Cow::Borrowed(Self::FOCUSED_EXPANDED_INDICATOR), 103 | style: Style { 104 | fg: Some(Color::LightYellow), 105 | ..Self::DEFAULT_STYLE 106 | }, 107 | }; 108 | 109 | pub const EXPANDED_FOCUSED_INDICATOR_SPAN: Span<'static> = Span { 110 | content: Cow::Borrowed(Self::EXPANDED_FOCUSED_INDICATOR), 111 | style: Style { 112 | fg: Some(Color::LightYellow), 113 | ..Self::DEFAULT_STYLE 114 | }, 115 | }; 116 | } 117 | -------------------------------------------------------------------------------- /clashctl/src/ui/utils/ext.rs: -------------------------------------------------------------------------------- 1 | use tui::{ 2 | style::Style, 3 | text::{Span, Spans, StyledGrapheme}, 4 | }; 5 | 6 | pub trait IntoSpan<'a> { 7 | fn into_span(self) -> Span<'a>; 8 | } 9 | 10 | impl<'a> IntoSpan<'a> for StyledGrapheme<'a> { 11 | fn into_span(self) -> Span<'a> { 12 | Span::styled(self.symbol, self.style) 13 | } 14 | } 15 | 16 | pub trait IntoSpans<'a> { 17 | fn into_spans(self) -> Spans<'a>; 18 | } 19 | 20 | impl<'a> IntoSpans<'a> for Vec> { 21 | fn into_spans(self) -> Spans<'a> { 22 | self.into_iter() 23 | .fold(None, |mut acc: Option<(Vec>, Style)>, x| { 24 | let x_style = x.style; 25 | match acc { 26 | Some((ref mut vec, ref mut style)) => { 27 | if style == &x_style { 28 | vec.last_mut().expect("vec.len() >= 1").content += x.symbol; 29 | } else { 30 | vec.push(x.into_span()); 31 | *style = x_style 32 | } 33 | } 34 | None => return Some((vec![x.into_span()], x_style)), 35 | }; 36 | acc 37 | }) 38 | .map(|(vec, _)| vec) 39 | .unwrap_or_default() 40 | .into() 41 | } 42 | } 43 | 44 | impl<'a> IntoSpans<'a> for Vec<(Style, char)> { 45 | fn into_spans(self) -> Spans<'a> { 46 | self.into_iter() 47 | .fold( 48 | None, 49 | |mut acc: Option<(Vec<(Style, String)>, Style)>, (x_style, c)| { 50 | match acc { 51 | Some((ref mut vec, ref mut style)) => { 52 | if style == &x_style { 53 | let last = &mut vec.last_mut().expect("vec.len() >= 1").1; 54 | last.push(c); 55 | } else { 56 | vec.push((x_style, c.to_string())); 57 | *style = x_style 58 | } 59 | } 60 | None => return Some((vec![(x_style, c.to_string())], x_style)), 61 | }; 62 | acc 63 | }, 64 | ) 65 | .map(|(vec, _)| { 66 | vec.into_iter() 67 | .map(|(style, string)| Span::styled(string, style)) 68 | .collect::>() 69 | }) 70 | .unwrap_or_default() 71 | .into() 72 | } 73 | } 74 | 75 | #[test] 76 | fn test_into_span() { 77 | use tui::style::Color; 78 | 79 | let style_blue = Style::default().fg(Color::Blue); 80 | let style_plain = Style::default(); 81 | let style_red = Style::default().fg(Color::Red); 82 | 83 | let (a, b, c) = ( 84 | Span::raw("Hello"), 85 | Span::raw(" "), 86 | Span::raw("World 中文测试"), 87 | ); 88 | let chars_blue = a.styled_graphemes(style_blue); 89 | let chars_plain = b.styled_graphemes(style_plain); 90 | let chars_red = c.styled_graphemes(style_red); 91 | 92 | let spans = chars_blue 93 | .chain(chars_plain) 94 | .chain(chars_red) 95 | .collect::>() 96 | .into_spans(); 97 | 98 | assert_eq!( 99 | spans, 100 | Spans::from(vec![ 101 | Span { 102 | content: "Hello".into(), 103 | style: style_blue 104 | }, 105 | Span { 106 | content: " ".into(), 107 | style: style_plain 108 | }, 109 | Span { 110 | content: "World 中文测试".into(), 111 | style: style_red 112 | }, 113 | ]) 114 | ) 115 | } 116 | -------------------------------------------------------------------------------- /clashctl/src/ui/pages/status.rs: -------------------------------------------------------------------------------- 1 | use std::iter::repeat; 2 | 3 | use bytesize::ByteSize; 4 | use tui::{ 5 | layout::{Constraint, Direction, Layout}, 6 | widgets::{Paragraph, Widget}, 7 | }; 8 | 9 | use crate::ui::{ 10 | components::{MovableListManage, Traffics}, 11 | define_widget, get_block, get_text_style, 12 | }; 13 | 14 | define_widget!(StatusPage); 15 | 16 | impl<'a> Widget for StatusPage<'a> { 17 | fn render(self, area: tui::layout::Rect, buf: &mut tui::buffer::Buffer) { 18 | let main = Layout::default() 19 | .constraints([Constraint::Length(35), Constraint::Min(0)]) 20 | .direction(Direction::Horizontal) 21 | .split(area); 22 | 23 | let last_traffic = self 24 | .state 25 | .traffics 26 | .iter() 27 | .last() 28 | .map(|x| x.to_owned()) 29 | .unwrap_or_default(); 30 | 31 | let (up_avg, down_avg) = match self.state.start_time { 32 | time if time.elapsed().as_secs() == 0 => ("?".to_string(), "?".to_string()), 33 | time => { 34 | let elapsed = time.elapsed().as_secs(); 35 | let (up_all, down_all) = self 36 | .state 37 | .traffics 38 | .iter() 39 | .fold((0, 0), |(up, down), traffic| { 40 | (up + traffic.up, down + traffic.down) 41 | }); 42 | 43 | ( 44 | ByteSize(up_all / elapsed).to_string_as(true) + "/s", 45 | ByteSize(down_all / elapsed).to_string_as(true), 46 | ) 47 | } 48 | }; 49 | 50 | let con_num = self.state.con_state.len().to_string(); 51 | let (total_up, total_down) = self.state.con_size; 52 | let height = main[0].height; 53 | let clash_ver = self 54 | .state 55 | .version 56 | .to_owned() 57 | .map_or_else(|| "?".to_owned(), |v| v.version.to_string()); 58 | 59 | let tails = [ 60 | ("Clash Ver.", clash_ver.as_str()), 61 | ("Clashctl Ver.", env!("CARGO_PKG_VERSION")), 62 | ]; 63 | 64 | let info = [ 65 | ("⇉ Connections", con_num.as_str()), 66 | ( 67 | "▲ Upload", 68 | &(ByteSize(last_traffic.up).to_string_as(true) + "/s"), 69 | ), 70 | ( 71 | "▼ Download", 72 | &(ByteSize(last_traffic.down).to_string_as(true) + "/s"), 73 | ), 74 | ("▲ Avg.", &up_avg), 75 | ("▼ Avg.", &down_avg), 76 | ( 77 | "▲ Max", 78 | &(ByteSize(self.state.max_traffic.up).to_string_as(true) + "/s"), 79 | ), 80 | ( 81 | "▼ Max", 82 | &(ByteSize(self.state.max_traffic.down).to_string_as(true) + "/s"), 83 | ), 84 | ("▲ Total", &ByteSize(total_up).to_string_as(true)), 85 | ("▼ Total", &ByteSize(total_down).to_string_as(true)), 86 | ]; 87 | 88 | let info_str = info 89 | .into_iter() 90 | .chain( 91 | repeat(("", "")) 92 | .take((height as usize).saturating_sub(info.len() + tails.len() + 2)), 93 | ) 94 | .chain(tails.into_iter()) 95 | .map(|(title, content)| format!(" {:<13}{:>18} ", title, content)) 96 | .fold(String::with_capacity((30 * height).into()), |mut a, b| { 97 | a.push_str(&b); 98 | a.push('\n'); 99 | a 100 | }); 101 | 102 | Paragraph::new(info_str) 103 | .block(get_block("Info")) 104 | .style(get_text_style()) 105 | .render(main[0], buf); 106 | 107 | let traffic = Traffics::new(self.state); 108 | traffic.render(main[1], buf) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /clashctl/src/interactive/sort/rule_sort.rs: -------------------------------------------------------------------------------- 1 | use crate::{EndlessSelf, OrderBy, SortMethod, SortOrder}; 2 | 3 | use clashctl_core::model::Rule; 4 | use serde::{Deserialize, Serialize}; 5 | use smart_default::SmartDefault; 6 | 7 | #[derive( 8 | Debug, 9 | Clone, 10 | Copy, 11 | PartialEq, 12 | Eq, 13 | PartialOrd, 14 | Ord, 15 | Serialize, 16 | Deserialize, 17 | SmartDefault, 18 | strum::EnumString, 19 | strum::Display, 20 | strum::EnumVariantNames, 21 | )] 22 | #[strum(ascii_case_insensitive)] 23 | #[serde(rename_all = "lowercase")] 24 | pub enum RuleSortBy { 25 | #[default] 26 | Payload, 27 | Proxy, 28 | Type, 29 | } 30 | 31 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] 32 | #[serde(rename_all = "lowercase")] 33 | pub struct RuleSort { 34 | by: RuleSortBy, 35 | order: SortOrder, 36 | } 37 | 38 | impl RuleSort { 39 | #[inline] 40 | pub fn new(by: RuleSortBy, order: SortOrder) -> Self { 41 | Self { by, order } 42 | } 43 | 44 | #[inline] 45 | pub fn by(&self) -> RuleSortBy { 46 | self.by 47 | } 48 | 49 | #[inline] 50 | pub fn order(&self) -> SortOrder { 51 | self.order 52 | } 53 | 54 | #[inline] 55 | pub fn by_type_asc() -> Self { 56 | Self::new(RuleSortBy::Type, SortOrder::Ascendant) 57 | } 58 | 59 | #[inline] 60 | pub fn by_type_dsc() -> Self { 61 | Self::new(RuleSortBy::Type, SortOrder::Descendant) 62 | } 63 | 64 | #[inline] 65 | pub fn by_payload_asc() -> Self { 66 | Self::new(RuleSortBy::Payload, SortOrder::Ascendant) 67 | } 68 | 69 | #[inline] 70 | pub fn by_payload_dsc() -> Self { 71 | Self::new(RuleSortBy::Payload, SortOrder::Descendant) 72 | } 73 | 74 | #[inline] 75 | pub fn by_proxy_name_asc() -> Self { 76 | Self::new(RuleSortBy::Proxy, SortOrder::Ascendant) 77 | } 78 | 79 | #[inline] 80 | pub fn by_proxy_name_dsc() -> Self { 81 | Self::new(RuleSortBy::Proxy, SortOrder::Descendant) 82 | } 83 | } 84 | 85 | impl EndlessSelf for RuleSort { 86 | fn next_self(&mut self) { 87 | use RuleSortBy::*; 88 | use SortOrder::*; 89 | 90 | *self = match (self.by, self.order) { 91 | (Payload, Ascendant) => Self::by_payload_dsc(), 92 | (Payload, Descendant) => Self::by_type_asc(), 93 | (Type, Ascendant) => Self::by_type_dsc(), 94 | (Type, Descendant) => Self::by_proxy_name_asc(), 95 | (Proxy, Ascendant) => Self::by_proxy_name_dsc(), 96 | (Proxy, Descendant) => Self::by_payload_asc(), 97 | } 98 | } 99 | fn prev_self(&mut self) { 100 | use RuleSortBy::*; 101 | use SortOrder::*; 102 | 103 | *self = match (self.by, self.order) { 104 | (Payload, Ascendant) => Self::by_proxy_name_dsc(), 105 | (Payload, Descendant) => Self::by_payload_asc(), 106 | (Type, Ascendant) => Self::by_payload_dsc(), 107 | (Type, Descendant) => Self::by_type_asc(), 108 | (Proxy, Ascendant) => Self::by_type_dsc(), 109 | (Proxy, Descendant) => Self::by_proxy_name_asc(), 110 | } 111 | } 112 | } 113 | 114 | impl ToString for RuleSort { 115 | fn to_string(&self) -> String { 116 | format!( 117 | "{} {}", 118 | self.by, 119 | match self.order { 120 | SortOrder::Ascendant => "▲", 121 | SortOrder::Descendant => "▼", 122 | } 123 | ) 124 | } 125 | } 126 | 127 | impl SortMethod for RuleSort { 128 | fn sort_fn(&self, a: &Rule, b: &Rule) -> std::cmp::Ordering { 129 | match self.by { 130 | RuleSortBy::Payload => a.payload.cmp(&b.payload), 131 | RuleSortBy::Proxy => a.proxy.cmp(&b.proxy), 132 | RuleSortBy::Type => a.rule_type.cmp(&b.rule_type), 133 | } 134 | .order_by(self.order) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /clashctl/src/ui/utils/helper.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, ops::Range}; 2 | 3 | use tui::{ 4 | style::{Color, Modifier, Style}, 5 | text::{Span, Spans}, 6 | widgets::{Block, Borders}, 7 | }; 8 | 9 | use crate::{IntoSpans, Wrap}; 10 | 11 | pub fn help_footer(content: &str, normal: Style, highlight: Style) -> Spans { 12 | if content.is_empty() { 13 | Spans(vec![]) 14 | } else if content.len() == 1 { 15 | Spans(vec![Span::raw(content)]) 16 | } else { 17 | let (index, _) = content.char_indices().nth(1).unwrap(); 18 | let (first_char, rest) = content.split_at(index); 19 | Spans(vec![ 20 | Span::styled("[", normal), 21 | Span::styled(first_char, highlight), 22 | Span::styled("]", normal), 23 | Span::styled(rest, normal), 24 | ]) 25 | } 26 | } 27 | 28 | pub fn tagged_footer(label: &str, style: Style, content: T) -> Spans { 29 | let mut ret = help_footer(label, style, style.add_modifier(Modifier::BOLD)).wrapped(); 30 | ret.0.push(Span::styled( 31 | content.to_string().wrapped(), 32 | Style::default() 33 | .fg(Color::White) 34 | .add_modifier(Modifier::REVERSED), 35 | )); 36 | ret 37 | } 38 | 39 | pub fn string_window<'a>(string: &'a str, range: &Range) -> Cow<'a, str> { 40 | string 41 | .chars() 42 | .skip(range.start) 43 | .take(range.end - range.start) 44 | .collect() 45 | } 46 | 47 | pub fn string_window_owned(string: String, range: &Range) -> String { 48 | string 49 | .chars() 50 | .skip(range.start) 51 | .take(range.end - range.start) 52 | .collect() 53 | } 54 | 55 | pub fn spans_window<'a>(spans: &'a Spans, range: &Range) -> Spans<'a> { 56 | let inner = &spans.0; 57 | match inner.len() { 58 | 0 => spans.to_owned(), 59 | 1 => { 60 | let item = &inner[0]; 61 | Spans(vec![Span::styled( 62 | string_window(&item.content, range), 63 | item.style, 64 | )]) 65 | } 66 | _ => { 67 | let (start, end) = (range.start, range.end); 68 | inner 69 | .iter() 70 | .flat_map(|x| x.styled_graphemes(Style::default())) 71 | .skip(start) 72 | .take(end - start) 73 | .collect::>() 74 | .into_spans() 75 | } 76 | } 77 | } 78 | 79 | pub fn spans_window_owned<'a>(mut spans: Spans<'a>, range: &Range) -> Spans<'a> { 80 | match spans.0.len() { 81 | 0 => spans, 82 | 1 => { 83 | let item = &mut spans.0[0]; 84 | item.content = string_window_owned(item.content.to_string(), range).into(); 85 | spans 86 | } 87 | _ => { 88 | let (start, end) = (range.start, range.end); 89 | spans 90 | .0 91 | .iter_mut() 92 | .flat_map(|x| x.content.chars().map(|c| (x.style, c))) 93 | .skip(start) 94 | .take(end - start) 95 | .collect::>() 96 | .into_spans() 97 | } 98 | } 99 | } 100 | 101 | pub fn get_block(title: &str) -> Block { 102 | Block::default() 103 | .borders(Borders::ALL) 104 | .style(Style::default().fg(Color::LightBlue)) 105 | .title(Span::raw(format!(" {} ", title))) 106 | } 107 | 108 | pub fn get_focused_block(title: &str) -> Block { 109 | Block::default() 110 | .borders(Borders::ALL) 111 | .title(Span::styled( 112 | format!(" {} ", title), 113 | Style::default().fg(Color::LightGreen), 114 | )) 115 | .style(Style::default().fg(Color::Green)) 116 | } 117 | 118 | pub fn get_text_style() -> Style { 119 | Style::default().fg(Color::White) 120 | } 121 | 122 | #[test] 123 | fn test_string_window() { 124 | let test = "▼ 代理相关的 API".to_owned(); 125 | assert_eq!("代理", &string_window(&test, &(2..4))); 126 | assert_eq!("理相关的 API", &string_window(&test, &(3..114))); 127 | } 128 | -------------------------------------------------------------------------------- /clashctl-core/src/model/connection.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::model::{RuleType, TimeType}; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct Metadata { 8 | #[serde(rename = "type")] 9 | pub connection_type: String, 10 | 11 | #[serde(rename = "sourceIP")] 12 | pub source_ip: String, 13 | pub source_port: String, 14 | 15 | #[serde(rename = "destinationIP")] 16 | pub destination_ip: String, 17 | pub destination_port: String, 18 | pub host: String, 19 | pub network: String, 20 | } 21 | 22 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 23 | #[serde(rename_all = "camelCase")] 24 | pub struct Connection { 25 | pub id: String, 26 | pub upload: u64, 27 | pub download: u64, 28 | pub metadata: Metadata, 29 | pub rule: RuleType, 30 | pub rule_payload: String, 31 | pub start: TimeType, 32 | pub chains: Vec, 33 | } 34 | 35 | #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)] 36 | #[serde(rename_all = "camelCase")] 37 | pub struct Connections { 38 | pub connections: Vec, 39 | pub download_total: u64, 40 | pub upload_total: u64, 41 | } 42 | 43 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 44 | pub struct ConnectionWithSpeed { 45 | pub connection: Connection, 46 | pub upload: Option, 47 | pub download: Option, 48 | } 49 | 50 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 51 | pub struct ConnectionsWithSpeed { 52 | pub connections: Vec, 53 | pub download_total: u64, 54 | pub upload_total: u64, 55 | } 56 | 57 | #[cfg(feature = "deserialize")] 58 | pub use deserialize::*; 59 | 60 | #[cfg(feature = "deserialize")] 61 | mod deserialize { 62 | use chrono::Utc; 63 | 64 | use crate::model::{Connection, ConnectionWithSpeed, Connections, ConnectionsWithSpeed}; 65 | 66 | impl Connection { 67 | pub fn up_speed(&self) -> Option { 68 | let elapsed = (Utc::now() - self.start).num_seconds(); 69 | if elapsed <= 0 { 70 | None 71 | } else { 72 | Some(self.upload / elapsed as u64) 73 | } 74 | } 75 | 76 | pub fn down_speed(&self) -> Option { 77 | let elapsed = (Utc::now() - self.start).num_seconds(); 78 | if elapsed <= 0 { 79 | None 80 | } else { 81 | Some(self.download / elapsed as u64) 82 | } 83 | } 84 | } 85 | 86 | impl From for ConnectionsWithSpeed { 87 | fn from(val: Connections) -> Self { 88 | Self { 89 | connections: val.connections.into_iter().map(Into::into).collect(), 90 | download_total: val.download_total, 91 | upload_total: val.upload_total, 92 | } 93 | } 94 | } 95 | 96 | impl From for Connections { 97 | fn from(val: ConnectionsWithSpeed) -> Self { 98 | Self { 99 | connections: val.connections.into_iter().map(Into::into).collect(), 100 | download_total: val.download_total, 101 | upload_total: val.upload_total, 102 | } 103 | } 104 | } 105 | 106 | impl From for ConnectionWithSpeed { 107 | fn from(val: Connection) -> Self { 108 | let elapsed = (Utc::now() - val.start).num_seconds(); 109 | if elapsed <= 0 { 110 | Self { 111 | connection: val, 112 | upload: None, 113 | download: None, 114 | } 115 | } else { 116 | Self { 117 | download: Some(val.download / elapsed as u64), 118 | upload: Some(val.upload / elapsed as u64), 119 | connection: val, 120 | } 121 | } 122 | } 123 | } 124 | 125 | impl From for Connection { 126 | fn from(val: ConnectionWithSpeed) -> Self { 127 | val.connection 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /clashctl/src/proxy_render.rs: -------------------------------------------------------------------------------- 1 | use clashctl_core::model::{Proxies, Proxy}; 2 | use either::Either; 3 | use owo_colors::OwoColorize; 4 | use terminal_size::{terminal_size, Height, Width}; 5 | 6 | use crate::{ 7 | interactive::{ProxySort, Sortable}, 8 | ProxyListOpt, 9 | }; 10 | 11 | pub trait RenderList { 12 | fn render_list(&self, opt: &ProxyListOpt); 13 | fn render_plain(&self, opt: &ProxyListOpt); 14 | fn render_tree(&self, opt: &ProxyListOpt); 15 | } 16 | 17 | impl RenderList for Proxies { 18 | // pub fn names(&self) -> impl Iterator { 19 | // self.iter().map(|x| x.0) 20 | // } 21 | 22 | fn render_list(&self, opt: &ProxyListOpt) { 23 | let (Width(terminal_width), _) = terminal_size().unwrap_or((Width(70), Height(0))); 24 | println!("\n{:-<1$}", "", terminal_width as usize); 25 | println!("{:<18}{:<8}NAME", "TYPE", "DELAY"); 26 | println!("{:-<1$}", "", terminal_width as usize); 27 | 28 | if opt.plain { 29 | self.render_plain(opt) 30 | } else { 31 | self.render_tree(opt) 32 | } 33 | 34 | println!("{:-<1$}", "", terminal_width as usize); 35 | } 36 | 37 | fn render_plain(&self, opt: &ProxyListOpt) { 38 | let mut list = self.iter().collect::>(); 39 | let sort_method = ProxySort::new(opt.sort_by, opt.sort_order); 40 | 41 | list.sort_with(&sort_method); 42 | 43 | let iter = if opt.reverse { 44 | Either::Left(list.into_iter().rev()) 45 | } else { 46 | Either::Right(list.into_iter()) 47 | } 48 | .filter(|x| { 49 | let proxy_type = &x.1.proxy_type; 50 | // When include all types 51 | if opt.include.is_empty() { 52 | !opt.exclude.contains(proxy_type) 53 | } else { 54 | // When types included is specified 55 | opt.include.contains(proxy_type) 56 | } 57 | }); 58 | 59 | for (name, proxy) in iter { 60 | let delay = proxy 61 | .history 62 | .get(0) 63 | .map(|x| match x.delay { 64 | 0 => "?".to_owned(), 65 | delay => delay.to_string(), 66 | }) 67 | .unwrap_or_else(|| "-".into()); 68 | let type_name = proxy.proxy_type.to_string(); 69 | println!("{:<18}{:<8}{}", type_name.green(), delay, name) 70 | } 71 | } 72 | 73 | fn render_tree(&self, opt: &ProxyListOpt) { 74 | let list = self 75 | .iter() 76 | .filter(|x| { 77 | let proxy_type = x.1.proxy_type; 78 | proxy_type.is_group() && !opt.exclude.contains(&proxy_type) 79 | }) 80 | .collect::>(); 81 | 82 | let groups = if opt.reverse { 83 | Either::Left(list.iter().rev()) 84 | } else { 85 | Either::Right(list.iter()) 86 | }; 87 | 88 | let sort_method = ProxySort::new(opt.sort_by, opt.sort_order); 89 | 90 | for (name, group) in groups.into_iter() { 91 | // Since list only contains groups, and only groups have `all`, so it is safe to 92 | // [`unwrap`] 93 | println!("{:<16} - {}\n", group.proxy_type.blue(), name); 94 | let mut members = group 95 | .all 96 | .as_ref() 97 | .expect("Proxy groups should have `all`") 98 | .iter() 99 | .map(|member_name| self.iter().find(|(name, _)| &member_name == name).unwrap()) 100 | .collect::>(); 101 | members.sort_with(&sort_method); 102 | for ( 103 | name, 104 | Proxy { 105 | proxy_type, 106 | history, 107 | .. 108 | }, 109 | ) in members 110 | { 111 | let delay = history 112 | .get(0) 113 | .map(|x| match x.delay { 114 | 0 => "?".to_owned(), 115 | delay => delay.to_string(), 116 | }) 117 | .unwrap_or_else(|| "-".into()); 118 | println!(" {:<16}{:<8}{}", proxy_type.green(), delay, name) 119 | } 120 | println!(); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /clashctl-core/src/model/proxy.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::ops::Deref; 3 | 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use super::TimeType; 7 | 8 | #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)] 9 | pub struct Proxies { 10 | pub proxies: HashMap, 11 | } 12 | 13 | impl Proxies { 14 | pub fn normal(&self) -> impl Iterator { 15 | self.iter().filter(|(_, x)| x.proxy_type.is_normal()) 16 | } 17 | 18 | pub fn groups(&self) -> impl Iterator { 19 | self.iter().filter(|(_, x)| x.proxy_type.is_group()) 20 | } 21 | 22 | pub fn selectors(&self) -> impl Iterator { 23 | self.iter().filter(|(_, x)| x.proxy_type.is_selector()) 24 | } 25 | 26 | pub fn built_ins(&self) -> impl Iterator { 27 | self.iter().filter(|(_, x)| x.proxy_type.is_built_in()) 28 | } 29 | } 30 | 31 | impl Deref for Proxies { 32 | type Target = HashMap; 33 | fn deref(&self) -> &Self::Target { 34 | &self.proxies 35 | } 36 | } 37 | 38 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] 39 | pub struct Proxy { 40 | #[serde(rename = "type")] 41 | pub proxy_type: ProxyType, 42 | pub history: Vec, 43 | pub udp: Option, 44 | 45 | // Only present in ProxyGroups 46 | pub all: Option>, 47 | pub now: Option, 48 | } 49 | 50 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] 51 | pub struct History { 52 | pub time: TimeType, 53 | pub delay: u64, 54 | } 55 | 56 | #[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Eq, Ord, Clone, Copy)] 57 | #[cfg_attr( 58 | feature = "enum_ext", 59 | derive(strum::EnumString, strum::Display, strum::EnumVariantNames), 60 | strum(ascii_case_insensitive) 61 | )] 62 | pub enum ProxyType { 63 | // Built-In types 64 | Direct, 65 | Reject, 66 | // ProxyGroups 67 | Selector, 68 | URLTest, 69 | Fallback, 70 | LoadBalance, 71 | // Proxies 72 | Shadowsocks, 73 | Vmess, 74 | ShadowsocksR, 75 | Http, 76 | Snell, 77 | Trojan, 78 | Socks5, 79 | // Relay 80 | Relay, 81 | // Unknown 82 | #[serde(other)] 83 | Unknown, 84 | } 85 | 86 | impl ProxyType { 87 | pub fn is_selector(&self) -> bool { 88 | matches!(self, ProxyType::Selector) 89 | } 90 | 91 | pub fn is_group(&self) -> bool { 92 | matches!( 93 | self, 94 | ProxyType::Selector 95 | | ProxyType::URLTest 96 | | ProxyType::Fallback 97 | | ProxyType::LoadBalance 98 | | ProxyType::Relay 99 | ) 100 | } 101 | 102 | pub fn is_built_in(&self) -> bool { 103 | matches!(self, ProxyType::Direct | ProxyType::Reject) 104 | } 105 | 106 | pub fn is_normal(&self) -> bool { 107 | matches!( 108 | self, 109 | ProxyType::Shadowsocks 110 | | ProxyType::Vmess 111 | | ProxyType::ShadowsocksR 112 | | ProxyType::Http 113 | | ProxyType::Snell 114 | | ProxyType::Trojan 115 | | ProxyType::Socks5 116 | ) 117 | } 118 | } 119 | 120 | #[test] 121 | fn test_proxies() { 122 | let proxy_kv = [ 123 | ( 124 | "test_a".to_owned(), 125 | Proxy { 126 | proxy_type: ProxyType::Direct, 127 | history: vec![], 128 | udp: Some(false), 129 | all: None, 130 | now: None, 131 | }, 132 | ), 133 | ( 134 | "test_b".to_owned(), 135 | Proxy { 136 | proxy_type: ProxyType::Selector, 137 | history: vec![], 138 | udp: Some(false), 139 | all: Some(vec!["test_c".into()]), 140 | now: Some("test_c".into()), 141 | }, 142 | ), 143 | ( 144 | "test_c".to_owned(), 145 | Proxy { 146 | proxy_type: ProxyType::Shadowsocks, 147 | history: vec![], 148 | udp: Some(false), 149 | all: None, 150 | now: None, 151 | }, 152 | ), 153 | ( 154 | "test_d".to_owned(), 155 | Proxy { 156 | proxy_type: ProxyType::Fallback, 157 | history: vec![], 158 | udp: Some(false), 159 | all: Some(vec!["test_c".into()]), 160 | now: Some("test_c".into()), 161 | }, 162 | ), 163 | ]; 164 | let proxies = Proxies { 165 | proxies: HashMap::from(proxy_kv), 166 | }; 167 | assert_eq!( 168 | { 169 | let mut tmp = proxies.groups().map(|x| x.0).collect::>(); 170 | tmp.sort(); 171 | tmp 172 | }, 173 | vec!["test_b", "test_d"] 174 | ); 175 | assert_eq!( 176 | proxies.built_ins().map(|x| x.0).collect::>(), 177 | vec!["test_a"] 178 | ); 179 | assert_eq!( 180 | proxies.normal().map(|x| x.0).collect::>(), 181 | vec!["test_c"] 182 | ); 183 | } 184 | -------------------------------------------------------------------------------- /clashctl/src/ui/app.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cell::RefCell, 3 | fs::OpenOptions, 4 | io::{self, Stdout}, 5 | sync::{mpsc::channel, Arc, Mutex, RwLock}, 6 | thread::spawn, 7 | time::{Duration, Instant}, 8 | }; 9 | 10 | use crossterm::{ 11 | execute, 12 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 13 | }; 14 | use log::{info, warn}; 15 | use owo_colors::OwoColorize; 16 | use tui::{ 17 | backend::CrosstermBackend, 18 | layout::{Constraint, Layout}, 19 | Frame, Terminal, 20 | }; 21 | 22 | // use clap::Parser; 23 | use crate::{ 24 | interactive::Flags, 25 | servo, 26 | ui::{ 27 | components::Tabs, get_config, init_config, pages::route, Interval, LoggerBuilder, 28 | TicksCounter, TuiOpt, TuiResult, TuiStates, 29 | }, 30 | }; 31 | 32 | thread_local!(pub(crate) static TICK_COUNTER: RefCell = RefCell::new(TicksCounter::new_with_time(Instant::now()))); 33 | 34 | pub type Backend = CrosstermBackend; 35 | 36 | fn setup() -> TuiResult> { 37 | let mut stdout = io::stdout(); 38 | 39 | execute!(stdout, EnterAlternateScreen)?; 40 | enable_raw_mode()?; 41 | 42 | let backend = CrosstermBackend::new(stdout); 43 | let mut terminal = Terminal::new(backend)?; 44 | terminal.clear()?; 45 | 46 | Ok(terminal) 47 | } 48 | 49 | fn wrap_up(mut terminal: Terminal) -> TuiResult<()> { 50 | execute!(terminal.backend_mut(), LeaveAlternateScreen,)?; 51 | 52 | disable_raw_mode()?; 53 | 54 | Ok(()) 55 | } 56 | 57 | pub fn main_loop(opt: TuiOpt, flag: Flags) -> TuiResult<()> { 58 | let config = flag.get_config()?; 59 | if config.using_server().is_none() { 60 | println!( 61 | "{} No API server configured yet. Use this command to add a server:\n\n $ {}", 62 | "WARN:".red(), 63 | "clashctl server add".green() 64 | ); 65 | return Ok(()); 66 | }; 67 | 68 | init_config(config); 69 | 70 | let state = Arc::new(RwLock::new(TuiStates::default())); 71 | let error = Arc::new(Mutex::new(None)); 72 | 73 | let (event_tx, event_rx) = channel(); 74 | let (action_tx, action_rx) = channel(); 75 | 76 | let servo_event_tx = event_tx.clone(); 77 | let servo = spawn(|| servo(servo_event_tx, action_rx, opt, flag)); 78 | 79 | LoggerBuilder::new(event_tx) 80 | .file(get_config().tui.log_file.as_ref().map(|x| { 81 | OpenOptions::new() 82 | .append(true) 83 | .create(true) 84 | .open(x) 85 | .unwrap() 86 | })) 87 | .apply()?; 88 | info!("Logger set"); 89 | 90 | let event_handler_state = state.clone(); 91 | let event_handler_error = error.clone(); 92 | 93 | let handle = spawn(move || { 94 | let mut should_quit; 95 | while let Ok(event) = event_rx.recv() { 96 | should_quit = event.is_quit(); 97 | let mut state = event_handler_state.write().unwrap(); 98 | match state.handle(event) { 99 | Ok(Some(action)) => { 100 | if let Err(e) = action_tx.send(action) { 101 | event_handler_error.lock().unwrap().replace(e.into()); 102 | should_quit = true; 103 | } 104 | } 105 | // No action needed 106 | Ok(None) => {} 107 | Err(e) => { 108 | event_handler_error.lock().unwrap().replace(e); 109 | should_quit = true; 110 | } 111 | } 112 | if should_quit { 113 | break; 114 | } 115 | } 116 | event_handler_state 117 | .write() 118 | .map(|mut x| x.should_quit = true) 119 | .unwrap(); 120 | }); 121 | 122 | let mut terminal = setup()?; 123 | 124 | let mut interval = Interval::every(Duration::from_millis(33)); 125 | while let Ok(state) = state.read() { 126 | if handle.is_finished() { 127 | info!("State handler quit"); 128 | break; 129 | } 130 | 131 | if servo.is_finished() { 132 | info!("Servo quit"); 133 | match servo.join() { 134 | Err(_) => { 135 | warn!("Servo panicked"); 136 | } 137 | Ok(Err(e)) => { 138 | warn!("TUI error ({e})"); 139 | } 140 | _ => {} 141 | } 142 | break; 143 | } 144 | 145 | if state.should_quit { 146 | info!("Should quit issued"); 147 | break; 148 | } 149 | 150 | TICK_COUNTER.with(|t| t.borrow_mut().new_tick()); 151 | if let Err(e) = terminal.draw(|f| render(&state, f)) { 152 | error.lock().unwrap().replace(e.into()); 153 | break; 154 | } 155 | drop(state); 156 | interval.tick(); 157 | } 158 | 159 | drop(handle); 160 | 161 | wrap_up(terminal)?; 162 | 163 | if let Some(error) = error.lock().unwrap().take() { 164 | return Err(error); 165 | } 166 | 167 | Ok(()) 168 | } 169 | 170 | fn render(state: &TuiStates, f: &mut Frame) { 171 | let layout = Layout::default() 172 | .constraints([Constraint::Length(3), Constraint::Min(0)]) 173 | .split(f.size()); 174 | 175 | let tabs = Tabs::new(state); 176 | f.render_widget(tabs, layout[0]); 177 | 178 | let main = layout[1]; 179 | 180 | route(state, main, f); 181 | } 182 | -------------------------------------------------------------------------------- /clashctl/src/interactive/config.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::Display, 3 | fs::{File, OpenOptions}, 4 | io::{Read, Seek, SeekFrom, Write}, 5 | ops::{Deref, DerefMut}, 6 | path::Path, 7 | time::Duration, 8 | }; 9 | 10 | use clashctl_core::{Clash, ClashBuilder}; 11 | use log::{debug, info}; 12 | use ron::{from_str, ser::PrettyConfig}; 13 | use serde::{Deserialize, Serialize}; 14 | use url::Url; 15 | 16 | use super::{ConfigData, InteractiveError, InteractiveResult}; 17 | 18 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] 19 | pub struct Server { 20 | pub url: url::Url, 21 | pub secret: Option, 22 | } 23 | 24 | impl Server { 25 | pub fn into_clash_with_timeout(self, timeout: Option) -> InteractiveResult { 26 | Ok(self.into_clash_builder()?.timeout(timeout).build()) 27 | } 28 | 29 | pub fn into_clash(self) -> InteractiveResult { 30 | self.into_clash_with_timeout(None) 31 | } 32 | 33 | pub fn into_clash_builder(self) -> InteractiveResult { 34 | Ok(ClashBuilder::new(self.url)?.secret(self.secret)) 35 | } 36 | } 37 | 38 | impl Display for Server { 39 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 40 | write!(f, "Server ({})", self.url) 41 | } 42 | } 43 | 44 | impl TryInto for Server { 45 | type Error = InteractiveError; 46 | 47 | fn try_into(self) -> std::result::Result { 48 | self.into_clash() 49 | } 50 | } 51 | 52 | impl TryInto for Server { 53 | type Error = InteractiveError; 54 | 55 | fn try_into(self) -> std::result::Result { 56 | self.into_clash_builder() 57 | } 58 | } 59 | 60 | #[derive(Debug)] 61 | pub struct Config { 62 | inner: ConfigData, 63 | file: File, 64 | } 65 | 66 | // TODO: use config crate 67 | impl Config { 68 | pub fn from_dir>(path: P) -> InteractiveResult { 69 | let path = path.as_ref(); 70 | 71 | debug!("Open config file @ {}", path.display()); 72 | 73 | let mut this = if !path.exists() { 74 | info!("Config file not exist, creating new one"); 75 | Self { 76 | inner: ConfigData::default(), 77 | file: File::create(path).map_err(InteractiveError::ConfigFileIoError)?, 78 | } 79 | } else { 80 | debug!("Reading and parsing config file"); 81 | 82 | let mut file = OpenOptions::new() 83 | .read(true) 84 | .open(path) 85 | .map_err(InteractiveError::ConfigFileIoError)?; 86 | 87 | let mut buf = match file.metadata() { 88 | Ok(meta) => String::with_capacity(meta.len() as usize), 89 | Err(_) => String::new(), 90 | }; 91 | 92 | file.read_to_string(&mut buf) 93 | .map_err(InteractiveError::ConfigFileIoError)?; 94 | 95 | debug!("Raw config:\n{}", buf); 96 | 97 | let inner = from_str(&buf)?; 98 | 99 | drop(file); 100 | 101 | debug!("Content read"); 102 | 103 | let file = File::create(path).map_err(InteractiveError::ConfigFileIoError)?; 104 | 105 | Self { inner, file } 106 | }; 107 | 108 | this.write()?; 109 | Ok(this) 110 | } 111 | 112 | pub fn write(&mut self) -> InteractiveResult<()> { 113 | let pretty_config = PrettyConfig::default().indentor(" ".to_owned()); 114 | 115 | // Reset the file - Move cursor to 0 and truncate to 0 116 | self.file 117 | .seek(SeekFrom::Start(0)) 118 | .and_then(|_| self.file.set_len(0)) 119 | .map_err(InteractiveError::ConfigFileIoError)?; 120 | 121 | ron::ser::to_writer_pretty(&mut self.file, &self.inner, pretty_config)?; 122 | self.file 123 | .flush() 124 | .map_err(InteractiveError::ConfigFileIoError)?; 125 | 126 | Ok(()) 127 | } 128 | 129 | pub fn using_server(&self) -> Option<&Server> { 130 | match self.using { 131 | Some(ref using) => self.servers.iter().find(|x| &x.url == using), 132 | None => None, 133 | } 134 | } 135 | 136 | pub fn use_server(&mut self, url: Url) -> InteractiveResult<()> { 137 | match self.get_server(&url) { 138 | Some(_s) => { 139 | self.using = Some(url); 140 | Ok(()) 141 | } 142 | None => Err(InteractiveError::ServerNotFound), 143 | } 144 | } 145 | 146 | pub fn get_server(&mut self, url: &Url) -> Option<&Server> { 147 | self.servers.iter().find(|x| &x.url == url) 148 | } 149 | 150 | pub fn get_inner(&self) -> &ConfigData { 151 | &self.inner 152 | } 153 | } 154 | 155 | impl Deref for Config { 156 | type Target = ConfigData; 157 | 158 | fn deref(&self) -> &Self::Target { 159 | &self.inner 160 | } 161 | } 162 | 163 | impl DerefMut for Config { 164 | fn deref_mut(&mut self) -> &mut ConfigData { 165 | &mut self.inner 166 | } 167 | } 168 | 169 | #[test] 170 | fn test_config() { 171 | use std::env; 172 | 173 | pretty_env_logger::formatted_builder() 174 | .filter_level(log::LevelFilter::Debug) 175 | .init(); 176 | 177 | let mut config = Config::from_dir("/tmp/test.ron").unwrap(); 178 | config.write().unwrap(); 179 | config.servers.push(Server { 180 | url: url::Url::parse(&env::var("PROXY_ADDR").unwrap()).unwrap(), 181 | secret: None, 182 | }); 183 | config.write().unwrap(); 184 | } 185 | -------------------------------------------------------------------------------- /clashctl/src/ui/event.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use clashctl_core::model::{ConnectionsWithSpeed, Log, Proxies, Rules, Traffic, Version}; 4 | use crossterm::event::{KeyCode as KC, KeyEvent as KE, KeyModifiers as KM}; 5 | use log::Level; 6 | use tui::{ 7 | style::{Color, Style}, 8 | text::{Span, Spans}, 9 | }; 10 | 11 | use crate::{ 12 | ui::{components::MovableListItem, utils::AsColor, TuiError, TuiResult}, 13 | Action, 14 | }; 15 | 16 | #[derive(Debug, Clone)] 17 | #[non_exhaustive] 18 | pub enum Event { 19 | Quit, 20 | Action(Action), 21 | Input(InputEvent), 22 | Update(UpdateEvent), 23 | Diagnostic(DiagnosticEvent), 24 | } 25 | 26 | impl<'a> MovableListItem<'a> for Event { 27 | fn to_spans(&self) -> Spans<'a> { 28 | match self { 29 | Event::Quit => Spans(vec![]), 30 | Event::Action(action) => Spans(vec![ 31 | Span::styled("⋉ ", Style::default().fg(Color::Yellow)), 32 | Span::raw(format!("{:?}", action)), 33 | ]), 34 | Event::Update(event) => Spans(vec![ 35 | Span::styled("⇵ ", Style::default().fg(Color::Yellow)), 36 | Span::raw(event.to_string()), 37 | ]), 38 | Event::Input(event) => Spans(vec![ 39 | Span::styled("✜ ", Style::default().fg(Color::Green)), 40 | Span::raw(format!("{:?}", event)), 41 | ]), 42 | Event::Diagnostic(event) => match event { 43 | DiagnosticEvent::Log(level, payload) => Spans(vec![ 44 | Span::styled( 45 | format!("✇ {:<6}", level), 46 | Style::default().fg(level.as_color()), 47 | ), 48 | Span::raw(payload.to_owned()), 49 | ]), 50 | }, 51 | } 52 | } 53 | } 54 | 55 | impl Event { 56 | pub fn is_quit(&self) -> bool { 57 | matches!(self, Event::Quit) 58 | } 59 | 60 | pub fn is_interface(&self) -> bool { 61 | matches!(self, Event::Input(_)) 62 | } 63 | 64 | pub fn is_update(&self) -> bool { 65 | matches!(self, Event::Update(_)) 66 | } 67 | 68 | pub fn is_diagnostic(&self) -> bool { 69 | matches!(self, Event::Diagnostic(_)) 70 | } 71 | } 72 | 73 | #[derive(Debug, Clone, PartialEq, Eq)] 74 | #[non_exhaustive] 75 | pub enum InputEvent { 76 | Esc, 77 | TabGoto(u8), 78 | ToggleDebug, 79 | ToggleHold, 80 | List(ListEvent), 81 | TestLatency, 82 | NextSort, 83 | PrevSort, 84 | Other(KE), 85 | } 86 | 87 | #[derive(Debug, Clone, PartialEq, Eq)] 88 | pub struct ListEvent { 89 | pub fast: bool, 90 | pub code: KC, 91 | } 92 | 93 | #[derive(Debug, Clone, PartialEq, Eq)] 94 | #[non_exhaustive] 95 | pub enum UpdateEvent { 96 | Config(crate::interactive::clashctl::model::Config), 97 | Connection(ConnectionsWithSpeed), 98 | Version(Version), 99 | Traffic(Traffic), 100 | Proxies(Proxies), 101 | Rules(Rules), 102 | Log(Log), 103 | ProxyTestLatencyDone, 104 | } 105 | 106 | impl Display for UpdateEvent { 107 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 108 | match self { 109 | UpdateEvent::Config(x) => write!(f, "{:?}", x), 110 | UpdateEvent::Connection(x) => write!(f, "{:?}", x), 111 | UpdateEvent::Version(x) => write!(f, "{:?}", x), 112 | UpdateEvent::Traffic(x) => write!(f, "{:?}", x), 113 | UpdateEvent::Proxies(x) => write!(f, "{:?}", x), 114 | UpdateEvent::Rules(x) => write!(f, "{:?}", x), 115 | UpdateEvent::Log(x) => write!(f, "{:?}", x), 116 | UpdateEvent::ProxyTestLatencyDone => write!(f, "Test latency done"), 117 | } 118 | } 119 | } 120 | 121 | #[derive(Debug, Clone, PartialEq, Eq)] 122 | #[non_exhaustive] 123 | pub enum DiagnosticEvent { 124 | Log(Level, String), 125 | } 126 | 127 | impl TryFrom for Event { 128 | type Error = TuiError; 129 | 130 | fn try_from(value: KC) -> TuiResult { 131 | match value { 132 | KC::Char('q') | KC::Char('x') => Ok(Event::Quit), 133 | KC::Char('t') => Ok(Event::Input(InputEvent::TestLatency)), 134 | KC::Esc => Ok(Event::Input(InputEvent::Esc)), 135 | KC::Char(' ') => Ok(Event::Input(InputEvent::ToggleHold)), 136 | KC::Char(char) if char.is_ascii_digit() => Ok(Event::Input(InputEvent::TabGoto( 137 | char.to_digit(10) 138 | .expect("char.is_ascii_digit() should be able to parse into number") 139 | as u8, 140 | ))), 141 | _ => Err(TuiError::TuiInternalErr), 142 | } 143 | } 144 | } 145 | 146 | impl From for Event { 147 | fn from(value: KE) -> Self { 148 | match (value.modifiers, value.code) { 149 | (KM::CONTROL, KC::Char('c')) => Self::Quit, 150 | (KM::CONTROL, KC::Char('d')) => Self::Input(InputEvent::ToggleDebug), 151 | (modi, arrow @ (KC::Left | KC::Right | KC::Up | KC::Down | KC::Enter)) => { 152 | Event::Input(InputEvent::List(ListEvent { 153 | fast: matches!(modi, KM::CONTROL | KM::SHIFT), 154 | code: arrow, 155 | })) 156 | } 157 | (KM::ALT, KC::Char('s')) => Self::Input(InputEvent::PrevSort), 158 | (KM::NONE, KC::Char('s')) => Self::Input(InputEvent::NextSort), 159 | (KM::NONE, key_code) => key_code 160 | .try_into() 161 | .unwrap_or(Self::Input(InputEvent::Other(value))), 162 | _ => Self::Input(InputEvent::Other(value)), 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /clashctl/src/ui/servo.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | sync::mpsc::{Receiver, Sender}, 3 | thread::{scope, JoinHandle}, 4 | time::Duration, 5 | }; 6 | 7 | use clashctl_core::Clash; 8 | use crossterm::event::Event as CrossTermEvent; 9 | use log::warn; 10 | use rayon::prelude::*; 11 | 12 | use crate::{ 13 | interactive::Flags, 14 | ui::{ 15 | event::{Event, UpdateEvent}, 16 | utils::{Interval, Pulse}, 17 | Action, TuiOpt, TuiResult, 18 | }, 19 | }; 20 | 21 | pub type Job = JoinHandle>; 22 | 23 | pub fn servo(tx: Sender, rx: Receiver, opt: TuiOpt, flags: Flags) -> TuiResult<()> { 24 | let clash = flags.connect_server_from_config()?; 25 | clash.get_version()?; 26 | 27 | scope(|r| -> TuiResult<()> { 28 | let tx_clone = tx.clone(); 29 | let handle1 = r.spawn(|| input_job(tx_clone)); 30 | 31 | let tx_clone = tx.clone(); 32 | let handle2 = r.spawn(|| traffic_job(tx_clone, &clash)); 33 | 34 | let tx_clone = tx.clone(); 35 | let handle3 = r.spawn(|| log_job(tx_clone, &clash)); 36 | 37 | let tx_clone = tx.clone(); 38 | let handle4 = r.spawn(|| req_job(&opt, &flags, tx_clone, &clash)); 39 | 40 | let handle5 = r.spawn(|| action_job(&opt, &flags, tx, rx, &clash)); 41 | 42 | handle1.join().unwrap()?; 43 | handle2.join().unwrap()?; 44 | handle3.join().unwrap()?; 45 | handle4.join().unwrap()?; 46 | handle5.join().unwrap()?; 47 | 48 | Ok(()) 49 | }) 50 | } 51 | 52 | fn input_job(tx: Sender) -> TuiResult<()> { 53 | loop { 54 | match crossterm::event::read() { 55 | Ok(CrossTermEvent::Key(event)) => tx.send(Event::from(event))?, 56 | Err(_) => { 57 | tx.send(Event::Quit)?; 58 | break; 59 | } 60 | _ => {} 61 | } 62 | } 63 | Ok(()) 64 | } 65 | 66 | fn req_job(_opt: &TuiOpt, _flags: &Flags, tx: Sender, clash: &Clash) -> TuiResult<()> { 67 | let mut interval = Interval::every(Duration::from_millis(50)); 68 | let mut connection_pulse = Pulse::new(20); // Every 1 s 69 | let mut proxies_pulse = Pulse::new(100); // Every 5 s + 0 tick 70 | let mut rules_pulse = Pulse::new(101); // Every 5 s + 1 tick 71 | let mut version_pulse = Pulse::new(102); // Every 5 s + 2 tick 72 | let mut config_pulse = Pulse::new(103); // Every 5 s + 3 tick 73 | 74 | loop { 75 | if version_pulse.tick() { 76 | tx.send(Event::Update(UpdateEvent::Version(clash.get_version()?)))?; 77 | } 78 | if connection_pulse.tick() { 79 | tx.send(Event::Update(UpdateEvent::Connection( 80 | clash.get_connections()?.into(), 81 | )))?; 82 | } 83 | if rules_pulse.tick() { 84 | tx.send(Event::Update(UpdateEvent::Rules(clash.get_rules()?)))?; 85 | } 86 | if proxies_pulse.tick() { 87 | tx.send(Event::Update(UpdateEvent::Proxies(clash.get_proxies()?)))?; 88 | } 89 | if config_pulse.tick() { 90 | tx.send(Event::Update(UpdateEvent::Config(clash.get_configs()?)))?; 91 | } 92 | interval.tick(); 93 | } 94 | } 95 | 96 | fn traffic_job(tx: Sender, clash: &Clash) -> TuiResult<()> { 97 | let mut traffics = clash.get_traffic()?; 98 | loop { 99 | match traffics.next() { 100 | Some(Ok(traffic)) => tx.send(Event::Update(UpdateEvent::Traffic(traffic)))?, 101 | Some(Err(e)) => warn!("{:?}", e), 102 | None => warn!("No more traffic"), 103 | } 104 | } 105 | } 106 | 107 | fn log_job(tx: Sender, clash: &Clash) -> TuiResult<()> { 108 | loop { 109 | let mut logs = clash.get_log()?; 110 | match logs.next() { 111 | Some(Ok(log)) => tx.send(Event::Update(UpdateEvent::Log(log)))?, 112 | Some(Err(e)) => warn!("{:?}", e), 113 | None => warn!("No more traffic"), 114 | } 115 | } 116 | } 117 | 118 | fn action_job( 119 | _opt: &TuiOpt, 120 | flags: &Flags, 121 | tx: Sender, 122 | rx: Receiver, 123 | clash: &Clash, 124 | ) -> TuiResult<()> { 125 | while let Ok(action) = rx.recv() { 126 | tx.send(Event::Action(action.clone()))?; 127 | match action { 128 | Action::TestLatency { proxies } => { 129 | let result = proxies 130 | .par_iter() 131 | .filter_map(|proxy| { 132 | clash 133 | .get_proxy_delay(proxy, flags.test_url.as_str(), flags.timeout) 134 | .err() 135 | }) 136 | .collect::>(); 137 | 138 | let count = result.len(); 139 | 140 | if count != 0 { 141 | warn!( 142 | " {}", 143 | result 144 | .into_iter() 145 | .map(|x| x.to_string()) 146 | .collect::>() 147 | .join(" ") 148 | ); 149 | warn!("({}) error(s) during test proxy delay", count); 150 | } 151 | 152 | tx.send(Event::Update(UpdateEvent::ProxyTestLatencyDone))?; 153 | tx.send(Event::Update(UpdateEvent::Proxies(clash.get_proxies()?)))?; 154 | } 155 | Action::ApplySelection { group, proxy } => { 156 | let _ = clash 157 | .set_proxygroup_selected(&group, &proxy) 158 | .map_err(|e| warn!("{:?}", e)); 159 | tx.send(Event::Update(UpdateEvent::Proxies(clash.get_proxies()?)))?; 160 | } 161 | } 162 | } 163 | Ok(()) 164 | } 165 | -------------------------------------------------------------------------------- /clashctl/src/ui/components/sparkline.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::min; 2 | use tui::{ 3 | buffer::Buffer, 4 | layout::Rect, 5 | style::Style, 6 | symbols, 7 | widgets::{Block, Widget}, 8 | }; 9 | 10 | /// Widget to render a sparkline over one or more lines. 11 | /// 12 | /// # Examples 13 | /// 14 | /// ``` 15 | /// # use tui::widgets::{Block, Borders, Sparkline}; 16 | /// # use tui::style::{Style, Color}; 17 | /// Sparkline::default() 18 | /// .block(Block::default().title("Sparkline").borders(Borders::ALL)) 19 | /// .data(&[0, 2, 3, 4, 1, 4, 10]) 20 | /// .max(5) 21 | /// .style(Style::default().fg(Color::Red).bg(Color::White)); 22 | /// ``` 23 | #[derive(Debug, Clone)] 24 | pub struct Sparkline<'a> { 25 | /// A block to wrap the widget in 26 | block: Option>, 27 | /// Widget style 28 | style: Style, 29 | /// A slice of the data to display 30 | data: &'a [u64], 31 | /// The maximum value to take to compute the maximum bar height (if nothing is specified, the 32 | /// widget uses the max of the dataset) 33 | max: Option, 34 | /// A set of bar symbols used to represent the give data 35 | bar_set: symbols::bar::Set, 36 | /// Up side down 37 | reversed: bool, 38 | } 39 | 40 | impl<'a> Default for Sparkline<'a> { 41 | fn default() -> Sparkline<'a> { 42 | Sparkline { 43 | block: None, 44 | style: Default::default(), 45 | data: &[], 46 | max: None, 47 | bar_set: symbols::bar::NINE_LEVELS, 48 | reversed: false, 49 | } 50 | } 51 | } 52 | 53 | impl<'a> Sparkline<'a> { 54 | pub fn block(mut self, block: Block<'a>) -> Sparkline<'a> { 55 | self.block = Some(block); 56 | self 57 | } 58 | 59 | pub fn style(mut self, style: Style) -> Sparkline<'a> { 60 | self.style = style; 61 | self 62 | } 63 | 64 | pub fn data(mut self, data: &'a [u64]) -> Sparkline<'a> { 65 | self.data = data; 66 | self 67 | } 68 | 69 | pub fn max(mut self, max: u64) -> Sparkline<'a> { 70 | self.max = Some(max); 71 | self 72 | } 73 | 74 | pub fn bar_set(mut self, bar_set: symbols::bar::Set) -> Sparkline<'a> { 75 | self.bar_set = bar_set; 76 | self 77 | } 78 | 79 | pub fn reversed(mut self, reversed: bool) -> Sparkline<'a> { 80 | self.reversed = reversed; 81 | self 82 | } 83 | } 84 | 85 | impl<'a> Widget for Sparkline<'a> { 86 | fn render(mut self, area: Rect, buf: &mut Buffer) { 87 | let spark_area = match self.block.take() { 88 | Some(b) => { 89 | let inner_area = b.inner(area); 90 | b.render(area, buf); 91 | inner_area 92 | } 93 | None => area, 94 | }; 95 | 96 | if spark_area.height < 1 { 97 | return; 98 | } 99 | 100 | let max = match self.max { 101 | Some(v) => v, 102 | None => *self.data.iter().max().unwrap_or(&1u64), 103 | }; 104 | let max_index = min(spark_area.width as usize, self.data.len()); 105 | let mut data = self 106 | .data 107 | .iter() 108 | .take(max_index) 109 | .map(|e| { 110 | if max != 0 { 111 | e * u64::from(spark_area.height) * 8 / max 112 | } else { 113 | 0 114 | } 115 | }) 116 | .collect::>(); 117 | for j in (0..spark_area.height).rev() { 118 | for (i, d) in data.iter_mut().enumerate() { 119 | let (symbol, x, y) = if self.reversed { 120 | ( 121 | match *d { 122 | 0 => self.bar_set.empty, 123 | 1 => self.bar_set.one_eighth, 124 | 2 => self.bar_set.one_quarter, 125 | 3 => self.bar_set.three_eighths, 126 | 4 => self.bar_set.half, 127 | 5 => self.bar_set.five_eighths, 128 | 6 => self.bar_set.three_quarters, 129 | 7 => self.bar_set.seven_eighths, 130 | _ => self.bar_set.full, 131 | }, 132 | spark_area.x + i as u16, 133 | spark_area.y + spark_area.height - j - 1, 134 | ) 135 | } else { 136 | ( 137 | match *d { 138 | 0 => self.bar_set.empty, 139 | 1 => self.bar_set.one_eighth, 140 | 2 => self.bar_set.one_quarter, 141 | 3 => self.bar_set.three_eighths, 142 | 4 => self.bar_set.half, 143 | 5 => self.bar_set.five_eighths, 144 | 6 => self.bar_set.three_quarters, 145 | 7 => self.bar_set.seven_eighths, 146 | _ => self.bar_set.full, 147 | }, 148 | spark_area.x + i as u16, 149 | spark_area.y + j, 150 | ) 151 | }; 152 | buf.get_mut(x, y).set_symbol(symbol).set_style(self.style); 153 | 154 | if *d > 8 { 155 | *d -= 8; 156 | } else { 157 | *d = 0; 158 | } 159 | } 160 | } 161 | } 162 | } 163 | 164 | #[cfg(test)] 165 | mod tests { 166 | use super::*; 167 | 168 | #[test] 169 | fn it_does_not_panic_if_max_is_zero() { 170 | let widget = Sparkline::default().data(&[0, 0, 0]); 171 | let area = Rect::new(0, 0, 3, 1); 172 | let mut buffer = Buffer::empty(area); 173 | widget.render(area, &mut buffer); 174 | } 175 | 176 | #[test] 177 | fn it_does_not_panic_if_max_is_set_to_zero() { 178 | let widget = Sparkline::default().data(&[0, 1, 2]).max(0); 179 | let area = Rect::new(0, 0, 3, 1); 180 | let mut buffer = Buffer::empty(area); 181 | widget.render(area, &mut buffer); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clashctl 2 | 3 | ## About 4 | 5 | Easy-to-use TUI & CLI to interact with [Clash](https://github.com/Dreamacro/clash) RESTful API. 6 | 7 | ## Screenshots 8 | 9 | ### Status panel 10 | 11 | ![Status panel](https://imagedelivery.net/b21oeeg7p6hqWEI-IA5xDw/be2ffc2e-4193-4418-0d0f-b82624f0c800/public) 12 | 13 | ### Proxies panel 14 | 15 | ![Proxies panel](https://imagedelivery.net/b21oeeg7p6hqWEI-IA5xDw/0166f654-c5c2-4b0a-e401-8d5b93d3f500/public) 16 | 17 | ## Installing 18 | 19 | ### Download release binaries 20 | 21 | For mac and Linux x86 users, find compiled binary under [release page](https://github.com/George-Miao/clashctl/releases). 22 | 23 | ### Compile from source 24 | 25 | ```bash 26 | $ git clone https://github.com/George-Miao/clashctl.git 27 | $ cd clashctl 28 | $ cargo install --path ./clashctl # Note that the path here is *NOT* a mistake - It's a submodule with exact same name that contains the bin 29 | ``` 30 | 31 | ## Getting Started 32 | 33 | First, add an API server: 34 | 35 | ```bash 36 | $ clashctl server add 37 | # Follow the prompts 38 | ``` 39 | 40 | Use the command without subcommands defaults to open TUI: 41 | 42 | ```bash 43 | $ clashctl 44 | 45 | # Equals 46 | 47 | $ clashctl tui 48 | ``` 49 | 50 | Or use a subcommand to use the CLI: 51 | 52 | ```bash 53 | $ clashctl proxy list 54 | 55 | --------------------------------------------------------- 56 | TYPE DELAY NAME 57 | --------------------------------------------------------- 58 | selector - All 59 | 60 | URLTest - Auto-All 61 | ShadowsocksR 19 SomeProxy-1 62 | Vmess 177 SomeProxy-2 63 | Vmess 137 SomeProxy-3 64 | Shadowsocks 143 SomeProxy-4 65 | 66 | --------------------------------------------------------- 67 | ``` 68 | 69 | ## Features 70 | 71 | - Pretty terminal UI 72 | - Change proxies 73 | - Display proxies, with filter and sorting supported, in both plain and grouped mode 74 | - Store and use multiple servers 75 | - Generate completion script (by [clap_generate](https://crates.io/crates/clap_generate)) 76 | - Manage multiple servers 77 | 78 | ### Done & TODO 79 | 80 | - [ ] CLI 81 | - [x] Manage servers 82 | - [x] Sort proxies 83 | - [ ] More features 84 | - [ ] TUI 85 | - [x] Status Panel 86 | - [x] Proxies Panel 87 | - [x] Update proxy 88 | - [x] Test latency 89 | - [x] Sort by {Original, LatencyAsc, LatencyDsc, NameAsc, NameDsc} 90 | - [x] Rules Panel 91 | - [x] Connections Panel 92 | - [ ] Sort 93 | - [x] Log Panel 94 | - [x] Debug Panel 95 | - [ ] Config Panel 96 | - [ ] Update clash configs 97 | - [ ] Update clashctl configs 98 | - [ ] Search 99 | - [ ] (Maybe?) mouse support 100 | 101 | ## Prerequisites 102 | 103 | You will need nightly rust environment (Cargo & rustc) to compile and install 104 | 105 | ## Usage 106 | 107 | ### Use the TUI 108 | 109 | - Use the cli to config servers (for now) 110 | - Use number to navigate between tabs 111 | - Space to hold the list (and therefor move the list) 112 | - Arrow key to move the list under Hold mode 113 | - [^d] open debug panel 114 | 115 | ### Use the CLI 116 | 117 | ``` 118 | $ clashctl -h 119 | clashctl 120 | 121 | George Miao 122 | 123 | Cli & Tui used to interact with Clash RESTful API 124 | 125 | USAGE: 126 | clashctl [OPTIONS] [SUBCOMMAND] 127 | 128 | OPTIONS: 129 | -c, --config-path Path of config file. Default to ~/.config/clashctl/config.ron 130 | --config-dir Path of config directory. Default to ~/.config/clashctl 131 | -h, --help Print help information 132 | -t, --timeout Timeout of requests, in ms [default: 2000] 133 | --test-url Url for testing proxy endpointes [default: http:// 134 | www.gstatic.com/generate_204] 135 | -v, --verbose Verbosity. Default: INFO, -v DEBUG, -vv TRACE 136 | -V, --version Print version information 137 | 138 | SUBCOMMANDS: 139 | completion Generate auto-completion scripts 140 | help Print this message or the help of the given subcommand(s) 141 | proxy Interacting with proxies 142 | server Interacting with servers 143 | tui Open TUI 144 | ``` 145 | 146 | ### Use as a crate 147 | 148 | ```toml 149 | # cargo.toml 150 | 151 | [dependencies] 152 | clashctl-core = "*" # Don't add `clashctl`, that will be the binary crate. `clashctl-core` contains API stuff. 153 | 154 | ``` 155 | 156 | Then in your project: 157 | 158 | ```rust 159 | use clashctl_core::Clash; 160 | 161 | fn main() { 162 | let clash = Clash::builder("http://example.com:9090").unwrap().build(); 163 | println!("Clash version is {:?}", clash.get_version().unwrap()) 164 | } 165 | ``` 166 | 167 | ## Development 168 | 169 | `clashctl` comes with a [`justfile`](https://github.com/casey/just) to speed up your development. 170 | Especially the command `just dev`, managed to reproduce the hot reload function in front-end development, with [`cargo-watch`](https://github.com/watchexec/cargo-watch). 171 | 172 | ### [`Just`](https://github.com/casey/just) commands 173 | 174 | #### `just dev` [ alias: `d` ] 175 | 176 | Hot reload development, auto reload on `cargo-check` approved changes, with all features enabled 177 | 178 | #### `just run {{ Args }}` [ alias: `r` ] 179 | 180 | Run with feature cli & ui 181 | 182 | #### `just ui` 183 | 184 | Run UI only 185 | 186 | #### `just cli` 187 | 188 | Run CLI only 189 | 190 | #### `just build` [ alias: `b` ] 191 | 192 | Build in release mode with feature cli & ui 193 | 194 | #### `just add` 195 | 196 | ### Project structure 197 | 198 | ```bash 199 | $ tree src -L 2 200 | ├── clashctl # Submodule for binary - Both CLI & TUI 201 | ├── clashctl-core # Submodule for API interaction 202 | ├── clashctl-interactive # Submodule for common dependency of CLI & TUI 203 | ├── clashctl-tui # TUI only binary 204 | ├── clashctl-workspace-hack # Workspace hack generated by cargo-hakari 205 | └── ... 206 | ``` 207 | -------------------------------------------------------------------------------- /clashctl/src/command/proxy.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use clap::{Parser, Subcommand}; 4 | use clashctl_core::{model::ProxyType, strum::VariantNames}; 5 | use log::{error, info, warn}; 6 | use owo_colors::OwoColorize; 7 | use requestty::{prompt_one, Answer, ListItem, Question}; 8 | 9 | use crate::{ 10 | interactive::{Flags, ProxySortBy, SortOrder}, 11 | RenderList, Result, 12 | }; 13 | // use crate::{Result}; 14 | 15 | // #[allow(clippy::match_str_case_mismatch)] 16 | // impl FromStr for ProxyType { 17 | // type Err = Error; 18 | // fn from_str(s: &str) -> std::result::Result { 19 | // match s.to_ascii_lowercase().as_str() { 20 | // "direct" => Ok(Self::Direct), 21 | // "reject" => Ok(Self::Reject), 22 | // "selector" => Ok(Self::Selector), 23 | // "urltest" => Ok(Self::URLTest), 24 | // "fallback" => Ok(Self::Fallback), 25 | // "loadbalance" => Ok(Self::LoadBalance), 26 | // "shadowsocks" => Ok(Self::Shadowsocks), 27 | // "vmess" => Ok(Self::Vmess), 28 | // "ssr" => Ok(Self::ShadowsocksR), 29 | // "http" => Ok(Self::Http), 30 | // "snell" => Ok(Self::Snell), 31 | // "trojan" => Ok(Self::Trojan), 32 | // "socks5" => Ok(Self::Socks5), 33 | // "relay" => Ok(Self::Relay), 34 | // _ => Err(Error::BadOption) 35 | // } 36 | // } 37 | // } 38 | 39 | #[derive(Subcommand, Debug)] 40 | #[clap(about = "Interacting with proxies")] 41 | pub enum ProxySubcommand { 42 | #[clap(alias = "ls", about = "List proxies (alias ls)")] 43 | List(ProxyListOpt), 44 | #[clap(about = "Set active proxy")] 45 | Use, 46 | } 47 | 48 | #[derive(Parser, Debug, Clone)] 49 | pub struct ProxyListOpt { 50 | #[clap( 51 | long, 52 | default_value = "delay", 53 | possible_values = &["type", "name", "delay"], 54 | )] 55 | pub sort_by: ProxySortBy, 56 | 57 | #[clap( 58 | long, 59 | default_value = "ascendant", 60 | possible_values = &["ascendant", "descendant"], 61 | )] 62 | pub sort_order: SortOrder, 63 | 64 | #[clap(short, long, help = "Reverse the listed result")] 65 | pub reverse: bool, 66 | 67 | #[clap( 68 | short, 69 | long, 70 | help = "Exclude proxy types", 71 | conflicts_with = "include", 72 | possible_values = ProxyType::VARIANTS 73 | )] 74 | pub exclude: Vec, 75 | 76 | #[clap( 77 | short, 78 | long, 79 | help = "Include proxy types", 80 | conflicts_with = "exclude", 81 | possible_values = ProxyType::VARIANTS 82 | )] 83 | pub include: Vec, 84 | 85 | #[clap(short, long, help = "Show proxies and groups without cascading")] 86 | pub plain: bool, 87 | } 88 | 89 | impl ProxySubcommand { 90 | pub fn handle(&self, flags: &Flags) -> Result<()> { 91 | let config = flags.get_config()?; 92 | let server = match config.using_server() { 93 | Some(server) => server.to_owned(), 94 | None => { 95 | warn!("No server configured yet. Use `clashctl server add` first."); 96 | return Ok(()); 97 | } 98 | }; 99 | info!("Using {}", server); 100 | let clash = server.into_clash_with_timeout(Some(Duration::from_millis(flags.timeout)))?; 101 | 102 | match self { 103 | ProxySubcommand::List(opt) => { 104 | let proxies = clash.get_proxies()?; 105 | proxies.render_list(opt); 106 | } 107 | ProxySubcommand::Use => { 108 | let proxies = clash.get_proxies()?; 109 | let mut groups = proxies 110 | .iter() 111 | .filter(|(_, p)| p.proxy_type.is_selector()) 112 | .map(|(name, _)| name) 113 | .filter(|name| !["GLOBAL", "REJECT"].contains(&name.as_str())) 114 | .collect::>(); 115 | groups.sort(); 116 | let group_selected = match prompt_one( 117 | Question::select("proxy") 118 | .message("Which group to change?") 119 | .choices(groups) 120 | .build(), 121 | ) { 122 | Ok(result) => result.as_list_item().unwrap().text.to_owned(), 123 | Err(e) => { 124 | error!("Error selecting proxy: {}", e); 125 | return Err(e.into()); 126 | } 127 | }; 128 | let proxy = clash.get_proxy(&group_selected)?; 129 | 130 | // all / now only occurs when proxy_type is [`ProxyType::Selector`] 131 | let members = proxy.all.unwrap(); 132 | let now = proxy.now.unwrap(); 133 | let cur_index = members.iter().position(|x| x == &now).unwrap(); 134 | let mut question = Question::select("proxy") 135 | .message("Which proxy to use?") 136 | .choices(members); 137 | if cur_index != 0 { 138 | question = question.default(cur_index) 139 | } 140 | let member_selected = match prompt_one(question.build()) { 141 | Ok(result) => match result { 142 | Answer::ListItem(ListItem { text, .. }) => text, 143 | _ => unreachable!(), 144 | }, 145 | Err(e) => { 146 | error!("Error selecting proxy: {}", e); 147 | return Err(e.into()); 148 | } 149 | }; 150 | info!( 151 | "Setting group {} to use {}", 152 | group_selected.green(), 153 | member_selected.green() 154 | ); 155 | clash.set_proxygroup_selected(&group_selected, &member_selected)?; 156 | info!("Done!") 157 | } 158 | } 159 | Ok(()) 160 | } 161 | } 162 | 163 | #[test] 164 | fn test_proxy_type() { 165 | let string = "direct"; 166 | let parsed = string.parse().unwrap(); 167 | assert_eq!(ProxyType::Direct, parsed); 168 | } 169 | -------------------------------------------------------------------------------- /clashctl/src/interactive/sort/proxy_sort.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | use clashctl_core::{model::Proxy, strum}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use crate::{EndlessSelf, SortMethod, SortOrder}; 7 | 8 | #[derive( 9 | Debug, 10 | Clone, 11 | Copy, 12 | PartialEq, 13 | Eq, 14 | PartialOrd, 15 | Ord, 16 | Serialize, 17 | Deserialize, 18 | strum::EnumString, 19 | strum::Display, 20 | strum::EnumVariantNames, 21 | )] 22 | #[serde(rename_all = "lowercase")] 23 | #[strum(ascii_case_insensitive)] 24 | pub enum ProxySortBy { 25 | Name, 26 | Type, 27 | Delay, 28 | } 29 | 30 | #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 31 | pub struct ProxySort { 32 | by: ProxySortBy, 33 | order: SortOrder, 34 | } 35 | 36 | impl ProxySort { 37 | #[inline] 38 | pub fn new(by: ProxySortBy, order: SortOrder) -> Self { 39 | Self { by, order } 40 | } 41 | 42 | #[inline] 43 | pub fn by(&self) -> ProxySortBy { 44 | self.by 45 | } 46 | 47 | #[inline] 48 | pub fn order(&self) -> SortOrder { 49 | self.order 50 | } 51 | 52 | #[inline] 53 | pub fn by_type_asc() -> Self { 54 | Self { 55 | by: ProxySortBy::Type, 56 | order: SortOrder::Ascendant, 57 | } 58 | } 59 | 60 | #[inline] 61 | pub fn by_name_asc() -> Self { 62 | Self { 63 | by: ProxySortBy::Name, 64 | order: SortOrder::Ascendant, 65 | } 66 | } 67 | 68 | #[inline] 69 | pub fn by_delay_asc() -> Self { 70 | Self { 71 | by: ProxySortBy::Delay, 72 | order: SortOrder::Ascendant, 73 | } 74 | } 75 | 76 | #[inline] 77 | pub fn by_type_dsc() -> Self { 78 | Self { 79 | by: ProxySortBy::Type, 80 | order: SortOrder::Descendant, 81 | } 82 | } 83 | 84 | #[inline] 85 | pub fn by_name_dsc() -> Self { 86 | Self { 87 | by: ProxySortBy::Name, 88 | order: SortOrder::Descendant, 89 | } 90 | } 91 | 92 | #[inline] 93 | pub fn by_delay_dsc() -> Self { 94 | Self { 95 | by: ProxySortBy::Delay, 96 | order: SortOrder::Descendant, 97 | } 98 | } 99 | } 100 | 101 | impl EndlessSelf for ProxySort { 102 | fn next_self(&mut self) { 103 | use ProxySortBy::*; 104 | use SortOrder::*; 105 | 106 | *self = match (self.by, self.order) { 107 | (Name, Ascendant) => Self { 108 | by: Name, 109 | order: Descendant, 110 | }, 111 | (Name, Descendant) => Self { 112 | by: Type, 113 | order: Ascendant, 114 | }, 115 | (Type, Ascendant) => Self { 116 | by: Type, 117 | order: Descendant, 118 | }, 119 | (Type, Descendant) => Self { 120 | by: Delay, 121 | order: Ascendant, 122 | }, 123 | (Delay, Ascendant) => Self { 124 | by: Delay, 125 | order: Descendant, 126 | }, 127 | (Delay, Descendant) => Self { 128 | by: Name, 129 | order: Ascendant, 130 | }, 131 | } 132 | } 133 | 134 | fn prev_self(&mut self) { 135 | use ProxySortBy::*; 136 | use SortOrder::*; 137 | 138 | *self = match (self.by, self.order) { 139 | (Name, Ascendant) => Self { 140 | by: Delay, 141 | order: Descendant, 142 | }, 143 | (Name, Descendant) => Self { 144 | by: Name, 145 | order: Ascendant, 146 | }, 147 | (Type, Ascendant) => Self { 148 | by: Name, 149 | order: Descendant, 150 | }, 151 | (Type, Descendant) => Self { 152 | by: Type, 153 | order: Ascendant, 154 | }, 155 | (Delay, Ascendant) => Self { 156 | by: Type, 157 | order: Descendant, 158 | }, 159 | (Delay, Descendant) => Self { 160 | by: Delay, 161 | order: Ascendant, 162 | }, 163 | } 164 | } 165 | } 166 | 167 | impl ToString for ProxySort { 168 | fn to_string(&self) -> String { 169 | format!( 170 | "{} {}", 171 | self.by, 172 | match self.order { 173 | SortOrder::Ascendant => "▲", 174 | SortOrder::Descendant => "▼", 175 | } 176 | ) 177 | } 178 | } 179 | 180 | impl Default for ProxySort { 181 | fn default() -> Self { 182 | Self::by_delay_asc() 183 | } 184 | } 185 | 186 | impl SortMethod<(&String, &Proxy)> for ProxySort { 187 | fn sort_fn(&self, a: &(&String, &Proxy), b: &(&String, &Proxy)) -> Ordering { 188 | let ret = match self.by() { 189 | ProxySortBy::Type => a.1.proxy_type.cmp(&b.1.proxy_type), 190 | ProxySortBy::Name => a.0.cmp(b.0), 191 | ProxySortBy::Delay => match (a.1.history.get(0), b.1.history.get(0)) { 192 | // 0 delay means unable to connect, so handle exceptionally 193 | // This will push all 0-delay proxies to the end of list 194 | (Some(l_history), Some(r_history)) => match (l_history.delay, r_history.delay) { 195 | (0, 0) => Ordering::Equal, 196 | (0, _) => Ordering::Greater, 197 | (_, 0) => Ordering::Less, 198 | (a, b) => a.cmp(&b), 199 | }, 200 | (Some(_), None) => Ordering::Greater, 201 | (None, Some(_)) => Ordering::Less, 202 | _ => Ordering::Equal, 203 | }, 204 | }; 205 | match self.order() { 206 | SortOrder::Ascendant => ret, 207 | SortOrder::Descendant => ret.reverse(), 208 | } 209 | } 210 | } 211 | 212 | #[test] 213 | fn test() { 214 | let serialized = r#"ProxySort ( by: name, order: ascendant )"#; 215 | let deserialized = ProxySort { 216 | by: ProxySortBy::Name, 217 | order: SortOrder::Ascendant, 218 | }; 219 | assert_eq!( 220 | ron::from_str::(serialized).unwrap(), 221 | deserialized 222 | ); 223 | } 224 | -------------------------------------------------------------------------------- /clashctl/src/ui/components/movable_list/widget.rs: -------------------------------------------------------------------------------- 1 | use tui::{ 2 | layout::Rect, 3 | style::{Color, Modifier, Style}, 4 | text::Span, 5 | widgets::{List, ListItem, Widget}, 6 | }; 7 | 8 | use crate::{ 9 | interactive::{EndlessSelf, Noop, SortMethod}, 10 | spans_window_owned, tagged_footer, 11 | ui::{ 12 | components::{ 13 | Footer, FooterItem, FooterWidget, MovableListItem, MovableListManage, MovableListState, 14 | }, 15 | utils::{get_block, get_focused_block, get_text_style}, 16 | }, 17 | }; 18 | 19 | // TODO Fixed item on top 20 | // Useful for table header 21 | // Append to vec on each render 22 | #[derive(Clone, Debug)] 23 | pub struct MovableList<'a, T, S = Noop> 24 | where 25 | T: MovableListItem<'a>, 26 | S: Default, 27 | { 28 | pub(super) title: String, 29 | pub(super) state: &'a MovableListState<'a, T, S>, 30 | } 31 | 32 | impl<'a, T, S> MovableList<'a, T, S> 33 | where 34 | S: SortMethod + EndlessSelf + Default + ToString, 35 | T: MovableListItem<'a>, 36 | MovableListState<'a, T, S>: MovableListManage, 37 | { 38 | pub fn new>(title: TITLE, state: &'a MovableListState<'a, T, S>) -> Self { 39 | Self { 40 | state, 41 | title: title.into(), 42 | } 43 | } 44 | 45 | fn render_footer(&self, area: tui::layout::Rect, buf: &mut tui::buffer::Buffer) { 46 | let mut footer = Footer::default(); 47 | let pos = self.state.current_pos(); 48 | 49 | let sort_str = self.state.sort.to_string(); 50 | 51 | footer.push_right(FooterItem::span(Span::styled( 52 | format!(" Ln {}, Col {} ", pos.y, pos.x), 53 | Style::default() 54 | .fg(if pos.hold { Color::Green } else { Color::Blue }) 55 | .add_modifier(Modifier::REVERSED), 56 | ))); 57 | 58 | if pos.hold { 59 | let style = Style::default() 60 | .fg(Color::Green) 61 | .add_modifier(Modifier::REVERSED); 62 | 63 | footer.push_left(FooterItem::span(Span::styled(" FREE ", style))); 64 | footer.push_left(FooterItem::span(Span::styled(" [^] ▲ ▼ ◀ ▶ Move ", style))); 65 | if !sort_str.is_empty() { 66 | footer.push_left(tagged_footer("Sort", style, sort_str).into()); 67 | } 68 | } else { 69 | let style = Style::default() 70 | .fg(Color::Blue) 71 | .add_modifier(Modifier::REVERSED); 72 | 73 | footer.push_left(FooterItem::span(Span::styled(" NORMAL ", style))); 74 | footer.push_left(FooterItem::span(Span::styled( 75 | " SPACE / [^] ▲ ▼ ◀ ▶ Move ", 76 | style, 77 | ))); 78 | if !sort_str.is_empty() { 79 | footer.push_left(tagged_footer("Sort", style, sort_str).into()); 80 | } 81 | } 82 | 83 | let widget = FooterWidget::new(&footer); 84 | widget.render(area, buf); 85 | } 86 | } 87 | 88 | impl<'a, T, S> Widget for MovableList<'a, T, S> 89 | where 90 | S: SortMethod + EndlessSelf + Default + ToString, 91 | T: MovableListItem<'a>, 92 | MovableListState<'a, T, S>: MovableListManage, 93 | { 94 | fn render(self, area: tui::layout::Rect, buf: &mut tui::buffer::Buffer) { 95 | let num = self.state.items.len(); 96 | 97 | let offset = self.state.offset; 98 | 99 | let block = if offset.hold { 100 | get_focused_block(&self.title) 101 | } else { 102 | get_block(&self.title) 103 | }; 104 | let pad = self.state.padding; 105 | let inner = block.inner(area); 106 | let inner = if pad == 0 { 107 | inner 108 | } else { 109 | Rect { 110 | x: inner.x + pad, 111 | y: inner.y, 112 | width: inner.width.saturating_sub(pad * 2), 113 | height: inner.height, 114 | } 115 | }; 116 | 117 | let height = inner.height as usize; 118 | 119 | // Calculate which portion of the list will be displayed 120 | let y_offset = if offset.y + 1 > num { 121 | num.saturating_sub(1) 122 | } else { 123 | offset.y 124 | }; 125 | 126 | let x_offset = offset.x; 127 | 128 | let index_width = num.to_string().len(); 129 | let index_style = Style::default().fg(Color::DarkGray); 130 | 131 | let x_range = x_offset 132 | ..(x_offset 133 | .saturating_add(inner.width as usize) 134 | .saturating_sub(index_width)); 135 | let with_index = self.state.with_index; 136 | let rev_index = self.state.reverse_index; 137 | 138 | // Get that portion of items 139 | let items = if num != 0 { 140 | self.state 141 | .items 142 | .iter() 143 | .rev() 144 | .skip(y_offset) 145 | .take(height as usize) 146 | .enumerate() 147 | .map(|(i, x)| { 148 | let content = x.to_spans(); 149 | let x_width = content.width(); 150 | let content = spans_window_owned(content, &x_range); 151 | 152 | let mut spans = if x_width != 0 && content.width() == 0 { 153 | Span::raw("◀").into() 154 | } else { 155 | content 156 | }; 157 | 158 | if with_index { 159 | let cur_index = if rev_index { 160 | num - i - y_offset 161 | } else { 162 | i + y_offset + 1 163 | }; 164 | spans.0.insert( 165 | 0, 166 | Span::styled( 167 | format!("{:>width$} ", cur_index, width = index_width), 168 | index_style, 169 | ), 170 | ); 171 | }; 172 | ListItem::new(spans) 173 | }) 174 | .collect::>() 175 | } else { 176 | vec![ListItem::new(Span::raw( 177 | self.state 178 | .placeholder 179 | .to_owned() 180 | .unwrap_or_else(|| "Nothing's here yet".into()), 181 | ))] 182 | }; 183 | 184 | block.render(area, buf); 185 | List::new(items).style(get_text_style()).render(inner, buf); 186 | 187 | self.render_footer(area, buf); 188 | } 189 | } 190 | 191 | // #[test] 192 | // fn test_movable_list() { 193 | // let items = &["Test1", "测试1", "[ABCD] 🇺🇲 测试 符号 194 | // 106"].into_iter().map(|x| x.); assert_eq!() 195 | // } 196 | -------------------------------------------------------------------------------- /clashctl/src/command/server.rs: -------------------------------------------------------------------------------- 1 | use clap::Subcommand; 2 | use log::{debug, info, warn}; 3 | use owo_colors::OwoColorize; 4 | use requestty::{prompt, prompt_one, Answers, Question}; 5 | use terminal_size::{terminal_size, Height, Width}; 6 | use url::Url; 7 | 8 | use crate::{ 9 | interactive::{Flags, Server}, 10 | Result, 11 | }; 12 | 13 | // use crate::Result; 14 | 15 | #[derive(Subcommand, Debug)] 16 | #[clap(about = "Interacting with servers")] 17 | pub enum ServerSubcommand { 18 | #[clap(alias = "a", about = "Add new server (alias a)")] 19 | Add, 20 | #[clap(about = "Select active server")] 21 | Use, 22 | #[clap(alias = "ls", about = "Show current active server")] 23 | List, 24 | #[clap(about = "Remove servers")] 25 | Del, 26 | } 27 | 28 | impl ServerSubcommand { 29 | pub fn handle(&self, flags: &Flags) -> Result<()> { 30 | let mut config = flags.get_config()?; 31 | 32 | match self { 33 | Self::Add => { 34 | let questions = [ 35 | Question::input("url") 36 | .message("URL of Clash API") 37 | .validate(|input, _| match Url::parse(input) { 38 | Ok(_) => Ok(()), 39 | Err(e) => Err(format!("Invalid URL: {}", e)), 40 | }) 41 | .build(), 42 | Question::password("secret") 43 | .message("Secret of Clash API, default to None:") 44 | .build(), 45 | ]; 46 | let mut res = prompt(questions).expect("Error during prompt"); 47 | debug!("{:#?}", res); 48 | let secret = match res.remove("secret").unwrap().try_into_string().unwrap() { 49 | string if string == *"" => None, 50 | secret => Some(secret), 51 | }; 52 | 53 | let url_str = res.remove("url").unwrap().try_into_string().unwrap(); 54 | let url = Url::parse(&url_str).unwrap(); 55 | 56 | let server = Server { 57 | secret, 58 | url: url.clone(), 59 | }; 60 | 61 | info!("Adding {}", server); 62 | 63 | config.servers.push(server); 64 | debug!("{:#?}", config.servers); 65 | config.use_server(url)?; 66 | config.write()?; 67 | } 68 | Self::Use => { 69 | if config.servers.is_empty() { 70 | warn!("No server configured yet. Use `clashctl server add` first."); 71 | return Ok(()); 72 | } 73 | let servers = config.servers.iter().map(|server| server.url.as_str()); 74 | let ans = &prompt_one( 75 | Question::select("server") 76 | .message("Select active server to interact with") 77 | .choices(servers) 78 | .build(), 79 | )?; 80 | let ans_str = &ans.as_list_item().unwrap().text; 81 | config.use_server(Url::parse(ans_str).unwrap())?; 82 | config.write()?; 83 | } 84 | Self::List => { 85 | if config.servers.is_empty() { 86 | warn!("No server configured yet. Use `clashctl server add` first."); 87 | return Ok(()); 88 | } 89 | let (Width(terminal_width), _) = terminal_size().unwrap_or((Width(70), Height(0))); 90 | let active = config.using_server(); 91 | println!("\n{:-<1$}", "", terminal_width as usize); 92 | println!("{:<8}{:<50}", "ACTIVE".green(), "URL"); 93 | println!("{:-<1$}", "", terminal_width as usize); 94 | for server in &config.servers { 95 | let is_active = match active { 96 | Some(active) => server == active, 97 | _ => false, 98 | }; 99 | println!( 100 | "{:^8}{:<50}", 101 | if is_active { "→".green() } else { "".green() }, 102 | server.url.as_str(), 103 | ) 104 | } 105 | println!("{:-<1$}\n", "", terminal_width as usize); 106 | } 107 | Self::Del => { 108 | if config.servers.is_empty() { 109 | warn!("No server configured yet. Use `clashctl server add` first."); 110 | return Ok(()); 111 | } 112 | let servers = config.servers.iter().map(|server| server.url.as_str()); 113 | let ans = &prompt([ 114 | Question::multi_select("server") 115 | .message("Select server(s) to remove") 116 | .choices(servers) 117 | .build(), 118 | Question::confirm("confirm") 119 | .when(|prev: &Answers| { 120 | prev.get("server") 121 | .and_then(|x| x.as_list_items()) 122 | .map(|x| !x.is_empty()) 123 | .unwrap_or(false) 124 | }) 125 | .default(true) 126 | .message(|prev: &Answers| { 127 | format!( 128 | "Confirm to remove {} servers?", 129 | prev.get("server").unwrap().as_list_items().unwrap().len() 130 | ) 131 | }) 132 | .build(), 133 | ])?; 134 | match ( 135 | ans.get("server").and_then(|x| x.as_list_items()), 136 | ans.get("confirm").and_then(|x| x.as_bool()), 137 | ) { 138 | (None, _) => { 139 | warn!("No servers given") 140 | } 141 | (Some(_), None | Some(false)) => { 142 | warn!("Operation cancelled") 143 | } 144 | (Some(servers), Some(true)) => { 145 | info!("Removing {} servers", servers.len()); 146 | let server_names = 147 | servers.iter().map(|x| x.text.clone()).collect::>(); 148 | config 149 | .servers 150 | .retain(|f| !server_names.contains(&f.url.to_string())) 151 | } 152 | } 153 | debug!("{:#?}", config.servers); 154 | config.write()?; 155 | } 156 | } 157 | Ok(()) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /clashctl/src/ui/components/proxy/group.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Debug, marker::PhantomData}; 2 | 3 | use clashctl_core::model::ProxyType; 4 | use tui::{ 5 | style::{Color, Modifier, Style}, 6 | text::{Span, Spans}, 7 | }; 8 | 9 | use crate::ui::{ 10 | components::{Consts, ProxyItem}, 11 | utils::get_text_style, 12 | }; 13 | 14 | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] 15 | pub struct ProxyGroup<'a> { 16 | pub(super) name: String, 17 | pub(super) proxy_type: ProxyType, 18 | pub(super) members: Vec, 19 | pub(super) current: Option, 20 | pub(super) cursor: usize, 21 | pub(super) _life: PhantomData<&'a ()>, 22 | } 23 | 24 | pub enum ProxyGroupFocusStatus { 25 | None, 26 | Focused, 27 | Expanded, 28 | } 29 | 30 | impl<'a> ProxyGroup<'a> { 31 | pub fn proxy_type(&self) -> ProxyType { 32 | self.proxy_type 33 | } 34 | 35 | pub fn members(&self) -> &Vec { 36 | &self.members 37 | } 38 | 39 | pub fn get_summary_widget(&self) -> impl Iterator { 40 | self.members.iter().map(|x| { 41 | if x.proxy_type.is_normal() { 42 | match x.history { 43 | Some(ref history) => Self::get_delay_span(history.delay), 44 | None => Consts::NO_LATENCY_SPAN, 45 | } 46 | } else { 47 | Consts::NOT_PROXY_SPAN 48 | } 49 | }) 50 | } 51 | 52 | pub fn get_widget(&'a self, width: usize, status: ProxyGroupFocusStatus) -> Vec> { 53 | let delimiter = Span::raw(" "); 54 | let prefix = if matches!(status, ProxyGroupFocusStatus::Focused) { 55 | Consts::FOCUSED_INDICATOR_SPAN 56 | } else { 57 | Consts::UNFOCUSED_INDICATOR_SPAN 58 | }; 59 | let name = Span::styled( 60 | &self.name, 61 | Style::default() 62 | .fg(Color::White) 63 | .add_modifier(Modifier::BOLD), 64 | ); 65 | 66 | let proxy_type = Span::styled(self.proxy_type.to_string(), Consts::PROXY_TYPE_STYLE); 67 | 68 | let count = self.members.len(); 69 | let proxy_count = Span::styled( 70 | if matches!(status, ProxyGroupFocusStatus::Expanded) { 71 | format!("{}/{}", self.cursor + 1, count) 72 | } else { 73 | count.to_string() 74 | }, 75 | Style::default().fg(Color::Green), 76 | ); 77 | 78 | let mut ret = Vec::with_capacity(if matches!(status, ProxyGroupFocusStatus::Expanded) { 79 | self.members.len() + 1 80 | } else { 81 | 2 82 | }); 83 | 84 | ret.push(Spans::from(vec![ 85 | prefix.clone(), 86 | name, 87 | delimiter.clone(), 88 | proxy_type, 89 | delimiter, 90 | proxy_count, 91 | ])); 92 | 93 | if matches!(status, ProxyGroupFocusStatus::Expanded) { 94 | let skipped = self.cursor.saturating_sub(4); 95 | let text_style = get_text_style(); 96 | let is_current = 97 | |index: usize| self.current.map(|x| x == index + skipped).unwrap_or(false); 98 | let is_pointed = |index: usize| self.cursor == index + skipped; 99 | 100 | let lines = self.members.iter().skip(skipped).enumerate().map(|(i, x)| { 101 | let prefix = if self.cursor == i + skipped { 102 | Consts::EXPANDED_FOCUSED_INDICATOR_SPAN 103 | } else { 104 | Consts::EXPANDED_INDICATOR_SPAN 105 | }; 106 | let name = Span::styled( 107 | &x.name, 108 | if is_current(i) { 109 | Style::default() 110 | .fg(Color::Blue) 111 | .add_modifier(Modifier::BOLD) 112 | } else if is_pointed(i) { 113 | text_style.fg(Color::LightBlue) 114 | } else { 115 | text_style 116 | }, 117 | ); 118 | let proxy_type = Span::styled(x.proxy_type.to_string(), Consts::PROXY_TYPE_STYLE); 119 | 120 | let delay_span = x 121 | .history 122 | .as_ref() 123 | .map(|x| { 124 | if x.delay > 0 { 125 | let style = Self::get_delay_style(x.delay); 126 | Span::styled(x.delay.to_string(), style) 127 | } else { 128 | Span::styled(Consts::NO_LATENCY_SIGN, Consts::NO_LATENCY_STYLE) 129 | } 130 | }) 131 | .unwrap_or_else(|| { 132 | if !x.proxy_type.is_normal() { 133 | Span::raw("") 134 | } else { 135 | Span::styled(Consts::NO_LATENCY_SIGN, Consts::NO_LATENCY_STYLE) 136 | } 137 | }); 138 | vec![ 139 | prefix, 140 | Consts::DELIMITER_SPAN.clone(), 141 | name, 142 | Consts::DELIMITER_SPAN.clone(), 143 | proxy_type, 144 | Consts::DELIMITER_SPAN.clone(), 145 | delay_span, 146 | ] 147 | .into() 148 | }); 149 | ret.extend(lines); 150 | } else { 151 | ret.extend( 152 | self.get_summary_widget() 153 | .collect::>() 154 | .chunks( 155 | width 156 | .saturating_sub(Consts::FOCUSED_INDICATOR_SPAN.width() + 2) 157 | .saturating_div(2), 158 | ) 159 | .map(|x| { 160 | std::iter::once(if matches!(status, ProxyGroupFocusStatus::Focused) { 161 | Consts::FOCUSED_INDICATOR_SPAN 162 | } else { 163 | Consts::UNFOCUSED_INDICATOR_SPAN 164 | }) 165 | .chain(x.to_owned()) 166 | .collect::>() 167 | .into() 168 | }), 169 | ) 170 | } 171 | 172 | ret 173 | } 174 | 175 | fn get_delay_style(delay: u64) -> Style { 176 | match delay { 177 | 0 => Consts::NO_LATENCY_STYLE, 178 | 1..=200 => Consts::LOW_LATENCY_STYLE, 179 | 201..=400 => Consts::MID_LATENCY_STYLE, 180 | 401.. => Consts::HIGH_LATENCY_STYLE, 181 | } 182 | } 183 | 184 | fn get_delay_span(delay: u64) -> Span<'static> { 185 | match delay { 186 | 0 => Consts::NO_LATENCY_SPAN, 187 | 1..=200 => Consts::LOW_LATENCY_SPAN, 188 | 201..=400 => Consts::MID_LATENCY_SPAN, 189 | 401.. => Consts::HIGH_LATENCY_SPAN, 190 | } 191 | } 192 | } 193 | 194 | impl<'a> Default for ProxyGroup<'a> { 195 | fn default() -> Self { 196 | Self { 197 | members: vec![], 198 | current: None, 199 | proxy_type: ProxyType::Selector, 200 | name: String::new(), 201 | cursor: 0, 202 | _life: PhantomData, 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /clashctl/src/ui/components/block_footer.rs: -------------------------------------------------------------------------------- 1 | use std::marker::PhantomData; 2 | 3 | use tui::{ 4 | layout::Rect, 5 | text::{Span, Spans}, 6 | widgets::Widget, 7 | }; 8 | 9 | use crate::ui::Wrap; 10 | 11 | #[derive(Debug, Clone, PartialEq)] 12 | pub struct Footer<'a> { 13 | left_offset: u16, 14 | right_offset: u16, 15 | left: Vec>, 16 | right: Vec>, 17 | _life: PhantomData<&'a ()>, 18 | } 19 | 20 | impl<'a> Default for Footer<'a> { 21 | fn default() -> Self { 22 | Self { 23 | left_offset: 2, 24 | right_offset: 2, 25 | left: Default::default(), 26 | right: Default::default(), 27 | _life: Default::default(), 28 | } 29 | } 30 | } 31 | 32 | impl<'a> Footer<'a> { 33 | pub fn show(&mut self) { 34 | self.items_mut().for_each(FooterItem::show); 35 | } 36 | 37 | pub fn hide(&mut self) { 38 | self.items_mut().for_each(FooterItem::hide); 39 | } 40 | 41 | pub fn items(&self) -> impl Iterator> { 42 | self.left.iter().chain(self.right.iter()) 43 | } 44 | 45 | pub fn items_mut(&mut self) -> impl Iterator> { 46 | self.left.iter_mut().chain(self.right.iter_mut()) 47 | } 48 | 49 | pub fn left_offset(&mut self, offset: u16) -> &mut Self { 50 | self.left_offset = offset; 51 | self 52 | } 53 | 54 | pub fn right_offset(&mut self, offset: u16) -> &mut Self { 55 | self.right_offset = offset; 56 | self 57 | } 58 | 59 | pub fn push_left(&mut self, item: FooterItem<'a>) -> &mut Self { 60 | self.left.push(item); 61 | self 62 | } 63 | 64 | pub fn push_right(&mut self, item: FooterItem<'a>) -> &mut Self { 65 | self.right.push(item); 66 | self 67 | } 68 | 69 | pub fn append_left(&mut self, item: &mut Vec>) -> &mut Self { 70 | self.left.append(item); 71 | self 72 | } 73 | 74 | pub fn append_right(&mut self, item: &mut Vec>) -> &mut Self { 75 | self.right.append(item); 76 | self 77 | } 78 | 79 | pub fn pop_left(&mut self) -> Option> { 80 | self.left.pop() 81 | } 82 | 83 | pub fn pop_right(&mut self) -> Option> { 84 | self.right.pop() 85 | } 86 | } 87 | 88 | #[derive(Debug, Clone)] 89 | pub struct FooterWidget<'a> { 90 | state: &'a Footer<'a>, 91 | } 92 | 93 | impl<'a> FooterWidget<'a> { 94 | pub fn render_one(&mut self, item: Spans, area: Rect, buf: &mut tui::buffer::Buffer) { 95 | buf.set_spans(area.x, area.y, &item, item.width() as u16); 96 | } 97 | 98 | pub fn new(state: &'a Footer) -> Self { 99 | Self { state } 100 | } 101 | } 102 | 103 | impl<'a> Widget for FooterWidget<'a> { 104 | fn render(self, area: Rect, buf: &mut tui::buffer::Buffer) { 105 | let y = area.y + area.height - 1; 106 | let (mut left, mut right) = (self.state.left.iter(), self.state.right.iter()); 107 | let (mut left_x, mut right_x) = ( 108 | area.x.saturating_add(self.state.left_offset), 109 | area.x 110 | .saturating_add(area.width + 1) 111 | .saturating_sub(self.state.right_offset), 112 | ); 113 | loop { 114 | let mut changed = false; 115 | if let Some(spans) = left.next() { 116 | if spans.show { 117 | let spans = spans.to_spans(); 118 | let width = spans.width() as u16; 119 | if right_x.saturating_sub(left_x) <= width { 120 | break; 121 | } 122 | buf.set_spans(left_x, y, &spans, width); 123 | left_x += width + 1; 124 | 125 | changed = true; 126 | } 127 | } 128 | 129 | if let Some(spans) = right.next() { 130 | if spans.show { 131 | let spans = spans.to_spans(); 132 | let width = spans.width() as u16; 133 | if right_x.saturating_sub(left_x) <= width { 134 | break; 135 | } 136 | right_x = right_x.saturating_sub(width + 1); 137 | buf.set_spans(right_x, y, &spans, width); 138 | changed = true; 139 | } 140 | } 141 | if !changed { 142 | break; 143 | } 144 | } 145 | } 146 | } 147 | 148 | #[derive(Debug, Clone, PartialEq)] 149 | pub struct FooterItem<'a> { 150 | inner: FooterItemInner<'a>, 151 | show: bool, 152 | } 153 | 154 | impl<'a> FooterItem<'a> { 155 | pub fn to_spans(&self) -> Spans<'a> { 156 | match self.inner { 157 | FooterItemInner::Raw(ref raw) => Spans::from(raw.to_string()), 158 | FooterItemInner::Span(ref span) => span.to_owned().into(), 159 | FooterItemInner::Spans(ref spans) => spans.to_owned(), 160 | } 161 | } 162 | 163 | pub fn raw(content: String) -> Self { 164 | Self { 165 | inner: FooterItemInner::Raw(content), 166 | show: true, 167 | } 168 | } 169 | 170 | pub fn span(content: Span<'a>) -> Self { 171 | Self { 172 | inner: FooterItemInner::Span(content), 173 | show: true, 174 | } 175 | } 176 | 177 | pub fn spans(content: Spans<'a>) -> Self { 178 | Self { 179 | inner: FooterItemInner::Spans(content), 180 | show: true, 181 | } 182 | } 183 | 184 | pub fn set_show(&mut self, show: bool) { 185 | self.show = show 186 | } 187 | 188 | pub fn show(&mut self) { 189 | self.set_show(true) 190 | } 191 | 192 | pub fn hide(&mut self) { 193 | self.set_show(false) 194 | } 195 | } 196 | 197 | impl<'a> From> for FooterItem<'a> { 198 | fn from(val: Span<'a>) -> Self { 199 | Self::span(val) 200 | } 201 | } 202 | 203 | impl<'a> From> for FooterItem<'a> { 204 | fn from(val: Spans<'a>) -> Self { 205 | Self::spans(val) 206 | } 207 | } 208 | 209 | impl<'a> From for FooterItem<'a> { 210 | fn from(val: String) -> Self { 211 | Self::raw(val) 212 | } 213 | } 214 | 215 | #[derive(Debug, Clone, PartialEq)] 216 | enum FooterItemInner<'a> { 217 | Raw(String), 218 | Span(Span<'a>), 219 | Spans(Spans<'a>), 220 | } 221 | 222 | impl<'a> From> for Spans<'a> { 223 | fn from(val: FooterItemInner<'a>) -> Self { 224 | match val { 225 | FooterItemInner::Raw(raw) => raw.into(), 226 | FooterItemInner::Span(span) => span.into(), 227 | FooterItemInner::Spans(spans) => spans, 228 | } 229 | } 230 | } 231 | 232 | impl<'a> From> for Spans<'a> { 233 | fn from(val: FooterItem<'a>) -> Self { 234 | val.inner.into() 235 | } 236 | } 237 | 238 | impl<'a> Wrap for FooterItem<'a> { 239 | fn wrap_by(mut self, char: char) -> Self { 240 | match self.inner { 241 | FooterItemInner::Raw(ref mut raw) => { 242 | raw.wrap_by(char); 243 | } 244 | FooterItemInner::Span(ref mut span) => { 245 | span.wrap_by(char); 246 | } 247 | FooterItemInner::Spans(ref mut spans) => { 248 | spans.wrap_by(char); 249 | } 250 | }; 251 | self 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /clashctl/src/ui/state.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, time::Instant}; 2 | 3 | use clashctl_core::model::{ConnectionWithSpeed, Log, Rule, Traffic, Version}; 4 | use smart_default::SmartDefault; 5 | 6 | use crate::{ 7 | interactive::{Noop, RuleSort}, 8 | ui::{ 9 | components::{MovableListManage, MovableListManager, MovableListState, ProxyTree}, 10 | TuiResult, 11 | }, 12 | Action, ConfigState, Event, InputEvent, UpdateEvent, 13 | }; 14 | 15 | pub(crate) type LogListState<'a> = MovableListState<'a, Log, Noop>; 16 | pub(crate) type ConListState<'a> = MovableListState<'a, ConnectionWithSpeed, Noop>; 17 | pub(crate) type RuleListState<'a> = MovableListState<'a, Rule, RuleSort>; 18 | pub(crate) type DebugListState<'a> = MovableListState<'a, Event, Noop>; 19 | 20 | #[derive(Debug, Clone, SmartDefault)] 21 | pub struct TuiStates<'a> { 22 | pub should_quit: bool, 23 | #[default(_code = "Instant::now()")] 24 | pub start_time: Instant, 25 | pub version: Option, 26 | pub traffics: Vec, 27 | pub max_traffic: Traffic, 28 | pub all_events_recv: usize, 29 | pub page_index: u8, 30 | pub show_debug: bool, 31 | pub proxy_tree: ProxyTree<'a>, 32 | pub rule_freq: HashMap, 33 | // (upload_size, download_size) 34 | pub con_size: (u64, u64), 35 | 36 | #[default(_code = "{ 37 | let mut ret = MovableListState::default(); 38 | ret.with_index().dsc_index(); 39 | ret 40 | }")] 41 | pub log_state: LogListState<'a>, 42 | pub con_state: ConListState<'a>, 43 | pub rule_state: RuleListState<'a>, 44 | pub debug_state: DebugListState<'a>, 45 | pub config_state: ConfigState, 46 | } 47 | 48 | // TODO fix: drop_events not working 49 | impl<'a> TuiStates<'a> { 50 | pub const TITLES: &'static [&'static str] = &[ 51 | "Status", "Proxies", "Rules", "Conns", "Logs", "Configs", "Debug", 52 | ]; 53 | 54 | pub fn handle(&mut self, event: Event) -> TuiResult> { 55 | self.all_events_recv += 1; 56 | if self.debug_state.len() >= 300 { 57 | let _ = self.drop_events(100); 58 | } 59 | self.debug_state.push(event.to_owned()); 60 | 61 | match event { 62 | Event::Quit => { 63 | self.should_quit = true; 64 | Ok(None) 65 | } 66 | Event::Input(event) => self.handle_input(event), 67 | Event::Update(update) => self.handle_update(update), 68 | _ => Ok(None), 69 | } 70 | } 71 | 72 | #[inline] 73 | pub fn page_len(&mut self) -> usize { 74 | if self.show_debug { 75 | Self::TITLES.len() 76 | } else { 77 | Self::TITLES.len() - 1 78 | } 79 | } 80 | 81 | #[inline] 82 | pub fn title(&self) -> &str { 83 | Self::TITLES[self.page_index as usize] 84 | } 85 | 86 | fn active_list<'own>(&'own mut self) -> Option> { 87 | match self.title() { 88 | "Rules" => Some(MovableListManager::Rule(&mut self.rule_state)), 89 | "Debug" => Some(MovableListManager::Event(&mut self.debug_state)), 90 | "Logs" => Some(MovableListManager::Log(&mut self.log_state)), 91 | "Conns" => Some(MovableListManager::Connection(&mut self.con_state)), 92 | "Proxies" => Some(MovableListManager::Proxy(&mut self.proxy_tree)), 93 | _ => None, 94 | } 95 | } 96 | 97 | fn handle_update(&mut self, update: UpdateEvent) -> TuiResult> { 98 | match update { 99 | UpdateEvent::Config(config) => self.config_state.update_clash(config), 100 | UpdateEvent::Connection(connection) => { 101 | self.con_size = (connection.upload_total, connection.download_total); 102 | self.con_state.sorted_merge(connection.connections); 103 | self.con_state.with_index(); 104 | } 105 | UpdateEvent::Version(version) => self.version = Some(version), 106 | UpdateEvent::Traffic(traffic) => { 107 | let Traffic { up, down } = traffic; 108 | self.max_traffic.up = self.max_traffic.up.max(up); 109 | self.max_traffic.down = self.max_traffic.down.max(down); 110 | self.traffics.push(traffic) 111 | } 112 | UpdateEvent::Proxies(proxies) => { 113 | let mut new_tree = Into::::into(proxies); 114 | new_tree.sort_groups_with_frequency(&self.rule_freq); 115 | self.proxy_tree.replace_with(new_tree); 116 | } 117 | UpdateEvent::Log(log) => self.log_state.push(log), 118 | UpdateEvent::Rules(rules) => { 119 | self.rule_freq = rules.owned_frequency(); 120 | self.rule_state.sorted_merge(rules.rules); 121 | } 122 | UpdateEvent::ProxyTestLatencyDone => { 123 | self.proxy_tree.end_testing(); 124 | } 125 | } 126 | Ok(None) 127 | } 128 | 129 | fn handle_input(&mut self, event: InputEvent) -> TuiResult> { 130 | match event { 131 | InputEvent::TabGoto(index) => { 132 | if index >= 1 && index <= self.page_len() as u8 { 133 | self.page_index = index - 1 134 | } 135 | } 136 | InputEvent::ToggleDebug => { 137 | self.show_debug = !self.show_debug; 138 | // On the debug page 139 | if self.page_index == Self::TITLES.len() as u8 - 1 { 140 | self.page_index -= 1; 141 | } else if self.show_debug { 142 | self.page_index = self.debug_page_index() 143 | } 144 | } 145 | InputEvent::Esc => { 146 | if let Some(mut list) = self.active_list() { 147 | list.end(); 148 | } 149 | } 150 | InputEvent::ToggleHold => { 151 | if let Some(mut list) = self.active_list() { 152 | list.toggle(); 153 | } 154 | } 155 | InputEvent::List(list_event) => { 156 | if let Some(mut list) = self.active_list() { 157 | return Ok(list.handle(list_event)); 158 | } 159 | } 160 | InputEvent::TestLatency => { 161 | if self.title() == "Proxies" && !self.proxy_tree.is_testing() { 162 | self.proxy_tree.start_testing(); 163 | let group = self.proxy_tree.current_group(); 164 | let proxies = group 165 | .members() 166 | .iter() 167 | .filter(|x| x.proxy_type().is_normal()) 168 | .map(|x| x.name().into()) 169 | .collect(); 170 | return Ok(Some(Action::TestLatency { proxies })); 171 | } 172 | } 173 | InputEvent::NextSort => { 174 | if let Some(mut list) = self.active_list() { 175 | list.next_sort(); 176 | } 177 | } 178 | InputEvent::PrevSort => { 179 | if let Some(mut list) = self.active_list() { 180 | list.prev_sort(); 181 | } 182 | } 183 | InputEvent::Other(_) => {} // InterfaceEvent::Other(event) => self.handle_list(event), 184 | } 185 | Ok(None) 186 | } 187 | 188 | pub const fn debug_page_index(&self) -> u8 { 189 | Self::TITLES.len() as u8 - 1 190 | } 191 | 192 | fn drop_events(&mut self, num: usize) -> impl Iterator + '_ { 193 | self.debug_state.drain(..num) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /clashctl-core/src/api.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io::{BufRead, BufReader, Read}, 3 | marker::PhantomData, 4 | time::Duration, 5 | }; 6 | 7 | use log::{debug, trace}; 8 | use serde::de::DeserializeOwned; 9 | use serde_json::{from_str, json}; 10 | use ureq::{Agent, Request}; 11 | use url::Url; 12 | 13 | use crate::{ 14 | model::{Config, Connections, Delay, Log, Proxies, Proxy, Rules, Traffic, Version}, 15 | Error, Result, 16 | }; 17 | 18 | trait Convert { 19 | fn convert(self) -> Result; 20 | } 21 | 22 | impl Convert for String { 23 | fn convert(self) -> Result { 24 | from_str(&self).map_err(Into::into) 25 | } 26 | } 27 | 28 | #[derive(Debug, Clone)] 29 | pub struct ClashBuilder { 30 | url: Url, 31 | secret: Option, 32 | timeout: Option, 33 | } 34 | 35 | impl ClashBuilder { 36 | pub fn new>(url: S) -> Result { 37 | let mut url_str = url.into(); 38 | // Handle trailling slash 39 | if !url_str.ends_with('/') { 40 | url_str += "/"; 41 | }; 42 | let url = Url::parse(&url_str).map_err(|_| Error::url_parse())?; 43 | Ok(Self { 44 | url, 45 | secret: None, 46 | timeout: None, 47 | }) 48 | } 49 | 50 | pub fn secret(mut self, secret: Option) -> Self { 51 | self.secret = secret; 52 | self 53 | } 54 | 55 | pub fn timeout(mut self, timeout: Option) -> Self { 56 | self.timeout = timeout; 57 | self 58 | } 59 | 60 | pub fn build(self) -> Clash { 61 | let mut clash = Clash::new(self.url); 62 | clash.secret = self.secret; 63 | clash.timeout = self.timeout; 64 | clash 65 | } 66 | } 67 | 68 | /// # Clash API 69 | /// 70 | /// Use struct `Clash` for interacting with Clash RESTful API. 71 | /// For more information, check , 72 | /// or maybe just read source code of clash 73 | #[derive(Debug, Clone)] 74 | pub struct Clash { 75 | url: Url, 76 | secret: Option, 77 | timeout: Option, 78 | agent: Agent, 79 | } 80 | 81 | impl Clash { 82 | pub fn builder>(url: S) -> Result { 83 | ClashBuilder::new(url) 84 | } 85 | 86 | pub fn new(url: Url) -> Self { 87 | debug!("Url of clash RESTful API: {}", url); 88 | Self { 89 | url, 90 | secret: None, 91 | timeout: None, 92 | agent: Agent::new(), 93 | } 94 | } 95 | 96 | fn build_request(&self, endpoint: &str, method: &str) -> Result { 97 | let url = self.url.join(endpoint).map_err(|_| Error::url_parse())?; 98 | let mut req = self.agent.request_url(method, &url); 99 | 100 | if let Some(timeout) = self.timeout { 101 | req = req.timeout(timeout) 102 | } 103 | 104 | if let Some(ref secret) = self.secret { 105 | req = req.set("Authorization", &format!("Bearer {}", secret)) 106 | } 107 | 108 | Ok(req) 109 | } 110 | 111 | fn build_request_without_timeout(&self, endpoint: &str, method: &str) -> Result { 112 | let url = self.url.join(endpoint).map_err(|_| Error::url_parse())?; 113 | let mut req = self.agent.request_url(method, &url); 114 | 115 | if let Some(ref secret) = self.secret { 116 | req = req.set("Authorization", &format!("Bearer {}", secret)) 117 | } 118 | 119 | Ok(req) 120 | } 121 | 122 | /// Send a oneshot request to the specific endpoint with method, with body 123 | pub fn oneshot_req_with_body( 124 | &self, 125 | endpoint: &str, 126 | method: &str, 127 | body: Option, 128 | ) -> Result { 129 | trace!("Body: {:#?}", body); 130 | let resp = if let Some(body) = body { 131 | self.build_request(endpoint, method)?.send_string(&body)? 132 | } else { 133 | self.build_request(endpoint, method)?.call()? 134 | }; 135 | 136 | if resp.status() >= 400 { 137 | return Err(Error::failed_response(resp.status())); 138 | } 139 | 140 | let text = resp 141 | .into_string() 142 | .map_err(|_| Error::bad_response_encoding())?; 143 | trace!("Received response: {}", text); 144 | 145 | Ok(text) 146 | } 147 | 148 | /// Send a oneshot request to the specific endpoint with method, without 149 | /// body 150 | pub fn oneshot_req(&self, endpoint: &str, method: &str) -> Result { 151 | self.oneshot_req_with_body(endpoint, method, None) 152 | } 153 | 154 | /// Send a longhaul request to the specific endpoint with method, 155 | /// Underlying is an http stream with chunked-encoding. 156 | /// 157 | /// Use [`LongHaul::next_item`], [`LongHaul::next_raw`] or 158 | /// [`Iterator::next`] to retreive data 159 | /// 160 | /// # Examplel 161 | /// 162 | /// ```rust 163 | /// # use clashctl_core::{ Clash, model::Traffic }; use std::env; 164 | /// # fn main() { 165 | /// # let clash = Clash::builder(env::var("PROXY_ADDR").unwrap()).unwrap().build(); 166 | /// let traffics = clash 167 | /// .longhaul_req::("traffic", "GET") 168 | /// .expect("connect failed"); 169 | /// 170 | /// // LongHaul implements `Iterator` so you can use iterator combinators 171 | /// for traffic in traffics.take(3) { 172 | /// println!("{:#?}", traffic) 173 | /// } 174 | /// # } 175 | /// ``` 176 | pub fn longhaul_req( 177 | &self, 178 | endpoint: &str, 179 | method: &str, 180 | ) -> Result> { 181 | let resp = self 182 | .build_request_without_timeout(endpoint, method)? 183 | .call()?; 184 | 185 | if resp.status() >= 400 { 186 | return Err(Error::failed_response(resp.status())); 187 | } 188 | 189 | Ok(LongHaul::new(Box::new(resp.into_reader()))) 190 | } 191 | 192 | /// Helper function for method `GET` 193 | pub fn get(&self, endpoint: &str) -> Result { 194 | self.oneshot_req(endpoint, "GET").and_then(Convert::convert) 195 | } 196 | 197 | /// Helper function for method `DELETE` 198 | pub fn delete(&self, endpoint: &str) -> Result<()> { 199 | self.oneshot_req(endpoint, "DELETE").map(|_| ()) 200 | } 201 | 202 | /// Helper function for method `PUT` 203 | pub fn put(&self, endpoint: &str, body: Option) -> Result { 204 | self.oneshot_req_with_body(endpoint, "PUT", body) 205 | .and_then(Convert::convert) 206 | } 207 | 208 | /// Get clash version 209 | pub fn get_version(&self) -> Result { 210 | self.get("version") 211 | } 212 | 213 | /// Get base configs 214 | pub fn get_configs(&self) -> Result { 215 | self.get("configs") 216 | } 217 | 218 | /// Reloading base configs. 219 | /// 220 | /// - `force`: will change ports etc., 221 | /// - `path`: the absolute path to config file 222 | /// 223 | /// This will **NOT** affect `external-controller` & `secret` 224 | pub fn reload_configs(&self, force: bool, path: &str) -> Result<()> { 225 | let body = json!({ "path": path }).to_string(); 226 | debug!("{}", body); 227 | self.put::(if force { "configs?force" } else { "configs" }, Some(body)) 228 | .map(|_| ()) 229 | } 230 | 231 | /// Get proxies information 232 | pub fn get_proxies(&self) -> Result { 233 | self.get("proxies") 234 | } 235 | 236 | /// Get rules information 237 | pub fn get_rules(&self) -> Result { 238 | self.get("rules") 239 | } 240 | 241 | /// Get specific proxy information 242 | pub fn get_proxy(&self, proxy: &str) -> Result { 243 | self.get(&format!("proxies/{}", proxy)) 244 | } 245 | 246 | /// Get connections information 247 | pub fn get_connections(&self) -> Result { 248 | self.get("connections") 249 | } 250 | 251 | /// Close all connections 252 | pub fn close_connections(&self) -> Result<()> { 253 | self.delete("connections") 254 | } 255 | 256 | /// Close specific connection 257 | pub fn close_one_connection(&self, id: &str) -> Result<()> { 258 | self.delete(&format!("connections/{}", id)) 259 | } 260 | 261 | /// Get real-time traffic data 262 | /// 263 | /// **Note**: This is a longhaul request, which will last forever until 264 | /// interrupted or disconnected. 265 | /// 266 | /// See [`longhaul_req`] for more information 267 | /// 268 | /// [`longhaul_req`]: Clash::longhaul_req 269 | pub fn get_traffic(&self) -> Result> { 270 | self.longhaul_req("traffic", "GET") 271 | } 272 | 273 | /// Get real-time logs 274 | /// 275 | /// **Note**: This is a longhaul request, which will last forever until 276 | /// interrupted or disconnected. 277 | /// 278 | /// See [`longhaul_req`] for more information 279 | /// 280 | /// [`longhaul_req`]: Clash::longhaul_req 281 | pub fn get_log(&self) -> Result> { 282 | self.longhaul_req("logs", "GET") 283 | } 284 | 285 | /// Get specific proxy delay test information 286 | pub fn get_proxy_delay(&self, proxy: &str, test_url: &str, timeout: u64) -> Result { 287 | use urlencoding::encode as e; 288 | let (proxy, test_url) = (e(proxy), e(test_url)); 289 | self.get(&format!( 290 | "proxies/{}/delay?url={}&timeout={}", 291 | proxy, test_url, timeout 292 | )) 293 | } 294 | 295 | /// Select specific proxy 296 | pub fn set_proxygroup_selected(&self, group: &str, proxy: &str) -> Result<()> { 297 | let body = format!("{{\"name\":\"{}\"}}", proxy); 298 | self.oneshot_req_with_body(&format!("proxies/{}", group), "PUT", Some(body))?; 299 | Ok(()) 300 | } 301 | } 302 | 303 | pub struct LongHaul { 304 | reader: BufReader>, 305 | ty: PhantomData, 306 | } 307 | 308 | impl LongHaul { 309 | pub fn new(reader: Box) -> Self { 310 | Self { 311 | reader: BufReader::new(reader), 312 | ty: PhantomData, 313 | } 314 | } 315 | 316 | pub fn next_item(&mut self) -> Option> { 317 | Some(self.next_raw()?.and_then(Convert::convert)) 318 | } 319 | 320 | pub fn next_raw(&mut self) -> Option> { 321 | let mut buf = String::with_capacity(30); 322 | match self.reader.read_line(&mut buf) { 323 | Ok(0) => None, 324 | Ok(_) => Some(Ok(buf)), 325 | Err(e) => Some(Err(Error::other(format!("{:}", e)))), 326 | } 327 | } 328 | } 329 | 330 | impl Iterator for LongHaul { 331 | type Item = Result; 332 | 333 | fn next(&mut self) -> Option { 334 | self.next_item() 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /clashctl/src/ui/components/movable_list/state.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | borrow::Cow, 3 | fmt::Debug, 4 | ops::{Deref, DerefMut}, 5 | }; 6 | 7 | use crossterm::event::KeyCode; 8 | use match_any::match_any; 9 | use paste::paste; 10 | use smart_default::SmartDefault; 11 | 12 | use crate::{ 13 | interactive::{EndlessSelf, SortMethod, Sortable}, 14 | ui::{ 15 | components::{MovableListItem, ProxyTree}, 16 | utils::Coord, 17 | }, 18 | Action, ConListState, DebugListState, ListEvent, LogListState, RuleListState, 19 | }; 20 | 21 | macro_rules! impl_setter { 22 | ($prop:ident, $ty:ty) => { 23 | paste! { 24 | pub fn [](&mut self, $prop: $ty) -> &mut Self { 25 | self.$prop = $prop; 26 | self 27 | } 28 | } 29 | }; 30 | ($prop:ident, $val:expr) => { 31 | pub fn $prop(&mut self) -> &mut Self { 32 | self.$prop = $val; 33 | self 34 | } 35 | }; 36 | ($fn_name:ident, $prop:ident, $val:expr) => { 37 | pub fn $fn_name(&mut self) -> &mut Self { 38 | self.$prop = $val; 39 | self 40 | } 41 | }; 42 | } 43 | 44 | impl<'a, T, S> MovableListState<'a, T, S> 45 | where 46 | T: MovableListItem<'a>, 47 | S: SortMethod + EndlessSelf + Default, 48 | { 49 | impl_setter!(with_index, true); 50 | 51 | impl_setter!(without_index, with_index, false); 52 | 53 | impl_setter!(asc_index, reverse_index, false); 54 | 55 | impl_setter!(dsc_index, reverse_index, true); 56 | 57 | impl_setter!(items, Vec); 58 | 59 | impl_setter!(padding, u16); 60 | 61 | pub fn new(items: Vec) -> Self 62 | where 63 | T: MovableListItem<'a>, 64 | { 65 | Self { 66 | items, 67 | ..Default::default() 68 | } 69 | } 70 | 71 | pub fn new_with_sort(mut items: Vec, sort: S) -> Self 72 | where 73 | T: MovableListItem<'a>, 74 | { 75 | items.sort_by(|a, b| sort.sort_fn(a, b)); 76 | 77 | Self { 78 | items, 79 | sort, 80 | ..Default::default() 81 | } 82 | } 83 | 84 | pub fn placeholder>>(&mut self, content: P) -> &mut Self { 85 | self.placeholder = Some(content.into()); 86 | self 87 | } 88 | 89 | pub fn sorted_merge(&mut self, other: Vec) { 90 | self.items = other; 91 | self.sort(); 92 | } 93 | 94 | pub fn push(&mut self, item: T) { 95 | self.items.push(item); 96 | if self.offset.hold { 97 | self.offset.y += 1; 98 | } 99 | } 100 | } 101 | 102 | // TODO: Use lazy updated footer 103 | #[derive(Debug, Clone, PartialEq, Eq, SmartDefault)] 104 | pub struct MovableListState<'a, T: MovableListItem<'a>, S: Default> { 105 | pub(super) offset: Coord, 106 | pub(super) items: Vec, 107 | pub(super) placeholder: Option>, 108 | #[default = 1] 109 | pub(super) padding: u16, 110 | pub(super) sort: S, 111 | pub(super) with_index: bool, 112 | pub(super) reverse_index: bool, 113 | } 114 | 115 | impl<'a, T, S> Deref for MovableListState<'a, T, S> 116 | where 117 | T: MovableListItem<'a>, 118 | S: Default, 119 | { 120 | type Target = Vec; 121 | 122 | fn deref(&self) -> &Self::Target { 123 | &self.items 124 | } 125 | } 126 | 127 | impl<'a, T, S> DerefMut for MovableListState<'a, T, S> 128 | where 129 | T: MovableListItem<'a>, 130 | S: Default, 131 | { 132 | fn deref_mut(&mut self) -> &mut Self::Target { 133 | &mut self.items 134 | } 135 | } 136 | 137 | impl<'a, T, S> Extend for MovableListState<'a, T, S> 138 | where 139 | T: MovableListItem<'a>, 140 | S: Default, 141 | { 142 | fn extend>(&mut self, iter: I) { 143 | self.items.extend(iter) 144 | } 145 | } 146 | 147 | impl<'a, T, S> Sortable<'a, S> for MovableListState<'a, T, S> 148 | where 149 | T: MovableListItem<'a>, 150 | S: SortMethod + Default, 151 | { 152 | type Item<'b> = T; 153 | 154 | fn sort_with(&mut self, method: &S) { 155 | self.items.sort_by(|a, b| method.sort_fn(a, b)) 156 | } 157 | } 158 | 159 | pub trait MovableListManage { 160 | fn sort(&mut self) -> &mut Self; 161 | 162 | fn next_sort(&mut self) -> &mut Self; 163 | 164 | fn prev_sort(&mut self) -> &mut Self; 165 | 166 | fn current_pos(&self) -> Coord; 167 | 168 | fn len(&self) -> usize; 169 | 170 | fn is_empty(&self) -> bool; 171 | fn toggle(&mut self) -> &mut Self; 172 | 173 | fn end(&mut self) -> &mut Self; 174 | 175 | fn hold(&mut self) -> &mut Self; 176 | 177 | fn handle(&mut self, event: ListEvent) -> Option; 178 | fn offset(&self) -> &Coord; 179 | } 180 | 181 | impl<'a, T, S> MovableListManage for MovableListState<'a, T, S> 182 | where 183 | T: MovableListItem<'a>, 184 | S: SortMethod + EndlessSelf + Default, 185 | { 186 | fn sort(&mut self) -> &mut Self { 187 | let sort = &self.sort; 188 | self.items.sort_with(sort); 189 | self 190 | } 191 | 192 | fn next_sort(&mut self) -> &mut Self { 193 | self.sort.next_self(); 194 | let sort = &self.sort; 195 | self.items.sort_with(sort); 196 | self 197 | } 198 | 199 | fn prev_sort(&mut self) -> &mut Self { 200 | self.sort.prev_self(); 201 | let sort = &self.sort; 202 | self.items.sort_with(sort); 203 | self 204 | } 205 | 206 | fn current_pos(&self) -> Coord { 207 | let x = self.offset.x; 208 | let y = self.len().saturating_sub(self.offset.y); 209 | Coord { 210 | x, 211 | y, 212 | hold: self.offset.hold, 213 | } 214 | } 215 | 216 | fn len(&self) -> usize { 217 | self.items.len() 218 | } 219 | 220 | fn is_empty(&self) -> bool { 221 | self.len() == 0 222 | } 223 | 224 | fn toggle(&mut self) -> &mut Self { 225 | self.offset.toggle(); 226 | self 227 | } 228 | 229 | fn end(&mut self) -> &mut Self { 230 | self.offset.end(); 231 | self 232 | } 233 | 234 | fn hold(&mut self) -> &mut Self { 235 | self.offset.hold(); 236 | self 237 | } 238 | 239 | fn handle(&mut self, event: ListEvent) -> Option { 240 | let len = self.len().saturating_sub(1); 241 | let offset = &mut self.offset; 242 | 243 | if !offset.hold { 244 | offset.hold = true; 245 | } 246 | 247 | match (event.fast, event.code) { 248 | (true, KeyCode::Left) => offset.x = offset.x.saturating_sub(7), 249 | (true, KeyCode::Right) => offset.x = offset.x.saturating_add(7), 250 | (true, KeyCode::Up) => offset.y = offset.y.saturating_sub(5), 251 | (true, KeyCode::Down) => offset.y = offset.y.saturating_add(5).min(len), 252 | (false, KeyCode::Left) => offset.x = offset.x.saturating_sub(1), 253 | (false, KeyCode::Right) => offset.x = offset.x.saturating_add(1), 254 | (false, KeyCode::Up) => offset.y = offset.y.saturating_sub(1), 255 | (false, KeyCode::Down) => offset.y = offset.y.saturating_add(1).min(len), 256 | _ => {} 257 | } 258 | None 259 | } 260 | 261 | fn offset(&self) -> &Coord { 262 | &self.offset 263 | } 264 | } 265 | 266 | pub enum MovableListManager<'a, 'own> { 267 | Log(&'own mut LogListState<'a>), 268 | Connection(&'own mut ConListState<'a>), 269 | Rule(&'own mut RuleListState<'a>), 270 | Event(&'own mut DebugListState<'a>), 271 | Proxy(&'own mut ProxyTree<'a>), 272 | } 273 | 274 | impl<'a, 'own> MovableListManage for MovableListManager<'a, 'own> { 275 | fn sort(&mut self) -> &mut Self { 276 | match_any!( 277 | self, 278 | Self::Log(inner) | 279 | Self::Event(inner) | 280 | Self::Rule(inner) | 281 | Self::Connection(inner) | 282 | Self::Proxy(inner) => { 283 | inner.sort(); 284 | } 285 | ); 286 | self 287 | } 288 | 289 | fn next_sort(&mut self) -> &mut Self { 290 | match_any!( 291 | self, 292 | Self::Log(inner) | 293 | Self::Event(inner) | 294 | Self::Rule(inner) | 295 | Self::Connection(inner) | 296 | Self::Proxy(inner) => { 297 | inner.next_sort(); 298 | } 299 | ); 300 | self 301 | } 302 | 303 | fn prev_sort(&mut self) -> &mut Self { 304 | match_any!( 305 | self, 306 | Self::Log(inner) | 307 | Self::Event(inner) | 308 | Self::Rule(inner) | 309 | Self::Connection(inner) | 310 | Self::Proxy(inner) => { 311 | inner.prev_sort(); 312 | } 313 | ); 314 | self 315 | } 316 | 317 | fn current_pos(&self) -> Coord { 318 | match_any!( 319 | self, 320 | Self::Log(inner) | 321 | Self::Event(inner) | 322 | Self::Rule(inner) | 323 | Self::Connection(inner) | 324 | Self::Proxy(inner) => { 325 | inner.current_pos() 326 | } 327 | ) 328 | } 329 | 330 | fn len(&self) -> usize { 331 | match_any!( 332 | self, 333 | Self::Log(inner) | 334 | Self::Event(inner) | 335 | Self::Rule(inner) | 336 | Self::Connection(inner) | 337 | Self::Proxy(inner) => { 338 | inner.len() 339 | } 340 | ) 341 | } 342 | 343 | fn is_empty(&self) -> bool { 344 | match_any!( 345 | self, 346 | Self::Log(inner) | 347 | Self::Event(inner) | 348 | Self::Rule(inner) | 349 | Self::Connection(inner) | 350 | Self::Proxy(inner) => { 351 | inner.is_empty() 352 | } 353 | ) 354 | } 355 | 356 | fn toggle(&mut self) -> &mut Self { 357 | match_any!( 358 | self, 359 | Self::Log(inner) | 360 | Self::Event(inner) | 361 | Self::Rule(inner) | 362 | Self::Connection(inner) | 363 | Self::Proxy(inner) => { 364 | inner.toggle(); 365 | } 366 | ); 367 | self 368 | } 369 | 370 | fn end(&mut self) -> &mut Self { 371 | match_any!( 372 | self, 373 | Self::Log(inner) | 374 | Self::Event(inner) | 375 | Self::Rule(inner) | 376 | Self::Connection(inner) | 377 | Self::Proxy(inner) => { 378 | inner.end(); 379 | } 380 | ); 381 | self 382 | } 383 | 384 | fn hold(&mut self) -> &mut Self { 385 | match_any!( 386 | self, 387 | Self::Log(inner) | 388 | Self::Event(inner) | 389 | Self::Rule(inner) | 390 | Self::Connection(inner) | 391 | Self::Proxy(inner) => { 392 | inner.hold(); 393 | } 394 | ); 395 | self 396 | } 397 | 398 | fn handle(&mut self, event: ListEvent) -> Option { 399 | match_any!( 400 | self, 401 | Self::Log(inner) | 402 | Self::Event(inner) | 403 | Self::Rule(inner) | 404 | Self::Connection(inner) | 405 | Self::Proxy(inner) => { 406 | inner.handle(event) 407 | } 408 | ) 409 | } 410 | 411 | fn offset(&self) -> &Coord { 412 | match_any!( 413 | self, 414 | Self::Log(inner) | 415 | Self::Event(inner) | 416 | Self::Rule(inner) | 417 | Self::Connection(inner) | 418 | Self::Proxy(inner) => { 419 | inner.offset() 420 | } 421 | ) 422 | } 423 | } 424 | --------------------------------------------------------------------------------