mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-23 21:01:00 +02:00
refactor: block windows app update if the browser is running
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user