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 { Ok(metrics(app, profile_path)?.active) } pub fn metrics(app: &AppHandle, profile_path: &Path) -> Result { #[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::(&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 { 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::().unwrap_or(0)); tx_bytes = tx_bytes.saturating_add(fields[6].parse::().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 { 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::().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 { 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 { 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() }