From c39fb22ad104f2e0b92dcdf915183ebe1414f8c8 Mon Sep 17 00:00:00 2001 From: JohanChane Date: Tue, 19 Mar 2024 14:31:52 +0800 Subject: [PATCH] Fix permissions of file in clash_cfg_dir. Add updating provider with api. And fix some bug. --- Example/profiles/profile1.yaml | 100 +++++ .../templates/generic_tpl_with_filter.yaml | 26 +- clashtui/Cargo.lock | 68 ++++ clashtui/Cargo.toml | 5 +- clashtui/api/Cargo.toml | 1 + clashtui/api/src/clash.rs | 179 +++++++-- clashtui/api/src/lib.rs | 2 +- clashtui/src/app.rs | 103 ++++- clashtui/src/main.rs | 94 +++-- clashtui/src/tui/symbols.rs | 38 +- clashtui/src/tui/tabs/mod.rs | 23 +- clashtui/src/tui/tabs/profile.rs | 41 +- clashtui/src/tui/utils/key_list.rs | 32 +- clashtui/src/utils/flags.rs | 5 +- clashtui/src/utils/mod.rs | 2 +- clashtui/src/utils/tui.rs | 21 +- clashtui/src/utils/tui/impl_profile.rs | 367 ++++++++++++++---- clashtui/src/utils/utils.rs | 214 +++++++++- clashtui/ui/src/widgets/list.rs | 10 +- clashtui/ui/src/widgets/msg.rs | 2 +- 20 files changed, 1085 insertions(+), 248 deletions(-) create mode 100644 Example/profiles/profile1.yaml diff --git a/Example/profiles/profile1.yaml b/Example/profiles/profile1.yaml new file mode 100644 index 0000000..15fc9ed --- /dev/null +++ b/Example/profiles/profile1.yaml @@ -0,0 +1,100 @@ +pp: + interval: 3600 + intehealth-check: + enable: true + url: https://www.gstatic.com/generate_204 + interval: 300 +delay_test: + url: https://www.gstatic.com/generate_204 + interval: 300 +proxy-groups: +- name: Entry + type: select + proxies: + - FilterFbAll +- name: FilterFbAll + type: fallback + use: + - provider0 + - provider1 + filter: (?i)美|us|unitedstates|united states|日本|jp|japan|韩|kr|korea|southkorea|south korea|新|sg|singapore + <<: + url: https://www.gstatic.com/generate_204 + interval: 300 +- name: Entry-RuleMode + type: select + proxies: + - DIRECT + - Entry +- name: Entry-LastMatch + type: select + proxies: + - Entry + - DIRECT +proxy-providers: + provider1: + <<: + interval: 3600 + intehealth-check: + enable: true + url: https://www.gstatic.com/generate_204 + interval: 300 + type: http + url: https://www.example.com + path: proxy-providers/tpl/provider1.yaml + provider0: + <<: + interval: 3600 + intehealth-check: + enable: true + url: https://www.gstatic.com/generate_204 + interval: 300 + type: http + url: https://www.example.com + path: proxy-providers/tpl/provider0.yaml +rule-anchor: + ip: + interval: 86400 + behavior: ipcidr + format: yaml + domain: + type: http + interval: 86400 + behavior: domain + format: yaml +rule-providers: + private: + type: http + url: https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/private.yaml + path: ./rule-providers/tpl/private.yaml + <<: + type: http + interval: 86400 + behavior: domain + format: yaml +rules: +- RULE-SET,private,DIRECT +- DOMAIN-SUFFIX,cn.bing.com,DIRECT +- DOMAIN-SUFFIX,bing.com,Entry +- DOMAIN,aur.archlinux.org,Entry +- GEOIP,lan,DIRECT,no-resolve +- GEOSITE,biliintl,Entry +- GEOSITE,ehentai,Entry +- GEOSITE,github,Entry +- GEOSITE,twitter,Entry +- GEOSITE,youtube,Entry +- GEOSITE,google,Entry +- GEOSITE,telegram,Entry +- GEOSITE,netflix,Entry +- GEOSITE,bilibili,Entry-RuleMode +- GEOSITE,bahamut,Entry +- GEOSITE,spotify,Entry +- GEOSITE,geolocation-!cn,Entry +- GEOIP,google,Entry +- GEOIP,netflix,Entry +- GEOIP,telegram,Entry +- GEOIP,twitter,Entry +- GEOSITE,pixiv,Entry +- GEOSITE,CN,Entry-RuleMode +- GEOIP,CN,Entry-RuleMode +- MATCH,Entry-LastMatch diff --git a/Example/templates/generic_tpl_with_filter.yaml b/Example/templates/generic_tpl_with_filter.yaml index 4b55989..a7512dd 100644 --- a/Example/templates/generic_tpl_with_filter.yaml +++ b/Example/templates/generic_tpl_with_filter.yaml @@ -5,30 +5,29 @@ proxy-groups: - name: "Entry" type: select proxies: - - FilterFallback - - FilterSelect - - - - # Empathize with `FilterFb` - - name: "FilterSelect" + - name: "FilterSelectAll" type: select use: - - + - # use proxy-providers which name is `provider`: provider0, provider1, ... filter: "(?i)美|us|unitedstates|united states|日本|jp|japan|韩|kr|korea|southkorea|south korea|新|sg|singapore" - - name: "FilterFallback" + - name: "FilterFb" # `FilterFb` name is customizable. Generate proxy-providers: FilterFb-provider0, FilterFb-provider1, ... + tpl_param: + providers: ["provider"] type: fallback - use: - - filter: "(?i)美|us|unitedstates|united states|日本|jp|japan|韩|kr|korea|southkorea|south korea|新|sg|singapore" - <<: *delay_test - - name: "Select" + - name: "Select" # Empathize with `FilterFb` tpl_param: providers: ["provider"] type: select - - name: "Auto" + - name: "Auto" # Empathize with `FilterFb` tpl_param: providers: ["provider"] type: url-test @@ -47,12 +46,13 @@ proxy-groups: - DIRECT proxy-providers: - provider: + provider: # `provider` name is customizable. Generate proxy-providers which name is `provider`: provider0, provider1, ... tpl_param: type: http <<: *pp rules: + #- IN-TYPE,INNER,DIRECT # set inner type connection. e.g. update proxy-providers, rule-providers etc. - GEOIP,lan,DIRECT,no-resolve - GEOSITE,biliintl,Entry - GEOSITE,ehentai,Entry diff --git a/clashtui/Cargo.lock b/clashtui/Cargo.lock index 0b69009..ec28539 100644 --- a/clashtui/Cargo.lock +++ b/clashtui/Cargo.lock @@ -14,6 +14,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.16" @@ -45,6 +54,7 @@ checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1" name = "api" version = "0.1.0" dependencies = [ + "chrono", "minreq", "serde", "serde-this-or-that", @@ -148,6 +158,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "chrono" version = "0.4.35" @@ -156,7 +172,9 @@ checksum = "8eaf5903dcbc0a39312feb77df2ff4c76387d591b9fc7b04a238dcf8bb62639a" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", "windows-targets 0.52.4", ] @@ -168,9 +186,12 @@ dependencies = [ "argh", "encoding", "enumflags2", + "libc", "log", "log4rs", + "nix", "ratatui", + "regex", "serde", "serde_json", "serde_yaml", @@ -481,6 +502,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "memchr" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" + [[package]] name = "minreq" version = "2.11.0" @@ -507,6 +534,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.4.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "num-traits" version = "0.2.18" @@ -599,6 +638,35 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "regex" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + [[package]] name = "ring" version = "0.17.8" diff --git a/clashtui/Cargo.toml b/clashtui/Cargo.toml index f37039f..41bd2cd 100644 --- a/clashtui/Cargo.toml +++ b/clashtui/Cargo.toml @@ -29,6 +29,9 @@ serde_json = "1.0" log = "0.4" log4rs = {version = "1.3", default-features = false, features = ["pattern_encoder", "file_appender"]} enumflags2 = "0.7.9" +nix = {version = "0.28.0", features = ["fs", "user"]} +libc = "0.2.153" +regex = "1.10.3" [target.'cfg(target_os = "windows")'.dependencies] encoding = "0.2.33" @@ -54,4 +57,4 @@ assets = [ ['target/release/clashtui', 'usr/bin/clashtui', '755'], ['../README.md', 'usr/share/doc/hust-network-login/README.md', '644'], ] -maintainer-scripts = 'debian/' \ No newline at end of file +maintainer-scripts = 'debian/' diff --git a/clashtui/api/Cargo.toml b/clashtui/api/Cargo.toml index 8e7fda2..9dd16d0 100644 --- a/clashtui/api/Cargo.toml +++ b/clashtui/api/Cargo.toml @@ -10,6 +10,7 @@ serde = { version = "1.0", features = ["derive"] } minreq = { version = "2.11", features = ["proxy", "https"] } serde_json = "1.0" serde-this-or-that = { version = "0.4.2", optional = true } +chrono = "0.4.35" [features] deprecated = ["github_api"] diff --git a/clashtui/api/src/clash.rs b/clashtui/api/src/clash.rs index 4aae79f..654707f 100644 --- a/clashtui/api/src/clash.rs +++ b/clashtui/api/src/clash.rs @@ -6,8 +6,29 @@ const GEO_URI: &str = "https://api.github.com/repos/MetaCubeX/meta-rules-dat/rel 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"; use std::io::Result; +use std::time::SystemTime; + +use minreq::{Method, Proxy}; +use chrono::{DateTime, Local, TimeZone}; + +// format: {type: [(name, modifytime)]} +pub type ProfileTimeMap = std::collections::HashMap)>>; + +#[derive(Copy, Clone, PartialEq, Eq, Hash)] +pub enum ProfileSectionType { + Profile, + ProxyProvider, + RuleProvider, +} + +pub fn provider_str_in_api(section_type: ProfileSectionType) -> Option { + match section_type { + ProfileSectionType::ProxyProvider => Some("proxies".to_string()), + ProfileSectionType::RuleProvider => Some("rules".to_string()), + _ => None, + } +} -use minreq::Method; trait ResProcess { fn process(self) -> core::result::Result; } @@ -59,14 +80,16 @@ pub struct ClashUtil { api: String, secret: String, pub proxy_addr: String, + clash_ua: String, } impl ClashUtil { - pub fn new(controller_api: String, secret: String, proxy_addr: String) -> Self { + pub fn new(controller_api: String, secret: String, proxy_addr: String, clash_ua: String) -> Self { Self { api: controller_api, secret, proxy_addr, + clash_ua, } } fn request( @@ -107,7 +130,7 @@ impl ClashUtil { pub fn mock_clash_core>(&self, url: S) -> Result { minreq::get(url) .with_proxy(minreq::Proxy::new(self.proxy_addr.clone()).map_err(process_err)?) - .with_header("user-agent", "clash.meta") + .with_header("user-agent", self.clash_ua.clone()) .with_timeout(TIMEOUT.into()) .send_lazy() .map(Resp) @@ -116,6 +139,86 @@ impl ClashUtil { pub fn config_patch(&self, payload: String) -> Result { self.request(Method::Patch, "/configs", Some(payload)) } + + pub fn update_providers(&self, provider_type: ProfileSectionType) -> Result)>> { + self.extract_net_providers(provider_type).and_then(|names| self.update_providers_helper(names, provider_type)) + } + + pub fn update_providers_helper(&self, provider_names: Vec, provider_type: ProfileSectionType) -> Result)>> { + let mut result = Vec::<(String, Result)>::new(); + for name in provider_names { + let sub_url = format!("/providers/{}/{}", provider_str_in_api(provider_type).unwrap(), name); + result.push((name, self.request(Method::Put, sub_url.as_str(), None))); + } + Ok(result) + } + + pub fn extract_net_providers(&self, provider_type: ProfileSectionType) -> Result>{ + let sub_url = format!("/providers/{}", provider_str_in_api(provider_type).unwrap()); + let response_str = self.request(Method::Get, sub_url.as_str(), None)?; + + let json_data: serde_json::Value = serde_json::from_str(response_str.as_str())?; + let mut net_providers = Vec::new(); + if let Some(providers) = json_data["providers"].as_object() { + let provider_type_str = match provider_type { + ProfileSectionType::ProxyProvider => Some("Proxy"), + ProfileSectionType::RuleProvider => Some("Rule"), + _ => None, + }; + for (_, provider) in providers.iter() { + if let (Some(p_name), Some(p_type), Some(vehicle_type)) = ( + provider.get("name").and_then(serde_json::Value::as_str), + provider.get("type").and_then(serde_json::Value::as_str), + provider.get("vehicleType").and_then(serde_json::Value::as_str), + ) { + if vehicle_type == "HTTP" { + if Some(p_type) == provider_type_str { + net_providers.push(p_name.to_string()); + } + } + } + } + } + + Ok(net_providers) + } + + // Sometime mihomo updated the provider but not update it to the file. + pub fn extract_provider_utimes_with_api(&self, provider_type: ProfileSectionType) -> Result)>>{ + let sub_url = format!("/providers/{}", provider_str_in_api(provider_type).unwrap()); + let response_str = self.request(Method::Get, sub_url.as_str(), None)?; + + let json_data: serde_json::Value = serde_json::from_str(response_str.as_str())?; + let mut net_providers = Vec::new(); + if let Some(providers) = json_data["providers"].as_object() { + let provider_type_str = match provider_type { + ProfileSectionType::ProxyProvider => Some("Proxy"), + ProfileSectionType::RuleProvider => Some("Rule"), + _ => None, + }; + for (_, provider) in providers.iter() { + if let (Some(p_name), Some(p_type), Some(vehicle_type), Some(time_str)) = ( + provider.get("name").and_then(serde_json::Value::as_str), + provider.get("type").and_then(serde_json::Value::as_str), + provider.get("vehicleType").and_then(serde_json::Value::as_str), + provider.get("updatedAt").and_then(serde_json::Value::as_str), + ) { + if vehicle_type == "HTTP" { + if Some(p_type) == provider_type_str { + let parsed_time = DateTime::parse_from_rfc3339(time_str); + net_providers.push(( + p_name.to_string(), + parsed_time.ok().map(|t| t.with_timezone(&Local).into()) + )); + } + } + } + } + } + + Ok(net_providers) + } + #[cfg(target_feature = "deprecated")] pub fn check_geo_update( &self, @@ -170,10 +273,37 @@ impl ClashUtil { Ok("Already Up to dated".to_string()) } } + /* pub fn flush_fakeip(&self) -> Result { self.post("/cache/fakeip/flush", None) } + pub fn provider(&self, is_rule: bool, name:Option<&String>, is_update: bool, is_check: bool) -> Result{ + // + if !is_rule{ + let api = "/providers/proxies"; + match name { + Some(v) => { + if is_update{ + self.put(&format!("{}/{}", api, v), None) + } else { + if is_check{ + self.get(&format!("{}/{}/healthcheck", api, v), None) + } else { + self.get(&format!("{}/{}", api, v), None) + } + } + }, + None => self.get(api, None), + } + } else { + let api = "/providers/rules"; + match name { + Some(v) => self.put(&format!("{}/{}", api, v), None), + None => self.get(api, None) + } + } + } pub fn update_geo(&self, payload:Option<&String>) -> Result{ match payload { Some(load) => self.post("/configs/geo", Some(load)), @@ -233,32 +363,6 @@ impl ClashUtil { } } } - pub fn provider(&self, is_rule: bool, name:Option<&String>, is_update: bool, is_check: bool) -> Result{ - // - if !is_rule{ - let api = "/providers/proxies"; - match name { - Some(v) => { - if is_update{ - self.put(&format!("{}/{}", api, v), None) - } else { - if is_check{ - self.get(&format!("{}/{}/healthcheck", api, v), None) - } else { - self.get(&format!("{}/{}", api, v), None) - } - } - }, - None => self.get(api, None), - } - } else { - let api = "/providers/rules"; - match name { - Some(v) => self.put(&format!("{}/{}", api, v), None), - None => self.get(api, None) - } - } - } pub fn dns_resolve(&self, name:&String, _type:Option<&String>) -> Result{ match _type { Some(v) => self.get(&format!("/dns/query?name={}&type={}", name, v), None), @@ -269,7 +373,7 @@ impl ClashUtil { } #[cfg(test)] mod tests { - use super::ClashUtil; + use super::{ClashUtil, ProfileSectionType}; fn sym() -> ClashUtil { ClashUtil::new( "http://127.0.0.1:9090".to_string(), @@ -337,4 +441,19 @@ mod tests { ); assert!(flag) } + + #[test] + fn test_update_all_providers() { + let sym = sym(); + + if let Ok(names) = sym.extract_net_providers(ProfileSectionType::ProxyProvider) { + println!("extract_net_providers: {:?}", names); + if let Ok(r) = sym.update_providers_helper(names, ProfileSectionType::RuleProvider) { + let res_str: Vec = r.iter().map(|(name, b)| { + format!("{}-{}", name, b).to_string() + }).collect(); + println!("names: {:?}", res_str) + } + } + } } diff --git a/clashtui/api/src/lib.rs b/clashtui/api/src/lib.rs index 28f03a2..a8e268e 100644 --- a/clashtui/api/src/lib.rs +++ b/clashtui/api/src/lib.rs @@ -5,7 +5,7 @@ mod dl_mihomo; #[cfg(target_feature = "github_api")] mod github_restful_api; -pub use clash::{ClashUtil, Resp}; +pub use clash::{ClashUtil, Resp, ProfileSectionType, ProfileTimeMap, provider_str_in_api}; pub use config::{ClashConfig, Mode, TunStack}; #[cfg(target_feature = "github_api")] pub use github_restful_api::GithubApi; diff --git a/clashtui/src/app.rs b/clashtui/src/app.rs index e37ac77..c002332 100644 --- a/clashtui/src/app.rs +++ b/clashtui/src/app.rs @@ -1,9 +1,10 @@ use core::cell::{OnceCell, RefCell}; use std::{path::PathBuf, rc::Rc}; +use std::io::{Write, BufRead, Read}; use ui::event; -use crate::msgpopup_methods; +use crate::{msgpopup_methods, utils}; use crate::tui::{ tabs::{ClashSrvCtlTab, ProfileTab, TabEvent, Tabs}, tools, @@ -15,6 +16,19 @@ use crate::utils::{ CfgError, ClashTuiUtil, Flag, Flags, SharedClashTuiState, SharedClashTuiUtil, State, }; +/// Mihomo (Clash.Meta) TUI Client +/// +/// A tui tool for mihomo +#[derive(argh::FromArgs)] +pub struct CliEnv { + /// don't show UI but only update all profiles + #[argh(switch, short = 'u')] + pub update_all_profiles: bool, + /// print version information and exit + #[argh(switch, short = 'v')] + pub version: bool, +} + pub struct App { tabbar: TabBar, tabs: Vec, @@ -23,7 +37,7 @@ pub struct App { info_popup: InfoPopUp, msgpopup: MsgPopup, - clashtui_util: SharedClashTuiUtil, + pub clashtui_util: SharedClashTuiUtil, statusbar: StatusBar, } @@ -34,22 +48,6 @@ impl App { ) -> (Option, Vec) { let (util, err_track) = ClashTuiUtil::new(clashtui_config_dir, !flags.contains(Flag::FirstInit)); - if flags.contains(Flag::UpdateOnly) { - log::info!("Cron Mode!"); - util.get_profile_names() - .unwrap() - .into_iter() - .inspect(|s| println!("\nProfile: {s}")) - .filter_map(|v| { - util.update_profile(&v, false) - .map_err(|e| println!("- Error! {e}")) - .ok() - }) - .flatten() - .for_each(|s| println!("- {s}")); - - return (None, err_track); - } // Finish cron let clashtui_util = SharedClashTuiUtil::new(util); let clashtui_state = @@ -70,7 +68,7 @@ impl App { let statusbar = StatusBar::new(Rc::clone(&clashtui_state)); let info_popup = InfoPopUp::with_items(&clashtui_util.clash_version()); - let app = Self { + let mut app = Self { tabbar, should_quit: false, help_popup: Default::default(), @@ -81,6 +79,8 @@ impl App { tabs, }; + app.do_some_job_after_initapp_before_setupui(); + (Some(app), err_track) } @@ -139,14 +139,14 @@ impl App { Keys::ClashConfig => { let _ = self .clashtui_util - .open_dir(self.clashtui_util.clashtui_dir.as_path()) + .open_dir(&PathBuf::from(&self.clashtui_util.tui_cfg.clash_cfg_dir)) .map_err(|e| log::error!("ODIR: {}", e)); EventState::WorkDone } Keys::AppConfig => { let _ = self .clashtui_util - .open_dir(&PathBuf::from(&self.clashtui_util.tui_cfg.clash_cfg_dir)) + .open_dir(self.clashtui_util.clashtui_dir.as_path()) .map_err(|e| log::error!("ODIR: {}", e)); EventState::WorkDone } @@ -248,6 +248,67 @@ impl App { .to_file(config_path) .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) } + + fn do_some_job_after_initapp_before_setupui(&mut self) { + // ## Correct the perm of files in clash_cfg_dir. + if ! self.clashtui_util.check_perms_of_ccd_files() { + let ccd_str = self.clashtui_util.tui_cfg.clash_cfg_dir.as_str(); + if ! utils::is_run_as_root() { + print!("The permissions of the '{}' files are incorrect. clashtui need to run as root to correct. Proceed with running as root? [Y/n] ", ccd_str); + std::io::stdout().flush().expect("Failed to flush stdout"); + + let mut input = String::new(); + let stdin = std::io::stdin(); + stdin.lock().read_line(&mut input).unwrap(); + + if input.trim().to_lowercase().as_str() == "y" { + utils::run_as_root(); + } + + } else { + if utils::is_clashtui_ep() { + println!("\nStart correct the permissions of files in '{}':\n", ccd_str); + let dir = std::path::Path::new(ccd_str); + if let Some(group_name) = utils::get_file_group_name(&dir.to_path_buf()) { + utils::restore_fileop_as_root(); + utils::modify_file_perms_in_dir(&dir.to_path_buf(), group_name.as_str()); + utils::mock_fileop_as_sudo_user(); + } + print!("\nEnd correct the permissions of files in '{}'. \n\nPress any key to continue. ", ccd_str); + std::io::stdout().flush().expect("Failed to flush stdout"); + let _ = std::io::stdin().read(&mut [0u8]); + } else { // user manually executing `sudo clashtui` + // Do nothing, as root is unaffected by permissions. + } + } + } + + let cli_env: CliEnv = argh::from_env(); + + // ## CliMode + let mut is_cli_mode = false; + + if cli_env.update_all_profiles { + is_cli_mode = true; + + log::info!("Cron Mode!"); + self.clashtui_util.get_profile_names() + .unwrap() + .into_iter() + .inspect(|s| println!("\nProfile: {s}")) + .filter_map(|v| { + self.clashtui_util.update_profile(&v, false) + .map_err(|e| println!("- Error! {e}")) + .ok() + }) + .flatten() + .for_each(|s| println!("- {s}")); + } + + if is_cli_mode { + std::process::exit(0); + } + } } msgpopup_methods!(App); diff --git a/clashtui/src/main.rs b/clashtui/src/main.rs index f553e7a..cf1a2fc 100644 --- a/clashtui/src/main.rs +++ b/clashtui/src/main.rs @@ -1,63 +1,61 @@ #![warn(clippy::all)] -use core::time::Duration; mod app; mod tui; mod utils; +use core::time::Duration; +use nix::sys; + use crate::app::App; use crate::utils::{Flag, Flags}; pub const VERSION: &str = concat!(env!("CLASHTUI_VERSION")); -/// Mihomo (Clash.Meta) TUI Client -/// -/// A tui tool for mihomo -#[derive(argh::FromArgs)] -struct CliEnv { - /// time in ms between two ticks. - #[argh(option, default = "250")] - tick_rate: u64, - /// don't show UI but only update all profiles - #[argh(switch, short = 'u')] - update_all_profiles: bool, - /// print version information and exit - #[argh(switch, short = 'v')] - version: bool, -} - fn main() { - let CliEnv { - tick_rate, - update_all_profiles, - version, - } = argh::from_env(); - if version { + let mut warning_list_msg = Vec::::new(); + + // ## Paser param + let cli_env: app::CliEnv = argh::from_env(); + if cli_env.version { println!("{VERSION}"); - } else { - let mut flags = Flags::empty(); - if update_all_profiles { - flags.insert(utils::Flag::UpdateOnly); - }; - if let Err(e) = run(flags, tick_rate) { - eprintln!("{e}"); - std::process::exit(-1) - } + std::process::exit(0); } - std::process::exit(0); -} -pub fn run(mut flags: Flags, tick_rate: u64) -> std::io::Result<()> { - let config_dir = load_app_dir(&mut flags); + let mut flags = Flags::empty(); + + // ## Is CliMode + if cli_env.update_all_profiles { + flags.insert(Flag::CliMode) + } + + // ## Setup logging as early as possible. So We can log. + let config_dir = load_app_dir(&mut flags); setup_logging(config_dir.join("clashtui.log").to_str().unwrap()); - let (app, err_track) = App::new(&flags, &config_dir); + // To allow the mihomo process to read and write files created by clashtui in clash_cfg_dir, set the umask to 0o002and Users manually add SGID to clash_cfg_dir. + if utils::is_clashtui_ep() { + utils::mock_fileop_as_sudo_user(); + } + sys::stat::umask(sys::stat::Mode::from_bits_truncate(0o002)); + + let tick_rate = 250; // time in ms between two ticks. + if let Err(e) = run(&mut flags, tick_rate, &config_dir, &mut warning_list_msg) { + eprintln!("{e}"); + std::process::exit(-1) + } + + std::process::exit(0); +} +pub fn run(flags: &mut Flags, tick_rate: u64, config_dir: &std::path::PathBuf, warning_list_msg: &mut Vec) -> std::io::Result<()> { + let (app, err_track) = App::new(&flags, config_dir); log::debug!("Current flags: {:?}", flags); + if let Some(mut app) = app { use ui::setup::*; // setup terminal setup()?; // create app and run it - run_app(&mut app, tick_rate, err_track, flags)?; + run_app(&mut app, tick_rate, err_track, flags, warning_list_msg)?; // restore terminal restore()?; @@ -73,18 +71,15 @@ fn run_app( app: &mut App, tick_rate: u64, err_track: Vec, - flags: Flags, + flags: &mut Flags, + warning_list_msg: &mut Vec, ) -> std::io::Result<()> { if flags.contains(utils::Flag::FirstInit) { - app.popup_txt_msg("Welcome to ClashTui(forked)!".to_string()); - app.popup_txt_msg( - "Please go to Config Tab to set configs so that program can work properly".to_string(), - ); + warning_list_msg.push("Welcome to ClashTui!".to_string()); + warning_list_msg.push("Please go to Config Tab to set configs so that program can work properly".to_string()); }; if flags.contains(utils::Flag::ErrorDuringInit) { - app.popup_txt_msg( - "Some Error happened during app init, Check the log for detail".to_string(), - ); + warning_list_msg.push("Some Error happened during app init, Check the log for detail".to_string()); } err_track .into_iter() @@ -93,8 +88,10 @@ fn run_app( use ratatui::{backend::CrosstermBackend, Terminal}; let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?; + terminal.clear()?; // Clear terminal residual text before draw. let tick_rate = Duration::from_millis(tick_rate); use ui::event; + app.popup_list_msg(warning_list_msg.to_owned()); // Set msg popup before draw while !app.should_quit { terminal.draw(|f| app.draw(f))?; @@ -121,7 +118,7 @@ fn load_app_dir(flags: &mut Flags) -> std::path::PathBuf { data_dir } else { #[cfg(target_os = "linux")] - let clashtui_config_dir_str = env::var("XDG_CONFIG_HOME") + let clashtui_config_dir_str = env::var("XDG_CONFIG_HOME").map(|p| format!("{}/clashtui", p)) .or_else(|_| env::var("HOME").map(|home| format!("{}/.config/clashtui", home))) .unwrap(); #[cfg(target_os = "windows")] @@ -145,6 +142,7 @@ fn load_app_dir(flags: &mut Flags) -> std::path::PathBuf { } clashtui_config_dir } + fn setup_logging(log_path: &str) { use log4rs::append::file::FileAppender; use log4rs::config::{Appender, Config, Root}; @@ -164,7 +162,7 @@ fn setup_logging(log_path: &str) { #[cfg(debug_assertions)] let log_level = log::LevelFilter::Debug; let file_appender = FileAppender::builder() - .encoder(Box::new(PatternEncoder::new("[{l}] {t} - {m}{n}"))) + .encoder(Box::new(PatternEncoder::new("{d(%H:%M:%S)} [{l}] {t} - {m}{n}"))) // Having a timestamp would be better. .build(log_path) .unwrap(); diff --git a/clashtui/src/tui/symbols.rs b/clashtui/src/tui/symbols.rs index 6ff37c9..6c3563e 100644 --- a/clashtui/src/tui/symbols.rs +++ b/clashtui/src/tui/symbols.rs @@ -1,36 +1,40 @@ -pub(super) const HELP: &str = r#"## Profile +pub(super) const HELP: &str = r#"## Common +j/k/h/l OR Up/Down/Left/Right: Scroll +Enter: Action +Esc: Close popup +Tab: Switch + +## Profile Tab p: Switch to profile -enter: Select +t: Switch to template + +## Profile Window +Enter: Select u: Update proxy-providers only -U: Update all network resources in profile +a: Update all network resources in profile i: Import d: Delete +s: Test e: Edit -t: Test -p: Preview +v: Preview ## Tempalte -T: Switch to template -enter: Create yaml +Enter: Create yaml e: Edit -p: Preview +v: Preview ## ClashSrvCtl -enter: Action - -## Scroll -j/k/h/l OR Up/Down/Left/Right: Scroll +Enter: Action ## Global -I: Show informations +q: Quit R: Restart clash core +L: Show recent log +I: Show informations H: Locate app home path G: Locate clash config dir -L: show recent log 1,2,...,9 OR Tab: Switch tabs -Esc: Close popup -Q: Quit -?: help"#; +?: Help"#; pub(crate) const DEFAULT_BASIC_CLASH_CFG_CONTENT: &str = r#"mixed-port: 7890 mode: rule diff --git a/clashtui/src/tui/tabs/mod.rs b/clashtui/src/tui/tabs/mod.rs index 81c46f6..e8974d3 100644 --- a/clashtui/src/tui/tabs/mod.rs +++ b/clashtui/src/tui/tabs/mod.rs @@ -41,13 +41,26 @@ pub trait TabEvent { macro_rules! msgpopup_methods { ($type:ident) => { impl $type { + // single-line popup pub fn popup_txt_msg(&mut self, msg: String) { - self.msgpopup.push_txt_msg(msg); - self.msgpopup.show(); + if ! msg.is_empty() { + self.msgpopup.push_txt_msg(msg); + self.msgpopup.show(); + } } - pub fn popup_list_msg(&mut self, msg: impl IntoIterator) { - self.msgpopup.push_list_msg(msg); - self.msgpopup.show(); + // multi-lines popup + pub fn popup_list_msg(&mut self, msg: I) + where + I: IntoIterator, + { + let mut list_msg = Vec::::new(); + for m in msg.into_iter() { + list_msg.push(m); + } + if list_msg.len() > 0 { + self.msgpopup.push_list_msg(list_msg); + self.msgpopup.show(); + } } #[allow(unused)] pub fn hide_msgpopup(&mut self) { diff --git a/clashtui/src/tui/tabs/profile.rs b/clashtui/src/tui/tabs/profile.rs index 24d4546..118e53b 100644 --- a/clashtui/src/tui/tabs/profile.rs +++ b/clashtui/src/tui/tabs/profile.rs @@ -5,9 +5,9 @@ use crate::tui::{ widgets::{ConfirmPopup, List, MsgPopup}, EventState, Visibility, }; -use crate::utils::{SharedClashTuiState, SharedClashTuiUtil}; -use crate::{msgpopup_methods, utils::get_modify_time}; -crate::define_enum!(PTOp, [Update, UpdateAll, Select, Delete]); +use crate::utils::{self, SharedClashTuiState, SharedClashTuiUtil, ProfileType}; +use crate::{msgpopup_methods, utils::get_mtime}; +crate::define_enum!(PTOp, [Update, UpdateAll, Select, Delete]); // PTOp: ProfileTabOperation #[derive(PartialEq)] enum Fouce { @@ -29,6 +29,7 @@ pub struct ProfileTab { clashtui_util: SharedClashTuiUtil, clashtui_state: SharedClashTuiState, op: Option, + confirm_op: Option, } impl ProfileTab { @@ -48,6 +49,7 @@ impl ProfileTab { clashtui_util, clashtui_state, op: None, + confirm_op: None, }; instance.update_profile_list(); @@ -142,7 +144,7 @@ impl ProfileTab { .map(|v| { self.clashtui_util .get_profile_yaml_path(v) - .and_then(get_modify_time) + .and_then(get_mtime) .map_err(|e| log::error!("{v} => {e}")) .ok() }) @@ -160,12 +162,12 @@ impl ProfileTab { self.profile_list .set_extras(profile_times.into_iter().map(|t| { t.map(|t| { - display_duration( + utils::str_duration( now.duration_since(t) .expect("Clock may have gone backwards"), ) }) - .unwrap_or("Never/Error".to_string()) + .unwrap_or("Never/Err".to_string()) })) } } @@ -180,7 +182,7 @@ impl super::TabEvent for ProfileTab { if event_state.is_notconsumed() { event_state = match self.confirm_popup.event(ev)? { EventState::Yes => { - self.op.replace(PTOp::Delete); + self.op = self.confirm_op.take(); EventState::WorkDone } EventState::Cancel | EventState::WorkDone => EventState::WorkDone, @@ -246,12 +248,15 @@ impl super::TabEvent for ProfileTab { Keys::ProfileDelete => { self.confirm_popup .popup_msg("`y` to Delete, `Esc` to cancel".to_string()); + self.confirm_op.replace(PTOp::Delete); EventState::WorkDone } Keys::Edit => { // Hmm, now every time I call edit, an window will pop up // even if there is no error. But I think it's fine, maybe // I'll solve it one day + // + // I fix it, because msg is empty. self.popup_txt_msg( self.profile_list .selected() @@ -283,7 +288,10 @@ impl super::TabEvent for ProfileTab { .map(|s| s.to_string()) .collect(); - if !self.clashtui_util.is_profile_yaml(profile_name) { + if self.clashtui_util.get_profile_type(profile_name) + .is_some_and(|t| t == ProfileType::Url) + { + log::debug!("get_profile_type: is url"); lines.push(String::new()); profile_path = self.clashtui_util.get_profile_yaml_path(profile_name).ok(); @@ -355,7 +363,7 @@ impl super::TabEvent for ProfileTab { self.clashtui_util.edit_file(&tpl_file_path).err() }) .map(|err| err.to_string()) - .collect(), + .collect() ); EventState::WorkDone } @@ -413,19 +421,4 @@ impl super::TabEvent for ProfileTab { self.confirm_popup.draw(f, area); } } -fn display_duration(t: std::time::Duration) -> String { - use std::time::Duration; - if t.is_zero() { - "Just Now".to_string() - } else if t < Duration::from_secs(60 * 59) { - let min = t.as_secs() / 60; - format!("In {} mins", min + 1) - } else if t < Duration::from_secs(3600 * 24) { - let hou = t.as_secs() / 3600; - format!("In {hou} hours") - } else { - let day = t.as_secs() / (3600 * 24); - format!("In about {day} days") - } -} msgpopup_methods!(ProfileTab); diff --git a/clashtui/src/tui/utils/key_list.rs b/clashtui/src/tui/utils/key_list.rs index cf7dac7..81b60fe 100644 --- a/clashtui/src/tui/utils/key_list.rs +++ b/clashtui/src/tui/utils/key_list.rs @@ -33,26 +33,34 @@ pub enum Keys { impl From for Keys { fn from(value: KeyCode) -> Self { match value { - KeyCode::Char('P') => Keys::ProfileSwitch, - KeyCode::Char('u') => Keys::ProfileUpdate, - KeyCode::Char('U') => Keys::ProfileUpdateAll, - KeyCode::Char('i') => Keys::ProfileImport, - KeyCode::Char('d') => Keys::ProfileDelete, - KeyCode::Char('t') => Keys::ProfileTestConfig, - KeyCode::Char('T') => Keys::TemplateSwitch, - KeyCode::Char('e') => Keys::Edit, - KeyCode::Char('p') => Keys::Preview, - - KeyCode::Char('R') => Keys::SoftRestart, + // Convention: Global Shortcuts As much as possible use uppercase. And Others as much as possible use lowcase to avoid conflicts with global shortcuts. + // ## Common shortcuts KeyCode::Down | KeyCode::Char('j') => Keys::Down, KeyCode::Up | KeyCode::Char('k') => Keys::Up, KeyCode::Enter => Keys::Select, KeyCode::Esc => Keys::Esc, KeyCode::Tab => Keys::Tab, + // ## Profile Tab shortcuts + KeyCode::Char('p') => Keys::ProfileSwitch, // Not Global shortcuts + KeyCode::Char('t') => Keys::TemplateSwitch, // Not Global shortcuts + + // ## For operating file in Profile and Template Windows + KeyCode::Char('e') => Keys::Edit, + KeyCode::Char('v') => Keys::Preview, + + // ## Profile windows shortcuts + KeyCode::Char('u') => Keys::ProfileUpdate, + KeyCode::Char('a') => Keys::ProfileUpdateAll, + KeyCode::Char('i') => Keys::ProfileImport, + KeyCode::Char('d') => Keys::ProfileDelete, + KeyCode::Char('s') => Keys::ProfileTestConfig, + + // ## Global Shortcuts (As much as possible use uppercase. And Others as much as possible use lowcase to avoid conflicts with global shortcuts.) + KeyCode::Char('q') => Keys::AppQuit, // Exiting is a common operation, and most software also exits with "q", so let's use "q". + KeyCode::Char('R') => Keys::SoftRestart, KeyCode::Char('L') => Keys::LogCat, - KeyCode::Char('Q') => Keys::AppQuit, KeyCode::Char('?') => Keys::AppHelp, KeyCode::Char('I') => Keys::AppInfo, KeyCode::Char('H') => Keys::AppConfig, diff --git a/clashtui/src/utils/flags.rs b/clashtui/src/utils/flags.rs index 0b8cbad..75b8154 100644 --- a/clashtui/src/utils/flags.rs +++ b/clashtui/src/utils/flags.rs @@ -5,7 +5,7 @@ pub use enumflags2::BitFlags; #[bitflags] #[repr(u8)] pub enum Flag { - UpdateOnly = 1, + CliMode = 1, FirstInit = 1 << 1, ErrorDuringInit = 1 << 2, PortableMode = 1 << 3, @@ -16,9 +16,6 @@ mod test { #[test] fn test_flags() { let mut flags = BitFlags::EMPTY; - flags.insert(Flag::UpdateOnly); - println!("{flags:?}"); - assert!(flags.contains(Flag::UpdateOnly)); println!("{:?}", flags.exactly_one()); flags.insert(Flag::FirstInit); println!("{flags:?}"); diff --git a/clashtui/src/utils/mod.rs b/clashtui/src/utils/mod.rs index 472ca60..e52084a 100644 --- a/clashtui/src/utils/mod.rs +++ b/clashtui/src/utils/mod.rs @@ -12,5 +12,5 @@ pub type SharedClashTuiState = std::rc::Rc>; pub use config::{init_config, CfgError}; pub use flags::{BitFlags as Flags, Flag}; pub use state::State; -pub use tui::ClashTuiUtil; +pub use tui::{ClashTuiUtil, ProfileType}; pub use utils::*; diff --git a/clashtui/src/utils/tui.rs b/clashtui/src/utils/tui.rs index 31dac58..2c01063 100644 --- a/clashtui/src/utils/tui.rs +++ b/clashtui/src/utils/tui.rs @@ -4,6 +4,8 @@ use std::{ io::Error, path::{Path, PathBuf}, }; +use api::ProfileSectionType; + mod impl_app; mod impl_clashsrv; mod impl_profile; @@ -14,6 +16,17 @@ use super::{ }; use api::{ClashConfig, ClashUtil, Resp}; +// format: {section_key: [(name, url, path)]} +pub type NetProviderMap = std::collections::HashMap>; +// format: {type, [(name, result)]} +pub type UpdateProviderType = std::collections::HashMap)>>; + +#[derive(Copy, Clone, PartialEq, Eq, Hash)] +pub enum ProfileType { + Url, + Yaml, +} + const BASIC_FILE: &str = "basic_clash_config.yaml"; pub struct ClashTuiUtil { @@ -120,6 +133,12 @@ fn load_app_config( .unwrap_or_default() .to_string(); + let clash_ua = basic_clash_config_value + .get("global-ua") + .and_then(|v| v.as_str()) + .unwrap_or("clash.meta") + .to_string(); + let configs = if skip_init_conf { let config_path = clashtui_dir.join("config.yaml"); match ClashTuiConfig::from_file(config_path.to_str().unwrap()) { @@ -148,7 +167,7 @@ fn load_app_config( }; ( configs, - ClashUtil::new(controller_api, secret, proxy_addr), + ClashUtil::new(controller_api, secret, proxy_addr, clash_ua), err_collect, ) } diff --git a/clashtui/src/utils/tui/impl_profile.rs b/clashtui/src/utils/tui/impl_profile.rs index fe653b9..3a8175f 100644 --- a/clashtui/src/utils/tui/impl_profile.rs +++ b/clashtui/src/utils/tui/impl_profile.rs @@ -1,5 +1,13 @@ +use std::os::unix::fs::{PermissionsExt, MetadataExt}; +use std::io::BufRead; +use regex::Regex; + +use crate::utils::tui::{NetProviderMap, UpdateProviderType, ProfileType}; +use api::ProfileTimeMap; + use super::ClashTuiUtil; use crate::utils::{is_yaml, utils as Utils}; +use api::ProfileSectionType; use std::{ fs::{create_dir_all, File}, io::Error, @@ -210,7 +218,6 @@ impl ClashTuiUtil { } } - log::error!("testssdfs"); let out_yaml_path = self.profile_dir.join(template_name); let out_yaml_file = File::create(out_yaml_path).map_err(|e| e.to_string())?; serde_yaml::to_writer(out_yaml_file, &out_parsed_yaml).map_err(|e| e.to_string())?; @@ -249,9 +256,10 @@ impl ClashTuiUtil { pub fn rmf_profile(&self, profile_name: &String) -> Result<(), String> { use std::fs::remove_file; - remove_file(self.get_profile_path_unchecked(profile_name)) - .and_then(|_| remove_file(self.get_profile_cache_unchecked(profile_name))) - .map_err(|e| e.to_string()) + if self.get_profile_type(profile_name).is_some_and(|t| t == ProfileType::Url) { + let _ = remove_file(self.get_profile_cache_unchecked(profile_name)); // Not important + } + remove_file(self.get_profile_path_unchecked(profile_name)).map_err(|e| e.to_string()) } pub fn test_profile_config(&self, path: &str, geodata_mode: bool) -> std::io::Result { @@ -335,11 +343,22 @@ impl ClashTuiUtil { &self, profile_name: &String, does_update_all: bool, + ) -> std::io::Result> { + self.update_profile_with_clashtui(profile_name, does_update_all) + //self.update_profile_with_api(profile_name, does_update_all) + } + + // The advantage of using this interface for updates is that you can know the reason for update failures without needing to check mihomo's logs. The downside is that it requires resolving file permission issues. + pub fn update_profile_with_clashtui( + &self, + profile_name: &String, + does_update_all: bool, ) -> std::io::Result> { let mut profile_yaml_path = self.profile_dir.join(profile_name); - let mut net_res: Vec<(String, String)> = Vec::new(); - // if it's just the link - if !self.is_profile_yaml(profile_name) { + let mut result = Vec::new(); + if self.get_profile_type(profile_name) + .is_some_and(|t| t == ProfileType::Url) + { let file_content = std::io::read_to_string(File::open(profile_yaml_path)?)?; let sub_url = file_content.trim(); @@ -347,67 +366,96 @@ impl ClashTuiUtil { // Update the file to keep up-to-date self.download_profile(sub_url, &profile_yaml_path)?; - net_res.push(( - sub_url.to_string(), - profile_yaml_path.to_string_lossy().to_string(), - )) + result.push(format!("Updated: {}, {}", profile_name, sub_url)); } - // Update the resouce in the file (if there is) - { - let yaml_content = std::io::read_to_string(File::open(profile_yaml_path)?)?; - let parsed_yaml = serde_yaml::Value::from(yaml_content.as_str()); - drop(yaml_content); - net_res.extend( - if !does_update_all { - vec!["proxy-providers"] - } else { - vec!["proxy-providers", "rule-providers"] + let mut section_types = vec![ProfileSectionType::ProxyProvider]; + if does_update_all { + section_types.push(ProfileSectionType::RuleProvider); + } + + let mut net_providers = NetProviderMap::new(); + if let Ok(providers) = self.extract_net_providers(&profile_yaml_path, §ion_types) { + net_providers.extend(providers); + } + + for (_, providers) in net_providers { + for (name, url, path) in providers { + match self.download_profile(&url, &Path::new(&self.tui_cfg.clash_cfg_dir).join(&path)) { + Ok(_) => result.push(format!("Updated: {}, {}", name, url)), + Err(e) => result.push(format!("Not updated: {}, {}, {}", name, url, e)), + } - .into_iter() - .filter_map(|key| parsed_yaml.get(key)) - .filter_map(|val| val.as_mapping()) - // flatten inner iter - .flat_map(|providers| { - providers - .into_iter() - .filter_map(|(_, provider_value)| provider_value.as_mapping()) - // pass only when type is http - .filter(|&provider_content| { - provider_content - .get("type") - .and_then(|v| v.as_str()) - .is_some_and(|t| t == "http") - }) - .filter_map(|provider_content| { - if let ( - Some(serde_yaml::Value::String(url)), - Some(serde_yaml::Value::String(path)), - ) = (provider_content.get("url"), provider_content.get("path")) - { - Some((url.clone(), path.clone())) - } else { - None - } - }) - }), + } + } + + Ok(result) + } + + // Using api update, the user needs to check the logs to understand why the updates failed. The success rate of my testing updates is not as high as using clashtui. + pub fn update_profile_with_api( + &self, + profile_name: &String, + does_update_all: bool, + ) -> std::io::Result> { + let mut profile_yaml_path = self.profile_dir.join(profile_name); + let mut result = Vec::new(); + if self.get_profile_type(profile_name) + .is_some_and(|t| t == ProfileType::Url) + { + let file_content = std::io::read_to_string(File::open(profile_yaml_path)?)?; + let sub_url = file_content.trim(); + + profile_yaml_path = self.get_profile_cache_unchecked(profile_name); + // Update the file to keep up-to-date + self.download_profile(sub_url, &profile_yaml_path)?; + + result.push( + format!("Updated: {}, {}", profile_name, sub_url) ); } - Ok(net_res - .into_iter() - .map(|(url, path)| { - match self - .download_profile(&url, &Path::new(&self.tui_cfg.clash_cfg_dir).join(path)) - { - Ok(_) => format!("Updated: {url}"), + let mut provider_types = vec![ProfileSectionType::ProxyProvider]; + if does_update_all { + provider_types.push(ProfileSectionType::RuleProvider); + } + + let mut update_providers_result = UpdateProviderType::new(); + let mut update_times = ProfileTimeMap::new(); + for t in provider_types { + // Get result of update providers + update_providers_result.insert(t, self.clash_api.update_providers(t)?); + + // Get update times after update providers + if let Ok(name_times) = self.clash_api.extract_provider_utimes_with_api(t) { + update_times.insert(t, name_times); + } + } + + // Add results of updating providers + for (section_type, res) in update_providers_result { + for (name, r) in res { + // Generate duration_str + let duration_str = if let Ok(d) = Self::cal_mtime_duration(&update_times, section_type, &name) { + Utils::str_duration(d) + } else { + "No update times or can't cal the duration".to_string() + }; + + let line = match r { + Ok(_) => { + format!("Sent update request: {}, duration = '{}'", name, duration_str) + } Err(err) => { - log::error!("Update profile:{err}"); - format!("Not Updated: {url}") + log::error!("Not Sent update request:{err}"); + format!("Not Sent update request: {}, duration = '{}'", name, duration_str) } - } - }) - .collect::>()) + }; + result.push(line); + } + } + + Ok(result) } fn download_profile(&self, url: &str, path: &PathBuf) -> std::io::Result<()> { @@ -418,11 +466,136 @@ impl ClashTuiUtil { create_dir_all(directory)?; } - let mut output_file = File::create(path)?; let response = self.dl_remote_profile(url)?; + let mut output_file = File::create(path)?; // will truncate the file response.copy_to(&mut output_file)?; Ok(()) } + + fn get_provider_mtime(&self, section_types: Vec, profile_yaml_path: &PathBuf) -> std::io::Result { + let mut modify_info = ProfileTimeMap::new(); + if let Ok(net_res) = self.extract_net_providers(profile_yaml_path, §ion_types) { + for (key, res) in net_res { + let name_and_times = res.into_iter().map(|(name, _, path)| { + let clash_cfg_dir = Path::new(&self.tui_cfg.clash_cfg_dir); + let time = Utils::get_mtime(clash_cfg_dir.join(path)).ok(); + (name, time) + }).collect(); + modify_info.insert(key, name_and_times); + } + } + + Ok(modify_info) + } + + pub fn extract_net_providers(&self, profile_yaml_path: &PathBuf, provider_types: &Vec) -> std::io::Result { + let yaml_content = std::fs::read_to_string(&profile_yaml_path)?; + let parsed_yaml = match serde_yaml::from_str::(&yaml_content) { + Ok(value) => value, + Err(err) => return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, err)), + }; + + let provider_keys: Vec<_> = provider_types.iter().filter_map(|s_type| { + match s_type { + ProfileSectionType::ProxyProvider => Some("proxy-providers"), + ProfileSectionType::RuleProvider => Some("rule-providers"), + _ => None, + } + }).collect(); + + let mut net_providers = NetProviderMap::new(); + for section_key in provider_keys { + let section_val = if let Some(val) = parsed_yaml.get(section_key) { + val + } else { + continue; + }; + + let the_section_val = if let serde_yaml::Value::Mapping(val) = section_val { + val + } else { + continue; + }; + + let mut providers: Vec<(String, String, String)> = Vec::new(); + for (provider_key, provider_val) in the_section_val { + let provider = if let Some(val) = provider_val.as_mapping() { + val + } else { + continue; + }; + + if let (Some(name), Some(url), Some(path)) = ( + Some(provider_key), + provider.get(&serde_yaml::Value::String("url".to_string())), + provider.get(&serde_yaml::Value::String("path".to_string())), + ) { + if let (serde_yaml::Value::String(name), serde_yaml::Value::String(url), serde_yaml::Value::String(path)) = (name, url, path) { + providers.push((name.clone(), url.clone(), path.clone())); + } + } + } + + if section_key == "proxy-providers" { + net_providers.insert(ProfileSectionType::ProxyProvider, providers); + } else if section_key == "rule-providers" { + net_providers.insert(ProfileSectionType::RuleProvider, providers); + } + } + + Ok(net_providers) + } + + // duration: now - mtime + fn cal_mtime_duration(mtimes: &ProfileTimeMap, section_type: ProfileSectionType, name: &String) -> std::io::Result { + let mt = Self::extract_the_mtime(mtimes, section_type, name).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "No the mtime in mtimes"))?; + let now = std::time::SystemTime::now(); + now.duration_since(mt).map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) + } + + fn extract_the_mtime<'a>(mtimes: &'a ProfileTimeMap, section_type: ProfileSectionType, name: &String) -> &'a Option { + if let Some(times) = mtimes.get(§ion_type) { + for (n, the_mtime) in times { + if n == name { + return the_mtime; + } + } + } + + &None + } + + // Check if need to correct perms of files in clash_cfg_dir. If perm is incorrect return false. + pub fn check_perms_of_ccd_files(&self) -> bool { + let dir = Path::new(self.tui_cfg.clash_cfg_dir.as_str()); + //let group_name = Utils::get_file_group_name(&dir.to_path_buf()); + //if group_name.is_none() { + // return false; + //} + + // check set-group-id + if let Ok(metadata) = std::fs::metadata(dir) { + let permissions = metadata.permissions(); + if permissions.mode() & 0o2000 == 0 { + return false; + } + } + + if let Ok(metadata) = std::fs::metadata(dir) { + if let Some(dir_group) = + nix::unistd::Group::from_gid(nix::unistd::Gid::from_raw(metadata.gid())).unwrap() + { + if Utils::find_files_not_in_group(&dir.to_path_buf(), dir_group.name.as_str()).len() > 0 + || Utils::find_files_not_group_writable(&dir.to_path_buf()).len() > 0 + { + return false; + } + } + } + + return true; + } + } impl ClashTuiUtil { @@ -472,12 +645,44 @@ impl ClashTuiUtil { .join(profile_name) .with_extension("yaml") } - /// Check file only in `profiles` - /// - /// Judging by format - pub fn is_profile_yaml>(&self, profile_name: P) -> bool { + + pub fn get_profile_type(&self, profile_name: &str) -> Option { let profile_path = self.get_profile_path_unchecked(profile_name); - is_yaml(&profile_path) + if is_yaml(&profile_path) { + return Some(ProfileType::Yaml); + } + + match self.extract_profile_url(profile_name) { + Ok(_) => return Some(ProfileType::Url), + Err(e) => { + log::warn!("{}", e); + } + } + + None + } + + pub fn extract_profile_url(&self, profile_name: &str) -> std::io::Result { + let profile_path = self.profile_dir.join(profile_name); + let file = File::open(profile_path)?; + let reader = std::io::BufReader::new(file); + + let url_regex = Regex::new(r#"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-])"#) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("Invalid regex: {}", e)))?; + + + for line in reader.lines() { + let line = line?; + let line = line.trim(); + + if !line.starts_with("#") { // `#` is comment char + if url_regex.is_match(&line) { + return Ok(line.to_string()); + } + } + } + + Err(std::io::Error::new(std::io::ErrorKind::NotFound, format!("No URL found in {}", profile_name))) } } /// # Limitations @@ -497,3 +702,33 @@ fn crt_symlink_file>(original: P, target: P) -> std::i #[cfg(target_os = "linux")] os::unix::fs::symlink(original, target) } + +#[cfg(test)] +mod tests { + use super::*; + fn sym() -> ClashTuiUtil { + let exe_dir = std::env::current_dir().unwrap(); + println!("{exe_dir:?}"); + let clashtui_dir = exe_dir.parent().unwrap().join("Example"); + let (util, _) = ClashTuiUtil::new( + &clashtui_dir.to_path_buf(), + true + ); + util + } + + #[test] + fn test_extrat_profile_net_res() { + let sym = sym(); + + let profile_name = "profile1.yaml"; + let mut profile_yaml_path = sym.profile_dir.join(profile_name); + if sym.get_profile_type(profile_name) + .is_some_and(|t| t == ProfileType::Url) + { + profile_yaml_path = sym.get_profile_cache_unchecked(profile_name); + } + let net_providers = sym.extract_net_providers(&profile_yaml_path, &vec![ProfileSectionType::ProxyProvider]); + } + +} diff --git a/clashtui/src/utils/utils.rs b/clashtui/src/utils/utils.rs index 5d905d7..5ba5f4b 100644 --- a/clashtui/src/utils/utils.rs +++ b/clashtui/src/utils/utils.rs @@ -1,3 +1,11 @@ +use std::{fs, process, env}; +use std::os::unix::fs::{PermissionsExt, MetadataExt}; +use nix::unistd::{Uid, Gid, Group, User, geteuid, setfsuid, setfsgid, getgroups, setgroups, initgroups, setuid, setgid}; +use std::path::{Path, PathBuf}; +//use libc::{getlogin, setreuid, setuid, setgid}; +use std::ffi::{CStr, CString}; +use std::os::unix::process::CommandExt; + pub(super) fn get_file_names

