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:
1 parent
a87a4664be
commit
cb79bdafbd
4 files changed
+154
-10
No files matched your search
@@ -7,9 +7,9 @@ use rand_core::OsRng;
|
|||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{MenuBuilder, MenuItemBuilder},
|
menu::{MenuBuilder, MenuItem, MenuItemBuilder},
|
||||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
AppHandle, Manager, State, WebviewWindow, Window, WindowEvent,
|
AppHandle, Manager, State, WebviewWindow, Window, WindowEvent, Wry,
|
||||||
};
|
};
|
||||||
use x25519_dalek::{PublicKey, StaticSecret};
|
use x25519_dalek::{PublicKey, StaticSecret};
|
||||||
|
|
||||||
@@ -19,9 +19,17 @@ const SINGLE_INSTANCE_ADDR: &str = "127.0.0.1:53190";
|
|||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
session: Mutex<Option<SessionState>>,
|
session: Mutex<Option<SessionState>>,
|
||||||
|
tray: Mutex<Option<TrayState>>,
|
||||||
single_instance_lock: TcpListener,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct SessionState {
|
struct SessionState {
|
||||||
@@ -214,6 +222,8 @@ async fn enroll_device(
|
|||||||
write_session_state(&app, &session_state)?;
|
write_session_state(&app, &session_state)?;
|
||||||
let mut session = state.session.lock().map_err(|_| "Unable to store client state".to_string())?;
|
let mut session = state.session.lock().map_err(|_| "Unable to store client state".to_string())?;
|
||||||
*session = Some(session_state);
|
*session = Some(session_state);
|
||||||
|
drop(session);
|
||||||
|
refresh_tray_menu(&app);
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -223,6 +233,8 @@ fn load_state(app: AppHandle, state: State<'_, AppState>) -> Result<Option<Enrol
|
|||||||
let loaded = read_session_state(&app)?;
|
let loaded = read_session_state(&app)?;
|
||||||
let mut session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
|
let mut session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
|
||||||
*session = loaded.clone();
|
*session = loaded.clone();
|
||||||
|
drop(session);
|
||||||
|
refresh_tray_menu(&app);
|
||||||
Ok(loaded.map(|value| value.enrollment))
|
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())?;
|
let mut session = state.session.lock().map_err(|_| "Unable to clear client state".to_string())?;
|
||||||
*session = None;
|
*session = None;
|
||||||
|
drop(session);
|
||||||
|
refresh_tray_menu(&app);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,41 +315,61 @@ async fn sync_profile(app: AppHandle, state: State<'_, AppState>) -> Result<Enro
|
|||||||
write_session_state(&app, &session_state)?;
|
write_session_state(&app, &session_state)?;
|
||||||
let mut session = state.session.lock().map_err(|_| "Unable to store client state".to_string())?;
|
let mut session = state.session.lock().map_err(|_| "Unable to store client state".to_string())?;
|
||||||
*session = Some(session_state);
|
*session = Some(session_state);
|
||||||
|
drop(session);
|
||||||
|
refresh_tray_menu(&app);
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn connect_tunnel(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
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 = 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())?;
|
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]
|
#[tauri::command]
|
||||||
fn disconnect_tunnel(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
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 = 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 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]
|
#[tauri::command]
|
||||||
fn tunnel_status(app: AppHandle, state: State<'_, AppState>) -> Result<bool, String> {
|
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 = 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 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]
|
#[tauri::command]
|
||||||
fn tunnel_metrics(app: AppHandle, state: State<'_, AppState>) -> Result<TunnelMetrics, String> {
|
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 = 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 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))?;
|
session.profile_path.clone()
|
||||||
Ok(TunnelMetrics {
|
};
|
||||||
|
let metrics = tunnel_manager::metrics(&app, std::path::Path::new(&profile_path))?;
|
||||||
|
let mapped = TunnelMetrics {
|
||||||
active: metrics.active,
|
active: metrics.active,
|
||||||
rx_bytes: metrics.rx_bytes,
|
rx_bytes: metrics.rx_bytes,
|
||||||
tx_bytes: metrics.tx_bytes,
|
tx_bytes: metrics.tx_bytes,
|
||||||
})
|
};
|
||||||
|
let _ = update_tray_menu(&app, mapped);
|
||||||
|
Ok(mapped)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_keypair() -> (String, String) {
|
fn generate_keypair() -> (String, String) {
|
||||||
@@ -395,6 +429,98 @@ fn ensure_app_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
|||||||
Ok(dir)
|
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) {
|
fn restore_webview_window(window: &WebviewWindow) {
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
let _ = window.unminimize();
|
let _ = window.unminimize();
|
||||||
@@ -412,9 +538,15 @@ pub fn run() {
|
|||||||
let single_instance_lock = TcpListener::bind(SINGLE_INSTANCE_ADDR)
|
let single_instance_lock = TcpListener::bind(SINGLE_INSTANCE_ADDR)
|
||||||
.map_err(|_| format!("{} is already running.", app.package_info().name))?;
|
.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 open_item = MenuItemBuilder::with_id("open", "Open NexaVPN").build(app)?;
|
||||||
let quit_item = MenuItemBuilder::with_id("quit", "Quit 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);
|
let mut tray = TrayIconBuilder::new().menu(&menu).show_menu_on_left_click(false);
|
||||||
if let Some(icon) = app.default_window_icon() {
|
if let Some(icon) = app.default_window_icon() {
|
||||||
@@ -423,6 +555,9 @@ pub fn run() {
|
|||||||
|
|
||||||
tray
|
tray
|
||||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||||
|
"toggle" => {
|
||||||
|
toggle_tray_connection(&app);
|
||||||
|
}
|
||||||
"open" => {
|
"open" => {
|
||||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||||
restore_webview_window(&window);
|
restore_webview_window(&window);
|
||||||
@@ -450,8 +585,15 @@ pub fn run() {
|
|||||||
|
|
||||||
app.manage(AppState {
|
app.manage(AppState {
|
||||||
session: Mutex::new(None),
|
session: Mutex::new(None),
|
||||||
|
tray: Mutex::new(Some(TrayState {
|
||||||
|
status_item,
|
||||||
|
received_item,
|
||||||
|
sent_item,
|
||||||
|
toggle_item,
|
||||||
|
})),
|
||||||
single_instance_lock,
|
single_instance_lock,
|
||||||
});
|
});
|
||||||
|
refresh_tray_menu(app.handle());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ fn parse_human_wireguard_bytes(value: &str) -> u64 {
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn find_windows_wg() -> Result<PathBuf, String> {
|
fn find_windows_wg() -> Result<PathBuf, String> {
|
||||||
let candidates = [
|
let candidates = [
|
||||||
|
PathBuf::from("wg"),
|
||||||
PathBuf::from(r"C:\Program Files\WireGuard\wg.exe"),
|
PathBuf::from(r"C:\Program Files\WireGuard\wg.exe"),
|
||||||
PathBuf::from(r"C:\Program Files (x86)\WireGuard\wg.exe"),
|
PathBuf::from(r"C:\Program Files (x86)\WireGuard\wg.exe"),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function currentProfileLabel(state: EnrollmentState | null) {
|
|||||||
|
|
||||||
function formatDataSize(bytes: number) {
|
function formatDataSize(bytes: number) {
|
||||||
if (!bytes) {
|
if (!bytes) {
|
||||||
return "0 MB";
|
return "0 B";
|
||||||
}
|
}
|
||||||
|
|
||||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
|||||||
@@ -735,6 +735,7 @@ fn read_transfer_totals(profile: &Path) -> Result<(u64, u64), String> {
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn find_wg_cli() -> Result<PathBuf, String> {
|
fn find_wg_cli() -> Result<PathBuf, String> {
|
||||||
let candidates = [
|
let candidates = [
|
||||||
|
PathBuf::from("wg"),
|
||||||
PathBuf::from(r"C:\Program Files\WireGuard\wg.exe"),
|
PathBuf::from(r"C:\Program Files\WireGuard\wg.exe"),
|
||||||
PathBuf::from(r"C:\Program Files (x86)\WireGuard\wg.exe"),
|
PathBuf::from(r"C:\Program Files (x86)\WireGuard\wg.exe"),
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in new issue
Block a user