feat(gui): add service and remote mode support (#1578)

This PR fundamentally restructures the EasyTier GUI, introducing support for service mode and remote mode, transforming it from a simple desktop application into a powerful network management terminal. This change allows users to persistently run the EasyTier core as a background service or remotely manage multiple EasyTier instances, greatly improving deployment flexibility and manageability.
This commit is contained in:
Mg Pig
2025-11-25 13:59:27 +08:00
committed by GitHub
parent b44053f496
commit 1f2517c731
29 changed files with 2921 additions and 2097 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ tauri-plugin-clipboard-manager = "2.3.0"
tauri-plugin-positioner = { version = "2.3.0", features = ["tray-icon"] }
tauri-plugin-vpnservice = { path = "../../tauri-plugin-vpnservice" }
tauri-plugin-os = "2.3.0"
tauri-plugin-autostart = "2.5.0"
uuid = "1.17.0"
async-trait = "0.1.89"
@@ -45,10 +45,6 @@
"os:allow-arch",
"os:allow-hostname",
"os:allow-platform",
"os:allow-locale",
"autostart:default",
"autostart:allow-disable",
"autostart:allow-enable",
"autostart:allow-is-enabled"
"os:allow-locale"
]
}
+388 -158
View File
@@ -3,6 +3,7 @@
mod elevate;
use anyhow::Context;
use easytier::proto::api::manage::{
CollectNetworkInfoResponse, ValidateConfigResponse, WebClientService,
WebClientServiceClientFactory,
@@ -17,10 +18,11 @@ use easytier::{
launcher::NetworkConfig,
rpc_service::ApiRpcServer,
tunnel::ring::RingTunnelListener,
utils::{self, NewFilterSender},
utils::{self},
};
use std::ops::Deref;
use std::sync::Arc;
use tokio::sync::{RwLock, RwLockReadGuard};
use uuid::Uuid;
use tauri::{AppHandle, Emitter, Manager as _};
@@ -28,19 +30,27 @@ use tauri::{AppHandle, Emitter, Manager as _};
#[cfg(not(target_os = "android"))]
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
pub const AUTOSTART_ARG: &str = "--autostart";
static INSTANCE_MANAGER: once_cell::sync::Lazy<Arc<NetworkInstanceManager>> =
once_cell::sync::Lazy::new(|| Arc::new(NetworkInstanceManager::new()));
static mut LOGGER_LEVEL_SENDER: once_cell::sync::Lazy<Option<NewFilterSender>> =
once_cell::sync::Lazy::new(Default::default);
static INSTANCE_MANAGER: once_cell::sync::Lazy<RwLock<Option<Arc<NetworkInstanceManager>>>> =
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::OnceCell<manager::GUIClientManager> =
once_cell::sync::OnceCell::new();
static CLIENT_MANAGER: once_cell::sync::Lazy<RwLock<Option<manager::GUIClientManager>>> =
once_cell::sync::Lazy::new(|| RwLock::new(None));
static RING_RPC_SERVER: once_cell::sync::Lazy<RwLock<Option<ApiRpcServer<RingTunnelListener>>>> =
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> {
@@ -88,19 +98,17 @@ async fn run_network_instance(
app.emit("pre_run_network_instance", cfg.instance_id())
.map_err(|e| e.to_string())?;
let client_manager = get_client_manager!()?;
#[cfg(target_os = "android")]
if cfg.no_tun() == false {
CLIENT_MANAGER
.get()
.unwrap()
client_manager
.disable_instances_with_tun(&app)
.await
.map_err(|e| e.to_string())?;
}
CLIENT_MANAGER
.get()
.unwrap()
client_manager
.handle_run_network_instance(app.clone(), cfg, save)
.await
.map_err(|e| e.to_string())?;
@@ -118,31 +126,32 @@ async fn collect_network_info(
let instance_id = instance_id
.parse()
.map_err(|e: uuid::Error| e.to_string())?;
CLIENT_MANAGER
.get()
.unwrap()
get_client_manager!()?
.handle_collect_network_info(app, Some(vec![instance_id]))
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
fn set_logging_level(level: String) -> Result<(), String> {
#[allow(static_mut_refs)]
let sender = unsafe { LOGGER_LEVEL_SENDER.as_ref().unwrap() };
sender.send(level).map_err(|e| e.to_string())?;
async fn set_logging_level(level: String) -> Result<(), String> {
println!("Setting logging level to: {}", level);
get_client_manager!()?
.set_logging_level(level.clone())
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
fn set_tun_fd(fd: i32) -> Result<(), String> {
if let Some(uuid) = CLIENT_MANAGER
.get()
.unwrap()
async fn set_tun_fd(fd: i32) -> Result<(), String> {
let Some(instance_manager) = INSTANCE_MANAGER.read().await.clone() else {
return Err("set_tun_fd is not supported in remote mode".to_string());
};
if let Some(uuid) = get_client_manager!()?
.get_enabled_instances_with_tun_ids()
.next()
{
INSTANCE_MANAGER
instance_manager
.set_tun_fd(&uuid, fd)
.map_err(|e| e.to_string())?;
}
@@ -153,9 +162,7 @@ fn set_tun_fd(fd: i32) -> Result<(), String> {
async fn list_network_instance_ids(
app: AppHandle,
) -> Result<ListNetworkInstanceIdsJsonResp, String> {
CLIENT_MANAGER
.get()
.unwrap()
get_client_manager!()?
.handle_list_network_instance_ids(app)
.await
.map_err(|e| e.to_string())
@@ -166,16 +173,12 @@ async fn remove_network_instance(app: AppHandle, instance_id: String) -> Result<
let instance_id = instance_id
.parse()
.map_err(|e: uuid::Error| e.to_string())?;
CLIENT_MANAGER
.get()
.unwrap()
let client_manager = get_client_manager!()?;
client_manager
.handle_remove_network_instances(app.clone(), vec![instance_id])
.await
.map_err(|e| e.to_string())?;
CLIENT_MANAGER
.get()
.unwrap()
.notify_vpn_stop_if_no_tun(&app)?;
client_manager.notify_vpn_stop_if_no_tun(&app)?;
Ok(())
}
@@ -188,17 +191,13 @@ async fn update_network_config_state(
let instance_id = instance_id
.parse()
.map_err(|e: uuid::Error| e.to_string())?;
CLIENT_MANAGER
.get()
.unwrap()
let client_manager = get_client_manager!()?;
client_manager
.handle_update_network_state(app.clone(), instance_id, disabled)
.await
.map_err(|e| e.to_string())?;
if disabled {
CLIENT_MANAGER
.get()
.unwrap()
.notify_vpn_stop_if_no_tun(&app)?;
client_manager.notify_vpn_stop_if_no_tun(&app)?;
}
Ok(())
}
@@ -209,9 +208,7 @@ async fn save_network_config(app: AppHandle, cfg: NetworkConfig) -> Result<(), S
.instance_id()
.parse()
.map_err(|e: uuid::Error| e.to_string())?;
CLIENT_MANAGER
.get()
.unwrap()
get_client_manager!()?
.handle_save_network_config(app, instance_id, cfg)
.await
.map_err(|e| e.to_string())
@@ -222,9 +219,7 @@ async fn validate_config(
app: AppHandle,
config: NetworkConfig,
) -> Result<ValidateConfigResponse, String> {
CLIENT_MANAGER
.get()
.unwrap()
get_client_manager!()?
.handle_validate_config(app, config)
.await
.map_err(|e| e.to_string())
@@ -232,9 +227,7 @@ async fn validate_config(
#[tauri::command]
async fn get_config(app: AppHandle, instance_id: String) -> Result<NetworkConfig, String> {
let cfg = CLIENT_MANAGER
.get()
.unwrap()
let cfg = get_client_manager!()?
.storage
.get_network_config(app, &instance_id)
.await
@@ -249,10 +242,7 @@ async fn load_configs(
configs: Vec<NetworkConfig>,
enabled_networks: Vec<String>,
) -> Result<(), String> {
CLIENT_MANAGER
.get()
.unwrap()
.storage
get_client_manager!()?
.load_configs(app, configs, enabled_networks)
.await
.map_err(|e| e.to_string())?;
@@ -264,14 +254,143 @@ async fn get_network_metas(
app: AppHandle,
instance_ids: Vec<uuid::Uuid>,
) -> Result<GetNetworkMetasResponse, String> {
CLIENT_MANAGER
.get()
.unwrap()
get_client_manager!()?
.handle_get_network_metas(app, instance_ids)
.await
.map_err(|e| e.to_string())
}
#[cfg(target_os = "android")]
#[tauri::command]
fn init_service() -> Result<(), String> {
Ok(())
}
#[cfg(not(target_os = "android"))]
#[tauri::command]
fn init_service(opts: Option<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")
}
}
#[tauri::command]
async fn init_rpc_connection(_app: AppHandle, 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 ring_rpc_server_guard = RING_RPC_SERVER
.try_write()
.map_err(|_| "Failed to acquire write lock for ring rpc server")?;
let normal_mode = url.is_none();
if normal_mode {
let instance_manager = if let Some(im) = instance_manager_guard.take() {
im
} else {
Arc::new(NetworkInstanceManager::new())
};
let rpc_server = if let Some(rpc_server) = ring_rpc_server_guard.take() {
rpc_server
} else {
ApiRpcServer::from_tunnel(
RingTunnelListener::new(
format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap(),
),
instance_manager.clone(),
)
.with_rx_timeout(None)
.serve()
.await
.map_err(|e| e.to_string())?
};
*instance_manager_guard = Some(instance_manager);
*ring_rpc_server_guard = Some(rpc_server);
} else {
*ring_rpc_server_guard = None;
}
let mut client_manager = tokio::time::timeout(
std::time::Duration::from_millis(1000),
manager::GUIClientManager::new(url),
)
.await
.map_err(|_| "connect remote rpc timed out".to_string())?
.with_context(|| "Failed to connect remote rpc")
.map_err(|e| format!("{:#}", e))?;
if let Some(old_manager) = client_manager_guard.take() {
client_manager.storage = old_manager.storage;
}
*client_manager_guard = Some(client_manager);
if !normal_mode {
if let Some(instance_manager) = instance_manager_guard.take() {
instance_manager
.retain_network_instance(vec![])
.map_err(|e| e.to_string())?;
drop(instance_manager);
}
}
Ok(())
}
#[tauri::command]
async fn is_client_running() -> Result<bool, String> {
Ok(get_client_manager!()?.rpc_manager.is_running())
}
#[cfg(not(target_os = "android"))]
fn toggle_window_visibility<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
if let Some(window) = app.get_webview_window("main") {
@@ -289,19 +408,23 @@ fn toggle_window_visibility<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
}
}
fn get_exe_path() -> String {
if let Ok(appimage_path) = std::env::var("APPIMAGE") {
if !appimage_path.is_empty() {
return appimage_path;
}
}
std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default()
}
#[cfg(not(target_os = "android"))]
fn check_sudo() -> bool {
let is_elevated = elevate::Command::is_elevated();
if !is_elevated {
let exe_path = std::env::var("APPIMAGE")
.ok()
.or_else(|| std::env::args().next())
.unwrap_or_default();
let args: Vec<String> = std::env::args().collect();
let mut stdcmd = std::process::Command::new(&exe_path);
if args.contains(&AUTOSTART_ARG.to_owned()) {
stdcmd.arg(AUTOSTART_ARG);
}
let exe_path = get_exe_path();
let stdcmd = std::process::Command::new(&exe_path);
elevate::Command::new(stdcmd)
.output()
.expect("Failed to run elevated command");
@@ -313,10 +436,15 @@ mod manager {
use super::*;
use async_trait::async_trait;
use dashmap::{DashMap, DashSet};
use easytier::common::global_ctx::GlobalCtx;
use easytier::common::stun::MockStunInfoCollector;
use easytier::launcher::NetworkConfig;
use easytier::proto::api::manage::RunNetworkInstanceRequest;
use easytier::proto::api::logger::{LoggerRpc, LoggerRpcClientFactory, SetLoggerConfigRequest};
use easytier::proto::api::manage::{ListNetworkInstanceRequest, RunNetworkInstanceRequest};
use easytier::proto::common::NatType;
use easytier::proto::rpc_impl::bidirect::BidirectRpcManager;
use easytier::proto::rpc_types::controller::BaseController;
use easytier::rpc_service::logger::LoggerRpcService;
use easytier::rpc_service::remote_client::PersistentConfig;
use easytier::tunnel::ring::RingTunnelConnector;
use easytier::tunnel::TunnelConnector;
@@ -344,54 +472,6 @@ mod manager {
}
}
pub(super) async fn load_configs(
&self,
app: AppHandle,
configs: Vec<NetworkConfig>,
enabled_networks: Vec<String>,
) -> anyhow::Result<()> {
self.network_configs.clear();
for cfg in configs {
let instance_id = cfg.instance_id();
self.network_configs.insert(
instance_id.parse()?,
GUIConfig(instance_id.to_string(), cfg),
);
}
self.enabled_networks.clear();
INSTANCE_MANAGER.iter().for_each(|v| {
self.enabled_networks.insert(*v.key());
});
for id in enabled_networks {
if let Ok(uuid) = id.parse() {
if !self.enabled_networks.contains(&uuid) {
let config = self
.network_configs
.get(&uuid)
.map(|i| i.value().1.clone())
.ok_or_else(|| anyhow::anyhow!("Config not found"))?;
CLIENT_MANAGER
.get()
.unwrap()
.get_rpc_client(app.clone())
.ok_or_else(|| anyhow::anyhow!("RPC client not found"))?
.run_network_instance(
BaseController::default(),
RunNetworkInstanceRequest {
inst_id: None,
config: Some(config),
overwrite: false,
},
)
.await?;
self.enabled_networks.insert(uuid);
}
}
}
Ok(())
}
fn save_configs(&self, app: &AppHandle) -> anyhow::Result<()> {
let configs: Result<Vec<String>, _> = self
.network_configs
@@ -507,14 +587,32 @@ mod manager {
pub(super) struct GUIClientManager {
pub(super) storage: GUIStorage,
rpc_manager: BidirectRpcManager,
pub(super) rpc_manager: BidirectRpcManager,
}
impl GUIClientManager {
pub async fn new() -> Result<Self, anyhow::Error> {
let mut connector = RingTunnelConnector::new(
format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap(),
);
let tunnel = connector.connect().await?;
pub async fn new(rpc_url: Option<String>) -> Result<Self, anyhow::Error> {
let global_ctx = Arc::new(GlobalCtx::new(TomlConfigLoader::default()));
global_ctx.replace_stun_info_collector(Box::new(MockStunInfoCollector {
udp_nat_type: NatType::Unknown,
}));
let mut flags = global_ctx.get_flags();
flags.bind_device = false;
global_ctx.set_flags(flags);
let tunnel = if let Some(url) = rpc_url {
let mut connector = easytier::connector::create_connector_by_url(
&url,
&global_ctx,
easytier::tunnel::IpVersion::Both,
)
.await?;
connector.connect().await?
} else {
let mut connector = RingTunnelConnector::new(
format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap(),
);
connector.connect().await?
};
let rpc_manager = BidirectRpcManager::new();
rpc_manager.run_with_tunnel(tunnel);
@@ -554,6 +652,84 @@ mod manager {
}
Ok(())
}
fn get_logger_rpc_client(
&self,
) -> Option<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: LoggerRpcService::string_to_log_level(&level).into(),
},
)
.await?;
Ok(())
}
pub(super) async fn load_configs(
&self,
app: AppHandle,
configs: Vec<NetworkConfig>,
enabled_networks: Vec<String>,
) -> anyhow::Result<()> {
self.storage.network_configs.clear();
for cfg in configs {
let instance_id = cfg.instance_id();
self.storage.network_configs.insert(
instance_id.parse()?,
GUIConfig(instance_id.to_string(), cfg),
);
}
self.storage.enabled_networks.clear();
let client = self
.get_rpc_client(app.clone())
.ok_or_else(|| anyhow::anyhow!("RPC client not found"))?;
let running_instances = client
.list_network_instance(BaseController::default(), ListNetworkInstanceRequest {})
.await?;
for id in running_instances.inst_ids {
self.storage.enabled_networks.insert(id.into());
}
for id in enabled_networks {
if let Ok(uuid) = id.parse() {
if !self.storage.enabled_networks.contains(&uuid) {
let config = self
.storage
.network_configs
.get(&uuid)
.map(|i| i.value().1.clone());
if config.is_none() {
continue;
}
client
.run_network_instance(
BaseController::default(),
RunNetworkInstanceRequest {
inst_id: None,
config,
overwrite: false,
},
)
.await?;
self.storage.enabled_networks.insert(uuid);
}
}
}
Ok(())
}
}
impl RemoteClientManager<AppHandle, GUIConfig, anyhow::Error> for GUIClientManager {
fn get_rpc_client(
@@ -577,8 +753,78 @@ mod manager {
}
}
#[cfg(not(target_os = "android"))]
mod service {
use anyhow::Context;
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct ServiceOptions {
pub(super) config_dir: String,
pub(super) rpc_portal: String,
pub(super) file_log_level: String,
pub(super) file_log_dir: String,
}
impl ServiceOptions {
fn to_args_vec(&self) -> Vec<std::ffi::OsString> {
vec![
"--config-dir".into(),
self.config_dir.clone().into(),
"--rpc-portal".into(),
self.rpc_portal.clone().into(),
"--file-log-level".into(),
self.file_log_level.clone().into(),
"--file-log-dir".into(),
self.file_log_dir.clone().into(),
"--daemon".into(),
]
}
}
pub fn install(opts: ServiceOptions) -> anyhow::Result<()> {
let service = easytier::service_manager::Service::new(env!("CARGO_PKG_NAME").to_string())?;
let options = easytier::service_manager::ServiceInstallOptions {
program: super::get_exe_path().into(),
args: opts.to_args_vec(),
work_directory: std::env::current_dir()?,
disable_autostart: false,
description: Some("EasyTier Gui Service".to_string()),
display_name: Some("EasyTier Gui Service".to_string()),
disable_restart_on_failure: false,
};
service
.install(&options)
.with_context(|| "Failed to install service")?;
Ok(())
}
pub fn uninstall() -> anyhow::Result<()> {
let service = easytier::service_manager::Service::new(env!("CARGO_PKG_NAME").to_string())?;
service.uninstall()?;
Ok(())
}
pub fn set_status(enable: bool) -> anyhow::Result<()> {
use easytier::service_manager::*;
let service = Service::new(env!("CARGO_PKG_NAME").to_string())?;
let status = service.status()?;
if enable && status != ServiceStatus::Running {
service.start().with_context(|| "Failed to start service")?;
} else if !enable && status == ServiceStatus::Running {
service.stop().with_context(|| "Failed to stop service")?;
} else if status == ServiceStatus::NotInstalled {
return Err(anyhow::anyhow!("Service not installed"));
}
Ok(())
}
pub fn status() -> anyhow::Result<easytier::service_manager::ServiceStatus> {
let service = easytier::service_manager::Service::new(env!("CARGO_PKG_NAME").to_string())?;
service.status()
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
pub fn run_gui() -> std::process::ExitCode {
#[cfg(not(target_os = "android"))]
if !check_sudo() {
use std::process;
@@ -587,35 +833,8 @@ pub fn run() {
utils::setup_panic_handler();
let _rpc_server_handle = tauri::async_runtime::spawn(async move {
let rpc_server = ApiRpcServer::from_tunnel(
RingTunnelListener::new(format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap()),
INSTANCE_MANAGER.clone(),
)
.serve()
.await
.expect("Failed to start RPC server");
let _ = CLIENT_MANAGER.set(
manager::GUIClientManager::new()
.await
.expect("Failed to create GUI client manager"),
);
rpc_server
});
let mut builder = tauri::Builder::default();
#[cfg(not(target_os = "android"))]
{
use tauri_plugin_autostart::MacosLauncher;
builder = builder.plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
Some(vec![AUTOSTART_ARG]),
));
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
@@ -651,13 +870,9 @@ pub fn run() {
})
.build()
.map_err(|e| e.to_string())?;
let Ok(Some(logger_reinit)) = utils::init_logger(&config, true) else {
let Ok(_) = utils::init_logger(&config, true) else {
return Ok(());
};
#[allow(static_mut_refs)]
unsafe {
LOGGER_LEVEL_SENDER.replace(logger_reinit)
};
// for tray icon, menu need to be built in js
#[cfg(not(target_os = "android"))]
@@ -699,6 +914,11 @@ pub fn run() {
get_config,
load_configs,
get_network_metas,
init_service,
set_service_status,
get_service_status,
init_rpc_connection,
is_client_running,
])
.on_window_event(|_win, event| match event {
#[cfg(not(target_os = "android"))]
@@ -724,4 +944,14 @@ pub fn run() {
_ => {}
});
}
std::process::ExitCode::SUCCESS
}
pub fn run_cli() -> std::process::ExitCode {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async { easytier::core::main().await })
}
+6 -2
View File
@@ -1,5 +1,9 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
fn main() -> std::process::ExitCode {
if std::env::args().count() > 1 {
app_lib::run_cli()
} else {
app_lib::run_gui()
}
}
+14
View File
@@ -28,15 +28,20 @@ declare global {
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getEasytierVersion: typeof import('./composables/backend')['getEasytierVersion']
const getNetworkMetas: typeof import('./composables/backend')['getNetworkMetas']
const getServiceStatus: typeof import('./composables/backend')['getServiceStatus']
const h: typeof import('vue')['h']
const initMobileVpnService: typeof import('./composables/mobile_vpn')['initMobileVpnService']
const initRpcConnection: typeof import('./composables/backend')['initRpcConnection']
const initService: typeof import('./composables/backend')['initService']
const inject: typeof import('vue')['inject']
const isClientRunning: typeof import('./composables/backend')['isClientRunning']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const listNetworkInstanceIds: typeof import('./composables/backend')['listNetworkInstanceIds']
const listenGlobalEvents: typeof import('./composables/event')['listenGlobalEvents']
const loadMode: typeof import('./composables/mode')['loadMode']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
@@ -69,11 +74,13 @@ declare global {
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const runNetworkInstance: typeof import('./composables/backend')['runNetworkInstance']
const saveMode: typeof import('./composables/mode')['saveMode']
const saveNetworkConfig: typeof import('./composables/backend')['saveNetworkConfig']
const sendConfigs: typeof import('./composables/backend')['sendConfigs']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setLoggingLevel: typeof import('./composables/backend')['setLoggingLevel']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const setServiceStatus: typeof import('./composables/backend')['setServiceStatus']
const setTrayMenu: typeof import('./composables/tray')['setTrayMenu']
const setTrayRunState: typeof import('./composables/tray')['setTrayRunState']
const setTrayTooltip: typeof import('./composables/tray')['setTrayTooltip']
@@ -141,15 +148,20 @@ declare module 'vue' {
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly getEasytierVersion: UnwrapRef<typeof import('./composables/backend')['getEasytierVersion']>
readonly getNetworkMetas: UnwrapRef<typeof import('./composables/backend')['getNetworkMetas']>
readonly getServiceStatus: UnwrapRef<typeof import('./composables/backend')['getServiceStatus']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly initMobileVpnService: UnwrapRef<typeof import('./composables/mobile_vpn')['initMobileVpnService']>
readonly initRpcConnection: UnwrapRef<typeof import('./composables/backend')['initRpcConnection']>
readonly initService: UnwrapRef<typeof import('./composables/backend')['initService']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly isClientRunning: UnwrapRef<typeof import('./composables/backend')['isClientRunning']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly listNetworkInstanceIds: UnwrapRef<typeof import('./composables/backend')['listNetworkInstanceIds']>
readonly listenGlobalEvents: UnwrapRef<typeof import('./composables/event')['listenGlobalEvents']>
readonly loadMode: UnwrapRef<typeof import('./composables/mode')['loadMode']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
@@ -182,11 +194,13 @@ declare module 'vue' {
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly runNetworkInstance: UnwrapRef<typeof import('./composables/backend')['runNetworkInstance']>
readonly saveMode: UnwrapRef<typeof import('./composables/mode')['saveMode']>
readonly saveNetworkConfig: UnwrapRef<typeof import('./composables/backend')['saveNetworkConfig']>
readonly sendConfigs: UnwrapRef<typeof import('./composables/backend')['sendConfigs']>
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
readonly setLoggingLevel: UnwrapRef<typeof import('./composables/backend')['setLoggingLevel']>
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
readonly setServiceStatus: UnwrapRef<typeof import('./composables/backend')['setServiceStatus']>
readonly setTrayMenu: UnwrapRef<typeof import('./composables/tray')['setTrayMenu']>
readonly setTrayRunState: UnwrapRef<typeof import('./composables/tray')['setTrayRunState']>
readonly setTrayTooltip: UnwrapRef<typeof import('./composables/tray')['setTrayTooltip']>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { computed, watch, onMounted, ref } from 'vue';
import type { Mode, ServiceMode, RemoteMode } from '~/composables/mode';
import { appConfigDir, appLogDir } from '@tauri-apps/api/path';
import { join } from '@tauri-apps/api/path';
import { getServiceStatus, type ServiceStatus } from '~/composables/backend';
const { t } = useI18n()
const model = defineModel<Mode>({ required: true })
const emit = defineEmits(['uninstall-service', 'stop-service'])
const defaultConfigDir = ref('')
const defaultLogDir = ref('')
const serviceStatus = ref<ServiceStatus>('NotInstalled')
const isServiceStatusLoaded = ref(false)
onMounted(async () => {
defaultConfigDir.value = await join(await appConfigDir(), 'config.d')
defaultLogDir.value = await appLogDir()
})
const modeOptions = computed(() => [
{ label: t('mode.normal'), value: 'normal' },
{ label: t('mode.service'), value: 'service' },
{ label: t('mode.remote'), value: 'remote' },
]);
const serviceMode = computed({
get: () => model.value.mode === 'service' ? model.value as ServiceMode : undefined,
set: (value) => {
if (value) {
model.value = value
}
}
})
const remoteMode = computed({
get: () => model.value.mode === 'remote' ? model.value as RemoteMode : undefined,
set: (value) => {
if (value) {
model.value = value
}
}
})
const statusColorClass = computed(() => {
switch (serviceStatus.value) {
case 'Running':
return 'text-green-600'
case 'Stopped':
return 'text-orange-600'
case 'NotInstalled':
return 'text-gray-600'
default:
return 'text-gray-600'
}
})
watch(() => model.value.mode, async (newMode, oldMode) => {
if (newMode === oldMode)
return
if (newMode === 'service' && !isServiceStatusLoaded.value) {
serviceStatus.value = await getServiceStatus()
isServiceStatusLoaded.value = true
}
const oldModelValue = { ...model.value }
if (newMode === 'normal') {
model.value = {
...oldModelValue,
mode: 'normal',
}
}
else if (newMode === 'service') {
model.value = {
...oldModelValue,
mode: 'service',
config_dir: serviceMode.value?.config_dir || defaultConfigDir.value,
rpc_portal: serviceMode.value?.rpc_portal || '127.0.0.1:15999',
file_log_level: serviceMode.value?.file_log_level || 'off',
file_log_dir: serviceMode.value?.file_log_dir || defaultLogDir.value,
}
}
else if (newMode === 'remote') {
model.value = {
...oldModelValue,
mode: 'remote',
remote_rpc_address: remoteMode.value?.remote_rpc_address || 'tcp://127.0.0.1:15999',
}
}
}, { immediate: true })
</script>
<template>
<div class="flex flex-col gap-4">
<div>
<SelectButton id="mode-select" v-model="model.mode" :options="modeOptions" option-label="label"
option-value="value" fluid />
</div>
<!-- Mode descriptions -->
<div v-if="model.mode === 'normal'" class="text-sm text-gray-500">
{{ t('mode.normal_description') }}
</div>
<div v-else-if="model.mode === 'service'" class="text-sm text-gray-500">
{{ t('mode.service_description') }}
</div>
<div v-else-if="model.mode === 'remote'" class="text-sm text-gray-500">
{{ t('mode.remote_description') }}
</div>
<div v-if="serviceMode" class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label for="config-dir">{{ t('mode.config_dir') }}</label>
<InputText id="config-dir" v-model="serviceMode.config_dir" class="flex-1" />
</div>
<div class="flex items-center gap-2">
<label for="rpc-portal">{{ t('mode.rpc_portal') }}</label>
<InputText id="rpc-portal" v-model="serviceMode.rpc_portal" class="flex-1" />
</div>
<div class="flex items-center gap-2">
<label for="log-level">{{ t('mode.log_level') }}</label>
<Select id="log-level" v-model="serviceMode.file_log_level"
:options="['off', 'warn', 'info', 'debug', 'trace']" />
</div>
<div class="flex items-center gap-2">
<label for="log-dir">{{ t('mode.log_dir') }}</label>
<InputText id="log-dir" v-model="serviceMode.file_log_dir" class="flex-1" />
</div>
<div class="flex items-center gap-2 justify-between">
<div class="flex items-center gap-2">
<label>{{ t('mode.service_status') }}</label>
<span :class="statusColorClass">{{ t(`mode.service_status_${serviceStatus.toLowerCase()}`) }}</span>
</div>
<div class="flex items-center gap-2">
<Button :label="t('mode.stop_service')" icon="pi pi-stop-circle" v-if="serviceStatus === 'Running'"
@click="emit('stop-service')" severity="warn" text />
<Button :label="t('mode.uninstall_service')" icon="pi pi-trash" v-if="serviceStatus !== 'NotInstalled'"
@click="emit('uninstall-service')" severity="danger" text />
</div>
</div>
</div>
<div v-if="remoteMode" class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label for="remote-addr">{{ t('mode.remote_rpc_address') }}</label>
<InputText id="remote-addr" v-model="remoteMode.remote_rpc_address" class="flex-1" />
</div>
</div>
</div>
</template>
+30 -3
View File
@@ -1,11 +1,19 @@
import { invoke } from '@tauri-apps/api/core'
import { Api, type NetworkTypes } from 'easytier-frontend-lib'
import { GetNetworkMetasResponse } from 'node_modules/easytier-frontend-lib/dist/modules/api'
import { getAutoLaunchStatusAsync } from '~/modules/auto_launch'
type NetworkConfig = NetworkTypes.NetworkConfig
type ValidateConfigResponse = Api.ValidateConfigResponse
type ListNetworkInstanceIdResponse = Api.ListNetworkInstanceIdResponse
interface ServiceOptions {
config_dir: string
rpc_portal: string
file_log_level: string
file_log_dir: string
}
export type ServiceStatus = "Running" | "Stopped" | "NotInstalled"
export async function parseNetworkConfig(cfg: NetworkConfig) {
return invoke<string>('parse_network_config', { cfg })
@@ -61,10 +69,29 @@ export async function getConfig(instanceId: string) {
export async function sendConfigs() {
let networkList: NetworkConfig[] = JSON.parse(localStorage.getItem('networkList') || '[]');
let autoStartInstIds = getAutoLaunchStatusAsync() ? JSON.parse(localStorage.getItem('autoStartInstIds') || '[]') : []
return await invoke('load_configs', { configs: networkList, enabledNetworks: autoStartInstIds })
return await invoke('load_configs', { configs: networkList, enabledNetworks: [] })
}
export async function getNetworkMetas(instanceIds: string[]) {
return await invoke<GetNetworkMetasResponse>('get_network_metas', { instanceIds })
}
export async function initService(opts?: ServiceOptions) {
return await invoke('init_service', { opts })
}
export async function setServiceStatus(enable: boolean) {
return await invoke('set_service_status', { enable })
}
export async function getServiceStatus() {
return await invoke<ServiceStatus>('get_service_status')
}
export async function initRpcConnection(url?: string) {
return await invoke('init_rpc_connection', { url })
}
export async function isClientRunning() {
return await invoke<boolean>('is_client_running')
}
-7
View File
@@ -4,7 +4,6 @@ import { NetworkTypes } from "easytier-frontend-lib"
const EVENTS = Object.freeze({
SAVE_CONFIGS: 'save_configs',
SAVE_ENABLED_NETWORKS: 'save_enabled_networks',
PRE_RUN_NETWORK_INSTANCE: 'pre_run_network_instance',
POST_RUN_NETWORK_INSTANCE: 'post_run_network_instance',
VPN_SERVICE_STOP: 'vpn_service_stop',
@@ -15,11 +14,6 @@ function onSaveConfigs(event: Event<NetworkTypes.NetworkConfig[]>) {
localStorage.setItem('networkList', JSON.stringify(event.payload));
}
function onSaveEnabledNetworks(event: Event<string[]>) {
console.log(`Received event '${EVENTS.SAVE_ENABLED_NETWORKS}': ${event.payload}`);
localStorage.setItem('autoStartInstIds', JSON.stringify(event.payload));
}
async function onPreRunNetworkInstance(event: Event<string>) {
if (type() === 'android') {
await prepareVpnService(event.payload);
@@ -39,7 +33,6 @@ async function onVpnServiceStop(event: Event<string>) {
export async function listenGlobalEvents() {
const unlisteners = [
await listen(EVENTS.SAVE_CONFIGS, onSaveConfigs),
await listen(EVENTS.SAVE_ENABLED_NETWORKS, onSaveEnabledNetworks),
await listen(EVENTS.PRE_RUN_NETWORK_INSTANCE, onPreRunNetworkInstance),
await listen(EVENTS.POST_RUN_NETWORK_INSTANCE, onPostRunNetworkInstance),
await listen(EVENTS.VPN_SERVICE_STOP, onVpnServiceStop),
+36
View File
@@ -0,0 +1,36 @@
interface NormalMode {
mode: 'normal'
}
export interface ServiceMode {
mode: 'service'
config_dir: string
rpc_portal: string
file_log_level: 'off' | 'warn' | 'info' | 'debug' | 'trace'
file_log_dir: string
}
export interface RemoteMode {
mode: 'remote'
remote_rpc_address: string
}
export function saveMode(mode: Mode) {
localStorage.setItem('app_mode', JSON.stringify(mode))
}
import { type } from '@tauri-apps/plugin-os';
export function loadMode(): Mode {
if (type() === 'android') {
return { mode: 'normal' };
}
const modeStr = localStorage.getItem('app_mode')
if (modeStr) {
return JSON.parse(modeStr) as Mode
} else {
return { mode: 'normal' }
}
}
export type Mode = NormalMode | ServiceMode | RemoteMode
+1 -2
View File
@@ -9,7 +9,7 @@ import App from '~/App.vue';
import 'easytier-frontend-lib/style.css';
import { ConfirmationService, DialogService, ToastService } from 'primevue';
import '~/styles.css';
import { getAutoLaunchStatusAsync, loadAutoLaunchStatusAsync } from './modules/auto_launch';
if (import.meta.env.PROD) {
document.addEventListener('keydown', (event) => {
@@ -29,7 +29,6 @@ if (import.meta.env.PROD) {
async function main() {
await I18nUtils.loadLanguageAsync(localStorage.getItem('lang') || 'en')
await loadAutoLaunchStatusAsync(getAutoLaunchStatusAsync())
const app = createApp(App)
-26
View File
@@ -1,26 +0,0 @@
import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart'
export async function loadAutoLaunchStatusAsync(target_enable: boolean): Promise<boolean> {
try {
if (target_enable) {
await enable()
}
else {
// 消除没有配置自启动时进行关闭操作报错
try {
await disable()
}
catch { }
}
localStorage.setItem('auto_launch', JSON.stringify(await isEnabled()))
return isEnabled()
}
catch (e) {
console.error(e)
return false
}
}
export function getAutoLaunchStatusAsync(): boolean {
return localStorage.getItem('auto_launch') === 'true'
}
+204 -8
View File
@@ -9,16 +9,193 @@ import { I18nUtils, RemoteManagement } from "easytier-frontend-lib"
import type { MenuItem } from 'primevue/menuitem'
import { useTray } from '~/composables/tray'
import { GUIRemoteClient } from '~/modules/api'
import { getAutoLaunchStatusAsync as getAutoLaunchStatus, loadAutoLaunchStatusAsync } from '~/modules/auto_launch'
import { getDockVisibilityStatus, loadDockVisibilityAsync } from '~/modules/dock_visibility'
import { useToast, useConfirm } from 'primevue'
import { loadMode, saveMode, type Mode } from '~/composables/mode'
import ModeSwitcher from '~/components/ModeSwitcher.vue'
import { getServiceStatus, type ServiceStatus } from '~/composables/backend'
const { t, locale } = useI18n()
const confirm = useConfirm()
const aboutVisible = ref(false)
const modeDialogVisible = ref(false)
const currentMode = ref<Mode>({ mode: 'normal' })
const editingMode = ref<Mode>({ mode: 'normal' })
const isModeSaving = ref(false)
const serviceStatus = ref<ServiceStatus>('NotInstalled')
async function openModeDialog() {
editingMode.value = JSON.parse(JSON.stringify(loadMode()))
if (editingMode.value.mode === 'service') {
serviceStatus.value = await getServiceStatus()
}
modeDialogVisible.value = true
}
async function onModeSave() {
if (isModeSaving.value) {
return;
}
isModeSaving.value = true
try {
await initWithMode(editingMode.value);
modeDialogVisible.value = false
}
catch (e: any) {
toast.add({ severity: 'error', summary: t('error'), detail: e, life: 10000 })
console.error("Error switching mode", e, currentMode.value, editingMode.value)
await initWithMode(currentMode.value);
}
finally {
isModeSaving.value = false
}
}
async function onUninstallService() {
confirm.require({
message: t('mode.uninstall_service_confirm'),
header: t('mode.uninstall_service'),
icon: 'pi pi-exclamation-triangle',
rejectProps: {
label: t('web.common.cancel'),
severity: 'secondary',
outlined: true
},
acceptProps: {
label: t('mode.uninstall_service'),
severity: 'danger'
},
accept: async () => {
isModeSaving.value = true
try {
await initWithMode({ ...currentMode.value, mode: 'normal' });
await initService(undefined)
toast.add({ severity: 'success', summary: t('web.common.success'), detail: t('mode.uninstall_service_success'), life: 3000 })
modeDialogVisible.value = false
} catch (e: any) {
toast.add({ severity: 'error', summary: t('error'), detail: e, life: 10000 })
console.error("Error uninstalling service", e)
} finally {
isModeSaving.value = false
}
},
});
}
async function onStopService() {
isModeSaving.value = true
try {
await setServiceStatus(false)
toast.add({ severity: 'success', summary: t('web.common.success'), detail: t('mode.stop_service_success'), life: 3000 })
modeDialogVisible.value = false
}
catch (e: any) {
toast.add({ severity: 'error', summary: t('error'), detail: e, life: 10000 })
console.error("Error stopping service", e)
}
finally {
isModeSaving.value = false
}
}
async function initWithMode(mode: Mode) {
if (currentMode.value.mode === 'service' && mode.mode !== 'service') {
let serviceStatus = await getServiceStatus()
if (serviceStatus === "Running") {
await setServiceStatus(false)
serviceStatus = await getServiceStatus()
}
if (serviceStatus === "Stopped") {
await initService(undefined)
}
}
let url: string | undefined = undefined
let retrys = 1
switch (mode.mode) {
case 'remote':
if (!mode.remote_rpc_address) {
toast.add({ severity: 'error', summary: t('error'), detail: t('mode.remote_rpc_address_empty'), life: 10000 })
return initWithMode({ ...mode, mode: 'normal' });
}
url = mode.remote_rpc_address
break;
case 'service':
if (!mode.config_dir || !mode.file_log_dir || !mode.file_log_level || !mode.rpc_portal) {
toast.add({ severity: 'error', summary: t('error'), detail: t('mode.service_config_empty'), life: 10000 })
return initWithMode({ ...mode, mode: 'normal' });
}
let serviceStatus = await getServiceStatus()
if (serviceStatus === "NotInstalled" || JSON.stringify(mode) !== JSON.stringify(currentMode.value)) {
await initService({
config_dir: mode.config_dir,
file_log_dir: mode.file_log_dir,
file_log_level: mode.file_log_level,
rpc_portal: mode.rpc_portal,
})
serviceStatus = await getServiceStatus()
}
if (serviceStatus === "Stopped") {
await setServiceStatus(true)
}
url = "tcp://" + mode.rpc_portal.replace("0.0.0.0", "127.0.0.1")
retrys = 5
break;
}
for (let i = 0; i < retrys; i++) {
try {
await connectRpcClient(url)
break;
} catch (e) {
if (i === retrys - 1) {
throw e;
}
console.error("Error connecting rpc client, retrying...", e)
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
currentMode.value = mode
saveMode(mode)
clientRunning.value = await isClientRunning()
}
onMounted(() => {
currentMode.value = loadMode()
initWithMode(currentMode.value);
});
useTray(true)
let toast = useToast();
const remoteClient = computed(() => new GUIRemoteClient());
const instanceId = ref<string | undefined>(undefined);
const clientRunning = ref(false);
watch(clientRunning, async (newVal, oldVal) => {
if (!newVal && oldVal) {
await reconnectClient()
}
})
onMounted(async () => {
clientRunning.value = await isClientRunning().catch(() => false)
const timer = setInterval(async () => {
try {
clientRunning.value = await isClientRunning()
} catch (e) {
clientRunning.value = false
console.error("Error checking client running status", e)
}
}, 1000)
return () => {
clearInterval(timer)
}
})
async function reconnectClient() {
editingMode.value = JSON.parse(JSON.stringify(loadMode()));
await onModeSave()
}
onMounted(async () => {
window.setTimeout(async () => {
@@ -81,11 +258,10 @@ const setting_menu_items: Ref<MenuItem[]> = ref([
},
},
{
label: () => getAutoLaunchStatus() ? t('disable_auto_launch') : t('enable_auto_launch'),
icon: 'pi pi-desktop',
command: async () => {
await loadAutoLaunchStatusAsync(!getAutoLaunchStatus())
},
label: () => `${t('mode.switch_mode')}: ${t('mode.' + currentMode.value.mode)}`,
icon: 'pi pi-sync',
command: openModeDialog,
visible: () => type() !== 'android',
},
{
label: () => getDockVisibilityStatus() ? t('hide_dock_icon') : t('show_dock_icon'),
@@ -117,6 +293,12 @@ const setting_menu_items: Ref<MenuItem[]> = ref([
},
])
async function connectRpcClient(url?: string) {
await initRpcConnection(url)
await sendConfigs()
console.log("easytier rpc connection established")
}
onMounted(async () => {
if (type() === 'android') {
try {
@@ -127,7 +309,6 @@ onMounted(async () => {
}
}
const unlisten = await listenGlobalEvents()
await sendConfigs()
return () => {
unlisten()
}
@@ -140,9 +321,24 @@ onMounted(async () => {
<Dialog v-model:visible="aboutVisible" modal :header="t('about.title')" :style="{ width: '70%' }">
<About />
</Dialog>
<Dialog v-model:visible="modeDialogVisible" modal :header="t('mode.switch_mode')" :style="{ width: '50vw' }">
<ModeSwitcher v-model="editingMode" @uninstall-service="onUninstallService" @stop-service="onStopService" />
<template #footer>
<Button :label="t('web.common.cancel')" icon="pi pi-times" @click="modeDialogVisible = false" text />
<Button :label="t('web.common.save')" icon="pi pi-save" @click="onModeSave" autofocus :loading="isModeSaving" />
</template>
</Dialog>
<Menu ref="log_menu" :model="log_menu_items_popup" :popup="true" />
<RemoteManagement class="flex-1 overflow-y-auto" :api="remoteClient" v-bind:instance-id="instanceId" />
<RemoteManagement v-if="clientRunning" class="flex-1 overflow-y-auto" :api="remoteClient"
:pause-auto-refresh="isModeSaving" v-bind:instance-id="instanceId" />
<div v-else class="empty-state flex-1 flex flex-col items-center py-12">
<i class="pi pi-server text-5xl text-secondary mb-4 opacity-50"></i>
<div class="text-xl text-center font-medium mb-3">{{ t('client.not_running') }}
</div>
<Button @click="reconnectClient" :loading="isModeSaving" :label="t('client.retry')" icon="pi pi-replay"
iconPos="left" />
</div>
<Menubar :model="setting_menu_items" breakpoint="560px">
<template #item="{ item, props }">