/*! # App Auto Updater This module provides comprehensive self-update functionality for the Donut Browser application across multiple operating systems and installation methods. ## Supported Platforms ### macOS - **Format**: DMG files - **Installation**: Replaces the .app bundle in place - **Architecture**: Supports both x64 and aarch64 (Apple Silicon) ### Windows - **Formats**: MSI (preferred), EXE, ZIP - **Installation**: - MSI: Silent installation using msiexec - EXE: Silent installation with multiple fallback flags (NSIS, Inno Setup) - ZIP: Binary replacement - **Architecture**: Supports both x64 and x86_64 ### Linux - **Formats**: DEB, RPM, AppImage, TAR.GZ - **Installation Methods**: - **DEB**: Uses dpkg or apt with pkexec for privilege escalation - **RPM**: Uses rpm, dnf, yum, or zypper with pkexec - **AppImage**: Direct replacement or installation to ~/.local/bin - **TAR.GZ**: Binary extraction and replacement - **Architecture**: Supports x64, x86_64, amd64, aarch64, arm64 ## Linux Installation Detection The updater automatically detects how the application was installed: - **AppImage**: Detected via APPIMAGE environment variable - **Package Manager**: Detected by executable location and package queries - **Manual**: Detected by location in user directories - **System**: Detected by location in system directories ## Update Process 1. **Check**: Fetches releases from GitHub API 2. **Filter**: Filters releases based on build type (stable vs nightly) 3. **Compare**: Compares versions using semantic versioning or commit hashes 4. **Download**: Downloads appropriate asset with progress tracking 5. **Extract**: Extracts or prepares installer based on format 6. **Install**: Installs using platform-appropriate method 7. **Restart**: Restarts application after successful installation ## Error Handling - Comprehensive error messages for each platform - Fallback mechanisms for different package managers - Backup creation before installation - Cleanup of temporary files - Graceful handling of permission issues ## Testing Includes comprehensive unit tests for: - Version comparison logic - Platform detection - Asset selection - Installation method detection (Linux) - File format support */ use crate::events; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; #[cfg(target_os = "linux")] #[derive(Debug, Clone)] enum LinuxInstallationMethod { Deb, // Installed via DEB package Rpm, // Installed via RPM package AppImage, // Running from AppImage Manual, // Manually installed (e.g., extracted tarball) Unknown, // Cannot determine } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct AppReleaseAsset { pub name: String, pub browser_download_url: String, pub size: u64, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct AppRelease { pub tag_name: String, pub name: String, pub body: String, pub published_at: String, pub prerelease: bool, pub assets: Vec, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct AppUpdateInfo { pub current_version: String, pub new_version: String, pub release_notes: String, pub download_url: String, pub is_nightly: bool, pub published_at: String, pub manual_update_required: bool, pub release_page_url: Option, /// True when a system package manager repo is configured (apt/dnf/zypper) pub repo_update: bool, } pub struct AppAutoUpdater { client: Client, extractor: &'static crate::extraction::Extractor, } impl AppAutoUpdater { fn new() -> Self { Self { client: Client::new(), extractor: crate::extraction::Extractor::instance(), } } pub fn instance() -> &'static AppAutoUpdater { &APP_AUTO_UPDATER } /// Check if running a nightly build based on environment variable pub fn is_nightly_build() -> bool { // If STABLE_RELEASE env var is set at compile time, it's a stable build if option_env!("STABLE_RELEASE").is_some() { return false; } // Also check if the current version starts with "nightly-" let current_version = Self::get_current_version(); if current_version.starts_with("nightly-") { return true; } // If STABLE_RELEASE is not set and version doesn't start with "nightly-", // it's still considered a nightly build (dev builds, main branch builds, etc.) true } /// Get current app version from build-time injection pub fn get_current_version() -> String { // Use build-time injected version instead of CARGO_PKG_VERSION env!("BUILD_VERSION").to_string() } /// Check for app updates pub async fn check_for_updates( &self, ) -> Result, Box> { let current_version = Self::get_current_version(); let is_nightly = Self::is_nightly_build(); log::info!("=== App Update Check ==="); log::info!("Current version: {current_version}"); log::info!("Is nightly build: {is_nightly}"); log::info!("STABLE_RELEASE env: {:?}", option_env!("STABLE_RELEASE")); let releases = self.fetch_app_releases().await?; log::info!("Fetched {} releases from GitHub", releases.len()); // Filter releases based on build type let filtered_releases: Vec<&AppRelease> = if is_nightly { // For nightly builds, look for nightly releases let nightly_releases: Vec<&AppRelease> = releases .iter() .filter(|release| release.tag_name.starts_with("nightly-")) .collect(); log::info!("Found {} nightly releases", nightly_releases.len()); nightly_releases } else { // For stable builds, look for stable releases (semver format) let stable_releases: Vec<&AppRelease> = releases .iter() .filter(|release| release.tag_name.starts_with('v')) .collect(); log::info!("Found {} stable releases", stable_releases.len()); stable_releases }; if filtered_releases.is_empty() { log::info!("No releases found for build type (nightly: {is_nightly})"); return Ok(None); } // Get the latest release let latest_release = filtered_releases[0]; log::info!( "Latest release: {} ({})", latest_release.tag_name, latest_release.name ); // Check if we need to update if self.should_update(¤t_version, &latest_release.tag_name, is_nightly) { log::info!("Update available!"); // Build the release page URL let release_page_url = format!( "https://github.com/zhom/donutbrowser/releases/tag/{}", latest_release.tag_name ); // Find the appropriate asset for current platform let download_url = self.get_download_url_for_platform(&latest_release.assets); // On Linux, when a package repo is configured, notify users to update via // their package manager instead of auto-downloading from GitHub. #[cfg(target_os = "linux")] { let repo_update = self.is_repo_configured(); let manual_update_required = download_url.is_none() || repo_update; let update_info = AppUpdateInfo { current_version, new_version: latest_release.tag_name.clone(), release_notes: latest_release.body.clone(), download_url: download_url.unwrap_or_else(|| release_page_url.clone()), is_nightly, published_at: latest_release.published_at.clone(), manual_update_required, release_page_url: Some(release_page_url), repo_update, }; log::info!( "Update info prepared: {} -> {} (manual_update_required: {}, repo_update: {})", update_info.current_version, update_info.new_version, update_info.manual_update_required, update_info.repo_update ); return Ok(Some(update_info)); } #[cfg(not(target_os = "linux"))] { if let Some(url) = download_url { let update_info = AppUpdateInfo { current_version, new_version: latest_release.tag_name.clone(), release_notes: latest_release.body.clone(), download_url: url, is_nightly, published_at: latest_release.published_at.clone(), manual_update_required: false, release_page_url: Some(release_page_url), repo_update: false, }; log::info!( "Update info prepared: {} -> {}", update_info.current_version, update_info.new_version ); return Ok(Some(update_info)); } else { log::info!("No suitable download asset found for current platform"); } } } else { log::info!("No update needed"); } Ok(None) } /// Fetch app releases from GitHub async fn fetch_app_releases( &self, ) -> Result, Box> { let url = "https://api.github.com/repos/zhom/donutbrowser/releases?per_page=100"; let response = self .client .get(url) .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") .send() .await?; if !response.status().is_success() { return Err(format!("GitHub API request failed: {}", response.status()).into()); } let releases: Vec = response.json().await?; Ok(releases) } /// Determine if an update should be performed fn should_update(&self, current_version: &str, new_version: &str, is_nightly: bool) -> bool { if current_version.starts_with("dev-") { return false; } log::info!( "Comparing versions: current={current_version}, new={new_version}, is_nightly={is_nightly}" ); if is_nightly { // For nightly builds, always update if there's a newer nightly if let (Some(current_hash), Some(new_hash)) = ( current_version.strip_prefix("nightly-"), new_version.strip_prefix("nightly-"), ) { // Different commit hashes mean we should update let should_update = new_hash != current_hash; log::info!("Nightly comparison: current_hash={current_hash}, new_hash={new_hash}, should_update={should_update}"); return should_update; } // If current version doesn't have nightly prefix but we're in nightly mode, // this could be a dev build or stable build upgrading to nightly if !current_version.starts_with("nightly-") { log::info!("Upgrading from non-nightly to nightly: {new_version}"); return true; } } else { // For stable builds, use semantic versioning comparison let should_update = self.is_version_newer(new_version, current_version); log::info!("Stable comparison: {new_version} > {current_version} = {should_update}"); return should_update; } false } /// Compare semantic versions (returns true if version1 > version2) fn is_version_newer(&self, version1: &str, version2: &str) -> bool { let v1 = self.parse_semver(version1); let v2 = self.parse_semver(version2); v1 > v2 } /// Parse semantic version string into comparable tuple fn parse_semver(&self, version: &str) -> (u32, u32, u32) { let clean_version = version.trim_start_matches('v'); let parts: Vec<&str> = clean_version.split('.').collect(); let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0); let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0); let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); (major, minor, patch) } /// Detect if we're running from an AppImage #[cfg(target_os = "linux")] fn is_running_from_appimage(&self) -> bool { // Check APPIMAGE environment variable first if std::env::var("APPIMAGE").is_ok() { return true; } // Check if current executable path looks like an AppImage if let Ok(exe_path) = std::env::current_exe() { if let Some(file_name) = exe_path.file_name().and_then(|n| n.to_str()) { if file_name.to_lowercase().contains("appimage") { return true; } } // Check if the executable is in a temporary mount point (typical for AppImages) if let Some(path_str) = exe_path.to_str() { if path_str.contains("/tmp/.mount_") || path_str.contains("/tmp/appimage") { return true; } } } false } /// Detect how the application was installed on Linux #[cfg(target_os = "linux")] fn detect_linux_installation_method(&self) -> LinuxInstallationMethod { // First check if we're running from an AppImage if self.is_running_from_appimage() { return LinuxInstallationMethod::AppImage; } // Get current executable path let exe_path = match std::env::current_exe() { Ok(path) => path, Err(_) => return LinuxInstallationMethod::Unknown, }; let exe_path_str = exe_path.to_string_lossy(); log::info!("Detecting installation method for: {exe_path_str}"); // Check if installed via package manager by querying package databases if let Some(exe_name) = exe_path.file_name().and_then(|n| n.to_str()) { // Try to find the package that owns this file // Check DEB systems (dpkg) if let Ok(output) = Command::new("dpkg").args(["-S", &exe_path_str]).output() { if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); if !stdout.trim().is_empty() && !stdout.contains("no path found") { log::info!("Found DEB package owning the executable"); return LinuxInstallationMethod::Deb; } } } // Check RPM systems (rpm) if let Ok(output) = Command::new("rpm").args(["-qf", &exe_path_str]).output() { if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); if !stdout.trim().is_empty() && !stdout.contains("not owned") { log::info!("Found RPM package owning the executable"); return LinuxInstallationMethod::Rpm; } } } // Alternative RPM check with different systems for rpm_cmd in &["dnf", "yum", "zypper"] { if let Ok(output) = Command::new(rpm_cmd) .args(["provides", &exe_path_str]) .output() { if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); if !stdout.trim().is_empty() && stdout.contains(exe_name) { log::info!("Found RPM package via {rpm_cmd}"); return LinuxInstallationMethod::Rpm; } } } } } // Check installation location to infer method if exe_path_str.starts_with("/usr/bin/") || exe_path_str.starts_with("/usr/local/bin/") { // Likely installed via package manager or system-wide installation log::info!("Executable in system directory, assuming package installation"); // Try to determine which package system is available if Command::new("dpkg").arg("--version").output().is_ok() { return LinuxInstallationMethod::Deb; } else if Command::new("rpm").arg("--version").output().is_ok() { return LinuxInstallationMethod::Rpm; } return LinuxInstallationMethod::Manual; } else if exe_path_str.contains("/.local/") || exe_path_str.starts_with("/home/") { // User-local installation log::info!("Executable in user directory, assuming manual installation"); return LinuxInstallationMethod::Manual; } log::info!("Could not determine installation method"); LinuxInstallationMethod::Unknown } /// Check if the APT repository is configured #[cfg(target_os = "linux")] fn is_deb_repo_configured() -> bool { Path::new("/etc/apt/sources.list.d/donutbrowser.list").exists() } /// Check if an RPM repository is configured (yum/dnf or zypper) #[cfg(target_os = "linux")] fn is_rpm_repo_configured() -> bool { Path::new("/etc/yum.repos.d/donutbrowser.repo").exists() || Path::new("/etc/zypp/repos.d/donutbrowser.repo").exists() } /// Check if a system package manager repo is configured for this installation. #[cfg(target_os = "linux")] fn is_repo_configured(&self) -> bool { let installation_method = self.detect_linux_installation_method(); match installation_method { LinuxInstallationMethod::Deb => Self::is_deb_repo_configured(), LinuxInstallationMethod::Rpm => Self::is_rpm_repo_configured(), _ => false, } } /// Get the appropriate download URL for the current platform fn get_download_url_for_platform(&self, assets: &[AppReleaseAsset]) -> Option { let arch = if cfg!(target_arch = "aarch64") { "aarch64" } else if cfg!(target_arch = "x86_64") { "x64" } else { "unknown" }; log::info!("Looking for platform-specific asset for arch: {arch}"); #[cfg(target_os = "linux")] { // If we're running from an AppImage, disable auto-updates for safety if self.is_running_from_appimage() { log::info!("Running from AppImage - auto-updates disabled for safety"); return None; } } #[cfg(target_os = "macos")] { self.get_macos_download_url(assets, arch) } #[cfg(target_os = "windows")] { self.get_windows_download_url(assets, arch) } #[cfg(target_os = "linux")] { self.get_linux_download_url(assets, arch) } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { log::info!("Unsupported platform for auto-update"); None } } #[cfg(target_os = "macos")] fn get_macos_download_url(&self, assets: &[AppReleaseAsset], arch: &str) -> Option { // Look for exact architecture match in DMG for asset in assets { if asset.name.contains(".dmg") && (asset.name.contains(&format!("_{arch}.dmg")) || asset.name.contains(&format!("-{arch}.dmg")) || asset.name.contains(&format!("_{arch}_")) || asset.name.contains(&format!("-{arch}-")) || asset.name.contains(&format!("_{arch}-"))) { log::info!("Found exact architecture match: {}", asset.name); return Some(asset.browser_download_url.clone()); } } // Look for x86_64 variations if we're looking for x64 if arch == "x64" { for asset in assets { if asset.name.contains(".dmg") && (asset.name.contains("x86_64") || asset.name.contains("x86-64")) { log::info!("Found x86_64 variant: {}", asset.name); return Some(asset.browser_download_url.clone()); } } } // Look for arm64 variations if we're looking for aarch64 if arch == "aarch64" { for asset in assets { if asset.name.contains(".dmg") && (asset.name.contains("arm64") || asset.name.contains("aarch64")) { log::info!("Found arm64 variant: {}", asset.name); return Some(asset.browser_download_url.clone()); } } } // Fallback to any macOS DMG for asset in assets { if asset.name.contains(".dmg") && (asset.name.to_lowercase().contains("macos") || asset.name.to_lowercase().contains("darwin") || !asset.name.contains(".app.tar.gz")) { log::info!("Found fallback DMG: {}", asset.name); return Some(asset.browser_download_url.clone()); } } None } #[cfg(target_os = "windows")] fn get_windows_download_url(&self, assets: &[AppReleaseAsset], arch: &str) -> Option { // Priority order: MSI > EXE > ZIP let extensions = ["msi", "exe", "zip"]; for ext in &extensions { // Look for exact architecture match for asset in assets { if asset.name.to_lowercase().ends_with(&format!(".{ext}")) && (asset.name.contains(&format!("_{arch}.{ext}")) || asset.name.contains(&format!("-{arch}.{ext}")) || asset.name.contains(&format!("_{arch}_")) || asset.name.contains(&format!("-{arch}-")) || asset.name.contains(&format!("_{arch}-"))) { log::info!("Found Windows {ext} with exact arch match: {}", asset.name); return Some(asset.browser_download_url.clone()); } } // Look for x86_64 variations if we're looking for x64 if arch == "x64" { for asset in assets { if asset.name.to_lowercase().ends_with(&format!(".{ext}")) && (asset.name.contains("x86_64") || asset.name.contains("x86-64")) { log::info!("Found Windows {ext} with x86_64 variant: {}", asset.name); return Some(asset.browser_download_url.clone()); } } } // Fallback to any Windows file of this type for asset in assets { if asset.name.to_lowercase().ends_with(&format!(".{ext}")) && (asset.name.to_lowercase().contains("windows") || asset.name.to_lowercase().contains("win32") || asset.name.to_lowercase().contains("win64")) { log::info!("Found Windows {ext} fallback: {}", asset.name); return Some(asset.browser_download_url.clone()); } } } None } #[cfg(target_os = "linux")] fn get_linux_download_url(&self, assets: &[AppReleaseAsset], arch: &str) -> Option { // Detect installation method to prioritize appropriate formats let installation_method = self.detect_linux_installation_method(); log::info!("Detected Linux installation method: {installation_method:?}"); // Priority order based on installation method let extensions = match installation_method { LinuxInstallationMethod::Deb => vec!["deb", "tar.gz"], LinuxInstallationMethod::Rpm => vec!["rpm", "tar.gz"], LinuxInstallationMethod::AppImage => { // AppImages should not auto-update for safety log::info!("AppImage installation detected - auto-updates disabled"); return None; } LinuxInstallationMethod::Manual | LinuxInstallationMethod::Unknown => { vec!["deb", "rpm", "tar.gz"] } }; for ext in &extensions { // Look for exact architecture match for asset in assets { let asset_name_lower = asset.name.to_lowercase(); if asset_name_lower.ends_with(&format!(".{ext}")) && (asset.name.contains(&format!("_{arch}.{ext}")) || asset.name.contains(&format!("-{arch}.{ext}")) || asset.name.contains(&format!("_{arch}_")) || asset.name.contains(&format!("-{arch}-")) || asset.name.contains(&format!("_{arch}-"))) { log::info!("Found Linux {ext} with exact arch match: {}", asset.name); return Some(asset.browser_download_url.clone()); } } // Look for x86_64 variations if we're looking for x64 if arch == "x64" { for asset in assets { let asset_name_lower = asset.name.to_lowercase(); if asset_name_lower.ends_with(&format!(".{ext}")) && (asset.name.contains("x86_64") || asset.name.contains("x86-64") || asset.name.contains("amd64")) { log::info!("Found Linux {ext} with x86_64 variant: {}", asset.name); return Some(asset.browser_download_url.clone()); } } } // Look for arm64 variations if we're looking for aarch64 if arch == "aarch64" { for asset in assets { let asset_name_lower = asset.name.to_lowercase(); if asset_name_lower.ends_with(&format!(".{ext}")) && (asset.name.contains("arm64") || asset.name.contains("aarch64")) { log::info!("Found Linux {ext} with arm64 variant: {}", asset.name); return Some(asset.browser_download_url.clone()); } } } // Fallback to any Linux file of this type for asset in assets { let asset_name_lower = asset.name.to_lowercase(); if asset_name_lower.ends_with(&format!(".{ext}")) && (asset_name_lower.contains("linux") || asset_name_lower.contains("ubuntu") || asset_name_lower.contains("debian")) { log::info!("Found Linux {ext} fallback: {}", asset.name); return Some(asset.browser_download_url.clone()); } } } None } /// Download the update file without progress tracking (silent download) async fn download_update_silent( &self, download_url: &str, dest_dir: &Path, filename: &str, ) -> Result> { let file_path = dest_dir.join(filename); let response = self .client .get(download_url) .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") .send() .await?; if !response.status().is_success() { return Err(format!("Download failed with status: {}", response.status()).into()); } let total_size = response.content_length().unwrap_or(0); log::info!("Silent download size: {} bytes", total_size); let raw_file = fs::File::create(&file_path)?; let mut file = std::io::BufWriter::with_capacity(8 * 1024 * 1024, raw_file); let mut stream = response.bytes_stream(); use futures_util::StreamExt; while let Some(chunk) = stream.next().await { let chunk = chunk?; file.write_all(&chunk)?; } std::io::Write::flush(&mut file)?; log::info!("Silent download completed: {}", file_path.display()); Ok(file_path) } /// Download and prepare app update (silent download + install + notify) pub async fn download_and_prepare_update( &self, _app_handle: &tauri::AppHandle, update_info: &AppUpdateInfo, ) -> Result<(), Box> { log::info!("Starting background update download and install"); let temp_dir = std::env::temp_dir().join("donut_app_update"); fs::create_dir_all(&temp_dir)?; let filename = update_info .download_url .split('/') .next_back() .unwrap_or("update.dmg") .to_string(); log::info!("Downloading update from: {}", update_info.download_url); let download_path = self .download_update_silent(&update_info.download_url, &temp_dir, &filename) .await?; log::info!("Extracting update..."); let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?; // On Windows, MSI/EXE installers close the running app, so running them now // would kill the process before the "Update ready" toast can appear. Instead, // defer execution to restart_application() when the user clicks "Restart Now". #[cfg(target_os = "windows")] { let ext = extracted_app_path .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); if ext == "msi" || ext == "exe" { log::info!("Deferring Windows installer execution until user-initiated restart"); *PENDING_INSTALLER_PATH.lock().unwrap() = Some(extracted_app_path); } else { log::info!("Installing update (overwriting binary)..."); self.install_update(&extracted_app_path).await?; log::info!("Cleaning up temporary files..."); let _ = fs::remove_dir_all(&temp_dir); } } #[cfg(not(target_os = "windows"))] { log::info!("Installing update (overwriting binary)..."); self.install_update(&extracted_app_path).await?; log::info!("Cleaning up temporary files..."); let _ = fs::remove_dir_all(&temp_dir); } log::info!("Update ready, emitting app-update-ready event"); let _ = events::emit("app-update-ready", update_info.new_version.clone()); Ok(()) } /// Extract the update using the extraction module async fn extract_update( &self, archive_path: &Path, dest_dir: &Path, ) -> Result> { let file_name = archive_path .file_name() .and_then(|name| name.to_str()) .unwrap_or(""); // Handle compound extensions like .tar.gz if file_name.ends_with(".tar.gz") { return self.extractor.extract_tar_gz(archive_path, dest_dir).await; } let extension = archive_path .extension() .and_then(|ext| ext.to_str()) .unwrap_or(""); match extension { "dmg" => { #[cfg(target_os = "macos")] { self.extractor.extract_dmg(archive_path, dest_dir).await } #[cfg(not(target_os = "macos"))] { Err("DMG extraction is only supported on macOS".into()) } } "msi" => { #[cfg(target_os = "windows")] { // For MSI files on Windows, we need to run the installer // MSI files can't be extracted like archives, they need to be executed // Return the path to the MSI file itself for installation Ok(archive_path.to_path_buf()) } #[cfg(not(target_os = "windows"))] { Err("MSI installation is only supported on Windows".into()) } } "exe" => { #[cfg(target_os = "windows")] { // For exe installers on Windows, return the path for execution Ok(archive_path.to_path_buf()) } #[cfg(not(target_os = "windows"))] { Err("EXE installation is only supported on Windows".into()) } } "deb" => { #[cfg(target_os = "linux")] { // For DEB files on Linux, return the path for installation Ok(archive_path.to_path_buf()) } #[cfg(not(target_os = "linux"))] { Err("DEB installation is only supported on Linux".into()) } } "rpm" => { #[cfg(target_os = "linux")] { // For RPM files on Linux, return the path for installation Ok(archive_path.to_path_buf()) } #[cfg(not(target_os = "linux"))] { Err("RPM installation is only supported on Linux".into()) } } "appimage" => { #[cfg(target_os = "linux")] { // For AppImage files, return the path for installation Ok(archive_path.to_path_buf()) } #[cfg(not(target_os = "linux"))] { Err("AppImage installation is only supported on Linux".into()) } } "zip" => self.extractor.extract_zip(archive_path, dest_dir).await, _ => Err(format!("Unsupported archive format: {extension}").into()), } } /// Install the update by replacing the current app async fn install_update( &self, #[allow(unused_variables)] installer_path: &Path, ) -> Result<(), Box> { #[cfg(target_os = "macos")] { // Get the current application bundle path let current_app_path = self.get_current_app_path()?; // Create a backup of the current app let backup_path = current_app_path.with_extension("app.backup"); if backup_path.exists() { fs::remove_dir_all(&backup_path)?; } // Move current app to backup fs::rename(¤t_app_path, &backup_path)?; // Move new app to current location fs::rename(installer_path, ¤t_app_path)?; // Remove the macOS quarantine attribute from the freshly-installed app // so Gatekeeper doesn't block its first launch — but only if it's // actually present. macOS Sequoia's App Management TCC fires on the // modify-class syscall regardless of whether anything is actually // modified, so we gate the call behind a read-only `getxattr` check. let needs_quarantine_removal = { use std::ffi::CString; use std::os::unix::ffi::OsStrExt; let path_c = CString::new(current_app_path.as_os_str().as_bytes()).ok(); let attr_c = CString::new("com.apple.quarantine").ok(); match (path_c, attr_c) { (Some(p), Some(a)) => { // SAFETY: getxattr with a null buffer is a read-only size query. let result = unsafe { libc::getxattr(p.as_ptr(), a.as_ptr(), std::ptr::null_mut(), 0, 0, 0) }; result >= 0 } _ => false, } }; if needs_quarantine_removal { let _ = Command::new("xattr") .args([ "-dr", "com.apple.quarantine", current_app_path.to_str().unwrap(), ]) .output(); } // Clean up backup after successful installation let _ = fs::remove_dir_all(&backup_path); // Clean up old "Donut Browser.app" if it exists (from before the project rename) if let Some(parent_dir) = current_app_path.parent() { let old_app_path = parent_dir.join("Donut Browser.app"); if old_app_path.exists() && old_app_path != current_app_path { log::info!( "Removing old 'Donut Browser.app' from: {}", old_app_path.display() ); if let Err(e) = fs::remove_dir_all(&old_app_path) { log::warn!("Warning: Failed to remove old 'Donut Browser.app': {e}"); } else { log::info!("Successfully removed old 'Donut Browser.app'"); } } } Ok(()) } #[cfg(target_os = "windows")] { let extension = installer_path .extension() .and_then(|ext| ext.to_str()) .unwrap_or(""); log::info!("Installing Windows update with extension: {extension}"); match extension { "msi" => { // Install MSI silently with enhanced error handling log::info!("Running MSI installer: {}", installer_path.display()); let mut cmd = Command::new("msiexec"); cmd.args([ "/i", installer_path.to_str().unwrap(), "/quiet", "/norestart", "REBOOT=ReallySuppress", "/l*v", // Enable verbose logging &format!("{}.log", installer_path.to_str().unwrap()), ]); use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x08000000; cmd.creation_flags(CREATE_NO_WINDOW); let output = cmd.output()?; if !output.status.success() { let error_msg = String::from_utf8_lossy(&output.stderr); let exit_code = output.status.code().unwrap_or(-1); // Try to read the log file for more details let log_path = format!("{}.log", installer_path.to_str().unwrap()); let log_content = fs::read_to_string(&log_path).unwrap_or_default(); log::info!("MSI installation failed with exit code: {exit_code}"); log::info!("Error output: {error_msg}"); if !log_content.is_empty() { log::info!( "Log file content (last 500 chars): {}", &log_content .chars() .rev() .take(500) .collect::() .chars() .rev() .collect::() ); } return Err( format!("MSI installation failed (exit code {exit_code}): {error_msg}").into(), ); } log::info!("MSI installation completed successfully"); } "exe" => { // Run exe installer silently with multiple fallback options log::info!("Running EXE installer: {}", installer_path.display()); // Try NSIS silent flag first (most common for Tauri) let mut success = false; let mut last_error = String::new(); // NSIS installer flags (used by Tauri) let nsis_args = vec![ vec!["/S"], // Standard NSIS silent flag vec!["/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"], // Inno Setup flags vec!["/quiet"], // Generic quiet flag vec!["/silent"], // Alternative silent flag ]; for args in nsis_args { log::info!("Trying installer with args: {:?}", args); let output = Command::new(installer_path).args(&args).output(); match output { Ok(output) if output.status.success() => { log::info!( "EXE installation completed successfully with args: {:?}", args ); success = true; break; } Ok(output) => { let error_msg = String::from_utf8_lossy(&output.stderr); last_error = format!( "Exit code {}: {}", output.status.code().unwrap_or(-1), error_msg ); log::info!("Installer failed with args {:?}: {}", args, last_error); } Err(e) => { last_error = format!("Failed to execute installer: {e}"); log::info!( "Failed to execute installer with args {:?}: {}", args, last_error ); } } } if !success { return Err( format!( "EXE installation failed after trying multiple methods. Last error: {last_error}" ) .into(), ); } } "zip" => { // Handle ZIP files by extracting and replacing the current executable log::info!("Handling ZIP update: {}", installer_path.display()); let temp_extract_dir = installer_path.parent().unwrap().join("extracted"); fs::create_dir_all(&temp_extract_dir)?; // Extract ZIP file let extracted_path = self .extractor .extract_zip(installer_path, &temp_extract_dir) .await?; // Find the executable in the extracted files let current_exe = self.get_current_app_path()?; let current_exe_name = current_exe.file_name().unwrap(); // Look for the new executable let new_exe_path = if extracted_path.is_file() && extracted_path.file_name() == Some(current_exe_name) { extracted_path } else { // Search in extracted directory let mut found_exe = None; if let Ok(entries) = fs::read_dir(&extracted_path) { for entry in entries.flatten() { let path = entry.path(); if path.file_name() == Some(current_exe_name) { found_exe = Some(path); break; } } } found_exe.ok_or("Could not find executable in ZIP file")? }; // Create backup of current executable let backup_path = current_exe.with_extension("exe.backup"); if backup_path.exists() { fs::remove_file(&backup_path)?; } fs::copy(¤t_exe, &backup_path)?; // Replace current executable fs::copy(&new_exe_path, ¤t_exe)?; // Clean up let _ = fs::remove_dir_all(&temp_extract_dir); log::info!("ZIP update completed successfully"); } _ => { return Err(format!("Unsupported installer format: {extension}").into()); } } Ok(()) } #[cfg(target_os = "linux")] { let file_name = installer_path .file_name() .and_then(|name| name.to_str()) .unwrap_or(""); log::info!("Installing Linux update: {}", installer_path.display()); // Handle compound extensions like .tar.gz if file_name.ends_with(".tar.gz") { return self.install_linux_tarball(installer_path).await; } let extension = installer_path .extension() .and_then(|ext| ext.to_str()) .unwrap_or(""); match extension { "deb" => self.install_linux_deb(installer_path).await, "rpm" => self.install_linux_rpm(installer_path).await, "appimage" => self.install_linux_appimage(installer_path).await, _ => Err(format!("Unsupported Linux installer format: {extension}").into()), } } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { Err("Auto-update installation not supported on this platform".into()) } } /// Install Linux DEB package #[cfg(target_os = "linux")] async fn install_linux_deb( &self, deb_path: &Path, ) -> Result<(), Box> { log::info!("Installing DEB package: {}", deb_path.display()); Self::install_linux_package_with_privileges(deb_path, "dpkg", "-i") } /// Install Linux RPM package #[cfg(target_os = "linux")] async fn install_linux_rpm( &self, rpm_path: &Path, ) -> Result<(), Box> { log::info!("Installing RPM package: {}", rpm_path.display()); Self::install_linux_package_with_privileges(rpm_path, "rpm", "-Uvh") } /// Install a Linux package with privilege escalation, using a fallback chain: /// 1. pkexec (graphical PolicyKit prompt — most common on desktop Linux) /// 2. zenity/kdialog password dialog → sudo -S (graphical sudo experience) /// 3. sudo (terminal fallback — works in TTY sessions) #[cfg(target_os = "linux")] fn install_linux_package_with_privileges( pkg_path: &Path, install_cmd: &str, install_arg: &str, ) -> Result<(), Box> { let pkg = pkg_path.to_str().unwrap_or_default(); // 1. Try pkexec (graphical PolicyKit prompt) if let Ok(status) = Command::new("pkexec") .args([install_cmd, install_arg, pkg]) .status() { if status.success() { log::info!("Installed {pkg} with pkexec"); return Ok(()); } } // 2. Try graphical password dialog → sudo -S if let Some(password) = Self::get_password_graphically() { if Self::install_with_sudo_stdin(pkg_path, &password, install_cmd, install_arg) { log::info!("Installed {pkg} with graphical sudo"); return Ok(()); } } // 3. Terminal sudo fallback if let Ok(status) = Command::new("sudo") .args([install_cmd, install_arg, pkg]) .status() { if status.success() { log::info!("Installed {pkg} with sudo"); return Ok(()); } } Err(format!("Failed to install {pkg} — all privilege escalation methods failed").into()) } /// Try zenity then kdialog to get a password graphically. #[cfg(target_os = "linux")] fn get_password_graphically() -> Option { // Try zenity if let Ok(output) = Command::new("zenity") .args([ "--password", "--title=Authentication Required", "--text=Enter your password to install the update:", ]) .output() { if output.status.success() { let pw = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !pw.is_empty() { return Some(pw); } } } // Fall back to kdialog if let Ok(output) = Command::new("kdialog") .args(["--password", "Enter your password to install the update:"]) .output() { if output.status.success() { let pw = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !pw.is_empty() { return Some(pw); } } } None } /// Pipe a password to `sudo -S `. #[cfg(target_os = "linux")] fn install_with_sudo_stdin( pkg_path: &Path, password: &str, install_cmd: &str, install_arg: &str, ) -> bool { use std::io::Write; let child = Command::new("sudo") .args([ "-S", install_cmd, install_arg, pkg_path.to_str().unwrap_or_default(), ]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn(); match child { Ok(mut child) => { if let Some(mut stdin) = child.stdin.take() { let _ = writeln!(stdin, "{password}"); } child.wait().map(|s| s.success()).unwrap_or(false) } Err(_) => false, } } /// Install Linux AppImage #[cfg(target_os = "linux")] async fn install_linux_appimage( &self, appimage_path: &Path, ) -> Result<(), Box> { log::info!("Installing AppImage: {}", appimage_path.display()); // This function should not be called for AppImages since we disable auto-updates for them // But if it somehow gets called, we'll handle it safely if !self.is_running_from_appimage() { return Err("AppImage installation attempted but not running from AppImage".into()); } let current_exe = self.get_current_app_path()?; // Detect if we're running from an AppImage using multiple methods let current_appimage = if let Ok(appimage_env) = std::env::var("APPIMAGE") { PathBuf::from(appimage_env) } else { // Fallback: use current executable path current_exe.clone() }; // Create backup let backup_path = current_appimage.with_extension("appimage.backup"); if backup_path.exists() { fs::remove_file(&backup_path)?; } fs::copy(¤t_appimage, &backup_path)?; // Make new AppImage executable let _ = Command::new("chmod") .args(["+x", appimage_path.to_str().unwrap()]) .output(); // Replace the AppImage fs::copy(appimage_path, ¤t_appimage)?; log::info!("AppImage replacement completed successfully"); Ok(()) } /// Install Linux tarball #[cfg(target_os = "linux")] async fn install_linux_tarball( &self, tarball_path: &Path, ) -> Result<(), Box> { log::info!("Installing tarball: {}", tarball_path.display()); let current_exe = self.get_current_app_path()?; let temp_extract_dir = tarball_path.parent().unwrap().join("extracted"); fs::create_dir_all(&temp_extract_dir)?; // Extract tarball let extracted_path = self .extractor .extract_tar_gz(tarball_path, &temp_extract_dir) .await?; // Find the executable in the extracted files let current_exe_name = current_exe.file_name().unwrap(); let new_exe_path = if extracted_path.is_file() && extracted_path.file_name() == Some(current_exe_name) { extracted_path } else { // Search in extracted directory let mut found_exe = None; if let Ok(entries) = fs::read_dir(&extracted_path) { for entry in entries.flatten() { let path = entry.path(); if path.file_name() == Some(current_exe_name) { found_exe = Some(path); break; } // Also check subdirectories if path.is_dir() { if let Ok(sub_entries) = fs::read_dir(&path) { for sub_entry in sub_entries.flatten() { let sub_path = sub_entry.path(); if sub_path.file_name() == Some(current_exe_name) { found_exe = Some(sub_path); break; } } } } } } found_exe.ok_or("Could not find executable in tarball")? }; // Create backup of current executable let backup_path = current_exe.with_extension("backup"); if backup_path.exists() { fs::remove_file(&backup_path)?; } fs::copy(¤t_exe, &backup_path)?; // Replace current executable fs::copy(&new_exe_path, ¤t_exe)?; // Make sure it's executable let _ = Command::new("chmod") .args(["+x", current_exe.to_str().unwrap()]) .output(); // Clean up let _ = fs::remove_dir_all(&temp_extract_dir); log::info!("Tarball installation completed successfully"); Ok(()) } /// Get the current application bundle path fn get_current_app_path(&self) -> Result> { #[cfg(target_os = "macos")] { // Get the current executable path let exe_path = std::env::current_exe()?; // Navigate up to find the .app bundle let mut current = exe_path.as_path(); while let Some(parent) = current.parent() { if parent.extension().is_some_and(|ext| ext == "app") { return Ok(parent.to_path_buf()); } current = parent; } Err("Could not find application bundle".into()) } #[cfg(target_os = "windows")] { // On Windows, just return the current executable path std::env::current_exe().map_err(|e| e.into()) } #[cfg(target_os = "linux")] { // On Linux, return the current executable path std::env::current_exe().map_err(|e| e.into()) } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { Err("Platform not supported".into()) } } /// Restart the application async fn restart_application(&self) -> Result<(), Box> { #[cfg(target_os = "macos")] { let app_path = self.get_current_app_path()?; let current_pid = std::process::id(); // Create a temporary restart script let temp_dir = std::env::temp_dir(); let script_path = temp_dir.join("donut_restart.sh"); // Create the restart script content let script_content = format!( r#"#!/bin/sh # Wait for the current process to exit while kill -0 {} 2>/dev/null; do sleep 0.5 done # Wait a bit more to ensure clean exit sleep 1 # Start the new application open "{}" # Clean up this script rm "{}" "#, current_pid, app_path.to_str().unwrap(), script_path.to_str().unwrap() ); // Write the script to file fs::write(&script_path, script_content)?; // Make the script executable let _ = Command::new("chmod") .args(["+x", script_path.to_str().unwrap()]) .output(); // Execute the restart script in the background let mut cmd = Command::new("sh"); cmd.arg(script_path.to_str().unwrap()); // Detach the process completely use std::os::unix::process::CommandExt; cmd.process_group(0); let _child = cmd.spawn()?; // Give the script a moment to start tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; // Exit the current process std::process::exit(0); } #[cfg(target_os = "windows")] { use std::ffi::OsStr; use std::os::windows::ffi::OsStrExt; let pending = PENDING_INSTALLER_PATH.lock().unwrap().take(); if let Some(installer_path) = pending { // 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 // AUTOLAUNCHAPP flags). let ext = installer_path .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); let (file, parameters) = match ext.as_str() { "exe" => { // NSIS installer: /S for silent, /UPDATE tells it this is an update let file = installer_path.as_os_str().to_os_string(); let params = std::ffi::OsString::from("/S /UPDATE"); (file, params) } "msi" => { // MSI: run msiexec.exe with the package let msiexec = std::env::var("SYSTEMROOT") .map(|p| format!("{p}\\System32\\msiexec.exe")) .unwrap_or_else(|_| "msiexec.exe".to_string()); let file = std::ffi::OsString::from(msiexec); let params = std::ffi::OsString::from(format!( "/i {} /quiet /norestart /promptrestart AUTOLAUNCHAPP=True", installer_path .to_str() .map(|p| format!("\"{p}\"")) .unwrap_or_default() )); (file, params) } _ => { return Err("Unsupported Windows installer format for restart".into()); } }; fn encode_wide(s: impl AsRef) -> Vec { s.as_ref().encode_wide().chain(std::iter::once(0)).collect() } let file_w = encode_wide(&file); let params_w = encode_wide(¶meters); log::info!( "Running installer via ShellExecuteW: {:?} {:?}", file, parameters ); // windows-sys is not a direct dep, so use the raw FFI via the // windows crate that Tauri pulls in. ShellExecuteW returns an // HINSTANCE > 32 on success. #[link(name = "shell32")] extern "system" { fn ShellExecuteW( hwnd: *mut std::ffi::c_void, operation: *const u16, file: *const u16, parameters: *const u16, directory: *const u16, show_cmd: i32, ) -> isize; } const SW_SHOWNORMAL: i32 = 1; let open: Vec = "open\0".encode_utf16().collect(); let result = unsafe { ShellExecuteW( std::ptr::null_mut(), open.as_ptr(), file_w.as_ptr(), params_w.as_ptr(), std::ptr::null(), SW_SHOWNORMAL, ) }; if result as usize <= 32 { return Err(format!("ShellExecuteW failed with code {result}").into()); } } 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()?; } tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; std::process::exit(0); } #[cfg(target_os = "linux")] { let app_path = self.get_current_app_path()?; let current_pid = std::process::id(); // Create a temporary restart script let temp_dir = std::env::temp_dir(); let script_path = temp_dir.join("donut_restart.sh"); // Create the restart script content let script_content = format!( r#"#!/bin/sh # Wait for the current process to exit while kill -0 {} 2>/dev/null; do sleep 0.5 done # Wait a bit more to ensure clean exit sleep 1 # Start the new application "{}" & # Clean up this script rm "{}" "#, current_pid, app_path.to_str().unwrap(), script_path.to_str().unwrap() ); // Write the script to file fs::write(&script_path, script_content)?; // Make the script executable let _ = Command::new("chmod") .args(["+x", script_path.to_str().unwrap()]) .output(); // Execute the restart script in the background let mut cmd = Command::new("sh"); cmd.arg(script_path.to_str().unwrap()); // Detach the process completely use std::os::unix::process::CommandExt; cmd.process_group(0); let _child = cmd.spawn()?; // Give the script a moment to start tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; // Exit the current process std::process::exit(0); } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { Err("Application restart not supported on this platform".into()) } } } // Tauri commands #[tauri::command] pub async fn check_for_app_updates() -> Result, String> { if crate::app_dirs::is_portable() { log::info!("App auto-updates disabled in portable mode"); return Ok(None); } // The disable_auto_updates setting controls app self-updates only let disabled = crate::settings_manager::SettingsManager::instance() .load_settings() .map(|s| s.disable_auto_updates) .unwrap_or(false); if disabled { log::info!("App auto-updates disabled by user setting"); return Ok(None); } let updater = AppAutoUpdater::instance(); updater .check_for_updates() .await .map_err(|e| format!("Failed to check for app updates: {e}")) } #[tauri::command] pub async fn download_and_prepare_app_update( app_handle: tauri::AppHandle, update_info: AppUpdateInfo, ) -> Result<(), String> { let updater = AppAutoUpdater::instance(); updater .download_and_prepare_update(&app_handle, &update_info) .await .map_err(|e| format!("Failed to download and prepare app update: {e}")) } #[tauri::command] pub async fn restart_application() -> Result<(), String> { let updater = AppAutoUpdater::instance(); updater .restart_application() .await .map_err(|e| format!("Failed to restart application: {e}")) } #[tauri::command] pub async fn check_for_app_updates_manual() -> Result, String> { log::info!("Manual app update check triggered"); let updater = AppAutoUpdater::instance(); updater .check_for_updates() .await .map_err(|e| format!("Failed to check for app updates: {e}")) } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_nightly_build() { // This will depend on whether STABLE_RELEASE is set during test compilation let is_nightly = AppAutoUpdater::is_nightly_build(); log::info!("Is nightly build: {is_nightly}"); // The result should be true for test builds since STABLE_RELEASE is not set // unless the test is run in a stable release environment assert!(is_nightly || option_env!("STABLE_RELEASE").is_some()); } #[test] fn test_version_comparison() { let updater = AppAutoUpdater::instance(); // Test semantic version comparison assert!(updater.is_version_newer("v1.1.0", "v1.0.0")); assert!(updater.is_version_newer("v2.0.0", "v1.9.9")); assert!(updater.is_version_newer("v1.0.1", "v1.0.0")); assert!(!updater.is_version_newer("v1.0.0", "v1.0.0")); assert!(!updater.is_version_newer("v1.0.0", "v1.0.1")); } #[test] fn test_parse_semver() { let updater = AppAutoUpdater::instance(); assert_eq!(updater.parse_semver("v1.2.3"), (1, 2, 3)); assert_eq!(updater.parse_semver("1.2.3"), (1, 2, 3)); assert_eq!(updater.parse_semver("v2.0.0"), (2, 0, 0)); assert_eq!(updater.parse_semver("0.1.0"), (0, 1, 0)); } #[test] fn test_should_update_stable() { let updater = AppAutoUpdater::instance(); // Stable version updates assert!(updater.should_update("v1.0.0", "v1.1.0", false)); assert!(updater.should_update("v1.0.0", "v2.0.0", false)); assert!(!updater.should_update("v1.1.0", "v1.0.0", false)); assert!(!updater.should_update("v1.0.0", "v1.0.0", false)); } #[test] fn test_should_update_nightly() { let updater = AppAutoUpdater::instance(); // Nightly version updates assert!(updater.should_update("nightly-abc123", "nightly-def456", true)); assert!(!updater.should_update("nightly-abc123", "nightly-abc123", true)); // Upgrade from stable to nightly assert!(updater.should_update("v1.0.0", "nightly-abc123", true)); // Don't upgrade dev, ever assert!(!updater.should_update("dev-0.1.0", "nightly-xyz987", false)); assert!(!updater.should_update("dev-0.1.0", "nightly-xyz987", true)); assert!(!updater.should_update("dev-0.1.0", "v1.2.3", false)); } #[test] fn test_should_update_edge_cases() { let updater = AppAutoUpdater::instance(); // Test with different nightly formats assert!(updater.should_update("nightly-abc123", "nightly-def456", true)); assert!(!updater.should_update("nightly-abc123", "nightly-abc123", true)); // Test stable version edge cases assert!(updater.should_update("v0.9.9", "v1.0.0", false)); assert!(!updater.should_update("v1.0.0", "v0.9.9", false)); assert!(!updater.should_update("v1.0.0", "v1.0.0", false)); // Test version without 'v' prefix assert!(updater.should_update("0.9.9", "v1.0.0", false)); assert!(updater.should_update("v0.9.9", "1.0.0", false)); } #[test] fn test_extract_update_uses_extractor() { // This test verifies that the extract_update method properly uses the Extractor // We can't run the actual extraction in unit tests without real DMG files, // but we can verify the method signature and basic logic let updater = AppAutoUpdater::instance(); // Test that unsupported formats would be rejected let temp_dir = std::env::temp_dir(); let unsupported_file = temp_dir.join("test.rar"); // Create a mock runtime to test the logic let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); // This would fail because .rar is not supported, which proves // our method is using the Extractor logic let result = rt.block_on(async { updater.extract_update(&unsupported_file, &temp_dir).await }); // Should fail with unsupported format error assert!(result.is_err(), "Unsupported format should return error"); let error_msg = result.expect_err("Should have error").to_string(); assert!( error_msg.contains("Unsupported archive format: rar") || error_msg.contains("unknown") || error_msg.contains("unsupported"), "Error should mention unsupported format, got: {error_msg}" ); } #[test] fn test_platform_specific_download_urls() { let updater = AppAutoUpdater::instance(); // Create comprehensive assets for all platforms let all_assets = vec![ // macOS assets AppReleaseAsset { name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(), browser_download_url: "https://example.com/aarch64.dmg".to_string(), size: 12345, }, AppReleaseAsset { name: "Donut.Browser_0.1.0_x64.dmg".to_string(), browser_download_url: "https://example.com/x64.dmg".to_string(), size: 12345, }, // Windows assets (NSIS naming: _ARCH-setup.exe) AppReleaseAsset { name: "Donut_0.1.0_x64-setup.exe".to_string(), browser_download_url: "https://example.com/x64-setup.exe".to_string(), size: 12345, }, // Linux assets AppReleaseAsset { name: "donutbrowser_0.1.0_amd64.deb".to_string(), browser_download_url: "https://example.com/amd64.deb".to_string(), size: 12345, }, AppReleaseAsset { name: "donutbrowser-0.1.0-1.x86_64.rpm".to_string(), browser_download_url: "https://example.com/x86_64.rpm".to_string(), size: 12345, }, AppReleaseAsset { name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(), browser_download_url: "https://example.com/x86_64.AppImage".to_string(), size: 12345, }, ]; // Test that the method returns a URL for the current platform let url = updater.get_download_url_for_platform(&all_assets); assert!( url.is_some(), "Should find a suitable download URL for current platform" ); // Test platform-specific behavior #[cfg(target_os = "macos")] { let url = url.unwrap(); assert!(url.contains(".dmg"), "macOS should prefer DMG files"); } #[cfg(target_os = "windows")] { let url = url.unwrap(); assert!( url.contains(".msi") || url.contains(".exe") || url.contains(".zip"), "Windows should prefer MSI, EXE, or ZIP files" ); } #[cfg(target_os = "linux")] { let url = url.unwrap(); assert!( url.contains(".deb") || url.contains(".rpm") || url.contains(".appimage") || url.contains(".tar.gz"), "Linux should prefer DEB, RPM, AppImage, or TAR.GZ files" ); } } #[test] fn test_supported_file_extensions() { let updater = AppAutoUpdater::instance(); let temp_dir = std::env::temp_dir(); let rt = tokio::runtime::Runtime::new().unwrap(); // Test that all supported extensions are handled let supported_extensions = ["dmg", "msi", "exe", "deb", "rpm", "appimage", "zip"]; for ext in &supported_extensions { let test_file = temp_dir.join(format!("test.{ext}")); let result = rt.block_on(async { updater.extract_update(&test_file, &temp_dir).await }); // The result should either succeed or fail with a platform-specific error, // but not with "Unsupported archive format" if let Err(e) = result { let error_msg = e.to_string(); assert!( !error_msg.contains("Unsupported archive format"), "Extension {ext} should be supported but got: {error_msg}" ); } } // Test tar.gz compound extension let tar_gz_file = temp_dir.join("test.tar.gz"); let result = rt.block_on(async { updater.extract_update(&tar_gz_file, &temp_dir).await }); if let Err(e) = result { let error_msg = e.to_string(); assert!( !error_msg.contains("Unsupported archive format"), "tar.gz should be supported but got: {error_msg}" ); } } #[cfg(target_os = "linux")] #[test] fn test_appimage_detection() { let updater = AppAutoUpdater::instance(); // Test that AppImage detection works with various scenarios // Note: These tests can't fully simulate AppImage environment without actual AppImage // Test that the method exists and doesn't panic let _is_appimage = updater.is_running_from_appimage(); // Test installation method detection let _method = updater.detect_linux_installation_method(); } #[cfg(target_os = "linux")] #[test] fn test_appimage_auto_update_disabled() { let updater = AppAutoUpdater::instance(); // Create mock assets including AppImage let assets = vec![ AppReleaseAsset { name: "donutbrowser_0.1.0_amd64.deb".to_string(), browser_download_url: "https://example.com/amd64.deb".to_string(), size: 12345, }, AppReleaseAsset { name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(), browser_download_url: "https://example.com/x86_64.AppImage".to_string(), size: 12345, }, ]; // If we're running from AppImage, should return None (disabled) // If not, should return a suitable download URL let url = updater.get_download_url_for_platform(&assets); // The test should pass regardless of whether we're in AppImage or not // If in AppImage: url should be None // If not in AppImage: url should be Some(...) if updater.is_running_from_appimage() { assert!( url.is_none(), "Auto-updates should be disabled for AppImages" ); } else { // Should find a suitable non-AppImage download if let Some(url_str) = url { assert!( !url_str.contains("AppImage"), "Should not select AppImage when not running from AppImage" ); } } } #[test] fn test_appimage_detection_logic() { let updater = AppAutoUpdater::instance(); // Test that the get_download_url_for_platform method properly handles AppImage detection // This test can run on all platforms // Create comprehensive assets for all platforms including AppImage let all_assets = vec![ // macOS assets AppReleaseAsset { name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(), browser_download_url: "https://example.com/aarch64.dmg".to_string(), size: 12345, }, // Windows assets AppReleaseAsset { name: "Donut.Browser_0.1.0_x64.msi".to_string(), browser_download_url: "https://example.com/x64.msi".to_string(), size: 12345, }, // Linux assets AppReleaseAsset { name: "donutbrowser_0.1.0_amd64.deb".to_string(), browser_download_url: "https://example.com/amd64.deb".to_string(), size: 12345, }, AppReleaseAsset { name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(), browser_download_url: "https://example.com/x86_64.AppImage".to_string(), size: 12345, }, ]; // Test that the method returns a URL for the current platform let url = updater.get_download_url_for_platform(&all_assets); // On non-Linux platforms, should always return a URL #[cfg(not(target_os = "linux"))] { assert!( url.is_some(), "Should find a suitable download URL for non-Linux platforms" ); let url_str = url.unwrap(); assert!( !url_str.contains("AppImage"), "Non-Linux platforms should not get AppImage downloads" ); } // On Linux platforms, behavior depends on AppImage detection #[cfg(target_os = "linux")] { // The URL might be None if AppImage is detected, or Some if not // This is expected behavior based on our implementation if let Some(url_str) = url { // If we get a URL, it should not be an AppImage assert!( !url_str.contains("AppImage"), "Should not select AppImage format" ); } // If url is None, it means AppImage was detected and auto-updates are disabled } } #[test] #[cfg(target_os = "linux")] fn test_repo_detection_returns_bool() { // These just verify the functions run without panicking. // Actual values depend on the host system configuration. let _deb = AppAutoUpdater::is_deb_repo_configured(); let _rpm = AppAutoUpdater::is_rpm_repo_configured(); } } // Global singleton instance lazy_static::lazy_static! { static ref APP_AUTO_UPDATER: AppAutoUpdater = AppAutoUpdater::new(); static ref PENDING_INSTALLER_PATH: std::sync::Mutex> = std::sync::Mutex::new(None); }