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:
@@ -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 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<bool, 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::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<TunnelMetrics, 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())?;
|
||||
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<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(())
|
||||
})
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user