From 98f92c31736b62b921a8452b3d9aa2c9a0ee5bb4 Mon Sep 17 00:00:00 2001 From: /home/neo <158327205+neoapps-dev@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:38:33 +0300 Subject: [PATCH] feat: download retry and error surfacing (#95) --- src-tauri/src/commands/download.rs | 116 +++++++++++++++++------------ src-tauri/src/commands/game.rs | 6 +- src-tauri/src/lib.rs | 1 + src-tauri/src/workshop_server.rs | 9 ++- src/hooks/useGameManager.ts | 8 ++ src/services/TauriService.ts | 12 +++ 6 files changed, 98 insertions(+), 54 deletions(-) diff --git a/src-tauri/src/commands/download.rs b/src-tauri/src/commands/download.rs index fc8cb49..b2ab9f7 100644 --- a/src-tauri/src/commands/download.rs +++ b/src-tauri/src/commands/download.rs @@ -1,19 +1,18 @@ use std::fs; use std::io::Write; +use std::time::Duration; use futures_util::StreamExt; use tauri::{AppHandle, Emitter, State}; use tokio_util::sync::CancellationToken; use crate::state::DownloadState; use crate::util; -#[tauri::command] -#[allow(non_snake_case)] -pub async fn download_and_install( - app: AppHandle, - state: State<'_, DownloadState>, - url: String, - instance_id: String, +async fn stream_download( + app: &AppHandle, + state: &DownloadState, + url: &str, + dest: &std::path::PathBuf, + progress_event: &str, ) -> Result { - let instance_dir = util::get_instance_working_dir(&app, &instance_id); let token = CancellationToken::new(); let child_token = token.clone(); { @@ -24,10 +23,9 @@ pub async fn download_and_install( *lock = Some(token); } - let root = util::get_app_dir(&app); - let zip_path = root.join(format!("temp_{}.zip", instance_id)); - let response = reqwest::get(&url).await.map_err(|e| e.to_string())?; + let response = reqwest::get(url).await.map_err(|e| e.to_string())?; if !response.status().is_success() { + { *state.token.lock().await = None; } return Err(format!("Download failed: {}", response.status())); } @@ -36,25 +34,80 @@ pub async fn download_and_install( .and_then(|h| h.to_str().ok()) .unwrap_or("") .to_string(); - let mut file = fs::File::create(&zip_path).map_err(|e| e.to_string())?; + let mut file = fs::File::create(dest).map_err(|e| e.to_string())?; let mut downloaded = 0.0; let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { if child_token.is_cancelled() { drop(file); - let _ = fs::remove_file(&zip_path); + let _ = fs::remove_file(dest); + { *state.token.lock().await = None; } return Err("CANCELLED".into()); } let chunk = chunk.map_err(|e| e.to_string())?; file.write_all(&chunk).map_err(|e| e.to_string())?; downloaded += chunk.len() as f64; if total_size > 0.0 { - let _ = app.emit("download-progress", downloaded / total_size * 100.0); + let _ = app.emit(progress_event, downloaded / total_size * 100.0); } } drop(file); { *state.token.lock().await = None; } + Ok(last_modified) +} + +async fn download_with_retry( + app: &AppHandle, + state: &DownloadState, + url: &str, + dest: &std::path::PathBuf, + progress_event: &str, + max_retries: u32, +) -> Result { + let mut last_error = String::new(); + for attempt in 1..=max_retries { + if attempt > 1 { + let backoff = Duration::from_secs(2u64.pow(attempt - 2)); + let cancel = CancellationToken::new(); + { + let mut lock = state.token.lock().await; + *lock = Some(cancel.clone()); + } + let _ = app.emit("download-retry", attempt); + tokio::select! { + _ = tokio::time::sleep(backoff) => {} + _ = cancel.cancelled() => { + let _ = fs::remove_file(dest); + return Err("CANCELLED".into()); + } + } + } + match stream_download(app, state, url, dest, progress_event).await { + Ok(result) => return Ok(result), + Err(e) if e == "CANCELLED" => return Err(e), + Err(e) => { + last_error = e; + let _ = fs::remove_file(dest); + } + } + } + let _ = app.emit("backend-error", format!("Download failed after {max_retries} attempts: {last_error}")); + Err(last_error) +} + +#[tauri::command] +#[allow(non_snake_case)] +pub async fn download_and_install( + app: AppHandle, + state: State<'_, DownloadState>, + url: String, + instance_id: String, +) -> Result { + let instance_dir = util::get_instance_working_dir(&app, &instance_id); + let root = util::get_app_dir(&app); + let zip_path = root.join(format!("temp_{}.zip", instance_id)); + let last_modified = download_with_retry(&app, &state, &url, &zip_path, "download-progress", 3).await?; let keep_list: std::collections::HashSet<&str> = [ "Windows64", "Windows64Media", "uid.dat", "username.txt", "settings.dat", "servers.dat", "servers.txt", "server.properties", "options.txt", "servers.db", @@ -192,43 +245,10 @@ pub async fn download_runner( let _ = fs::remove_dir_all(&runner_dir); } fs::create_dir_all(&runner_dir).map_err(|e| e.to_string())?; - let token = CancellationToken::new(); - let child_token = token.clone(); - { - let mut lock = state.token.lock().await; - if let Some(old_token) = lock.take() { - old_token.cancel(); - } - *lock = Some(token); - } let tarball_path = runners_dir.join(format!("{}.tar.gz", name)); - let response = reqwest::get(&url).await.map_err(|e| e.to_string())?; - if !response.status().is_success() { - return Err(format!("Download failed: {}", response.status())); - } + download_with_retry(&app, &state, &url, &tarball_path, "runner-download-progress", 3).await?; - let total_size = response.content_length().unwrap_or(0) as f64; - let mut file = fs::File::create(&tarball_path).map_err(|e| e.to_string())?; - let mut downloaded = 0.0; - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - if child_token.is_cancelled() { - drop(file); - let _ = fs::remove_file(&tarball_path); - let _ = fs::remove_dir_all(&runner_dir); - return Err("CANCELLED".into()); - } - let chunk = chunk.map_err(|e| e.to_string())?; - file.write_all(&chunk).map_err(|e| e.to_string())?; - downloaded += chunk.len() as f64; - if total_size > 0.0 { - let _ = app.emit("runner-download-progress", downloaded / total_size * 100.0); - } - } - - drop(file); - { *state.token.lock().await = None; } let status = std::process::Command::new("tar") .args(["-zxf", tarball_path.to_str().unwrap(), "-C", runner_dir.to_str().unwrap(), "--strip-components=1"]) .status() diff --git a/src-tauri/src/commands/game.rs b/src-tauri/src/commands/game.rs index 92580c2..bc4384a 100644 --- a/src-tauri/src/commands/game.rs +++ b/src-tauri/src/commands/game.rs @@ -1,7 +1,7 @@ use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; -use tauri::{AppHandle, Manager, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use tauri_plugin_opener::OpenerExt; use crate::commands::runners; use crate::config; @@ -39,8 +39,7 @@ pub async fn launch_game( } } ensure_server_list(&working_dir, servers); - - let ws_cancel = workshop_server::start().await; + let ws_cancel = workshop_server::start(app.clone()).await; let _ws_guard = workshop_server::Guard::new(ws_cancel.clone()); { let mut lock = state.workshop_cancel.lock().await; @@ -589,6 +588,7 @@ fn perform_dlc_sync(app: &AppHandle, instance_dir: &PathBuf) -> Result<(), Strin } else { fs::copy(entry.path(), &dest_path).map(|_| ()) } { + let _ = app.emit("backend-error", format!("DLC Sync: Failed to copy {:?}: {}", entry.path(), e)); eprintln!("[DLC Sync] Failed to copy {:?} to {:?}: {}", entry.path(), dest_path, e); } else { println!("[DLC Sync] Copied to {:?}", dest_path); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f7ba1b7..dc3ba01 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -142,6 +142,7 @@ pub fn run() { match game::launch_game(app_handle_clone.clone(), state, instance_id, Vec::new(), vec![]).await { Ok(_) => app_handle_clone.exit(0), Err(e) => { + let _ = app_handle_clone.emit("backend-error", format!("Auto-launch: {e}")); eprintln!("Auto-launch error: {}", e); app_handle_clone.exit(1); } diff --git a/src-tauri/src/workshop_server.rs b/src-tauri/src/workshop_server.rs index f868f22..c381c53 100644 --- a/src-tauri/src/workshop_server.rs +++ b/src-tauri/src/workshop_server.rs @@ -2,6 +2,7 @@ use once_cell::sync::Lazy; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; +use tauri::{AppHandle, Emitter}; const REGISTRY_URL: &str = "https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main"; static CLIENT: Lazy = Lazy::new(|| reqwest::Client::new()); pub struct Guard { @@ -22,20 +23,21 @@ impl Drop for Guard { } } -pub async fn start() -> CancellationToken { +pub async fn start(app: AppHandle) -> CancellationToken { let cancel = CancellationToken::new(); let server_cancel = cancel.clone(); tokio::spawn(async move { - serve(server_cancel).await; + serve(app, server_cancel).await; }); cancel } -async fn serve(cancel: CancellationToken) { +async fn serve(app: AppHandle, cancel: CancellationToken) { let listener = match TcpListener::bind("127.0.0.1:5582").await { Ok(l) => l, Err(e) => { + let _ = app.emit("backend-error", format!("Workshop server failed to bind: {e}")); eprintln!("[WorkshopServer] Failed to bind: {e}"); return; } @@ -49,6 +51,7 @@ async fn serve(cancel: CancellationToken) { tokio::spawn(handle(stream)); } Err(e) => { + let _ = app.emit("backend-error", format!("Workshop server accept error: {e}")); eprintln!("[WorkshopServer] Accept error: {e}"); } } diff --git a/src/hooks/useGameManager.ts b/src/hooks/useGameManager.ts index 7e20df8..3ec8d2b 100644 --- a/src/hooks/useGameManager.ts +++ b/src/hooks/useGameManager.ts @@ -364,9 +364,17 @@ export function useGameManager({ const unlistenRunner = TauriService.onRunnerDownloadProgress((p) => setRunnerDownloadProgress(p), ); + const unlistenError = TauriService.onBackendError((msg) => { + setError(msg); + }); + const unlistenRetry = TauriService.onDownloadRetry((attempt) => { + setError(`Download failed, retrying (${attempt}/3)...`); + }); return () => { unlistenDownload.then((u) => u()); unlistenRunner.then((u) => u()); + unlistenError.then((u) => u()); + unlistenRetry.then((u) => u()); }; }, [customEditions, checkInstalls]); diff --git a/src/services/TauriService.ts b/src/services/TauriService.ts index adc6e14..d03d291 100644 --- a/src/services/TauriService.ts +++ b/src/services/TauriService.ts @@ -206,6 +206,18 @@ export class TauriService { ); } + static onBackendError(callback: (message: string) => void) { + return listen("backend-error", (event) => + callback(event.payload), + ); + } + + static onDownloadRetry(callback: (attempt: number) => void) { + return listen("download-retry", (event) => + callback(event.payload), + ); + } + static async openUrl(url: string): Promise { return invoke("plugin:opener|open_url", { url }); }