From 2cf9013d28ccdd1342036943384ea01d39781d87 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:48:52 +0400 Subject: [PATCH] chore: handle download interuptions --- src-tauri/src/downloader.rs | 55 ++++++++++++++++++++++++++++--- src-tauri/src/extraction.rs | 45 ++++++++++++------------- src-tauri/src/geoip_downloader.rs | 13 +++++++- src-tauri/src/lib.rs | 10 +++++- src/hooks/use-browser-download.ts | 17 ++++++++++ 5 files changed, 112 insertions(+), 28 deletions(-) diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index eba4622..82d8fbb 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -308,12 +308,40 @@ impl Downloader { .resolve_download_url(browser_type.clone(), version, download_info) .await?; - // Determine if we have a partial file to resume + // Check existing file size — if it matches the expected size, skip download let mut existing_size: u64 = 0; if let Ok(meta) = std::fs::metadata(&file_path) { existing_size = meta.len(); } + // Do a HEAD request to get the expected file size for skip/resume decisions + let head_response = self + .client + .head(&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 + .ok(); + + let expected_size = head_response.as_ref().and_then(|r| r.content_length()); + + // If existing file matches expected size, skip download entirely + if existing_size > 0 { + if let Some(expected) = expected_size { + if existing_size == expected { + log::info!( + "Archive {} already exists with correct size ({} bytes), skipping download", + file_path.display(), + existing_size + ); + return Ok(file_path); + } + } + } + // Build request, add Range only if we have bytes. If the server responds with 416 (Range Not // Satisfiable), delete the partial file and retry once without the Range header. let response = { @@ -683,11 +711,16 @@ impl Downloader { // Do not remove the archive here. We keep it until verification succeeds. } Err(e) => { - // Do not remove the archive or extracted files. Just drop the registry entry - // so it won't be reported as downloaded. + log::error!("Extraction failed for {browser_str} {version}: {e}"); + + // Delete the corrupt/invalid archive so a fresh download happens next time + if download_path.exists() { + log::info!("Deleting corrupt archive: {}", download_path.display()); + let _ = std::fs::remove_file(&download_path); + } + let _ = self.registry.remove_browser(&browser_str, &version); let _ = self.registry.save(); - // Remove browser-version pair from downloading set on error { let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap(); downloading.remove(&download_key); @@ -696,6 +729,20 @@ impl Downloader { let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap(); tokens.remove(&download_key); } + + // Emit error stage so the UI shows a toast + let progress = DownloadProgress { + browser: browser_str.clone(), + version: version.clone(), + downloaded_bytes: 0, + total_bytes: None, + percentage: 0.0, + speed_bytes_per_sec: 0.0, + eta_seconds: None, + stage: "error".to_string(), + }; + let _ = events::emit("download-progress", &progress); + return Err(format!("Failed to extract browser: {e}").into()); } } diff --git a/src-tauri/src/extraction.rs b/src-tauri/src/extraction.rs index 48ad7f1..2bb6e52 100644 --- a/src-tauri/src/extraction.rs +++ b/src-tauri/src/extraction.rs @@ -7,7 +7,7 @@ use crate::downloader::DownloadProgress; use crate::events; #[cfg(target_os = "macos")] -use std::process::Command; +use tokio::process::Command; #[cfg(target_os = "macos")] use std::fs::create_dir_all; @@ -232,17 +232,8 @@ impl Extractor { &self, file_path: &Path, ) -> Result> { - // First check file extension for DMG files since they're common on macOS - // and can have misleading magic numbers - if let Some(ext) = file_path.extension().and_then(|ext| ext.to_str()) { - if ext.to_lowercase() == "dmg" { - return Ok("dmg".to_string()); - } - if ext.to_lowercase() == "msi" { - return Ok("msi".to_string()); - } - } - + // Always check magic bytes first — the file extension may be wrong + // (e.g. CDN serving a ZIP with .dmg extension) let mut file = File::open(file_path)?; let mut buffer = [0u8; 12]; // Read first 12 bytes for magic number detection file.read_exact(&mut buffer)?; @@ -357,16 +348,20 @@ impl Extractor { .args([ "attach", "-nobrowse", + "-noverify", + "-noautoopen", "-mountpoint", mount_point.to_str().unwrap(), dmg_path.to_str().unwrap(), ]) - .output()?; + .stdin(std::process::Stdio::null()) + .output() + .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); - log::info!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}"); + log::error!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}"); // Clean up mount point before returning error let _ = fs::remove_dir_all(&mount_point); @@ -382,12 +377,13 @@ impl Extractor { let app_entry = match app_result { Ok(app_path) => app_path, Err(e) => { - log::info!("Failed to find .app in mount point: {e}"); + log::error!("Failed to find .app in mount point: {e}"); // Try to unmount before returning error let _ = Command::new("hdiutil") .args(["detach", "-force", mount_point.to_str().unwrap()]) - .output(); + .output() + .await; let _ = fs::remove_dir_all(&mount_point); return Err("No .app found after extraction".into()); @@ -407,16 +403,18 @@ impl Extractor { app_entry.to_str().unwrap(), app_path.to_str().unwrap(), ]) - .output()?; + .output() + .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - log::info!("Failed to copy app: {stderr}"); + log::error!("Failed to copy app: {stderr}"); // Unmount before returning error let _ = Command::new("hdiutil") .args(["detach", "-force", mount_point.to_str().unwrap()]) - .output(); + .output() + .await; let _ = fs::remove_dir_all(&mount_point); return Err(format!("Failed to copy app: {stderr}").into()); @@ -427,18 +425,21 @@ impl Extractor { // Remove quarantine attributes let _ = Command::new("xattr") .args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()]) - .output(); + .output() + .await; let _ = Command::new("xattr") .args(["-cr", app_path.to_str().unwrap()]) - .output(); + .output() + .await; log::info!("Removed quarantine attributes"); // Unmount the DMG let output = Command::new("hdiutil") .args(["detach", mount_point.to_str().unwrap()]) - .output()?; + .output() + .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/src-tauri/src/geoip_downloader.rs b/src-tauri/src/geoip_downloader.rs index acc9b99..2238203 100644 --- a/src-tauri/src/geoip_downloader.rs +++ b/src-tauri/src/geoip_downloader.rs @@ -174,6 +174,13 @@ impl GeoIPDownloader { let mmdb_path = Self::get_mmdb_file_path()?; + // Always download to a temp file first, then atomically rename. + // This prevents corruption if the app is closed mid-download. + let temp_path = mmdb_path.with_extension("mmdb.downloading"); + + // Remove any leftover temp file from a previous interrupted download + let _ = fs::remove_file(&temp_path).await; + // Download the file let response = self.client.get(&download_url).send().await?; @@ -189,7 +196,7 @@ impl GeoIPDownloader { let total_size = response.content_length().unwrap_or(0); let mut downloaded: u64 = 0; - let mut file = fs::File::create(&mmdb_path).await?; + let mut file = fs::File::create(&temp_path).await?; let mut stream = response.bytes_stream(); use futures_util::StreamExt; @@ -237,6 +244,10 @@ impl GeoIPDownloader { } file.flush().await?; + drop(file); + + // Atomically replace the old database with the new one + fs::rename(&temp_path, &mmdb_path).await?; // Write download timestamp let timestamp_path = Self::get_timestamp_path(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8e0041b..1dcf230 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -293,9 +293,17 @@ async fn fetch_dynamic_proxy( url: String, format: String, ) -> Result { - crate::proxy_manager::PROXY_MANAGER + let settings = crate::proxy_manager::PROXY_MANAGER .fetch_dynamic_proxy(&url, &format) + .await?; + + // Validate the proxy actually works by routing through a temporary local proxy + crate::proxy_manager::PROXY_MANAGER + .check_proxy_validity("_dynamic_test", &settings) .await + .map_err(|e| format!("Proxy resolved but connection failed: {e}"))?; + + Ok(settings) } #[tauri::command] diff --git a/src/hooks/use-browser-download.ts b/src/hooks/use-browser-download.ts index a4a6227..7a36b6c 100644 --- a/src/hooks/use-browser-download.ts +++ b/src/hooks/use-browser-download.ts @@ -335,6 +335,23 @@ export function useBrowserDownload() { `download-${browserName.toLowerCase()}-${progress.version}`, ); setDownloadProgress(null); + } else if (progress.stage === "error") { + setDownloadingBrowsers((prev) => { + const next = new Set(prev); + next.delete(progress.browser); + return next; + }); + dismissToast( + `download-${browserName.toLowerCase()}-${progress.version}`, + ); + setDownloadProgress(null); + showErrorToast( + `${browserName} ${progress.version}: extraction failed`, + { + description: + "The corrupt file was deleted. It will be re-downloaded on next attempt.", + }, + ); } else if (progress.stage === "completed") { setDownloadingBrowsers((prev) => { const next = new Set(prev);