// Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod elevate; use anyhow::Context; use easytier::proto::api::manage::{ CollectNetworkInfoResponse, ValidateConfigResponse, WebClientService, WebClientServiceClientFactory, }; use easytier::rpc_service::remote_client::{ GetNetworkMetasResponse, ListNetworkInstanceIdsJsonResp, ListNetworkProps, RemoteClientManager, Storage, }; use easytier::web_client::{self, WebClient}; use easytier::{ common::{ config::{ ConfigLoader, ConfigSource, FileLoggerConfig, LoggingConfigBuilder, TomlConfigLoader, }, log, }, instance_manager::NetworkInstanceManager, launcher::NetworkConfig, rpc_service::ApiRpcServer, tunnel::TunnelListener, tunnel::ring::RingTunnelListener, tunnel::tcp::TcpTunnelListener, utils::panic::setup_panic_handler, }; use std::ops::Deref; use std::sync::Arc; use tokio::sync::{Mutex, RwLock, RwLockReadGuard}; use uuid::Uuid; use tauri::{AppHandle, Emitter, Manager as _}; #[cfg(not(target_os = "android"))] use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; 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::Lazy>> = once_cell::sync::Lazy::new(|| RwLock::new(None)); type BoxedTunnelListener = Box; #[derive(Clone, Copy, PartialEq, Eq)] enum RpcServerKind { Ring, Tcp, } struct RpcServer { kind: RpcServerKind, _server: ApiRpcServer, bind_url: Option, } static RPC_SERVER: once_cell::sync::Lazy>> = once_cell::sync::Lazy::new(|| Mutex::new(None)); static WEB_CLIENT: 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 { Ok(easytier::VERSION.to_string()) } #[tauri::command] fn set_dock_visibility(app: tauri::AppHandle, visible: bool) -> Result<(), String> { #[cfg(target_os = "macos")] { use tauri::ActivationPolicy; app.set_activation_policy(if visible { ActivationPolicy::Regular } else { ActivationPolicy::Accessory }) .map_err(|e| e.to_string())?; } #[cfg(not(target_os = "macos"))] let _ = (app, visible); Ok(()) } #[tauri::command] fn parse_network_config(cfg: NetworkConfig) -> Result { let toml = cfg.gen_config().map_err(|e| e.to_string())?; Ok(toml.dump()) } #[tauri::command] fn generate_network_config(toml_config: String) -> Result { let config = TomlConfigLoader::new_from_str(&toml_config).map_err(|e| e.to_string())?; let cfg = NetworkConfig::new_from_config(&config).map_err(|e| e.to_string())?; Ok(cfg) } #[tauri::command] async fn run_network_instance( app: AppHandle, cfg: NetworkConfig, save: bool, ) -> Result<(), String> { let client_manager = get_client_manager!()?; let toml_config = cfg.gen_config().map_err(|e| e.to_string())?; client_manager .pre_run_network_instance_hook(&app, &toml_config, manager::PersistedConfigSource::User) .await?; client_manager .handle_run_network_instance(app.clone(), cfg, save) .await .map_err(|e| e.to_string())?; client_manager .post_run_network_instance_hook(&app, &toml_config.get_id()) .await?; Ok(()) } #[tauri::command] async fn collect_network_info( app: AppHandle, instance_id: String, ) -> Result { let instance_id = instance_id .parse() .map_err(|e: uuid::Error| e.to_string())?; get_client_manager!()? .handle_collect_network_info(app, Some(vec![instance_id])) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn set_logging_level(level: String) -> Result<(), String> { get_client_manager!()? .set_logging_level(level.clone()) .await .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] 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 .set_tun_fd(&uuid, fd) .map_err(|e| e.to_string())?; } Ok(()) } #[tauri::command] async fn list_network_instance_ids( app: AppHandle, ) -> Result { get_client_manager!()? .handle_list_network_instance_ids(app) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn remove_network_instance(app: AppHandle, instance_id: String) -> Result<(), String> { let instance_id = instance_id .parse() .map_err(|e: uuid::Error| e.to_string())?; 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 .post_stop_network_instances_hook(&app) .await?; Ok(()) } #[tauri::command] async fn update_network_config_state( app: AppHandle, instance_id: String, disabled: bool, ) -> Result<(), String> { let instance_id = instance_id .parse() .map_err(|e: uuid::Error| e.to_string())?; let client_manager = get_client_manager!()?; if !disabled { let (cfg, source) = client_manager .handle_get_network_config_with_source(app.clone(), instance_id) .await .map_err(|e| e.to_string())?; let toml_config = cfg.gen_config().map_err(|e| e.to_string())?; client_manager .pre_run_network_instance_hook( &app, &toml_config, manager::PersistedConfigSource::from_runtime_source(source), ) .await?; } client_manager .handle_update_network_state(app.clone(), instance_id, disabled) .await .map_err(|e| e.to_string())?; if disabled { client_manager .post_stop_network_instances_hook(&app) .await?; } else { client_manager .post_run_network_instance_hook(&app, &instance_id) .await?; } Ok(()) } #[tauri::command] async fn save_network_config(app: AppHandle, cfg: NetworkConfig) -> Result<(), String> { let instance_id = cfg .instance_id() .parse() .map_err(|e: uuid::Error| e.to_string())?; get_client_manager!()? .handle_save_network_config(app, instance_id, cfg) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn validate_config( app: AppHandle, config: NetworkConfig, ) -> Result { get_client_manager!()? .handle_validate_config(app, config) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn get_config(app: AppHandle, instance_id: String) -> Result { let instance_id = instance_id .parse() .map_err(|e: uuid::Error| e.to_string())?; let cfg = get_client_manager!()? .handle_get_network_config(app, instance_id) .await .map_err(|e| e.to_string())?; Ok(cfg) } #[tauri::command] async fn load_configs( app: AppHandle, configs: Vec, enabled_networks: Vec, ) -> Result<(), String> { get_client_manager!()? .load_configs(app, configs, enabled_networks) .await .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] async fn get_network_metas( app: AppHandle, instance_ids: Vec, ) -> Result { 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") } } fn normalize_normal_mode_rpc_portal(portal: &str) -> Result<(url::Url, url::Url), String> { let portal_url: url::Url = portal .parse() .map_err(|e| format!("invalid rpc portal: {:#}", e))?; let bind_url = portal_url.clone(); let mut connect_url = portal_url.clone(); // if bind addr is 0.0.0.0, should convert to 127.0.0.1 if connect_url.host_str() == Some("0.0.0.0") { connect_url.set_host(Some("127.0.0.1")).unwrap(); } Ok((bind_url, connect_url)) } #[tauri::command] async fn init_rpc_connection( _app: AppHandle, is_normal_mode: bool, 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 rpc_server_guard = RPC_SERVER .try_lock() .map_err(|_| "Failed to acquire lock for rpc server")?; let mut client_url = url.clone(); if is_normal_mode { let instance_manager = if let Some(im) = instance_manager_guard.take() { im } else { Arc::new(NetworkInstanceManager::new()) }; let portal = url.and_then(|s| { let trimmed = s.trim().to_string(); if trimmed.is_empty() { None } else { Some(trimmed) } }); let (desired_kind, bind_url, connect_url) = if let Some(portal) = portal { let (bind_url, connect_url) = normalize_normal_mode_rpc_portal(&portal)?; (RpcServerKind::Tcp, Some(bind_url), Some(connect_url)) } else { (RpcServerKind::Ring, None, None) }; let need_restart = rpc_server_guard .as_ref() .map(|x| x.kind != desired_kind || x.bind_url != bind_url) .unwrap_or(true); if need_restart { *rpc_server_guard = None; let tunnel: BoxedTunnelListener = match desired_kind { RpcServerKind::Ring => Box::new(RingTunnelListener::new( format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap(), )), RpcServerKind::Tcp => Box::new(TcpTunnelListener::new( bind_url.clone().expect("tcp rpc must have bind url"), )), }; let rpc_server = ApiRpcServer::from_tunnel(tunnel, instance_manager.clone()) .with_rx_timeout(None) .serve() .await .map_err(|e| e.to_string())?; *rpc_server_guard = Some(RpcServer { kind: desired_kind, _server: rpc_server, bind_url, }); } *instance_manager_guard = Some(instance_manager); client_url = connect_url.map(|u| u.to_string()); } else { *rpc_server_guard = None; } let client_manager = tokio::time::timeout( std::time::Duration::from_millis(1000), manager::GUIClientManager::new(client_url), ) .await .map_err(|_| "connect remote rpc timed out".to_string())? .with_context(|| "Failed to connect remote rpc") .map_err(|e| format!("{:#}", e))?; *client_manager_guard = Some(client_manager); if !is_normal_mode { drop(WEB_CLIENT.write().await.take()); 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()) } #[tauri::command] async fn init_web_client(app: AppHandle, url: Option) -> Result<(), String> { let mut web_client_guard = WEB_CLIENT.write().await; let Some(url) = url else { *web_client_guard = None; return Ok(()); }; let instance_manager = INSTANCE_MANAGER .try_read() .map_err(|_| "Failed to acquire read lock for instance manager")? .clone() .ok_or_else(|| "Instance manager is not available".to_string())?; let hooks = Arc::new(manager::GuiHooks { app: app.clone() }); let machine_id_state_dir = app .path() .app_data_dir() .with_context(|| "Failed to resolve machine id state directory") .map_err(|e| format!("{:#}", e))?; let web_client = web_client::run_web_client( url.as_str(), easytier::common::MachineIdOptions { explicit_machine_id: None, state_dir: Some(machine_id_state_dir), }, None, false, instance_manager, Some(hooks), ) .await .with_context(|| "Failed to initialize web client") .map_err(|e| format!("{:#}", e))?; *web_client_guard = Some(web_client); Ok(()) } #[tauri::command] async fn is_web_client_connected() -> Result { let web_client_guard = WEB_CLIENT.read().await; if let Some(web_client) = web_client_guard.as_ref() { Ok(web_client.is_connected()) } else { Ok(false) } } // 获取日志目录的辅助函数 fn get_log_dir(app: &tauri::AppHandle) -> Result { if cfg!(target_os = "android") { // Android: cache_dir + logs 子目录 app.path().cache_dir().map(|p| p.join("logs")) } else { // 其他平台: 默认日志目录 app.path().app_log_dir() } } #[tauri::command] async fn get_log_dir_path(app: tauri::AppHandle) -> Result { match get_log_dir(&app) { Ok(log_dir) => { std::fs::create_dir_all(&log_dir).ok(); Ok(log_dir.to_string_lossy().to_string()) } Err(e) => Err(format!("Failed to get log directory: {}", e)), } } #[cfg(not(target_os = "android"))] fn toggle_window_visibility(app: &tauri::AppHandle) { if let Some(window) = app.get_webview_window("main") { let visible = window.is_visible().unwrap_or_default(); let minimized = window.is_minimized().unwrap_or_default(); let focused = window.is_focused().unwrap_or_default(); let should_show = !visible || minimized || !focused; if should_show { if !visible { let _ = window.show(); } if minimized { let _ = window.unminimize(); } if !focused { let _ = window.set_focus(); } let _ = set_dock_visibility(app.clone(), true); } else { let _ = window.hide(); let _ = set_dock_visibility(app.clone(), false); } } } fn get_exe_path() -> String { if let Ok(appimage_path) = std::env::var("APPIMAGE") && !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 = get_exe_path(); let stdcmd = std::process::Command::new(&exe_path); elevate::Command::new(stdcmd) .output() .expect("Failed to run elevated command"); } is_elevated } 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::logger::{LoggerRpc, LoggerRpcClientFactory, SetLoggerConfigRequest}; use easytier::proto::api::manage::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::TunnelConnector; use easytier::tunnel::ring::RingTunnelConnector; use easytier::web_client::WebClientHooks; pub(super) struct GuiHooks { pub(super) app: AppHandle, } #[async_trait] impl WebClientHooks for GuiHooks { async fn pre_run_network_instance( &self, cfg: &easytier::common::config::TomlConfigLoader, ) -> Result<(), String> { let client_manager = get_client_manager!()?; client_manager .pre_run_network_instance_hook( &self.app, cfg, PersistedConfigSource::from_runtime_source(cfg.get_network_config_source()), ) .await } async fn post_run_network_instance(&self, instance_id: &uuid::Uuid) -> Result<(), String> { let client_manager = get_client_manager!()?; client_manager .post_run_network_instance_hook(&self.app, instance_id) .await } async fn post_remove_network_instances(&self, ids: &[uuid::Uuid]) -> Result<(), String> { let client_manager = get_client_manager!()?; client_manager .post_remote_remove_network_instances_hook(&self.app, ids) .await } } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] #[derive(Default)] pub(super) enum PersistedConfigSource { User, Webhook, #[serde(other)] #[default] Legacy, } impl PersistedConfigSource { pub(super) fn from_runtime_source(source: ConfigSource) -> Self { match source { ConfigSource::User => Self::User, ConfigSource::Webhook => Self::Webhook, } } fn merge_persisted(self, incoming: Self) -> Self { match (self, incoming) { // Older runtimes report missing source as `user`. Keep the stronger persisted // ownership until webhook sync or an explicit user save repairs it. (Self::Webhook, Self::User) | (Self::Legacy, Self::User) => self, (_, next) => next, } } fn to_runtime_source(self) -> ConfigSource { match self { Self::User | Self::Legacy => ConfigSource::User, Self::Webhook => ConfigSource::Webhook, } } #[cfg(any(test, target_os = "android"))] fn is_webhook_like(self) -> bool { matches!(self, Self::Webhook) } } #[derive(Clone)] pub(super) struct GUIConfig { inst_id: String, pub(crate) config: NetworkConfig, source: PersistedConfigSource, } #[derive(Clone, serde::Serialize, serde::Deserialize)] pub(super) struct StoredGuiConfig { config: NetworkConfig, #[serde(default)] source: PersistedConfigSource, } impl GUIConfig { fn new(inst_id: String, config: NetworkConfig, source: PersistedConfigSource) -> Self { Self { inst_id, config, source, } } fn into_stored(self) -> StoredGuiConfig { StoredGuiConfig { config: self.config, source: self.source, } } } impl PersistentConfig for GUIConfig { fn get_network_inst_id(&self) -> &str { &self.inst_id } fn get_network_config(&self) -> Result { Ok(self.config.clone()) } fn get_network_config_source(&self) -> ConfigSource { self.source.to_runtime_source() } } pub(super) struct GUIStorage { network_configs: DashMap, enabled_networks: DashSet, } impl GUIStorage { fn new() -> Self { Self { network_configs: DashMap::new(), enabled_networks: DashSet::new(), } } fn save_configs(&self, app: &AppHandle) -> anyhow::Result<()> { let configs = self .network_configs .iter() .map(|entry| entry.value().clone().into_stored()) .collect::>(); app.emit("save_configs", configs)?; Ok(()) } fn save_enabled_networks(&self, app: &AppHandle) -> anyhow::Result<()> { let payload: Vec = self .enabled_networks .iter() .map(|entry| entry.key().to_string()) .collect(); app.emit("save_enabled_networks", payload)?; Ok(()) } fn save_config( &self, app: &AppHandle, inst_id: Uuid, cfg: NetworkConfig, source: PersistedConfigSource, ) -> anyhow::Result<()> { let source = self .network_configs .get(&inst_id) .map(|existing| existing.source.merge_persisted(source)) .unwrap_or(source); let config = GUIConfig::new(inst_id.to_string(), cfg, source); self.network_configs.insert(inst_id, config); self.save_configs(app) } } #[async_trait] impl Storage for GUIStorage { async fn insert_or_update_user_network_config( &self, app: AppHandle, network_inst_id: Uuid, network_config: NetworkConfig, source: ConfigSource, ) -> Result<(), anyhow::Error> { self.save_config( &app, network_inst_id, network_config, PersistedConfigSource::from_runtime_source(source), )?; self.enabled_networks.insert(network_inst_id); self.save_enabled_networks(&app)?; Ok(()) } async fn delete_network_configs( &self, app: AppHandle, network_inst_ids: &[Uuid], ) -> Result<(), anyhow::Error> { for network_inst_id in network_inst_ids { self.network_configs.remove(network_inst_id); self.enabled_networks.remove(network_inst_id); } self.save_configs(&app)?; self.save_enabled_networks(&app)?; Ok(()) } async fn update_network_config_state( &self, app: AppHandle, network_inst_id: Uuid, disabled: bool, ) -> Result<(), anyhow::Error> { if disabled { self.enabled_networks.remove(&network_inst_id); } else { self.enabled_networks.insert(network_inst_id); } self.save_enabled_networks(&app)?; Ok(()) } async fn list_network_configs( &self, _: AppHandle, props: ListNetworkProps, ) -> Result, anyhow::Error> { let mut ret = Vec::new(); for entry in self.network_configs.iter() { let id: Uuid = entry.key().to_owned(); match props { ListNetworkProps::All => { ret.push(entry.value().clone()); } ListNetworkProps::EnabledOnly => { if self.enabled_networks.contains(&id) { ret.push(entry.value().clone()); } } ListNetworkProps::DisabledOnly => { if !self.enabled_networks.contains(&id) { ret.push(entry.value().clone()); } } } } Ok(ret) } async fn get_network_config( &self, _: AppHandle, network_inst_id: &str, ) -> Result, anyhow::Error> { let uuid = Uuid::parse_str(network_inst_id)?; Ok(self .network_configs .get(&uuid) .map(|entry| entry.value().clone())) } } pub(super) struct GUIClientManager { pub(super) storage: GUIStorage, pub(super) rpc_manager: BidirectRpcManager, } impl GUIClientManager { 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); Ok(Self { storage: GUIStorage::new(), rpc_manager, }) } pub fn get_enabled_instances_with_tun_ids(&self) -> impl Iterator + '_ { self.storage .network_configs .iter() .filter(|v| self.storage.enabled_networks.contains(v.key())) .filter(|v| !v.config.no_tun()) .filter_map(|c| c.config.instance_id().parse::().ok()) } #[cfg(target_os = "android")] pub fn get_enabled_instances_with_webhook_like_tun_ids( &self, ) -> impl Iterator + '_ { self.storage .network_configs .iter() .filter(|v| self.storage.enabled_networks.contains(v.key())) .filter(|v| !v.config.no_tun()) .filter(|v| v.source.is_webhook_like()) .filter_map(|c| c.config.instance_id().parse::().ok()) } #[cfg(target_os = "android")] pub(super) async fn disable_instances_with_tun( &self, app: &AppHandle, webhook_only: bool, ) -> Result<(), easytier::rpc_service::remote_client::RemoteClientError> { let inst_ids: Vec = if webhook_only { self.get_enabled_instances_with_webhook_like_tun_ids() .collect() } else { self.get_enabled_instances_with_tun_ids().collect() }; for inst_id in inst_ids { self.handle_update_network_state(app.clone(), inst_id, true) .await?; } Ok(()) } pub(super) fn notify_vpn_stop_if_no_tun(&self, app: &AppHandle) -> Result<(), String> { let has_tun = self.get_enabled_instances_with_tun_ids().any(|_| true); if !has_tun { app.emit("vpn_service_stop", "") .map_err(|e| e.to_string())?; } Ok(()) } pub(super) async fn pre_run_network_instance_hook( &self, app: &AppHandle, cfg: &easytier::common::config::TomlConfigLoader, source: PersistedConfigSource, ) -> Result<(), String> { let instance_id = cfg.get_id(); app.emit("pre_run_network_instance", instance_id.to_string()) .map_err(|e| e.to_string())?; #[cfg(target_os = "android")] if !cfg.get_flags().no_tun { match source { PersistedConfigSource::User | PersistedConfigSource::Legacy => { self.disable_instances_with_tun(app, false) .await .map_err(|e| e.to_string())?; } PersistedConfigSource::Webhook => { self.disable_instances_with_tun(app, true) .await .map_err(|e| e.to_string())?; if self.get_enabled_instances_with_tun_ids().next().is_some() { return Err( "Android only supports one active TUN network; user-managed VPN remains active" .to_string(), ); } } } } self.storage .save_config( app, instance_id, NetworkConfig::new_from_config(cfg).map_err(|e| e.to_string())?, source, ) .map_err(|e| e.to_string())?; Ok(()) } pub(super) async fn post_run_network_instance_hook( &self, app: &AppHandle, instance_id: &uuid::Uuid, ) -> Result<(), String> { #[cfg(target_os = "android")] if let Some(instance_manager) = super::INSTANCE_MANAGER.read().await.as_ref() { let instance_uuid = *instance_id; if let Some(instance_ref) = instance_manager .iter() .find(|item| *item.key() == instance_uuid) { if let Some(mut event_receiver) = instance_ref.value().subscribe_event() { let app_clone = app.clone(); let instance_id_clone = *instance_id; tokio::spawn(async move { let instance_id_str = instance_id_clone.to_string(); loop { match event_receiver.recv().await { Ok(easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv4Changed(_, _)) => { let _ = app_clone.emit("dhcp_ip_changed", &instance_id_str); } Ok(easytier::common::global_ctx::GlobalCtxEvent::ProxyCidrsUpdated(_, _)) => { let _ = app_clone.emit("proxy_cidrs_updated", &instance_id_str); } Ok(_) => {} Err(tokio::sync::broadcast::error::RecvError::Closed) => { break; } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { let _ = app_clone.emit("event_lagged", &instance_id_str); event_receiver = event_receiver.resubscribe(); } } } }); } } } self.storage.enabled_networks.insert(*instance_id); app.emit("post_run_network_instance", instance_id.to_string()) .map_err(|e| e.to_string())?; Ok(()) } pub(super) async fn post_remote_remove_network_instances_hook( &self, app: &AppHandle, ids: &[uuid::Uuid], ) -> Result<(), String> { self.storage .delete_network_configs(app.clone(), ids) .await .map_err(|e| e.to_string())?; self.notify_vpn_stop_if_no_tun(app)?; Ok(()) } pub(super) async fn post_stop_network_instances_hook( &self, app: &AppHandle, ) -> Result<(), String> { self.notify_vpn_stop_if_no_tun(app)?; 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 stored in configs { let instance_id = stored.config.instance_id(); self.storage.network_configs.insert( instance_id.parse()?, GUIConfig::new(instance_id.to_string(), stored.config, stored.source), ); } self.storage.enabled_networks.clear(); let client = self .get_rpc_client(app.clone()) .ok_or_else(|| anyhow::anyhow!("RPC client not found"))?; for id in enabled_networks { if let Ok(uuid) = id.parse() && !self.storage.enabled_networks.contains(&uuid) { let config = self .storage .network_configs .get(&uuid) .map(|i| (i.value().config.clone(), i.value().source)); let Some((config, source)) = config else { continue; }; let toml_config = config.gen_config()?; self.pre_run_network_instance_hook(&app, &toml_config, source) .await .map_err(|e| anyhow::anyhow!(e))?; client .run_network_instance( BaseController::default(), RunNetworkInstanceRequest { inst_id: None, config: Some(config), overwrite: false, source: source.to_runtime_source().to_rpc(), }, ) .await?; self.post_run_network_instance_hook(&app, &uuid) .await .map_err(|e| anyhow::anyhow!(e))?; } } Ok(()) } } impl RemoteClientManager for GUIClientManager { fn get_rpc_client( &self, _: AppHandle, ) -> Option + Send>> { Some( self.rpc_manager .rpc_client() .scoped_client::>( 1, 1, "".to_string(), ), ) } fn get_storage(&self) -> &impl Storage { &self.storage } } #[cfg(test)] mod tests { use super::{PersistedConfigSource, StoredGuiConfig}; use easytier::proto::api::manage::NetworkConfig; #[test] fn stored_gui_config_defaults_missing_source_to_legacy() { let stored: StoredGuiConfig = serde_json::from_value(serde_json::json!({ "config": NetworkConfig::default(), })) .unwrap(); assert_eq!(stored.source, PersistedConfigSource::Legacy); } #[test] fn persisted_source_merge_keeps_legacy_and_webhook_over_ambiguous_user() { assert_eq!( PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::User), PersistedConfigSource::Legacy ); assert_eq!( PersistedConfigSource::Webhook.merge_persisted(PersistedConfigSource::User), PersistedConfigSource::Webhook ); assert_eq!( PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::Webhook), PersistedConfigSource::Webhook ); } #[test] fn only_webhook_configs_are_webhook_like() { assert!(!PersistedConfigSource::Legacy.is_webhook_like()); assert!(!PersistedConfigSource::User.is_webhook_like()); assert!(PersistedConfigSource::Webhook.is_webhook_like()); } } } #[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, pub(super) config_server: Option, } impl ServiceOptions { fn to_args_vec(&self) -> Vec { let mut args = 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(), ]; if let Some(config_server) = &self.config_server { args.push("--config-server".into()); args.push(config_server.clone().into()); } args } } 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_gui() -> std::process::ExitCode { #[cfg(not(target_os = "android"))] if !check_sudo() { use std::process; process::exit(0); } setup_panic_handler(); let mut builder = tauri::Builder::default(); #[cfg(not(any(target_os = "android", target_os = "ios")))] { builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { app.webview_windows() .values() .next() .expect("Sorry, no window found") .set_focus() .expect("Can't Bring Window to Focus"); })); } builder = builder .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_vpnservice::init()); let app = builder .setup(|app| { // for logging config let Ok(log_dir) = get_log_dir(app.app_handle()) else { return Ok(()); }; let config = LoggingConfigBuilder::default() .file_logger(FileLoggerConfig { dir: Some(log_dir.to_string_lossy().to_string()), level: None, file: None, size_mb: None, count: None, }) .build() .map_err(|e| e.to_string())?; let Ok(_) = log::init(&config, true) else { return Ok(()); }; // for tray icon, menu need to be built in js #[cfg(not(target_os = "android"))] let _tray_menu = TrayIconBuilder::with_id("main") .show_menu_on_left_click(false) .on_tray_icon_event(|tray, event| { if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event { let app = tray.app_handle(); toggle_window_visibility(app); } }) .icon(tauri::image::Image::from_bytes(include_bytes!( "../icons/icon.png" ))?) .icon_as_template(true) .build(app)?; Ok(()) }) .invoke_handler(tauri::generate_handler![ parse_network_config, generate_network_config, run_network_instance, collect_network_info, set_logging_level, set_tun_fd, easytier_version, set_dock_visibility, list_network_instance_ids, remove_network_instance, update_network_config_state, save_network_config, validate_config, get_config, load_configs, get_network_metas, init_service, set_service_status, get_service_status, init_rpc_connection, is_client_running, init_web_client, is_web_client_connected, get_log_dir_path, ]) .on_window_event(|_win, event| match event { #[cfg(not(target_os = "android"))] tauri::WindowEvent::CloseRequested { api, .. } => { let _ = _win.hide(); let _ = set_dock_visibility(_win.app_handle().clone(), false); api.prevent_close(); } _ => {} }) .build(tauri::generate_context!()) .unwrap(); app.run(|_app, _event| {}); 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 }) }