From 2171f95f36b85702edfb3f1388eb0aa1b77cd31d Mon Sep 17 00:00:00 2001 From: skjsjhb Date: Fri, 28 Feb 2025 11:06:05 +0800 Subject: [PATCH] feat(ui): add support for cancelling installation --- src/main/api/install.ts | 44 +++++++++++++------- src/main/install/forge-compat.ts | 5 ++- src/main/install/forge.ts | 2 +- src/main/install/neoforged.ts | 3 +- src/main/install/smelt.ts | 9 ++-- src/main/ipc/channels.ts | 1 + src/main/net/aria2.ts | 5 +++ src/main/util/exception.ts | 2 +- src/preload/preload.ts | 9 +++- src/renderer/components/ExceptionDisplay.tsx | 4 +- src/renderer/pages/games/GameCard.tsx | 7 +--- src/renderer/pages/games/GameCardActions.tsx | 38 ++++++++++++----- 12 files changed, 86 insertions(+), 43 deletions(-) diff --git a/src/main/api/install.ts b/src/main/api/install.ts index 3301d801..9edf92b9 100644 --- a/src/main/api/install.ts +++ b/src/main/api/install.ts @@ -12,7 +12,8 @@ import { ipcMain } from "@/main/ipc/typed"; import { jrt } from "@/main/jrt/install"; import { profileLoader } from "@/main/profile/loader"; import { reg } from "@/main/registry/registry"; -import type { Progress } from "@/main/util/progress"; +import { exceptions } from "@/main/util/exception"; +import type { Progress, ProgressController } from "@/main/util/progress"; import fs from "fs-extra"; export type VanillaInstallEvent = @@ -30,6 +31,14 @@ export type VanillaInstallEvent = type ForgeInstallActionType = "smelt" | "smelt-legacy" | "merge" | "none"; +const installControllers = new Map(); + +ipcMain.on("cancelInstall", (_, gameId) => { + const ac = installControllers.get(gameId); + installControllers.delete(gameId); + ac?.abort(exceptions.create("cancelled", {})); +}); + ipcMain.on("installGame", async (e, gameId) => { const [port] = e.ports; console.debug(`Starting installation of ${gameId}`); @@ -45,13 +54,19 @@ ipcMain.on("installGame", async (e, gameId) => { send({ type: "progress", progress: p }); } - // TODO add signal control to stop the action + const abortController = new AbortController(); + installControllers.set(gameId, abortController); try { const installType = game.installProps.type; const { gameVersion } = game.installProps; - const vanillaProfile = await vanillaInstaller.installProfile(gameVersion, c, { onProgress }); + const control: ProgressController = { + signal: abortController.signal, + onProgress + }; + + const vanillaProfile = await vanillaInstaller.installProfile(gameVersion, c, control); let p = vanillaProfile; let forgeInstallerPath: string | null = null; @@ -65,7 +80,7 @@ ipcMain.on("installGame", async (e, gameId) => { gameVersion, game.installProps.loaderVersion, c, - { onProgress } + control ); p = await profileLoader.fromContainer(fid, c); } @@ -75,7 +90,7 @@ ipcMain.on("installGame", async (e, gameId) => { gameVersion, game.installProps.loaderVersion, c, - { onProgress } + control ); p = await profileLoader.fromContainer(qid, c); } @@ -84,10 +99,10 @@ ipcMain.on("installGame", async (e, gameId) => { let loaderVersion = game.installProps.loaderVersion; if (!loaderVersion) { - loaderVersion = await neoforgedInstaller.pickLoaderVersion(gameVersion, { onProgress }); + loaderVersion = await neoforgedInstaller.pickLoaderVersion(gameVersion, control); } - forgeInstallerPath = await neoforgedInstaller.downloadInstaller(loaderVersion, { onProgress }); + forgeInstallerPath = await neoforgedInstaller.downloadInstaller(loaderVersion, control); forgeInstallAction = "smelt"; forgeInstallerInit = await smelt.readInstallProfile(forgeInstallerPath); @@ -99,11 +114,11 @@ ipcMain.on("installGame", async (e, gameId) => { let loaderVersion = game.installProps.loaderVersion; if (!loaderVersion) { - loaderVersion = await forgeInstaller.pickLoaderVersion(gameVersion, { onProgress }); + loaderVersion = await forgeInstaller.pickLoaderVersion(gameVersion, control); } const installerType = forgeInstaller.getInstallType(gameVersion); - forgeInstallerPath = await forgeInstaller.downloadInstaller(loaderVersion, installerType, { onProgress }); + forgeInstallerPath = await forgeInstaller.downloadInstaller(loaderVersion, installerType, control); const modLoaderUrl = await forgeCompat.getModLoaderUrl(gameVersion); forgeModLoaderPath = modLoaderUrl && await forgeCompat.downloadModLoader(modLoaderUrl); @@ -135,14 +150,14 @@ ipcMain.on("installGame", async (e, gameId) => { } // Ensure libraries - await jrt.installRuntime(p.javaVersion?.component ?? "jre-legacy", { onProgress }); + await jrt.installRuntime(p.javaVersion?.component ?? "jre-legacy", control); - await vanillaInstaller.installLibraries(p, c, new Set(), { onProgress }); + await vanillaInstaller.installLibraries(p, c, new Set(), control); // Finalize Forge if (installType === "neoforged" || installType === "forge") { if (forgeInstallAction === "smelt") { - await smelt.runPostInstall(forgeInstallerInit!, forgeInstallerPath!, p, c, { onProgress }); + await smelt.runPostInstall(forgeInstallerInit!, forgeInstallerPath!, p, c, control); } else if (forgeInstallAction === "merge") { await smeltLegacy.patchLegacyLibraries( jrt.executable(p.javaVersion?.component ?? "jre-legacy"), @@ -164,7 +179,7 @@ ipcMain.on("installGame", async (e, gameId) => { } // Game-level post install - await vanillaInstaller.installAssets(p, c, game.assetsLevel, { onProgress }); + await vanillaInstaller.installAssets(p, c, game.assetsLevel, control); await vanillaInstaller.emitOptions(c); game.launchHint.profileId = p.id; @@ -177,8 +192,9 @@ ipcMain.on("installGame", async (e, gameId) => { port.close(); } catch (e) { send({ type: "error", err: e }); - + } finally { port.close(); + installControllers.delete(gameId); } }); diff --git a/src/main/install/forge-compat.ts b/src/main/install/forge-compat.ts index 2af0df58..0a91b579 100644 --- a/src/main/install/forge-compat.ts +++ b/src/main/install/forge-compat.ts @@ -3,6 +3,7 @@ import { paths } from "@/main/fs/paths"; import { dlx } from "@/main/net/dlx"; import type { Library } from "@/main/profile/version-profile"; import { unwrapESM } from "@/main/util/module"; +import type { ProgressController } from "@/main/util/progress"; import fs from "fs-extra"; import { nanoid } from "nanoid"; @@ -18,10 +19,10 @@ async function getModLoaderUrl(v: string): Promise { return (await loadCompat() as any)["mod-loader"][v] ?? ""; } -async function downloadModLoader(url: string): Promise { +async function downloadModLoader(url: string, control?: ProgressController): Promise { console.debug(`Fetching ModLoader from ${url}`); const fp = paths.temp.to(`mod-loader-${nanoid()}.jar`); - await dlx.getAll([{ url, path: fp }]); + await dlx.getAll([{ url, path: fp }], { signal: control?.signal }); return fp; } diff --git a/src/main/install/forge.ts b/src/main/install/forge.ts index 1cbb7fdf..2636d9f1 100644 --- a/src/main/install/forge.ts +++ b/src/main/install/forge.ts @@ -85,7 +85,7 @@ async function downloadInstaller(loaderVersion: string, type: "installer" | "uni url, path: fp } - ]); + ], { signal: control?.signal }); return fp; } diff --git a/src/main/install/neoforged.ts b/src/main/install/neoforged.ts index abf96498..0220be4b 100644 --- a/src/main/install/neoforged.ts +++ b/src/main/install/neoforged.ts @@ -19,7 +19,6 @@ async function syncVersions(): Promise { async function queryLoaderVersions(gameVersion: string, control?: ProgressController): Promise { control?.onProgress?.(progress.indefinite("neoforged.resolve")); - // TODO fix version detection const [, minor, patch] = gameVersion.split("."); const versions = await syncVersions(); @@ -53,7 +52,7 @@ async function downloadInstaller(loaderVersion: string, control?: ProgressContro url: genInstallerUrl(loaderVersion), path: fp } - ]); + ], { signal: control?.signal }); return fp; } diff --git a/src/main/install/smelt.ts b/src/main/install/smelt.ts index d392858f..d89b8da9 100644 --- a/src/main/install/smelt.ts +++ b/src/main/install/smelt.ts @@ -56,7 +56,6 @@ async function readInstallProfile(installer: string): Promise const data = await zip.entryData("install_profile.json"); const obj = JSON.parse(data.toString()); - // TODO add support for V0 installers if (typeof obj === "object" && obj) { const ip = obj as InstallProfile; const versionData = await zip.entryData(ip.json.startsWith("/") ? ip.json.slice(1) : ip.json); @@ -249,7 +248,7 @@ async function getMainClass(jar: string): Promise { /** * Executes the processor JARs to finalize the installation. */ -async function runProcessor(jrtExec: string, p: Processor, values: Map, container: Container) { +async function runProcessor(jrtExec: string, p: Processor, values: Map, container: Container, signal?: AbortSignal) { // Skip mappings download first const taskIndex = p.args.indexOf("--task"); if (taskIndex >= 0 && p.args[taskIndex + 1] === "DOWNLOAD_MOJMAPS") { @@ -286,8 +285,12 @@ async function runProcessor(jrtExec: string, p: Processor, values: Map proc.kill()); + const code = await pEvent(proc, "exit"); + signal?.throwIfAborted(); + if (code !== 0) throw "Failed to execute processor"; } @@ -334,8 +337,8 @@ async function runPostInstall( onProgress?.(prog); for (const p of init.installProfile.processors) { - await runProcessor(jrtExec, p, values, container); signal?.throwIfAborted(); + await runProcessor(jrtExec, p, values, container); prog.value.current++; onProgress?.(prog); diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts index 481fffe9..89d38920 100644 --- a/src/main/ipc/channels.ts +++ b/src/main/ipc/channels.ts @@ -21,6 +21,7 @@ export type IpcCallEvents = { destroyGame: (id: string) => void; revealGameContent: (id: string, scope: string) => void; languageChange: (lang: string) => void; + cancelInstall: (id: string) => void; } /** diff --git a/src/main/net/aria2.ts b/src/main/net/aria2.ts index 70f30ada..0c2c3bd7 100644 --- a/src/main/net/aria2.ts +++ b/src/main/net/aria2.ts @@ -207,6 +207,10 @@ function extractEmitter(gid: string): Emittery | null { return em; } +function notifyCancel(gid: string) { + extractEmitter(gid)?.emit("error", "Cancelled"); +} + function notifyComplete(gid: string) { extractEmitter(gid)?.emit("finish"); } @@ -242,6 +246,7 @@ function available() { */ async function remove(gid: string) { try { + notifyCancel(gid); await aria2cRpcClient?.request("aria2.remove", [ "token:" + aria2cToken, gid diff --git a/src/main/util/exception.ts b/src/main/util/exception.ts index 85d30ae6..9315850f 100644 --- a/src/main/util/exception.ts +++ b/src/main/util/exception.ts @@ -26,7 +26,7 @@ function create(type: K, detail: ExceptionType[K] // Electron prohibits transferring arbitrary object as errors to the renderer // We serialize the data, prefix it with a special placeholder, then send it to the renderer // It will be restored there - return "\0\0\0" + JSON.stringify({ + return "\x00\x01\x02" + JSON.stringify({ ALICORN_EXCEPTION: true, type, detail diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 8a1b9096..51d5b324 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -73,7 +73,7 @@ const native = { */ install: { /** - * Install vanilla game. + * Run game installer. */ installGame(gameId: string): void { const ch = new MessageChannel(); @@ -81,6 +81,13 @@ const native = { exposePort(gameId, ch.port1); }, + /** + * Cancels existing installation process. + */ + cancel(gameId: string): void { + ipcRenderer.send("cancelInstall", gameId); + }, + queryAvailableModLoaders(gameVersion: string): Promise { return ipcRenderer.invoke("queryAvailableModLoaders", gameVersion); } diff --git a/src/renderer/components/ExceptionDisplay.tsx b/src/renderer/components/ExceptionDisplay.tsx index 24fbe8e9..b31f3130 100644 --- a/src/renderer/components/ExceptionDisplay.tsx +++ b/src/renderer/components/ExceptionDisplay.tsx @@ -119,8 +119,8 @@ function restoreError(e: unknown) { const se = String(e); // Restore the error previously serialized - if (se.includes("\0\0\0")) { - const json = se.split("\0\0\0")[1]; + if (se.includes("\x00\x01\x02") && se.includes("ALICORN_EXCEPTION")) { + const json = se.split("\x00\x01\x02")[1]; return JSON.parse(json); } } catch {} diff --git a/src/renderer/pages/games/GameCard.tsx b/src/renderer/pages/games/GameCard.tsx index 861d5b33..7dc8fe20 100644 --- a/src/renderer/pages/games/GameCard.tsx +++ b/src/renderer/pages/games/GameCard.tsx @@ -1,5 +1,5 @@ import type { GameProfile } from "@/main/game/spec"; -import { remoteInstaller, useInstallProgress } from "@/renderer/services/install"; +import { useInstallProgress } from "@/renderer/services/install"; import { GameTypeImage } from "@components/GameTypeImage"; import { Card, CardBody, Chip } from "@heroui/react"; import { GameCardActions } from "@pages/games/GameCardActions"; @@ -20,10 +20,6 @@ export function GameCard({ game }: GameCardProps) { const progressText = installProgress && tc(installProgress.state, { ...installProgress.value }); - async function handleInstall() { - await remoteInstaller.install(id); - } - return
@@ -53,7 +49,6 @@ export function GameCard({ game }: GameCardProps) {
diff --git a/src/renderer/pages/games/GameCardActions.tsx b/src/renderer/pages/games/GameCardActions.tsx index c187d095..3454bcf1 100644 --- a/src/renderer/pages/games/GameCardActions.tsx +++ b/src/renderer/pages/games/GameCardActions.tsx @@ -1,7 +1,8 @@ +import { remoteInstaller } from "@/renderer/services/install"; import { procService } from "@/renderer/services/proc"; import { useNav } from "@/renderer/util/nav"; import { Button } from "@heroui/react"; -import { CirclePlayIcon, DownloadIcon, EllipsisIcon } from "lucide-react"; +import { CirclePlayIcon, DownloadIcon, EllipsisIcon, XIcon } from "lucide-react"; import { useState } from "react"; type InstallStatus = "installed" | "installing" | "not-installed"; @@ -9,10 +10,9 @@ type InstallStatus = "installed" | "installing" | "not-installed"; interface GameActionsProps { installStatus: InstallStatus; gameId: string; - onInstall: () => void; } -export function GameCardActions({ installStatus, gameId, onInstall }: GameActionsProps) { +export function GameCardActions({ installStatus, gameId }: GameActionsProps) { const [launching, setLaunching] = useState(false); const nav = useNav(); @@ -20,6 +20,14 @@ export function GameCardActions({ installStatus, gameId, onInstall }: GameAction nav(`/game-detail/${gameId}`); } + function handleInstall() { + void remoteInstaller.install(gameId); + } + + function handleCancel() { + void native.install.cancel(gameId); + } + async function launch() { try { setLaunching(true); @@ -39,14 +47,22 @@ export function GameCardActions({ installStatus, gameId, onInstall }: GameAction : - + installStatus === "installing" ? + + : + }