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
284 lines
8.3 KiB
Rust
284 lines
8.3 KiB
Rust
use std::{
|
|
path::{Path, PathBuf},
|
|
process::Command,
|
|
};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::{AppHandle, Manager};
|
|
|
|
#[cfg(target_os = "windows")]
|
|
use std::os::windows::process::CommandExt;
|
|
|
|
#[cfg(target_os = "windows")]
|
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
|
|
pub fn current_tunnel_strategy() -> &'static str {
|
|
if cfg!(target_os = "windows") {
|
|
"embedded-wireguard-windows-x64"
|
|
} else if cfg!(target_os = "macos") {
|
|
"embedded-wireguard-macos-arm"
|
|
} else {
|
|
"unsupported"
|
|
}
|
|
}
|
|
|
|
pub fn connect(app: &AppHandle, profile_path: &Path) -> Result<(), String> {
|
|
let backend = bundled_backend(app)?;
|
|
let mut command = Command::new(backend);
|
|
command.arg("connect").arg("--profile").arg(profile_path);
|
|
#[cfg(target_os = "windows")]
|
|
command.creation_flags(CREATE_NO_WINDOW);
|
|
let output = command
|
|
.output()
|
|
.map_err(|err| format!("Unable to start embedded tunnel backend: {}", err))?;
|
|
|
|
if !output.status.success() {
|
|
return Err(format_helper_error("connect", &output));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn disconnect(app: &AppHandle, profile_path: &Path) -> Result<(), String> {
|
|
let backend = bundled_backend(app)?;
|
|
let mut command = Command::new(backend);
|
|
command.arg("disconnect").arg("--profile").arg(profile_path);
|
|
#[cfg(target_os = "windows")]
|
|
command.creation_flags(CREATE_NO_WINDOW);
|
|
let output = command
|
|
.output()
|
|
.map_err(|err| format!("Unable to stop embedded tunnel backend: {}", err))?;
|
|
|
|
if !output.status.success() {
|
|
return Err(format_helper_error("disconnect", &output));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn is_active(app: &AppHandle, profile_path: &Path) -> Result<bool, String> {
|
|
Ok(metrics(app, profile_path)?.active)
|
|
}
|
|
|
|
pub fn metrics(app: &AppHandle, profile_path: &Path) -> Result<TunnelMetrics, String> {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
if let Ok(metrics) = direct_windows_metrics(profile_path) {
|
|
return Ok(metrics);
|
|
}
|
|
}
|
|
|
|
let backend = bundled_backend(app)?;
|
|
let mut command = Command::new(backend);
|
|
command.arg("metrics").arg("--profile").arg(profile_path);
|
|
#[cfg(target_os = "windows")]
|
|
command.creation_flags(CREATE_NO_WINDOW);
|
|
let output = command
|
|
.output()
|
|
.map_err(|err| format!("Unable to query embedded tunnel backend: {}", err))?;
|
|
|
|
if !output.status.success() {
|
|
return Err(format_helper_error("metrics", &output));
|
|
}
|
|
|
|
serde_json::from_slice::<TunnelMetrics>(&output.stdout)
|
|
.map_err(|err| format!("Unable to decode tunnel metrics: {}", err))
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct TunnelMetrics {
|
|
pub active: bool,
|
|
pub rx_bytes: u64,
|
|
pub tx_bytes: u64,
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn direct_windows_metrics(profile_path: &Path) -> Result<TunnelMetrics, String> {
|
|
let tunnel_name = profile_path
|
|
.file_stem()
|
|
.and_then(|value| value.to_str())
|
|
.ok_or_else(|| "invalid profile filename".to_string())?;
|
|
let service_name = format!("WireGuardTunnel${}", tunnel_name);
|
|
|
|
let mut status_cmd = Command::new("sc");
|
|
status_cmd.arg("query").arg(&service_name).creation_flags(CREATE_NO_WINDOW);
|
|
let status_output = status_cmd
|
|
.output()
|
|
.map_err(|err| format!("Unable to query tunnel status: {err}"))?;
|
|
|
|
if !status_output.status.success() {
|
|
return Ok(TunnelMetrics {
|
|
active: false,
|
|
rx_bytes: 0,
|
|
tx_bytes: 0,
|
|
});
|
|
}
|
|
|
|
let status_stdout = String::from_utf8_lossy(&status_output.stdout);
|
|
if !status_stdout.contains("RUNNING") {
|
|
return Ok(TunnelMetrics {
|
|
active: false,
|
|
rx_bytes: 0,
|
|
tx_bytes: 0,
|
|
});
|
|
}
|
|
|
|
let wg = find_windows_wg()?;
|
|
let mut wg_cmd = Command::new(wg);
|
|
wg_cmd
|
|
.arg("show")
|
|
.arg(tunnel_name)
|
|
.arg("dump")
|
|
.creation_flags(CREATE_NO_WINDOW);
|
|
let wg_output = wg_cmd
|
|
.output()
|
|
.map_err(|err| format!("Unable to query WireGuard counters: {err}"))?;
|
|
|
|
if !wg_output.status.success() {
|
|
return read_windows_metrics_from_show(tunnel_name);
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&wg_output.stdout);
|
|
let mut rx_bytes = 0_u64;
|
|
let mut tx_bytes = 0_u64;
|
|
|
|
for line in stdout.lines().skip(1) {
|
|
let fields: Vec<&str> = line.split('\t').collect();
|
|
if fields.len() < 7 {
|
|
continue;
|
|
}
|
|
rx_bytes = rx_bytes.saturating_add(fields[5].parse::<u64>().unwrap_or(0));
|
|
tx_bytes = tx_bytes.saturating_add(fields[6].parse::<u64>().unwrap_or(0));
|
|
}
|
|
|
|
Ok(TunnelMetrics {
|
|
active: true,
|
|
rx_bytes,
|
|
tx_bytes,
|
|
})
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn read_windows_metrics_from_show(tunnel_name: &str) -> Result<TunnelMetrics, String> {
|
|
let wg = find_windows_wg()?;
|
|
let mut command = Command::new(wg);
|
|
command
|
|
.arg("show")
|
|
.arg(tunnel_name)
|
|
.creation_flags(CREATE_NO_WINDOW);
|
|
let output = command
|
|
.output()
|
|
.map_err(|err| format!("Unable to query WireGuard transfer text: {err}"))?;
|
|
|
|
if !output.status.success() {
|
|
return Ok(TunnelMetrics {
|
|
active: true,
|
|
rx_bytes: 0,
|
|
tx_bytes: 0,
|
|
});
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let mut rx_bytes = 0_u64;
|
|
let mut tx_bytes = 0_u64;
|
|
|
|
for line in stdout.lines() {
|
|
let trimmed = line.trim();
|
|
if let Some(rest) = trimmed.strip_prefix("transfer:") {
|
|
let parts: Vec<&str> = rest.split(',').collect();
|
|
if let Some(received) = parts.first() {
|
|
rx_bytes = parse_human_wireguard_bytes(received);
|
|
}
|
|
if let Some(sent) = parts.get(1) {
|
|
tx_bytes = parse_human_wireguard_bytes(sent);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(TunnelMetrics {
|
|
active: true,
|
|
rx_bytes,
|
|
tx_bytes,
|
|
})
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn parse_human_wireguard_bytes(value: &str) -> u64 {
|
|
let cleaned = value
|
|
.replace("received", "")
|
|
.replace("sent", "")
|
|
.trim()
|
|
.to_string();
|
|
let mut parts = cleaned.split_whitespace();
|
|
let amount = parts
|
|
.next()
|
|
.map(|raw| raw.replace(',', "."))
|
|
.and_then(|raw| raw.parse::<f64>().ok())
|
|
.unwrap_or(0.0);
|
|
let unit = parts.next().unwrap_or("B");
|
|
|
|
let multiplier = match unit {
|
|
"B" => 1.0,
|
|
"KiB" => 1024.0,
|
|
"MiB" => 1024.0 * 1024.0,
|
|
"GiB" => 1024.0 * 1024.0 * 1024.0,
|
|
"TiB" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
|
|
_ => 1.0,
|
|
};
|
|
|
|
(amount * multiplier) as 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"),
|
|
];
|
|
|
|
candidates
|
|
.into_iter()
|
|
.find(|path| path.exists())
|
|
.ok_or_else(|| "required Windows WireGuard CLI is not available".to_string())
|
|
}
|
|
|
|
fn bundled_backend(app: &AppHandle) -> Result<PathBuf, String> {
|
|
let resource_dir = app
|
|
.path()
|
|
.resource_dir()
|
|
.map_err(|err| format!("Unable to resolve resource dir: {}", err))?;
|
|
|
|
let relative = if cfg!(target_os = "windows") {
|
|
PathBuf::from("bundled/windows-x64/nexavpn-tunnel-helper.exe")
|
|
} else if cfg!(target_os = "macos") {
|
|
PathBuf::from("bundled/macos-arm64/nexavpn-tunnel-helper")
|
|
} else {
|
|
return Err("This NexaVPN client build supports embedded tunnel backends only for Windows x64 and macOS ARM".into());
|
|
};
|
|
|
|
let path = resource_dir.join(relative);
|
|
if !path.exists() {
|
|
return Err("Embedded NexaVPN tunnel backend is not bundled in this build yet.".into());
|
|
}
|
|
|
|
Ok(path)
|
|
}
|
|
|
|
fn format_helper_error(action: &str, output: &std::process::Output) -> String {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let details = if !stderr.trim().is_empty() {
|
|
stderr.trim()
|
|
} else {
|
|
stdout.trim()
|
|
};
|
|
|
|
if details.is_empty() {
|
|
return format!("NexaVPN tunnel backend {} failed with status {}", action, output.status);
|
|
}
|
|
|
|
details.to_string()
|
|
}
|