feat: parallel downloads, redesigned download overlay (#100)

This commit is contained in:
/home/neo 2026-06-28 18:12:40 +03:00 committed by GitHub
parent 92bd9c6f2a
commit a94e4ec758
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 239 additions and 133 deletions

View file

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

View file

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

View file

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

View file

@ -3,7 +3,7 @@ use std::sync::Arc;
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
pub struct DownloadState {
pub token: Arc<Mutex<Option<CancellationToken>>>,
pub tokens: Arc<Mutex<HashMap<String, CancellationToken>>>,
}
pub struct GameState {

View file

@ -3,52 +3,70 @@ import { memo } from "react";
import type { Edition } from "../../types/edition";
interface DownloadOverlayProps {
downloadProgress: number | null;
downloadingId: string | null;
downloadProgress: Record<string, number>;
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 (
<motion.div
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 50 }}
className="absolute top-14 right-8 z-100 w-64 p-4 shadow-2xl flex flex-col gap-2"
style={{
backgroundImage: "url('/images/Download_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
initial={{ opacity: 0, scale: 0.95, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -10 }}
transition={{ duration: 0.15 }}
className="absolute top-14 right-8 z-100 w-72 bg-[#1a1a1a] border-2 border-[#555] shadow-2xl"
style={{ imageRendering: "pixelated" }}
>
<div className="flex flex-col gap-1 w-full">
<span className="text-[15px] text-[#FFFF55] mc-text-shadow uppercase tracking-widest text-center w-full">
Downloading
<div className="px-3 pt-2.5 pb-2 border-b border-white/10">
<span className="text-[13px] text-[#FFFF55] mc-text-shadow uppercase tracking-widest">
Downloads
</span>
<div className="text-[10px] text-gray-300 mc-text-shadow truncate uppercase opacity-80 pb-1 text-center w-full">
{editions.find((e) => e.id === downloadingId)?.name || "Game Files"}
</div>
<div className="flex items-center gap-2 w-full">
<span className="text-[10px] text-white mc-text-shadow w-6 text-right shrink-0 flex items-center justify-end h-[14px] leading-none">
{Math.floor(downloadProgress)}%
</span>
<div className="flex-1 h-3.5 border-2 border-white bg-black/40 relative">
<div
className="h-full bg-white transition-all duration-300"
style={{ width: `${downloadProgress}%` }}
/>
</div>
<div className="w-6 flex items-center justify-start shrink-0">
<img
src="/images/loading.gif"
alt="Loading"
className="w-4 h-4 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-1.5 px-3 py-2 max-h-[260px] overflow-y-auto custom-scrollbar">
{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 (
<div key={id} className="flex items-center gap-2.5">
{edition?.logo ? (
<img
src={edition.logo}
alt=""
className="w-6 h-6 object-contain shrink-0"
style={{ imageRendering: "pixelated" }}
/>
) : (
<div className="w-6 h-6 flex items-center justify-center border border-[#555] bg-black/40 shrink-0">
<svg className="w-3 h-3 text-[#FFFF55]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</div>
)}
<div className="flex-1 min-w-0 flex flex-col gap-1">
<span className="text-[11px] text-white mc-text-shadow truncate leading-tight">
{name}
</span>
<div className="flex items-center gap-1.5">
<div className="flex-1 h-2 border border-white/30 bg-black/60">
<div
className="h-full bg-[#FFFF55]"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-[10px] text-gray-400 mc-text-shadow w-7 text-right shrink-0 leading-none">
{Math.floor(pct)}%
</span>
</div>
</div>
</div>
);
})}
</div>
</motion.div>
);

View file

@ -28,6 +28,7 @@ export default function DownloadDlcModal({
const [error, setError] = useState<string | null>(null);
const [downloading, setDownloading] = useState(false);
const [downloaded, setDownloaded] = useState<string[]>([]);
const [dlcProgress, setDlcProgress] = useState<Record<string, number>>({});
const [focusIndex, setFocusIndex] = useState(0);
const listRef = useRef<HTMLDivElement>(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<string, number> = {};
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({
</div>
)}
{!loading && !error && dlcList.length === 0 && (
{!loading && !error && dlcList.length === 0 && !downloading && (
<div className="text-white text-sm mc-text-shadow mb-4 py-8">
No DLC folders found in the repository.
</div>
)}
{!loading && dlcList.length > 0 && (
{!loading && dlcList.length > 0 && !downloading && (
<>
<div className="flex items-center justify-between w-full mb-2 gap-2">
<span className="text-white text-[10px] mc-text-shadow uppercase tracking-widest">
@ -270,10 +295,39 @@ export default function DownloadDlcModal({
</>
)}
{downloading && (
<div className="w-full max-h-[40vh] overflow-y-auto custom-scrollbar border border-[#373737] bg-black/20 flex flex-col gap-2 p-3">
{dlcList
.filter((d) => d.selected)
.map((dlc) => {
const pct = dlcProgress[dlc.name] ?? 0;
const isDone = downloaded.includes(dlc.name);
return (
<div key={dlc.name} className="flex items-center gap-2">
<span className="text-[11px] text-white mc-text-shadow w-7 text-right shrink-0">
{isDone ? "100" : Math.floor(pct)}%
</span>
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
<span className="text-[10px] text-gray-300 mc-text-shadow truncate">
{dlc.name}
</span>
<div className="h-2 border border-white/30 bg-black/60">
<div
className="h-full bg-[#FFFF55]"
style={{ width: `${isDone ? 100 : pct}%` }}
/>
</div>
</div>
</div>
);
})}
</div>
)}
<div className="flex items-center justify-center gap-4 mt-4">
{downloading && (
<div className="text-[#FFFF55] text-sm mc-text-shadow">
Downloading... ({downloaded.length}/{selectedCount})
Downloaded {downloaded.length}/{selectedCount}
</div>
)}
</div>

View file

@ -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<number | null>(null);
const hasAnyInstall = installs.length > 0;

View file

@ -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() {
<div className="w-6 flex items-center justify-center flex-shrink-0">
{isDownloading ? (
<span className="text-xs text-gray-400 font-bold">
{Math.floor(downloadProgress || 0)}%
{Math.floor(downloadProgress[edition.instanceId] || 0)}%
</span>
) : edition.logo ? (
edition.logo.startsWith("http") ||
@ -404,10 +404,10 @@ const VersionsView = memo(function VersionsView() {
<button
onClick={(e) => {
e.stopPropagation();
if (!isDownloading && !downloadingId) {
if (!isDownloading) {
toggleInstall(edition.instanceId);
} else if (isDownloading) {
handleCancelDownload();
} else {
handleCancelDownload(edition.instanceId);
}
}}
onMouseEnter={() =>
@ -415,9 +415,7 @@ const VersionsView = memo(function VersionsView() {
}
onMouseLeave={() => setHoveredBtn(null)}
className={`w-9 h-9 flex items-center justify-center ${
isDownloading || (!!downloadingId && !isInstalled)
? "opacity-50"
: ""
isDownloading ? "opacity-50" : ""
}`}
style={{
backgroundImage:

View file

@ -72,7 +72,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
const game = useMemo(() => gameRaw, [
gameRaw.installs, gameRaw.isGameRunning, gameRaw.downloadProgress,
gameRaw.downloadingId, gameRaw.editions, gameRaw.isRunnerDownloading,
gameRaw.downloadingIds, gameRaw.editions, gameRaw.isRunnerDownloading,
gameRaw.runnerDownloadProgress, gameRaw.error, gameRaw.updateCustomEdition,
gameRaw.handleUninstall, gameRaw.handleCancelDownload, gameRaw.gameUpdateMessage, configRaw.profile,
gameRaw.updatesAvailable, gameRaw.addToSteam, gameRaw.steamSuccessMessage,
@ -94,7 +94,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
activeView,
isGameRunning: game.isGameRunning,
downloadProgress: game.downloadProgress,
downloadingId: game.downloadingId,
downloadingIds: game.downloadingIds,
editions: game.editions,
isWindowVisible,
});

View file

@ -9,8 +9,8 @@ interface DiscordRPCProps {
activeView: string;
isGameRunning: boolean;
isWindowVisible: boolean;
downloadProgress: number | null;
downloadingId: string | null;
downloadProgress: Record<string, number>;
downloadingIds: string[];
editions: Edition[];
}
@ -23,15 +23,18 @@ export function useDiscordRPC({
isGameRunning,
isWindowVisible,
downloadProgress,
downloadingId,
downloadingIds,
editions,
}: DiscordRPCProps) {
useEffect(() => {
const updateRPC = async () => {
if (!rpcEnabled || showIntro || !username) return;
if (!isWindowVisible && !isGameRunning && downloadProgress === null)
if (
!isWindowVisible &&
!isGameRunning &&
Object.keys(downloadProgress).length === 0
)
return;
const version = editions.find((e) => e.id === profile);
const versionName = version ? version.name : "Unknown Version";
let details = "In Menus";
@ -41,10 +44,16 @@ export function useDiscordRPC({
if (isGameRunning) {
details = `Playing ${versionName}`;
} else if (downloadProgress !== null) {
} else if (downloadingIds.length > 0) {
const firstId = downloadingIds[0];
const pct = downloadProgress[firstId];
const downloadingName =
editions.find((e) => e.id === downloadingId)?.name || "Game Files";
details = `Downloading ${downloadingName} (${downloadProgress.toFixed(0)}%)`;
editions.find((e) => e.id === firstId)?.name || "Game Files";
const extra =
downloadingIds.length > 1
? ` +${downloadingIds.length - 1} more`
: "";
details = `Downloading ${downloadingName}${extra} (${(pct ?? 0).toFixed(0)}%)`;
} else {
const tabNames: Record<string, string> = {
main: "Main Menu",
@ -78,8 +87,8 @@ export function useDiscordRPC({
activeView,
isGameRunning,
isWindowVisible,
Math.floor(downloadProgress || 0),
downloadingId,
downloadProgress,
downloadingIds,
editions,
]);
}

View file

@ -132,8 +132,8 @@ export function useGameManager({
}: GameManagerProps) {
const [installs, setInstalls] = useState<string[]>([]);
const [isGameRunning, setIsGameRunning] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState<Record<string, number>>({});
const [downloadingIds, setDownloadingIds] = useState<string[]>([]);
const [isRunnerDownloading, setIsRunnerDownloading] = useState(false);
const [runnerDownloadProgress, setRunnerDownloadProgress] = useState<
number | null
@ -359,8 +359,8 @@ export function useGameManager({
useEffect(() => {
checkInstalls();
const unlistenDownload = TauriService.onDownloadProgress((p) =>
setDownloadProgress(p),
const unlistenDownload = TauriService.onDownloadProgress((data) =>
setDownloadProgress((prev) => ({ ...prev, [data.instanceId]: data.percent })),
);
const unlistenRunner = TauriService.onRunnerDownloadProgress((p) =>
setRunnerDownloadProgress(p),
@ -406,19 +406,23 @@ export function useGameManager({
const toggleInstall = useCallback(
async (id: string) => {
if (downloadingId) return;
if (downloadingIds.includes(id)) return;
const edition = editions.find((e) => e.instanceId === id);
if (!edition) return;
setError(null);
try {
setDownloadingId(id);
setDownloadProgress(0);
setDownloadingIds((prev) => [...prev, id]);
setDownloadProgress((prev) => ({ ...prev, [id]: 0 }));
await TauriService.downloadAndInstall(edition.url, id);
await TauriService.syncDlc(id);
await checkInstalls();
setProfile(id);
setDownloadProgress(null);
setDownloadingId(null);
setDownloadProgress((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
setDownloadingIds((prev) => prev.filter((did) => did !== id));
} catch (e: unknown) {
console.error(e);
setError(
@ -428,11 +432,15 @@ export function useGameManager({
? e
: "Failed to install version",
);
setDownloadProgress(null);
setDownloadingId(null);
setDownloadProgress((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
setDownloadingIds((prev) => prev.filter((did) => did !== id));
}
},
[downloadingId, editions, checkInstalls, setProfile],
[downloadingIds, editions, checkInstalls, setProfile],
);
const handleUninstall = useCallback(
@ -443,18 +451,21 @@ export function useGameManager({
[checkInstalls],
);
const handleCancelDownload = useCallback(async () => {
if (!downloadingId) return;
const handleCancelDownload = useCallback(async (id: string) => {
try {
await TauriService.cancelDownload();
await TauriService.deleteInstance(downloadingId);
setDownloadingId(null);
setDownloadProgress(null);
await TauriService.cancelDownload(id);
await TauriService.deleteInstance(id);
setDownloadProgress((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
setDownloadingIds((prev) => prev.filter((did) => did !== id));
await checkInstalls();
} catch (e) {
console.error(e);
}
}, [downloadingId, checkInstalls]);
}, [checkInstalls]);
const handleLaunch = useCallback(async () => {
if (isGameRunning) return;
@ -589,7 +600,7 @@ export function useGameManager({
installs,
isGameRunning,
downloadProgress,
downloadingId,
downloadingIds,
isRunnerDownloading,
runnerDownloadProgress,
error,

View file

@ -116,7 +116,7 @@ export default function App() {
{
isGameRunning: game.isGameRunning,
downloadProgress: game.downloadProgress,
downloadingId: game.downloadingId,
downloadingIds: game.downloadingIds,
},
game.installs,
);
@ -124,7 +124,7 @@ export default function App() {
config,
game.isGameRunning,
game.downloadProgress,
game.downloadingId,
game.downloadingIds,
game.installs,
config.isLoaded,
]);
@ -367,7 +367,7 @@ export default function App() {
<DownloadOverlay
downloadProgress={game.downloadProgress}
downloadingId={game.downloadingId}
downloadingIds={game.downloadingIds}
editions={game.editions}
/>

View file

@ -131,8 +131,8 @@ export class TauriService {
return invoke("delete_instance", { instanceId });
}
static async cancelDownload(): Promise<void> {
return invoke("cancel_download");
static async cancelDownload(instanceId: string): Promise<void> {
return invoke("cancel_download", { instanceId });
}
static async setupMacosRuntime(): Promise<void> {
@ -188,8 +188,8 @@ export class TauriService {
return invoke("workshop_list_installed");
}
static onDownloadProgress(callback: (percent: number) => void) {
return listen<number>("download-progress", (event) =>
static onDownloadProgress(callback: (data: { instanceId: string; percent: number }) => void) {
return listen<{ instanceId: string; percent: number }>("download-progress", (event) =>
callback(event.payload),
);
}