mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-13 15:56:14 -08:00
Building easytier and easytier-web together enabled both ring and aws-lc through reqwest 0.13. HTTPS endpoint discovery used rustls automatic provider selection and could panic before sending a ClientHello. Use reqwest rustls-no-provider to share ring, and explicitly install the process default before starting CLI, GUI and web services. Initialize it for standalone webhook construction too, retaining any provider already selected by the host. Pass ring directly to HTTPS discovery and WebSocket TLS builders so library use is independent of application initialization order. Keep existing certificate verification, SNI and protocol settings. Add a duplex-stream regression that reproduces the original panic with both backends enabled, and run it in CI. Explain provider selection and feature-unification constraints next to the relevant code. Validation: the new regression fails before the fix and passes after it. Docker tests pass: 10 discovery, 13 webhook, 2 WebSocket, WSS three-node AES-GCM relay, and WSS credential connectivity. Clippy with warnings denied and fmt pass. GUI, browser, WASI, minimal native, endpoint-only and WebSocket-only compile checks pass. Linux and Windows release dependency trees contain only the ring runtime backend. Fixes #2607
1563 lines
52 KiB
Rust
1563 lines
52 KiB
Rust
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
|
|
mod elevate;
|
|
|
|
use anyhow::Context;
|
|
#[cfg(target_os = "android")]
|
|
use easytier::instance::factory::subscribe_native_instance_event;
|
|
use easytier::proto::api::config::{
|
|
ConfigPatchAction, ConfigRpc, ConfigRpcClientFactory, VpnPortalClientPatch,
|
|
};
|
|
use easytier::proto::api::instance::{
|
|
GetVpnPortalInfoRequest, InstanceIdentifier, VpnPortalInfo, VpnPortalRpc,
|
|
VpnPortalRpcClientFactory, instance_identifier,
|
|
};
|
|
use easytier::proto::api::manage::{
|
|
CollectNetworkInfoResponse, ValidateConfigResponse, VpnPortalClientConfig, WebClientService,
|
|
WebClientServiceClientFactory,
|
|
};
|
|
use easytier::proto::rpc_types::controller::BaseController;
|
|
use easytier::web_client::{self, WebClient};
|
|
use easytier::{
|
|
common::config::{NetworkConfig, NetworkConfigExt},
|
|
common::{
|
|
config::{ConfigLoader, ConfigSource, FileLoggerConfig, LoggingConfig, TomlConfigLoader},
|
|
log,
|
|
},
|
|
instance::factory::{NativeInstanceManager, native_instance_manager},
|
|
proto::rpc::standalone::{runtime_rpc_dialer, runtime_rpc_listener},
|
|
rpc_service::ApiRpcServer,
|
|
utils::panic::setup_panic_handler,
|
|
};
|
|
use easytier_core::management::config_source_to_rpc;
|
|
use easytier_core::management::remote_client::{
|
|
GetNetworkMetasResponse, ListNetworkInstanceIdsJsonResp, ListNetworkProps, RemoteClientManager,
|
|
Storage,
|
|
};
|
|
use easytier_core::{
|
|
connectivity::protocol::raw::TunnelDialer as _, process_runtime::CoreProcessRuntime,
|
|
socket::SocketListener, tunnel::Tunnel,
|
|
};
|
|
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<RwLock<Option<Arc<NativeInstanceManager>>>> =
|
|
once_cell::sync::Lazy::new(|| RwLock::new(None));
|
|
|
|
static RPC_RING_UUID: once_cell::sync::Lazy<uuid::Uuid> =
|
|
once_cell::sync::Lazy::new(uuid::Uuid::new_v4);
|
|
|
|
static CLIENT_MANAGER: once_cell::sync::Lazy<RwLock<Option<manager::GUIClientManager>>> =
|
|
once_cell::sync::Lazy::new(|| RwLock::new(None));
|
|
|
|
type BoxedTunnelListener = Box<dyn SocketListener<Accepted = Box<dyn Tunnel>>>;
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum RpcServerKind {
|
|
Ring,
|
|
Tcp,
|
|
}
|
|
|
|
struct RpcServer {
|
|
kind: RpcServerKind,
|
|
_server: ApiRpcServer<BoxedTunnelListener>,
|
|
bind_url: Option<url::Url>,
|
|
}
|
|
static RPC_SERVER: once_cell::sync::Lazy<Mutex<Option<RpcServer>>> =
|
|
once_cell::sync::Lazy::new(|| Mutex::new(None));
|
|
|
|
static WEB_CLIENT: once_cell::sync::Lazy<RwLock<Option<WebClient>>> =
|
|
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<String, String> {
|
|
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<String, String> {
|
|
let toml = cfg.gen_config().map_err(|e| e.to_string())?;
|
|
Ok(toml.dump())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn generate_network_config(toml_config: String) -> Result<NetworkConfig, String> {
|
|
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())?;
|
|
let _mutation = client_manager.config_mutation.lock().await;
|
|
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<CollectNetworkInfoResponse, String> {
|
|
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 get_vpn_portal_info(instance_id: String) -> Result<Option<VpnPortalInfo>, String> {
|
|
let instance_id = instance_id
|
|
.parse::<uuid::Uuid>()
|
|
.map_err(|e| e.to_string())?;
|
|
let client_manager = get_client_manager!()?;
|
|
let client = client_manager
|
|
.rpc_manager
|
|
.rpc_client()
|
|
.scoped_client::<VpnPortalRpcClientFactory<BaseController>>(1, 1, "".to_string());
|
|
let response = client
|
|
.get_vpn_portal_info(
|
|
BaseController::default(),
|
|
GetVpnPortalInfoRequest {
|
|
instance: Some(InstanceIdentifier {
|
|
selector: Some(instance_identifier::Selector::Id(instance_id.into())),
|
|
}),
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(response.vpn_portal_info)
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn patch_vpn_portal_clients(
|
|
app: AppHandle,
|
|
instance_id: String,
|
|
action: String,
|
|
name: Option<String>,
|
|
virtual_ip: Option<String>,
|
|
groups: Option<Vec<String>>,
|
|
) -> Result<(), String> {
|
|
let instance_id = instance_id
|
|
.parse::<uuid::Uuid>()
|
|
.map_err(|e| e.to_string())?;
|
|
let action = match action.as_str() {
|
|
"add" => ConfigPatchAction::Add,
|
|
"remove" => ConfigPatchAction::Remove,
|
|
"clear" => ConfigPatchAction::Clear,
|
|
other => return Err(format!("invalid vpn portal client patch action: {other}")),
|
|
};
|
|
let client = if action == ConfigPatchAction::Clear {
|
|
None
|
|
} else {
|
|
Some(VpnPortalClientConfig {
|
|
name: name.unwrap_or_default(),
|
|
virtual_ip: virtual_ip.unwrap_or_default(),
|
|
groups: groups.unwrap_or_default(),
|
|
})
|
|
};
|
|
|
|
let client_manager = get_client_manager!()?;
|
|
let _mutation = client_manager.config_mutation.lock().await;
|
|
client_manager
|
|
.handle_patch_vpn_portal_clients(
|
|
app,
|
|
instance_id,
|
|
vec![VpnPortalClientPatch {
|
|
action: action as i32,
|
|
client,
|
|
}],
|
|
)
|
|
.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
|
|
.attach_tun_fd(uuid, fd)
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn list_network_instance_ids(
|
|
app: AppHandle,
|
|
) -> Result<ListNetworkInstanceIdsJsonResp, String> {
|
|
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!()?;
|
|
let _mutation = client_manager.config_mutation.lock().await;
|
|
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!()?;
|
|
let _mutation = client_manager.config_mutation.lock().await;
|
|
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())?;
|
|
let client_manager = get_client_manager!()?;
|
|
let _mutation = client_manager.config_mutation.lock().await;
|
|
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<ValidateConfigResponse, String> {
|
|
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<NetworkConfig, String> {
|
|
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<manager::StoredGuiConfig>,
|
|
enabled_networks: Vec<String>,
|
|
) -> Result<(), String> {
|
|
let client_manager = get_client_manager!()?;
|
|
let _mutation = client_manager.config_mutation.lock().await;
|
|
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<uuid::Uuid>,
|
|
) -> Result<GetNetworkMetasResponse, String> {
|
|
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<service::ServiceOptions>) -> 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))
|
|
}
|
|
|
|
async fn resolve_rpc_bind_url(url: &url::Url) -> Result<std::net::SocketAddr, String> {
|
|
if url.scheme() != "tcp" {
|
|
return Err(format!("RPC portal requires tcp URL: {url}"));
|
|
}
|
|
let host = url
|
|
.host_str()
|
|
.ok_or_else(|| format!("RPC portal has no host: {url}"))?;
|
|
let port = url.port().unwrap_or(11010);
|
|
tokio::net::lookup_host((host, port))
|
|
.await
|
|
.map_err(|error| format!("failed to resolve RPC portal {url}: {error}"))?
|
|
.next()
|
|
.ok_or_else(|| format!("RPC portal has no resolved address: {url}"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn init_rpc_connection(
|
|
_app: AppHandle,
|
|
is_normal_mode: bool,
|
|
url: Option<String>,
|
|
) -> 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();
|
|
let mut local_process_runtime = None;
|
|
if is_normal_mode {
|
|
let instance_manager = if let Some(im) = instance_manager_guard.take() {
|
|
im
|
|
} else {
|
|
Arc::new(native_instance_manager())
|
|
};
|
|
|
|
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 => instance_manager
|
|
.process_runtime()
|
|
.bind_ring_tunnel(*RPC_RING_UUID.deref())
|
|
.map_err(|error| error.to_string())?,
|
|
RpcServerKind::Tcp => {
|
|
let bind_url = bind_url.as_ref().expect("tcp rpc must have bind url");
|
|
Box::new(runtime_rpc_listener(resolve_rpc_bind_url(bind_url).await?))
|
|
}
|
|
};
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
local_process_runtime = Some(instance_manager.process_runtime());
|
|
*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, local_process_runtime),
|
|
)
|
|
.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_instances(&[])
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
drop(instance_manager);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn is_client_running() -> Result<bool, String> {
|
|
Ok(get_client_manager!()?.rpc_manager.is_running())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn init_web_client(app: AppHandle, url: Option<String>) -> 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<bool, String> {
|
|
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<std::path::PathBuf, tauri::Error> {
|
|
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<String, String> {
|
|
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::config::{NetworkConfig, NetworkConfigExt};
|
|
use easytier::proto::api::logger::{LoggerRpc, LoggerRpcClientFactory, SetLoggerConfigRequest};
|
|
use easytier::proto::api::manage::RunNetworkInstanceRequest;
|
|
use easytier::proto::rpc::bidirect::BidirectRpcManager;
|
|
use easytier::proto::rpc_types::controller::BaseController;
|
|
use easytier::web_client::WebClientHooks;
|
|
use easytier_core::management::remote_client::PersistentConfig;
|
|
|
|
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,
|
|
#[serde(alias = "webhook")]
|
|
Web,
|
|
#[serde(other)]
|
|
#[default]
|
|
Legacy,
|
|
}
|
|
|
|
impl PersistedConfigSource {
|
|
pub(super) fn from_runtime_source(source: ConfigSource) -> Self {
|
|
match source {
|
|
ConfigSource::User => Self::User,
|
|
ConfigSource::Web => Self::Web,
|
|
}
|
|
}
|
|
|
|
fn merge_persisted(self, incoming: Self) -> Self {
|
|
match (self, incoming) {
|
|
// Older runtimes report missing source as `user`. Keep the stronger persisted
|
|
// ownership until web sync or an explicit user save repairs it.
|
|
(Self::Web, Self::User) | (Self::Legacy, Self::User) => self,
|
|
(_, next) => next,
|
|
}
|
|
}
|
|
|
|
fn to_runtime_source(self) -> ConfigSource {
|
|
match self {
|
|
Self::User | Self::Legacy => ConfigSource::User,
|
|
Self::Web => ConfigSource::Web,
|
|
}
|
|
}
|
|
|
|
#[cfg(any(test, target_os = "android"))]
|
|
fn is_web_like(self) -> bool {
|
|
matches!(self, Self::Web)
|
|
}
|
|
}
|
|
|
|
#[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<anyhow::Error> for GUIConfig {
|
|
fn get_network_inst_id(&self) -> &str {
|
|
&self.inst_id
|
|
}
|
|
fn get_network_config(&self) -> Result<NetworkConfig, anyhow::Error> {
|
|
Ok(self.config.clone())
|
|
}
|
|
fn get_network_config_source(&self) -> ConfigSource {
|
|
self.source.to_runtime_source()
|
|
}
|
|
}
|
|
|
|
pub(super) struct GUIStorage {
|
|
network_configs: DashMap<Uuid, GUIConfig>,
|
|
enabled_networks: DashSet<Uuid>,
|
|
}
|
|
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::<Vec<_>>();
|
|
app.emit("save_configs", configs)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn save_enabled_networks(&self, app: &AppHandle) -> anyhow::Result<()> {
|
|
let payload: Vec<String> = 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<AppHandle, GUIConfig, anyhow::Error> 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<Vec<GUIConfig>, 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<Option<GUIConfig>, 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,
|
|
pub(super) config_mutation: Mutex<()>,
|
|
}
|
|
impl GUIClientManager {
|
|
pub async fn new(
|
|
rpc_url: Option<String>,
|
|
local_process_runtime: Option<Arc<CoreProcessRuntime>>,
|
|
) -> Result<Self, anyhow::Error> {
|
|
let tunnel = if let Some(url) = rpc_url {
|
|
runtime_rpc_dialer(url.parse()?).connect().await?
|
|
} else {
|
|
local_process_runtime
|
|
.context("local RPC requires a core process runtime")?
|
|
.connect_ring_tunnel(*RPC_RING_UUID.deref())?
|
|
};
|
|
|
|
let rpc_manager = BidirectRpcManager::new();
|
|
rpc_manager.run_with_tunnel(tunnel);
|
|
|
|
Ok(Self {
|
|
storage: GUIStorage::new(),
|
|
rpc_manager,
|
|
config_mutation: Mutex::new(()),
|
|
})
|
|
}
|
|
|
|
pub fn get_enabled_instances_with_tun_ids(&self) -> impl Iterator<Item = uuid::Uuid> + '_ {
|
|
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::<uuid::Uuid>().ok())
|
|
}
|
|
|
|
#[cfg(target_os = "android")]
|
|
pub fn get_enabled_instances_with_web_like_tun_ids(
|
|
&self,
|
|
) -> impl Iterator<Item = uuid::Uuid> + '_ {
|
|
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_web_like())
|
|
.filter_map(|c| c.config.instance_id().parse::<uuid::Uuid>().ok())
|
|
}
|
|
|
|
#[cfg(target_os = "android")]
|
|
pub(super) async fn disable_instances_with_tun(
|
|
&self,
|
|
app: &AppHandle,
|
|
web_only: bool,
|
|
) -> Result<(), easytier_core::management::remote_client::RemoteClientError<anyhow::Error>>
|
|
{
|
|
let inst_ids: Vec<uuid::Uuid> = if web_only {
|
|
self.get_enabled_instances_with_web_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::Web => {
|
|
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) = instance_manager.instance(instance_uuid) {
|
|
if let Some(mut event_receiver) = subscribe_native_instance_event(&instance) {
|
|
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<Box<dyn LoggerRpc<Controller = BaseController> + Send>> {
|
|
Some(
|
|
self.rpc_manager
|
|
.rpc_client()
|
|
.scoped_client::<LoggerRpcClientFactory<BaseController>>(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: easytier_core::management::parse_log_level(&level).into(),
|
|
},
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn load_configs(
|
|
&self,
|
|
app: AppHandle,
|
|
configs: Vec<StoredGuiConfig>,
|
|
enabled_networks: Vec<String>,
|
|
) -> 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: config_source_to_rpc(source.to_runtime_source()),
|
|
},
|
|
)
|
|
.await?;
|
|
self.post_run_network_instance_hook(&app, &uuid)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!(e))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
impl RemoteClientManager<AppHandle, GUIConfig, anyhow::Error> for GUIClientManager {
|
|
fn get_config_rpc_client(
|
|
&self,
|
|
_: AppHandle,
|
|
) -> Option<Box<dyn ConfigRpc<Controller = BaseController> + Send>> {
|
|
Some(
|
|
self.rpc_manager
|
|
.rpc_client()
|
|
.scoped_client::<ConfigRpcClientFactory<BaseController>>(1, 1, String::new()),
|
|
)
|
|
}
|
|
|
|
fn get_rpc_client(
|
|
&self,
|
|
_: AppHandle,
|
|
) -> Option<Box<dyn WebClientService<Controller = BaseController> + Send>> {
|
|
Some(
|
|
self.rpc_manager
|
|
.rpc_client()
|
|
.scoped_client::<WebClientServiceClientFactory<BaseController>>(
|
|
1,
|
|
1,
|
|
"".to_string(),
|
|
),
|
|
)
|
|
}
|
|
|
|
fn get_storage(&self) -> &impl Storage<AppHandle, GUIConfig, anyhow::Error> {
|
|
&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 stored_gui_config_deserializes_webhook_source_as_web() {
|
|
let stored: StoredGuiConfig = serde_json::from_value(serde_json::json!({
|
|
"config": NetworkConfig::default(),
|
|
"source": "webhook",
|
|
}))
|
|
.unwrap();
|
|
assert_eq!(stored.source, PersistedConfigSource::Web);
|
|
}
|
|
|
|
#[test]
|
|
fn stored_gui_config_defaults_unknown_source_to_legacy() {
|
|
let stored: StoredGuiConfig = serde_json::from_value(serde_json::json!({
|
|
"config": NetworkConfig::default(),
|
|
"source": "unknown",
|
|
}))
|
|
.unwrap();
|
|
assert_eq!(stored.source, PersistedConfigSource::Legacy);
|
|
}
|
|
|
|
#[test]
|
|
fn persisted_source_merge_keeps_legacy_and_web_over_ambiguous_user() {
|
|
assert_eq!(
|
|
PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::User),
|
|
PersistedConfigSource::Legacy
|
|
);
|
|
assert_eq!(
|
|
PersistedConfigSource::Web.merge_persisted(PersistedConfigSource::User),
|
|
PersistedConfigSource::Web
|
|
);
|
|
assert_eq!(
|
|
PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::Web),
|
|
PersistedConfigSource::Web
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn only_web_configs_are_web_like() {
|
|
assert!(!PersistedConfigSource::Legacy.is_web_like());
|
|
assert!(!PersistedConfigSource::User.is_web_like());
|
|
assert!(PersistedConfigSource::Web.is_web_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<String>,
|
|
}
|
|
impl ServiceOptions {
|
|
fn to_args_vec(&self) -> Vec<std::ffi::OsString> {
|
|
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
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn service_environment() -> Option<Vec<(String, String)>> {
|
|
// System LaunchDaemons run as root but launchd does not provide HOME.
|
|
Some(vec![("HOME".to_string(), "/var/root".to_string())])
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
fn service_environment() -> Option<Vec<(String, String)>> {
|
|
None
|
|
}
|
|
|
|
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()?,
|
|
environment: service_environment(),
|
|
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<easytier::service_manager::ServiceStatus> {
|
|
let service = easytier::service_manager::Service::new(env!("CARGO_PKG_NAME").to_string())?;
|
|
service.status()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn service_environment_matches_platform() {
|
|
#[cfg(target_os = "macos")]
|
|
assert_eq!(
|
|
super::service_environment(),
|
|
Some(vec![("HOME".to_string(), "/var/root".to_string())])
|
|
);
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
assert_eq!(super::service_environment(), None);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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();
|
|
easytier::utils::init_crypto_provider();
|
|
|
|
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 = LoggingConfig::builder()
|
|
.file_logger(FileLoggerConfig {
|
|
dir: Some(log_dir.to_string_lossy().to_string()),
|
|
level: None,
|
|
file: None,
|
|
size_mb: None,
|
|
count: None,
|
|
})
|
|
.build();
|
|
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,
|
|
get_vpn_portal_info,
|
|
patch_vpn_portal_clients,
|
|
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 })
|
|
}
|