From c87f373283392300decb0b5e4d925bfaf8ed1081 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:11:00 +0400 Subject: [PATCH] feat: portable self-update --- AGENTS.md | 1 + src-tauri/src/app_auto_updater.rs | 166 ++++++++++---- src-tauri/src/app_dirs.rs | 2 +- src-tauri/src/lib.rs | 7 + src-tauri/src/portable_update.rs | 351 ++++++++++++++++++++++++++++++ src/i18n/locales/de.json | 1 + src/i18n/locales/en.json | 1 + src/i18n/locales/es.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/ko.json | 1 + src/i18n/locales/pt.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/tr.json | 1 + src/i18n/locales/vi.json | 1 + src/i18n/locales/zh.json | 1 + src/lib/backend-errors.ts | 3 + 17 files changed, 499 insertions(+), 42 deletions(-) create mode 100644 src-tauri/src/portable_update.rs diff --git a/AGENTS.md b/AGENTS.md index c304d240..2a97957d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ donutbrowser/ │ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management │ │ ├── downloader.rs # Browser binary downloader │ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi) +│ │ ├── portable_update.rs # Portable Windows ZIP self-update: staged in .update/, files swapped at restart │ │ ├── settings_manager.rs # App settings persistence (atomic writes), tips + paid-welcome state │ │ ├── system_proxy.rs # The OS proxy Donut's own requests use; named in the error when it is unreachable │ │ ├── vault.rs # Per-install key that seals local secrets; opens legacy build-password seals once diff --git a/src-tauri/src/app_auto_updater.rs b/src-tauri/src/app_auto_updater.rs index 88733196..1842538a 100644 --- a/src-tauri/src/app_auto_updater.rs +++ b/src-tauri/src/app_auto_updater.rs @@ -17,6 +17,7 @@ across multiple operating systems and installation methods. - MSI: Silent installation using msiexec - EXE: Silent installation with multiple fallback flags (NSIS, Inno Setup) - ZIP: Binary replacement + - Portable ZIP: files swapped at restart, data kept (`portable_update.rs`) - **Architecture**: Supports both x64 and x86_64 ### Linux @@ -157,10 +158,11 @@ pub struct AppUpdateInfo { } impl AppUpdateInfo { - /// What this install may do with the update. A portable copy is replaced - /// by hand; with automatic updates off, the person starts the install. - fn with_install_policy(mut self, portable: bool, auto_updates_disabled: bool) -> Self { - if portable { + /// What this install may do with the update. A copy replaced by hand only + /// hears about it; with automatic updates off, the person starts the + /// install. + fn with_install_policy(mut self, replaced_by_hand: bool, auto_updates_disabled: bool) -> Self { + if replaced_by_hand { self.manual_update_required = true; } self.auto_install = !self.manual_update_required && !auto_updates_disabled; @@ -722,6 +724,9 @@ impl AppAutoUpdater { #[cfg(target_os = "windows")] { + if crate::app_dirs::is_portable() { + return crate::portable_update::asset_url(assets, arch); + } self.get_windows_download_url(assets, arch) } @@ -1070,6 +1075,15 @@ impl AppAutoUpdater { } /// Download the update file without progress tracking (silent download) + /// Where an update is downloaded. A portable copy keeps it on its own drive. + fn download_dir() -> PathBuf { + #[cfg(target_os = "windows")] + if let Some(install_dir) = crate::app_dirs::portable_dir() { + return crate::portable_update::update_dir(install_dir); + } + std::env::temp_dir().join("donut_app_update") + } + async fn download_update_silent( &self, download_url: &str, @@ -1140,7 +1154,7 @@ impl AppAutoUpdater { update_info: &AppUpdateInfo, stage: &mut &'static str, ) -> Result<(String, u64, &'static str), Box> { - let temp_dir = std::env::temp_dir().join("donut_app_update"); + let temp_dir = Self::download_dir(); fs::create_dir_all(&temp_dir)?; let filename = update_info @@ -1168,6 +1182,16 @@ impl AppAutoUpdater { update_info.asset_digest.as_deref(), )?; + // The swap needs every Donut process gone, so it waits for the restart. + #[cfg(target_os = "windows")] + if let Some(install_dir) = crate::app_dirs::portable_dir() { + *stage = "extract"; + let package = crate::portable_update::stage(&download_path, install_dir).await?; + let _ = fs::remove_file(&download_path); + *PENDING_UPDATE.lock().unwrap() = Some(PendingUpdate::Portable(package)); + return Ok((filename, bytes, "portable_deferred_to_restart")); + } + *stage = "extract"; let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?; @@ -1182,7 +1206,7 @@ impl AppAutoUpdater { .unwrap_or("") .to_lowercase(); if ext == "msi" || ext == "exe" { - *PENDING_INSTALLER_PATH.lock().unwrap() = Some(extracted_app_path); + *PENDING_UPDATE.lock().unwrap() = Some(PendingUpdate::Installer(extracted_app_path)); return Ok((filename, bytes, "installer_deferred_to_restart")); } *stage = "install"; @@ -1930,6 +1954,73 @@ impl AppAutoUpdater { ) } + /// Moves the staged portable update into place once no Donut process runs + /// from the old files. On failure the current version stays. + #[cfg(target_os = "windows")] + async fn install_portable_update( + package: &Path, + exe: &Path, + ) -> Result<(), Box> { + Self::prepare_windows_installer().await?; + let started = std::time::Instant::now(); + let (Some(install_dir), Some(exe_name)) = (exe.parent(), exe.file_name()) else { + return Err("The portable executable has no folder".into()); + }; + let package = package.to_path_buf(); + let install_dir = install_dir.to_path_buf(); + let exe_name = exe_name.to_os_string(); + let applied = tokio::task::spawn_blocking(move || { + crate::portable_update::apply(&package, &install_dir, &exe_name) + }) + .await?; + match applied { + Ok(files) => { + log::info!( + "Portable update installed files={files} elapsed_ms={}", + started.elapsed().as_millis() + ); + Ok(()) + } + Err(e) => { + log::error!("Portable update failed, current version kept err=\"{e}\""); + Err( + serde_json::json!({ "code": "PORTABLE_UPDATE_FAILED" }) + .to_string() + .into(), + ) + } + } + } + + /// Starts `app_path` once this process has exited, from a detached script. + #[cfg(target_os = "windows")] + fn relaunch_after_exit(app_path: &Path) -> Result<(), Box> { + let current_pid = std::process::id(); + let script_path = std::env::temp_dir().join("donut_restart.bat"); + let script_content = format!( + "@echo off\n\ + :w\n\ + tasklist /fi \"PID eq {current_pid}\" 2>nul | find \"{current_pid}\" >nul && (timeout /t 1 /nobreak >nul & goto w)\n\ + timeout /t 1 /nobreak >nul\n\ + start \"\" \"{app}\"\n\ + del \"%~f0\"\n", + app = app_path.to_str().unwrap(), + ); + fs::write(&script_path, script_content)?; + + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + let child = Command::new("cmd") + .args(["/C", script_path.to_str().unwrap()]) + .creation_flags(CREATE_NO_WINDOW) + .spawn()?; + log::info!( + "Process spawned pid={} purpose=\"relaunch after exit\" waits_for_pid={current_pid}", + child.id() + ); + Ok(()) + } + /// Restart the application async fn restart_application(&self) -> Result<(), Box> { #[cfg(target_os = "macos")] @@ -1997,11 +2088,19 @@ rm "{}" use std::ffi::OsStr; use std::os::windows::ffi::OsStrExt; - let pending = PENDING_INSTALLER_PATH.lock().unwrap().take(); + let pending = PENDING_UPDATE.lock().unwrap().take(); - if let Some(installer_path) = pending { + if let Some(PendingUpdate::Portable(package)) = pending { + // Read before the swap gives the running file another name. + let exe = std::env::current_exe()?; + if let Err(e) = Self::install_portable_update(&package, &exe).await { + *PENDING_UPDATE.lock().unwrap() = Some(PendingUpdate::Portable(package)); + return Err(e); + } + Self::relaunch_after_exit(&exe)?; + } else if let Some(PendingUpdate::Installer(installer_path)) = pending { if let Err(e) = Self::prepare_windows_installer().await { - *PENDING_INSTALLER_PATH.lock().unwrap() = Some(installer_path); + *PENDING_UPDATE.lock().unwrap() = Some(PendingUpdate::Installer(installer_path)); return Err(e); } @@ -2084,34 +2183,7 @@ rm "{}" parameters ); } else { - // No pending installer — just restart the app. Use a minimal - // detached process to relaunch after we exit. - let app_path = self.get_current_app_path()?; - let current_pid = std::process::id(); - let temp_dir = std::env::temp_dir(); - let script_path = temp_dir.join("donut_restart.bat"); - - let script_content = format!( - "@echo off\n\ - :w\n\ - tasklist /fi \"PID eq {current_pid}\" 2>nul | find \"{current_pid}\" >nul && (timeout /t 1 /nobreak >nul & goto w)\n\ - timeout /t 1 /nobreak >nul\n\ - start \"\" \"{app}\"\n\ - del \"%~f0\"\n", - app = app_path.to_str().unwrap(), - ); - fs::write(&script_path, script_content)?; - - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - let child = Command::new("cmd") - .args(["/C", script_path.to_str().unwrap()]) - .creation_flags(CREATE_NO_WINDOW) - .spawn()?; - log::info!( - "Process spawned pid={} purpose=\"relaunch after exit\" waits_for_pid={current_pid}", - child.id() - ); + Self::relaunch_after_exit(&self.get_current_app_path()?)?; } tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; @@ -2213,7 +2285,10 @@ fn with_install_policy(update: Option) -> Option { .load_settings() .map(|s| s.disable_auto_updates) .unwrap_or(false); - update.map(|info| info.with_install_policy(crate::app_dirs::is_portable(), auto_updates_disabled)) + // Releases ship a portable ZIP for Windows only. Anywhere else a `.portable` + // copy would lose its marker to the installer. + let replaced_by_hand = crate::app_dirs::is_portable() && !cfg!(target_os = "windows"); + update.map(|info| info.with_install_policy(replaced_by_hand, auto_updates_disabled)) } #[tauri::command] @@ -2371,9 +2446,18 @@ fn status_logged(cmd: &mut Command, purpose: &str) -> std::io::Result> = std::sync::Mutex::new(None); +enum PendingUpdate { + /// An NSIS or MSI installer. + Installer(PathBuf), + /// The unpacked files of the portable ZIP. + Portable(PathBuf), +} + +#[cfg(target_os = "windows")] +static PENDING_UPDATE: std::sync::Mutex> = std::sync::Mutex::new(None); #[cfg(test)] mod tests { @@ -2844,7 +2928,7 @@ mod tests { } #[test] - fn a_portable_copy_is_told_to_download_by_hand() { + fn a_copy_replaced_by_hand_is_told_to_download() { for disabled in [false, true] { let info = update_info(false).with_install_policy(true, disabled); assert!(info.manual_update_required); diff --git a/src-tauri/src/app_dirs.rs b/src-tauri/src/app_dirs.rs index 9110b812..8fa0b893 100644 --- a/src-tauri/src/app_dirs.rs +++ b/src-tauri/src/app_dirs.rs @@ -10,7 +10,7 @@ fn base_dirs() -> &'static BaseDirs { } /// Returns the portable base directory if a `.portable` marker exists next to the executable. -fn portable_dir() -> Option<&'static PathBuf> { +pub fn portable_dir() -> Option<&'static PathBuf> { PORTABLE_DIR .get_or_init(|| { std::env::current_exe() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6e5cf360..13ab7396 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -206,6 +206,7 @@ mod log_redaction; pub mod log_streak; mod log_viewer; mod platform_browser; +mod portable_update; mod process_report; mod profile; mod profile_dns; @@ -2696,6 +2697,12 @@ pub fn run_with_builder( data_dir ); + // Before an update check can stage a new portable update there. + #[cfg(target_os = "windows")] + if let Some(dir) = app_dirs::portable_dir() { + portable_update::clean_up(dir); + } + // Before the first request Donut makes. app_proxy::load(); diff --git a/src-tauri/src/portable_update.rs b/src-tauri/src/portable_update.rs new file mode 100644 index 00000000..15bfdbad --- /dev/null +++ b/src-tauri/src/portable_update.rs @@ -0,0 +1,351 @@ +//! Self-update of the portable Windows ZIP. +//! +//! The release's portable ZIP is checked against SHA256SUMS.txt like every +//! other update, then unpacked into `.update/staged` inside the portable +//! folder. At "Restart now" each program file moves aside into `.update/old` +//! and the new file takes its place: Windows lets a running executable be +//! renamed, never overwritten. `data`, `cache` and `logs` stay where they are. +//! The next start removes `.update`. +#![cfg_attr(not(target_os = "windows"), allow(dead_code))] + +use std::ffi::OsStr; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::app_auto_updater::AppReleaseAsset; + +/// Scratch folder inside the portable folder. It keeps the download on the +/// portable drive, and a rename inside one volume cannot fail halfway. +const UPDATE_DIR: &str = ".update"; +const STAGED_DIR: &str = "staged"; +const OLD_DIR: &str = "old"; +/// The main executable's name in the ZIP. +const PACKAGE_EXE: &str = "Donut.exe"; +/// What the portable folder keeps across updates. +const KEPT: [&str; 4] = ["data", "cache", "logs", UPDATE_DIR]; +/// An antivirus scan holds a new file for a moment after it is written. +const RENAME_ATTEMPTS: u32 = 20; +const RENAME_PAUSE: Duration = Duration::from_millis(250); + +/// The portable ZIP for `arch` among a release's assets: +/// `Donut__x64-portable.zip`, or `Donut_x64-portable.zip` for a +/// nightly. +pub fn asset_url(assets: &[AppReleaseAsset], arch: &str) -> Option { + let suffix = format!("_{arch}-portable.zip"); + assets + .iter() + .find(|asset| asset.name.to_lowercase().ends_with(&suffix)) + .map(|asset| asset.browser_download_url.clone()) +} + +pub fn update_dir(install_dir: &Path) -> PathBuf { + install_dir.join(UPDATE_DIR) +} + +/// Unpacks a verified ZIP into `.update/staged`. Returns the folder that holds +/// the package's files. +pub async fn stage( + zip: &Path, + install_dir: &Path, +) -> Result> { + let staged = update_dir(install_dir).join(STAGED_DIR); + if staged.exists() { + fs::remove_dir_all(&staged)?; + } + crate::extraction::Extractor::instance() + .extract_zip(zip, &staged, None) + .await?; + package_root(&staged).ok_or_else(|| format!("The portable ZIP has no {PACKAGE_EXE}").into()) +} + +/// The ZIP holds one `Donut-Portable` folder; files at the top count too. +fn package_root(staged: &Path) -> Option { + if staged.join(PACKAGE_EXE).is_file() { + return Some(staged.to_path_buf()); + } + fs::read_dir(staged) + .ok()? + .flatten() + .map(|entry| entry.path()) + .find(|path| path.join(PACKAGE_EXE).is_file()) +} + +/// Moves every file of `package` into `install_dir`. The file it replaces +/// moves into `.update/old` first. The package's `Donut.exe` takes +/// `exe_name`, the name of the running executable. When a move fails, the +/// moves before it are undone. Returns the number of files replaced. +pub fn apply(package: &Path, install_dir: &Path, exe_name: &OsStr) -> io::Result { + let old = update_dir(install_dir).join(OLD_DIR); + if old.exists() { + fs::remove_dir_all(&old)?; + } + let files = package_files(package)?; + let mut done: Vec<(PathBuf, Option)> = Vec::new(); + for relative in &files { + let target_relative = if relative.as_os_str() == PACKAGE_EXE { + PathBuf::from(exe_name) + } else { + relative.clone() + }; + let target = install_dir.join(&target_relative); + match swap( + &package.join(relative), + &target, + &old.join(&target_relative), + ) { + Ok(aside) => done.push((target, aside)), + Err(e) => { + undo(&done); + return Err(io::Error::new( + e.kind(), + format!("{}: {e}", target_relative.display()), + )); + } + } + } + Ok(files.len()) +} + +/// Puts `new` at `target`, keeping the file it replaces at `aside`. +fn swap(new: &Path, target: &Path, aside: &Path) -> io::Result> { + let moved = if target.symlink_metadata().is_ok() { + if let Some(parent) = aside.parent() { + fs::create_dir_all(parent)?; + } + rename(target, aside)?; + Some(aside.to_path_buf()) + } else { + None + }; + let placed = target + .parent() + .map_or(Ok(()), fs::create_dir_all) + .and_then(|()| rename(new, target)); + if let Err(e) = placed { + if let Some(aside) = &moved { + let _ = rename(aside, target); + } + return Err(e); + } + Ok(moved) +} + +fn undo(done: &[(PathBuf, Option)]) { + for (target, aside) in done.iter().rev() { + let restored = match aside { + Some(aside) => rename(aside, target), + None => fs::remove_file(target), + }; + if let Err(e) = restored { + log::error!( + "Portable update undo failed path=\"{}\" err=\"{e}\"", + target.display() + ); + } + } +} + +fn rename(from: &Path, to: &Path) -> io::Result<()> { + let mut attempt = 1; + loop { + match fs::rename(from, to) { + Err(e) if e.kind() == io::ErrorKind::PermissionDenied && attempt < RENAME_ATTEMPTS => { + attempt += 1; + std::thread::sleep(RENAME_PAUSE); + } + result => return result, + } + } +} + +/// The package's files, relative to its root, without anything the portable +/// folder keeps. +fn package_files(package: &Path) -> io::Result> { + let mut files = Vec::new(); + let mut dirs = vec![PathBuf::new()]; + while let Some(dir) = dirs.pop() { + for entry in fs::read_dir(package.join(&dir))? { + let entry = entry?; + let relative = dir.join(entry.file_name()); + if dir.as_os_str().is_empty() && KEPT.iter().any(|kept| entry.file_name() == *kept) { + continue; + } + if entry.file_type()?.is_dir() { + dirs.push(relative); + } else { + files.push(relative); + } + } + } + files.sort(); + Ok(files) +} + +/// Removes what the last update left: the old program files, which could not +/// be deleted while they ran, and a staged update that was never applied. +pub fn clean_up(install_dir: &Path) { + let dir = update_dir(install_dir); + if !dir.exists() { + return; + } + match fs::remove_dir_all(&dir) { + Ok(()) => log::debug!("Portable update leftovers removed"), + Err(e) => log::warn!( + "Portable update leftovers not removed path=\"{}\" err=\"{e}\"", + dir.display() + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn asset(name: &str) -> AppReleaseAsset { + AppReleaseAsset { + name: name.to_string(), + browser_download_url: format!("https://example.invalid/{name}"), + size: 1, + digest: None, + } + } + + fn write(path: &Path, content: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + + fn read(path: &Path) -> String { + fs::read_to_string(path).unwrap() + } + + /// A portable folder at version 1 with data, and version 2 staged. + fn folder() -> (tempfile::TempDir, PathBuf, PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let install = tmp.path().join("Donut-Portable"); + write(&install.join("Donut.exe"), "app 1"); + write(&install.join("donut-proxy.exe"), "proxy 1"); + write(&install.join(".portable"), ""); + write(&install.join("data/profiles/a/Cookies"), "login"); + write(&install.join("logs/DonutBrowser.log"), "log"); + let package = update_dir(&install).join("staged/Donut-Portable"); + write(&package.join("Donut.exe"), "app 2"); + write(&package.join("donut-proxy.exe"), "proxy 2"); + write(&package.join("xray.exe"), "xray 2"); + write(&package.join("licenses/Xray-core-LICENSE.txt"), "license 2"); + write(&package.join(".portable"), ""); + (tmp, install, package) + } + + #[test] + fn picks_the_portable_zip_of_a_stable_or_nightly_release() { + let stable = [ + asset("Donut_0.36.0_x64-setup.exe"), + asset("Donut_0.36.0_x64-portable.zip"), + asset("SHA256SUMS.txt"), + ]; + assert_eq!( + asset_url(&stable, "x64").as_deref(), + Some("https://example.invalid/Donut_0.36.0_x64-portable.zip") + ); + let nightly = [ + asset("Donut_nightly_x64-setup.exe"), + asset("Donut_x64-portable.zip"), + ]; + assert_eq!( + asset_url(&nightly, "x64").as_deref(), + Some("https://example.invalid/Donut_x64-portable.zip") + ); + assert_eq!(asset_url(&stable, "aarch64"), None); + } + + #[test] + fn finds_the_package_in_its_folder_or_at_the_top() { + let (_tmp, install, package) = folder(); + let staged = update_dir(&install).join(STAGED_DIR); + assert_eq!(package_root(&staged), Some(package.clone())); + assert_eq!(package_root(&package), Some(package)); + assert_eq!(package_root(&install.join("data")), None); + } + + #[test] + fn replaces_the_program_and_keeps_the_data() { + let (_tmp, install, package) = folder(); + assert_eq!( + apply(&package, &install, OsStr::new("Donut.exe")).unwrap(), + 5 + ); + assert_eq!(read(&install.join("Donut.exe")), "app 2"); + assert_eq!(read(&install.join("donut-proxy.exe")), "proxy 2"); + assert_eq!(read(&install.join("xray.exe")), "xray 2"); + assert_eq!( + read(&install.join("licenses/Xray-core-LICENSE.txt")), + "license 2" + ); + assert_eq!(read(&install.join("data/profiles/a/Cookies")), "login"); + assert_eq!(read(&install.join("logs/DonutBrowser.log")), "log"); + let old = update_dir(&install).join(OLD_DIR); + assert_eq!(read(&old.join("Donut.exe")), "app 1"); + assert_eq!(read(&old.join("donut-proxy.exe")), "proxy 1"); + + clean_up(&install); + assert!(!update_dir(&install).exists()); + assert!(install.join(".portable").exists()); + } + + #[test] + fn a_renamed_executable_keeps_its_name() { + let (_tmp, install, package) = folder(); + fs::rename(install.join("Donut.exe"), install.join("Work.exe")).unwrap(); + apply(&package, &install, OsStr::new("Work.exe")).unwrap(); + assert_eq!(read(&install.join("Work.exe")), "app 2"); + assert!(!install.join("Donut.exe").exists()); + } + + #[test] + fn never_takes_data_from_a_package() { + let (_tmp, install, package) = folder(); + write(&package.join("data/profiles/a/Cookies"), "empty"); + write(&package.join("logs/DonutBrowser.log"), "empty"); + apply(&package, &install, OsStr::new("Donut.exe")).unwrap(); + assert_eq!(read(&install.join("data/profiles/a/Cookies")), "login"); + assert_eq!(read(&install.join("logs/DonutBrowser.log")), "log"); + } + + #[test] + fn a_failed_move_restores_the_old_version() { + let (_tmp, install, package) = folder(); + // A file where the package has a folder: the license cannot move in, + // after Donut.exe and donut-proxy.exe did. + write(&install.join("licenses"), "in the way"); + assert!(apply(&package, &install, OsStr::new("Donut.exe")).is_err()); + assert_eq!(read(&install.join("Donut.exe")), "app 1"); + assert_eq!(read(&install.join("donut-proxy.exe")), "proxy 1"); + assert_eq!(read(&install.join("licenses")), "in the way"); + assert!(!install.join("xray.exe").exists()); + assert_eq!(read(&install.join("data/profiles/a/Cookies")), "login"); + } + + /// The property the update depends on: a running executable can be renamed + /// and another file can take its name. + #[cfg(target_os = "windows")] + #[test] + fn replaces_a_running_executable() { + let (_tmp, install, package) = folder(); + let system = std::env::var_os("SystemRoot").unwrap(); + let ping = PathBuf::from(system).join("System32").join("PING.EXE"); + fs::copy(&ping, install.join("Donut.exe")).unwrap(); + let mut running = std::process::Command::new(install.join("Donut.exe")) + .args(["-n", "30", "127.0.0.1"]) + .stdout(std::process::Stdio::null()) + .spawn() + .unwrap(); + let result = apply(&package, &install, OsStr::new("Donut.exe")); + let _ = running.kill(); + let _ = running.wait(); + result.unwrap(); + assert_eq!(read(&install.join("Donut.exe")), "app 2"); + } +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index fc31bd48..1c5d5218 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2677,6 +2677,7 @@ "wayfernNotResponding": "Wayfern wurde gestartet, hat aber nicht innerhalb von {{seconds}} Sekunden geantwortet. Möglicherweise hält eine Sicherheitssoftware es fest, oder es konnte sein Fenster nicht öffnen. Erlauben Sie Wayfern in Ihrem Virenschutz und versuchen Sie es erneut.", "updateProfilesRunning": "Stoppen Sie alle laufenden Profile, bevor Sie das Update installieren.", "updatePreparationFailed": "Donut Browser konnte einen Netzwerkprozess im Hintergrund nicht sicher beenden. Starten Sie Ihren Computer neu und versuchen Sie das Update dann erneut.", + "portableUpdateFailed": "Donut Browser konnte seine Dateien im portablen Ordner nicht ersetzen und behält die aktuelle Version. Schließen Sie Programme, die Dateien in diesem Ordner verwenden, und starten Sie dann erneut neu.", "wayfernTermsRequired": "Akzeptieren Sie die Nutzungsbedingungen von Wayfern, um Wayfern zu verwenden.", "apiPortUnavailable": "Der API-Server konnte keinen freien Port finden.", "mcpAgentUnknown": "Dieser MCP-Client wird nicht unterstützt.", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a2b585c0..43f3587a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2677,6 +2677,7 @@ "wayfernNotResponding": "Wayfern started but did not answer within {{seconds}} seconds. Security software may be holding it, or it could not open its window. Allow Wayfern in your antivirus, then try again.", "updateProfilesRunning": "Stop all running profiles before installing the update.", "updatePreparationFailed": "Donut Browser could not safely stop a background network process. Restart your computer, then try the update again.", + "portableUpdateFailed": "Donut Browser could not replace its files in the portable folder and kept the current version. Close programs that use files in that folder, then restart again.", "wayfernTermsRequired": "Accept the Wayfern Terms and Conditions to use Wayfern.", "apiPortUnavailable": "The API server could not find an available port.", "mcpAgentUnknown": "This MCP client is not supported.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 1860ba4f..07c935d2 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2696,6 +2696,7 @@ "wayfernNotResponding": "Wayfern se inició, pero no respondió en {{seconds}} segundos. Puede que un software de seguridad lo esté reteniendo o que no haya podido abrir su ventana. Permite Wayfern en tu antivirus y vuelve a intentarlo.", "updateProfilesRunning": "Detén todos los perfiles en ejecución antes de instalar la actualización.", "updatePreparationFailed": "Donut Browser no pudo detener de forma segura un proceso de red en segundo plano. Reinicia el equipo y vuelve a intentar la actualización.", + "portableUpdateFailed": "Donut Browser no pudo reemplazar sus archivos en la carpeta portátil y mantiene la versión actual. Cierra los programas que usan archivos de esa carpeta y vuelve a reiniciar.", "wayfernTermsRequired": "Acepta los Términos y condiciones de Wayfern para usar Wayfern.", "apiPortUnavailable": "El servidor API no pudo encontrar un puerto disponible.", "mcpAgentUnknown": "Este cliente MCP no es compatible.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 4af6ddb9..02b39943 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2696,6 +2696,7 @@ "wayfernNotResponding": "Wayfern a démarré mais n'a pas répondu en {{seconds}} secondes. Un logiciel de sécurité le bloque peut-être, ou il n'a pas pu ouvrir sa fenêtre. Autorisez Wayfern dans votre antivirus, puis réessayez.", "updateProfilesRunning": "Arrêtez tous les profils en cours d’exécution avant d’installer la mise à jour.", "updatePreparationFailed": "Donut Browser n’a pas pu arrêter en toute sécurité un processus réseau en arrière-plan. Redémarrez l’ordinateur, puis réessayez la mise à jour.", + "portableUpdateFailed": "Donut Browser n’a pas pu remplacer ses fichiers dans le dossier portable et garde la version actuelle. Fermez les programmes qui utilisent des fichiers de ce dossier, puis redémarrez à nouveau.", "wayfernTermsRequired": "Acceptez les conditions générales de Wayfern pour utiliser Wayfern.", "apiPortUnavailable": "Le serveur API n’a trouvé aucun port disponible.", "mcpAgentUnknown": "Ce client MCP n’est pas pris en charge.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 11af7591..9c1fcef2 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2677,6 +2677,7 @@ "wayfernNotResponding": "Wayfern は起動しましたが、{{seconds}} 秒以内に応答しませんでした。セキュリティソフトが止めているか、ウィンドウを開けなかった可能性があります。ウイルス対策ソフトで Wayfern を許可してから、もう一度お試しください。", "updateProfilesRunning": "アップデートをインストールする前に、実行中のプロファイルをすべて停止してください。", "updatePreparationFailed": "バックグラウンドのネットワークプロセスを安全に停止できませんでした。コンピューターを再起動してから、もう一度アップデートしてください。", + "portableUpdateFailed": "ポータブルフォルダー内のファイルを置き換えられなかったため、現在のバージョンのままです。このフォルダーのファイルを使っているプログラムを閉じてから、もう一度再起動してください。", "wayfernTermsRequired": "Wayfernを使用するには、Wayfernの利用規約に同意してください。", "apiPortUnavailable": "APIサーバーで利用可能なポートが見つかりませんでした。", "mcpAgentUnknown": "このMCPクライアントはサポートされていません。", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 2de5e0d6..e0a3610a 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -2677,6 +2677,7 @@ "wayfernNotResponding": "Wayfern이 시작되었지만 {{seconds}}초 안에 응답하지 않았습니다. 보안 소프트웨어가 막고 있거나 창을 열지 못했을 수 있습니다. 백신 프로그램에서 Wayfern을 허용한 뒤 다시 시도하세요.", "updateProfilesRunning": "업데이트를 설치하기 전에 실행 중인 모든 프로필을 중지하세요.", "updatePreparationFailed": "Donut Browser가 백그라운드 네트워크 프로세스를 안전하게 중지하지 못했습니다. 컴퓨터를 다시 시작한 후 업데이트를 다시 시도하세요.", + "portableUpdateFailed": "Donut Browser가 포터블 폴더의 파일을 교체하지 못해 현재 버전을 유지합니다. 이 폴더의 파일을 사용하는 프로그램을 닫은 후 다시 재시작하세요.", "wayfernTermsRequired": "Wayfern을 사용하려면 Wayfern 이용 약관에 동의하세요.", "apiPortUnavailable": "API 서버에서 사용 가능한 포트를 찾지 못했습니다.", "mcpAgentUnknown": "이 MCP 클라이언트는 지원되지 않습니다.", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 60fff176..f0ed12f8 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2696,6 +2696,7 @@ "wayfernNotResponding": "O Wayfern iniciou, mas não respondeu em {{seconds}} segundos. Um software de segurança pode estar bloqueando-o, ou ele não conseguiu abrir a janela. Permita o Wayfern no seu antivírus e tente novamente.", "updateProfilesRunning": "Pare todos os perfis em execução antes de instalar a atualização.", "updatePreparationFailed": "O Donut Browser não conseguiu encerrar com segurança um processo de rede em segundo plano. Reinicie o computador e tente atualizar novamente.", + "portableUpdateFailed": "O Donut Browser não conseguiu substituir os arquivos na pasta portátil e manteve a versão atual. Feche os programas que usam arquivos dessa pasta e reinicie novamente.", "wayfernTermsRequired": "Aceite os Termos e Condições da Wayfern para usar o Wayfern.", "apiPortUnavailable": "O servidor da API não encontrou uma porta disponível.", "mcpAgentUnknown": "Este cliente MCP não é compatível.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e2665a50..00e87643 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2715,6 +2715,7 @@ "wayfernNotResponding": "Wayfern запустился, но не ответил за {{seconds}} с. Возможно, его удерживает защитное ПО или он не смог открыть окно. Разрешите Wayfern в антивирусе и повторите попытку.", "updateProfilesRunning": "Остановите все запущенные профили перед установкой обновления.", "updatePreparationFailed": "Donut Browser не удалось безопасно остановить фоновый сетевой процесс. Перезагрузите компьютер и повторите обновление.", + "portableUpdateFailed": "Donut Browser не удалось заменить свои файлы в портативной папке, текущая версия сохранена. Закройте программы, которые используют файлы из этой папки, и перезапустите ещё раз.", "wayfernTermsRequired": "Примите Условия использования Wayfern, чтобы пользоваться Wayfern.", "apiPortUnavailable": "Серверу API не удалось найти свободный порт.", "mcpAgentUnknown": "Этот клиент MCP не поддерживается.", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 320d5e4f..c05fc755 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -2677,6 +2677,7 @@ "wayfernNotResponding": "Wayfern başladı ancak {{seconds}} saniye içinde yanıt vermedi. Bir güvenlik yazılımı onu bekletiyor olabilir veya penceresini açamadı. Antivirüs programınızda Wayfern'e izin verin ve yeniden deneyin.", "updateProfilesRunning": "Güncellemeyi yüklemeden önce çalışan tüm profilleri durdurun.", "updatePreparationFailed": "Donut Browser arka plandaki bir ağ işlemini güvenli şekilde durduramadı. Bilgisayarınızı yeniden başlatıp güncellemeyi tekrar deneyin.", + "portableUpdateFailed": "Donut Browser taşınabilir klasördeki dosyalarını değiştiremedi ve mevcut sürümü korudu. Bu klasördeki dosyaları kullanan programları kapatıp yeniden başlatın.", "wayfernTermsRequired": "Wayfern'ü kullanmak için Wayfern Hüküm ve Koşullarını kabul edin.", "apiPortUnavailable": "API sunucusu kullanılabilir bir bağlantı noktası bulamadı.", "mcpAgentUnknown": "Bu MCP istemcisi desteklenmiyor.", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index 52be3656..bf4a03dd 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -2677,6 +2677,7 @@ "wayfernNotResponding": "Wayfern đã khởi động nhưng không phản hồi trong {{seconds}} giây. Có thể phần mềm bảo mật đang giữ nó, hoặc nó không mở được cửa sổ. Hãy cho phép Wayfern trong phần mềm diệt virus rồi thử lại.", "updateProfilesRunning": "Hãy dừng tất cả hồ sơ đang chạy trước khi cài đặt bản cập nhật.", "updatePreparationFailed": "Donut Browser không thể dừng an toàn một tiến trình mạng chạy nền. Hãy khởi động lại máy tính rồi thử cập nhật lại.", + "portableUpdateFailed": "Donut Browser không thể thay thế các tệp trong thư mục di động và giữ phiên bản hiện tại. Hãy đóng các chương trình đang dùng tệp trong thư mục đó, rồi khởi động lại lần nữa.", "wayfernTermsRequired": "Hãy chấp nhận Điều khoản và Điều kiện của Wayfern để sử dụng Wayfern.", "apiPortUnavailable": "Máy chủ API không tìm thấy cổng khả dụng.", "mcpAgentUnknown": "Ứng dụng MCP này không được hỗ trợ.", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1b01675b..52ef23ef 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2677,6 +2677,7 @@ "wayfernNotResponding": "Wayfern 已启动,但未在 {{seconds}} 秒内响应。可能是安全软件拦截了它,或它无法打开窗口。请在杀毒软件中允许 Wayfern,然后重试。", "updateProfilesRunning": "安装更新前,请停止所有正在运行的配置文件。", "updatePreparationFailed": "Donut Browser 无法安全停止后台网络进程。请重启电脑,然后再次尝试更新。", + "portableUpdateFailed": "Donut Browser 无法替换便携文件夹中的文件,已保留当前版本。请关闭正在使用该文件夹中文件的程序,然后再次重启。", "wayfernTermsRequired": "请先接受 Wayfern 条款与条件,再使用 Wayfern。", "apiPortUnavailable": "API 服务器找不到可用端口。", "mcpAgentUnknown": "不支持此 MCP 客户端。", diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index d9cf68a2..2ff2389f 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -117,6 +117,7 @@ export type BackendErrorCode = | "PROFILE_ROUTE_UNAVAILABLE" | "UPDATE_PROFILES_RUNNING" | "UPDATE_PREPARATION_FAILED" + | "PORTABLE_UPDATE_FAILED" | "PROFILE_NAME_EXISTS" | "IMPORT_SOURCE_NOT_FOUND" | "IMPORT_SOURCE_NOT_CHROMIUM" @@ -703,6 +704,8 @@ export function translateBackendError(t: TFunction, err: unknown): string { return t("backendErrors.updateProfilesRunning"); case "UPDATE_PREPARATION_FAILED": return t("backendErrors.updatePreparationFailed"); + case "PORTABLE_UPDATE_FAILED": + return t("backendErrors.portableUpdateFailed"); case "PROFILE_NAME_EXISTS": return t("backendErrors.profileNameExists", { name: parsed.params?.name ?? "",