From a94e4ec7585904e675793ac67356bfcfd959ce02 Mon Sep 17 00:00:00 2001 From: /home/neo <158327205+neoapps-dev@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:12:40 +0300 Subject: [PATCH] feat: parallel downloads, redesigned download overlay (#100) --- src-tauri/src/commands/dlc.rs | 10 ++- src-tauri/src/commands/download.rs | 50 +++++++----- src-tauri/src/lib.rs | 2 +- src-tauri/src/state.rs | 2 +- src/components/layout/DownloadOverlay.tsx | 94 +++++++++++++--------- src/components/modals/DownloadDlcModal.tsx | 86 ++++++++++++++++---- src/components/views/HomeView.tsx | 4 +- src/components/views/VersionsView.tsx | 26 +++--- src/context/LauncherContext.tsx | 4 +- src/hooks/useDiscordRPC.ts | 29 ++++--- src/hooks/useGameManager.ts | 51 +++++++----- src/pages/App.tsx | 6 +- src/services/TauriService.ts | 8 +- 13 files changed, 239 insertions(+), 133 deletions(-) diff --git a/src-tauri/src/commands/dlc.rs b/src-tauri/src/commands/dlc.rs index 7caec1c..88f3f04 100644 --- a/src-tauri/src/commands/dlc.rs +++ b/src-tauri/src/commands/dlc.rs @@ -1,7 +1,8 @@ use std::fs; use serde::Serialize; -use tauri::AppHandle; +use tauri::{AppHandle, Emitter}; use crate::util; +use super::download::DownloadProgress; #[derive(Serialize)] pub struct GitEntry { pub name: String, @@ -180,7 +181,8 @@ pub async fn download_dlc_files( fs::create_dir_all(&dlc_dest).map_err(|e| e.to_string())?; let client = reqwest::Client::new(); - for file_path in &files_to_download { + let total = files_to_download.len(); + for (i, file_path) in files_to_download.iter().enumerate() { let raw_url = get_raw_url(&host, &owner, &repo, &branch, file_path, is_github); let response = client.get(&raw_url) .header("User-Agent", "Emerald-Launcher") @@ -199,6 +201,10 @@ pub async fn download_dlc_files( fs::create_dir_all(parent).map_err(|e| e.to_string())?; } fs::write(&dest_path, &bytes).map_err(|e| format!("Failed to write {}: {}", file_path, e))?; + let _ = app.emit("download-progress", DownloadProgress { + instance_id: format!("dlc:{}", dlc_folder), + percent: ((i + 1) as f64 / total as f64) * 100.0, + }); } Ok(()) diff --git a/src-tauri/src/commands/download.rs b/src-tauri/src/commands/download.rs index b2ab9f7..fa7d0d5 100644 --- a/src-tauri/src/commands/download.rs +++ b/src-tauri/src/commands/download.rs @@ -2,30 +2,35 @@ use std::fs; use std::io::Write; use std::time::Duration; use futures_util::StreamExt; +use serde::Serialize; use tauri::{AppHandle, Emitter, State}; use tokio_util::sync::CancellationToken; use crate::state::DownloadState; use crate::util; +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadProgress { + pub instance_id: String, + pub percent: f64, +} + async fn stream_download( app: &AppHandle, state: &DownloadState, url: &str, dest: &std::path::PathBuf, - progress_event: &str, + instance_id: &str, ) -> Result { 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 mut lock = state.tokens.lock().await; + lock.insert(instance_id.to_string(), token); } let response = reqwest::get(url).await.map_err(|e| e.to_string())?; if !response.status().is_success() { - { *state.token.lock().await = None; } + { state.tokens.lock().await.remove(instance_id); } return Err(format!("Download failed: {}", response.status())); } @@ -41,19 +46,22 @@ async fn stream_download( if child_token.is_cancelled() { drop(file); let _ = fs::remove_file(dest); - { *state.token.lock().await = None; } + { state.tokens.lock().await.remove(instance_id); } 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(progress_event, downloaded / total_size * 100.0); + let _ = app.emit("download-progress", DownloadProgress { + instance_id: instance_id.to_string(), + percent: downloaded / total_size * 100.0, + }); } } drop(file); - { *state.token.lock().await = None; } + { state.tokens.lock().await.remove(instance_id); } Ok(last_modified) } @@ -62,7 +70,7 @@ async fn download_with_retry( state: &DownloadState, url: &str, dest: &std::path::PathBuf, - progress_event: &str, + instance_id: &str, max_retries: u32, ) -> Result { let mut last_error = String::new(); @@ -71,8 +79,8 @@ async fn download_with_retry( 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 mut lock = state.tokens.lock().await; + lock.insert(instance_id.to_string(), cancel.clone()); } let _ = app.emit("download-retry", attempt); tokio::select! { @@ -83,7 +91,7 @@ async fn download_with_retry( } } } - match stream_download(app, state, url, dest, progress_event).await { + match stream_download(app, state, url, dest, instance_id).await { Ok(result) => return Ok(result), Err(e) if e == "CANCELLED" => return Err(e), Err(e) => { @@ -107,7 +115,7 @@ pub async fn download_and_install( 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 last_modified = download_with_retry(&app, &state, &url, &zip_path, &instance_id, 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", @@ -226,8 +234,11 @@ pub async fn download_and_install( } #[tauri::command] -pub async fn cancel_download(state: State<'_, DownloadState>) -> Result<(), String> { - if let Some(token) = state.token.lock().await.take() { token.cancel(); } +pub async fn cancel_download(state: State<'_, DownloadState>, instance_id: String) -> Result<(), String> { + let token = state.tokens.lock().await.remove(&instance_id); + if let Some(t) = token { + t.cancel(); + } Ok(()) } @@ -245,10 +256,9 @@ 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 tarball_path = runners_dir.join(format!("{}.tar.gz", name)); - download_with_retry(&app, &state, &url, &tarball_path, "runner-download-progress", 3).await?; - + let runner_id = format!("runner_{}", name); + download_with_retry(&app, &state, &url, &tarball_path, &runner_id, 3).await?; 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/lib.rs b/src-tauri/src/lib.rs index c14f827..09aca54 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -48,7 +48,7 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .manage(DownloadState { - token: Arc::new(Mutex::new(None)), + tokens: Arc::new(Mutex::new(HashMap::new())), }) .manage(GameState { child: Arc::new(Mutex::new(None)), diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index ab777c1..cc35004 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; pub struct DownloadState { - pub token: Arc>>, + pub tokens: Arc>>, } pub struct GameState { diff --git a/src/components/layout/DownloadOverlay.tsx b/src/components/layout/DownloadOverlay.tsx index 34b188b..c4c2241 100644 --- a/src/components/layout/DownloadOverlay.tsx +++ b/src/components/layout/DownloadOverlay.tsx @@ -3,52 +3,70 @@ import { memo } from "react"; import type { Edition } from "../../types/edition"; interface DownloadOverlayProps { - downloadProgress: number | null; - downloadingId: string | null; + downloadProgress: Record; + downloadingIds: string[]; editions: Edition[]; } -export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress, downloadingId, editions }: DownloadOverlayProps) { - if (downloadProgress === null) return null; +export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress, downloadingIds, editions }: DownloadOverlayProps) { + if (downloadingIds.length === 0) return null; return ( -
- - Downloading +
+ + Downloads -
- {editions.find((e) => e.id === downloadingId)?.name || "Game Files"} -
-
- - {Math.floor(downloadProgress)}% - -
-
-
-
- Loading -
-
+
+
+ {downloadingIds.map((id) => { + const pct = downloadProgress[id] ?? 0; + const edition = editions.find((e) => e.instanceId === id || e.id === id); + const name = edition?.name || "Game Files"; + return ( +
+ {edition?.logo ? ( + + ) : ( +
+ + + + + +
+ )} +
+ + {name} + +
+
+
+
+ + {Math.floor(pct)}% + +
+
+
+ ); + })}
); diff --git a/src/components/modals/DownloadDlcModal.tsx b/src/components/modals/DownloadDlcModal.tsx index 4a24203..4c43847 100644 --- a/src/components/modals/DownloadDlcModal.tsx +++ b/src/components/modals/DownloadDlcModal.tsx @@ -28,6 +28,7 @@ export default function DownloadDlcModal({ const [error, setError] = useState(null); const [downloading, setDownloading] = useState(false); const [downloaded, setDownloaded] = useState([]); + const [dlcProgress, setDlcProgress] = useState>({}); const [focusIndex, setFocusIndex] = useState(0); const listRef = useRef(null); const [branch, repoUrl] = officialDLC.includes(":") @@ -82,6 +83,7 @@ export default function DownloadDlcModal({ setError(null); setDownloading(false); setDownloaded([]); + setDlcProgress({}); setFocusIndex(0); fetchDlcs(); } @@ -148,23 +150,46 @@ export default function DownloadDlcModal({ playPressSound(); setDownloading(true); setDownloaded([]); - for (const dlc of selected) { - try { - await TauriService.downloadDlcFiles( - instanceId, - repoUrl, - branch, - dlc.name, - ); - setDownloaded((prev) => [...prev, dlc.name]); - } catch (e) { + setDlcProgress({}); + const initialProgress: Record = {}; + selected.forEach((dlc) => { + initialProgress[dlc.name] = 0; + }); + setDlcProgress(initialProgress); + const unlisten = await TauriService.onDownloadProgress((data) => { + if (data.instanceId.startsWith("dlc:")) { + const name = data.instanceId.slice(4); + setDlcProgress((prev) => { + if (prev[name] === undefined) return prev; + return { ...prev, [name]: data.percent }; + }); + if (data.percent >= 100) { + setDownloaded((prev) => + prev.includes(name) ? prev : [...prev, name], + ); + } + } + }); + + const results = await Promise.allSettled( + selected.map((dlc) => + TauriService.downloadDlcFiles(instanceId, repoUrl, branch, dlc.name), + ), + ); + + unlisten(); + const succeeded: string[] = []; + for (let i = 0; i < results.length; i++) { + const r = results[i]; + if (r.status === "fulfilled") { + succeeded.push(selected[i].name); + } else { setError( - `Failed to download ${dlc.name}: ${e instanceof Error ? e.message : String(e)}`, + `Failed to download ${selected[i].name}: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`, ); - setDownloading(false); - return; } } + setDownloaded(succeeded); setDownloading(false); }; @@ -204,13 +229,13 @@ export default function DownloadDlcModal({
)} - {!loading && !error && dlcList.length === 0 && ( + {!loading && !error && dlcList.length === 0 && !downloading && (
No DLC folders found in the repository.
)} - {!loading && dlcList.length > 0 && ( + {!loading && dlcList.length > 0 && !downloading && ( <>
@@ -270,10 +295,39 @@ export default function DownloadDlcModal({ )} + {downloading && ( +
+ {dlcList + .filter((d) => d.selected) + .map((dlc) => { + const pct = dlcProgress[dlc.name] ?? 0; + const isDone = downloaded.includes(dlc.name); + return ( +
+ + {isDone ? "100" : Math.floor(pct)}% + +
+ + {dlc.name} + +
+
+
+
+
+ ); + })} +
+ )} +
{downloading && (
- Downloading... ({downloaded.length}/{selectedCount}) + Downloaded {downloaded.length}/{selectedCount}
)}
diff --git a/src/components/views/HomeView.tsx b/src/components/views/HomeView.tsx index 90e0f13..ea484be 100644 --- a/src/components/views/HomeView.tsx +++ b/src/components/views/HomeView.tsx @@ -19,7 +19,7 @@ const HomeView = memo(function HomeView() { editions, installs, toggleInstall, - downloadingId, + downloadingIds, isGameRunning, stopGame, updatesAvailable, @@ -30,7 +30,7 @@ const HomeView = memo(function HomeView() { const selectedEdition = editions.find((e: Edition) => e.id === profile); const selectedVersionName = selectedEdition?.name || "Game"; const isInstalled = installs.includes(profile); - const isDownloading = downloadingId === profile; + const isDownloading = downloadingIds.includes(profile); const [menuFocus, setMenuFocus] = useState(null); const hasAnyInstall = installs.length > 0; diff --git a/src/components/views/VersionsView.tsx b/src/components/views/VersionsView.tsx index de812bc..9c1e0d9 100644 --- a/src/components/views/VersionsView.tsx +++ b/src/components/views/VersionsView.tsx @@ -74,7 +74,7 @@ const VersionsView = memo(function VersionsView() { deleteCustomEdition: onDeleteEdition, addCustomEdition: onAddEdition, updateCustomEdition: onUpdateEdition, - downloadingId, + downloadingIds, downloadProgress, updatesAvailable, addToSteam, @@ -150,17 +150,17 @@ const VersionsView = memo(function VersionsView() { if (focusIndex < editions.length) { const edition = editions[focusIndex]; const isInstalled = installedVersions.includes(edition.instanceId); - const isDownloading = downloadingId === edition.instanceId; + const isDownloading = downloadingIds.includes(edition.instanceId); if (focusBtn === 0) { if (isInstalled) { playPressSound(); setOpenMenuId(openMenuId === edition.id ? null : edition.id); } else { - if (!isDownloading && !downloadingId) { + if (!isDownloading) { playPressSound(); toggleInstall(edition.instanceId); - } else if (isDownloading) { - handleCancelDownload(); + } else { + handleCancelDownload(edition.instanceId); } } } else if (focusBtn === 1 && !isInstalled) { @@ -190,7 +190,7 @@ const VersionsView = memo(function VersionsView() { focusBtn, editions, installedVersions, - downloadingId, + downloadingIds, ITEM_COUNT, playPressSound, playBackSound, @@ -285,7 +285,7 @@ const VersionsView = memo(function VersionsView() { hasAnyInstall && selectedProfile === edition.instanceId; const isFocused = focusIndex === i; const isCustom = edition.id.startsWith("custom_"); - const isDownloading = downloadingId === edition.instanceId; + const isDownloading = downloadingIds.includes(edition.instanceId); const isComingSoon = edition.comingSoon; return ( @@ -302,7 +302,7 @@ const VersionsView = memo(function VersionsView() {
{isDownloading ? ( - {Math.floor(downloadProgress || 0)}% + {Math.floor(downloadProgress[edition.instanceId] || 0)}% ) : edition.logo ? ( edition.logo.startsWith("http") || @@ -404,10 +404,10 @@ const VersionsView = memo(function VersionsView() {