refactor: block windows app update if the browser is running

This commit is contained in:
zhom
2026-07-20 00:16:49 +04:00
parent a4ed5c855a
commit a71dad735e
24 changed files with 345 additions and 30 deletions
+15
View File
@@ -0,0 +1,15 @@
!macro NSIS_HOOK_PREINSTALL
IfFileExists "$INSTDIR\donut-proxy.exe" 0 donut_proxy_preinstall_done
DetailPrint "Stopping Donut proxy workers before replacing application files"
nsExec::ExecToStack '"$SYSDIR\taskkill.exe" /F /T /IM "donut-proxy.exe"'
Pop $0
Pop $1
Sleep 1000
; Removing the old sidecar first prevents NSIS from retaining a same-version
; or previously locked executable while updating the main application.
Delete "$INSTDIR\donut-proxy.exe"
donut_proxy_preinstall_done:
!macroend
+114 -1
View File
@@ -1698,6 +1698,95 @@ impl AppAutoUpdater {
}
}
#[cfg(any(target_os = "windows", test))]
async fn prepare_windows_installer() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let profiles = match crate::profile::ProfileManager::instance().list_profiles() {
Ok(profiles) => profiles,
Err(e) => {
log::error!("Failed to inspect running profiles before app update: {e}");
return Err(
serde_json::json!({
"code": "UPDATE_PREPARATION_FAILED"
})
.to_string()
.into(),
);
}
};
let has_running_profiles = profiles.into_iter().any(|profile| {
profile
.process_id
.is_some_and(|pid| pid != 0 && crate::proxy_storage::is_process_running(pid))
});
if has_running_profiles {
return Err(
serde_json::json!({
"code": "UPDATE_PROFILES_RUNNING"
})
.to_string()
.into(),
);
}
let proxy_configs = crate::proxy_storage::list_proxy_configs();
let vpn_configs = crate::vpn_worker_storage::list_vpn_worker_configs();
let mut worker_pids: Vec<u32> = proxy_configs
.iter()
.filter_map(|config| config.pid)
.chain(vpn_configs.iter().filter_map(|config| config.pid))
.collect();
worker_pids.sort_unstable();
worker_pids.dedup();
let proxy_ids: Vec<String> = proxy_configs.into_iter().map(|config| config.id).collect();
let vpn_ids: Vec<String> = vpn_configs.into_iter().map(|config| config.id).collect();
let stop_proxies = futures_util::future::join_all(
proxy_ids
.iter()
.map(|id| crate::proxy_runner::stop_proxy_process(id)),
);
let stop_vpns = futures_util::future::join_all(
vpn_ids
.iter()
.map(|id| crate::vpn_worker_runner::stop_vpn_worker(id)),
);
let (proxy_results, vpn_results) = tokio::join!(stop_proxies, stop_vpns);
for result in proxy_results.into_iter().chain(vpn_results) {
if let Err(e) = result {
log::warn!("Failed to stop a network worker before app update: {e}");
}
}
for _ in 0..20 {
if worker_pids
.iter()
.all(|pid| !crate::proxy_storage::is_process_running(*pid))
{
return Ok(());
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
let remaining: Vec<u32> = worker_pids
.into_iter()
.filter(|pid| crate::proxy_storage::is_process_running(*pid))
.collect();
log::error!(
"App update aborted because donut-proxy worker PIDs are still running: {:?}",
remaining
);
Err(
serde_json::json!({
"code": "UPDATE_PREPARATION_FAILED"
})
.to_string()
.into(),
)
}
/// Restart the application
async fn restart_application(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
#[cfg(target_os = "macos")]
@@ -1764,6 +1853,11 @@ rm "{}"
let pending = PENDING_INSTALLER_PATH.lock().unwrap().take();
if let Some(installer_path) = pending {
if let Err(e) = Self::prepare_windows_installer().await {
*PENDING_INSTALLER_PATH.lock().unwrap() = Some(installer_path);
return Err(e);
}
// Use ShellExecuteW to run the installer directly — no batch script,
// no cmd.exe console window. The NSIS/MSI installer handles killing the
// old process and restarting the app natively (via /UPDATE and
@@ -1991,7 +2085,7 @@ pub async fn restart_application() -> Result<(), String> {
updater
.restart_application()
.await
.map_err(|e| format!("Failed to restart application: {e}"))
.map_err(|e| crate::wrap_backend_error(e, "Failed to restart application"))
}
#[tauri::command]
@@ -2197,6 +2291,25 @@ not-a-hash Donut_0.29.0_amd64.deb
assert_eq!(with.digest.as_deref(), Some("sha256:ab12"));
}
#[test]
fn test_windows_installer_hook_protects_sidecar_replacement() {
let _ = AppAutoUpdater::prepare_windows_installer;
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
assert_eq!(
config["bundle"]["windows"]["nsis"]["installerHooks"].as_str(),
Some("installer-hooks.nsh")
);
let hooks = include_str!("../installer-hooks.nsh");
assert!(hooks.contains("NSIS_HOOK_PREINSTALL"));
assert!(hooks.contains("IfFileExists \"$INSTDIR\\donut-proxy.exe\""));
assert!(hooks.contains("taskkill.exe"));
assert!(hooks.contains("donut-proxy.exe"));
assert!(hooks.contains("Delete \"$INSTDIR\\donut-proxy.exe\""));
}
#[test]
fn test_platform_specific_download_urls() {
let updater = AppAutoUpdater::instance();
+1
View File
@@ -110,6 +110,7 @@ async fn main() {
}));
let matches = Command::new("donut-proxy")
.version(env!("BUILD_VERSION"))
.subcommand(
Command::new("proxy")
.about("Manage proxy servers")
+2 -2
View File
@@ -274,7 +274,7 @@ impl BrowserRunner {
)
.await
.map_err(|e| {
let error_msg = format!("Failed to start local proxy for Wayfern: {e}");
let error_msg = crate::wrap_backend_error(e, "Failed to start local proxy for Wayfern");
log::error!("{}", error_msg);
error_msg
})?;
@@ -1297,7 +1297,7 @@ pub async fn launch_browser_profile_impl(
return format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH);
}
}
format!("Failed to launch browser or open URL: {e}")
crate::wrap_backend_error(e, "Failed to launch browser or open URL")
})?;
log::info!(
+4
View File
@@ -1614,6 +1614,10 @@ impl ProxyManager {
}
}
crate::proxy_runner::ensure_sidecar_version()
.await
.map_err(|e| e.to_string())?;
// Start a new proxy using the donut-proxy binary with the correct CLI interface
let mut proxy_cmd = app_handle
.shell()
+104
View File
@@ -4,11 +4,14 @@ use crate::proxy_storage::{
};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering};
lazy_static::lazy_static! {
static ref PROXY_PROCESSES: std::sync::Mutex<std::collections::HashMap<String, u32>> =
std::sync::Mutex::new(std::collections::HashMap::new());
}
static SIDECAR_VERSION_VERIFIED: AtomicBool = AtomicBool::new(false);
fn target_binary_name(base_name: &str) -> Option<String> {
let target = std::env::var("TARGET").ok()?;
@@ -156,6 +159,77 @@ pub(crate) fn find_sidecar_executable(
)
}
fn parse_sidecar_version(stdout: &[u8]) -> Option<String> {
let output = std::str::from_utf8(stdout).ok()?.trim();
output
.strip_prefix("donut-proxy ")
.map(str::trim)
.filter(|version| !version.is_empty() && !version.contains(char::is_whitespace))
.map(str::to_string)
}
fn sidecar_version_mismatch_error() -> Box<dyn std::error::Error> {
serde_json::json!({
"code": "PROXY_SIDECAR_VERSION_MISMATCH"
})
.to_string()
.into()
}
/// Verify that the installed sidecar was built for the same release as the
/// main app. Windows can otherwise retain an executing, locked sidecar while
/// NSIS replaces the app, leaving an incompatible mixed-version installation.
pub(crate) async fn ensure_sidecar_version() -> Result<(), Box<dyn std::error::Error>> {
if SIDECAR_VERSION_VERIFIED.load(Ordering::Acquire) {
return Ok(());
}
let executable = match find_sidecar_executable("donut-proxy") {
Ok(executable) => executable,
Err(e) => {
log::error!("Failed to locate donut-proxy for version verification: {e}");
return Err(sidecar_version_mismatch_error());
}
};
let mut command = std::process::Command::new(&executable);
command.arg("--version");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(CREATE_NO_WINDOW);
}
let output = match command.output() {
Ok(output) => output,
Err(e) => {
log::error!(
"Failed to run {} for version verification: {e}",
executable.display()
);
return Err(sidecar_version_mismatch_error());
}
};
let actual_version = parse_sidecar_version(&output.stdout);
let expected_version = env!("BUILD_VERSION");
if output.status.success() && actual_version.as_deref() == Some(expected_version) {
SIDECAR_VERSION_VERIFIED.store(true, Ordering::Release);
return Ok(());
}
log::error!(
"donut-proxy version mismatch: expected {}, got {:?}; status={}, stdout={:?}, stderr={:?}",
expected_version,
actual_version,
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
Err(sidecar_version_mismatch_error())
}
pub async fn start_proxy_process(
upstream_url: Option<String>,
port: Option<u16>,
@@ -173,6 +247,8 @@ pub async fn start_proxy_process_with_profile(
dns_allowlist_mode: bool,
local_protocol: Option<String>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
ensure_sidecar_version().await?;
let id = generate_proxy_id();
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
@@ -442,3 +518,31 @@ pub async fn stop_all_proxy_processes() -> Result<(), Box<dyn std::error::Error>
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::parse_sidecar_version;
#[test]
fn parses_exact_sidecar_version_output() {
assert_eq!(
parse_sidecar_version(b"donut-proxy v0.28.2\n").as_deref(),
Some("v0.28.2")
);
assert_eq!(
parse_sidecar_version(b"donut-proxy nightly-2026-07-19-a4ed5c8\r\n").as_deref(),
Some("nightly-2026-07-19-a4ed5c8")
);
}
#[test]
fn rejects_missing_or_ambiguous_sidecar_version_output() {
assert_eq!(parse_sidecar_version(b""), None);
assert_eq!(parse_sidecar_version(b"donut-proxy"), None);
assert_eq!(parse_sidecar_version(b"other-proxy v0.28.2"), None);
assert_eq!(
parse_sidecar_version(b"donut-proxy v0.28.2\nunexpected"),
None
);
}
}
+2
View File
@@ -100,6 +100,8 @@ async fn wait_for_vpn_worker_ready(
}
pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
crate::proxy_runner::ensure_sidecar_version().await?;
for config in list_vpn_worker_configs() {
if let Some(pid) = config.pid {
if !is_process_running(pid) {
+4 -1
View File
@@ -57,7 +57,10 @@
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
"timestampUrl": "",
"nsis": {
"installerHooks": "installer-hooks.nsh"
}
}
},
"plugins": {
+30 -1
View File
@@ -66,7 +66,17 @@ async fn setup_test() -> Result<std::path::PathBuf, Box<dyn std::error::Error +
.join("debug")
.join(proxy_binary_name);
if !proxy_binary.exists() {
let binary_is_current = proxy_binary.exists()
&& std::process::Command::new(&proxy_binary)
.arg("--version")
.output()
.is_ok_and(|output| {
output.status.success()
&& String::from_utf8_lossy(&output.stdout).trim()
== format!("donut-proxy {}", env!("BUILD_VERSION"))
});
if !binary_is_current {
println!("Building donut-proxy binary for integration tests...");
let build_status = std::process::Command::new("cargo")
.args(["build", "--bin", "donut-proxy"])
@@ -127,6 +137,25 @@ impl Drop for ProxyTestTracker {
}
}
#[tokio::test]
#[serial]
async fn test_sidecar_reports_build_version() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
{
let binary_path = setup_test().await?;
let output = TestUtils::execute_command(&binary_path, &["--version"]).await?;
assert!(
output.status.success(),
"donut-proxy --version failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
String::from_utf8(output.stdout)?.trim(),
format!("donut-proxy {}", env!("BUILD_VERSION"))
);
Ok(())
}
/// Test starting a local proxy without upstream proxy (DIRECT)
#[tokio::test]
#[serial]