Skip to content

Commit

Permalink
feat(ui): add support for cancelling installation
Browse files Browse the repository at this point in the history
  • Loading branch information
skjsjhb committed Feb 28, 2025
1 parent 2140a77 commit 2171f95
Show file tree
Hide file tree
Showing 12 changed files with 86 additions and 43 deletions.
44 changes: 30 additions & 14 deletions src/main/api/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -30,6 +31,14 @@ export type VanillaInstallEvent =

type ForgeInstallActionType = "smelt" | "smelt-legacy" | "merge" | "none";

const installControllers = new Map<string, AbortController>();

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}`);
Expand All @@ -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;
Expand All @@ -65,7 +80,7 @@ ipcMain.on("installGame", async (e, gameId) => {
gameVersion,
game.installProps.loaderVersion,
c,
{ onProgress }
control
);
p = await profileLoader.fromContainer(fid, c);
}
Expand All @@ -75,7 +90,7 @@ ipcMain.on("installGame", async (e, gameId) => {
gameVersion,
game.installProps.loaderVersion,
c,
{ onProgress }
control
);
p = await profileLoader.fromContainer(qid, c);
}
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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"),
Expand All @@ -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;
Expand All @@ -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);
}
});

Expand Down
5 changes: 3 additions & 2 deletions src/main/install/forge-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -18,10 +19,10 @@ async function getModLoaderUrl(v: string): Promise<string> {
return (await loadCompat() as any)["mod-loader"][v] ?? "";
}

async function downloadModLoader(url: string): Promise<string> {
async function downloadModLoader(url: string, control?: ProgressController): Promise<string> {
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;
}

Expand Down
2 changes: 1 addition & 1 deletion src/main/install/forge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ async function downloadInstaller(loaderVersion: string, type: "installer" | "uni
url,
path: fp
}
]);
], { signal: control?.signal });

return fp;
}
Expand Down
3 changes: 1 addition & 2 deletions src/main/install/neoforged.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ async function syncVersions(): Promise<string[]> {
async function queryLoaderVersions(gameVersion: string, control?: ProgressController): Promise<string[]> {
control?.onProgress?.(progress.indefinite("neoforged.resolve"));

// TODO fix version detection
const [, minor, patch] = gameVersion.split(".");

const versions = await syncVersions();
Expand Down Expand Up @@ -53,7 +52,7 @@ async function downloadInstaller(loaderVersion: string, control?: ProgressContro
url: genInstallerUrl(loaderVersion),
path: fp
}
]);
], { signal: control?.signal });

return fp;
}
Expand Down
9 changes: 6 additions & 3 deletions src/main/install/smelt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ async function readInstallProfile(installer: string): Promise<SmeltInstallInit>
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);
Expand Down Expand Up @@ -249,7 +248,7 @@ async function getMainClass(jar: string): Promise<string> {
/**
* Executes the processor JARs to finalize the installation.
*/
async function runProcessor(jrtExec: string, p: Processor, values: Map<string, string>, container: Container) {
async function runProcessor(jrtExec: string, p: Processor, values: Map<string, string>, container: Container, signal?: AbortSignal) {
// Skip mappings download first
const taskIndex = p.args.indexOf("--task");
if (taskIndex >= 0 && p.args[taskIndex + 1] === "DOWNLOAD_MOJMAPS") {
Expand Down Expand Up @@ -286,8 +285,12 @@ async function runProcessor(jrtExec: string, p: Processor, values: Map<string, s
...args
], { stdio: ["ignore", "inherit", "inherit"] });

signal?.addEventListener("abort", () => proc.kill());

const code = await pEvent(proc, "exit");

signal?.throwIfAborted();

if (code !== 0) throw "Failed to execute processor";
}

Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/main/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
5 changes: 5 additions & 0 deletions src/main/net/aria2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down Expand Up @@ -242,6 +246,7 @@ function available() {
*/
async function remove(gid: string) {
try {
notifyCancel(gid);
await aria2cRpcClient?.request("aria2.remove", [
"token:" + aria2cToken,
gid
Expand Down
2 changes: 1 addition & 1 deletion src/main/util/exception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function create<K extends keyof ExceptionType>(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
Expand Down
9 changes: 8 additions & 1 deletion src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,21 @@ const native = {
*/
install: {
/**
* Install vanilla game.
* Run game installer.
*/
installGame(gameId: string): void {
const ch = new MessageChannel();
ipcRenderer.postMessage("installGame", gameId, [ch.port2]);
exposePort(gameId, ch.port1);
},

/**
* Cancels existing installation process.
*/
cancel(gameId: string): void {
ipcRenderer.send("cancelInstall", gameId);
},

queryAvailableModLoaders(gameVersion: string): Promise<string[]> {
return ipcRenderer.invoke("queryAvailableModLoaders", gameVersion);
}
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/components/ExceptionDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
7 changes: 1 addition & 6 deletions src/renderer/pages/games/GameCard.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 <Card shadow="sm">
<CardBody>
<div className="flex gap-4 items-center h-16 px-3">
Expand Down Expand Up @@ -53,7 +49,6 @@ export function GameCard({ game }: GameCardProps) {
<GameCardActions
gameId={id}
installStatus={installStatus}
onInstall={handleInstall}
/>
</div>
</div>
Expand Down
38 changes: 27 additions & 11 deletions src/renderer/pages/games/GameCardActions.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
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";

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

function handleShowDetails() {
nav(`/game-detail/${gameId}`);
}

function handleInstall() {
void remoteInstaller.install(gameId);
}

function handleCancel() {
void native.install.cancel(gameId);
}

async function launch() {
try {
setLaunching(true);
Expand All @@ -39,14 +47,22 @@ export function GameCardActions({ installStatus, gameId, onInstall }: GameAction
<CirclePlayIcon/>
</Button>
:
<Button
isIconOnly
isLoading={installStatus === "installing"}
color="secondary"
onPress={onInstall}
>
<DownloadIcon/>
</Button>
installStatus === "installing" ?
<Button
isIconOnly
color="danger"
onPress={handleCancel}
>
<XIcon/>
</Button>
:
<Button
isIconOnly
color="secondary"
onPress={handleInstall}
>
<DownloadIcon/>
</Button>
}

<Button isIconOnly onPress={handleShowDetails}>
Expand Down

0 comments on commit 2171f95

Please sign in to comment.