mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-08-20 04:27:29 +00:00
feat: download retry and error surfacing (#95)
This commit is contained in:
parent
85855ac4af
commit
98f92c3173
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue