diff --git a/Cargo.lock b/Cargo.lock index 750c9dd..b368d8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,17 +491,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "auto-launch" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" -dependencies = [ - "dirs 4.0.0", - "thiserror 1.0.63", - "winreg 0.10.1", -] - [[package]] name = "auto_impl" version = "1.2.1" @@ -2256,7 +2245,6 @@ dependencies = [ "serde_json", "tauri", "tauri-build", - "tauri-plugin-autostart", "tauri-plugin-clipboard-manager", "tauri-plugin-os", "tauri-plugin-positioner", @@ -7703,13 +7691,14 @@ dependencies = [ [[package]] name = "service-manager" version = "0.8.0" -source = "git+https://github.com/chipsenkbeil/service-manager-rs.git?branch=main#0294d3b9769c8ef7db8b4e831fb1c4f14b7d473b" +source = "git+https://github.com/EasyTier/service-manager-rs.git?branch=main#5eb28f7a686858eea4f4933534ed989d3b71dc2a" dependencies = [ "cfg-if", "dirs 4.0.0", "encoding-utils", "encoding_rs", "plist", + "sys-info", "which 4.4.2", "xml-rs", ] @@ -8319,6 +8308,16 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "sys-info" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b3a0d0aba8bf96a0e1ddfdc352fc53b3df7f39318c71854910c3c4b024ae52c" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "sys-locale" version = "0.3.1" @@ -8584,20 +8583,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "tauri-plugin-autostart" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "062cdcd483d5e3148c9a64dabf8c574e239e2aa1193cf208d95cf89a676f87a5" -dependencies = [ - "auto-launch", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.11", -] - [[package]] name = "tauri-plugin-clipboard-manager" version = "2.3.0" @@ -10891,15 +10876,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] - [[package]] name = "winreg" version = "0.50.0" diff --git a/easytier-contrib/easytier-ohrs/Cargo.lock b/easytier-contrib/easytier-ohrs/Cargo.lock index 462f5f4..7182f8c 100644 --- a/easytier-contrib/easytier-ohrs/Cargo.lock +++ b/easytier-contrib/easytier-ohrs/Cargo.lock @@ -3882,13 +3882,14 @@ dependencies = [ [[package]] name = "service-manager" version = "0.8.0" -source = "git+https://github.com/chipsenkbeil/service-manager-rs.git?branch=main#0294d3b9769c8ef7db8b4e831fb1c4f14b7d473b" +source = "git+https://github.com/EasyTier/service-manager-rs.git?branch=main#5eb28f7a686858eea4f4933534ed989d3b71dc2a" dependencies = [ "cfg-if", "dirs", "encoding-utils", "encoding_rs", "plist", + "sys-info", "which 4.4.2", "xml-rs", ] @@ -4071,6 +4072,16 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "sys-info" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b3a0d0aba8bf96a0e1ddfdc352fc53b3df7f39318c71854910c3c4b024ae52c" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "sys-locale" version = "0.3.2" diff --git a/easytier-gui/src-tauri/Cargo.toml b/easytier-gui/src-tauri/Cargo.toml index 65d458c..4e731bb 100644 --- a/easytier-gui/src-tauri/Cargo.toml +++ b/easytier-gui/src-tauri/Cargo.toml @@ -50,7 +50,7 @@ tauri-plugin-clipboard-manager = "2.3.0" tauri-plugin-positioner = { version = "2.3.0", features = ["tray-icon"] } tauri-plugin-vpnservice = { path = "../../tauri-plugin-vpnservice" } tauri-plugin-os = "2.3.0" -tauri-plugin-autostart = "2.5.0" + uuid = "1.17.0" async-trait = "0.1.89" diff --git a/easytier-gui/src-tauri/capabilities/migrated.json b/easytier-gui/src-tauri/capabilities/migrated.json index 7a29e5c..5c499eb 100644 --- a/easytier-gui/src-tauri/capabilities/migrated.json +++ b/easytier-gui/src-tauri/capabilities/migrated.json @@ -45,10 +45,6 @@ "os:allow-arch", "os:allow-hostname", "os:allow-platform", - "os:allow-locale", - "autostart:default", - "autostart:allow-disable", - "autostart:allow-enable", - "autostart:allow-is-enabled" + "os:allow-locale" ] } \ No newline at end of file diff --git a/easytier-gui/src-tauri/src/lib.rs b/easytier-gui/src-tauri/src/lib.rs index 6570c61..420596c 100644 --- a/easytier-gui/src-tauri/src/lib.rs +++ b/easytier-gui/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod elevate; +use anyhow::Context; use easytier::proto::api::manage::{ CollectNetworkInfoResponse, ValidateConfigResponse, WebClientService, WebClientServiceClientFactory, @@ -17,10 +18,11 @@ use easytier::{ launcher::NetworkConfig, rpc_service::ApiRpcServer, tunnel::ring::RingTunnelListener, - utils::{self, NewFilterSender}, + utils::{self}, }; use std::ops::Deref; use std::sync::Arc; +use tokio::sync::{RwLock, RwLockReadGuard}; use uuid::Uuid; use tauri::{AppHandle, Emitter, Manager as _}; @@ -28,19 +30,27 @@ use tauri::{AppHandle, Emitter, Manager as _}; #[cfg(not(target_os = "android"))] use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; -pub const AUTOSTART_ARG: &str = "--autostart"; - -static INSTANCE_MANAGER: once_cell::sync::Lazy> = - once_cell::sync::Lazy::new(|| Arc::new(NetworkInstanceManager::new())); - -static mut LOGGER_LEVEL_SENDER: once_cell::sync::Lazy> = - once_cell::sync::Lazy::new(Default::default); +static INSTANCE_MANAGER: once_cell::sync::Lazy>>> = + once_cell::sync::Lazy::new(|| RwLock::new(None)); static RPC_RING_UUID: once_cell::sync::Lazy = once_cell::sync::Lazy::new(uuid::Uuid::new_v4); -static CLIENT_MANAGER: once_cell::sync::OnceCell = - once_cell::sync::OnceCell::new(); +static CLIENT_MANAGER: once_cell::sync::Lazy>> = + once_cell::sync::Lazy::new(|| RwLock::new(None)); + +static RING_RPC_SERVER: once_cell::sync::Lazy>>> = + once_cell::sync::Lazy::new(|| RwLock::new(None)); + +macro_rules! get_client_manager { + () => {{ + let guard = CLIENT_MANAGER + .try_read() + .map_err(|_| "Failed to acquire read lock for client manager")?; + RwLockReadGuard::try_map(guard, |cm| cm.as_ref()) + .map_err(|_| "RPC connection not initialized".to_string()) + }}; +} #[tauri::command] fn easytier_version() -> Result { @@ -88,19 +98,17 @@ async fn run_network_instance( app.emit("pre_run_network_instance", cfg.instance_id()) .map_err(|e| e.to_string())?; + let client_manager = get_client_manager!()?; + #[cfg(target_os = "android")] if cfg.no_tun() == false { - CLIENT_MANAGER - .get() - .unwrap() + client_manager .disable_instances_with_tun(&app) .await .map_err(|e| e.to_string())?; } - CLIENT_MANAGER - .get() - .unwrap() + client_manager .handle_run_network_instance(app.clone(), cfg, save) .await .map_err(|e| e.to_string())?; @@ -118,31 +126,32 @@ async fn collect_network_info( let instance_id = instance_id .parse() .map_err(|e: uuid::Error| e.to_string())?; - CLIENT_MANAGER - .get() - .unwrap() + get_client_manager!()? .handle_collect_network_info(app, Some(vec![instance_id])) .await .map_err(|e| e.to_string()) } #[tauri::command] -fn set_logging_level(level: String) -> Result<(), String> { - #[allow(static_mut_refs)] - let sender = unsafe { LOGGER_LEVEL_SENDER.as_ref().unwrap() }; - sender.send(level).map_err(|e| e.to_string())?; +async fn set_logging_level(level: String) -> Result<(), String> { + println!("Setting logging level to: {}", level); + get_client_manager!()? + .set_logging_level(level.clone()) + .await + .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] -fn set_tun_fd(fd: i32) -> Result<(), String> { - if let Some(uuid) = CLIENT_MANAGER - .get() - .unwrap() +async fn set_tun_fd(fd: i32) -> Result<(), String> { + let Some(instance_manager) = INSTANCE_MANAGER.read().await.clone() else { + return Err("set_tun_fd is not supported in remote mode".to_string()); + }; + if let Some(uuid) = get_client_manager!()? .get_enabled_instances_with_tun_ids() .next() { - INSTANCE_MANAGER + instance_manager .set_tun_fd(&uuid, fd) .map_err(|e| e.to_string())?; } @@ -153,9 +162,7 @@ fn set_tun_fd(fd: i32) -> Result<(), String> { async fn list_network_instance_ids( app: AppHandle, ) -> Result { - CLIENT_MANAGER - .get() - .unwrap() + get_client_manager!()? .handle_list_network_instance_ids(app) .await .map_err(|e| e.to_string()) @@ -166,16 +173,12 @@ async fn remove_network_instance(app: AppHandle, instance_id: String) -> Result< let instance_id = instance_id .parse() .map_err(|e: uuid::Error| e.to_string())?; - CLIENT_MANAGER - .get() - .unwrap() + let client_manager = get_client_manager!()?; + client_manager .handle_remove_network_instances(app.clone(), vec![instance_id]) .await .map_err(|e| e.to_string())?; - CLIENT_MANAGER - .get() - .unwrap() - .notify_vpn_stop_if_no_tun(&app)?; + client_manager.notify_vpn_stop_if_no_tun(&app)?; Ok(()) } @@ -188,17 +191,13 @@ async fn update_network_config_state( let instance_id = instance_id .parse() .map_err(|e: uuid::Error| e.to_string())?; - CLIENT_MANAGER - .get() - .unwrap() + let client_manager = get_client_manager!()?; + client_manager .handle_update_network_state(app.clone(), instance_id, disabled) .await .map_err(|e| e.to_string())?; if disabled { - CLIENT_MANAGER - .get() - .unwrap() - .notify_vpn_stop_if_no_tun(&app)?; + client_manager.notify_vpn_stop_if_no_tun(&app)?; } Ok(()) } @@ -209,9 +208,7 @@ async fn save_network_config(app: AppHandle, cfg: NetworkConfig) -> Result<(), S .instance_id() .parse() .map_err(|e: uuid::Error| e.to_string())?; - CLIENT_MANAGER - .get() - .unwrap() + get_client_manager!()? .handle_save_network_config(app, instance_id, cfg) .await .map_err(|e| e.to_string()) @@ -222,9 +219,7 @@ async fn validate_config( app: AppHandle, config: NetworkConfig, ) -> Result { - CLIENT_MANAGER - .get() - .unwrap() + get_client_manager!()? .handle_validate_config(app, config) .await .map_err(|e| e.to_string()) @@ -232,9 +227,7 @@ async fn validate_config( #[tauri::command] async fn get_config(app: AppHandle, instance_id: String) -> Result { - let cfg = CLIENT_MANAGER - .get() - .unwrap() + let cfg = get_client_manager!()? .storage .get_network_config(app, &instance_id) .await @@ -249,10 +242,7 @@ async fn load_configs( configs: Vec, enabled_networks: Vec, ) -> Result<(), String> { - CLIENT_MANAGER - .get() - .unwrap() - .storage + get_client_manager!()? .load_configs(app, configs, enabled_networks) .await .map_err(|e| e.to_string())?; @@ -264,14 +254,143 @@ async fn get_network_metas( app: AppHandle, instance_ids: Vec, ) -> Result { - CLIENT_MANAGER - .get() - .unwrap() + get_client_manager!()? .handle_get_network_metas(app, instance_ids) .await .map_err(|e| e.to_string()) } +#[cfg(target_os = "android")] +#[tauri::command] +fn init_service() -> Result<(), String> { + Ok(()) +} + +#[cfg(not(target_os = "android"))] +#[tauri::command] +fn init_service(opts: Option) -> Result<(), String> { + match opts { + Some(args) => { + let path = std::path::Path::new(&args.config_dir); + if !path.exists() { + std::fs::create_dir_all(&args.config_dir).map_err(|e| e.to_string())?; + } else if !path.is_dir() { + return Err("config_dir exists but is not a directory".to_string()); + } + let path = std::path::Path::new(&args.file_log_dir); + if !path.exists() { + std::fs::create_dir_all(&args.file_log_dir).map_err(|e| e.to_string())?; + } else if !path.is_dir() { + return Err("file_log_dir exists but is not a directory".to_string()); + } + + service::install(args).map_err(|e| format!("{:#}", e))?; + } + None => { + service::uninstall().map_err(|e| format!("{:#}", e))?; + } + } + Ok(()) +} + +#[tauri::command] +fn set_service_status(_enable: bool) -> Result<(), String> { + #[cfg(not(target_os = "android"))] + { + service::set_status(_enable).map_err(|e| format!("{:#}", e))?; + } + Ok(()) +} + +#[tauri::command] +fn get_service_status() -> Result<&'static str, String> { + #[cfg(not(target_os = "android"))] + { + use easytier::service_manager::ServiceStatus; + let status = service::status().map_err(|e| format!("{:#}", e))?; + match status { + ServiceStatus::NotInstalled => Ok("NotInstalled"), + ServiceStatus::Stopped(_) => Ok("Stopped"), + ServiceStatus::Running => Ok("Running"), + } + } + #[cfg(target_os = "android")] + { + Ok("NotInstalled") + } +} + +#[tauri::command] +async fn init_rpc_connection(_app: AppHandle, url: Option) -> Result<(), String> { + let mut client_manager_guard = + tokio::time::timeout(std::time::Duration::from_secs(5), CLIENT_MANAGER.write()) + .await + .map_err(|_| "Failed to acquire write lock for client manager")?; + let mut instance_manager_guard = INSTANCE_MANAGER + .try_write() + .map_err(|_| "Failed to acquire write lock for instance manager")?; + let mut ring_rpc_server_guard = RING_RPC_SERVER + .try_write() + .map_err(|_| "Failed to acquire write lock for ring rpc server")?; + + let normal_mode = url.is_none(); + if normal_mode { + let instance_manager = if let Some(im) = instance_manager_guard.take() { + im + } else { + Arc::new(NetworkInstanceManager::new()) + }; + let rpc_server = if let Some(rpc_server) = ring_rpc_server_guard.take() { + rpc_server + } else { + ApiRpcServer::from_tunnel( + RingTunnelListener::new( + format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap(), + ), + instance_manager.clone(), + ) + .with_rx_timeout(None) + .serve() + .await + .map_err(|e| e.to_string())? + }; + + *instance_manager_guard = Some(instance_manager); + *ring_rpc_server_guard = Some(rpc_server); + } else { + *ring_rpc_server_guard = None; + } + + let mut client_manager = tokio::time::timeout( + std::time::Duration::from_millis(1000), + manager::GUIClientManager::new(url), + ) + .await + .map_err(|_| "connect remote rpc timed out".to_string())? + .with_context(|| "Failed to connect remote rpc") + .map_err(|e| format!("{:#}", e))?; + if let Some(old_manager) = client_manager_guard.take() { + client_manager.storage = old_manager.storage; + } + *client_manager_guard = Some(client_manager); + + if !normal_mode { + if let Some(instance_manager) = instance_manager_guard.take() { + instance_manager + .retain_network_instance(vec![]) + .map_err(|e| e.to_string())?; + drop(instance_manager); + } + } + + Ok(()) +} + +#[tauri::command] +async fn is_client_running() -> Result { + Ok(get_client_manager!()?.rpc_manager.is_running()) +} + #[cfg(not(target_os = "android"))] fn toggle_window_visibility(app: &tauri::AppHandle) { if let Some(window) = app.get_webview_window("main") { @@ -289,19 +408,23 @@ fn toggle_window_visibility(app: &tauri::AppHandle) { } } +fn get_exe_path() -> String { + if let Ok(appimage_path) = std::env::var("APPIMAGE") { + if !appimage_path.is_empty() { + return appimage_path; + } + } + std::env::current_exe() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default() +} + #[cfg(not(target_os = "android"))] fn check_sudo() -> bool { let is_elevated = elevate::Command::is_elevated(); if !is_elevated { - let exe_path = std::env::var("APPIMAGE") - .ok() - .or_else(|| std::env::args().next()) - .unwrap_or_default(); - let args: Vec = std::env::args().collect(); - let mut stdcmd = std::process::Command::new(&exe_path); - if args.contains(&AUTOSTART_ARG.to_owned()) { - stdcmd.arg(AUTOSTART_ARG); - } + let exe_path = get_exe_path(); + let stdcmd = std::process::Command::new(&exe_path); elevate::Command::new(stdcmd) .output() .expect("Failed to run elevated command"); @@ -313,10 +436,15 @@ mod manager { use super::*; use async_trait::async_trait; use dashmap::{DashMap, DashSet}; + use easytier::common::global_ctx::GlobalCtx; + use easytier::common::stun::MockStunInfoCollector; use easytier::launcher::NetworkConfig; - use easytier::proto::api::manage::RunNetworkInstanceRequest; + use easytier::proto::api::logger::{LoggerRpc, LoggerRpcClientFactory, SetLoggerConfigRequest}; + use easytier::proto::api::manage::{ListNetworkInstanceRequest, RunNetworkInstanceRequest}; + use easytier::proto::common::NatType; use easytier::proto::rpc_impl::bidirect::BidirectRpcManager; use easytier::proto::rpc_types::controller::BaseController; + use easytier::rpc_service::logger::LoggerRpcService; use easytier::rpc_service::remote_client::PersistentConfig; use easytier::tunnel::ring::RingTunnelConnector; use easytier::tunnel::TunnelConnector; @@ -344,54 +472,6 @@ mod manager { } } - pub(super) async fn load_configs( - &self, - app: AppHandle, - configs: Vec, - enabled_networks: Vec, - ) -> anyhow::Result<()> { - self.network_configs.clear(); - for cfg in configs { - let instance_id = cfg.instance_id(); - self.network_configs.insert( - instance_id.parse()?, - GUIConfig(instance_id.to_string(), cfg), - ); - } - - self.enabled_networks.clear(); - INSTANCE_MANAGER.iter().for_each(|v| { - self.enabled_networks.insert(*v.key()); - }); - for id in enabled_networks { - if let Ok(uuid) = id.parse() { - if !self.enabled_networks.contains(&uuid) { - let config = self - .network_configs - .get(&uuid) - .map(|i| i.value().1.clone()) - .ok_or_else(|| anyhow::anyhow!("Config not found"))?; - CLIENT_MANAGER - .get() - .unwrap() - .get_rpc_client(app.clone()) - .ok_or_else(|| anyhow::anyhow!("RPC client not found"))? - .run_network_instance( - BaseController::default(), - RunNetworkInstanceRequest { - inst_id: None, - config: Some(config), - overwrite: false, - }, - ) - .await?; - self.enabled_networks.insert(uuid); - } - } - } - Ok(()) - } - fn save_configs(&self, app: &AppHandle) -> anyhow::Result<()> { let configs: Result, _> = self .network_configs @@ -507,14 +587,32 @@ mod manager { pub(super) struct GUIClientManager { pub(super) storage: GUIStorage, - rpc_manager: BidirectRpcManager, + pub(super) rpc_manager: BidirectRpcManager, } impl GUIClientManager { - pub async fn new() -> Result { - let mut connector = RingTunnelConnector::new( - format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap(), - ); - let tunnel = connector.connect().await?; + pub async fn new(rpc_url: Option) -> Result { + let global_ctx = Arc::new(GlobalCtx::new(TomlConfigLoader::default())); + global_ctx.replace_stun_info_collector(Box::new(MockStunInfoCollector { + udp_nat_type: NatType::Unknown, + })); + let mut flags = global_ctx.get_flags(); + flags.bind_device = false; + global_ctx.set_flags(flags); + let tunnel = if let Some(url) = rpc_url { + let mut connector = easytier::connector::create_connector_by_url( + &url, + &global_ctx, + easytier::tunnel::IpVersion::Both, + ) + .await?; + connector.connect().await? + } else { + let mut connector = RingTunnelConnector::new( + format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap(), + ); + connector.connect().await? + }; + let rpc_manager = BidirectRpcManager::new(); rpc_manager.run_with_tunnel(tunnel); @@ -554,6 +652,84 @@ mod manager { } Ok(()) } + + fn get_logger_rpc_client( + &self, + ) -> Option + Send>> { + Some( + self.rpc_manager + .rpc_client() + .scoped_client::>(1, 1, "".to_string()), + ) + } + + pub(super) async fn set_logging_level(&self, level: String) -> Result<(), anyhow::Error> { + let logger_rpc = self + .get_logger_rpc_client() + .ok_or_else(|| anyhow::anyhow!("Logger RPC client not available"))?; + logger_rpc + .set_logger_config( + BaseController::default(), + SetLoggerConfigRequest { + level: LoggerRpcService::string_to_log_level(&level).into(), + }, + ) + .await?; + Ok(()) + } + + pub(super) async fn load_configs( + &self, + app: AppHandle, + configs: Vec, + enabled_networks: Vec, + ) -> anyhow::Result<()> { + self.storage.network_configs.clear(); + for cfg in configs { + let instance_id = cfg.instance_id(); + self.storage.network_configs.insert( + instance_id.parse()?, + GUIConfig(instance_id.to_string(), cfg), + ); + } + + self.storage.enabled_networks.clear(); + let client = self + .get_rpc_client(app.clone()) + .ok_or_else(|| anyhow::anyhow!("RPC client not found"))?; + let running_instances = client + .list_network_instance(BaseController::default(), ListNetworkInstanceRequest {}) + .await?; + for id in running_instances.inst_ids { + self.storage.enabled_networks.insert(id.into()); + } + for id in enabled_networks { + if let Ok(uuid) = id.parse() { + if !self.storage.enabled_networks.contains(&uuid) { + let config = self + .storage + .network_configs + .get(&uuid) + .map(|i| i.value().1.clone()); + if config.is_none() { + continue; + } + client + .run_network_instance( + BaseController::default(), + RunNetworkInstanceRequest { + inst_id: None, + config, + overwrite: false, + }, + ) + .await?; + self.storage.enabled_networks.insert(uuid); + } + } + } + Ok(()) + } } impl RemoteClientManager for GUIClientManager { fn get_rpc_client( @@ -577,8 +753,78 @@ mod manager { } } +#[cfg(not(target_os = "android"))] +mod service { + use anyhow::Context; + + #[derive(Clone, serde::Serialize, serde::Deserialize)] + pub struct ServiceOptions { + pub(super) config_dir: String, + pub(super) rpc_portal: String, + pub(super) file_log_level: String, + pub(super) file_log_dir: String, + } + impl ServiceOptions { + fn to_args_vec(&self) -> Vec { + vec![ + "--config-dir".into(), + self.config_dir.clone().into(), + "--rpc-portal".into(), + self.rpc_portal.clone().into(), + "--file-log-level".into(), + self.file_log_level.clone().into(), + "--file-log-dir".into(), + self.file_log_dir.clone().into(), + "--daemon".into(), + ] + } + } + + pub fn install(opts: ServiceOptions) -> anyhow::Result<()> { + let service = easytier::service_manager::Service::new(env!("CARGO_PKG_NAME").to_string())?; + let options = easytier::service_manager::ServiceInstallOptions { + program: super::get_exe_path().into(), + args: opts.to_args_vec(), + work_directory: std::env::current_dir()?, + disable_autostart: false, + description: Some("EasyTier Gui Service".to_string()), + display_name: Some("EasyTier Gui Service".to_string()), + disable_restart_on_failure: false, + }; + service + .install(&options) + .with_context(|| "Failed to install service")?; + Ok(()) + } + + pub fn uninstall() -> anyhow::Result<()> { + let service = easytier::service_manager::Service::new(env!("CARGO_PKG_NAME").to_string())?; + service.uninstall()?; + Ok(()) + } + + pub fn set_status(enable: bool) -> anyhow::Result<()> { + use easytier::service_manager::*; + let service = Service::new(env!("CARGO_PKG_NAME").to_string())?; + let status = service.status()?; + if enable && status != ServiceStatus::Running { + service.start().with_context(|| "Failed to start service")?; + } else if !enable && status == ServiceStatus::Running { + service.stop().with_context(|| "Failed to stop service")?; + } else if status == ServiceStatus::NotInstalled { + return Err(anyhow::anyhow!("Service not installed")); + } + Ok(()) + } + + pub fn status() -> anyhow::Result { + let service = easytier::service_manager::Service::new(env!("CARGO_PKG_NAME").to_string())?; + service.status() + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { +pub fn run_gui() -> std::process::ExitCode { #[cfg(not(target_os = "android"))] if !check_sudo() { use std::process; @@ -587,35 +833,8 @@ pub fn run() { utils::setup_panic_handler(); - let _rpc_server_handle = tauri::async_runtime::spawn(async move { - let rpc_server = ApiRpcServer::from_tunnel( - RingTunnelListener::new(format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap()), - INSTANCE_MANAGER.clone(), - ) - .serve() - .await - .expect("Failed to start RPC server"); - - let _ = CLIENT_MANAGER.set( - manager::GUIClientManager::new() - .await - .expect("Failed to create GUI client manager"), - ); - - rpc_server - }); - let mut builder = tauri::Builder::default(); - #[cfg(not(target_os = "android"))] - { - use tauri_plugin_autostart::MacosLauncher; - builder = builder.plugin(tauri_plugin_autostart::init( - MacosLauncher::LaunchAgent, - Some(vec![AUTOSTART_ARG]), - )); - } - #[cfg(not(any(target_os = "android", target_os = "ios")))] { builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { @@ -651,13 +870,9 @@ pub fn run() { }) .build() .map_err(|e| e.to_string())?; - let Ok(Some(logger_reinit)) = utils::init_logger(&config, true) else { + let Ok(_) = utils::init_logger(&config, true) else { return Ok(()); }; - #[allow(static_mut_refs)] - unsafe { - LOGGER_LEVEL_SENDER.replace(logger_reinit) - }; // for tray icon, menu need to be built in js #[cfg(not(target_os = "android"))] @@ -699,6 +914,11 @@ pub fn run() { get_config, load_configs, get_network_metas, + init_service, + set_service_status, + get_service_status, + init_rpc_connection, + is_client_running, ]) .on_window_event(|_win, event| match event { #[cfg(not(target_os = "android"))] @@ -724,4 +944,14 @@ pub fn run() { _ => {} }); } + + std::process::ExitCode::SUCCESS +} + +pub fn run_cli() -> std::process::ExitCode { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { easytier::core::main().await }) } diff --git a/easytier-gui/src-tauri/src/main.rs b/easytier-gui/src-tauri/src/main.rs index c672c6a..05049eb 100644 --- a/easytier-gui/src-tauri/src/main.rs +++ b/easytier-gui/src-tauri/src/main.rs @@ -1,5 +1,9 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -fn main() { - app_lib::run(); +fn main() -> std::process::ExitCode { + if std::env::args().count() > 1 { + app_lib::run_cli() + } else { + app_lib::run_gui() + } } diff --git a/easytier-gui/src/auto-imports.d.ts b/easytier-gui/src/auto-imports.d.ts index 21da4e5..4f34ad8 100644 --- a/easytier-gui/src/auto-imports.d.ts +++ b/easytier-gui/src/auto-imports.d.ts @@ -28,15 +28,20 @@ declare global { const getCurrentScope: typeof import('vue')['getCurrentScope'] const getEasytierVersion: typeof import('./composables/backend')['getEasytierVersion'] const getNetworkMetas: typeof import('./composables/backend')['getNetworkMetas'] + const getServiceStatus: typeof import('./composables/backend')['getServiceStatus'] const h: typeof import('vue')['h'] const initMobileVpnService: typeof import('./composables/mobile_vpn')['initMobileVpnService'] + const initRpcConnection: typeof import('./composables/backend')['initRpcConnection'] + const initService: typeof import('./composables/backend')['initService'] const inject: typeof import('vue')['inject'] + const isClientRunning: typeof import('./composables/backend')['isClientRunning'] const isProxy: typeof import('vue')['isProxy'] const isReactive: typeof import('vue')['isReactive'] const isReadonly: typeof import('vue')['isReadonly'] const isRef: typeof import('vue')['isRef'] const listNetworkInstanceIds: typeof import('./composables/backend')['listNetworkInstanceIds'] const listenGlobalEvents: typeof import('./composables/event')['listenGlobalEvents'] + const loadMode: typeof import('./composables/mode')['loadMode'] const mapActions: typeof import('pinia')['mapActions'] const mapGetters: typeof import('pinia')['mapGetters'] const mapState: typeof import('pinia')['mapState'] @@ -69,11 +74,13 @@ declare global { const ref: typeof import('vue')['ref'] const resolveComponent: typeof import('vue')['resolveComponent'] const runNetworkInstance: typeof import('./composables/backend')['runNetworkInstance'] + const saveMode: typeof import('./composables/mode')['saveMode'] const saveNetworkConfig: typeof import('./composables/backend')['saveNetworkConfig'] const sendConfigs: typeof import('./composables/backend')['sendConfigs'] const setActivePinia: typeof import('pinia')['setActivePinia'] const setLoggingLevel: typeof import('./composables/backend')['setLoggingLevel'] const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] + const setServiceStatus: typeof import('./composables/backend')['setServiceStatus'] const setTrayMenu: typeof import('./composables/tray')['setTrayMenu'] const setTrayRunState: typeof import('./composables/tray')['setTrayRunState'] const setTrayTooltip: typeof import('./composables/tray')['setTrayTooltip'] @@ -141,15 +148,20 @@ declare module 'vue' { readonly getCurrentScope: UnwrapRef readonly getEasytierVersion: UnwrapRef readonly getNetworkMetas: UnwrapRef + readonly getServiceStatus: UnwrapRef readonly h: UnwrapRef readonly initMobileVpnService: UnwrapRef + readonly initRpcConnection: UnwrapRef + readonly initService: UnwrapRef readonly inject: UnwrapRef + readonly isClientRunning: UnwrapRef readonly isProxy: UnwrapRef readonly isReactive: UnwrapRef readonly isReadonly: UnwrapRef readonly isRef: UnwrapRef readonly listNetworkInstanceIds: UnwrapRef readonly listenGlobalEvents: UnwrapRef + readonly loadMode: UnwrapRef readonly mapActions: UnwrapRef readonly mapGetters: UnwrapRef readonly mapState: UnwrapRef @@ -182,11 +194,13 @@ declare module 'vue' { readonly ref: UnwrapRef readonly resolveComponent: UnwrapRef readonly runNetworkInstance: UnwrapRef + readonly saveMode: UnwrapRef readonly saveNetworkConfig: UnwrapRef readonly sendConfigs: UnwrapRef readonly setActivePinia: UnwrapRef readonly setLoggingLevel: UnwrapRef readonly setMapStoreSuffix: UnwrapRef + readonly setServiceStatus: UnwrapRef readonly setTrayMenu: UnwrapRef readonly setTrayRunState: UnwrapRef readonly setTrayTooltip: UnwrapRef diff --git a/easytier-gui/src/components/ModeSwitcher.vue b/easytier-gui/src/components/ModeSwitcher.vue new file mode 100644 index 0000000..67f7326 --- /dev/null +++ b/easytier-gui/src/components/ModeSwitcher.vue @@ -0,0 +1,155 @@ + + + diff --git a/easytier-gui/src/composables/backend.ts b/easytier-gui/src/composables/backend.ts index 5491ace..c79a90e 100644 --- a/easytier-gui/src/composables/backend.ts +++ b/easytier-gui/src/composables/backend.ts @@ -1,11 +1,19 @@ import { invoke } from '@tauri-apps/api/core' import { Api, type NetworkTypes } from 'easytier-frontend-lib' import { GetNetworkMetasResponse } from 'node_modules/easytier-frontend-lib/dist/modules/api' -import { getAutoLaunchStatusAsync } from '~/modules/auto_launch' + type NetworkConfig = NetworkTypes.NetworkConfig type ValidateConfigResponse = Api.ValidateConfigResponse type ListNetworkInstanceIdResponse = Api.ListNetworkInstanceIdResponse +interface ServiceOptions { + config_dir: string + rpc_portal: string + file_log_level: string + file_log_dir: string +} + +export type ServiceStatus = "Running" | "Stopped" | "NotInstalled" export async function parseNetworkConfig(cfg: NetworkConfig) { return invoke('parse_network_config', { cfg }) @@ -61,10 +69,29 @@ export async function getConfig(instanceId: string) { export async function sendConfigs() { let networkList: NetworkConfig[] = JSON.parse(localStorage.getItem('networkList') || '[]'); - let autoStartInstIds = getAutoLaunchStatusAsync() ? JSON.parse(localStorage.getItem('autoStartInstIds') || '[]') : [] - return await invoke('load_configs', { configs: networkList, enabledNetworks: autoStartInstIds }) + return await invoke('load_configs', { configs: networkList, enabledNetworks: [] }) } export async function getNetworkMetas(instanceIds: string[]) { return await invoke('get_network_metas', { instanceIds }) } + +export async function initService(opts?: ServiceOptions) { + return await invoke('init_service', { opts }) +} + +export async function setServiceStatus(enable: boolean) { + return await invoke('set_service_status', { enable }) +} + +export async function getServiceStatus() { + return await invoke('get_service_status') +} + +export async function initRpcConnection(url?: string) { + return await invoke('init_rpc_connection', { url }) +} + +export async function isClientRunning() { + return await invoke('is_client_running') +} diff --git a/easytier-gui/src/composables/event.ts b/easytier-gui/src/composables/event.ts index 4c9c261..9cf0ca4 100644 --- a/easytier-gui/src/composables/event.ts +++ b/easytier-gui/src/composables/event.ts @@ -4,7 +4,6 @@ import { NetworkTypes } from "easytier-frontend-lib" const EVENTS = Object.freeze({ SAVE_CONFIGS: 'save_configs', - SAVE_ENABLED_NETWORKS: 'save_enabled_networks', PRE_RUN_NETWORK_INSTANCE: 'pre_run_network_instance', POST_RUN_NETWORK_INSTANCE: 'post_run_network_instance', VPN_SERVICE_STOP: 'vpn_service_stop', @@ -15,11 +14,6 @@ function onSaveConfigs(event: Event) { localStorage.setItem('networkList', JSON.stringify(event.payload)); } -function onSaveEnabledNetworks(event: Event) { - console.log(`Received event '${EVENTS.SAVE_ENABLED_NETWORKS}': ${event.payload}`); - localStorage.setItem('autoStartInstIds', JSON.stringify(event.payload)); -} - async function onPreRunNetworkInstance(event: Event) { if (type() === 'android') { await prepareVpnService(event.payload); @@ -39,7 +33,6 @@ async function onVpnServiceStop(event: Event) { export async function listenGlobalEvents() { const unlisteners = [ await listen(EVENTS.SAVE_CONFIGS, onSaveConfigs), - await listen(EVENTS.SAVE_ENABLED_NETWORKS, onSaveEnabledNetworks), await listen(EVENTS.PRE_RUN_NETWORK_INSTANCE, onPreRunNetworkInstance), await listen(EVENTS.POST_RUN_NETWORK_INSTANCE, onPostRunNetworkInstance), await listen(EVENTS.VPN_SERVICE_STOP, onVpnServiceStop), diff --git a/easytier-gui/src/composables/mode.ts b/easytier-gui/src/composables/mode.ts new file mode 100644 index 0000000..52e50c4 --- /dev/null +++ b/easytier-gui/src/composables/mode.ts @@ -0,0 +1,36 @@ +interface NormalMode { + mode: 'normal' +} + +export interface ServiceMode { + mode: 'service' + config_dir: string + rpc_portal: string + file_log_level: 'off' | 'warn' | 'info' | 'debug' | 'trace' + file_log_dir: string +} + +export interface RemoteMode { + mode: 'remote' + remote_rpc_address: string +} + +export function saveMode(mode: Mode) { + localStorage.setItem('app_mode', JSON.stringify(mode)) +} + +import { type } from '@tauri-apps/plugin-os'; + +export function loadMode(): Mode { + if (type() === 'android') { + return { mode: 'normal' }; + } + const modeStr = localStorage.getItem('app_mode') + if (modeStr) { + return JSON.parse(modeStr) as Mode + } else { + return { mode: 'normal' } + } +} + +export type Mode = NormalMode | ServiceMode | RemoteMode diff --git a/easytier-gui/src/main.ts b/easytier-gui/src/main.ts index 325f726..4481a6a 100644 --- a/easytier-gui/src/main.ts +++ b/easytier-gui/src/main.ts @@ -9,7 +9,7 @@ import App from '~/App.vue'; import 'easytier-frontend-lib/style.css'; import { ConfirmationService, DialogService, ToastService } from 'primevue'; import '~/styles.css'; -import { getAutoLaunchStatusAsync, loadAutoLaunchStatusAsync } from './modules/auto_launch'; + if (import.meta.env.PROD) { document.addEventListener('keydown', (event) => { @@ -29,7 +29,6 @@ if (import.meta.env.PROD) { async function main() { await I18nUtils.loadLanguageAsync(localStorage.getItem('lang') || 'en') - await loadAutoLaunchStatusAsync(getAutoLaunchStatusAsync()) const app = createApp(App) diff --git a/easytier-gui/src/modules/auto_launch.ts b/easytier-gui/src/modules/auto_launch.ts deleted file mode 100644 index 81ae0a9..0000000 --- a/easytier-gui/src/modules/auto_launch.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart' - -export async function loadAutoLaunchStatusAsync(target_enable: boolean): Promise { - try { - if (target_enable) { - await enable() - } - else { - // 消除没有配置自启动时进行关闭操作报错 - try { - await disable() - } - catch { } - } - localStorage.setItem('auto_launch', JSON.stringify(await isEnabled())) - return isEnabled() - } - catch (e) { - console.error(e) - return false - } -} - -export function getAutoLaunchStatusAsync(): boolean { - return localStorage.getItem('auto_launch') === 'true' -} diff --git a/easytier-gui/src/pages/index.vue b/easytier-gui/src/pages/index.vue index 3bf8f13..742e11d 100644 --- a/easytier-gui/src/pages/index.vue +++ b/easytier-gui/src/pages/index.vue @@ -9,16 +9,193 @@ import { I18nUtils, RemoteManagement } from "easytier-frontend-lib" import type { MenuItem } from 'primevue/menuitem' import { useTray } from '~/composables/tray' import { GUIRemoteClient } from '~/modules/api' -import { getAutoLaunchStatusAsync as getAutoLaunchStatus, loadAutoLaunchStatusAsync } from '~/modules/auto_launch' + import { getDockVisibilityStatus, loadDockVisibilityAsync } from '~/modules/dock_visibility' +import { useToast, useConfirm } from 'primevue' +import { loadMode, saveMode, type Mode } from '~/composables/mode' +import ModeSwitcher from '~/components/ModeSwitcher.vue' +import { getServiceStatus, type ServiceStatus } from '~/composables/backend' const { t, locale } = useI18n() +const confirm = useConfirm() const aboutVisible = ref(false) +const modeDialogVisible = ref(false) +const currentMode = ref({ mode: 'normal' }) +const editingMode = ref({ mode: 'normal' }) +const isModeSaving = ref(false) +const serviceStatus = ref('NotInstalled') + +async function openModeDialog() { + editingMode.value = JSON.parse(JSON.stringify(loadMode())) + if (editingMode.value.mode === 'service') { + serviceStatus.value = await getServiceStatus() + } + modeDialogVisible.value = true +} + +async function onModeSave() { + if (isModeSaving.value) { + return; + } + isModeSaving.value = true + try { + await initWithMode(editingMode.value); + modeDialogVisible.value = false + } + catch (e: any) { + toast.add({ severity: 'error', summary: t('error'), detail: e, life: 10000 }) + console.error("Error switching mode", e, currentMode.value, editingMode.value) + await initWithMode(currentMode.value); + } + finally { + isModeSaving.value = false + } +} + +async function onUninstallService() { + confirm.require({ + message: t('mode.uninstall_service_confirm'), + header: t('mode.uninstall_service'), + icon: 'pi pi-exclamation-triangle', + rejectProps: { + label: t('web.common.cancel'), + severity: 'secondary', + outlined: true + }, + acceptProps: { + label: t('mode.uninstall_service'), + severity: 'danger' + }, + accept: async () => { + isModeSaving.value = true + try { + await initWithMode({ ...currentMode.value, mode: 'normal' }); + await initService(undefined) + toast.add({ severity: 'success', summary: t('web.common.success'), detail: t('mode.uninstall_service_success'), life: 3000 }) + modeDialogVisible.value = false + } catch (e: any) { + toast.add({ severity: 'error', summary: t('error'), detail: e, life: 10000 }) + console.error("Error uninstalling service", e) + } finally { + isModeSaving.value = false + } + }, + }); +} + +async function onStopService() { + isModeSaving.value = true + try { + await setServiceStatus(false) + toast.add({ severity: 'success', summary: t('web.common.success'), detail: t('mode.stop_service_success'), life: 3000 }) + modeDialogVisible.value = false + } + catch (e: any) { + toast.add({ severity: 'error', summary: t('error'), detail: e, life: 10000 }) + console.error("Error stopping service", e) + } + finally { + isModeSaving.value = false + } +} + +async function initWithMode(mode: Mode) { + if (currentMode.value.mode === 'service' && mode.mode !== 'service') { + let serviceStatus = await getServiceStatus() + if (serviceStatus === "Running") { + await setServiceStatus(false) + serviceStatus = await getServiceStatus() + } + if (serviceStatus === "Stopped") { + await initService(undefined) + } + } + + let url: string | undefined = undefined + let retrys = 1 + switch (mode.mode) { + case 'remote': + if (!mode.remote_rpc_address) { + toast.add({ severity: 'error', summary: t('error'), detail: t('mode.remote_rpc_address_empty'), life: 10000 }) + return initWithMode({ ...mode, mode: 'normal' }); + } + url = mode.remote_rpc_address + break; + case 'service': + if (!mode.config_dir || !mode.file_log_dir || !mode.file_log_level || !mode.rpc_portal) { + toast.add({ severity: 'error', summary: t('error'), detail: t('mode.service_config_empty'), life: 10000 }) + return initWithMode({ ...mode, mode: 'normal' }); + } + let serviceStatus = await getServiceStatus() + if (serviceStatus === "NotInstalled" || JSON.stringify(mode) !== JSON.stringify(currentMode.value)) { + await initService({ + config_dir: mode.config_dir, + file_log_dir: mode.file_log_dir, + file_log_level: mode.file_log_level, + rpc_portal: mode.rpc_portal, + }) + serviceStatus = await getServiceStatus() + } + if (serviceStatus === "Stopped") { + await setServiceStatus(true) + } + url = "tcp://" + mode.rpc_portal.replace("0.0.0.0", "127.0.0.1") + retrys = 5 + break; + } + for (let i = 0; i < retrys; i++) { + try { + await connectRpcClient(url) + break; + } catch (e) { + if (i === retrys - 1) { + throw e; + } + console.error("Error connecting rpc client, retrying...", e) + await new Promise(resolve => setTimeout(resolve, 1000)) + } + } + currentMode.value = mode + saveMode(mode) + clientRunning.value = await isClientRunning() +} + +onMounted(() => { + currentMode.value = loadMode() + initWithMode(currentMode.value); +}); useTray(true) +let toast = useToast(); const remoteClient = computed(() => new GUIRemoteClient()); const instanceId = ref(undefined); +const clientRunning = ref(false); + +watch(clientRunning, async (newVal, oldVal) => { + if (!newVal && oldVal) { + await reconnectClient() + } +}) + +onMounted(async () => { + clientRunning.value = await isClientRunning().catch(() => false) + const timer = setInterval(async () => { + try { + clientRunning.value = await isClientRunning() + } catch (e) { + clientRunning.value = false + console.error("Error checking client running status", e) + } + }, 1000) + return () => { + clearInterval(timer) + } +}) +async function reconnectClient() { + editingMode.value = JSON.parse(JSON.stringify(loadMode())); + await onModeSave() +} onMounted(async () => { window.setTimeout(async () => { @@ -81,11 +258,10 @@ const setting_menu_items: Ref = ref([ }, }, { - label: () => getAutoLaunchStatus() ? t('disable_auto_launch') : t('enable_auto_launch'), - icon: 'pi pi-desktop', - command: async () => { - await loadAutoLaunchStatusAsync(!getAutoLaunchStatus()) - }, + label: () => `${t('mode.switch_mode')}: ${t('mode.' + currentMode.value.mode)}`, + icon: 'pi pi-sync', + command: openModeDialog, + visible: () => type() !== 'android', }, { label: () => getDockVisibilityStatus() ? t('hide_dock_icon') : t('show_dock_icon'), @@ -117,6 +293,12 @@ const setting_menu_items: Ref = ref([ }, ]) +async function connectRpcClient(url?: string) { + await initRpcConnection(url) + await sendConfigs() + console.log("easytier rpc connection established") +} + onMounted(async () => { if (type() === 'android') { try { @@ -127,7 +309,6 @@ onMounted(async () => { } } const unlisten = await listenGlobalEvents() - await sendConfigs() return () => { unlisten() } @@ -140,9 +321,24 @@ onMounted(async () => { + + + + - + +
+ +
{{ t('client.not_running') }} +
+