diff --git a/easytier-gui/src-tauri/src/lib.rs b/easytier-gui/src-tauri/src/lib.rs index 1209462..33cff00 100644 --- a/easytier-gui/src-tauri/src/lib.rs +++ b/easytier-gui/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ use easytier::rpc_service::remote_client::{ GetNetworkMetasResponse, ListNetworkInstanceIdsJsonResp, ListNetworkProps, RemoteClientManager, Storage, }; +use easytier::web_client::{self, WebClient}; use easytier::{ common::config::{ConfigLoader, FileLoggerConfig, LoggingConfigBuilder, TomlConfigLoader}, instance_manager::NetworkInstanceManager, @@ -42,6 +43,9 @@ static CLIENT_MANAGER: once_cell::sync::Lazy>>> = once_cell::sync::Lazy::new(|| RwLock::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 @@ -93,61 +97,18 @@ async fn run_network_instance( cfg: NetworkConfig, save: bool, ) -> Result<(), String> { - let instance_id = cfg.instance_id().to_string(); - - 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 - .disable_instances_with_tun(&app) - .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) + .await?; client_manager .handle_run_network_instance(app.clone(), cfg, save) .await .map_err(|e| e.to_string())?; - - #[cfg(target_os = "android")] - if let Some(instance_manager) = INSTANCE_MANAGER.read().await.as_ref() { - let instance_uuid = instance_id - .parse::() - .map_err(|e| e.to_string())?; - 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.clone(); - tokio::spawn(async move { - loop { - match event_receiver.recv().await { - Ok(event) => { - if let easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv4Changed(_, _) = event { - let _ = app_clone.emit("dhcp_ip_changed", instance_id_clone.clone()); - } - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - break; - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { - event_receiver = event_receiver.resubscribe(); - } - } - } - }); - } - } - } - - app.emit("post_run_network_instance", instance_id) - .map_err(|e| e.to_string())?; + client_manager + .post_run_network_instance_hook(&app, &toml_config.get_id()) + .await?; Ok(()) } @@ -211,7 +172,10 @@ async fn remove_network_instance(app: AppHandle, instance_id: String) -> Result< .handle_remove_network_instances(app.clone(), vec![instance_id]) .await .map_err(|e| e.to_string())?; - client_manager.notify_vpn_stop_if_no_tun(&app)?; + client_manager + .post_remove_network_instances_hook(&app, &[instance_id]) + .await?; + Ok(()) } @@ -229,9 +193,13 @@ async fn update_network_config_state( .handle_update_network_state(app.clone(), instance_id, disabled) .await .map_err(|e| e.to_string())?; + if disabled { - client_manager.notify_vpn_stop_if_no_tun(&app)?; + client_manager + .post_remove_network_instances_hook(&app, &[instance_id]) + .await?; } + Ok(()) } @@ -260,13 +228,14 @@ async fn validate_config( #[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!()? - .storage - .get_network_config(app, &instance_id) + .handle_get_network_config(app, instance_id) .await - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Config not found for instance ID: {}", instance_id))?; - Ok(cfg.1) + .map_err(|e| e.to_string())?; + Ok(cfg) } #[tauri::command] @@ -394,7 +363,7 @@ async fn init_rpc_connection(_app: AppHandle, url: Option) -> Result<(), *ring_rpc_server_guard = None; } - let mut client_manager = tokio::time::timeout( + let client_manager = tokio::time::timeout( std::time::Duration::from_millis(1000), manager::GUIClientManager::new(url), ) @@ -402,12 +371,10 @@ async fn init_rpc_connection(_app: AppHandle, url: Option) -> Result<(), .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 { + drop(WEB_CLIENT.write().await.take()); if let Some(instance_manager) = instance_manager_guard.take() { instance_manager .retain_network_instance(vec![]) @@ -424,6 +391,40 @@ 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 web_client = + web_client::run_web_client(url.as_str(), None, None, 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) + } +} + #[cfg(not(target_os = "android"))] fn toggle_window_visibility(app: &tauri::AppHandle) { if let Some(window) = app.get_webview_window("main") { @@ -479,7 +480,7 @@ mod manager { use easytier::common::stun::MockStunInfoCollector; use easytier::launcher::NetworkConfig; use easytier::proto::api::logger::{LoggerRpc, LoggerRpcClientFactory, SetLoggerConfigRequest}; - use easytier::proto::api::manage::{ListNetworkInstanceRequest, RunNetworkInstanceRequest}; + 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; @@ -487,6 +488,38 @@ mod manager { use easytier::rpc_service::remote_client::PersistentConfig; use easytier::tunnel::ring::RingTunnelConnector; use easytier::tunnel::TunnelConnector; + 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) + .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_remove_network_instances_hook(&self.app, ids) + .await + } + } #[derive(Clone)] pub(super) struct GUIConfig(String, pub(crate) NetworkConfig); @@ -693,6 +726,89 @@ mod manager { Ok(()) } + pub(super) async fn pre_run_network_instance_hook( + &self, + app: &AppHandle, + cfg: &easytier::common::config::TomlConfigLoader, + ) -> Result<(), String> { + let instance_id = cfg.get_id(); + app.emit("pre_run_network_instance", instance_id) + .map_err(|e| e.to_string())?; + + #[cfg(target_os = "android")] + if !cfg.get_flags().no_tun { + self.disable_instances_with_tun(app) + .await + .map_err(|e| e.to_string())?; + } + + self.storage + .save_config( + app, + instance_id, + NetworkConfig::new_from_config(cfg).map_err(|e| e.to_string())?, + ) + .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 { + loop { + match event_receiver.recv().await { + Ok(event) => { + if let easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv4Changed(_, _) = event { + let _ = app_clone.emit("dhcp_ip_changed", instance_id_clone); + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + break; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + event_receiver = event_receiver.resubscribe(); + } + } + } + }); + } + } + } + + self.storage.enabled_networks.insert(*instance_id); + + app.emit("post_run_network_instance", instance_id) + .map_err(|e| e.to_string())?; + + Ok(()) + } + + pub(super) async fn post_remove_network_instances_hook( + &self, + app: &AppHandle, + _ids: &[uuid::Uuid], + ) -> Result<(), String> { + self.storage + .enabled_networks + .retain(|id| !_ids.contains(id)); + self.notify_vpn_stop_if_no_tun(app)?; + Ok(()) + } + fn get_logger_rpc_client( &self, ) -> Option + Send>> { @@ -737,12 +853,6 @@ mod manager { 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) { @@ -803,10 +913,11 @@ mod service { 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 { - vec![ + let mut args = vec![ "--config-dir".into(), self.config_dir.clone().into(), "--rpc-portal".into(), @@ -816,7 +927,14 @@ mod service { "--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 } } @@ -959,6 +1077,8 @@ pub fn run_gui() -> std::process::ExitCode { get_service_status, init_rpc_connection, is_client_running, + init_web_client, + is_web_client_connected, ]) .on_window_event(|_win, event| match event { #[cfg(not(target_os = "android"))] diff --git a/easytier-gui/src/auto-imports.d.ts b/easytier-gui/src/auto-imports.d.ts index 4f34ad8..cc69d39 100644 --- a/easytier-gui/src/auto-imports.d.ts +++ b/easytier-gui/src/auto-imports.d.ts @@ -33,12 +33,14 @@ declare global { const initMobileVpnService: typeof import('./composables/mobile_vpn')['initMobileVpnService'] const initRpcConnection: typeof import('./composables/backend')['initRpcConnection'] const initService: typeof import('./composables/backend')['initService'] + const initWebClient: typeof import('./composables/backend')['initWebClient'] 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 isWebClientConnected: typeof import('./composables/backend')['isWebClientConnected'] const listNetworkInstanceIds: typeof import('./composables/backend')['listNetworkInstanceIds'] const listenGlobalEvents: typeof import('./composables/event')['listenGlobalEvents'] const loadMode: typeof import('./composables/mode')['loadMode'] @@ -153,12 +155,14 @@ declare module 'vue' { readonly initMobileVpnService: UnwrapRef readonly initRpcConnection: UnwrapRef readonly initService: UnwrapRef + readonly initWebClient: UnwrapRef readonly inject: UnwrapRef readonly isClientRunning: UnwrapRef readonly isProxy: UnwrapRef readonly isReactive: UnwrapRef readonly isReadonly: UnwrapRef readonly isRef: UnwrapRef + readonly isWebClientConnected: UnwrapRef readonly listNetworkInstanceIds: UnwrapRef readonly listenGlobalEvents: UnwrapRef readonly loadMode: UnwrapRef diff --git a/easytier-gui/src/composables/backend.ts b/easytier-gui/src/composables/backend.ts index c79a90e..5baaf6e 100644 --- a/easytier-gui/src/composables/backend.ts +++ b/easytier-gui/src/composables/backend.ts @@ -11,6 +11,7 @@ interface ServiceOptions { rpc_portal: string file_log_level: string file_log_dir: string + config_server?: string } export type ServiceStatus = "Running" | "Stopped" | "NotInstalled" @@ -67,9 +68,9 @@ export async function getConfig(instanceId: string) { return await invoke('get_config', { instanceId }) } -export async function sendConfigs() { +export async function sendConfigs(enabledNetworks: string[]) { let networkList: NetworkConfig[] = JSON.parse(localStorage.getItem('networkList') || '[]'); - return await invoke('load_configs', { configs: networkList, enabledNetworks: [] }) + return await invoke('load_configs', { configs: networkList, enabledNetworks }) } export async function getNetworkMetas(instanceIds: string[]) { @@ -95,3 +96,11 @@ export async function initRpcConnection(url?: string) { export async function isClientRunning() { return await invoke('is_client_running') } + +export async function initWebClient(url?: string) { + return await invoke('init_web_client', { url }) +} + +export async function isWebClientConnected() { + return await invoke('is_web_client_connected') +} diff --git a/easytier-gui/src/composables/mode.ts b/easytier-gui/src/composables/mode.ts index 52e50c4..5ed95f6 100644 --- a/easytier-gui/src/composables/mode.ts +++ b/easytier-gui/src/composables/mode.ts @@ -1,8 +1,14 @@ -interface NormalMode { +import { type } from '@tauri-apps/plugin-os'; + +export interface WebClientConfig { + config_server_url?: string +} + +interface NormalMode extends WebClientConfig { mode: 'normal' } -export interface ServiceMode { +export interface ServiceMode extends WebClientConfig { mode: 'service' config_dir: string rpc_portal: string @@ -19,15 +25,15 @@ 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 + let mode = JSON.parse(modeStr) as Mode + if (type() === 'android') { + return { ...mode, mode: 'normal' } + } + return mode } else { return { mode: 'normal' } } diff --git a/easytier-gui/src/pages/index.vue b/easytier-gui/src/pages/index.vue index 6f0a41d..75abfb6 100644 --- a/easytier-gui/src/pages/index.vue +++ b/easytier-gui/src/pages/index.vue @@ -5,15 +5,15 @@ import { type } from '@tauri-apps/plugin-os' import { appLogDir } from '@tauri-apps/api/path' import { writeText } from '@tauri-apps/plugin-clipboard-manager' import { exit } from '@tauri-apps/plugin-process' -import { I18nUtils, RemoteManagement } from "easytier-frontend-lib" +import { I18nUtils, RemoteManagement, Utils } from "easytier-frontend-lib" import type { MenuItem } from 'primevue/menuitem' import { useTray } from '~/composables/tray' import { GUIRemoteClient } from '~/modules/api' import { useToast, useConfirm } from 'primevue' -import { loadMode, saveMode, type Mode } from '~/composables/mode' +import { loadMode, saveMode, WebClientConfig, type Mode } from '~/composables/mode' import ModeSwitcher from '~/components/ModeSwitcher.vue' -import { getServiceStatus, type ServiceStatus } from '~/composables/backend' +import { getServiceStatus } from '~/composables/backend' const { t, locale } = useI18n() const confirm = useConfirm() @@ -22,13 +22,13 @@ const modeDialogVisible = ref(false) const currentMode = ref({ mode: 'normal' }) const editingMode = ref({ mode: 'normal' }) const isModeSaving = ref(false) -const serviceStatus = ref('NotInstalled') +const manualDisconnect = ref(false) + +const configServerDialogVisible = ref(false) +const configServerConnected = ref(false) async function openModeDialog() { editingMode.value = JSON.parse(JSON.stringify(loadMode())) - if (editingMode.value.mode === 'service') { - serviceStatus.value = await getServiceStatus() - } modeDialogVisible.value = true } @@ -84,6 +84,7 @@ async function onUninstallService() { async function onStopService() { isModeSaving.value = true + manualDisconnect.value = true try { await setServiceStatus(false) toast.add({ severity: 'success', summary: t('web.common.success'), detail: t('mode.stop_service_success'), life: 3000 }) @@ -99,11 +100,21 @@ async function onStopService() { } async function initWithMode(mode: Mode) { + const running_inst_ids = (await remoteClient.value.list_network_instance_ids().catch(() => undefined))?.running_inst_ids ?? [] + if (currentMode.value.mode === 'service' && mode.mode !== 'service') { let serviceStatus = await getServiceStatus() if (serviceStatus === "Running") { + manualDisconnect.value = true await setServiceStatus(false) serviceStatus = await getServiceStatus() + for (let i = 0; i < 10; i++) { // macOS takes a while to stop the service + if (serviceStatus === "Stopped") { + break; + } + await new Promise(resolve => setTimeout(resolve, 100)) + serviceStatus = await getServiceStatus() + } } if (serviceStatus === "Stopped") { await initService(undefined) @@ -127,11 +138,13 @@ async function initWithMode(mode: Mode) { } let serviceStatus = await getServiceStatus() if (serviceStatus === "NotInstalled" || JSON.stringify(mode) !== JSON.stringify(currentMode.value)) { + mode.config_server_url = mode.config_server_url || undefined 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, + config_server: mode.config_server_url, }) serviceStatus = await getServiceStatus() } @@ -154,6 +167,11 @@ async function initWithMode(mode: Mode) { await new Promise(resolve => setTimeout(resolve, 1000)) } } + await sendConfigs(running_inst_ids.map(Utils.UuidToStr)) + if (mode.mode === 'normal') { + mode.config_server_url = mode.config_server_url || undefined + initWebClient(mode.config_server_url) + } currentMode.value = mode saveMode(mode) clientRunning.value = await isClientRunning() @@ -173,6 +191,10 @@ const clientRunning = ref(false); watch(clientRunning, async (newVal, oldVal) => { if (!newVal && oldVal) { + if (manualDisconnect.value) { + manualDisconnect.value = false + return + } await reconnectClient() } }) @@ -187,9 +209,10 @@ onMounted(async () => { console.error("Error checking client running status", e) } }, 1000) - return () => { + + onUnmounted(() => { clearInterval(timer) - } + }) }) async function reconnectClient() { editingMode.value = JSON.parse(JSON.stringify(loadMode())); @@ -262,6 +285,12 @@ const setting_menu_items: Ref = ref([ command: openModeDialog, visible: () => type() !== 'android', }, + { + label: () => `${t('config-server.title')}${t('config-server.' + configServerConnectionStatus.value)}`, + icon: 'pi pi-globe', + command: openConfigServerDialog, + visible: () => ["normal", "service"].includes(currentMode.value.mode), + }, { key: 'logging_menu', label: () => t('logging'), @@ -286,7 +315,6 @@ const setting_menu_items: Ref = ref([ async function connectRpcClient(url?: string) { await initRpcConnection(url) - await sendConfigs() console.log("easytier rpc connection established") } @@ -300,9 +328,66 @@ onMounted(async () => { } } const unlisten = await listenGlobalEvents() - return () => { + + onUnmounted(() => { unlisten() + }) +}) + +async function openConfigServerDialog() { + editingMode.value = JSON.parse(JSON.stringify(loadMode())) + configServerDialogVisible.value = true +} +async function onConfigServerSave() { + if (JSON.stringify(currentMode.value) === JSON.stringify(editingMode.value)) { + configServerDialogVisible.value = false + return; } + if (editingMode.value.mode === 'service') { + await new Promise((resolve, reject) => { + confirm.require({ + message: t('config-server.update_service_confirm'), + icon: 'pi pi-exclamation-triangle', + rejectProps: { + label: t('web.common.cancel'), + severity: 'secondary', + outlined: true + }, + acceptProps: { + label: t('web.common.confirm'), + }, + accept: async () => { + resolve() + }, + reject: () => { + reject() + } + }); + }) + } + console.log("Saving config server url", (editingMode.value as WebClientConfig).config_server_url) + await onModeSave(); + configServerDialogVisible.value = false +} +onMounted(() => { + const timer = setInterval(async () => { + if (currentMode.value.mode !== 'normal') return; + if (!currentMode.value.config_server_url) return; + configServerConnected.value = await isWebClientConnected(); + }, 1000) + + onUnmounted(() => { + clearInterval(timer) + }) +}) +const configServerConnectionStatus = computed(() => { + if (currentMode.value.mode !== 'normal') { + return 'unknown' + } + if (!currentMode.value.config_server_url) { + return 'disconnected' + } + return configServerConnected.value ? 'connected' : 'connecting' }) @@ -319,10 +404,26 @@ onMounted(async () => {