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
This commit is contained in:
2026-03-18 08:45:06 +01:00
parent a87a4664be
commit cb79bdafbd
4 changed files with 162 additions and 18 deletions

View File

@@ -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<Option<SessionState>>,
tray: Mutex<Option<TrayState>>,
single_instance_lock: TcpListener,
}
struct TrayState {
status_item: MenuItem<Wry>,
received_item: MenuItem<Wry>,
sent_item: MenuItem<Wry>,
toggle_item: MenuItem<Wry>,
}
#[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<Option<Enrol
let loaded = read_session_state(&app)?;
let mut session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
*session = loaded.clone();
drop(session);
refresh_tray_menu(&app);
Ok(loaded.map(|value| value.enrollment))
}
@@ -241,6 +253,8 @@ fn clear_session(app: AppHandle, state: State<'_, AppState>) -> 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<Enro
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)
}
#[tauri::command]
fn connect_tunnel(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
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())?;
tunnel_manager::connect(&app, std::path::Path::new(&session.profile_path))
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 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())?;
tunnel_manager::disconnect(&app, std::path::Path::new(&session.profile_path))
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<bool, String> {
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())?;
tunnel_manager::is_active(&app, std::path::Path::new(&session.profile_path))
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<TunnelMetrics, String> {
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())?;
let metrics = tunnel_manager::metrics(&app, std::path::Path::new(&session.profile_path))?;
Ok(TunnelMetrics {
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<PathBuf, String> {
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<TunnelMetrics, String> {
let state = app.state::<AppState>();
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::<AppState>();
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::<AppState>();
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(())
})

View File

@@ -233,6 +233,7 @@ fn parse_human_wireguard_bytes(value: &str) -> u64 {
#[cfg(target_os = "windows")]
fn find_windows_wg() -> Result<PathBuf, String> {
let candidates = [
PathBuf::from("wg"),
PathBuf::from(r"C:\Program Files\WireGuard\wg.exe"),
PathBuf::from(r"C:\Program Files (x86)\WireGuard\wg.exe"),
];

View File

@@ -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"];

View File

@@ -735,6 +735,7 @@ fn read_transfer_totals(profile: &Path) -> Result<(u64, u64), String> {
#[cfg(target_os = "windows")]
fn find_wg_cli() -> Result<PathBuf, String> {
let candidates = [
PathBuf::from("wg"),
PathBuf::from(r"C:\Program Files\WireGuard\wg.exe"),
PathBuf::from(r"C:\Program Files (x86)\WireGuard\wg.exe"),
];