From cb79bdafbd956870c2b1851bdd76d864fa7b669a Mon Sep 17 00:00:00 2001 From: nessi Date: Wed, 18 Mar 2026 08:45:06 +0100 Subject: [PATCH] feat: add dynamic tray menu with connection status, transfer metrics, and toggle action Add TrayState struct to track menu items for status, received/sent bytes, and connection toggle. Add format_data_size helper to convert bytes to human-readable units (B, KB, MB, GB, TB). Add current_metrics, update_tray_menu, refresh_tray_menu, and toggle_tray_connection functions to manage tray state. Update tray menu to include status, received, sent, and toggle items. Call refresh_tray_menu after enroll_device --- desktop-client/src-tauri/src/lib.rs | 176 ++++++++++++++++-- .../src-tauri/src/tunnel_manager.rs | 1 + desktop-client/src/App.tsx | 2 +- desktop-client/tunnel-helper/src/main.rs | 1 + 4 files changed, 162 insertions(+), 18 deletions(-) diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index 14c9cb5..3f967d5 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -7,9 +7,9 @@ use rand_core::OsRng; use reqwest::Client; use serde::{Deserialize, Serialize}; use tauri::{ - menu::{MenuBuilder, MenuItemBuilder}, + menu::{MenuBuilder, MenuItem, MenuItemBuilder}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, Manager, State, WebviewWindow, Window, WindowEvent, + AppHandle, Manager, State, WebviewWindow, Window, WindowEvent, Wry, }; use x25519_dalek::{PublicKey, StaticSecret}; @@ -19,9 +19,17 @@ const SINGLE_INSTANCE_ADDR: &str = "127.0.0.1:53190"; struct AppState { session: Mutex>, + tray: Mutex>, single_instance_lock: TcpListener, } +struct TrayState { + status_item: MenuItem, + received_item: MenuItem, + sent_item: MenuItem, + toggle_item: MenuItem, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct SessionState { @@ -214,6 +222,8 @@ async fn enroll_device( write_session_state(&app, &session_state)?; let mut session = state.session.lock().map_err(|_| "Unable to store client state".to_string())?; *session = Some(session_state); + drop(session); + refresh_tray_menu(&app); Ok(result) } @@ -223,6 +233,8 @@ fn load_state(app: AppHandle, state: State<'_, AppState>) -> Result) -> Result<(), Strin let mut session = state.session.lock().map_err(|_| "Unable to clear client state".to_string())?; *session = None; + drop(session); + refresh_tray_menu(&app); Ok(()) } @@ -301,41 +315,61 @@ async fn sync_profile(app: AppHandle, state: State<'_, AppState>) -> Result) -> Result<(), String> { - let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?; - let session = session.as_ref().ok_or_else(|| "No enrolled profile is available yet".to_string())?; - tunnel_manager::connect(&app, std::path::Path::new(&session.profile_path)) + let profile_path = { + let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?; + let session = session.as_ref().ok_or_else(|| "No enrolled profile is available yet".to_string())?; + session.profile_path.clone() + }; + let result = tunnel_manager::connect(&app, std::path::Path::new(&profile_path)); + refresh_tray_menu(&app); + result } #[tauri::command] fn disconnect_tunnel(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> { - let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?; - let session = session.as_ref().ok_or_else(|| "No active session is available".to_string())?; - tunnel_manager::disconnect(&app, std::path::Path::new(&session.profile_path)) + let profile_path = { + let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?; + let session = session.as_ref().ok_or_else(|| "No active session is available".to_string())?; + session.profile_path.clone() + }; + let result = tunnel_manager::disconnect(&app, std::path::Path::new(&profile_path)); + refresh_tray_menu(&app); + result } #[tauri::command] fn tunnel_status(app: AppHandle, state: State<'_, AppState>) -> Result { - let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?; - let session = session.as_ref().ok_or_else(|| "No active session is available".to_string())?; - tunnel_manager::is_active(&app, std::path::Path::new(&session.profile_path)) + let profile_path = { + let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?; + let session = session.as_ref().ok_or_else(|| "No active session is available".to_string())?; + session.profile_path.clone() + }; + tunnel_manager::is_active(&app, std::path::Path::new(&profile_path)) } #[tauri::command] fn tunnel_metrics(app: AppHandle, state: State<'_, AppState>) -> Result { - let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?; - let session = session.as_ref().ok_or_else(|| "No active session is available".to_string())?; - let metrics = tunnel_manager::metrics(&app, std::path::Path::new(&session.profile_path))?; - Ok(TunnelMetrics { + let profile_path = { + let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?; + let session = session.as_ref().ok_or_else(|| "No active session is available".to_string())?; + session.profile_path.clone() + }; + let metrics = tunnel_manager::metrics(&app, std::path::Path::new(&profile_path))?; + let mapped = TunnelMetrics { active: metrics.active, rx_bytes: metrics.rx_bytes, tx_bytes: metrics.tx_bytes, - }) + }; + let _ = update_tray_menu(&app, mapped); + Ok(mapped) } fn generate_keypair() -> (String, String) { @@ -395,6 +429,98 @@ fn ensure_app_dir(app: &AppHandle) -> Result { Ok(dir) } +fn format_data_size(bytes: u64) -> String { + if bytes == 0 { + return "0 B".into(); + } + + let units = ["B", "KB", "MB", "GB", "TB"]; + let mut value = bytes as f64; + let mut unit_index = 0; + + while value >= 1024.0 && unit_index < units.len() - 1 { + value /= 1024.0; + unit_index += 1; + } + + if value >= 100.0 || unit_index == 0 { + format!("{value:.0} {}", units[unit_index]) + } else { + format!("{value:.1} {}", units[unit_index]) + } +} + +fn current_metrics(app: &AppHandle) -> Result { + let state = app.state::(); + let profile_path = { + let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?; + let session = session.as_ref().ok_or_else(|| "No active session is available".to_string())?; + session.profile_path.clone() + }; + tunnel_manager::metrics(app, std::path::Path::new(&profile_path)).map(|metrics| TunnelMetrics { + active: metrics.active, + rx_bytes: metrics.rx_bytes, + tx_bytes: metrics.tx_bytes, + }) +} + +fn update_tray_menu(app: &AppHandle, metrics: TunnelMetrics) -> Result<(), String> { + let state = app.state::(); + let tray = state.tray.lock().map_err(|_| "Unable to update tray state".to_string())?; + let Some(tray) = tray.as_ref() else { + return Ok(()); + }; + + let status_text = if metrics.active { + "Status: Connected" + } else { + "Status: Disconnected" + }; + let toggle_text = if metrics.active { "Disconnect NexaVPN" } else { "Connect NexaVPN" }; + + let _ = tray.status_item.set_text(status_text); + let _ = tray.received_item.set_text(format!("Received: {}", format_data_size(metrics.rx_bytes))); + let _ = tray.sent_item.set_text(format!("Sent: {}", format_data_size(metrics.tx_bytes))); + let _ = tray.toggle_item.set_text(toggle_text); + Ok(()) +} + +fn refresh_tray_menu(app: &AppHandle) { + let metrics = current_metrics(app).unwrap_or(TunnelMetrics { + active: false, + rx_bytes: 0, + tx_bytes: 0, + }); + let _ = update_tray_menu(app, metrics); +} + +fn toggle_tray_connection(app: &AppHandle) { + let metrics = current_metrics(app).unwrap_or(TunnelMetrics { + active: false, + rx_bytes: 0, + tx_bytes: 0, + }); + + let state = app.state::(); + let profile_path = match state.session.lock() { + Ok(session) => match session.as_ref() { + Some(session) => session.profile_path.clone(), + None => return, + }, + Err(_) => return, + }; + + let result = if metrics.active { + tunnel_manager::disconnect(app, std::path::Path::new(&profile_path)) + } else { + tunnel_manager::connect(app, std::path::Path::new(&profile_path)) + }; + + if result.is_ok() { + refresh_tray_menu(app); + } +} + fn restore_webview_window(window: &WebviewWindow) { let _ = window.show(); let _ = window.unminimize(); @@ -412,9 +538,15 @@ pub fn run() { let single_instance_lock = TcpListener::bind(SINGLE_INSTANCE_ADDR) .map_err(|_| format!("{} is already running.", app.package_info().name))?; + let status_item = MenuItemBuilder::with_id("status", "Status: Disconnected").build(app)?; + let received_item = MenuItemBuilder::with_id("received", "Received: 0 B").build(app)?; + let sent_item = MenuItemBuilder::with_id("sent", "Sent: 0 B").build(app)?; + let toggle_item = MenuItemBuilder::with_id("toggle", "Connect NexaVPN").build(app)?; let open_item = MenuItemBuilder::with_id("open", "Open NexaVPN").build(app)?; let quit_item = MenuItemBuilder::with_id("quit", "Quit NexaVPN").build(app)?; - let menu = MenuBuilder::new(app).items(&[&open_item, &quit_item]).build()?; + let menu = MenuBuilder::new(app) + .items(&[&status_item, &received_item, &sent_item, &toggle_item, &open_item, &quit_item]) + .build()?; let mut tray = TrayIconBuilder::new().menu(&menu).show_menu_on_left_click(false); if let Some(icon) = app.default_window_icon() { @@ -423,6 +555,9 @@ pub fn run() { tray .on_menu_event(|app, event| match event.id().as_ref() { + "toggle" => { + toggle_tray_connection(&app); + } "open" => { if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { restore_webview_window(&window); @@ -450,8 +585,15 @@ pub fn run() { app.manage(AppState { session: Mutex::new(None), + tray: Mutex::new(Some(TrayState { + status_item, + received_item, + sent_item, + toggle_item, + })), single_instance_lock, }); + refresh_tray_menu(app.handle()); Ok(()) }) diff --git a/desktop-client/src-tauri/src/tunnel_manager.rs b/desktop-client/src-tauri/src/tunnel_manager.rs index 3a9c7ac..90ca97b 100644 --- a/desktop-client/src-tauri/src/tunnel_manager.rs +++ b/desktop-client/src-tauri/src/tunnel_manager.rs @@ -233,6 +233,7 @@ fn parse_human_wireguard_bytes(value: &str) -> u64 { #[cfg(target_os = "windows")] fn find_windows_wg() -> Result { let candidates = [ + PathBuf::from("wg"), PathBuf::from(r"C:\Program Files\WireGuard\wg.exe"), PathBuf::from(r"C:\Program Files (x86)\WireGuard\wg.exe"), ]; diff --git a/desktop-client/src/App.tsx b/desktop-client/src/App.tsx index 5d22e75..42eb795 100644 --- a/desktop-client/src/App.tsx +++ b/desktop-client/src/App.tsx @@ -52,7 +52,7 @@ function currentProfileLabel(state: EnrollmentState | null) { function formatDataSize(bytes: number) { if (!bytes) { - return "0 MB"; + return "0 B"; } const units = ["B", "KB", "MB", "GB", "TB"]; diff --git a/desktop-client/tunnel-helper/src/main.rs b/desktop-client/tunnel-helper/src/main.rs index 8ef7e6b..99baff2 100644 --- a/desktop-client/tunnel-helper/src/main.rs +++ b/desktop-client/tunnel-helper/src/main.rs @@ -735,6 +735,7 @@ fn read_transfer_totals(profile: &Path) -> Result<(u64, u64), String> { #[cfg(target_os = "windows")] fn find_wg_cli() -> Result { let candidates = [ + PathBuf::from("wg"), PathBuf::from(r"C:\Program Files\WireGuard\wg.exe"), PathBuf::from(r"C:\Program Files (x86)\WireGuard\wg.exe"), ];