From 5003a2f0f72f518e8ac75d83c4ac52b8b53e2dd1 Mon Sep 17 00:00:00 2001 From: nessi Date: Tue, 24 Mar 2026 18:35:19 +0100 Subject: [PATCH] feat: add automated WireGuard runtime installation for Windows desktop client with bundled MSI installer Add install-runtime command to tunnel-helper for automated WireGuard installation on Windows. Download and bundle official WireGuard MSI during build process with automatic version discovery from wireguard.com. Add ensure_windows_runtime_installed checks before connect/disconnect operations. Implement install_windows_runtime with UAC elevation prompt and install_windows_runtime_direct for MS --- .gitea/workflows/windows-desktop-client.yml | 1 + desktop-client/scripts/build-tunnel-helper.sh | 22 ++++ desktop-client/src-tauri/windows/hooks.nsh | 1 + desktop-client/tunnel-helper/src/main.rs | 121 +++++++++++++++++- 4 files changed, 144 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/windows-desktop-client.yml b/.gitea/workflows/windows-desktop-client.yml index 2252deb..5f6dbf3 100644 --- a/.gitea/workflows/windows-desktop-client.yml +++ b/.gitea/workflows/windows-desktop-client.yml @@ -134,4 +134,5 @@ jobs: path: | desktop-client/src-tauri/target/x86_64-pc-windows-msvc/release/nexavpn-desktop.exe desktop-client/src-tauri/bundled/windows-x64/nexavpn-tunnel-helper.exe + desktop-client/src-tauri/bundled/windows-x64/wireguard-installer.msi if-no-files-found: error diff --git a/desktop-client/scripts/build-tunnel-helper.sh b/desktop-client/scripts/build-tunnel-helper.sh index 14fb9a2..ca15e3a 100644 --- a/desktop-client/scripts/build-tunnel-helper.sh +++ b/desktop-client/scripts/build-tunnel-helper.sh @@ -78,6 +78,28 @@ else fi install -m 755 "${HELPER_DIR}/target/${TARGET}/release/${OUTPUT_NAME}" "${OUTPUT_DIR}/${OUTPUT_NAME}" +if [ "${TARGET}" = "x86_64-pc-windows-msvc" ]; then + if ! command -v curl >/dev/null 2>&1; then + echo "curl is required to bundle the official WireGuard Windows MSI." + exit 1 + fi + + MSI_URL="${NEXAVPN_WIREGUARD_WINDOWS_MSI_URL:-}" + if [ -z "${MSI_URL}" ]; then + INDEX_HTML="$(curl -fsSL https://download.wireguard.com/windows-client/)" + MSI_NAME="$(printf '%s' "${INDEX_HTML}" | grep -oE 'wireguard-amd64-[0-9.]+\.msi' | sort -uV | tail -n 1)" + if [ -z "${MSI_NAME}" ]; then + echo "Unable to discover the latest official WireGuard Windows MSI." + exit 1 + fi + MSI_URL="https://download.wireguard.com/windows-client/${MSI_NAME}" + fi + + echo "Bundling WireGuard Windows runtime from ${MSI_URL}" + rm -f "${OUTPUT_DIR}/wireguard-installer.msi" + curl -fsSL "${MSI_URL}" -o "${OUTPUT_DIR}/wireguard-installer.msi" +fi + if [ "${TARGET}" = "aarch64-apple-darwin" ]; then WG_BIN="$(command -v wg || true)" WG_QUICK_BIN="$(command -v wg-quick || true)" diff --git a/desktop-client/src-tauri/windows/hooks.nsh b/desktop-client/src-tauri/windows/hooks.nsh index ba9dd4e..49a904a 100644 --- a/desktop-client/src-tauri/windows/hooks.nsh +++ b/desktop-client/src-tauri/windows/hooks.nsh @@ -6,6 +6,7 @@ !macroend !macro NSIS_HOOK_POSTINSTALL + nsExec::ExecToLog '"$INSTDIR\bundled\windows-x64\nexavpn-tunnel-helper.exe" install-runtime' nsExec::ExecToLog '"$INSTDIR\bundled\windows-x64\nexavpn-tunnel-helper.exe" install-service' !macroend diff --git a/desktop-client/tunnel-helper/src/main.rs b/desktop-client/tunnel-helper/src/main.rs index c9ffaa8..3c4712f 100644 --- a/desktop-client/tunnel-helper/src/main.rs +++ b/desktop-client/tunnel-helper/src/main.rs @@ -82,6 +82,8 @@ fn run() -> Result<(), String> { { return match command.as_str() { "service" => run_windows_service_dispatcher(), + "install-runtime" => install_windows_runtime(), + "install-runtime-direct" => install_windows_runtime_direct(), "install-service" => install_windows_service(), "install-service-direct" => install_windows_service_direct(), "uninstall-service" => uninstall_windows_service(), @@ -332,6 +334,68 @@ fn install_windows_service() -> Result<(), String> { Ok(()) } +#[cfg(target_os = "windows")] +fn install_windows_runtime() -> Result<(), String> { + if windows_wireguard_runtime_ready() { + return Ok(()); + } + + if is_windows_admin()? { + return install_windows_runtime_direct(); + } + + let helper_path = env::current_exe().map_err(|err| format!("Unable to resolve helper path: {err}"))?; + let status = Command::new("powershell") + .arg("-NoProfile") + .arg("-Command") + .arg(format!( + "Start-Process -FilePath '{}' -ArgumentList 'install-runtime-direct' -Verb RunAs -Wait", + helper_path.display() + )) + .creation_flags(CREATE_NO_WINDOW) + .status() + .map_err(|err| format!("Unable to request elevated WireGuard runtime install: {err}"))?; + + if !status.success() { + return Err("WireGuard runtime installation was cancelled or failed.".into()); + } + + if windows_wireguard_runtime_ready() { + return Ok(()); + } + + Err("WireGuard runtime installation finished, but the runtime is still unavailable.".into()) +} + +#[cfg(target_os = "windows")] +fn install_windows_runtime_direct() -> Result<(), String> { + if windows_wireguard_runtime_ready() { + return Ok(()); + } + + let installer = bundled_windows_installer() + .ok_or_else(|| "Bundled WireGuard Windows installer is missing from this NexaVPN build.".to_string())?; + + let status = Command::new("msiexec.exe") + .arg("/i") + .arg(&installer) + .arg("/qn") + .arg("/norestart") + .creation_flags(CREATE_NO_WINDOW) + .status() + .map_err(|err| format!("Unable to launch bundled WireGuard MSI: {err}"))?; + + if !status.success() { + return Err(format!("Bundled WireGuard MSI installation failed with status {status}.")); + } + + if windows_wireguard_runtime_ready() { + return Ok(()); + } + + Err("WireGuard MSI completed, but wireguard.exe/wg.exe are still unavailable.".into()) +} + #[cfg(target_os = "windows")] fn install_windows_service_direct() -> Result<(), String> { let manager = ServiceManager::local_computer( @@ -506,6 +570,7 @@ fn is_windows_admin() -> Result { fn connect_direct(profile: &Path) -> Result<(), String> { #[cfg(target_os = "windows")] { + ensure_windows_runtime_installed()?; if tunnel_service_is_active(profile)? { return Ok(()); } @@ -551,6 +616,7 @@ fn connect_direct(profile: &Path) -> Result<(), String> { fn disconnect_direct(profile: &Path) -> Result<(), String> { #[cfg(target_os = "windows")] { + ensure_windows_runtime_installed()?; let wireguard = find_windows_wireguard()?; let tunnel_name = profile .file_stem() @@ -811,6 +877,10 @@ fn parse_human_wireguard_bytes(value: &str) -> u64 { #[cfg(target_os = "windows")] fn find_wg_cli() -> Result { + if let Some(path) = bundled_windows_tool("wg.exe") { + return Ok(path); + } + let candidates = [ PathBuf::from(r"C:\Program Files\WireGuard\wg.exe"), PathBuf::from(r"C:\Program Files (x86)\WireGuard\wg.exe"), @@ -820,7 +890,11 @@ fn find_wg_cli() -> Result { return Ok(path); } - Ok(PathBuf::from("wg.exe")) + if command_exists_on_windows_path("wg.exe") { + return Ok(PathBuf::from("wg.exe")); + } + + Err("required Windows wg.exe runtime is not available".to_string()) } #[cfg(target_os = "macos")] @@ -880,6 +954,10 @@ fn macos_command_path_prefix(tool_path: &Path) -> String { #[cfg(target_os = "windows")] fn find_windows_wireguard() -> Result { + if let Some(path) = bundled_windows_tool("wireguard.exe") { + return Ok(path); + } + let candidates = [ PathBuf::from(r"C:\Program Files\WireGuard\wireguard.exe"), PathBuf::from(r"C:\Program Files (x86)\WireGuard\wireguard.exe"), @@ -888,9 +966,50 @@ fn find_windows_wireguard() -> Result { candidates .into_iter() .find(|path| path.exists()) + .or_else(|| command_exists_on_windows_path("wireguard.exe").then_some(PathBuf::from("wireguard.exe"))) .ok_or_else(|| "required Windows tunnel runtime is not available".to_string()) } +#[cfg(target_os = "windows")] +fn ensure_windows_runtime_installed() -> Result<(), String> { + if windows_wireguard_runtime_ready() { + return Ok(()); + } + + install_windows_runtime() +} + +#[cfg(target_os = "windows")] +fn windows_wireguard_runtime_ready() -> bool { + find_windows_wireguard().is_ok() && find_wg_cli().is_ok() +} + +#[cfg(target_os = "windows")] +fn bundled_windows_tool(name: &str) -> Option { + let current_exe = env::current_exe().ok()?; + let dir = current_exe.parent()?; + let path = dir.join(name); + path.exists().then_some(path) +} + +#[cfg(target_os = "windows")] +fn bundled_windows_installer() -> Option { + let current_exe = env::current_exe().ok()?; + let dir = current_exe.parent()?; + let path = dir.join("wireguard-installer.msi"); + path.exists().then_some(path) +} + +#[cfg(target_os = "windows")] +fn command_exists_on_windows_path(name: &str) -> bool { + Command::new("where.exe") + .arg(name) + .creation_flags(CREATE_NO_WINDOW) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + #[cfg(target_os = "windows")] fn format_windows_runtime_error(action: &str, output: &Output) -> String { let stderr = String::from_utf8_lossy(&output.stderr);