├── .gitignore ├── Doc ├── README.md ├── win │ ├── clashtui_usage_zh.md │ ├── clashtui_usage.md │ ├── install_clashtui_manually_zh.md │ └── install_clashtui_manually.md ├── save_profile_to_git_repo.md ├── my │ ├── basic_clash_config.yaml │ └── templates │ │ ├── my_tpl.yaml │ │ └── gpt.yaml ├── clashtui_usage_zh.md ├── install_clashtui_manually_zh.md ├── clashtui_usage.md └── install_clashtui_manually.md ├── clashtui ├── src │ ├── tui │ │ ├── utils │ │ │ ├── mod.rs │ │ │ ├── help_popup.rs │ │ │ ├── info_popup.rs │ │ │ ├── list_popup.rs │ │ │ └── key_list.rs │ │ ├── mod.rs │ │ ├── symbols.rs │ │ ├── statusbar.rs │ │ ├── tabbar.rs │ │ └── tabs │ │ │ ├── mod.rs │ │ │ ├── profile_input.rs │ │ │ └── clashsrvctl.rs │ ├── utils │ │ ├── mod.rs │ │ ├── flags.rs │ │ ├── clashtui_data.rs │ │ ├── ipc.rs │ │ ├── state.rs │ │ ├── tui │ │ │ ├── impl_app.rs │ │ │ └── impl_clashsrv.rs │ │ ├── tui.rs │ │ ├── config.rs │ │ └── utils.rs │ └── main.rs ├── ui │ ├── src │ │ ├── widgets │ │ │ ├── mod.rs │ │ │ ├── confirm_popup.rs │ │ │ ├── msg.rs │ │ │ ├── input.rs │ │ │ └── list.rs │ │ ├── utils │ │ │ ├── mod.rs │ │ │ ├── error.rs │ │ │ ├── tools.rs │ │ │ └── theme.rs │ │ └── lib.rs │ └── Cargo.toml ├── ui-derive │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── api │ ├── src │ │ ├── lib.rs │ │ ├── github_restful_api.rs │ │ ├── dl_mihomo.rs │ │ └── config.rs │ └── Cargo.toml ├── build.rs └── Cargo.toml ├── Example ├── config.yaml ├── basic_clash_config.yaml ├── templates │ ├── common_tpl.yaml │ ├── generic_tpl_with_all.yaml │ ├── generic_tpl.yaml │ ├── generic_tpl_with_filter.yaml │ └── generic_tpl_with_ruleset.yaml └── profiles │ └── profile1.yaml ├── .github ├── workflows │ ├── stale_issues.yml~ │ ├── pr.yml │ └── build_release.yml └── ISSUE_TEMPLATE │ ├── feature_request.yaml │ └── bug_report.yaml ├── LICENSE ├── Assets └── clashtui_demo.tape ├── PkgManagers └── PKGBUILD ├── InstallRes ├── templates │ ├── common_tpl.yaml │ ├── generic_tpl_with_all.yaml │ ├── generic_tpl.yaml │ ├── generic_tpl_with_filter.yaml │ └── generic_tpl_with_ruleset.yaml └── basic_clash_config.yaml ├── README_ZH.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | clashtui/target 2 | /test 3 | /vendor 4 | /.vscode -------------------------------------------------------------------------------- /Doc/README.md: -------------------------------------------------------------------------------- 1 | # ClashTui Doc 2 | 3 | ## [ClashTUI Usage](./clashtui_usage.md) 4 | 5 | ## [Install ClashTUI Manually](./install_clashtui_manually.md) 6 | 7 | ## [Save Profile to git](./save_profile_to_git_repo.md) 8 | -------------------------------------------------------------------------------- /clashtui/src/tui/utils/mod.rs: -------------------------------------------------------------------------------- 1 | mod help_popup; 2 | mod info_popup; 3 | mod key_list; 4 | mod list_popup; 5 | 6 | pub use self::help_popup::HelpPopUp; 7 | pub use self::info_popup::InfoPopUp; 8 | pub use self::key_list::Keys; 9 | -------------------------------------------------------------------------------- /clashtui/ui/src/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | mod confirm_popup; 2 | mod input; 3 | mod list; 4 | mod msg; 5 | 6 | pub use self::confirm_popup::ConfirmPopup; 7 | pub use self::input::InputPopup; 8 | pub use self::list::List; 9 | pub use self::msg::MsgPopup; 10 | -------------------------------------------------------------------------------- /Example/config.yaml: -------------------------------------------------------------------------------- 1 | basic: 2 | clash_config_dir: /srv/mihomo 3 | clash_bin_path: /usr/bin/mihomo 4 | clash_config_path: /srv/mihomo/config.yaml 5 | timeout: null 6 | service: 7 | clash_srv_name: mihomo 8 | is_user: false 9 | extra: 10 | edit_cmd: '' 11 | open_dir_cmd: '' 12 | -------------------------------------------------------------------------------- /clashtui/src/tui/mod.rs: -------------------------------------------------------------------------------- 1 | mod statusbar; 2 | pub mod symbols; 3 | mod tabbar; 4 | pub mod tabs; 5 | pub mod utils; 6 | extern crate ui; 7 | pub use ui::utils::tools; 8 | pub use ui::{widgets, EventState, Theme, Visibility}; 9 | 10 | pub use statusbar::StatusBar; 11 | pub use tabbar::TabBar; 12 | -------------------------------------------------------------------------------- /clashtui/ui-derive/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ui-derive" 3 | version = "0.1.1" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | [lib] 8 | proc-macro = true 9 | 10 | [dependencies] 11 | syn = {version = "^2", features = ["default"]} 12 | quote = "^1" 13 | -------------------------------------------------------------------------------- /clashtui/api/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod clash; 2 | mod config; 3 | #[cfg(target_feature = "deprecated")] 4 | mod dl_mihomo; 5 | #[cfg(target_feature = "github_api")] 6 | mod github_restful_api; 7 | 8 | pub use clash::{ClashUtil, Resp, UrlType, UrlItem, ProfileSectionType}; 9 | pub use config::{ClashConfig, Mode, TunStack}; 10 | #[cfg(target_feature = "github_api")] 11 | pub use github_restful_api::GithubApi; 12 | -------------------------------------------------------------------------------- /clashtui/ui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ui" 3 | version = "0.2.1" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | ui-derive ={ path = "../ui-derive"} 10 | crossterm = { version = "^0" ,default-features = false} 11 | ratatui = { version = "^0", default-features = false, features = ["serde"] } 12 | -------------------------------------------------------------------------------- /clashtui/ui/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | mod error; 2 | mod theme; 3 | pub mod tools; 4 | pub use error::Infailable; 5 | pub use theme::Theme; 6 | 7 | #[derive(PartialEq, Eq)] 8 | pub enum EventState { 9 | Yes, 10 | Cancel, 11 | NotConsumed, 12 | WorkDone, 13 | } 14 | 15 | impl EventState { 16 | pub fn is_consumed(&self) -> bool { 17 | !self.is_notconsumed() 18 | } 19 | pub fn is_notconsumed(&self) -> bool { 20 | self == &Self::NotConsumed 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /clashtui/ui/src/utils/error.rs: -------------------------------------------------------------------------------- 1 | /// This error means there should be `no` error 2 | #[derive(Debug)] 3 | pub struct Infailable; 4 | 5 | impl std::fmt::Display for Infailable { 6 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 7 | write!(f, "End of Stream") 8 | } 9 | } 10 | 11 | impl std::error::Error for Infailable {} 12 | 13 | impl From for std::io::Error { 14 | fn from(_: Infailable) -> Self { 15 | std::io::Error::new(std::io::ErrorKind::Other, "Should Not fail") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /clashtui/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | mod flags; 3 | mod ipc; 4 | mod state; 5 | mod tui; 6 | #[allow(clippy::module_inception)] 7 | mod utils; 8 | mod clashtui_data; 9 | 10 | pub type SharedClashTuiUtil = std::rc::Rc; 11 | pub type SharedClashTuiState = std::rc::Rc>; 12 | 13 | pub use config::{init_config, CfgError, check_essential_files}; 14 | pub use flags::{BitFlags as Flags, Flag}; 15 | pub use state::State; 16 | pub use tui::{ClashTuiUtil, ProfileType}; 17 | pub use utils::*; 18 | pub use clashtui_data::ClashTuiData; 19 | -------------------------------------------------------------------------------- /clashtui/api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "api" 3 | version = "0.1.1" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | serde = { version = "^1", features = ["derive"] } 10 | minreq = { version = "^2", features = ["proxy", "https"] } 11 | url = {version = "^2"} 12 | base64 = {version = "^0"} 13 | serde_json = "^1" 14 | serde_yaml = "^0" 15 | serde-this-or-that = { version = "^0", optional = true } 16 | chrono = "^0" 17 | 18 | [features] 19 | deprecated = ["github_api"] 20 | github_api = ["serde-this-or-that"] 21 | -------------------------------------------------------------------------------- /clashtui/src/utils/flags.rs: -------------------------------------------------------------------------------- 1 | use enumflags2::bitflags; 2 | pub use enumflags2::BitFlags; 3 | 4 | #[derive(Clone, Copy, Debug)] 5 | #[bitflags] 6 | #[repr(u8)] 7 | pub enum Flag { 8 | FirstInit = 1, 9 | ErrorDuringInit = 1 << 1, 10 | PortableMode = 1 << 2, 11 | EssentialFileMissing = 1 << 3, 12 | } 13 | #[cfg(test)] 14 | mod test { 15 | use super::*; 16 | #[test] 17 | fn test_flags() { 18 | let mut flags = BitFlags::EMPTY; 19 | println!("{:?}", flags.exactly_one()); 20 | flags.insert(Flag::FirstInit); 21 | println!("{flags:?}"); 22 | assert!(flags.contains(Flag::FirstInit)); 23 | println!("{:?}", flags.exactly_one()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Doc/win/clashtui_usage_zh.md: -------------------------------------------------------------------------------- 1 | # ClashTUI Usage 2 | 3 | ## ClashTUI 的配置 4 | 5 | 配置文件的路径是 `%APPDATA%/clashtui/config.yaml`. 6 | 7 | ```yaml 8 | # 下面参数对应命令 -d -f 9 | clash_core_path: "D:/ClashTUI/mihomo.exe" 10 | clash_cfg_dir: "D:/ClashTUI/mihomo_config" 11 | clash_cfg_path: "D:/ClashTUI/mihomo_config/config.yaml" 12 | clash_srv_name: "mihomo" # nssm {install | remove | restart | stop | edit} 13 | edit_cmd: 'notepad "%s"' # `%s` 会被替换为相应的文件路径。如果为空, 则使用默认的方式打开文件。 14 | open_dir_cmd: 'explorer "%s"' # 与 `edit_cmd` 同理 15 | ``` 16 | 17 | ## 其他 18 | 19 | 参考 [ref](../clashtui_usage_zh.md) 20 | -------------------------------------------------------------------------------- /.github/workflows/stale_issues.yml~: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PR' 2 | on: 3 | schedule: 4 | - cron: '30 1 * * *' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v9 11 | with: 12 | stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' 13 | stale-pr-message: 'This PR is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 10 days.' 14 | close-issue-message: 'This issue was closed because it has been stalled for 5 days with no activity.' 15 | days-before-stale: 30 16 | days-before-close: 5 17 | days-before-pr-close: -1 18 | -------------------------------------------------------------------------------- /clashtui/src/utils/clashtui_data.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::fs::File; 3 | use std::result::Result; 4 | use std::error::Error; 5 | 6 | #[derive(Debug, Default, Serialize, Deserialize)] 7 | #[serde(default)] 8 | pub struct ClashTuiData { 9 | pub current_profile: String, 10 | pub no_pp: bool, 11 | } 12 | 13 | impl ClashTuiData { 14 | pub fn from_file(file_path: &str) -> Result> { 15 | let f = File::open(file_path)?; 16 | Ok(serde_yaml::from_reader(f)?) 17 | } 18 | 19 | pub fn to_file(&self, file_path: &str) -> Result<(), Box> { 20 | let f = File::create(file_path)?; 21 | Ok(serde_yaml::to_writer(f, self)?) 22 | } 23 | 24 | pub fn update_profile(&mut self, new_profile: &str) { 25 | self.current_profile = new_profile.to_string(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Doc/win/clashtui_usage.md: -------------------------------------------------------------------------------- 1 | # ClashTUI Usage 2 | 3 | ## Configuration of ClashTUI 4 | 5 | The configuration file path is `%APPDATA%/clashtui/config.yaml`. 6 | 7 | ```yaml 8 | # The parameters below correspond to the command -d -f 9 | clash_core_path: "D:/ClashTUI/mihomo.exe" 10 | clash_cfg_dir: "D:/ClashTUI/mihomo_config" 11 | clash_cfg_path: "D:/ClashTUI/mihomo_config/config.yaml" 12 | clash_srv_name: "mihomo" # nssm {install | remove | restart | stop | edit} 13 | edit_cmd: 'notepad "%s"' # `%s` will be replaced with the corresponding file path. If empty, the file will be opened using the default method. 14 | open_dir_cmd: 'explorer "%s"' # Same principle as `edit_cmd` 15 | ``` 16 | 17 | ## Others 18 | 19 | Reference [ref](../clashtui_usage.md) 20 | -------------------------------------------------------------------------------- /clashtui/src/tui/utils/help_popup.rs: -------------------------------------------------------------------------------- 1 | use super::list_popup::PopUp; 2 | use crate::tui::{symbols::HELP, EventState, Visibility}; 3 | use ui::event::Event; 4 | use ratatui::prelude as Ra; 5 | 6 | pub struct HelpPopUp { 7 | inner: PopUp, 8 | } 9 | 10 | impl HelpPopUp { 11 | pub fn new() -> Self { 12 | let mut inner = PopUp::new("Help".to_string()); 13 | inner.set_items(HELP.lines().map(|line| line.trim().to_string())); 14 | Self { inner } 15 | } 16 | pub fn event(&mut self, ev: &Event) -> Result { 17 | self.inner.event(ev) 18 | } 19 | pub fn draw(&mut self, f: &mut Ra::Frame, area: Ra::Rect) { 20 | self.inner.draw(f, area) 21 | } 22 | } 23 | 24 | impl Visibility for HelpPopUp { 25 | fn is_visible(&self) -> bool { 26 | self.inner.is_visible() 27 | } 28 | 29 | fn show(&mut self) { 30 | self.inner.show() 31 | } 32 | 33 | fn hide(&mut self) { 34 | self.inner.hide() 35 | } 36 | 37 | fn set_visible(&mut self, b: bool) { 38 | self.inner.set_visible(b) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 JohanChane 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 | -------------------------------------------------------------------------------- /clashtui/src/tui/symbols.rs: -------------------------------------------------------------------------------- 1 | pub(super) const HELP: &str = r#"## Common 2 | j/k/h/l OR Up/Down/Left/Right: Scroll 3 | Enter: Action 4 | Esc: Close popup 5 | Tab: Switch 6 | 7 | ## Profile Tab 8 | p: Switch to profile 9 | t: Switch to template 10 | 11 | ## Profile Window 12 | Enter: Select 13 | u: Update proxy-providers only 14 | a: Update all network resources in profile 15 | i: Import 16 | d: Delete 17 | s: Test 18 | e: Edit 19 | v: Preview 20 | n: Info 21 | m: Switch `No proxy providers` 22 | 23 | ## Tempalte 24 | Enter: Create yaml 25 | e: Edit 26 | v: Preview 27 | 28 | ## ClashSrvCtl 29 | Enter: Action 30 | 31 | ## Global 32 | q: Quit 33 | R: Restart clash core 34 | L: Show recent log 35 | I: Show informations 36 | H: Locate app home path 37 | G: Locate clash config dir 38 | 1,2,...,9 OR Tab: Switch tabs 39 | ?: Help"#; 40 | 41 | pub(crate) const DEFAULT_BASIC_CLASH_CFG_CONTENT: &str = r#"mixed-port: 7890 42 | mode: rule 43 | log-level: info 44 | external-controller: 127.0.0.1:9090"#; 45 | 46 | pub(super) const PROFILE: &str = "Profile"; 47 | pub(super) const TEMPALTE: &str = "Template"; 48 | pub(super) const CLASHSRVCTL: &str = "ClashSrvCtl"; 49 | -------------------------------------------------------------------------------- /clashtui/src/tui/statusbar.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{prelude as Ra, widgets as Raw}; 2 | 3 | use super::Theme; 4 | use crate::utils::SharedClashTuiState; 5 | 6 | pub struct StatusBar { 7 | is_visible: bool, 8 | clashtui_state: SharedClashTuiState, 9 | } 10 | 11 | impl StatusBar { 12 | pub fn new(clashtui_state: SharedClashTuiState) -> Self { 13 | Self { 14 | is_visible: true, 15 | clashtui_state, 16 | } 17 | } 18 | 19 | pub fn draw(&mut self, f: &mut Ra::Frame, area: Ra::Rect) { 20 | if !self.is_visible { 21 | return; 22 | } 23 | 24 | f.render_widget(Raw::Clear, area); 25 | let state = self.clashtui_state.borrow(); 26 | let status_str = state.render(); 27 | let paragraph = Raw::Paragraph::new(Ra::Span::styled( 28 | status_str, 29 | Ra::Style::default().fg(Theme::get().statusbar_text_fg), 30 | )) 31 | //.alignment(ratatui::prelude::Alignment::Right) 32 | .wrap(Raw::Wrap { trim: true }); 33 | let block = Raw::Block::new().borders(Raw::Borders::ALL); 34 | f.render_widget(paragraph.block(block), area); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Assets/clashtui_demo.tape: -------------------------------------------------------------------------------- 1 | # Recorded by [vhs](https://github.com/charmbracelet/vhs) 2 | # Where should we write the GIF? 3 | Output demo.gif 4 | 5 | # Set up a 1200x600 terminal with 42px font. 6 | Set FontSize 20 7 | Set Width 1200 8 | Set Height 800 9 | 10 | # ## Start clashtui 11 | Type "clashtui" 12 | Sleep 1s 13 | Enter 14 | Sleep 1s 15 | 16 | # ## Help 17 | Set TypingSpeed 1s 18 | Type "?" 19 | Type "jjjj" 20 | Escape 21 | 22 | # ## Profile 23 | Type "i" 24 | Set TypingSpeed 300ms 25 | Type "sub" 26 | Tab 27 | Type "https://example.com" 28 | Enter 29 | 30 | Set TypingSpeed 1s 31 | 32 | # scroll list 33 | Type "j" 34 | Sleep 2s 35 | Type "k" 36 | Sleep 2s 37 | 38 | # Select and update the profile 39 | Enter 40 | Sleep 5s 41 | Type "u" 42 | Sleep 10s 43 | Escape 44 | 45 | # Preview the profile 46 | #Type "v" 47 | #Sleep 2s 48 | #Escape 49 | 50 | # Generate profile with Template 51 | Type "t" 52 | Sleep 1s 53 | Enter 54 | Sleep 1s 55 | Escape 56 | Sleep 1s 57 | Type "p" 58 | Sleep 1s 59 | 60 | # Delete profile 61 | Type "d" 62 | Sleep 2s 63 | Type "y" 64 | Sleep 1s 65 | 66 | # ## ClashSrvCtl 67 | Set TypingSpeed 300ms 68 | Type "2" 69 | Type "jjjj" 70 | Type "j" 71 | 72 | # ## Global 73 | # Show log 74 | Type "L" 75 | Sleep 2s 76 | Escape 77 | 78 | # ## Exit 79 | Type "q" 80 | Sleep 1s 81 | -------------------------------------------------------------------------------- /clashtui/api/src/github_restful_api.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | #[derive(Debug, Serialize, Deserialize)] 3 | pub struct GithubApi { 4 | // Not caring about the value, just keep it as string 5 | #[serde(deserialize_with = "serde_this_or_that::as_string")] 6 | pub id: String, 7 | pub name: String, 8 | pub tag_name: String, 9 | draft: bool, 10 | prerelease: bool, 11 | 12 | pub published_at: String, 13 | pub assets: Vec, 14 | } 15 | impl GithubApi { 16 | pub fn check(&self, current_version: &str) -> bool { 17 | current_version == self.id && !self.draft && !self.prerelease 18 | } 19 | pub fn get_url(&self, target: usize) -> Option<&String> { 20 | self.assets.get(target).map(|asset| asset.get_url()) 21 | } 22 | } 23 | #[derive(Debug, Serialize, Deserialize)] 24 | pub struct Asset { 25 | pub name: String, 26 | pub browser_download_url: String, 27 | } 28 | impl Asset { 29 | pub fn get_url(&self) -> &String { 30 | &self.browser_download_url 31 | } 32 | } 33 | impl From for (String, String) { 34 | fn from(value: Asset) -> Self { 35 | let Asset { 36 | name, 37 | browser_download_url, 38 | .. 39 | } = value; 40 | (name, browser_download_url) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | 3 | on: 4 | pull_request: 5 | 6 | env: 7 | CARGO_TERM_COLOR: always 8 | CLASHTUI_VERSION: ${{ github.head_ref }} 9 | 10 | jobs: 11 | build-linux: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Mihomo 18 | run: | 19 | wget --output-document mihomo.gz https://github.com/MetaCubeX/mihomo/releases/download/v1.18.0/mihomo-linux-amd64-v1.18.0.gz 20 | gunzip mihomo.gz 21 | chmod +x mihomo 22 | nohup ./mihomo -d Example -f Example/basic_clash_config.yaml & 23 | 24 | - name: Download Dependencies 25 | run: cd clashtui && cargo fetch 26 | 27 | - name: Build 28 | run: cd clashtui && cargo build --verbose 29 | 30 | - name: Run tests 31 | run: cd clashtui && cargo test --all --verbose 32 | 33 | - name: Build Version 34 | run: cd clashtui && cargo r -- -v 35 | 36 | - name: Pre Upload 37 | run: | 38 | mkdir artifacts 39 | mv ./clashtui/target/debug/clashtui ./artifacts/clashtui.debug 40 | 41 | - name: upload artifacts 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: Linux_Build 45 | path: artifacts 46 | retention-days: 5 47 | -------------------------------------------------------------------------------- /PkgManagers/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Kimiblock Moe 2 | # Maintainer: JohanChane 3 | 4 | pkgname=clashtui-git 5 | pkgdesc="Mihomo (Clash.Meta) TUI Client" 6 | url="https://github.com/JohanChane/clashtui" 7 | license=("MIT") 8 | arch=("any") 9 | pkgver=0.2.0.r8.gd6e96fb0 10 | pkgrel=1 11 | makedepends=("rust" "cargo" "git") 12 | depends=("gcc-libs" "glibc", "sudo") 13 | source=("git+https://github.com/JohanChane/clashtui.git#branch=main") 14 | md5sums=("SKIP") 15 | provides=("clashtui") 16 | conflicts=("clashtui") 17 | options=(!lto) 18 | 19 | function pkgver() { 20 | cd "${srcdir}/clashtui/clashtui" 21 | git describe --long --tags --abbrev=8 | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' 22 | } 23 | 24 | function prepare() { 25 | cd "${srcdir}/clashtui/clashtui" 26 | export RUSTUP_TOOLCHAIN=stable 27 | cargo fetch --target "$CARCH-unknown-linux-gnu" 28 | } 29 | 30 | function build() { 31 | cd "${srcdir}/clashtui/clashtui" 32 | export RUSTUP_TOOLCHAIN=stable 33 | export CARGO_TARGET_DIR=target 34 | cargo build --release --frozen --all-features --locked 35 | } 36 | 37 | function check() { 38 | cd "${srcdir}/clashtui/clashtui" 39 | export RUSTUP_TOOLCHAIN=stable 40 | cargo test --release --frozen --all-features --locked 41 | } 42 | 43 | function package() { 44 | install -Dm755 "${srcdir}/clashtui/clashtui/target/release/clashtui" "${pkgdir}/usr/bin/clashtui" 45 | install -Dm644 "${srcdir}/clashtui/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" 46 | } 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | title: "[Feat]: " 4 | labels: ["enhancement"] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | ### Thank you for your willingness to advise Clashtui! 11 | ### Here are some precautions, be sure to read to make it easier for us to handle 12 | 13 | #### ❗ | ISSUE determined that there is no same problem has been raised. 14 | #### 📝 | Determining that existing PR and code do not have an implementation/similar functionality of this function。 15 | 16 | --- 17 | - type: checkboxes 18 | id: terms 19 | attributes: 20 | label: Please make sure you have read the above considerations and tick the confirmation box below. 21 | options: 22 | - label: I'm sure this is a feature that has never been proposed and implemented. 23 | required: true 24 | - label: I've looked for the question I'm asking in [Issue Tracker](https://github.com/JohanChane/clashtui/issues?q=is%3Aissue), and I didn't find the ISSUE for the same question. 25 | required: true 26 | 27 | - type: textarea 28 | id: feature-to-add 29 | attributes: 30 | label: Describe the solution 31 | description: | 32 | A clear and concise description of what the problem is, what you want to happen, and any alternative solutions or features you've considered. 33 | validations: 34 | required: true -------------------------------------------------------------------------------- /clashtui/src/utils/ipc.rs: -------------------------------------------------------------------------------- 1 | use std::process::{Command, Output, Stdio}; 2 | 3 | use std::io::Result; 4 | 5 | pub fn exec(pgm: &str, args: Vec<&str>) -> Result { 6 | log::debug!("IPC: {} {:?}", pgm, args); 7 | let output = Command::new(pgm).args(args).output()?; 8 | string_process_output(output) 9 | } 10 | 11 | pub fn spawn(pgm: &str, args: Vec<&str>) -> Result<()> { 12 | log::debug!("SPW: {} {:?}", pgm, args); 13 | // Just ignore the output, otherwise the ui might be broken 14 | Command::new(pgm) 15 | .stderr(Stdio::null()) 16 | .stdout(Stdio::null()) 17 | .args(args) 18 | .spawn()?; 19 | Ok(()) 20 | } 21 | 22 | pub fn exec_with_sbin(pgm: &str, args: Vec<&str>) -> Result { 23 | log::debug!("LIPC: {} {:?}", pgm, args); 24 | let mut path = std::env::var("PATH").unwrap_or_default(); 25 | path.push_str(":/usr/sbin"); 26 | let output = Command::new(pgm).env("PATH", path).args(args).output()?; 27 | string_process_output(output) 28 | } 29 | 30 | fn string_process_output(output: Output) -> Result { 31 | let stdout_str = String::from_utf8(output.stdout).unwrap(); 32 | let stderr_str = String::from_utf8(output.stderr).unwrap(); 33 | 34 | let result_str = format!( 35 | r#" 36 | Status: 37 | {} 38 | 39 | Stdout: 40 | {} 41 | 42 | Stderr: 43 | {} 44 | "#, 45 | output.status, stdout_str, stderr_str 46 | ); 47 | 48 | Ok(result_str) 49 | } 50 | -------------------------------------------------------------------------------- /Example/basic_clash_config.yaml: -------------------------------------------------------------------------------- 1 | # 随意配置的, 仅供参考。 2 | mode: rule 3 | mixed-port: 7890 4 | allow-lan: false 5 | log-level: silent # silent/error/warning/info/debug 6 | ipv6: true 7 | 8 | secret: '' 9 | external-controller: 127.0.0.1:9090 10 | #external-ui: /usr/share/metacubexd 11 | external-ui: uis/metacubexd # 以防出现权限问题, 将 metacubexd 放在 clash_cfg_dir 目录下. 12 | # `git clone https://github.com/metacubex/metacubexd.git -b gh-pages /uis/metacubexd` 13 | # OR pacman metacubexd-bin hook: Exec = /bin/sh -c 'rm -rf /srv/mihomo/uis/metacubexd && cp -r /usr/share/metacubexd /srv/mihomo/uis/' 14 | external-ui-name: metacubexd 15 | external-ui-url: https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip 16 | 17 | #geox-url: 18 | # geoip: "https://fastly.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.dat" 19 | # geosite: "https://fastly.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geosite.dat" 20 | # mmdb: "https://fastly.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.metadb" 21 | #geo-auto-update: false 22 | #geo-update-interval: 24 23 | 24 | profile: 25 | store-selected: true 26 | unified-delay: true 27 | 28 | dns: 29 | enable: true 30 | listen: 0.0.0.0:1053 31 | ipv6: true 32 | enhanced-mode: fake-ip 33 | fake-ip-range: 198.18.0.1/16 34 | nameserver: 35 | - 114.114.114.114 36 | - 223.5.5.5 37 | - 8.8.8.8 38 | fallback: [] 39 | 40 | tun: 41 | enable: true 42 | stack: system 43 | dns-hijack: 44 | - any:53 45 | auto-route: true 46 | auto-detect-interface: true 47 | -------------------------------------------------------------------------------- /Doc/save_profile_to_git_repo.md: -------------------------------------------------------------------------------- 1 | # 使用 git 存放 profile 2 | 3 | ## 使用 gitlab 私有仓库存放个人的 profile 4 | 5 | 如果没有私人服务器, 可以将通过模板生成的 profile 上传到 gitlab 的私人仓库。这样就相当于个人的订阅链接。当要更换 `proxy-provider` 时, 用模板重新生成 profile 再上传到 gitlab。然后 mihomo 客户端更新该订阅链接即可。 6 | 7 | 操作如下: 8 | 1. 创建 gitlab 的私有仓库。 9 | 2. 创建[个人的访问令牌](https://gitlab.com/-/user_settings/personal_access_tokens)。令牌的范围选择 `read_repository`。 10 | 3. 配置 profile 的路径: 11 | - [生成一个 uuid](https://www.uuidgenerator.net/)。 12 | - 在仓库中创建目录 `Clash/`。 将你的链接放在该目录下。 13 | - 同理, 如果共享你的订阅链接, 你可以将 profile 放置在 `Clash` 目录下。或者放置在另外一个 uuid 目录。 14 | 4. profile 的 url: `https://gitlab.com/api/v4/projects//repository/files/Clash%2F%2Fconfig.yaml/raw?ref=&private_token=` 15 | - project_id: 项目设置->通用->项目ID 16 | - uuid: 刚才生成的 uuid。 17 | - branch: 默认为 main 18 | - your token: 个人的访问令牌 19 | - 文件路径的 `/`: `%2F`。 20 | 21 | 解释上面的操作: 22 | - url 虽然会泄露个人令牌。但是令牌的范围是 `read_repository`。该范围是无法列出仓库的文件树(文件名称)。 23 | - 所以创建 uuid 目录, 可以防止别人通过猜测其他文件的路径, 从而通过私人令牌取得其他文件。 24 | - 如果害怕泄露个人令牌, 可以单独创建一个专门用于共享订阅链接的 gitlab 帐号。 25 | 26 | ## 使用 github 私有仓库存放个人的 profile 27 | 28 | 添加 `Personal access tokens`: 29 | 30 | ``` 31 | Settings -> Developer settings -> Personal access tokens -> Tokens (classic) -> Generate new token -> Select scopes (check `repo`) 32 | ``` 33 | 34 | The Url of file in private repo: 35 | 36 | ``` 37 | # the url of raw file in private repo 38 | https://raw.githubusercontent.com/xxx 39 | 40 | # Add token 41 | https://@raw.githubusercontent.com/xxx 42 | 43 | # OR 44 | https://x-access-token:@raw.githubusercontent.com/xxx 45 | ``` 46 | -------------------------------------------------------------------------------- /clashtui/build.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::process::Command; 3 | 4 | fn get_version() -> String { 5 | let branch_name = match Command::new("git") 6 | .args(["rev-parse", "--abbrev-ref", "HEAD"]) 7 | .output() { 8 | Ok(v) => { 9 | String::from_utf8(v.stdout).expect("failed to read stdout").trim_end().to_string() 10 | } 11 | Err(err) => { 12 | eprintln!("`git rev-parse` err: {}", err); 13 | "".to_string() 14 | } 15 | }; 16 | 17 | let git_describe = match Command::new("git") 18 | .args(["describe", "--always"]) 19 | .output() { 20 | Ok(v) => { 21 | String::from_utf8(v.stdout).expect("failed to read stdout").trim_end().to_string() 22 | } 23 | Err(err) => { 24 | eprintln!("`git describe` err: {}", err); 25 | "".to_string() 26 | 27 | } 28 | }; 29 | 30 | let cargo_pkg_version = env::var("CARGO_PKG_VERSION").unwrap(); 31 | 32 | let build_type: bool = env::var("DEBUG").unwrap().parse().unwrap(); 33 | let build_type_str = if build_type {"-debug"} else {""}; 34 | 35 | let version = format!("v{cargo_pkg_version}-{branch_name}-{git_describe}{build_type_str}"); 36 | 37 | version 38 | } 39 | 40 | fn main() { 41 | println!("cargo:rerun-if-changed=../.git/HEAD"); 42 | println!("cargo:rerun-if-changed=../.git/refs/heads/dev"); 43 | println!("cargo:rerun-if-changed=build.rs",); 44 | 45 | if let Ok(_) = env::var("CLASHTUI_VERSION") { 46 | } else { 47 | println!( 48 | "cargo:rustc-env=CLASHTUI_VERSION={}", 49 | get_version() 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Example/templates/common_tpl.yaml: -------------------------------------------------------------------------------- 1 | proxy-anchor: 2 | - delay_test: 3 | &pa_dt { url: https://www.gstatic.com/generate_204, interval: 300 } 4 | - proxy_provider: 5 | &pa_pp { 6 | interval: 3600, 7 | intehealth-check: 8 | { 9 | enable: true, 10 | url: https://www.gstatic.com/generate_204, 11 | interval: 300, 12 | }, 13 | } 14 | 15 | proxy-groups: 16 | - name: "Entry" 17 | type: select 18 | proxies: 19 | - 20 | - 21 | use: 22 | - 23 | 24 | - name: "Sl" 25 | tpl_param: 26 | providers: ["pvd"] 27 | type: select 28 | 29 | - name: "At" 30 | tpl_param: 31 | providers: ["pvd"] 32 | type: url-test 33 | <<: *pa_dt 34 | 35 | - name: "Entry-RuleMode" 36 | type: select 37 | proxies: 38 | - DIRECT 39 | - Entry 40 | 41 | - name: "Entry-LastMatch" 42 | type: select 43 | proxies: 44 | - Entry 45 | - DIRECT 46 | 47 | proxy-providers: 48 | pvd: 49 | tpl_param: 50 | type: http 51 | <<: *pa_pp 52 | 53 | rules: 54 | - GEOIP,lan,DIRECT,no-resolve 55 | - GEOSITE,github,Entry 56 | - GEOSITE,twitter,Entry 57 | - GEOSITE,youtube,Entry 58 | - GEOSITE,google,Entry 59 | - GEOSITE,telegram,Entry 60 | - GEOSITE,netflix,Entry 61 | - GEOSITE,bilibili,Entry-RuleMode 62 | - GEOSITE,bahamut,Entry 63 | - GEOSITE,spotify,Entry 64 | - GEOSITE,steam@cn,Entry-RuleMode 65 | - GEOSITE,category-games@cn,Entry-RuleMode 66 | - GEOSITE,CN,Entry-RuleMode 67 | - GEOSITE,geolocation-!cn,Entry 68 | - GEOIP,google,Entry 69 | - GEOIP,netflix,Entry 70 | - GEOIP,telegram,Entry 71 | - GEOIP,twitter,Entry 72 | - GEOIP,CN,Entry-RuleMode 73 | - MATCH,Entry-LastMatch 74 | -------------------------------------------------------------------------------- /InstallRes/templates/common_tpl.yaml: -------------------------------------------------------------------------------- 1 | proxy-anchor: 2 | - delay_test: 3 | &pa_dt { url: https://www.gstatic.com/generate_204, interval: 300 } 4 | - proxy_provider: 5 | &pa_pp { 6 | interval: 3600, 7 | intehealth-check: 8 | { 9 | enable: true, 10 | url: https://www.gstatic.com/generate_204, 11 | interval: 300, 12 | }, 13 | } 14 | 15 | proxy-groups: 16 | - name: "Entry" 17 | type: select 18 | proxies: 19 | - 20 | - 21 | use: 22 | - 23 | 24 | - name: "Sl" 25 | tpl_param: 26 | providers: ["pvd"] 27 | type: select 28 | 29 | - name: "At" 30 | tpl_param: 31 | providers: ["pvd"] 32 | type: url-test 33 | <<: *pa_dt 34 | 35 | - name: "Entry-RuleMode" 36 | type: select 37 | proxies: 38 | - DIRECT 39 | - Entry 40 | 41 | - name: "Entry-LastMatch" 42 | type: select 43 | proxies: 44 | - Entry 45 | - DIRECT 46 | 47 | proxy-providers: 48 | pvd: 49 | tpl_param: 50 | type: http 51 | <<: *pa_pp 52 | 53 | rules: 54 | - GEOIP,lan,DIRECT,no-resolve 55 | - GEOSITE,github,Entry 56 | - GEOSITE,twitter,Entry 57 | - GEOSITE,youtube,Entry 58 | - GEOSITE,google,Entry 59 | - GEOSITE,telegram,Entry 60 | - GEOSITE,netflix,Entry 61 | - GEOSITE,bilibili,Entry-RuleMode 62 | - GEOSITE,bahamut,Entry 63 | - GEOSITE,spotify,Entry 64 | - GEOSITE,steam@cn,Entry-RuleMode 65 | - GEOSITE,category-games@cn,Entry-RuleMode 66 | - GEOSITE,CN,Entry-RuleMode 67 | - GEOSITE,geolocation-!cn,Entry 68 | - GEOIP,google,Entry 69 | - GEOIP,netflix,Entry 70 | - GEOIP,telegram,Entry 71 | - GEOIP,twitter,Entry 72 | - GEOIP,CN,Entry-RuleMode 73 | - MATCH,Entry-LastMatch 74 | -------------------------------------------------------------------------------- /clashtui/ui/src/widgets/confirm_popup.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{Event, KeyCode, KeyEventKind}; 2 | use ratatui::prelude as Ra; 3 | 4 | use crate::{EventState, Infailable}; 5 | 6 | use super::MsgPopup; 7 | /// Modified [MsgPopup] 8 | /// 9 | /// Add 'y', 'n'/Esc to close 10 | /// 11 | /// Not impl [Visibility][crate::Visibility] since [MsgPopup] does 12 | pub struct ConfirmPopup(MsgPopup); 13 | 14 | impl ConfirmPopup { 15 | pub fn new() -> Self { 16 | Self(MsgPopup::default()) 17 | } 18 | 19 | pub fn event(&mut self, ev: &Event) -> Result { 20 | if !self.0.is_visible() { 21 | return Ok(EventState::NotConsumed); 22 | } 23 | 24 | let mut event_state = EventState::NotConsumed; 25 | if let Event::Key(key) = ev { 26 | if key.kind != KeyEventKind::Press { 27 | return Ok(EventState::NotConsumed); 28 | } 29 | match key.code { 30 | KeyCode::Char('y') => { 31 | self.0.hide(); 32 | return Ok(EventState::Yes); 33 | } 34 | KeyCode::Char('n') | KeyCode::Esc => { 35 | self.0.hide(); 36 | return Ok(EventState::Cancel); 37 | } 38 | _ => { 39 | event_state = self.0.event(ev)?; 40 | } 41 | } 42 | } 43 | 44 | Ok(event_state) 45 | } 46 | 47 | pub fn draw(&mut self, f: &mut Ra::Frame, _area: Ra::Rect) { 48 | //! area is only used to keep the args 49 | self.0.draw(f, _area); 50 | } 51 | 52 | pub fn popup_msg(&mut self, confirm_str: String) { 53 | self.0.push_txt_msg(confirm_str); 54 | self.0.show(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Example/profiles/profile1.yaml: -------------------------------------------------------------------------------- 1 | pp: 2 | interval: 3600 3 | intehealth-check: 4 | enable: true 5 | url: https://www.gstatic.com/generate_204 6 | interval: 300 7 | delay_test: 8 | url: https://www.gstatic.com/generate_204 9 | interval: 300 10 | proxy-groups: 11 | - name: Entry 12 | type: select 13 | proxies: 14 | - Auto-provider0 15 | - Select-provider0 16 | - name: Select-provider0 17 | type: select 18 | use: 19 | - provider0 20 | - name: Auto-provider0 21 | <<: 22 | url: https://www.gstatic.com/generate_204 23 | interval: 300 24 | type: url-test 25 | use: 26 | - provider0 27 | - name: Entry-RuleMode 28 | type: select 29 | proxies: 30 | - DIRECT 31 | - Entry 32 | - name: Entry-LastMatch 33 | type: select 34 | proxies: 35 | - Entry 36 | - DIRECT 37 | proxy-providers: 38 | provider0: 39 | <<: 40 | interval: 3600 41 | intehealth-check: 42 | enable: true 43 | url: https://www.gstatic.com/generate_204 44 | interval: 300 45 | type: http 46 | url: https://cdn.jsdelivr.net/gh/anaer/Sub@main/clash.yaml 47 | path: proxy-providers/tpl/provider0.yaml 48 | rule-anchor: 49 | ip: 50 | interval: 86400 51 | behavior: ipcidr 52 | format: yaml 53 | domain: 54 | type: http 55 | interval: 86400 56 | behavior: domain 57 | format: yaml 58 | rule-providers: 59 | private: 60 | type: http 61 | url: https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/private.yaml 62 | path: ./rule-providers/tpl/private.yaml 63 | <<: 64 | type: http 65 | interval: 86400 66 | behavior: domain 67 | format: yaml 68 | rules: 69 | - RULE-SET,private,DIRECT 70 | - GEOIP,lan,DIRECT,no-resolve 71 | - GEOSITE,biliintl,Entry 72 | - MATCH,Entry-LastMatch 73 | -------------------------------------------------------------------------------- /Example/templates/generic_tpl_with_all.yaml: -------------------------------------------------------------------------------- 1 | proxy-anchor: 2 | - delay_test: &pa_dt {url: https://www.gstatic.com/generate_204, interval: 300} 3 | - proxy_provider: &pa_pp {interval: 3600, intehealth-check: {enable: true, url: https://www.gstatic.com/generate_204, interval: 300}} 4 | 5 | 6 | proxy-groups: 7 | - name: "Entry" 8 | type: select 9 | proxies: 10 | - AllAt 11 | - AllSl 12 | - 13 | - 14 | 15 | - name: "AllSl" 16 | type: select 17 | use: 18 | - 19 | 20 | - name: "Sl" 21 | tpl_param: 22 | providers: ["pvd"] 23 | type: select 24 | 25 | - name: "AllAt" 26 | type: url-test 27 | proxies: 28 | - 29 | <<: *pa_dt 30 | 31 | - name: "At" 32 | tpl_param: 33 | providers: ["pvd"] 34 | type: url-test 35 | <<: *pa_dt 36 | 37 | - name: "Entry-RuleMode" 38 | type: select 39 | proxies: 40 | - DIRECT 41 | - Entry 42 | 43 | - name: "Entry-LastMatch" 44 | type: select 45 | proxies: 46 | - Entry 47 | - DIRECT 48 | 49 | proxy-providers: 50 | pvd: 51 | tpl_param: 52 | type: http 53 | <<: *pa_pp 54 | 55 | rules: 56 | - GEOIP,lan,DIRECT,no-resolve 57 | - GEOSITE,biliintl,Entry 58 | - GEOSITE,ehentai,Entry 59 | - GEOSITE,github,Entry 60 | - GEOSITE,twitter,Entry 61 | - GEOSITE,youtube,Entry 62 | - GEOSITE,google,Entry 63 | - GEOSITE,telegram,Entry 64 | - GEOSITE,netflix,Entry 65 | - GEOSITE,bilibili,Entry-RuleMode 66 | - GEOSITE,bahamut,Entry 67 | - GEOSITE,spotify,Entry 68 | - GEOSITE,geolocation-!cn,Entry 69 | - GEOIP,google,Entry 70 | - GEOIP,netflix,Entry 71 | - GEOIP,telegram,Entry 72 | - GEOIP,twitter,Entry 73 | - GEOSITE,pixiv,Entry 74 | - GEOSITE,CN,Entry-RuleMode 75 | - GEOIP,CN,Entry-RuleMode 76 | - MATCH,Entry-LastMatch 77 | -------------------------------------------------------------------------------- /InstallRes/templates/generic_tpl_with_all.yaml: -------------------------------------------------------------------------------- 1 | proxy-anchor: 2 | - delay_test: &pa_dt {url: https://www.gstatic.com/generate_204, interval: 300} 3 | - proxy_provider: &pa_pp {interval: 3600, intehealth-check: {enable: true, url: https://www.gstatic.com/generate_204, interval: 300}} 4 | 5 | 6 | proxy-groups: 7 | - name: "Entry" 8 | type: select 9 | proxies: 10 | - AllAt 11 | - AllSl 12 | - 13 | - 14 | 15 | - name: "AllSl" 16 | type: select 17 | use: 18 | - 19 | 20 | - name: "Sl" 21 | tpl_param: 22 | providers: ["pvd"] 23 | type: select 24 | 25 | - name: "AllAt" 26 | type: url-test 27 | proxies: 28 | - 29 | <<: *pa_dt 30 | 31 | - name: "At" 32 | tpl_param: 33 | providers: ["pvd"] 34 | type: url-test 35 | <<: *pa_dt 36 | 37 | - name: "Entry-RuleMode" 38 | type: select 39 | proxies: 40 | - DIRECT 41 | - Entry 42 | 43 | - name: "Entry-LastMatch" 44 | type: select 45 | proxies: 46 | - Entry 47 | - DIRECT 48 | 49 | proxy-providers: 50 | pvd: 51 | tpl_param: 52 | type: http 53 | <<: *pa_pp 54 | 55 | rules: 56 | - GEOIP,lan,DIRECT,no-resolve 57 | - GEOSITE,github,Entry 58 | - GEOSITE,twitter,Entry 59 | - GEOSITE,youtube,Entry 60 | - GEOSITE,google,Entry 61 | - GEOSITE,telegram,Entry 62 | - GEOSITE,netflix,Entry 63 | - GEOSITE,bilibili,Entry-RuleMode 64 | - GEOSITE,bahamut,Entry 65 | - GEOSITE,spotify,Entry 66 | - GEOSITE,steam@cn,Entry-RuleMode 67 | - GEOSITE,category-games@cn,Entry-RuleMode 68 | - GEOSITE,CN,Entry-RuleMode 69 | - GEOSITE,geolocation-!cn,Entry 70 | - GEOIP,google,Entry 71 | - GEOIP,netflix,Entry 72 | - GEOIP,telegram,Entry 73 | - GEOIP,twitter,Entry 74 | - GEOIP,CN,Entry-RuleMode 75 | - MATCH,Entry-LastMatch 76 | -------------------------------------------------------------------------------- /clashtui/src/utils/state.rs: -------------------------------------------------------------------------------- 1 | use super::SharedClashTuiUtil; 2 | use api::{Mode, TunStack}; 3 | 4 | pub struct _State { 5 | pub profile: String, 6 | pub mode: Option, 7 | pub tun: Option, 8 | pub no_pp: bool // no proxy providers 9 | } 10 | pub struct State { 11 | st: _State, 12 | ct: SharedClashTuiUtil, 13 | } 14 | impl State { 15 | pub fn new(ct: SharedClashTuiUtil) -> Self { 16 | Self { 17 | st: ct.update_state(None, None, None), 18 | ct, 19 | } 20 | } 21 | pub fn refresh(&mut self){ 22 | self.st = self.ct.update_state(None, None, None) 23 | } 24 | pub fn get_profile(&self) -> &String { 25 | &self.st.profile 26 | } 27 | pub fn set_profile(&mut self, profile: String) { 28 | self.st = self.ct.update_state(Some(profile), None, None) 29 | } 30 | pub fn set_mode(&mut self, mode: String) { 31 | self.st = self.ct.update_state(None, Some(mode), None) 32 | } 33 | pub fn switch_no_pp(&mut self) { 34 | let no_pp = !self.st.no_pp; 35 | self.st = self.ct.update_state(None, None, Some(no_pp)) 36 | } 37 | pub fn get_no_pp(&self) -> bool { 38 | self.st.no_pp 39 | } 40 | pub fn render(&self) -> String { 41 | let status_str = format!( 42 | "Profile: {} Mode: {} Tun: {} NoPp: {} Help: ?", 43 | self.st.profile, 44 | self.st 45 | .mode 46 | .as_ref() 47 | .map_or("Unknown".to_string(), |v| format!("{}", v)), 48 | self.st 49 | .tun 50 | .as_ref() 51 | .map_or("Unknown".to_string(), |v| format!("{}", v)), 52 | if self.st.no_pp {"On"} else {"Off"}, 53 | ); 54 | status_str 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /README_ZH.md: -------------------------------------------------------------------------------- 1 | # ClashTui 2 | 3 | ![Demo](https://github.com/user-attachments/assets/7a35f4a7-e400-4e73-b2ec-0d68f287b99c) 4 | 5 | Language: [English](./README.md) | [中文](./README_ZH.md) 6 | 7 |
8 | Table of Contents 9 | 10 | 11 | * [支持的平台](#支持的平台) 12 | * [适用人群](#适用人群) 13 | * [Install](#install) 14 | * [ClashTUI Usage](#clashtui-usage) 15 | * [Uninstall](#uninstall) 16 | * [See more](#see-more) 17 | * [尝试新东西](#尝试新东西) 18 | * [项目免责声明](#项目免责声明) 19 | 20 | 21 |
22 | 23 | ## 支持的平台 24 | 25 | - Linux 26 | - Windows. 请转到 [Windows README](https://github.com/JohanChane/clashtui/blob/win/README_ZH.md) 27 | 28 | ## 适用人群 29 | 30 | - 对 clash 配置有一定了解。 31 | - 喜欢 TUI 软件。 32 | 33 | ## Install 34 | 35 | ```sh 36 | # Optional. sudo pacman -S mihomo clashtui 37 | bash -c "$(curl -fsSL https://raw.githubusercontent.com/JohanChane/clashtui/refs/heads/main/install)" 38 | # Optional. sudo systemctl enable clashtui_mihomo 39 | ``` 40 | 41 | 如果你想手动安装。请参考 [Install Manually](./Doc/install_clashtui_manually_zh.md) 42 | 43 | ## ClashTUI Usage 44 | 45 | See [clashtui_usage](./Doc/clashtui_usage.md) 46 | 47 | ## Uninstall 48 | 49 | ```sh 50 | curl -o /tmp/install https://raw.githubusercontent.com/JohanChane/clashtui/refs/heads/main/install 51 | bash /tmp/install -u 52 | ``` 53 | ## See more 54 | 55 | [Doc](./Doc) 56 | 57 | ## 尝试新东西 58 | 59 | - [clashtui v3](https://github.com/JohanChane/clashtui/tree/master) 60 | - [clashcli](https://github.com/JohanChane/clashtui/tree/aio) 61 | 62 | ## 项目免责声明 63 | 64 | 此项目仅供学习和参考之用。作者并不保证项目中代码的准确性、完整性或适用性。使用者应当自行承担使用本项目代码所带来的风险。 65 | 66 | 作者对于因使用本项目代码而导致的任何直接或间接损失概不负责,包括但不限于数据丢失、计算机损坏、业务中断等。 67 | 68 | 使用者应在使用本项目代码前,充分了解其功能和潜在风险,并在必要时寻求专业建议。对于因对本项目代码的使用而导致的任何后果,作者不承担任何责任。 69 | 70 | 在使用本项目代码时,请遵守相关法律法规,不得用于非法活动或侵犯他人权益的行为。 71 | 72 | 作者保留对本免责声明的最终解释权,并可能随时对其进行修改和更新。 73 | -------------------------------------------------------------------------------- /clashtui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "clashtui" 3 | version = "0.2.3" 4 | edition = "2021" 5 | 6 | authors = ["Johan Chane "] 7 | description = "Mihomo TUI Client" 8 | license = "MIT" 9 | repository = "https://github.com/JohanChane/clashtui" 10 | homepage = "https://github.com/JohanChane/clashtui" 11 | documentation = "https://github.com/JohanChane/clashtui" 12 | readme = "README.md" 13 | 14 | include = ["clashtui/*", "LICENSE", "README.md", "README_ZH.md"] 15 | 16 | [badges] 17 | maintenance = { status = "passively-maintained" } 18 | 19 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 20 | 21 | [dependencies] 22 | ui = { path = "ui" } 23 | api = { path = "api" } 24 | ratatui = {version = "^0", default-features = false, features = ["crossterm"]} 25 | serde = {version = "^1", default-features = false} 26 | argh = "^0" 27 | serde_yaml = "^0" 28 | serde_json = "^1" 29 | log = "^0" 30 | log4rs = {version = "^1", default-features = false, features = ["pattern_encoder", "file_appender"]} 31 | enumflags2 = "^0" 32 | nix = {version = "^0", features = ["fs", "user"]} 33 | regex = {version = "^1", default-features = false, features = ["std", "unicode-perl"]} 34 | chrono = "^0" 35 | strum = "^0" 36 | strum_macros = "^0.23" 37 | 38 | [workspace] 39 | resolver = '2' 40 | members = ["api", "ui", "ui-derive"] 41 | 42 | [profile.release] 43 | lto = "fat" 44 | opt-level = 'z' 45 | strip = true 46 | 47 | [package.metadata.deb] 48 | maintainer = 'Jackhr-arch <63526062+Jackhr-arch@users.noreply.github.com>' 49 | extended-description = """ 50 | A tui tool for mihomo 51 | """ 52 | depends = "$auto" 53 | section = "utility" 54 | priority = "optional" 55 | assets = [ 56 | ['target/release/clashtui', 'usr/bin/clashtui', '755'], 57 | ['../README.md', 'usr/share/doc/clashtui/README.md', '644'], 58 | ] 59 | maintainer-scripts = 'debian/' 60 | -------------------------------------------------------------------------------- /clashtui/ui/src/utils/tools.rs: -------------------------------------------------------------------------------- 1 | use ratatui::prelude as Ra; 2 | use Ra::{Constraint, Direction, Layout, Rect}; 3 | 4 | /// Create a centered rect using up certain percentage of the available rect `r` 5 | pub fn centered_percent_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 6 | let popup_layout = Layout::default() 7 | .direction(Direction::Vertical) 8 | .constraints( 9 | [ 10 | Constraint::Percentage((100 - percent_y) / 2), 11 | Constraint::Percentage(percent_y), 12 | Constraint::Percentage((100 - percent_y) / 2), 13 | ] 14 | .as_ref(), 15 | ) 16 | .split(r); 17 | 18 | Layout::default() 19 | .direction(Direction::Horizontal) 20 | .constraints( 21 | [ 22 | Constraint::Percentage((100 - percent_x) / 2), 23 | Constraint::Percentage(percent_x), 24 | Constraint::Percentage((100 - percent_x) / 2), 25 | ] 26 | .as_ref(), 27 | ) 28 | .split(popup_layout[1])[1] 29 | } 30 | 31 | /// Create a centered rect using specific lengths for width and height 32 | pub fn centered_lenght_rect(width: u16, height: u16, container: Rect) -> Rect { 33 | let popup_layout = Layout::default() 34 | .direction(Direction::Vertical) 35 | .constraints([ 36 | Constraint::Length((container.height - height) / 2), 37 | Constraint::Length(height), 38 | Constraint::Length((container.height - height) / 2), 39 | ]) 40 | .split(container); 41 | 42 | Layout::default() 43 | .direction(Direction::Horizontal) 44 | .constraints([ 45 | Constraint::Length((container.width - width) / 2), 46 | Constraint::Length(width), 47 | Constraint::Length((container.width - width) / 2), 48 | ]) 49 | .split(popup_layout[1])[1] 50 | } 51 | -------------------------------------------------------------------------------- /clashtui/ui/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::new_without_default)] 2 | extern crate ui_derive; 3 | pub use crossterm::event; 4 | pub use ui_derive::Visibility; 5 | /// Visibility-related functions, can be impl using `derive` 6 | /// 7 | /// Require `is_visible:bool` in the struct 8 | pub trait Visibility { 9 | fn is_visible(&self) -> bool; 10 | fn show(&mut self); 11 | fn hide(&mut self); 12 | fn set_visible(&mut self, b: bool); 13 | } 14 | pub mod utils; 15 | pub mod widgets; 16 | pub use utils::{EventState, Infailable, Theme}; 17 | 18 | pub mod setup { 19 | use crossterm::{ 20 | cursor, 21 | event::{DisableMouseCapture, EnableMouseCapture}, 22 | execute, 23 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 24 | }; 25 | pub fn setup() -> Result<(), std::io::Error> { 26 | enable_raw_mode()?; 27 | execute!(std::io::stdout(), EnterAlternateScreen, EnableMouseCapture) 28 | } 29 | pub fn restore() -> Result<(), std::io::Error> { 30 | disable_raw_mode()?; 31 | execute!( 32 | std::io::stdout(), 33 | LeaveAlternateScreen, 34 | DisableMouseCapture, 35 | cursor::Show 36 | ) 37 | } 38 | } 39 | 40 | #[cfg(test)] 41 | mod tests { 42 | use super::Visibility; 43 | #[test] 44 | fn set() { 45 | #[derive(Visibility)] 46 | struct Test { 47 | is_visible: bool, 48 | } 49 | let mut x = Test { is_visible: false }; 50 | assert!(!x.is_visible); 51 | assert!(!x.is_visible()); 52 | x.show(); 53 | assert!(x.is_visible()); 54 | x.hide(); 55 | assert!(!x.is_visible()); 56 | x.set_visible(true); 57 | assert!(x.is_visible()); 58 | } 59 | // Due to the leak of is_visible in this struct, It won't even pass build 60 | 61 | // #[test] 62 | // #[should_panic] 63 | // fn bad(){ 64 | // #[derive(Visibility)] 65 | // struct BadTest{place:bool} 66 | // } 67 | } 68 | -------------------------------------------------------------------------------- /Example/templates/generic_tpl.yaml: -------------------------------------------------------------------------------- 1 | proxy-anchor: 2 | - delay_test: &pa_dt {url: https://www.gstatic.com/generate_204, interval: 300} 3 | - proxy_provider: &pa_pp {interval: 3600, intehealth-check: {enable: true, url: https://www.gstatic.com/generate_204, interval: 300}} 4 | 5 | proxy-groups: 6 | - name: "Entry" 7 | type: select 8 | proxies: 9 | - # 使用 proxy-groups 中的 `At` 模板代理组。 10 | - # 与 `` 同理。 11 | 12 | - name: "Sl" # 定义名称是 `Sl` (名称可自定义) 的模板代理组。根据模板代理提供者 `pvd`, 会生成 `Sl-pvd0`, `Sl-pvd1`, ... 13 | tpl_param: 14 | providers: ["pvd"] # 表示使用名称是 `pvd` 的模板代理提供者。 15 | type: select 16 | 17 | - name: "At" # 与 `Sl` 同理。 18 | tpl_param: 19 | providers: ["pvd"] 20 | type: url-test 21 | <<: *pa_dt 22 | 23 | - name: "Entry-RuleMode" # 类似于黑白名单模式。用于控制有无代理都可以访问的网站使用代理或直连。 24 | type: select 25 | proxies: 26 | - DIRECT 27 | - Entry 28 | 29 | - name: "Entry-LastMatch" # 设置不匹配规则的连接的入口。 30 | type: select 31 | proxies: 32 | - Entry 33 | - DIRECT 34 | 35 | proxy-providers: 36 | pvd: # 定义名称是 `pvd` (名称可自定义) 的模板代理提供者。会生成 `pvd0`, `pvd1`, ... 37 | tpl_param: 38 | type: http # type 字段要放在此处, 不能放入 pp。原因是要用于更新资源。 39 | <<: *pa_pp 40 | 41 | rules: 42 | #- IN-TYPE,INNER,DIRECT # 设置 mihomo 内部的网络连接(比如: 更新 proxy-providers, rule-providers 等)是直连。 43 | - GEOIP,lan,DIRECT,no-resolve 44 | - GEOSITE,biliintl,Entry 45 | - GEOSITE,ehentai,Entry 46 | - GEOSITE,github,Entry 47 | - GEOSITE,twitter,Entry 48 | - GEOSITE,youtube,Entry 49 | - GEOSITE,google,Entry 50 | - GEOSITE,telegram,Entry 51 | - GEOSITE,netflix,Entry 52 | - GEOSITE,bilibili,Entry-RuleMode 53 | - GEOSITE,bahamut,Entry 54 | - GEOSITE,spotify,Entry 55 | - GEOSITE,geolocation-!cn,Entry 56 | - GEOIP,google,Entry 57 | - GEOIP,netflix,Entry 58 | - GEOIP,telegram,Entry 59 | - GEOIP,twitter,Entry 60 | - GEOSITE,pixiv,Entry 61 | - GEOSITE,CN,Entry-RuleMode 62 | - GEOIP,CN,Entry-RuleMode 63 | - MATCH,Entry-LastMatch 64 | -------------------------------------------------------------------------------- /clashtui/ui-derive/src/lib.rs: -------------------------------------------------------------------------------- 1 | use proc_macro::{self, TokenStream}; 2 | use quote::quote; 3 | use syn::{parse_macro_input, DeriveInput}; 4 | 5 | #[proc_macro_derive(Visibility)] 6 | pub fn derive(input: TokenStream) -> TokenStream { 7 | let DeriveInput { ident, .. } = parse_macro_input!(input); 8 | let output = quote! { 9 | impl Visibility for #ident { 10 | fn is_visible(&self) -> bool { 11 | self.is_visible 12 | } 13 | fn show(&mut self) { 14 | self.is_visible = true; 15 | } 16 | fn hide(&mut self) { 17 | self.is_visible = false; 18 | } 19 | fn set_visible(&mut self, b: bool) { 20 | self.is_visible = b; 21 | } 22 | } 23 | }; 24 | output.into() 25 | } 26 | 27 | // macro_rules! title_methods { 28 | // ($type:ident) => { 29 | // impl $type { 30 | // pub fn get_title(&self) -> &String { 31 | // &self.title 32 | // } 33 | // } 34 | // }; 35 | // } 36 | // 37 | // macro_rules! fouce_methods { 38 | // ($type:ident) => { 39 | // impl $type { 40 | // pub fn is_fouce(&self) -> bool { 41 | // self.is_fouce 42 | // } 43 | // 44 | // pub fn set_fouce(&mut self, is_fouce: bool) { 45 | // self.is_fouce = is_fouce; 46 | // } 47 | // } 48 | // }; 49 | // } 50 | // 51 | // macro_rules! msgpopup_methods { 52 | // ($type:ident) => { 53 | // impl $type { 54 | // pub fn popup_txt_msg(&mut self, msg: String) { 55 | // self.msgpopup.push_txt_msg(msg); 56 | // self.msgpopup.show(); 57 | // } 58 | // pub fn popup_list_msg(&mut self, msg: Vec) { 59 | // self.msgpopup.push_list_msg(msg); 60 | // self.msgpopup.show(); 61 | // } 62 | // pub fn hide_msgpopup(&mut self) { 63 | // self.msgpopup.hide(); 64 | // } 65 | // } 66 | // }; 67 | // } 68 | // 69 | -------------------------------------------------------------------------------- /Doc/my/basic_clash_config.yaml: -------------------------------------------------------------------------------- 1 | # 随意配置的, 仅供参考。 2 | mixed-port: 7890 3 | ipv6: false 4 | allow-lan: false 5 | log-level: silent 6 | unified-delay: false 7 | tcp-concurrent: true # 域名有多个IP 时,就并发尝试所有IP连接的TCP握手。有一个成功即可。 8 | 9 | secret: "" 10 | external-controller: 127.0.0.1:9090 11 | external-ui: uis/metacubexd 12 | external-ui-url: https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip 13 | 14 | #geodata-mode: true 15 | #geox-url: 16 | # geoip: "https://mirror.ghproxy.com/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip-lite.dat" 17 | # geosite: "https://mirror.ghproxy.com/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat" 18 | # mmdb: "https://mirror.ghproxy.com/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country-lite.mmdb" 19 | # asn: "https://mirror.ghproxy.com/https://github.com/xishang0128/geoip/releases/download/latest/GeoLite2-ASN.mmdb" 20 | 21 | geo-auto-update: true 22 | geo-update-interval: 7 23 | 24 | #find-process-mode: strict 25 | #global-client-fingerprint: chrome 26 | 27 | profile: 28 | store-selected: true 29 | store-fake-ip: true 30 | 31 | dns: 32 | enable: true 33 | prefer-h3: true 34 | #listen: :1053 # for redirect/tproxy 35 | ipv6: false 36 | respect-rules: true 37 | enhanced-mode: fake-ip 38 | fake-ip-filter: 39 | - "*" 40 | - "+.lan" 41 | - "+.local" 42 | nameserver: 43 | - https://120.53.53.53/dns-query 44 | - https://223.5.5.5/dns-query 45 | proxy-server-nameserver: 46 | - https://120.53.53.53/dns-query 47 | - https://223.5.5.5/dns-query 48 | nameserver-policy: 49 | geosite:cn,private: 50 | #- 114.114.114.114 51 | #- 223.5.5.5 52 | - https://120.53.53.53/dns-query 53 | - https://223.5.5.5/dns-query 54 | geosite:geolocation-!cn: 55 | #- 8.8.8.8 56 | - https://dns.cloudflare.com/dns-query 57 | - https://dns.google/dns-query 58 | 59 | tun: 60 | enable: true 61 | stack: system 62 | dns-hijack: 63 | - "any:53" 64 | - "tcp://any:53" 65 | auto-route: true 66 | auto-redirect: true 67 | auto-detect-interface: true 68 | -------------------------------------------------------------------------------- /InstallRes/templates/generic_tpl.yaml: -------------------------------------------------------------------------------- 1 | proxy-anchor: 2 | - delay_test: &pa_dt {url: https://www.gstatic.com/generate_204, interval: 300} 3 | - proxy_provider: &pa_pp {interval: 3600, intehealth-check: {enable: true, url: https://www.gstatic.com/generate_204, interval: 300}} 4 | 5 | proxy-groups: 6 | - name: "Entry" 7 | type: select 8 | proxies: 9 | - # 使用 proxy-groups 中的 `At` 模板代理组。 10 | - # 与 `` 同理。 11 | 12 | - name: "Sl" # 定义名称是 `Sl` (名称可自定义) 的模板代理组。根据模板代理提供者 `pvd`, 会生成 `Sl-pvd0`, `Sl-pvd1`, ... 13 | tpl_param: 14 | providers: ["pvd"] # 表示使用名称是 `pvd` 的模板代理提供者。 15 | type: select 16 | 17 | - name: "At" # 与 `Sl` 同理。 18 | tpl_param: 19 | providers: ["pvd"] 20 | type: url-test 21 | <<: *pa_dt 22 | 23 | - name: "Entry-RuleMode" # 类似于黑白名单模式。用于控制有无代理都可以访问的网站使用代理或直连。 24 | type: select 25 | proxies: 26 | - DIRECT 27 | - Entry 28 | 29 | - name: "Entry-LastMatch" # 设置不匹配规则的连接的入口。 30 | type: select 31 | proxies: 32 | - Entry 33 | - DIRECT 34 | 35 | proxy-providers: 36 | pvd: # 定义名称是 `pvd` (名称可自定义) 的模板代理提供者。会生成 `pvd0`, `pvd1`, ... 37 | tpl_param: 38 | type: http # type 字段要放在此处, 不能放入 pp。原因是要用于更新资源。 39 | <<: *pa_pp 40 | 41 | rules: 42 | #- IN-TYPE,INNER,DIRECT # 设置 mihomo 内部的网络连接(比如: 更新 proxy-providers, rule-providers 等)是直连。 43 | #- DOMAIN-SUFFIX,cn.bing.com,DIRECT 44 | #- DOMAIN-SUFFIX,bing.com,Entry 45 | #- DOMAIN,aur.archlinux.org,Entry 46 | 47 | - GEOIP,lan,DIRECT,no-resolve 48 | - GEOSITE,github,Entry 49 | - GEOSITE,twitter,Entry 50 | - GEOSITE,youtube,Entry 51 | - GEOSITE,google,Entry 52 | - GEOSITE,telegram,Entry 53 | - GEOSITE,netflix,Entry 54 | - GEOSITE,bilibili,Entry-RuleMode 55 | - GEOSITE,bahamut,Entry 56 | - GEOSITE,spotify,Entry 57 | - GEOSITE,steam@cn,Entry-RuleMode 58 | - GEOSITE,category-games@cn,Entry-RuleMode 59 | - GEOSITE,CN,Entry-RuleMode 60 | - GEOSITE,geolocation-!cn,Entry 61 | - GEOIP,google,Entry 62 | - GEOIP,netflix,Entry 63 | - GEOIP,telegram,Entry 64 | - GEOIP,twitter,Entry 65 | - GEOIP,CN,Entry-RuleMode 66 | - MATCH,Entry-LastMatch 67 | -------------------------------------------------------------------------------- /InstallRes/basic_clash_config.yaml: -------------------------------------------------------------------------------- 1 | # 随意配置的, 仅供参考。 2 | mixed-port: 7890 3 | ipv6: false 4 | allow-lan: false 5 | log-level: silent 6 | unified-delay: true 7 | tcp-concurrent: true # 域名有多个IP 时,就并发尝试所有IP连接的TCP握手。有一个成功即可。 8 | 9 | secret: "" 10 | external-controller: 127.0.0.1:9090 11 | external-ui: uis/metacubexd 12 | external-ui-url: https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip 13 | 14 | #geodata-mode: true 15 | #geox-url: 16 | # geoip: "https://mirror.ghproxy.com/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip-lite.dat" 17 | # geosite: "https://mirror.ghproxy.com/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat" 18 | # mmdb: "https://mirror.ghproxy.com/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country-lite.mmdb" 19 | # asn: "https://mirror.ghproxy.com/https://github.com/xishang0128/geoip/releases/download/latest/GeoLite2-ASN.mmdb" 20 | 21 | geo-auto-update: true 22 | geo-update-interval: 7 23 | 24 | #find-process-mode: strict 25 | #global-client-fingerprint: chrome 26 | 27 | profile: 28 | store-selected: true 29 | store-fake-ip: true 30 | 31 | dns: 32 | enable: true 33 | prefer-h3: true 34 | #listen: :1053 # for redirect/tproxy 35 | ipv6: false 36 | respect-rules: true 37 | enhanced-mode: fake-ip 38 | fake-ip-filter: 39 | - "*" 40 | - "+.lan" 41 | - "+.local" 42 | - "auth-6441.wifi.com" 43 | nameserver: 44 | - https://120.53.53.53/dns-query 45 | - https://223.5.5.5/dns-query 46 | proxy-server-nameserver: 47 | - https://120.53.53.53/dns-query 48 | - https://223.5.5.5/dns-query 49 | nameserver-policy: 50 | "auth-6441.wifi.com": "system://" # 使用系统 DNS 查询 51 | geosite:cn,private: 52 | #- 114.114.114.114 53 | #- 223.5.5.5 54 | - https://120.53.53.53/dns-query 55 | - https://223.5.5.5/dns-query 56 | geosite:geolocation-!cn: 57 | #- 8.8.8.8 58 | - https://dns.cloudflare.com/dns-query 59 | - https://dns.google/dns-query 60 | 61 | tun: 62 | enable: true 63 | stack: system 64 | dns-hijack: 65 | - "any:53" 66 | - "tcp://any:53" 67 | auto-route: true 68 | auto-redirect: true 69 | auto-detect-interface: true 70 | -------------------------------------------------------------------------------- /clashtui/src/tui/utils/info_popup.rs: -------------------------------------------------------------------------------- 1 | use crate::tui::{EventState, Visibility}; 2 | use ui::event::Event; 3 | use ratatui::prelude as Ra; 4 | use std::collections::HashMap; 5 | 6 | use super::list_popup::PopUp; 7 | 8 | pub struct InfoPopUp { 9 | inner: PopUp, 10 | items: HashMap, 11 | } 12 | #[derive(Clone, PartialEq, Eq, Hash)] 13 | enum Infos { 14 | TuiVer, 15 | MihomoVer, 16 | } 17 | impl core::fmt::Display for Infos { 18 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 19 | write!( 20 | f, 21 | "{}", 22 | match self { 23 | Infos::TuiVer => "ClashTui:".to_string(), 24 | Infos::MihomoVer => "Mihomo:".to_string(), 25 | } 26 | ) 27 | } 28 | } 29 | impl InfoPopUp { 30 | #[allow(unused)] 31 | pub fn set_items(&mut self, mihomover: Option<&String>) { 32 | if let Some(v) = mihomover { 33 | self.items.insert(Infos::MihomoVer, v.clone()); 34 | } else { 35 | return; 36 | } 37 | self.inner 38 | .set_items(self.items.iter().map(|(k, v)| format!("{k}:{v}"))) 39 | } 40 | pub fn with_items(mihomover: &str) -> Self { 41 | let mut items = HashMap::new(); 42 | items.insert(Infos::TuiVer, crate::VERSION.to_string()); 43 | items.insert(Infos::MihomoVer, mihomover.to_owned()); 44 | let mut inner = PopUp::new("Info".to_string()); 45 | inner.set_items(items.iter().map(|(k, v)| format!("{k}:{v}"))); 46 | Self { items, inner } 47 | } 48 | } 49 | 50 | impl InfoPopUp { 51 | pub fn event(&mut self, ev: &Event) -> Result { 52 | self.inner.event(ev) 53 | } 54 | pub fn draw(&mut self, f: &mut Ra::Frame, area: Ra::Rect) { 55 | self.inner.draw(f, area) 56 | } 57 | } 58 | 59 | impl Visibility for InfoPopUp { 60 | fn is_visible(&self) -> bool { 61 | self.inner.is_visible() 62 | } 63 | 64 | fn show(&mut self) { 65 | self.inner.show() 66 | } 67 | 68 | fn hide(&mut self) { 69 | self.inner.hide() 70 | } 71 | 72 | fn set_visible(&mut self, b: bool) { 73 | self.inner.set_visible(b) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /clashtui/src/tui/utils/list_popup.rs: -------------------------------------------------------------------------------- 1 | use crate::tui::{tools, EventState, Visibility}; 2 | use ui::event::{Event, KeyEventKind}; 3 | use ratatui::{prelude as Ra, widgets as Raw}; 4 | use std::cmp::{max, min}; 5 | use ui::widgets::List; 6 | 7 | use super::Keys; 8 | 9 | pub struct PopUp(List); 10 | impl PopUp { 11 | pub fn new(title: String) -> Self { 12 | let mut l = List::new(title); 13 | l.hide(); 14 | Self(l) 15 | } 16 | pub fn set_items(&mut self, items: I) 17 | where 18 | I: Iterator, 19 | T: Into, 20 | { 21 | self.0.set_items(items.map(|v| v.into()).collect()) 22 | } 23 | } 24 | impl Visibility for PopUp { 25 | fn is_visible(&self) -> bool { 26 | self.0.is_visible() 27 | } 28 | 29 | fn show(&mut self) { 30 | self.0.show() 31 | } 32 | 33 | fn hide(&mut self) { 34 | self.0.hide() 35 | } 36 | 37 | fn set_visible(&mut self, b: bool) { 38 | self.0.set_visible(b) 39 | } 40 | } 41 | 42 | impl PopUp { 43 | pub fn event(&mut self, ev: &Event) -> Result { 44 | if !self.0.is_visible() { 45 | return Ok(EventState::NotConsumed); 46 | } 47 | 48 | if let Event::Key(key) = ev { 49 | if key.kind == KeyEventKind::Press { 50 | match key.code.into() { 51 | Keys::Esc => self.0.hide(), 52 | 53 | _ => return self.0.event(ev), 54 | }; 55 | } 56 | } 57 | 58 | Ok(EventState::WorkDone) 59 | } 60 | pub fn draw(&mut self, f: &mut Ra::Frame, _area: Ra::Rect) { 61 | if !self.0.is_visible() { 62 | return; 63 | } 64 | // 自适应 65 | let items = self.0.get_items(); 66 | let item_len = items.len(); 67 | let max_item_width = items 68 | .iter() 69 | .map(|s| s.as_str()) 70 | .map(Raw::ListItem::new) 71 | .map(|i| i.width()) 72 | .max() 73 | .unwrap_or(0); 74 | let dialog_width = max(min(max_item_width + 2, f.size().width as usize - 4), 60); // min_width = 60 75 | let dialog_height = min(item_len + 2, f.size().height as usize - 6); 76 | let area = tools::centered_lenght_rect(dialog_width as u16, dialog_height as u16, f.size()); 77 | 78 | f.render_widget(Raw::Clear, area); 79 | self.0.draw(f, area, true); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /clashtui/ui/src/utils/theme.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::Color; 2 | use std::sync::OnceLock; 3 | 4 | static GLOBAL_THEME: OnceLock = OnceLock::new(); 5 | 6 | // Given that this is a single-theard app, sync do not seem to be important 7 | // But OnceCell impl !Sync, so this new type is needed 8 | // However, there seems to be not need to bypass this, so I should just keep it 9 | // 10 | // struct Bx(std::cell::OnceCell); 11 | // unsafe impl Sync for Bx {} 12 | 13 | // #[derive(serde::Serialize, serde::Deserialize)] 14 | pub struct Theme { 15 | pub popup_block_fg: Color, 16 | pub popup_text_fg: Color, 17 | 18 | pub input_text_selected_fg: Color, 19 | pub input_text_unselected_fg: Color, 20 | 21 | pub list_block_fouced_fg: Color, 22 | pub list_block_unfouced_fg: Color, 23 | pub list_hl_bg_fouced: Color, 24 | 25 | pub tabbar_text_fg: Color, 26 | pub tabbar_hl_fg: Color, 27 | 28 | pub statusbar_text_fg: Color, 29 | 30 | pub profile_update_interval_fg: Color, 31 | } 32 | 33 | impl Theme { 34 | pub fn load(_ph: Option<&std::path::PathBuf>) -> Result<(), String> { 35 | let _ = GLOBAL_THEME.set(Self::default()); 36 | Ok(()) 37 | } 38 | pub fn get() -> &'static Self { 39 | GLOBAL_THEME.get_or_init(Self::default) 40 | } 41 | //pub fn load(ph: Option<&std::path::PathBuf>) -> Result { 42 | // ph.map_or_else( 43 | // || Ok(Self::default()), 44 | // |ph| { 45 | // std::fs::File::open(ph) 46 | // .map_err(|e| e.to_string()) 47 | // .and_then(|f| serde_yaml::from_reader(f).map_err(|e| e.to_string())) 48 | // }, 49 | // ) 50 | //} 51 | } 52 | 53 | impl Default for Theme { 54 | fn default() -> Self { 55 | Self { 56 | popup_block_fg: Color::Rgb(0, 102, 102), 57 | popup_text_fg: Color::Rgb(46, 204, 113), 58 | 59 | input_text_selected_fg: Color::Yellow, 60 | input_text_unselected_fg: Color::Reset, 61 | 62 | list_block_fouced_fg: Color::Rgb(0, 204, 153), 63 | list_block_unfouced_fg: Color::Rgb(220, 220, 220), 64 | list_hl_bg_fouced: Color::Rgb(64, 64, 64), 65 | 66 | tabbar_text_fg: Color::Rgb(0, 153, 153), 67 | tabbar_hl_fg: Color::Rgb(46, 204, 113), 68 | 69 | statusbar_text_fg: Color::Rgb(20, 122, 122), 70 | 71 | profile_update_interval_fg: Color::Red, 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | ### Thank you for your willingness to fill in the error return! 11 | ### Here are some precautions, be sure to read to make it easier for us to handle 12 | 13 | #### ❗ | ISSUE determined that there is no same problem has been raised. 14 | #### 🌎 | Please fill in the environmental information accurately. 15 | #### ❔ | Download the debug build of your version for reproduction and provide full log content. Please delete the personal information and sensitive content that exists in the log by yourself. 16 | 17 | --- 18 | - type: checkboxes 19 | id: terms 20 | attributes: 21 | label: Please make sure you have read the above considerations and tick the confirmation box below. 22 | options: 23 | - label: I've looked for the question I'm asking in [Issue Tracker](https://github.com/JohanChane/clashtui/issues?q=is%3Aissue), and I didn't find the ISSUE for the same question. 24 | required: true 25 | - label: The latest ci build hasn't fix this 26 | required: true 27 | 28 | - type: markdown 29 | attributes: 30 | value: | 31 | ## Infos 32 | 33 | - type: input 34 | id: clashtui-ver 35 | attributes: 36 | label: Clashtui version 37 | validations: 38 | required: true 39 | 40 | - type: dropdown 41 | id: env-vm-ver 42 | attributes: 43 | label: OS 44 | options: 45 | - Windows 46 | - Linux 47 | validations: 48 | required: true 49 | 50 | - type: textarea 51 | id: reproduce-steps 52 | attributes: 53 | label: Steps to reproduce the behavior 54 | description: | 55 | What do we need to do to get the bug to appear? 56 | The concise and clear reproducing steps can help us locate the problem more quickly. 57 | validations: 58 | required: true 59 | 60 | - type: textarea 61 | id: expected 62 | attributes: 63 | label: Expected behavior 64 | validations: 65 | required: true 66 | 67 | - type: textarea 68 | id: actual 69 | attributes: 70 | label: Actual results 71 | validations: 72 | required: true 73 | 74 | - type: textarea 75 | id: logging 76 | attributes: 77 | label: logs 78 | validations: 79 | required: true 80 | 81 | - type: textarea 82 | id: extra-desc 83 | attributes: 84 | label: Additional context -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ClashTui 2 | 3 | ![Demo](https://github.com/user-attachments/assets/7a35f4a7-e400-4e73-b2ec-0d68f287b99c) 4 | 5 | Language: [English](./README.md) | [中文](./README_ZH.md) 6 | 7 | ## Table of Contents 8 | 9 |
10 | Table of Contents 11 | 12 | 13 | * [Supported Platforms](#supported-platforms) 14 | * [Target Audience](#target-audience) 15 | * [Install](#install) 16 | * [ClashTUI Usage](#clashtui-usage) 17 | * [Uninstall](#uninstall) 18 | * [See more](#see-more) 19 | * [Trying new things](#trying-new-things) 20 | * [Project Disclaimer](#project-disclaimer) 21 | 22 | 23 |
24 | 25 | ## Supported Platforms 26 | 27 | - Linux 28 | - Windows. Please refer to [Windows README](https://github.com/JohanChane/clashtui/blob/win/README.md) 29 | 30 | ## Target Audience 31 | 32 | - Those with a certain understanding of Clash configurations. 33 | - Fans of TUI software. 34 | 35 | ## Install 36 | 37 | ```sh 38 | # Optional. sudo pacman -S mihomo clashtui 39 | bash -c "$(curl -fsSL https://raw.githubusercontent.com/JohanChane/clashtui/refs/heads/main/install)" 40 | # Optional. sudo systemctl enable clashtui_mihomo 41 | ``` 42 | 43 | If you want to install manually. See [Install Manually](./Doc/install_clashtui_manually.md) 44 | 45 | ## ClashTUI Usage 46 | 47 | See [clashtui_usage](./Doc/clashtui_usage.md) 48 | 49 | ## Uninstall 50 | 51 | ```sh 52 | curl -o /tmp/install https://raw.githubusercontent.com/JohanChane/clashtui/refs/heads/main/install 53 | bash /tmp/install -u 54 | ``` 55 | 56 | ## See more 57 | 58 | [Doc](./Doc) 59 | 60 | ## Trying new things 61 | 62 | - [dev](https://github.com/JohanChane/clashtui/tree/dev) 63 | - [clashcli](https://github.com/JohanChane/clashtui/tree/aio) 64 | 65 | ## Project Disclaimer 66 | 67 | This project is for learning and reference purposes only. The author does not guarantee the accuracy, completeness, or applicability of the code in the project. Users should use the code in this project at their own risk. 68 | 69 | The author is not responsible for any direct or indirect losses caused by the use of the code in this project, including but not limited to data loss, computer damage, and business interruption. 70 | 71 | Before using the code in this project, users should fully understand its functionality and potential risks, and seek professional advice if necessary. The author is not liable for any consequences resulting from the use of the code in this project. 72 | 73 | When using the code in this project, please comply with relevant laws and regulations, and refrain from using it for illegal activities or activities that infringe upon the rights of others. 74 | 75 | The author reserves the right of final interpretation of this disclaimer, and may modify and update it at any time. 76 | -------------------------------------------------------------------------------- /clashtui/api/src/dl_mihomo.rs: -------------------------------------------------------------------------------- 1 | const MIHOMO: &str = "https://api.github.com/repos/MetaCubeX/mihomo/releases/latest"; 2 | const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; 3 | 4 | fn dl>(url: S) -> minreq::ResponseLazy { 5 | minreq::get(url) 6 | .with_header("user-agent", USER_AGENT) 7 | .with_timeout(120) 8 | .send_lazy() 9 | .unwrap() 10 | } 11 | fn dl_mihomo>(path: P) -> std::io::Result { 12 | let apinfo: super::GithubApi = serde_json::from_reader(dl(MIHOMO))?; 13 | 14 | let name = { 15 | // it should be ok to do at compile-time, since 64bit platform can run 32bit software 16 | #[cfg(target_os = "linux")] 17 | let os = "linux"; 18 | #[cfg(target_os = "windows")] 19 | let os = "windows"; 20 | #[cfg(target_arch = "x86_64")] 21 | let arch = "amd64"; 22 | #[cfg(target_arch = "x86")] 23 | let arch = "386"; 24 | let compat = false; 25 | let oldgo = false; 26 | let tag_name = apinfo.tag_name; 27 | let mut s = format!("mihomo-{os}-{arch}"); 28 | if compat { 29 | s.push_str("-compatible") 30 | } 31 | if oldgo { 32 | s.push_str("-go120") 33 | } 34 | // mihomo-linux-amd64-compatible-go120-v1.18.1.gz 35 | format!("{s}-{tag_name}.gz") 36 | }; 37 | let name = name.as_str(); 38 | let path = { 39 | let path = std::path::Path::new(path.as_ref()); 40 | if path.is_dir() { 41 | path.join(name) 42 | } else { 43 | return Err(std::io::Error::new(std::io::ErrorKind::Other, "not dir!")); 44 | } 45 | }; 46 | 47 | if let Some(v) = apinfo.assets.iter().find(|s| s.name == name) { 48 | let mut fp = std::fs::OpenOptions::new() 49 | .create(true) 50 | .write(true) 51 | .open(&path)?; 52 | std::io::copy(&mut dl(&v.browser_download_url), &mut fp)?; 53 | }; 54 | Ok(path.join(name)) 55 | } 56 | #[test] 57 | fn doit() { 58 | let cur = std::env::current_dir().unwrap(); 59 | println!("{cur:?}"); 60 | let worksapce = cur.parent().unwrap().parent().unwrap(); 61 | let program = dl_mihomo(worksapce).unwrap(); 62 | // TODO:unzip the file and chmod 63 | std::process::Command::new(program) 64 | .args([ 65 | "-d", 66 | worksapce.join("Example").to_str().unwrap(), 67 | "-f", 68 | worksapce 69 | .join("Example") 70 | .join("basic_clash_config.yaml") 71 | .to_str() 72 | .unwrap(), 73 | ]) 74 | .spawn() 75 | .unwrap(); 76 | } 77 | -------------------------------------------------------------------------------- /clashtui/src/tui/tabbar.rs: -------------------------------------------------------------------------------- 1 | use ui::event::{Event, KeyCode, KeyEventKind}; 2 | use ratatui::{prelude as Ra, widgets as Raw}; 3 | 4 | use super::Theme; 5 | use crate::tui::EventState; 6 | 7 | pub struct TabBar { 8 | is_visible: bool, 9 | tab_titles: Vec, 10 | index: usize, 11 | } 12 | impl TabBar { 13 | pub fn new(tab_titles: Vec) -> Self { 14 | Self { 15 | is_visible: true, 16 | tab_titles, 17 | index: 0, 18 | } 19 | } 20 | 21 | pub fn event(&mut self, ev: &Event) -> Result { 22 | if !self.is_visible { 23 | return Ok(EventState::NotConsumed); 24 | } 25 | 26 | let mut event_stata = EventState::NotConsumed; 27 | if let Event::Key(key) = ev { 28 | if key.kind == KeyEventKind::Press { 29 | event_stata = match key.code { 30 | // 1..=9 31 | // need to kown the range 32 | #[allow(clippy::is_digit_ascii_radix)] 33 | KeyCode::Char(c) if c.is_digit(10) && c != '0' => { 34 | let digit = c.to_digit(10); 35 | if let Some(d) = digit { 36 | if d <= self.tab_titles.len() as u32 { 37 | self.index = (d - 1) as usize; 38 | } 39 | } 40 | EventState::WorkDone 41 | } 42 | KeyCode::Tab => { 43 | self.next(); 44 | EventState::WorkDone 45 | } 46 | _ => EventState::NotConsumed, 47 | } 48 | } 49 | } 50 | 51 | Ok(event_stata) 52 | } 53 | 54 | pub fn draw(&mut self, f: &mut Ra::Frame, area: Ra::Rect) { 55 | let items: Vec = self 56 | .tab_titles 57 | .iter() 58 | .map(|t| { 59 | Ra::text::Line::from(Ra::Span::styled( 60 | t, 61 | Ra::Style::default().fg(Theme::get().tabbar_text_fg), 62 | )) 63 | }) 64 | .collect(); 65 | let tabs = Raw::Tabs::new(items) 66 | .block(Raw::Block::default().borders(Raw::Borders::ALL)) 67 | .highlight_style(Ra::Style::default().fg(Theme::get().tabbar_hl_fg)) 68 | .select(self.index); 69 | f.render_widget(tabs, area); 70 | } 71 | 72 | pub fn next(&mut self) { 73 | self.index = (self.index + 1) % self.tab_titles.len(); 74 | } 75 | #[allow(unused)] 76 | pub fn previous(&mut self) { 77 | if self.index > 0 { 78 | self.index -= 1; 79 | } else { 80 | self.index = self.tab_titles.len() - 1; 81 | } 82 | } 83 | 84 | pub fn selected(&self) -> Option<&String> { 85 | self.tab_titles.get(self.index) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /InstallRes/templates/generic_tpl_with_filter.yaml: -------------------------------------------------------------------------------- 1 | proxy-anchor: 2 | - delay_test: &pa_dt {url: https://www.gstatic.com/generate_204, interval: 300} 3 | - proxy_provider: &pa_pp {interval: 3600, intehealth-check: {enable: true, url: https://www.gstatic.com/generate_204, interval: 300}} 4 | - filter: &pa_flt 5 | #filter: "(?i)美|us|unitedstates|united states|日本|jp|japan|韩|kr|korea|southkorea|south korea|新|sg|singapore" 6 | exclude-filter: "(?i)剩余|到期|勿连接|不要连接|失联|中国|国内|cn|china|香港|hk|hongkong|hong kong|澳门|mo|macau|台湾|tw|taiwan|tai wan" 7 | 8 | proxy-groups: 9 | - name: "Entry" 10 | type: select 11 | proxies: 12 | #- FltAllAt 13 | #- FltAllLb 14 | - 15 | #- 16 | #- 17 | - FltAllSl 18 | - 19 | - 20 | 21 | - name: "FltAllSl" 22 | type: select 23 | use: 24 | - 25 | <<: *pa_flt 26 | 27 | - name: "Sl" 28 | tpl_param: 29 | providers: ["pvd"] 30 | type: select 31 | 32 | #- name: "FltAllAt" 33 | # type: url-test 34 | # proxies: 35 | # - 36 | # <<: *pa_dt 37 | 38 | #- name: "FltAllLb" 39 | # proxies: 40 | # - 41 | # type: load-balance 42 | # #strategy: consistent-hashing 43 | # #strategy: round-robin 44 | # <<: *pa_dt 45 | 46 | - name: "FltAt" 47 | tpl_param: 48 | providers: ["pvd"] 49 | type: url-test 50 | <<: [*pa_dt, *pa_flt] 51 | 52 | #- name: "FltFb" 53 | # tpl_param: 54 | # providers: ["pvd"] 55 | # type: fallback 56 | # <<: [*pa_dt, *pa_flt] 57 | # 58 | #- name: "FltLb" 59 | # tpl_param: 60 | # providers: ["pvd"] 61 | # type: load-balance 62 | # #strategy: consistent-hashing 63 | # #strategy: round-robin 64 | # <<: [*pa_dt, *pa_flt] 65 | 66 | - name: "At" 67 | tpl_param: 68 | providers: ["pvd"] 69 | type: url-test 70 | <<: *pa_dt 71 | 72 | - name: "Entry-RuleMode" 73 | type: select 74 | proxies: 75 | - DIRECT 76 | - Entry 77 | 78 | - name: "Entry-LastMatch" 79 | type: select 80 | proxies: 81 | - Entry 82 | - DIRECT 83 | 84 | proxy-providers: 85 | pvd: 86 | tpl_param: 87 | type: http 88 | <<: *pa_pp 89 | 90 | rules: 91 | - GEOIP,lan,DIRECT,no-resolve 92 | - GEOSITE,github,Entry 93 | - GEOSITE,twitter,Entry 94 | - GEOSITE,youtube,Entry 95 | - GEOSITE,google,Entry 96 | - GEOSITE,telegram,Entry 97 | - GEOSITE,netflix,Entry 98 | - GEOSITE,bilibili,Entry-RuleMode 99 | - GEOSITE,bahamut,Entry 100 | - GEOSITE,spotify,Entry 101 | - GEOSITE,steam@cn,Entry-RuleMode 102 | - GEOSITE,category-games@cn,Entry-RuleMode 103 | - GEOSITE,CN,Entry-RuleMode 104 | - GEOSITE,geolocation-!cn,Entry 105 | - GEOIP,google,Entry 106 | - GEOIP,netflix,Entry 107 | - GEOIP,telegram,Entry 108 | - GEOIP,twitter,Entry 109 | - GEOIP,CN,Entry-RuleMode 110 | - MATCH,Entry-LastMatch 111 | -------------------------------------------------------------------------------- /Example/templates/generic_tpl_with_filter.yaml: -------------------------------------------------------------------------------- 1 | proxy-anchor: 2 | - delay_test: &pa_dt {url: https://www.gstatic.com/generate_204, interval: 300} 3 | - proxy_provider: &pa_pp {interval: 3600, intehealth-check: {enable: true, url: https://www.gstatic.com/generate_204, interval: 300}} 4 | - filter: &pa_flt 5 | #filter: "(?i)美|us|unitedstates|united states|日本|jp|japan|韩|kr|korea|southkorea|south korea|新|sg|singapore" 6 | exclude-filter: "(?i)剩余|到期|勿连接|不要连接|失联|中国|国内|cn|china|香港|hk|hongkong|hong kong|澳门|mo|macau|台湾|tw|taiwan|tai wan" 7 | 8 | proxy-groups: 9 | - name: "Entry" 10 | type: select 11 | proxies: 12 | #- FltAllAt 13 | #- FltAllLb 14 | - 15 | #- 16 | #- 17 | - FltAllSl 18 | - 19 | - 20 | 21 | - name: "FltAllSl" 22 | type: select 23 | use: 24 | - 25 | <<: *pa_flt 26 | 27 | - name: "Sl" 28 | tpl_param: 29 | providers: ["pvd"] 30 | type: select 31 | 32 | #- name: "FltAllAt" 33 | # type: url-test 34 | # proxies: 35 | # - 36 | # <<: *pa_dt 37 | 38 | #- name: "FltAllLb" 39 | # proxies: 40 | # - 41 | # type: load-balance 42 | # #strategy: consistent-hashing 43 | # #strategy: round-robin 44 | # <<: *pa_dt 45 | 46 | - name: "FltAt" 47 | tpl_param: 48 | providers: ["pvd"] 49 | type: url-test 50 | <<: [*pa_dt, *pa_flt] 51 | 52 | #- name: "FltFb" 53 | # tpl_param: 54 | # providers: ["pvd"] 55 | # type: fallback 56 | # <<: [*pa_dt, *pa_flt] 57 | # 58 | #- name: "FltLb" 59 | # tpl_param: 60 | # providers: ["pvd"] 61 | # type: load-balance 62 | # #strategy: consistent-hashing 63 | # #strategy: round-robin 64 | # <<: [*pa_dt, *pa_flt] 65 | 66 | - name: "At" 67 | tpl_param: 68 | providers: ["pvd"] 69 | type: url-test 70 | <<: *pa_dt 71 | 72 | - name: "Entry-RuleMode" 73 | type: select 74 | proxies: 75 | - DIRECT 76 | - Entry 77 | 78 | - name: "Entry-LastMatch" 79 | type: select 80 | proxies: 81 | - Entry 82 | - DIRECT 83 | 84 | proxy-providers: 85 | pvd: 86 | tpl_param: 87 | type: http 88 | <<: *pa_pp 89 | 90 | rules: 91 | #- DOMAIN-SUFFIX,cn.bing.com,DIRECT 92 | #- DOMAIN-SUFFIX,bing.com,Entry 93 | #- DOMAIN,aur.archlinux.org,Entry 94 | 95 | - GEOIP,lan,DIRECT,no-resolve 96 | - GEOSITE,biliintl,Entry 97 | - GEOSITE,ehentai,Entry 98 | - GEOSITE,github,Entry 99 | - GEOSITE,twitter,Entry 100 | - GEOSITE,youtube,Entry 101 | - GEOSITE,google,Entry 102 | - GEOSITE,telegram,Entry 103 | - GEOSITE,netflix,Entry 104 | - GEOSITE,bilibili,Entry-RuleMode 105 | - GEOSITE,bahamut,Entry 106 | - GEOSITE,spotify,Entry 107 | - GEOSITE,geolocation-!cn,Entry 108 | - GEOIP,google,Entry 109 | - GEOIP,netflix,Entry 110 | - GEOIP,telegram,Entry 111 | - GEOIP,twitter,Entry 112 | - GEOSITE,pixiv,Entry 113 | - GEOSITE,CN,Entry-RuleMode 114 | - GEOIP,CN,Entry-RuleMode 115 | - MATCH,Entry-LastMatch 116 | -------------------------------------------------------------------------------- /Doc/win/install_clashtui_manually_zh.md: -------------------------------------------------------------------------------- 1 | # Install ClashTUI Manually 2 | 3 | ## 安装 mihomo 程序 4 | 5 | [安装 scoop](https://github.com/ScoopInstaller/Install) (可选): 6 | 7 | ```powershell 8 | irm get.scoop.sh -outfile 'install.ps1' 9 | .\install.ps1 -ScoopDir 'D:\Scoop' -ScoopGlobalDir 'D:\ScoopGlobal' -NoProxy # 我选择安装在 D 盘。 10 | ``` 11 | 12 | 通过 scoop 安装 mihomo: 13 | 14 | ```powershell 15 | scoop install main/mihomo 16 | ``` 17 | 18 | 也可以手动下载适合自己系统的 mihomo。See [mihomo github releases](https://github.com/MetaCubeX/mihomo/releases)。 19 | 20 | ## 检测 mihomo 是否能运行 21 | 22 | 创建 mihomo 运行需要的文件: 23 | 24 | ```powershell 25 | New-Item -ItemType Directory -Path "D:\ClashTUI\mihomo_config" # 路径不要有空格 26 | New-Item -ItemType File -Path "D:\ClashTUI\mihomo_config\config.yaml" # 添加你的 mihomo 配置 27 | ``` 28 | 29 | 运行 mihomo: 30 | 31 | ```powershell 32 | -d D:\ClashTUI\mihomo_config -f D:\ClashTUI\mihomo_config\config.yaml 33 | ``` 34 | 35 | 可能出现的问题: 36 | 1. 如果可以访问 mihomo 客户端 (比如: metacubexd) 而无法访问需要代理的网站, 则尝试允许 `mihomo.exe` 通过防火墙: 37 | - 如果通过 Scoop 安装 mihomo 的: 允许 `D:\Scoop\apps\mihomo\\mihomo.exe`, 而不是 current 路径的。之后 mihomo 升级版本之后, 可能还要继续这样的操作。 38 | - 如果手动下载 mihomo 安装的: 允许 39 | 2. mihomo 下载 geo 文件比较慢: 40 | 41 | ```powershell 42 | Invoke-WebRequest -Uri "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb" -OutFile "D:\ClashTUI\mihomo_config\geoip.metadb" 43 | Invoke-WebRequest -Uri "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat" -OutFile "D:\ClashTUI\mihomo_config\GeoSite.dat" 44 | ``` 45 | 46 | ## 安装 clashtui 47 | 48 | 通过 scoop 安装 clashtui: 49 | 50 | ```powershell 51 | scoop bucket add extras 52 | scoop install clashtui 53 | ``` 54 | 55 | 也可以手动下载。[clashtui github releases](https://github.com/JohanChane/clashtui/releases) 56 | 57 | ## 运行 clashtui 58 | 59 | 先运行 clashtui, 会在 `%APPDATA%/clashtui` 生成一些默认文件。然后修改 `%APPDATA%/clashtui/config.yaml`。配置参考 [ref](./clashtui_usage_zh.md) 60 | 61 | ```yaml 62 | # 下面参数对应命令 -d -f 63 | clash_core_path: "D:/ClashTUI/mihomo.exe" 64 | clash_cfg_dir: "D:/ClashTUI/mihomo_config" 65 | clash_cfg_path: "D:/ClashTUI/mihomo_config/config.yaml" 66 | clash_srv_name: "clashtui_mihomo" # nssm {install | remove | restart | stop | edit} 67 | ``` 68 | 69 | 1. 安装 [nssm](https://nssm.cc/download): 70 | - 下载并改名为 nssm。 71 | - 将命令加入 PATH 72 | 73 | 如果有 scoop, 则可以直接安装: 74 | 75 | ```powershell 76 | scoop install nssm 77 | ``` 78 | 79 | 2. 安装 [Loopback Manager](https://github.com/tiagonmas/Windows-Loopback-Exemption-Manager) (可选): 80 | - 下载并改名为 EnableLoopback.exe 81 | - 将命令加入 PATH 82 | 83 | 3. 通过 clashtui 安装和启动 clashtui_mihomo 服务: 84 | - 运行 clashtui。在 `ClashSrvCtl` Tab 选择 `InstallSrv`, 程序会根据上面的配置安装 `clashtui_mihomo` 内核服务。 85 | - 该服务会开机启动。安装之后启动内核服务, 使用 ClashSrvCtl Tab 的 `StartClashService` 启动 mihomo 服务。 86 | 87 | ## 下载模板 88 | 89 | See [ref](https://github.com/JohanChane/clashtui/blob/main/Doc/install_clashtui_manually_zh.md#%E4%B8%8B%E8%BD%BD%E6%A8%A1%E6%9D%BF-%E5%8F%AF%E9%80%89) 90 | -------------------------------------------------------------------------------- /clashtui/src/tui/utils/key_list.rs: -------------------------------------------------------------------------------- 1 | use ui::event::{KeyCode, KeyEvent}; 2 | #[derive(PartialEq)] 3 | pub enum Keys { 4 | ProfileSwitch, 5 | ProfileUpdate, 6 | ProfileUpdateAll, 7 | ProfileImport, 8 | ProfileDelete, 9 | ProfileTestConfig, 10 | ProfileInfo, 11 | ProfileNoPp, // no proxy provider 12 | TemplateSwitch, 13 | Edit, 14 | Preview, 15 | 16 | Down, 17 | Up, 18 | // Left, 19 | // Right, 20 | Select, 21 | Esc, 22 | Tab, 23 | 24 | SoftRestart, 25 | LogCat, 26 | AppQuit, 27 | AppConfig, 28 | ClashConfig, 29 | AppHelp, 30 | AppInfo, 31 | 32 | Reserved, 33 | } 34 | 35 | impl From for Keys { 36 | fn from(value: KeyCode) -> Self { 37 | match value { 38 | // Convention: Global Shortcuts As much as possible use uppercase. And Others as much as possible use lowcase to avoid conflicts with global shortcuts. ToDo: User can config shortcuts. 39 | 40 | // ## Common shortcuts 41 | KeyCode::Down | KeyCode::Char('j') => Keys::Down, 42 | KeyCode::Up | KeyCode::Char('k') => Keys::Up, 43 | KeyCode::Enter => Keys::Select, 44 | KeyCode::Esc => Keys::Esc, 45 | KeyCode::Tab => Keys::Tab, 46 | 47 | // ## Profile Tab shortcuts 48 | KeyCode::Char('p') => Keys::ProfileSwitch, // Not Global shortcuts 49 | KeyCode::Char('t') => Keys::TemplateSwitch, // Not Global shortcuts 50 | 51 | // ## For operating file in Profile and Template Windows 52 | KeyCode::Char('e') => Keys::Edit, 53 | KeyCode::Char('v') => Keys::Preview, 54 | 55 | // ## Profile windows shortcuts 56 | KeyCode::Char('u') => Keys::ProfileUpdate, 57 | KeyCode::Char('a') => Keys::ProfileUpdateAll, 58 | KeyCode::Char('i') => Keys::ProfileImport, 59 | KeyCode::Char('d') => Keys::ProfileDelete, 60 | KeyCode::Char('s') => Keys::ProfileTestConfig, 61 | KeyCode::Char('n') => Keys::ProfileInfo, 62 | KeyCode::Char('m') => Keys::ProfileNoPp, 63 | 64 | // ## Global Shortcuts (As much as possible use uppercase. And Others as much as possible use lowcase to avoid conflicts with global shortcuts.) 65 | KeyCode::Char('q') => Keys::AppQuit, // Exiting is a common operation, and most software also exits with "q", so let's use "q". 66 | KeyCode::Char('R') => Keys::SoftRestart, 67 | KeyCode::Char('L') => Keys::LogCat, 68 | KeyCode::Char('?') => Keys::AppHelp, 69 | KeyCode::Char('I') => Keys::AppInfo, 70 | KeyCode::Char('H') => Keys::AppConfig, 71 | KeyCode::Char('G') => Keys::ClashConfig, 72 | 73 | _ => Keys::Reserved, 74 | } 75 | } 76 | } 77 | 78 | // impl core::cmp::PartialEq for Keys { 79 | // fn eq(&self, other: &KeyCode) -> bool { 80 | // >::into(*other) == *self 81 | // } 82 | // } 83 | 84 | impl core::cmp::PartialEq for Keys { 85 | fn eq(&self, other: &KeyEvent) -> bool { 86 | >::into(other.code) == *self 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /clashtui/src/utils/tui/impl_app.rs: -------------------------------------------------------------------------------- 1 | use super::ClashTuiUtil; 2 | use crate::utils::state::_State; 3 | use std::path::Path; 4 | // IPC Related 5 | impl ClashTuiUtil { 6 | pub fn update_state(&self, new_pf: Option, new_mode: Option, no_pp: Option) -> _State { 7 | let (pf, mode, tun, no_pp_value) = self._update_state(new_pf, new_mode, no_pp); 8 | _State { 9 | profile: pf, 10 | mode, 11 | tun, 12 | no_pp: no_pp_value, 13 | } 14 | } 15 | 16 | pub fn fetch_recent_logs(&self, num_lines: usize) -> Vec { 17 | std::fs::read_to_string(self.clashtui_dir.join("clashtui.log")) 18 | .unwrap_or_default() 19 | .lines() 20 | .rev() 21 | .take(num_lines) 22 | .map(String::from) 23 | .collect() 24 | } 25 | /// Exec `cmd` for given `path` 26 | /// 27 | /// Auto detect `cmd` is_empty and use system default app to open `path` 28 | fn spawn_open(cmd: &str, path: &Path) -> std::io::Result<()> { 29 | use crate::utils::ipc::spawn; 30 | if !cmd.is_empty() { 31 | let open_cmd = cmd.replace("%s", path.to_str().unwrap_or("")); 32 | return spawn("sh", vec!["-c", open_cmd.as_str()]); 33 | } else { 34 | return spawn("xdg-open", vec![path.to_str().unwrap_or("")]); 35 | } 36 | } 37 | 38 | pub fn edit_file(&self, path: &Path) -> std::io::Result<()> { 39 | Self::spawn_open(self.tui_cfg.edit_cmd.as_str(), path) 40 | } 41 | pub fn open_dir(&self, path: &Path) -> std::io::Result<()> { 42 | Self::spawn_open(self.tui_cfg.open_dir_cmd.as_str(), path) 43 | } 44 | fn _update_state( 45 | &self, 46 | new_pf: Option, 47 | new_mode: Option, 48 | no_pp: Option, 49 | ) -> (String, Option, Option, bool) { 50 | if let Some(v) = new_mode { 51 | let load = format!(r#"{{"mode": "{}"}}"#, v); 52 | let _ = self 53 | .clash_api 54 | .config_patch(load) 55 | .map_err(|e| log::error!("Patch Errr: {}", e)); 56 | } 57 | 58 | let pf = match new_pf { 59 | Some(v) => { 60 | self.clashtui_data.borrow_mut().update_profile(&v); 61 | v 62 | } 63 | None => self.clashtui_data.borrow().current_profile.clone(), 64 | }; 65 | let clash_cfg = self 66 | .fetch_remote() 67 | .map_err(|e| log::warn!("Fetch Remote:{e}")) 68 | .ok(); 69 | let (mode, tun) = match clash_cfg { 70 | Some(v) => ( 71 | Some(v.mode), 72 | if v.tun.enable { 73 | Some(v.tun.stack) 74 | } else { 75 | None 76 | }, 77 | ), 78 | None => (None, None), 79 | }; 80 | 81 | if let Some(v) = no_pp { 82 | self.clashtui_data.borrow_mut().no_pp = v; 83 | } 84 | let no_pp_value = self.clashtui_data.borrow().no_pp; 85 | 86 | (pf, mode, tun, no_pp_value) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /clashtui/api/src/config.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | #[derive(Debug, Serialize, Deserialize, Default)] 3 | #[serde(rename_all = "kebab-case")] 4 | #[serde(default)] 5 | pub struct ClashConfig { 6 | pub mixed_port: usize, 7 | pub mode: Mode, 8 | pub log_level: LogLevel, 9 | pub allow_lan: bool, 10 | pub bind_address: String, 11 | pub ipv6: bool, 12 | //pub secret: String, 13 | pub tcp_concurrent: bool, 14 | //pub external_controller: String, 15 | pub global_client_fingerprint: String, 16 | pub global_ua: String, 17 | pub tun: TunConfig, 18 | pub dns: String, 19 | pub geodata_mode: bool, 20 | pub unified_delay: bool, 21 | pub geo_auto_update: bool, 22 | pub geo_update_interval: u16, 23 | pub find_process_mode: String, 24 | } 25 | impl std::str::FromStr for ClashConfig { 26 | type Err = std::fmt::Error; 27 | 28 | fn from_str(s: &str) -> Result { 29 | if s.is_empty() { 30 | return Err(std::fmt::Error); 31 | }; 32 | serde_json::from_str(s).map_err(|_| std::fmt::Error) 33 | } 34 | } 35 | 36 | #[derive(Debug, Serialize, Deserialize, Default, Clone, Copy)] 37 | #[serde(rename_all = "lowercase")] 38 | pub enum Mode { 39 | #[default] 40 | Rule, 41 | Global, 42 | Direct, 43 | } 44 | impl std::fmt::Display for Mode { 45 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 46 | let x = match self { 47 | Mode::Rule => "Rule", 48 | Mode::Global => "Global", 49 | Mode::Direct => "Direct", 50 | }; 51 | write!(f, "{}", x) 52 | } 53 | } 54 | impl From for String { 55 | fn from(val: Mode) -> Self { 56 | val.to_string() 57 | } 58 | } 59 | 60 | #[derive(Debug, Serialize, Deserialize, Default)] 61 | #[serde(rename_all = "lowercase")] 62 | pub enum LogLevel { 63 | Silent, 64 | Error, 65 | Warning, 66 | #[default] 67 | Info, 68 | Debug, 69 | } 70 | 71 | impl std::fmt::Display for LogLevel { 72 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 73 | let s = match self { 74 | LogLevel::Silent => "silent", 75 | LogLevel::Error => "error", 76 | LogLevel::Warning => "warning", 77 | LogLevel::Info => "info", 78 | LogLevel::Debug => "debug", 79 | }; 80 | write!(f, "{}", s) 81 | } 82 | } 83 | 84 | #[derive(Debug, Serialize, Deserialize, Default)] 85 | pub struct TunConfig { 86 | pub enable: bool, 87 | pub stack: TunStack, 88 | } 89 | 90 | #[derive(Debug, Serialize, Deserialize, Default, Clone, Copy)] 91 | pub enum TunStack { 92 | #[default] 93 | #[serde(alias = "Mixed")] 94 | Mixed, 95 | #[serde(alias = "gVisor")] 96 | Gvisor, 97 | #[serde(alias = "System")] 98 | System, 99 | } 100 | impl std::fmt::Display for TunStack { 101 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 102 | let val = match self { 103 | TunStack::Mixed => "Mixed", 104 | TunStack::Gvisor => "gVisor", 105 | TunStack::System => "System", 106 | }; 107 | write!(f, "{}", val) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Doc/clashtui_usage_zh.md: -------------------------------------------------------------------------------- 1 | # ClashTUI Usage 2 | 3 | ## ClashTUI 的配置 4 | 5 | 配置文件的路径是 `~/.config/clashtui/config.yaml`. 6 | 7 | ```yaml 8 | # 下面参数对应命令 -d -f 9 | basic: 10 | clash_config_dir: '/opt/clashtui/mihomo_config' 11 | clash_bin_path: '/usr/bin/mihomo' 12 | clash_config_path: '/opt/clashtui/config.yaml' 13 | timeout: null # 模拟 clash_ua 下载的超时时间。`null` 表示没有超时时间。单位是`秒`。 14 | service: 15 | clash_srv_name: 'mihomo' # systemctl {restart | stop} 16 | is_user: false # true: systemctl --user ... 17 | extra: 18 | edit_cmd: 'alacritty -e nvim "%s"' # `%s` 会被替换为相应的文件路径。如果为空, 则使用 `xdg-open` 打开文件。 19 | open_dir_cmd: 'alacritty -e ranger "%s"' 20 | ``` 21 | 22 | ## 快捷方式 23 | 24 | 按 `?` 显示 help。 25 | 26 | ## 便携模式 27 | 28 | 在 clashtui 程序所在的目录创建一个名为 `data` 的文件夹。则会将数据放在 `data` 内而不是 `~/.config/clashtui`。 29 | 30 | ## 结合 cronie 定时更新 profiles 31 | 32 | ```sh 33 | clashtui -u # 以命令行的模式更新所有 profiles。如果 profile 有 proxy-providers, 同时也会更新它们。 34 | ``` 35 | 36 | 所以可以结合 cronie 来定时更新 profiles: 37 | 38 | ```sh 39 | # crontab -e 40 | 0 10,14,16,22 * * * /usr/bin/env clashtui -u >> ~/cron.out 2>&1 41 | ``` 42 | 43 | cronie 的使用, See [ref](https://wiki.archlinuxcn.org/wiki/Cron)。 44 | 45 | ## ClashTUI 文件结构 46 | 47 | `~/.config/clahstui`: 48 | - basic_clash_config.yaml: 存放 mihomo 配置的基础字段, 这些字段会合并到 `clash_cfg_path`。 49 | - config.yaml: clashtui 程序的配置。 50 | - templates/template_proxy_providers: 存放模板使用的代理订阅。 51 | 52 | clash_config_path: mihomo 最终使用的配置。 53 | 54 | mihomo 配置的基础字段: 除了这些字段 "proxy-groups"、"proxy-providers"、"proxies"、"sub-rules"、"rules" 和 "rule-providers" 都是基础字段。 55 | 56 | ## Template 57 | 58 | 前提已经掌握 [mihomo 的配置](https://wiki.metacubex.one/config/)和 yaml 的语法。 59 | 60 | ### Proxy-Providers Template 61 | 62 | 作用: 为 `template_proxy_providers` 中的每个订阅生成一个 proxy-provider。 63 | 64 | For example: 65 | 66 | ```yaml 67 | proxy-anchor: 68 | - delay_test: &pa_dt {url: https://www.gstatic.com/generate_204, interval: 300} 69 | - proxy_provider: &pa_pp {interval: 3600, health-check: {enable: true, url: https://www.gstatic.com/generate_204, interval: 300}} 70 | 71 | proxy-providers: 72 | provider: 73 | tpl_param: 74 | type: http # type 字段要放在此处, 不能放入 pa_pp。原因是 clashtui 根据这个字段检测是否是网络资源。 75 | <<: *pa_pp 76 | ``` 77 | 78 | ### Proxy-Groups Template 79 | 80 | 作用: 为 Proxy-Providers template 生成的每个 proxy-provider 都生成一个 Proxy-Group. 81 | 82 | ```yaml 83 | proxy-groups: 84 | - name: "Select" 85 | tpl_param: 86 | providers: ["provider"] 87 | type: select 88 | 89 | - name: "Auto" 90 | tpl_param: 91 | providers: ["provider"] 92 | type: url-test 93 | <<: *pa_dt 94 | ``` 95 | 96 | ### Using Proxy-Groups Template 97 | 98 | 使用 `<>` 包含 Proxy-Group template 的名称即可使用 Proxy-Group template 生成的每个 proxy-group. 99 | 100 | For example: 101 | 102 | ```yaml 103 | proxy-groups: 104 | - name: "Entry" 105 | type: select 106 | proxies: 107 | - 108 | - 109 | ``` 110 | 111 | --- 112 | 113 | You can find the latest templates here. See [ref](https://github.com/JohanChane/clashtui/tree/main/InstallRes/templates). 114 | -------------------------------------------------------------------------------- /clashtui/ui/src/widgets/msg.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{Event, KeyCode, KeyEventKind}; 2 | use ratatui::{prelude as Ra, widgets as Raw}; 3 | use std::cmp::{max, min}; 4 | 5 | use crate::{utils::tools, EventState, Infailable, Theme}; 6 | 7 | /// Pop a Message Window 8 | /// 9 | /// Using arrow keys or `j\k\h\l`(vim-like) to navigate. 10 | /// Press Esc to close, do nothing for others 11 | /// 12 | /// Not impl [Visibility][crate::Visibility] but impl the functions 13 | #[derive(Default)] 14 | pub struct MsgPopup { 15 | is_visible: bool, 16 | msg: Vec, 17 | scroll_v: u16, 18 | scroll_h: u16, 19 | } 20 | impl MsgPopup { 21 | pub fn event(&mut self, ev: &Event) -> Result { 22 | if !self.is_visible { 23 | return Ok(EventState::NotConsumed); 24 | } 25 | 26 | if let Event::Key(key) = ev { 27 | if key.kind != KeyEventKind::Press { 28 | return Ok(EventState::NotConsumed); 29 | } 30 | match key.code { 31 | KeyCode::Esc => self.hide(), 32 | KeyCode::Down | KeyCode::Char('j') => self.scroll_down(), 33 | KeyCode::Up | KeyCode::Char('k') => self.scroll_up(), 34 | KeyCode::Left | KeyCode::Char('h') => self.scroll_left(), 35 | KeyCode::Right | KeyCode::Char('l') => self.scroll_right(), 36 | _ => {} 37 | } 38 | } 39 | 40 | Ok(EventState::WorkDone) 41 | } 42 | 43 | pub fn draw(&mut self, f: &mut Ra::Frame, _area: Ra::Rect) { 44 | //! area is only used to keep the args 45 | if !self.is_visible { 46 | return; 47 | } 48 | 49 | let text: Vec = self 50 | .msg 51 | .iter() 52 | .map(|s| { 53 | Ra::Line::from(Ra::Span::styled( 54 | s, 55 | Ra::Style::default().fg(Theme::get().popup_text_fg), 56 | )) 57 | }) 58 | .collect(); 59 | 60 | // 自适应 61 | let max_item_width = text.iter().map(|i| i.width()).max().unwrap_or(0); 62 | let dialog_width = max(min(max_item_width + 2, f.size().width as usize - 4), 60); // min_width = 60 63 | let dialog_height = min(if text.len() == 0 {3} else {text.len() + 2}, f.size().height as usize - 6); 64 | let area = tools::centered_lenght_rect(dialog_width as u16, dialog_height as u16, f.size()); 65 | 66 | let paragraph = if text.len() == 1 && max_item_width < area.width as usize { 67 | Raw::Paragraph::new(text) 68 | .wrap(Raw::Wrap { trim: true }) 69 | .alignment(Ra::Alignment::Center) 70 | } else { 71 | Raw::Paragraph::new(text).scroll((self.scroll_v, self.scroll_h)) 72 | }; 73 | 74 | let block = Raw::Block::new() 75 | .borders(Raw::Borders::ALL) 76 | .border_style(Ra::Style::default().fg(Theme::get().popup_block_fg)) 77 | .title("Msg"); 78 | 79 | f.render_widget(Raw::Clear, area); 80 | f.render_widget(paragraph.block(block), area); 81 | } 82 | 83 | pub fn scroll_up(&mut self) { 84 | if self.scroll_v > 0 { 85 | self.scroll_v -= 1; 86 | } 87 | } 88 | pub fn scroll_down(&mut self) { 89 | self.scroll_v += 1; 90 | } 91 | pub fn scroll_left(&mut self) { 92 | if self.scroll_h > 0 { 93 | self.scroll_h -= 1; 94 | } 95 | } 96 | pub fn scroll_right(&mut self) { 97 | self.scroll_h += 1; 98 | } 99 | 100 | pub fn is_visible(&self) -> bool { 101 | self.is_visible 102 | } 103 | pub fn show(&mut self) { 104 | self.is_visible = true; 105 | } 106 | pub fn hide(&mut self) { 107 | self.is_visible = false; 108 | self.msg.clear(); 109 | self.scroll_v = 0; 110 | self.scroll_h = 0; 111 | } 112 | pub fn set_msg(&mut self, msg: Vec) { 113 | self.msg = msg; 114 | } 115 | 116 | pub fn clear_msg(&mut self) { 117 | self.msg.clear(); 118 | } 119 | pub fn push_txt_msg(&mut self, msg: String) { 120 | //self.msg.clear(); // let hide() clear msg. 121 | self.msg.push(msg); 122 | } 123 | pub fn push_list_msg(&mut self, msg: impl IntoIterator) { 124 | //self.msg.clear(); // let hide() clear msg. 125 | self.msg.extend(msg); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Doc/my/templates/gpt.yaml: -------------------------------------------------------------------------------- 1 | proxy-anchor: 2 | - delay_test: &pa_dt {url: https://www.gstatic.com/generate_204, interval: 300} 3 | - proxy_provider: &pa_pp {interval: 3600, intehealth-check: {enable: true, url: https://www.gstatic.com/generate_204, interval: 300}} 4 | - filter: &pa_flt 5 | #filter: "(?i)美|us|unitedstates|united states|日本|jp|japan|韩|kr|korea|southkorea|south korea|新|sg|singapore" 6 | exclude-filter: "(?i)剩余|到期|勿连接|不要连接|失联|中国|国内|cn|china|香港|hk|hongkong|hong kong|澳门|mo|macau|台湾|tw|taiwan|tai wan" 7 | - filter_gpt: &pa_flt_gpt 8 | # proxy-group 在用 proixes 的情况下, filter 无效。 9 | #exclude-filter: "(?i)美|us|unitedstates|canada|mexico|巴西|brazil|阿根廷|argentina|英国|uk|united kingdom|德国|germany|法国|france|意大利|italy|西班牙|spain|荷兰|netherlands|瑞士|switzerland|瑞典|sweden|挪威|norway|丹麦|denmark|芬兰|finland|比利时|belgium|奥地利|austria|爱尔兰|ireland|葡萄牙|portugal|希腊|greece|波兰|poland|捷克|czech republic|匈牙利|hungary|斯洛伐克|slovakia|克罗地亚|croatia|罗马尼亚|romania|保加利亚|bulgaria|斯洛文尼亚|slovenia|拉脱维亚|latvia|立陶宛|lithuania|爱沙尼亚|estonia|冰岛|iceland" 10 | exclude-filter: "^(?!.*(?i)(美|us|unitedstates|canada|mexico|巴西|brazil|阿根廷|argentina|英国|uk|united kingdom|德国|germany|法国|france|意大利|italy|西班牙|spain|荷兰|netherlands|瑞士|switzerland|瑞典|sweden|挪威|norway|丹麦|denmark|芬兰|finland|比利时|belgium|奥地利|austria|爱尔兰|ireland|葡萄牙|portugal|希腊|greece|波兰|poland|捷克|czech republic|匈牙利|hungary|斯洛伐克|slovakia|克罗地亚|croatia|罗马尼亚|romania|保加利亚|bulgaria|斯洛文尼亚|slovenia|拉脱维亚|latvia|立陶宛|lithuania|爱沙尼亚|estonia|冰岛|iceland)).*$" 11 | 12 | proxy-groups: 13 | - name: "Entry" 14 | type: select 15 | proxies: 16 | - 17 | #- FltAllAt 18 | #- FltAllLb 19 | - 20 | #- 21 | #- 22 | #- FltAllSl 23 | - 24 | - 看视频和下载不要选这个 25 | 26 | - name: "Entry-Gpt" 27 | type: select 28 | proxies: 29 | - 30 | - 31 | 32 | #- name: "FltAllSl" 33 | # type: select 34 | # use: 35 | # - 36 | # <<: *pa_flt 37 | 38 | - name: "Sl-Gpt" 39 | tpl_param: 40 | providers: ["pvd"] 41 | type: select 42 | filter: "美|us|unitedstates" 43 | <<: *pa_flt_gpt 44 | 45 | - name: "Sl" 46 | tpl_param: 47 | providers: ["pvd"] 48 | type: select 49 | 50 | #- name: "FltAllAt" 51 | # type: url-test 52 | # proxies: 53 | # - 54 | # <<: *pa_dt 55 | 56 | #- name: "FltAllLb" 57 | # proxies: 58 | # - 59 | # type: load-balance 60 | # #strategy: consistent-hashing 61 | # #strategy: round-robin 62 | # <<: *pa_dt 63 | 64 | - name: "FltAt-Gpt" 65 | tpl_param: 66 | providers: ["pvd"] 67 | type: url-test 68 | filter: "(?i)美|us|unitedstates" 69 | <<: [*pa_dt, *pa_flt_gpt] 70 | 71 | - name: "FltAt" 72 | tpl_param: 73 | providers: ["pvd"] 74 | type: url-test 75 | <<: [*pa_dt, *pa_flt] 76 | 77 | #- name: "FltFb" 78 | # tpl_param: 79 | # providers: ["pvd"] 80 | # type: fallback 81 | # <<: [*pa_dt, *pa_flt] 82 | # 83 | #- name: "FltLb" 84 | # tpl_param: 85 | # providers: ["pvd"] 86 | # type: load-balance 87 | # #strategy: consistent-hashing 88 | # #strategy: round-robin 89 | # <<: [*pa_dt, *pa_flt] 90 | 91 | - name: "At" 92 | tpl_param: 93 | providers: ["pvd"] 94 | type: url-test 95 | <<: *pa_dt 96 | 97 | - name: "看视频和下载不要选这个" 98 | type: select 99 | use: 100 | - bak 101 | <<: *pa_flt 102 | 103 | - name: "Entry-RuleMode" 104 | type: select 105 | proxies: 106 | - DIRECT 107 | - Entry 108 | 109 | - name: "Entry-LastMatch" 110 | type: select 111 | proxies: 112 | - Entry 113 | - DIRECT 114 | 115 | proxy-providers: 116 | pvd: 117 | tpl_param: 118 | type: http 119 | <<: *pa_pp 120 | bak: 121 | type: http 122 | url: 123 | path: proxy-providers/tpl/my_tpl/bak.yaml 124 | <<: *pa_pp 125 | 126 | rules: 127 | - DOMAIN-SUFFIX,cn.bing.com,DIRECT 128 | - DOMAIN-SUFFIX,bing.com,Entry 129 | - DOMAIN,aur.archlinux.org,Entry 130 | 131 | - GEOIP,lan,DIRECT,no-resolve 132 | - GEOSITE,openai,Entry-Gpt 133 | - GEOSITE,github,Entry 134 | - GEOSITE,twitter,Entry 135 | - GEOSITE,youtube,Entry 136 | - GEOSITE,google,Entry 137 | - GEOSITE,telegram,Entry 138 | - GEOSITE,netflix,Entry 139 | - GEOSITE,bilibili,Entry-RuleMode 140 | - GEOSITE,bahamut,Entry 141 | - GEOSITE,spotify,Entry 142 | - GEOSITE,steam@cn,Entry-RuleMode 143 | - GEOSITE,category-games@cn,Entry-RuleMode 144 | - GEOSITE,CN,Entry-RuleMode 145 | - GEOSITE,geolocation-!cn,Entry 146 | - GEOIP,google,Entry 147 | - GEOIP,netflix,Entry 148 | - GEOIP,telegram,Entry 149 | - GEOIP,twitter,Entry 150 | - GEOIP,CN,Entry-RuleMode 151 | - MATCH,Entry-LastMatch 152 | -------------------------------------------------------------------------------- /Doc/install_clashtui_manually.md: -------------------------------------------------------------------------------- 1 | # Install ClashTUI Manually 2 | 3 | ## Install mihomo 4 | 5 | 1. Install the mihomo program 6 | 7 | ArchLinux: 8 | 9 | ```sh 10 | sudo pacman -S mihomo 11 | ``` 12 | 13 | 2. Create mihomo user and mihomo group, and add the user to the group 14 | 15 | ```sh 16 | sudo groupadd --system mihomo 17 | sudo useradd --system --no-create-home --gid mihomo --shell /bin/false mihomo 18 | sudo gpasswd -a $USER mihomo # Please log out and log back in for the group file permissions to take effect; this will be used later. 19 | groups $USER # Check if you have been added to the mihomo group 20 | ``` 21 | 22 | *It is possible that the mihomo user and group were already created during the installation of mihomo.* 23 | 24 | ## Install ClashTUI 25 | 26 | Install the ClashTUI program, e.g., on ArchLinux: 27 | 28 | ```sh 29 | sudo pacman -S clashtui 30 | ``` 31 | 32 | It is not recommended to use `cargo install clashtui` for installation: 33 | - Because subsequent versions of ClashTUI have not been uploaded to `crates.io`, as ClashTUI is now split into multiple modules. 34 | - Uploading to `crates.io` would require uploading each dependent module, and some modules do not need to be uploaded to `crates.io`. See [ref](https://users.rust-lang.org/t/is-it-possible-to-publish-crates-with-path-specified/91497/2). 35 | 36 | ## Run mihomo 37 | 38 | 1. Create necessary files 39 | 40 | ```sh 41 | sudo mkdir -p /opt/clashtui/mihomo_config 42 | cat > /opt/clashtui/mihomo_config/config.yaml << EOF 43 | mixed-port: 7890 44 | external-controller: 127.0.0.1:9090 45 | EOF 46 | 47 | # Optional. Download geo files in advance to make the first startup of the mihomo service respond faster. 48 | sudo curl -o /opt/clashtui/mihomo_config/geoip.metadb https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb 49 | sudo curl -o /opt/clashtui/mihomo_config/GeoSite.dat https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat 50 | 51 | sudo chown -R mihomo:mihomo /opt/clashtui/mihomo_config 52 | ``` 53 | 54 | 2. Create a systemd unit `clashtui_mihomo.service` 55 | 56 | It is recommended to use the [file](https://wiki.metacubex.one/startup/service/) provided by the mihomo documentation rather than the one provided by the installation. 57 | 58 | There may be differences that make unified modifications inconvenient. However, if you are familiar with it, you can use the one provided by the installation. 59 | 60 | Create the systemd configuration file /etc/systemd/system/clashtui_mihomo.service: (Added User and Group) 61 | 62 | ``` 63 | [Unit] 64 | Description=mihomo Daemon, Another Clash Kernel. 65 | After=network.target NetworkManager.service systemd-networkd.service iwd.service 66 | 67 | [Service] 68 | Type=simple 69 | User=mihomo 70 | Group=mihomo 71 | LimitNPROC=500 72 | LimitNOFILE=1000000 73 | CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_TIME CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE 74 | AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_TIME CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE 75 | Restart=always 76 | ExecStartPre=/usr/bin/sleep 1s 77 | ExecStart=/opt/clashtui/mihomo -d /opt/clashtui/mihomo_config 78 | ExecReload=/bin/kill -HUP $MAINPID 79 | 80 | [Install] 81 | WantedBy=multi-user.target 82 | ``` 83 | 84 | 3. Link the mihomo program (Optional): 85 | 86 | ```sh 87 | sudo ln -s $(which mihomo) /opt/clashtui/mihomo 88 | ``` 89 | 90 | 4. Enable startup on boot (Optional): 91 | 92 | ```sh 93 | sudo systemctl enable clashtui_mihomo 94 | ``` 95 | 96 | It is recommended to start the clashtui_mihomo systemd unit first to check for any issues: 97 | 98 | ```sh 99 | sudo systemctl start clashtui_mihomo 100 | ``` 101 | 102 | ## Configure ClashTUI 103 | 104 | First, run ClashTUI to generate necessary files. Then modify `$XDG_CONFIG_HOME/clashtui/config.yaml`. For configuration reference, see [ref](./clashtui_usage.md). 105 | 106 | Use the repository's `basic_clash_config.yaml` (Optional): 107 | 108 | ```sh 109 | curl -o $XDG_CONFIG_HOME/clashtui/basic_clash_config.yaml https://raw.githubusercontent.com/JohanChane/clashtui/refs/heads/main/InstallRes/basic_clash_config.yaml 110 | ``` 111 | 112 | ## Download Templates (Optional) 113 | 114 | ```sh 115 | cd $XDG_CONFIG_HOME/clashtui/templates 116 | 117 | curl -O "https://raw.githubusercontent.com/JohanChane/clashtui/refs/heads/main/InstallRes/templates/common_tpl.yaml" 118 | curl -O "https://raw.githubusercontent.com/JohanChane/clashtui/refs/heads/main/InstallRes/templates/generic_tpl.yaml" 119 | curl -O "https://raw.githubusercontent.com/JohanChane/clashtui/refs/heads/main/InstallRes/templates/generic_tpl_with_all.yaml" 120 | curl -O "https://raw.githubusercontent.com/JohanChane/clashtui/refs/heads/main/InstallRes/templates/generic_tpl_with_filter.yaml" 121 | curl -O "https://raw.githubusercontent.com/JohanChane/clashtui/refs/heads/main/InstallRes/templates/generic_tpl_with_ruleset.yaml" 122 | ``` 123 | -------------------------------------------------------------------------------- /clashtui/ui/src/widgets/input.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{Event, KeyCode, KeyEventKind}; 2 | use ratatui::{prelude as Ra, widgets as Raw}; 3 | 4 | use crate::{EventState, Infailable, Theme, Visibility}; 5 | /// Collect input and cache as [String] 6 | #[derive(Visibility)] 7 | pub struct InputPopup { 8 | title: String, 9 | is_visible: bool, 10 | input: String, 11 | cursor_position: usize, 12 | input_data: String, 13 | } 14 | 15 | impl InputPopup { 16 | pub fn new(title: String) -> Self { 17 | Self { 18 | title, 19 | is_visible: false, 20 | input: String::new(), 21 | cursor_position: 0, 22 | input_data: String::new(), 23 | } 24 | } 25 | 26 | pub fn event(&mut self, ev: &Event) -> Result { 27 | if !self.is_visible { 28 | return Ok(EventState::NotConsumed); 29 | } 30 | 31 | if let Event::Key(key) = ev { 32 | if key.kind == KeyEventKind::Press { 33 | match key.code { 34 | KeyCode::Char(to_insert) => self.enter_char(to_insert), 35 | KeyCode::Backspace => self.delete_char(), 36 | 37 | KeyCode::Left => self.move_cursor_left(), 38 | KeyCode::Right => self.move_cursor_right(), 39 | KeyCode::Enter => { 40 | self.hide(); 41 | self.handle_enter_ev(); 42 | } 43 | KeyCode::Esc => { 44 | self.hide(); 45 | self.handle_esc_ev(); 46 | } 47 | _ => {} 48 | }; 49 | } 50 | } 51 | 52 | Ok(EventState::WorkDone) 53 | } 54 | 55 | pub fn draw(&mut self, f: &mut Ra::Frame, area: Ra::Rect, is_selected: bool) { 56 | if !self.is_visible { 57 | return; 58 | } 59 | 60 | f.render_widget(Raw::Clear, area); 61 | 62 | let input = Raw::Paragraph::new(self.input.as_str()) 63 | .style(Ra::Style::default().fg(if is_selected { 64 | Theme::get().input_text_selected_fg 65 | } else { 66 | Theme::get().input_text_unselected_fg 67 | })) 68 | .block( 69 | Raw::Block::default() 70 | .borders(Raw::Borders::ALL) 71 | .title(self.title.as_str()), 72 | ); 73 | f.render_widget(input, area); 74 | } 75 | 76 | pub fn get_input_data(&self) -> String { 77 | self.input_data.clone() 78 | } 79 | 80 | pub fn set_pre_data(&mut self, info: String) { 81 | self.input = info; 82 | } 83 | 84 | pub fn handle_enter_ev(&mut self) { 85 | self.input_data.clone_from(&self.input); 86 | self.input.clear(); 87 | self.reset_cursor(); 88 | } 89 | pub fn handle_esc_ev(&mut self) { 90 | self.input.clear(); 91 | self.input_data.clear(); 92 | self.reset_cursor(); 93 | } 94 | } 95 | impl InputPopup { 96 | fn move_cursor_left(&mut self) { 97 | let cursor_moved_left = self.cursor_position.saturating_sub(1); 98 | self.cursor_position = self.clamp_cursor(cursor_moved_left); 99 | } 100 | 101 | fn move_cursor_right(&mut self) { 102 | let cursor_moved_right = self.cursor_position.saturating_add(1); 103 | self.cursor_position = self.clamp_cursor(cursor_moved_right); 104 | } 105 | 106 | fn enter_char(&mut self, new_char: char) { 107 | self.input.insert(self.cursor_position, new_char); 108 | self.move_cursor_right(); 109 | } 110 | 111 | fn delete_char(&mut self) { 112 | let is_not_cursor_leftmost = self.cursor_position != 0; 113 | if is_not_cursor_leftmost { 114 | // Method "remove" is not used on the saved text for deleting the selected char. 115 | // Reason: Using remove on String works on bytes instead of the chars. 116 | // Using remove would require special care because of char boundaries. 117 | 118 | let current_index = self.cursor_position; 119 | let from_left_to_current_index = current_index - 1; 120 | 121 | // Getting all characters before the selected character. 122 | let before_char_to_delete = self.input.chars().take(from_left_to_current_index); 123 | // Getting all characters after selected character. 124 | let after_char_to_delete = self.input.chars().skip(current_index); 125 | 126 | // Put all characters together except the selected one. 127 | // By leaving the selected one out, it is forgotten and therefore deleted. 128 | self.input = before_char_to_delete.chain(after_char_to_delete).collect(); 129 | self.move_cursor_left(); 130 | } 131 | } 132 | 133 | fn clamp_cursor(&self, new_cursor_pos: usize) -> usize { 134 | new_cursor_pos.clamp(0, self.input.len()) 135 | } 136 | 137 | fn reset_cursor(&mut self) { 138 | self.cursor_position = 0; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /clashtui/src/tui/tabs/clashsrvctl.rs: -------------------------------------------------------------------------------- 1 | use super::ClashSrvOp; 2 | use crate::msgpopup_methods; 3 | use crate::{ 4 | tui::{ 5 | symbols::CLASHSRVCTL, 6 | tools, 7 | utils::Keys, 8 | widgets::{List, MsgPopup}, 9 | EventState, Visibility, 10 | }, 11 | utils::{SharedClashTuiState, SharedClashTuiUtil}, 12 | }; 13 | use api::Mode; 14 | 15 | #[derive(Visibility)] 16 | pub struct ClashSrvCtlTab { 17 | is_visible: bool, 18 | 19 | main_list: List, 20 | msgpopup: MsgPopup, 21 | 22 | mode_selector: List, 23 | 24 | clashtui_util: SharedClashTuiUtil, 25 | clashtui_state: SharedClashTuiState, 26 | 27 | op: Option, 28 | } 29 | 30 | impl ClashSrvCtlTab { 31 | pub fn new(clashtui_util: SharedClashTuiUtil, clashtui_state: SharedClashTuiState) -> Self { 32 | let mut operations = List::new(CLASHSRVCTL.to_string()); 33 | operations.set_items(vec![ 34 | ClashSrvOp::SetPermission.into(), 35 | ClashSrvOp::StartClashService.into(), 36 | ClashSrvOp::StopClashService.into(), 37 | ClashSrvOp::SwitchMode.into(), 38 | ClashSrvOp::CloseConnections.into(), 39 | ]); 40 | let mut modes = List::new("Mode".to_string()); 41 | modes.set_items(vec![ 42 | Mode::Rule.into(), 43 | Mode::Direct.into(), 44 | Mode::Global.into(), 45 | ]); 46 | modes.hide(); 47 | 48 | Self { 49 | is_visible: false, 50 | main_list: operations, 51 | mode_selector: modes, 52 | clashtui_util, 53 | clashtui_state, 54 | msgpopup: Default::default(), 55 | op: None, 56 | } 57 | } 58 | } 59 | impl super::TabEvent for ClashSrvCtlTab { 60 | fn popup_event(&mut self, ev: &ui::event::Event) -> Result { 61 | if !self.is_visible { 62 | return Ok(EventState::NotConsumed); 63 | } 64 | let event_state; 65 | if self.mode_selector.is_visible() { 66 | event_state = self.mode_selector.event(ev)?; 67 | if event_state == EventState::WorkDone { 68 | return Ok(event_state); 69 | } 70 | if let ui::event::Event::Key(key) = ev { 71 | if &Keys::Select == key { 72 | if let Some(new) = self.mode_selector.selected() { 73 | self.clashtui_state.borrow_mut().set_mode(new.clone()); 74 | } 75 | self.mode_selector.hide(); 76 | } 77 | if &Keys::Esc == key { 78 | self.mode_selector.hide(); 79 | } 80 | } 81 | return Ok(EventState::WorkDone); 82 | } 83 | 84 | event_state = self.msgpopup.event(ev)?; 85 | 86 | Ok(event_state) 87 | } 88 | fn event(&mut self, ev: &ui::event::Event) -> Result { 89 | if !self.is_visible { 90 | return Ok(EventState::NotConsumed); 91 | } 92 | 93 | let event_state; 94 | if let ui::event::Event::Key(key) = ev { 95 | if key.kind != ui::event::KeyEventKind::Press { 96 | return Ok(EventState::NotConsumed); 97 | } 98 | // override `Enter` 99 | event_state = if &Keys::Select == key { 100 | let op = ClashSrvOp::from(self.main_list.selected().unwrap().as_str()); 101 | if let ClashSrvOp::SwitchMode = op { 102 | self.mode_selector.show(); 103 | } else { 104 | self.op.replace(op); 105 | self.popup_txt_msg("Working...".to_string()); 106 | } 107 | EventState::WorkDone 108 | } else { 109 | self.main_list.event(ev)? 110 | }; 111 | } else { 112 | event_state = EventState::NotConsumed 113 | } 114 | 115 | Ok(event_state) 116 | } 117 | fn late_event(&mut self) { 118 | if let Some(op) = self.op.take() { 119 | self.hide_msgpopup(); 120 | match op { 121 | ClashSrvOp::SwitchMode => unreachable!(), 122 | _ => match self.clashtui_util.clash_srv_ctl(op.clone()) { 123 | Ok(output) => { 124 | self.popup_list_msg(output.lines().map(|line| line.trim().to_string())); 125 | } 126 | Err(err) => { 127 | self.popup_txt_msg(err.to_string()); 128 | } 129 | }, 130 | } 131 | match op { 132 | // Ops that doesn't need refresh 133 | ClashSrvOp::SetPermission => {}, 134 | 135 | ClashSrvOp::StartClashService => { 136 | std::thread::sleep(std::time::Duration::from_secs(2)); // Waiting for mihomo to finish starting. 137 | self.clashtui_state.borrow_mut().refresh(); 138 | } 139 | _ => { 140 | self.clashtui_state.borrow_mut().refresh(); 141 | }, 142 | } 143 | } 144 | } 145 | fn draw(&mut self, f: &mut ratatui::prelude::Frame, area: ratatui::prelude::Rect) { 146 | if !self.is_visible() { 147 | return; 148 | } 149 | 150 | self.main_list.draw(f, area, true); 151 | if self.mode_selector.is_visible() { 152 | let select_area = tools::centered_percent_rect(60, 30, f.size()); 153 | f.render_widget(ratatui::widgets::Clear, select_area); 154 | self.mode_selector.draw(f, select_area, true); 155 | } 156 | self.msgpopup.draw(f, area); 157 | } 158 | } 159 | 160 | msgpopup_methods!(ClashSrvCtlTab); 161 | -------------------------------------------------------------------------------- /Example/templates/generic_tpl_with_ruleset.yaml: -------------------------------------------------------------------------------- 1 | proxy-anchor: 2 | - delay_test: &pa_dt {url: https://www.gstatic.com/generate_204, interval: 300} 3 | - proxy_provider: &pa_pp {interval: 3600, intehealth-check: {enable: true, url: https://www.gstatic.com/generate_204, interval: 300}} 4 | 5 | proxy-groups: 6 | - name: "Entry" 7 | type: select 8 | proxies: 9 | - 10 | - 11 | 12 | - name: "Sl" 13 | tpl_param: 14 | providers: ["pvd"] 15 | type: select 16 | 17 | - name: "At" 18 | tpl_param: 19 | providers: ["pvd"] 20 | type: url-test 21 | <<: *pa_dt 22 | 23 | - name: "Entry-RuleMode" 24 | type: select 25 | proxies: 26 | - DIRECT 27 | - Entry 28 | 29 | - name: "Entry-LastMatch" 30 | type: select 31 | proxies: 32 | - Entry 33 | - DIRECT 34 | 35 | proxy-providers: 36 | pvd: 37 | tpl_param: 38 | type: http 39 | <<: *pa_pp 40 | 41 | rule-anchor: 42 | ip: &ra_ip {interval: 86400, behavior: ipcidr, format: yaml} 43 | domain: &ra_domain {interval: 86400, behavior: domain, format: yaml} 44 | 45 | rule-providers: 46 | private: 47 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/private.yaml" 48 | type: http 49 | path: ./rule-providers/geosite/private.yaml 50 | <<: *ra_domain 51 | cn_domain: 52 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/cn.yaml" 53 | type: http 54 | path: ./rule-providers/geosite/cn.yaml 55 | <<: *ra_domain 56 | biliintl_domain: 57 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/biliintl.yaml" 58 | type: http 59 | path: ./rule-providers/geosite/biliintl.yaml 60 | <<: *ra_domain 61 | ehentai_domain: 62 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/ehentai.yaml" 63 | type: http 64 | path: ./rule-providers/geosite/ehentai.yaml 65 | <<: *ra_domain 66 | github_domain: 67 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/github.yaml" 68 | type: http 69 | path: ./rule-providers/geosite/github.yaml 70 | <<: *ra_domain 71 | twitter_domain: 72 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/twitter.yaml" 73 | type: http 74 | path: ./rule-providers/geosite/twitter.yaml 75 | <<: *ra_domain 76 | youtube_domain: 77 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/youtube.yaml" 78 | type: http 79 | path: ./rule-providers/geosite/youtube.yaml 80 | <<: *ra_domain 81 | google_domain: 82 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/google.yaml" 83 | type: http 84 | path: ./rule-providers/geosite/google.yaml 85 | <<: *ra_domain 86 | telegram_domain: 87 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/telegram.yaml" 88 | type: http 89 | path: ./rule-providers/geosite/telegram.yaml 90 | <<: *ra_domain 91 | netflix_domain: 92 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/netflix.yaml" 93 | type: http 94 | path: ./rule-providers/geosite/netflix.yaml 95 | <<: *ra_domain 96 | bilibili_domain: 97 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/bilibili.yaml" 98 | type: http 99 | path: ./rule-providers/geosite/bilibili.yaml 100 | <<: *ra_domain 101 | bahamut_domain: 102 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/bahamut.yaml" 103 | type: http 104 | path: ./rule-providers/geosite/bahamut.yaml 105 | <<: *ra_domain 106 | spotify_domain: 107 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/spotify.yaml" 108 | type: http 109 | path: ./rule-providers/geosite/spotify.yaml 110 | <<: *ra_domain 111 | pixiv_domain: 112 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/pixiv.yaml" 113 | type: http 114 | path: ./rule-providers/geosite/pixiv.yaml 115 | <<: *ra_domain 116 | geolocation-!cn: 117 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/geolocation-!cn.yaml" 118 | type: http 119 | path: ./rule-providers/geosite/geolocation-notcn.yaml 120 | <<: *ra_domain 121 | 122 | cn_ip: 123 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/cn.yaml" 124 | type: http 125 | path: ./rule-providers/geoip/cn.yaml 126 | <<: *ra_ip 127 | google_ip: 128 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/google.yaml" 129 | type: http 130 | path: ./rule-providers/geoip/google.yaml 131 | <<: *ra_ip 132 | netflix_ip: 133 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/netflix.yaml" 134 | type: http 135 | path: ./rule-providers/geoip/netflix.yaml 136 | <<: *ra_ip 137 | twitter_ip: 138 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/twitter.yaml" 139 | type: http 140 | path: ./rule-providers/geoip/twitter.yaml 141 | <<: *ra_ip 142 | telegram_ip: 143 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/telegram.yaml" 144 | type: http 145 | path: ./rule-providers/geoip/telegram.yaml 146 | <<: *ra_ip 147 | 148 | rules: 149 | - GEOIP,lan,DIRECT,no-resolve 150 | - RULE-SET,biliintl_domain,Entry 151 | - RULE-SET,ehentai_domain,Entry 152 | - RULE-SET,github_domain,Entry 153 | - RULE-SET,twitter_domain,Entry 154 | - RULE-SET,youtube_domain,Entry 155 | - RULE-SET,google_domain,Entry 156 | - RULE-SET,telegram_domain,Entry 157 | - RULE-SET,netflix_domain,Entry 158 | - RULE-SET,bilibili_domain,Entry-RuleMode 159 | - RULE-SET,bahamut_domain,Entry 160 | - RULE-SET,spotify_domain,Entry 161 | - RULE-SET,pixiv_domain,Entry 162 | - RULE-SET,geolocation-!cn,Entry 163 | - RULE-SET,google_ip,Entry 164 | - RULE-SET,netflix_ip,Entry 165 | - RULE-SET,telegram_ip,Entry 166 | - RULE-SET,twitter_ip,Entry 167 | 168 | - RULE-SET,cn_domain,Entry-RuleMode 169 | - RULE-SET,cn_ip,Entry-RuleMode 170 | - MATCH,Entry-LastMatch 171 | -------------------------------------------------------------------------------- /InstallRes/templates/generic_tpl_with_ruleset.yaml: -------------------------------------------------------------------------------- 1 | proxy-anchor: 2 | - delay_test: &pa_dt {url: https://www.gstatic.com/generate_204, interval: 300} 3 | - proxy_provider: &pa_pp {interval: 3600, intehealth-check: {enable: true, url: https://www.gstatic.com/generate_204, interval: 300}} 4 | 5 | proxy-groups: 6 | - name: "Entry" 7 | type: select 8 | proxies: 9 | - 10 | - 11 | 12 | - name: "Sl" 13 | tpl_param: 14 | providers: ["pvd"] 15 | type: select 16 | 17 | - name: "At" 18 | tpl_param: 19 | providers: ["pvd"] 20 | type: url-test 21 | <<: *pa_dt 22 | 23 | - name: "Entry-RuleMode" 24 | type: select 25 | proxies: 26 | - DIRECT 27 | - Entry 28 | 29 | - name: "Entry-LastMatch" 30 | type: select 31 | proxies: 32 | - Entry 33 | - DIRECT 34 | 35 | proxy-providers: 36 | pvd: 37 | tpl_param: 38 | type: http 39 | <<: *pa_pp 40 | 41 | rule-anchor: 42 | ip: &ra_ip {interval: 86400, behavior: ipcidr, format: yaml} 43 | domain: &ra_domain {interval: 86400, behavior: domain, format: yaml} 44 | 45 | rule-providers: 46 | private: 47 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/private.yaml" 48 | type: http 49 | path: ./rule-providers/geosite/private.yaml 50 | <<: *ra_domain 51 | cn_domain: 52 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/cn.yaml" 53 | type: http 54 | path: ./rule-providers/geosite/cn.yaml 55 | <<: *ra_domain 56 | biliintl_domain: 57 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/biliintl.yaml" 58 | type: http 59 | path: ./rule-providers/geosite/biliintl.yaml 60 | <<: *ra_domain 61 | ehentai_domain: 62 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/ehentai.yaml" 63 | type: http 64 | path: ./rule-providers/geosite/ehentai.yaml 65 | <<: *ra_domain 66 | github_domain: 67 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/github.yaml" 68 | type: http 69 | path: ./rule-providers/geosite/github.yaml 70 | <<: *ra_domain 71 | twitter_domain: 72 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/twitter.yaml" 73 | type: http 74 | path: ./rule-providers/geosite/twitter.yaml 75 | <<: *ra_domain 76 | youtube_domain: 77 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/youtube.yaml" 78 | type: http 79 | path: ./rule-providers/geosite/youtube.yaml 80 | <<: *ra_domain 81 | google_domain: 82 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/google.yaml" 83 | type: http 84 | path: ./rule-providers/geosite/google.yaml 85 | <<: *ra_domain 86 | telegram_domain: 87 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/telegram.yaml" 88 | type: http 89 | path: ./rule-providers/geosite/telegram.yaml 90 | <<: *ra_domain 91 | netflix_domain: 92 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/netflix.yaml" 93 | type: http 94 | path: ./rule-providers/geosite/netflix.yaml 95 | <<: *ra_domain 96 | bilibili_domain: 97 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/bilibili.yaml" 98 | type: http 99 | path: ./rule-providers/geosite/bilibili.yaml 100 | <<: *ra_domain 101 | bahamut_domain: 102 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/bahamut.yaml" 103 | type: http 104 | path: ./rule-providers/geosite/bahamut.yaml 105 | <<: *ra_domain 106 | spotify_domain: 107 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/spotify.yaml" 108 | type: http 109 | path: ./rule-providers/geosite/spotify.yaml 110 | <<: *ra_domain 111 | pixiv_domain: 112 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/pixiv.yaml" 113 | type: http 114 | path: ./rule-providers/geosite/pixiv.yaml 115 | <<: *ra_domain 116 | geolocation-!cn: 117 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/geolocation-!cn.yaml" 118 | type: http 119 | path: ./rule-providers/geosite/geolocation-notcn.yaml 120 | <<: *ra_domain 121 | 122 | cn_ip: 123 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/cn.yaml" 124 | type: http 125 | path: ./rule-providers/geoip/cn.yaml 126 | <<: *ra_ip 127 | google_ip: 128 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/google.yaml" 129 | type: http 130 | path: ./rule-providers/geoip/google.yaml 131 | <<: *ra_ip 132 | netflix_ip: 133 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/netflix.yaml" 134 | type: http 135 | path: ./rule-providers/geoip/netflix.yaml 136 | <<: *ra_ip 137 | twitter_ip: 138 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/twitter.yaml" 139 | type: http 140 | path: ./rule-providers/geoip/twitter.yaml 141 | <<: *ra_ip 142 | telegram_ip: 143 | url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/telegram.yaml" 144 | type: http 145 | path: ./rule-providers/geoip/telegram.yaml 146 | <<: *ra_ip 147 | 148 | rules: 149 | - GEOIP,lan,DIRECT,no-resolve 150 | - RULE-SET,biliintl_domain,Entry 151 | - RULE-SET,ehentai_domain,Entry 152 | - RULE-SET,github_domain,Entry 153 | - RULE-SET,twitter_domain,Entry 154 | - RULE-SET,youtube_domain,Entry 155 | - RULE-SET,google_domain,Entry 156 | - RULE-SET,telegram_domain,Entry 157 | - RULE-SET,netflix_domain,Entry 158 | - RULE-SET,bilibili_domain,Entry-RuleMode 159 | - RULE-SET,bahamut_domain,Entry 160 | - RULE-SET,spotify_domain,Entry 161 | - RULE-SET,pixiv_domain,Entry 162 | - RULE-SET,geolocation-!cn,Entry 163 | - RULE-SET,google_ip,Entry 164 | - RULE-SET,netflix_ip,Entry 165 | - RULE-SET,telegram_ip,Entry 166 | - RULE-SET,twitter_ip,Entry 167 | 168 | - RULE-SET,cn_domain,Entry-RuleMode 169 | - RULE-SET,cn_ip,Entry-RuleMode 170 | - MATCH,Entry-LastMatch 171 | -------------------------------------------------------------------------------- /clashtui/src/utils/tui/impl_clashsrv.rs: -------------------------------------------------------------------------------- 1 | use super::ClashTuiUtil; 2 | use crate::tui::tabs::ClashSrvOp; 3 | use crate::utils::{ 4 | ipc::{self, exec}, 5 | utils as toolkit, 6 | }; 7 | 8 | use std::io::Error; 9 | 10 | impl ClashTuiUtil { 11 | pub fn clash_srv_ctl(&self, op: ClashSrvOp) -> Result { 12 | const RC_SERVCIE_BIN: &str = "/sbin/rc-service"; 13 | if(self.check_file_exist(RC_SERVCIE_BIN).is_ok()){ 14 | return self.clash_srv_ctl_rc_service(op); 15 | } 16 | return self.clash_srv_ctl_systemctl(op); 17 | } 18 | pub fn clash_srv_ctl_systemctl(&self, op: ClashSrvOp) -> Result { 19 | match op { 20 | ClashSrvOp::StartClashService => { 21 | let mut args = vec!["restart", self.tui_cfg.clash_srv_name.as_str()]; 22 | if self.tui_cfg.is_user { 23 | args.push("--user") 24 | } 25 | let output1 = exec("systemctl", args)?; // Although the command execution is successful, 26 | // the operation may not necessarily be successful. 27 | // So we need to show the command's output to the user. 28 | args = vec!["status", self.tui_cfg.clash_srv_name.as_str()]; 29 | if self.tui_cfg.is_user { 30 | args.push("--user") 31 | } 32 | let output2 = exec("systemctl", args)?; 33 | 34 | Ok(format!("# ## restart\n{output1}# ## status\n{output2}")) 35 | } 36 | ClashSrvOp::StopClashService => { 37 | let mut args = vec!["stop", self.tui_cfg.clash_srv_name.as_str()]; 38 | if self.tui_cfg.is_user { 39 | args.push("--user") 40 | } 41 | let output1 = exec("systemctl", args)?; 42 | args = vec!["status", self.tui_cfg.clash_srv_name.as_str()]; 43 | if self.tui_cfg.is_user { 44 | args.push("--user") 45 | } 46 | let output2 = exec("systemctl", args)?; 47 | 48 | Ok(format!("# ## stop\n{output1}# ## status\n{output2}")) 49 | } 50 | ClashSrvOp::SetPermission => { 51 | let pgm = "setcap"; 52 | let args = vec![ 53 | "cap_net_admin,cap_net_bind_service=+ep", 54 | self.tui_cfg.clash_bin_path.as_str(), 55 | ]; 56 | if toolkit::is_run_as_root() { 57 | ipc::exec_with_sbin(pgm, args) 58 | } else { 59 | let mut cmd = vec![pgm]; 60 | cmd.extend(args); 61 | // `setcap` doesn't trigger the polkit agent. 62 | ipc::exec_with_sbin("pkexec", cmd) 63 | } 64 | } 65 | ClashSrvOp::CloseConnections => { 66 | self.clash_api.close_connnections() 67 | } 68 | _ => Err(Error::new( 69 | std::io::ErrorKind::NotFound, 70 | "No Support Action", 71 | )), 72 | } 73 | } 74 | pub fn check_file_exist(&self, absolute_path_str: &str) -> Result { 75 | 76 | let file_path = std::path::PathBuf::from(absolute_path_str); 77 | if file_path.exists() { 78 | Ok(true) 79 | } else { 80 | Err(Error::new( 81 | std::io::ErrorKind::NotFound, 82 | format!("File not found: {}", file_path.display()), 83 | )) 84 | } 85 | } 86 | pub fn clash_srv_ctl_rc_service(&self, op: ClashSrvOp) -> Result { 87 | const SERVICE_CTR_CMD: &str = "rc-service"; 88 | match op { 89 | ClashSrvOp::StartClashService => { 90 | let mut args = vec![self.tui_cfg.clash_srv_name.as_str(), "restart"]; 91 | if self.tui_cfg.is_user { 92 | args.push("--user") 93 | } 94 | let output1 = exec(SERVICE_CTR_CMD, args)?; // Although the command execution is successful, 95 | // the operation may not necessarily be successful. 96 | // So we need to show the command's output to the user. 97 | args = vec![self.tui_cfg.clash_srv_name.as_str(), "status"]; 98 | if self.tui_cfg.is_user { 99 | args.push("--user") 100 | } 101 | let output2 = exec(SERVICE_CTR_CMD, args)?; 102 | 103 | Ok(format!("# ## restart\n{output1}# ## status\n{output2}")) 104 | } 105 | ClashSrvOp::StopClashService => { 106 | let mut args = vec![self.tui_cfg.clash_srv_name.as_str(), "stop"]; 107 | if self.tui_cfg.is_user { 108 | args.push("--user") 109 | } 110 | let output1 = exec(SERVICE_CTR_CMD, args)?; 111 | args = vec![self.tui_cfg.clash_srv_name.as_str(), "status"]; 112 | if self.tui_cfg.is_user { 113 | args.push("--user") 114 | } 115 | let output2 = exec(SERVICE_CTR_CMD, args)?; 116 | 117 | Ok(format!("# ## stop\n{output1}# ## status\n{output2}")) 118 | } 119 | ClashSrvOp::SetPermission => { 120 | let pgm = "setcap"; 121 | let args = vec![ 122 | "cap_net_admin,cap_net_bind_service=+ep", 123 | self.tui_cfg.clash_bin_path.as_str(), 124 | ]; 125 | if toolkit::is_run_as_root() { 126 | ipc::exec_with_sbin(pgm, args) 127 | } else { 128 | let mut cmd = vec![pgm]; 129 | cmd.extend(args); 130 | // `setcap` doesn't trigger the polkit agent. 131 | ipc::exec_with_sbin("pkexec", cmd) 132 | } 133 | } 134 | ClashSrvOp::CloseConnections => { 135 | self.clash_api.close_connnections() 136 | } 137 | _ => Err(Error::new( 138 | std::io::ErrorKind::NotFound, 139 | "No Support Action", 140 | )), 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /clashtui/src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all)] 2 | mod app; 3 | mod tui; 4 | mod utils; 5 | 6 | use core::time::Duration; 7 | use nix::sys; 8 | 9 | use crate::app::App; 10 | use crate::utils::{Flag, Flags}; 11 | 12 | pub const VERSION: &str = concat!(env!("CLASHTUI_VERSION")); 13 | 14 | fn main() { 15 | // Prevent create root files before fixing files permissions. 16 | if utils::is_clashtui_ep() { 17 | utils::mock_fileop_as_sudo_user(); 18 | } 19 | // To allow the mihomo process to read and write files created by clashtui in clash_cfg_dir, set the umask to 0o002. 20 | sys::stat::umask(sys::stat::Mode::from_bits_truncate(0o002)); 21 | 22 | let mut warning_list_msg = Vec::::new(); 23 | 24 | // ## Paser param 25 | let cli_env: app::CliEnv = argh::from_env(); 26 | if cli_env.version { 27 | println!("{VERSION}"); 28 | std::process::exit(0); 29 | } 30 | 31 | let mut flags = Flags::empty(); 32 | 33 | // ## Setup logging as early as possible. So We can log. 34 | let config_dir = cli_env.config_dir.unwrap_or_else(|| load_app_dir(&mut flags)); 35 | setup_logging(config_dir.join("clashtui.log").to_str().unwrap()); 36 | 37 | let tick_rate = 250; // time in ms between two ticks. 38 | if let Err(e) = run(&mut flags, tick_rate, &config_dir, &mut warning_list_msg) { 39 | eprintln!("{e}"); 40 | std::process::exit(-1) 41 | } 42 | 43 | std::process::exit(0); 44 | } 45 | pub fn run(flags: &mut Flags, tick_rate: u64, config_dir: &std::path::PathBuf, warning_list_msg: &mut Vec) -> std::io::Result<()> { 46 | let (app, err_track) = App::new(&flags, config_dir); 47 | log::debug!("Current flags: {:?}", flags); 48 | 49 | if let Some(mut app) = app { 50 | use ui::setup::*; 51 | // setup terminal 52 | setup()?; 53 | // create app and run it 54 | run_app(&mut app, tick_rate, err_track, flags, warning_list_msg)?; 55 | // restore terminal 56 | restore()?; 57 | 58 | app.save_to_data_file(); 59 | } else { 60 | err_track.into_iter().for_each(|v| eprintln!("{v}")); 61 | } 62 | Ok(()) 63 | } 64 | 65 | use utils::CfgError; 66 | fn run_app( 67 | app: &mut App, 68 | tick_rate: u64, 69 | err_track: Vec, 70 | flags: &mut Flags, 71 | warning_list_msg: &mut Vec, 72 | ) -> std::io::Result<()> { 73 | if flags.contains(utils::Flag::FirstInit) { 74 | warning_list_msg.push("Welcome to ClashTui!".to_string()); 75 | warning_list_msg.push(format!("Please go to config the clashtui_cfg_dir '{}' so that program can work properly", app.clashtui_util.clashtui_dir.to_str().unwrap_or("Failed to get the path"))); 76 | }; 77 | if flags.contains(utils::Flag::ErrorDuringInit) { 78 | warning_list_msg.push("Some error happened during app init, check the log for detail".to_string()); 79 | } 80 | err_track 81 | .into_iter() 82 | .for_each(|e| app.popup_txt_msg(e.reason)); 83 | log::info!("App init finished"); 84 | 85 | use ratatui::{backend::CrosstermBackend, Terminal}; 86 | let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?; 87 | terminal.clear()?; // Clear terminal residual text before draw. 88 | let tick_rate = Duration::from_millis(tick_rate); 89 | use ui::event; 90 | app.popup_list_msg(warning_list_msg.to_owned()); // Set msg popup before draw 91 | while !app.should_quit { 92 | terminal.draw(|f| app.draw(f))?; 93 | 94 | app.late_event(); 95 | 96 | if event::poll(tick_rate)? { 97 | if let Err(e) = app.event(&event::read()?) { 98 | app.popup_txt_msg(e.to_string()) 99 | }; 100 | } 101 | } 102 | log::info!("App Exit"); 103 | Ok(()) 104 | } 105 | 106 | fn load_app_dir(flags: &mut Flags) -> std::path::PathBuf { 107 | let clashtui_config_dir = { 108 | use std::{env, path::PathBuf}; 109 | let exe_dir = env::current_exe().unwrap().parent().unwrap().to_path_buf(); 110 | let data_dir = exe_dir.join("data"); 111 | if data_dir.exists() && data_dir.is_dir() { 112 | // portable mode 113 | flags.insert(Flag::PortableMode); 114 | data_dir 115 | } else { 116 | let clashtui_config_dir_str = env::var("XDG_CONFIG_HOME").map(|p| format!("{}/clashtui", p)) 117 | .or_else(|_| env::var("HOME").map(|home| format!("{}/.config/clashtui", home))) 118 | .unwrap(); 119 | PathBuf::from(&clashtui_config_dir_str) 120 | } 121 | }; 122 | 123 | if !clashtui_config_dir.join("config.yaml").exists() { 124 | use tui::symbols; 125 | flags.insert(Flag::FirstInit); 126 | if let Err(err) = crate::utils::init_config( 127 | &clashtui_config_dir, 128 | symbols::DEFAULT_BASIC_CLASH_CFG_CONTENT, 129 | ) { 130 | flags.insert(Flag::ErrorDuringInit); 131 | log::error!("{}", err); 132 | } 133 | } 134 | if let Err(err) = crate::utils::check_essential_files( 135 | &clashtui_config_dir, 136 | ) { 137 | log::error!("{}", err); 138 | eprintln!("Error: {}", err.reason); 139 | std::process::exit(1); 140 | } 141 | 142 | clashtui_config_dir 143 | } 144 | 145 | fn setup_logging(log_path: &str) { 146 | use log4rs::append::file::FileAppender; 147 | use log4rs::config::{Appender, Config, Root}; 148 | use log4rs::encode::pattern::PatternEncoder; 149 | #[cfg(debug_assertions)] 150 | let _ = std::fs::remove_file(log_path); // auto rm old log for debug 151 | let mut flag = false; 152 | if let Ok(m) = std::fs::File::open(log_path).and_then(|f| f.metadata()) { 153 | if m.len() > 1024 * 1024 { 154 | let _ = std::fs::remove_file(log_path); 155 | flag = true 156 | }; 157 | } 158 | // No need to change. This is set to auto switch to Info level when build release 159 | #[allow(unused_variables)] 160 | let log_level = log::LevelFilter::Info; 161 | #[cfg(debug_assertions)] 162 | let log_level = log::LevelFilter::Debug; 163 | let file_appender = FileAppender::builder() 164 | .encoder(Box::new(PatternEncoder::new("{d(%H:%M:%S)} [{l}] {t} - {m}{n}"))) // Having a timestamp would be better. 165 | .build(log_path) 166 | .unwrap(); 167 | 168 | let config = Config::builder() 169 | .appender(Appender::builder().build("file", Box::new(file_appender))) 170 | .build(Root::builder().appender("file").build(log_level)) 171 | .unwrap(); 172 | 173 | log4rs::init_config(config).unwrap(); 174 | if flag { 175 | log::info!("Log file too large, clear") 176 | } 177 | log::info!("Start Log, level: {}", log_level); 178 | } 179 | -------------------------------------------------------------------------------- /clashtui/ui/src/widgets/list.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{Event, KeyCode, KeyEventKind}; 2 | use ratatui::{prelude as Ra, widgets as Raw}; 3 | 4 | use crate::{utils::Theme, EventState, Infailable, Visibility}; 5 | 6 | /// Interactive list, mainly used as basic interface 7 | /// 8 | /// Using arrow keys or j\k(vim-like) to navigate. 9 | #[derive(Visibility)] 10 | pub struct List { 11 | title: String, 12 | is_visible: bool, 13 | items: Vec, 14 | extra: Option>, 15 | list_state: Raw::ListState, 16 | scrollbar: Raw::ScrollbarState, 17 | } 18 | 19 | impl List { 20 | pub fn new(title: String) -> Self { 21 | Self { 22 | title, 23 | is_visible: true, 24 | items: vec![], 25 | extra: None, 26 | list_state: Raw::ListState::default(), 27 | scrollbar: Raw::ScrollbarState::default(), 28 | } 29 | } 30 | 31 | pub fn event(&mut self, ev: &Event) -> Result { 32 | if !self.is_visible { 33 | return Ok(EventState::NotConsumed); 34 | } 35 | 36 | if let Event::Key(key) = ev { 37 | if key.kind == KeyEventKind::Press { 38 | match key.code { 39 | KeyCode::Down | KeyCode::Char('j') => self.next(), 40 | KeyCode::Up | KeyCode::Char('k') => self.previous(), 41 | _ => return Ok(EventState::NotConsumed), 42 | }; 43 | return Ok(EventState::WorkDone); 44 | } 45 | } 46 | 47 | Ok(EventState::NotConsumed) 48 | } 49 | 50 | pub fn draw(&mut self, f: &mut Ra::Frame, area: Ra::Rect, is_fouced: bool) { 51 | if !self.is_visible { 52 | return; 53 | } 54 | 55 | f.render_stateful_widget( 56 | if let Some(vc) = self.extra.as_ref() { 57 | Raw::List::from_iter(self.items.iter().zip(vc.iter()).map(|(v, e)| { 58 | Raw::ListItem::new( 59 | Ra::Line::from(vec![ 60 | Ra::Span::styled(v.to_owned(), Ra::Style::default()), 61 | Ra::Span::styled(" ".to_owned(), Ra::Style::default()), 62 | //Ra::Span::styled(e, Ra::Style::default().fg(Ra::Color::Rgb(192, 192, 192))) 63 | Ra::Span::styled(e, Ra::Style::default().fg(Theme::get().profile_update_interval_fg)) 64 | ]) 65 | ) 66 | })) 67 | } else { 68 | Raw::List::from_iter(self.items.iter().map(|i| { 69 | Raw::ListItem::new(Ra::Line::from(i.as_str())).style(Ra::Style::default()) 70 | })) 71 | } 72 | .block( 73 | Raw::Block::default() 74 | .borders(Raw::Borders::ALL) 75 | .border_style(Ra::Style::default().fg(if is_fouced { 76 | Theme::get().list_block_fouced_fg 77 | } else { 78 | Theme::get().list_block_unfouced_fg 79 | })) 80 | .title(self.title.as_str()), 81 | ) 82 | .highlight_style( 83 | Ra::Style::default() 84 | .bg(if is_fouced { 85 | Theme::get().list_hl_bg_fouced 86 | } else { 87 | Ra::Color::default() 88 | }) 89 | .add_modifier(Ra::Modifier::BOLD), 90 | ), 91 | area, 92 | &mut self.list_state, 93 | ); 94 | 95 | if self.items.len() + 2 > area.height as usize { 96 | f.render_stateful_widget( 97 | Raw::Scrollbar::default() 98 | .orientation(Raw::ScrollbarOrientation::VerticalRight) 99 | .begin_symbol(Some("↑")) 100 | .end_symbol(Some("↓")), 101 | area, 102 | &mut self.scrollbar, 103 | ); 104 | } 105 | } 106 | 107 | pub fn selected(&self) -> Option<&String> { 108 | if self.items.is_empty() { 109 | return None; 110 | } 111 | 112 | self.list_state.selected().map(|i| &self.items[i]) 113 | } 114 | 115 | fn next(&mut self) { 116 | if self.items.is_empty() { 117 | return; 118 | } 119 | 120 | let i = match self.list_state.selected() { 121 | Some(i) => { 122 | if i >= self.items.len() - 1 { 123 | self.scrollbar.first(); 124 | 0 125 | } else { 126 | self.scrollbar.next(); 127 | i + 1 128 | } 129 | } 130 | None => { 131 | self.scrollbar.first(); 132 | 0 133 | } 134 | }; 135 | self.list_state.select(Some(i)); 136 | } 137 | 138 | fn previous(&mut self) { 139 | if self.items.is_empty() { 140 | return; 141 | } 142 | 143 | let i = match self.list_state.selected() { 144 | Some(i) => { 145 | if i == 0 { 146 | self.scrollbar.last(); 147 | self.items.len() - 1 148 | } else { 149 | self.scrollbar.prev(); 150 | i - 1 151 | } 152 | } 153 | None => { 154 | self.scrollbar.last(); 155 | 0 156 | } 157 | }; 158 | self.list_state.select(Some(i)); 159 | } 160 | 161 | pub fn set_items(&mut self, items: Vec) { 162 | match self.list_state.selected() { 163 | Some(i) => { 164 | if i == 0 { 165 | self.list_state.select(None); 166 | } else if i >= items.len() { 167 | self.list_state.select(Some(items.len() - 1)); 168 | } 169 | } 170 | None => self.list_state.select(None), 171 | } 172 | self.items = items; 173 | self.scrollbar = self.scrollbar.content_length(self.items.len()); 174 | 175 | if self.list_state.selected().is_none() && !self.items.is_empty() { 176 | self.list_state.select(Some(0)); 177 | self.scrollbar.first(); 178 | } 179 | } 180 | 181 | pub fn set_extras(&mut self, extra: I) 182 | where 183 | I: Iterator + ExactSizeIterator, 184 | { 185 | assert_eq!(self.items.len(), extra.len()); 186 | self.extra.replace(Vec::from_iter(extra)); 187 | } 188 | 189 | pub fn get_items(&self) -> &Vec { 190 | &self.items 191 | } 192 | 193 | pub fn select(&mut self, name: &str) { 194 | if let Some(index) = self.items.iter().position(|item| item == name) { 195 | self.list_state.select(Some(index)); 196 | } 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /clashtui/src/utils/tui.rs: -------------------------------------------------------------------------------- 1 | use core::cell::RefCell; 2 | use core::str::FromStr as _; 3 | use std::{ 4 | io::Error, 5 | path::{Path, PathBuf}, 6 | }; 7 | use strum_macros::Display; 8 | use api::{ProfileSectionType, UrlItem}; 9 | 10 | mod impl_app; 11 | mod impl_clashsrv; 12 | mod impl_profile; 13 | 14 | use super::{ 15 | config::{CfgError, CtCfg, ErrKind}, 16 | parse_yaml, 17 | ClashTuiData, 18 | }; 19 | use api::{ClashConfig, ClashUtil, Resp}; 20 | 21 | // format: {section_key: [(name, url, path)]} 22 | pub type NetProviderMap = std::collections::HashMap>; 23 | 24 | #[derive(Copy, Clone, PartialEq, Eq, Hash, Display)] 25 | pub enum ProfileType { 26 | ClashPf, // clash profile yaml file 27 | SubUrl, // Only a SubUrl 28 | CtPf, // ClashTui profile no sub url 29 | CtPfWithSubUrl // ClashTui profile with sub url 30 | } 31 | 32 | pub struct ProfileItem { 33 | pub typ: ProfileType, 34 | pub name: String, // profile name 35 | pub url_item: Option, // ProfileType: Url 36 | } 37 | 38 | impl ProfileItem { 39 | pub fn from_url(typ: ProfileType, name: String) -> Self { 40 | Self { 41 | typ: typ, 42 | name: name, 43 | url_item: None 44 | } 45 | } 46 | } 47 | 48 | const BASIC_FILE: &str = "basic_clash_config.yaml"; 49 | const DATA_FILE: &str = "data.yaml"; 50 | 51 | pub struct ClashTuiUtil { 52 | pub clashtui_dir: PathBuf, 53 | profile_dir: PathBuf, 54 | 55 | clash_api: ClashUtil, 56 | pub tui_cfg: CtCfg, 57 | pub clashtui_data: RefCell, 58 | } 59 | 60 | // Misc 61 | impl ClashTuiUtil { 62 | pub fn new(clashtui_dir: &PathBuf, is_inited: bool) -> (Self, Vec) { 63 | let ret = load_app_config(clashtui_dir, is_inited); 64 | let mut err_track = ret.2; 65 | let clash_api = ret.1; 66 | 67 | if clash_api.version().is_err() { 68 | err_track.push(CfgError::new( 69 | ErrKind::LoadClashConfig, 70 | "Fail to load config from clash core. Is it Running?".to_string(), 71 | )); 72 | log::warn!("Fail to connect to clash. Is it Running?") 73 | } 74 | 75 | let data_path = clashtui_dir.join(DATA_FILE); 76 | let clashtui_data = RefCell::new(ClashTuiData::from_file(data_path.to_str().unwrap()).unwrap_or_default()); 77 | 78 | ( 79 | Self { 80 | clashtui_dir: clashtui_dir.clone(), 81 | profile_dir: clashtui_dir.join("profiles").to_path_buf(), 82 | clash_api, 83 | tui_cfg: ret.0, 84 | clashtui_data, 85 | }, 86 | err_track, 87 | ) 88 | } 89 | pub fn clash_version(&self) -> String { 90 | match self.clash_api.version() { 91 | Ok(v) => v, 92 | Err(e) => { 93 | log::warn!("{}", e); 94 | "Unknown".to_string() 95 | } 96 | } 97 | } 98 | fn fetch_remote(&self) -> Result { 99 | self.clash_api.config_get().and_then(|cur_remote| { 100 | ClashConfig::from_str(cur_remote.as_str()) 101 | .map_err(|_| Error::new(std::io::ErrorKind::InvalidData, "Failed to prase str")) 102 | }) 103 | } 104 | pub fn restart_clash(&self) -> Result { 105 | self.clash_api.restart(None) 106 | } 107 | fn dl_remote_profile(&self, url_item: &UrlItem) -> Result { 108 | let timeout = self.tui_cfg.timeout; 109 | self.clash_api.mock_clash_core(url_item, timeout) 110 | } 111 | fn config_reload(&self, body: String) -> Result<(), Error> { 112 | self.clash_api.config_reload(body) 113 | } 114 | 115 | pub fn save_to_data_file(&self) { 116 | let data_path = self.clashtui_dir.join(DATA_FILE); 117 | let _ = self.clashtui_data.borrow_mut().to_file(data_path.to_str().unwrap()); 118 | } 119 | pub fn check_proxy(&self) -> bool { 120 | self.clash_api.version().is_ok() && self.clash_api.check_connectivity().is_ok() 121 | } 122 | } 123 | 124 | fn load_app_config( 125 | clashtui_dir: &PathBuf, 126 | skip_init_conf: bool, 127 | ) -> (CtCfg, ClashUtil, Vec) { 128 | let mut err_collect = Vec::new(); 129 | let basic_clash_config_path = Path::new(clashtui_dir).join(BASIC_FILE); 130 | let basic_clash_config_value: serde_yaml::Value = 131 | match parse_yaml(basic_clash_config_path.as_path()) { 132 | Ok(r) => r, 133 | Err(_) => { 134 | err_collect.push(CfgError::new( 135 | ErrKind::LoadProfileConfig, 136 | "Fail to load User Defined Config".to_string(), 137 | )); 138 | serde_yaml::Value::Null 139 | } 140 | }; 141 | let controller_api = basic_clash_config_value 142 | .get("external-controller") 143 | .and_then(|v| { 144 | let controller_address = v.as_str().expect("external-controller not str?"); 145 | if controller_address.starts_with("0.0.0.0:") { 146 | // replace 0.0.0.0 with 127.0.0.1 147 | let port_index = controller_address.find(':').unwrap_or(0) + 1; 148 | let port = &controller_address[port_index..]; 149 | let machine_ip = "127.0.0.1"; 150 | Some(format!("http://{machine_ip}:{port}")) 151 | } else { 152 | Some(format!("http://{}", controller_address)) 153 | } 154 | }) 155 | .unwrap_or_else(|| panic!("No external-controller in {BASIC_FILE}")); 156 | log::info!("controller_api: {}", controller_api); 157 | 158 | let proxy_addr = get_proxy_addr(&basic_clash_config_value); 159 | log::info!("proxy_addr: {}", proxy_addr); 160 | 161 | let secret = basic_clash_config_value 162 | .get("secret") 163 | .and_then(|v| v.as_str()) 164 | .unwrap_or_default() 165 | .to_string(); 166 | 167 | let clash_ua = basic_clash_config_value 168 | .get("global-ua") 169 | .and_then(|v| v.as_str()) 170 | .unwrap_or("clash.meta") 171 | .to_string(); 172 | log::info!("clash_ua: {}", clash_ua); 173 | 174 | let configs = if skip_init_conf { 175 | let config_path = clashtui_dir.join("config.yaml"); 176 | match CtCfg::load(config_path.to_str().unwrap()) { 177 | Ok(v) => { 178 | if !v.is_valid() { 179 | err_collect.push(CfgError::new( 180 | ErrKind::LoadAppConfig, 181 | "Some Key Configs are missing, or Default".to_string(), 182 | )); 183 | log::warn!("Empty Config?"); 184 | log::debug!("{:?}", v) 185 | }; 186 | v 187 | } 188 | Err(e) => { 189 | err_collect.push(CfgError::new( 190 | ErrKind::LoadAppConfig, 191 | "Fail to load configs, using Default".to_string(), 192 | )); 193 | log::error!("Unable to load config file. {}", e); 194 | CtCfg::default() 195 | } 196 | } 197 | } else { 198 | CtCfg::default() 199 | }; 200 | ( 201 | configs, 202 | ClashUtil::new(controller_api, secret, proxy_addr, clash_ua), 203 | err_collect, 204 | ) 205 | } 206 | 207 | fn get_proxy_addr(yaml_data: &serde_yaml::Value) -> String { 208 | let host = "127.0.0.1"; 209 | if let Some(port) = yaml_data.get("mixed-port").and_then(|v| v.as_u64()) { 210 | return format!("http://{}:{}", host, port); 211 | } 212 | if let Some(port) = yaml_data.get("port").and_then(|v| v.as_u64()) { 213 | return format!("http://{}:{}", host, port); 214 | } 215 | if let Some(port) = yaml_data.get("socks-port").and_then(|v| v.as_u64()) { 216 | return format!("socks5://{}:{}", host, port); 217 | } 218 | panic!("No prots in {BASIC_FILE}") 219 | } 220 | -------------------------------------------------------------------------------- /clashtui/src/utils/config.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::fs::File; 3 | 4 | #[derive(Debug, Default, Serialize, Deserialize)] 5 | #[serde(default)] 6 | struct Basic { 7 | #[serde(rename="clash_config_dir")] 8 | clash_cfg_dir: String, 9 | #[serde(rename="clash_bin_path")] 10 | clash_bin_path: String, 11 | #[serde(rename="clash_config_path")] 12 | clash_cfg_path: String, 13 | timeout: Option, 14 | } 15 | #[derive(Debug, Default, Serialize, Deserialize)] 16 | #[serde(default)] 17 | struct Extra { 18 | edit_cmd: String, 19 | open_dir_cmd: String, 20 | } 21 | #[derive(Debug, Default, Serialize, Deserialize)] 22 | #[serde(default)] 23 | struct Service { 24 | clash_srv_name: String, 25 | is_user: bool, 26 | } 27 | #[derive(Debug, Serialize, Deserialize)] 28 | #[serde(default)] 29 | pub struct CtCfgForUser { 30 | basic: Basic, 31 | service: Service, 32 | extra: Extra, 33 | } 34 | 35 | // ClashTui config for user 36 | impl Default for CtCfgForUser { 37 | fn default() -> Self { 38 | CtCfgForUser { 39 | basic: Basic { 40 | clash_cfg_dir: String::from("/srv/mihomo"), 41 | clash_cfg_path: String::from("/srv/mihomo/config.yaml"), 42 | clash_bin_path: String::from("/usr/bin/mihomo"), 43 | timeout: None, 44 | }, 45 | service: Service { 46 | clash_srv_name: String::from("mihomo"), 47 | is_user: false, // true: systemctl --user ... 48 | }, 49 | extra: Extra { 50 | edit_cmd: String::from(""), 51 | open_dir_cmd: String::from(""), 52 | }, 53 | } 54 | } 55 | } 56 | 57 | impl CtCfgForUser { 58 | pub fn load(config_path: &str) -> Result { 59 | let f = File::open(config_path)?; 60 | Ok(serde_yaml::from_reader(f)?) 61 | } 62 | 63 | pub fn save(&self, config_path: &str) -> Result<()> { 64 | let f = File::create(config_path)?; 65 | Ok(serde_yaml::to_writer(f, self)?) 66 | } 67 | 68 | pub fn is_valid(&self) -> bool { 69 | !self.basic.clash_cfg_dir.is_empty() 70 | && !self.basic.clash_cfg_path.is_empty() 71 | && !self.basic.clash_bin_path.is_empty() 72 | } 73 | } 74 | 75 | // ClashTui config 76 | #[derive(Debug, Default, Clone)] 77 | pub struct CtCfg { 78 | /// where clash store its data 79 | pub clash_cfg_dir: String, 80 | /// where clash binary is 81 | pub clash_bin_path: String, 82 | /// where profile stored 83 | pub clash_cfg_path: String, 84 | /// the name of clash service 85 | pub clash_srv_name: String, 86 | /// whether service is running as a user instance 87 | pub is_user: bool, 88 | pub timeout: Option, 89 | 90 | pub edit_cmd: String, 91 | pub open_dir_cmd: String, 92 | } 93 | 94 | impl CtCfg { 95 | pub fn load>(conf_path: P) -> Result { 96 | let CtCfgForUser { 97 | basic, 98 | service, 99 | extra, 100 | } = CtCfgForUser::load(conf_path.as_ref())?; 101 | let Basic { 102 | clash_cfg_dir, 103 | clash_bin_path, 104 | clash_cfg_path, 105 | timeout, 106 | } = basic; 107 | let Service { 108 | clash_srv_name, 109 | is_user, 110 | } = service; 111 | let Extra { 112 | edit_cmd, 113 | open_dir_cmd, 114 | } = extra; 115 | Ok(Self { 116 | clash_cfg_dir, 117 | clash_bin_path, 118 | clash_cfg_path, 119 | timeout, 120 | edit_cmd, 121 | open_dir_cmd, 122 | clash_srv_name, 123 | is_user, 124 | }) 125 | } 126 | 127 | pub fn save>(self, conf_path: P) -> Result<()> { 128 | let CtCfg { 129 | clash_cfg_dir, 130 | clash_bin_path, 131 | clash_cfg_path, 132 | timeout, 133 | edit_cmd, 134 | open_dir_cmd, 135 | clash_srv_name, 136 | is_user, 137 | } = self; 138 | let basic = Basic { 139 | clash_cfg_dir, 140 | clash_bin_path, 141 | clash_cfg_path, 142 | timeout, 143 | }; 144 | let service = Service { 145 | clash_srv_name, 146 | is_user, 147 | }; 148 | let extra = Extra { 149 | edit_cmd, 150 | open_dir_cmd, 151 | }; 152 | let conf = CtCfgForUser { 153 | basic, 154 | service, 155 | extra, 156 | }; 157 | conf.save(&conf_path.as_ref())?; 158 | Ok(()) 159 | } 160 | 161 | pub fn is_valid(&self) -> bool { 162 | !self.clash_cfg_dir.is_empty() 163 | && !self.clash_cfg_path.is_empty() 164 | && !self.clash_bin_path.is_empty() 165 | } 166 | } 167 | 168 | #[cfg(test)] 169 | mod test { 170 | use super::*; 171 | #[test] 172 | fn test_save_and_load() { 173 | let exe_dir = std::env::current_dir().unwrap(); 174 | println!("{exe_dir:?}"); 175 | let path_ = exe_dir.parent().unwrap().join("Example/config.yaml"); 176 | println!("{path_:?}"); 177 | assert!(path_.is_file()); 178 | let path = path_.as_path().to_str().unwrap(); 179 | let conf = CtCfg::load(path).unwrap(); 180 | println!("{:?}", conf); 181 | conf.save(path).unwrap(); 182 | } 183 | } 184 | #[derive(Debug)] 185 | pub enum ErrKind { 186 | IO, 187 | Serde, 188 | LoadAppConfig, 189 | LoadProfileConfig, 190 | LoadClashConfig, 191 | } 192 | type Result = core::result::Result; 193 | #[derive(Debug)] 194 | pub struct CfgError { 195 | _kind: ErrKind, 196 | pub reason: String, 197 | } 198 | impl CfgError { 199 | pub fn new(_kind: ErrKind, reason: String) -> Self { 200 | Self { _kind, reason } 201 | } 202 | } 203 | impl core::fmt::Display for CfgError { 204 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 205 | write!(f, "{:#?}", self) 206 | } 207 | } 208 | impl std::error::Error for CfgError {} 209 | impl From for CfgError { 210 | fn from(value: std::io::Error) -> Self { 211 | Self { 212 | _kind: ErrKind::IO, 213 | reason: value.to_string(), 214 | } 215 | } 216 | } 217 | impl From for CfgError { 218 | fn from(value: serde_yaml::Error) -> Self { 219 | Self { 220 | _kind: ErrKind::Serde, 221 | reason: value.to_string(), 222 | } 223 | } 224 | } 225 | pub fn init_config( 226 | clashtui_config_dir: &std::path::PathBuf, 227 | default_basic_clash_cfg_content: &str, 228 | ) -> Result<()> { 229 | use std::fs; 230 | fs::create_dir_all(clashtui_config_dir)?; 231 | 232 | CtCfg::default().save(clashtui_config_dir.join("config.yaml").to_str().unwrap())?; 233 | 234 | fs::create_dir(clashtui_config_dir.join("profiles"))?; 235 | fs::create_dir(clashtui_config_dir.join("templates"))?; 236 | let template_proxy_providers_files = clashtui_config_dir.join("templates").join("template_proxy_providers"); 237 | if !template_proxy_providers_files.exists() { 238 | fs::File::create(&template_proxy_providers_files)?; 239 | } 240 | 241 | fs::write( 242 | clashtui_config_dir.join("basic_clash_config.yaml"), 243 | default_basic_clash_cfg_content, 244 | )?; 245 | Ok(()) 246 | } 247 | 248 | pub fn check_essential_files( 249 | clashtui_config_dir: &std::path::PathBuf, 250 | ) -> Result<()> { 251 | use std::fs; 252 | if !clashtui_config_dir.exists() { 253 | fs::create_dir_all(clashtui_config_dir)?; 254 | } 255 | 256 | if !clashtui_config_dir.join("config.yaml").exists() { 257 | return Err(CfgError::new( 258 | ErrKind::LoadClashConfig, 259 | "\"config.yaml\" not found in config directory".to_string(), 260 | )); 261 | } 262 | 263 | if !clashtui_config_dir.join("profiles").exists() { 264 | fs::create_dir(clashtui_config_dir.join("profiles"))?; 265 | } 266 | if !clashtui_config_dir.join("templates").exists() { 267 | fs::create_dir(clashtui_config_dir.join("templates"))?; 268 | } 269 | let template_proxy_providers_files = clashtui_config_dir.join("templates").join("template_proxy_providers"); 270 | if !template_proxy_providers_files.exists() { 271 | fs::File::create(&template_proxy_providers_files)?; 272 | } 273 | 274 | if !clashtui_config_dir.join("basic_clash_config.yaml").exists() { 275 | return Err(CfgError::new( 276 | ErrKind::LoadClashConfig, 277 | "\"basic_clash_config.yaml\" not found in config directory".to_string(), 278 | )); 279 | } 280 | 281 | Ok(()) 282 | } 283 | -------------------------------------------------------------------------------- /clashtui/src/utils/utils.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, process, env}; 2 | use std::os::unix::fs::{PermissionsExt, MetadataExt}; 3 | use nix::unistd::{Uid, Gid, Group, geteuid, setfsuid, setfsgid, initgroups}; 4 | use std::path::PathBuf; 5 | use std::ffi::CString; 6 | use std::os::unix::process::CommandExt; 7 | 8 | pub(super) fn get_file_names

(dir: P) -> std::io::Result> 9 | where 10 | P: AsRef, 11 | { 12 | let mut file_names: Vec = Vec::new(); 13 | 14 | for entry in std::fs::read_dir(dir)? { 15 | let path = entry?.path(); 16 | if path.is_file() { 17 | if let Some(file_name) = path.file_name() { 18 | file_names.push(file_name.to_string_lossy().to_string()); 19 | } 20 | } 21 | } 22 | Ok(file_names) 23 | } 24 | /// Judging by format 25 | pub(super) fn is_yaml(path: &std::path::Path) -> bool { 26 | std::fs::File::open(path).is_ok_and(|f| { 27 | serde_yaml::from_reader::(f).is_ok_and(|v| v.is_mapping()) 28 | }) 29 | } 30 | 31 | pub(super) fn parse_yaml(yaml_path: &std::path::Path) -> std::io::Result { 32 | serde_yaml::from_reader(std::fs::File::open(yaml_path)?) 33 | .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) 34 | } 35 | 36 | pub fn get_mtime

(file_path: P) -> std::io::Result 37 | where 38 | P: AsRef, 39 | { 40 | let file = std::fs::metadata(file_path)?; 41 | if file.is_file() { 42 | file.modified() 43 | } else { 44 | Err(std::io::Error::new( 45 | std::io::ErrorKind::InvalidData, 46 | "Not a file?", 47 | )) 48 | } 49 | } 50 | 51 | pub fn str_duration(t: std::time::Duration) -> String { 52 | use std::time::Duration; 53 | if t.is_zero() { 54 | "Just Now".to_string() 55 | } else if t < Duration::from_secs(60 * 59) { 56 | let min = t.as_secs() / 60; 57 | format!("{}m", min + 1) 58 | } else if t < Duration::from_secs(3600 * 24) { 59 | let hou = t.as_secs() / 3600; 60 | format!("{hou}h") 61 | } else { 62 | let day = t.as_secs() / (3600 * 24); 63 | format!("{day}d") 64 | } 65 | } 66 | 67 | pub fn gen_file_dur_str(path: &PathBuf, now: Option) -> std::io::Result { 68 | if let Some(n) = now { 69 | get_mtime(path).map(|time| str_duration(n.duration_since(time).unwrap_or_default())) 70 | } else { 71 | get_mtime(path).map(|time| str_duration(time.elapsed().unwrap_or_default())) 72 | } 73 | } 74 | 75 | pub fn modify_file_perms_in_dir(dir: &PathBuf, group_name: &str) { 76 | // dir add set-group-id: `chmod g+s dir` 77 | if let Ok(metadata) = std::fs::metadata(dir) { 78 | let permissions = metadata.permissions(); 79 | if permissions.mode() & 0o2000 == 0 { 80 | if let Ok(metadata) = fs::metadata(dir) { 81 | let permissions = metadata.permissions(); 82 | let mut new_permissions = permissions.clone(); 83 | new_permissions.set_mode(permissions.mode() | 0o2020); 84 | println!("Adding `g+s` permission to '{:?}'", dir); 85 | if let Err(e) = fs::set_permissions(dir, new_permissions) { 86 | eprintln!("Failed to set `g+s` permissions for '{:?}': {}", dir, e); 87 | } 88 | } 89 | } 90 | } 91 | 92 | let files_not_in_group = find_files_not_in_group(dir, group_name); 93 | for file in &files_not_in_group { 94 | let path = std::path::Path::new(dir).join(file); 95 | if let Ok(group) = Group::from_name(group_name) { 96 | println!("Changing group to '{}' for {:?}:", group_name, file); 97 | if let Err(e) = nix::unistd::chown(&path, None, group.map(|g| g.gid)) { 98 | eprintln!("Failed to change group to '{}' for '{:?}': {}", group_name, file, e); 99 | } 100 | } 101 | } 102 | 103 | let files_not_group_writable = find_files_not_group_writable(dir); 104 | for file in &files_not_group_writable { 105 | if let Ok(metadata) = fs::metadata(file) { 106 | let permissions = metadata.permissions(); 107 | let mut new_permissions = permissions.clone(); 108 | new_permissions.set_mode(permissions.mode() | 0o0020); 109 | println!("Adding `g+w` permission to '{:?}'", file); 110 | if let Err(e) = fs::set_permissions(file, new_permissions) { 111 | eprintln!("Failed to set `g+w` permissions for '{:?}': {}", file, e); 112 | } 113 | } 114 | } 115 | } 116 | 117 | // Check dir member and dir itself. 118 | pub fn find_files_not_group_writable(dir: &PathBuf) -> Vec { 119 | let mut result = Vec::new(); 120 | 121 | if let Ok(entries) = fs::read_dir(dir) { 122 | for entry in entries { 123 | if let Ok(entry) = entry { 124 | let path = entry.path(); 125 | let metadata = entry.metadata().unwrap(); 126 | if metadata.is_file() { 127 | let permissions = metadata.permissions(); 128 | 129 | if permissions.mode() & 0o0020 == 0 { 130 | result.push(path.clone()); 131 | } 132 | } 133 | if metadata.is_dir() { 134 | result.extend(find_files_not_group_writable(&path)); 135 | } 136 | } 137 | } 138 | } 139 | 140 | if let Ok(metadata) = fs::metadata(dir) { 141 | let permissions = metadata.permissions(); 142 | if permissions.mode() & 0o0020 == 0 { 143 | result.push(dir.clone()); 144 | } 145 | } 146 | 147 | result 148 | } 149 | 150 | // Check dir member and dir itself. 151 | pub fn find_files_not_in_group(dir: &PathBuf, group_name: &str) -> Vec { 152 | let mut result = Vec::new(); 153 | 154 | if let Ok(entries) = fs::read_dir(dir) { 155 | for entry in entries { 156 | if let Ok(entry) = entry { 157 | let metadata = entry.metadata().unwrap(); 158 | 159 | if metadata.is_file() { 160 | let file_gid = metadata.gid(); 161 | if let Ok(Some(group)) = 162 | Group::from_gid(Gid::from_raw(file_gid)) 163 | { 164 | if group.name != group_name { 165 | result.push(entry.path().clone()); 166 | } 167 | } 168 | } else if metadata.is_dir() { 169 | let sub_dir = entry.path(); 170 | result.extend(find_files_not_in_group(&sub_dir, group_name)); 171 | } 172 | } 173 | } 174 | } 175 | 176 | if let Ok(metadata) = fs::metadata(dir) { 177 | if let Some(dir_group) = 178 | Group::from_gid(Gid::from_raw(metadata.gid())).unwrap() 179 | { 180 | if dir_group.name != group_name { 181 | result.push(dir.clone()); 182 | } 183 | } 184 | } 185 | 186 | result 187 | } 188 | 189 | pub fn get_file_group_name(dir: &PathBuf) -> Option { 190 | if let Ok(metadata) = std::fs::metadata(dir) { 191 | if let Some(dir_group) = 192 | nix::unistd::Group::from_gid(nix::unistd::Gid::from_raw(metadata.gid())).unwrap() 193 | { 194 | return Some(dir_group.name); 195 | } 196 | } 197 | 198 | None 199 | } 200 | 201 | // Perform file operations with clashtui process as a sudo user. 202 | pub fn mock_fileop_as_sudo_user() { 203 | if ! is_run_as_root() { 204 | return; 205 | } 206 | 207 | // sudo printenv: SUDO_USER, SUDO_UID, SUDO_GID, ... 208 | if let (Ok(uid_str), Ok(gid_str)) = (env::var("SUDO_UID"), env::var("SUDO_GID")) { 209 | if let (Ok(uid_num), Ok(gid_num)) = (uid_str.parse::(), gid_str.parse::()) { 210 | // In Linux, file operation permissions are determined using fsuid, fdgid, and auxiliary groups. 211 | 212 | let uid = Uid::from_raw(uid_num); 213 | let gid = Gid::from_raw(gid_num); 214 | setfsuid(uid); 215 | setfsgid(gid); 216 | 217 | // Need to use the group permissions of the auxiliary group mihomo 218 | if let Ok(user_name) = env::var("SUDO_USER") { 219 | let user_name = CString::new(user_name).unwrap(); 220 | let _ = initgroups(&user_name, gid); 221 | } 222 | } 223 | } 224 | } 225 | 226 | pub fn is_run_as_root() -> bool { 227 | return geteuid().is_root(); 228 | } 229 | 230 | pub fn restore_fileop_as_root() { 231 | setfsuid(Uid::from_raw(0)); 232 | setfsgid(Gid::from_raw(0)); 233 | } 234 | 235 | pub fn run_as_root() { 236 | let app_path_binding = env::current_exe() 237 | .expect("Failed to get current executable path"); 238 | let app_path = app_path_binding.to_str() 239 | .expect("Failed to convert path to string"); 240 | 241 | // Skip the param of exe path 242 | let params: Vec = env::args().skip(1).collect(); 243 | 244 | let mut sudo_cmd = vec![app_path]; 245 | 246 | sudo_cmd.extend(params.iter().map(|s| s.as_str())); 247 | 248 | // CLASHTUI_EP: clashtui elevate privileges 249 | env::set_var("CLASHTUI_EP", "true"); // To distinguish when users manually execute `sudo clashtui` 250 | let _ = process::Command::new("sudo") 251 | .args(vec!["--preserve-env=CLASHTUI_EP,XDG_CONFIG_HOME,HOME,USER"]) 252 | //.args(vec!["--preserve-env"]) 253 | .args(&sudo_cmd) 254 | .exec(); 255 | } 256 | 257 | pub fn run_as_previous_user() { 258 | if ! is_clashtui_ep() || env::var("SUDO_USER").is_err() { 259 | return; 260 | } 261 | 262 | let user_name = env::var("SUDO_USER").unwrap(); 263 | 264 | let app_path_binding = env::current_exe() 265 | .expect("Failed to get current executable path"); 266 | let app_path = app_path_binding.to_str() 267 | .expect("Failed to convert path to string"); 268 | 269 | // Skip the param of exe path 270 | let params: Vec = env::args().skip(1).collect(); 271 | 272 | let mut sudo_cmd = vec![app_path]; 273 | 274 | sudo_cmd.extend(params.iter().map(|s| s.as_str())); 275 | 276 | log::info!("run_as_previous_user: {}", user_name); 277 | let _ = process::Command::new("sudo") 278 | .args(["-i", "-u", user_name.as_str()]) 279 | .args(&sudo_cmd) 280 | .exec(); 281 | } 282 | 283 | // Is clashtui elevate privileges 284 | pub fn is_clashtui_ep() -> bool { 285 | if let Ok(str) = env::var("CLASHTUI_EP") { 286 | if str == "true" { 287 | return true; 288 | } 289 | } 290 | 291 | false 292 | } 293 | 294 | pub fn extract_domain(url: &str) -> Option<&str> { 295 | if let Some(protocol_end) = url.find("://") { 296 | let rest = &url[(protocol_end + 3)..]; 297 | if let Some(path_start) = rest.find('/') { 298 | return Some(&rest[..path_start]); 299 | } else { 300 | return Some(rest); 301 | } 302 | } 303 | None 304 | } 305 | 306 | pub fn bytes_to_readable(bytes: u64) -> String { 307 | const KILOBYTE: u64 = 1024; 308 | const MEGABYTE: u64 = KILOBYTE * 1024; 309 | const GIGABYTE: u64 = MEGABYTE * 1024; 310 | const TERABYTE: u64 = GIGABYTE * 1024; 311 | 312 | if bytes >= TERABYTE { 313 | format!("{:.2} TB", bytes as f64 / TERABYTE as f64) 314 | } else if bytes >= GIGABYTE { 315 | format!("{:.2} GB", bytes as f64 / GIGABYTE as f64) 316 | } else if bytes >= MEGABYTE { 317 | format!("{:.2} MB", bytes as f64 / MEGABYTE as f64) 318 | } else if bytes >= KILOBYTE { 319 | format!("{:.2} KB", bytes as f64 / KILOBYTE as f64) 320 | } else { 321 | format!("{} Bytes", bytes) 322 | } 323 | } 324 | 325 | pub fn timestamp_to_readable(timestamp: u64) -> String { 326 | let duration = std::time::Duration::from_secs(timestamp); 327 | let datetime = std::time::UNIX_EPOCH + duration; 328 | let datetime: chrono::DateTime = datetime.into(); 329 | datetime.format("%Y-%m-%d %H:%M:%S").to_string() 330 | } 331 | --------------------------------------------------------------------------------