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
+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) {