(dir: P) -> std::io::Result> where P: AsRef, @@ -26,7 +34,7 @@ pub(super) fn parse_yaml(yaml_path: &std::path::Path) -> std::io::Result(file_path: P) -> std::io::Result +pub fn get_mtime

(file_path: P) -> std::io::Result where P: AsRef, { @@ -40,3 +48,207 @@ where )) } } + +pub fn str_duration(t: std::time::Duration) -> String { + use std::time::Duration; + if t.is_zero() { + "Just Now".to_string() + } else if t < Duration::from_secs(60 * 59) { + let min = t.as_secs() / 60; + format!("{}m", min + 1) + } else if t < Duration::from_secs(3600 * 24) { + let hou = t.as_secs() / 3600; + format!("{hou}h") + } else { + let day = t.as_secs() / (3600 * 24); + format!("{day}d") + } +} + +pub fn modify_file_perms_in_dir(dir: &PathBuf, group_name: &str) { + let files_not_in_group = find_files_not_in_group(dir, group_name); + for file in &files_not_in_group { + let path = std::path::Path::new(dir).join(file); + if let Ok(group) = Group::from_name(group_name) { + println!("Changing group to '{}' for {:?}:", group_name, file); + if let Err(e) = nix::unistd::chown(&path, None, group.map(|g| g.gid)) { + eprintln!("Failed to change group to '{}' for '{:?}': {}", group_name, file, e); + } + } + } + + let files_not_group_writable = find_files_not_group_writable(dir); + for file in &files_not_group_writable { + if let Ok(metadata) = fs::metadata(file) { + let permissions = metadata.permissions(); + let mut new_permissions = permissions.clone(); + new_permissions.set_mode(permissions.mode() | 0o0020); + println!("Adding `g+w` permission to '{:?}'", file); + if let Err(e) = fs::set_permissions(file, new_permissions) { + eprintln!("Failed to set `g+w` permissions for '{:?}': {}", file, e); + } + } + } + + // dir add set-group-id: `chmod g+s dir` + if let Ok(metadata) = fs::metadata(dir) { + let permissions = metadata.permissions(); + let mut new_permissions = permissions.clone(); + new_permissions.set_mode(permissions.mode() | 0o2020); + println!("Adding `g+s` permission to '{:?}'", dir); + if let Err(e) = fs::set_permissions(dir, new_permissions) { + eprintln!("Failed to set `g+s` permissions for '{:?}': {}", dir, e); + } + } +} + +// Check dir member and dir itself. +pub fn find_files_not_group_writable(dir: &PathBuf) -> Vec { + let mut result = Vec::new(); + + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries { + if let Ok(entry) = entry { + let path = entry.path(); + let metadata = entry.metadata().unwrap(); + if metadata.is_file() { + let permissions = metadata.permissions(); + + if permissions.mode() & 0o0020 == 0 { + result.push(path.clone()); + } + } + if metadata.is_dir() { + result.extend(find_files_not_group_writable(&path)); + } + } + } + } + + if let Ok(metadata) = fs::metadata(dir) { + let permissions = metadata.permissions(); + if permissions.mode() & 0o0020 == 0 { + result.push(dir.clone()); + } + } + + result +} + +// Check dir member and dir itself. +pub fn find_files_not_in_group(dir: &PathBuf, group_name: &str) -> Vec { + let mut result = Vec::new(); + + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries { + if let Ok(entry) = entry { + let metadata = entry.metadata().unwrap(); + + if metadata.is_file() { + let file_gid = metadata.gid(); + if let Ok(Some(group)) = + Group::from_gid(Gid::from_raw(file_gid)) + { + if group.name != group_name { + result.push(entry.path().clone()); + } + } + } else if metadata.is_dir() { + let sub_dir = entry.path(); + result.extend(find_files_not_in_group(&sub_dir, group_name)); + } + } + } + } + + if let Ok(metadata) = fs::metadata(dir) { + if let Some(dir_group) = + Group::from_gid(Gid::from_raw(metadata.gid())).unwrap() + { + if dir_group.name != group_name { + result.push(dir.clone()); + } + } + } + + result +} + +pub fn get_file_group_name(dir: &PathBuf) -> Option { + if let Ok(metadata) = std::fs::metadata(dir) { + if let Some(dir_group) = + nix::unistd::Group::from_gid(nix::unistd::Gid::from_raw(metadata.gid())).unwrap() + { + return Some(dir_group.name); + } + } + + None +} + +// Perform file operations with clashtui process as a sudo user. +pub fn mock_fileop_as_sudo_user() { + if ! is_run_as_root() { + return; + } + + // sudo printenv: SUDO_USER, SUDO_UID, SUDO_GID, ... + if let (Ok(uid_str), Ok(gid_str)) = (env::var("SUDO_UID"), env::var("SUDO_GID")) { + if let (Ok(uid_num), Ok(gid_num)) = (uid_str.parse::(), gid_str.parse::()) { + // In Linux, file operation permissions are determined using fsuid, fdgid, and auxiliary groups. + + let uid = Uid::from_raw(uid_num); + let gid = Gid::from_raw(gid_num); + setfsuid(uid); + setfsgid(gid); + + // Need to use the group permissions of the auxiliary group mihomo + if let Ok(user_name) = env::var("SUDO_USER") { + let user_name = CString::new(user_name).unwrap(); + let _ = initgroups(&user_name, gid); + } + } + } +} + +pub fn is_run_as_root() -> bool { + return geteuid().is_root(); +} + +pub fn restore_fileop_as_root() { + setfsuid(Uid::from_raw(0)); + setfsgid(Gid::from_raw(0)); +} + +pub fn run_as_root() { + let app_path_binding = env::current_exe() + .expect("Failed to get current executable path"); + let app_path = app_path_binding.to_str() + .expect("Failed to convert path to string"); + + // Skip the param of exe path + let params: Vec = env::args().skip(1).collect(); + + let mut sudo_cmd = vec![app_path]; + + sudo_cmd.extend(params.iter().map(|s| s.as_str())); + + // CLASHTUI_EP: clashtui elevate privileges + env::set_var("CLASHTUI_EP", "true"); // To distinguish when users manually execute `sudo clashtui` + let _ = process::Command::new("sudo") + .args(vec!["--preserve-env=CLASHTUI_EP,XDG_CONFIG_HOME,HOME,USER"]) + //.args(vec!["--preserve-env"]) + .args(&sudo_cmd) + .exec(); +} + +// Is clashtui elevate privileges +pub fn is_clashtui_ep() -> bool { + if let Ok(str) = env::var("CLASHTUI_EP") { + if str == "true" { + return true; + } + } + + false +} diff --git a/clashtui/ui/src/widgets/list.rs b/clashtui/ui/src/widgets/list.rs index a7707a1..6833db3 100644 --- a/clashtui/ui/src/widgets/list.rs +++ b/clashtui/ui/src/widgets/list.rs @@ -55,8 +55,14 @@ impl List { f.render_stateful_widget( if let Some(vc) = self.extra.as_ref() { Raw::List::from_iter(self.items.iter().zip(vc.iter()).map(|(v, e)| { - Raw::ListItem::new(Ra::Line::from(v.to_owned() + "(" + e + ")")) - .style(Ra::Style::default()) + Raw::ListItem::new( + Ra::Line::from(vec![ + Ra::Span::styled(v.to_owned(), Ra::Style::default()), + Ra::Span::styled(" ".to_owned(), Ra::Style::default()), + //Ra::Span::styled(e, Ra::Style::default().fg(Ra::Color::Rgb(192, 192, 192))) + Ra::Span::styled(e, Ra::Style::default().fg(Ra::Color::Red)) + ]) + ) })) } else { Raw::List::from_iter(self.items.iter().map(|i| { diff --git a/clashtui/ui/src/widgets/msg.rs b/clashtui/ui/src/widgets/msg.rs index 72b11d0..3bd1c0a 100644 --- a/clashtui/ui/src/widgets/msg.rs +++ b/clashtui/ui/src/widgets/msg.rs @@ -60,7 +60,7 @@ impl MsgPopup { // 自适应 let max_item_width = text.iter().map(|i| i.width()).max().unwrap_or(0); let dialog_width = max(min(max_item_width + 2, f.size().width as usize - 4), 60); // min_width = 60 - let dialog_height = min(text.len() + 2, f.size().height as usize - 6); + let dialog_height = min(if text.len() == 0 {3} else {text.len() + 2}, f.size().height as usize - 6); let area = tools::centered_lenght_rect(dialog_width as u16, dialog_height as u16, f.size()); let paragraph = if text.len() == 1 && max_item_width < area.width as usize {