feat: download retry and error surfacing (#95)

This commit is contained in:
/home/neo 2026-06-28 14:38:33 +03:00 committed by GitHub
parent 85855ac4af
commit 98f92c3173
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 98 additions and 54 deletions

View file

@ -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<String, String> {
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<String, String> {
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<String, String> {
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()

View file

@ -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);

View file

@ -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);
}

View file

@ -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<reqwest::Client> = 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}");
}
}

View file

@ -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]);

View file

@ -206,6 +206,18 @@ export class TauriService {
);
}
static onBackendError(callback: (message: string) => void) {
return listen<string>("backend-error", (event) =>
callback(event.payload),
);
}
static onDownloadRetry(callback: (attempt: number) => void) {
return listen<number>("download-retry", (event) =>
callback(event.payload),
);
}
static async openUrl(url: string): Promise<void> {
return invoke("plugin:opener|open_url", { url });
}