From 8b22b50fa7a11913e778e8f7659f3f7480ec717f Mon Sep 17 00:00:00 2001 From: "Ted \"skjsjhb\" Gao" Date: Mon, 27 Jan 2025 18:15:13 +0800 Subject: [PATCH] feat(launch): add remote module to manage game status - Add remote game manager. - Add event handlers for game managing. - Standardize namings. --- src/main/api/launcher.ts | 20 +++-- src/main/game/inspect.ts | 4 +- src/main/game/spec.ts | 5 +- src/main/ipc/channels.ts | 7 +- src/main/launch/bl.ts | 8 +- src/main/launch/{game-proc.ts => proc.ts} | 33 +++++--- src/preload/preload.ts | 23 +++--- src/renderer/lib/remote-game.ts | 97 +++++++++++++++++++++++ src/renderer/pages/games/GameCard.tsx | 12 +-- 9 files changed, 163 insertions(+), 46 deletions(-) rename src/main/launch/{game-proc.ts => proc.ts} (81%) create mode 100644 src/renderer/lib/remote-game.ts diff --git a/src/main/api/launcher.ts b/src/main/api/launcher.ts index bd21253d..444156e0 100644 --- a/src/main/api/launcher.ts +++ b/src/main/api/launcher.ts @@ -4,28 +4,36 @@ import { reg } from "@/main/registry/registry"; import { MessageChannelMain, MessagePortMain } from "electron"; import type EventEmitter from "node:events"; +export interface LaunchGameResult { + id: string; + pid: number; +} + ipcMain.handle("launch", async (_, gameId: string) => { const launchHint = reg.games.get(gameId).launchHint; console.log(`Launching ${gameId}`); const g = await bl.launch(launchHint); - return g.id; + return { + id: g.id, + pid: g.pid() + }; }); ipcMain.on("subscribeGameEvents", (e, gid: string) => { const g = bl.getInstance(gid); const ch = new MessageChannelMain(); forwardGameEvents(g.emitter, ch.port1); - e.sender.postMessage(`feedGameEvents:${gid}`, null, [ch.port2]); + e.sender.postMessage(`dispatchGameEvents:${gid}`, null, [ch.port2]); }); -ipcMain.on("stopGame", (_, gid: string) => { - bl.getInstance(gid).stop(); +ipcMain.on("stopGame", (_, procId: string) => { + bl.getInstance(procId).stop(); }); -ipcMain.on("removeGame", (_, gid: string) => { - bl.removeInstance(gid); +ipcMain.on("removeGame", (_, procId: string) => { + bl.removeInstance(procId); }); function forwardGameEvents(src: EventEmitter, dst: MessagePortMain) { diff --git a/src/main/game/inspect.ts b/src/main/game/inspect.ts index e7766e13..14e1d116 100644 --- a/src/main/game/inspect.ts +++ b/src/main/game/inspect.ts @@ -1,5 +1,5 @@ import { containers } from "@/main/container/manage"; -import type { GameSummary } from "@/main/game/spec"; +import type { GameProfileDetail } from "@/main/game/spec"; import { profileInspector } from "@/main/profile/inspect"; import { profileLoader } from "@/main/profile/loader"; import { reg } from "@/main/registry/registry"; @@ -7,7 +7,7 @@ import { reg } from "@/main/registry/registry"; /** * Creates a detailed summary object for the specified game. */ -async function tellGame(gameId: string): Promise { +async function tellGame(gameId: string): Promise { const game = reg.games.get(gameId); const container = containers.get(game.launchHint.containerId); const profile = await profileLoader.fromContainer(game.launchHint.profileId, container); diff --git a/src/main/game/spec.ts b/src/main/game/spec.ts index 9292005c..ad82e43d 100644 --- a/src/main/game/spec.ts +++ b/src/main/game/spec.ts @@ -27,7 +27,10 @@ export interface GameProfile { launchHint: LaunchHint; } -export interface GameSummary { +/** + * Detailed game profile to be used in the frontend. + */ +export interface GameProfileDetail { id: string; name: string; versionId: string; diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts index 07b52bab..4592b413 100644 --- a/src/main/ipc/channels.ts +++ b/src/main/ipc/channels.ts @@ -1,5 +1,6 @@ +import type { LaunchGameResult } from "@/main/api/launcher"; import type { UserConfig } from "@/main/conf/conf"; -import type { GameProfile, GameSummary } from "@/main/game/spec"; +import type { GameProfile, GameProfileDetail } from "@/main/game/spec"; export type IpcEvents = { updateConfig: (c: UserConfig) => void; @@ -17,6 +18,6 @@ export type IpcCommands = { getConfig: () => UserConfig; selectDir: () => string; listGames: () => GameProfile[]; - tellGame: (gameId: string) => GameSummary; - launch: (id: string) => string; + tellGame: (gameId: string) => GameProfileDetail; + launch: (id: string) => LaunchGameResult; } diff --git a/src/main/launch/bl.ts b/src/main/launch/bl.ts index 6cc8a8a1..7a6a8378 100644 --- a/src/main/launch/bl.ts +++ b/src/main/launch/bl.ts @@ -3,13 +3,13 @@ */ import { jrt } from "@/main/jrt/install"; import { launchArgs } from "@/main/launch/args"; -import { GameInstance, gameProc } from "@/main/launch/game-proc"; +import { gameProc, GameProcess } from "@/main/launch/proc"; import { LaunchHint, LaunchInit } from "@/main/launch/types"; import { profileLoader } from "@/main/profile/loader"; import { reg } from "@/main/registry/registry"; import { containers } from "../container/manage"; -const games = new Map(); +const games = new Map(); /** * Loads necessary information from the launch hint profile and builds launch init params. @@ -40,7 +40,7 @@ async function prepare(hint: LaunchHint): Promise { }; } -async function launch(hint: LaunchHint): Promise { +async function launch(hint: LaunchHint): Promise { const init = await prepare(hint); const args = launchArgs.createArguments(init); const g = gameProc.create(init.jrtExec, args, init.container.gameDir()); @@ -48,7 +48,7 @@ async function launch(hint: LaunchHint): Promise { return g; } -function getInstance(gid: string): GameInstance { +function getInstance(gid: string): GameProcess { const g = games.get(gid); if (!g) throw `No such instance: ${gid}`; return g; diff --git a/src/main/launch/game-proc.ts b/src/main/launch/proc.ts similarity index 81% rename from src/main/launch/game-proc.ts rename to src/main/launch/proc.ts index 1ba807c1..07a0b7ad 100644 --- a/src/main/launch/game-proc.ts +++ b/src/main/launch/proc.ts @@ -4,9 +4,9 @@ import * as child_process from "node:child_process"; import { ChildProcess } from "node:child_process"; import EventEmitter from "node:events"; import { Readable } from "node:stream"; -import TypedEmitter from "typed-emitter"; +import type TypedEmitter from "typed-emitter"; -export type GameInstanceEvents = { +type GameProcessEvents = { /** * Exits normally. */ @@ -33,12 +33,12 @@ export type GameInstanceEvents = { stderr: (s: string) => void } -type GameInstanceStatus = "created" | "running" | "exited" | "crashed" +type GameProcessStatus = "created" | "running" | "exited" | "crashed" -export class GameInstance { +export class GameProcess { id = nanoid(); - emitter = new EventEmitter() as TypedEmitter; - status: GameInstanceStatus = "created"; + emitter = new EventEmitter() as TypedEmitter; + status: GameProcessStatus = "created"; logs = { stdout: [] as string[], stderr: [] as string[] @@ -65,11 +65,11 @@ export class GameInstance { proc.once("exit", (code) => { console.log(`Game instance ${this.id} (PID ${this.proc?.pid ?? "UNKNOWN"}) exited with code ${code}.`); if (code === 0) { - this.emitter.emit("exit"); this.status = "exited"; + this.emitter.emit("exit"); } else { - this.emitter.emit("crash"); this.status = "crashed"; + this.emitter.emit("crash"); } this.emitter.emit("end"); @@ -97,6 +97,13 @@ export class GameInstance { collect(proc.stderr, this.logs.stderr); } + /** + * Gets the process PID. + */ + pid() { + return this.proc?.pid ?? -1; + } + /** * Terminates the process. */ @@ -113,15 +120,15 @@ export class GameInstance { } } -const instances = new Map(); +const procs = new Map(); /** * Creates a new game process and saves it for lookups. */ -function create(...args: ConstructorParameters): GameInstance { - const g = new GameInstance(...args); - instances.set(g.id, g); - g.emitter.once("exit", () => instances.delete(g.id)); +function create(...args: ConstructorParameters): GameProcess { + const g = new GameProcess(...args); + procs.set(g.id, g); + g.emitter.once("exit", () => procs.delete(g.id)); return g; } diff --git a/src/preload/preload.ts b/src/preload/preload.ts index c9080eff..b66ecc2e 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -1,5 +1,6 @@ +import type { LaunchGameResult } from "@/main/api/launcher"; import type { UserConfig } from "@/main/conf/conf"; -import type { GameProfile, GameSummary } from "@/main/game/spec"; +import type { GameProfile, GameProfileDetail } from "@/main/game/spec"; import { type IpcCommands, type IpcEvents } from "@/main/ipc/channels"; import type { TypedIpcRenderer } from "@/main/ipc/typed"; import { contextBridge, ipcRenderer as ipcRendererRaw } from "electron"; @@ -56,7 +57,7 @@ const native = { /** * Gets detailed information for the given game. */ - tell(gameId: string): Promise { + tell(gameId: string): Promise { return ipcRenderer.invoke("tellGame", gameId); } }, @@ -68,17 +69,17 @@ const native = { /** * Launches the game using the given launch hint. */ - launch(launchHintId: string): Promise { - return ipcRenderer.invoke("launch", launchHintId); + launch(gameId: string): Promise { + return ipcRenderer.invoke("launch", gameId); }, /** * Creates an event target to receive events from the game. */ - async subscribe(gameId: string): Promise { - ipcRenderer.send("subscribeGameEvents", gameId); + async subscribe(procId: string): Promise { + ipcRenderer.send("subscribeGameEvents", procId); const port = await new Promise(res => - ipcRendererRaw.once(`feedGameEvents:${gameId}`, (e) => res(e.ports[0])) + ipcRendererRaw.once(`dispatchGameEvents:${procId}`, (e) => res(e.ports[0])) ) as MessagePort; const et = new EventTarget(); @@ -93,15 +94,15 @@ const native = { /** * Terminates the given game. */ - stop(gameId: string): void { - ipcRenderer.send("stopGame", gameId); + stop(procId: string): void { + ipcRenderer.send("stopGame", procId); }, /** * Detaches the given game. */ - remove(gameId: string): void { - ipcRenderer.send("removeGame", gameId); + remove(procId: string): void { + ipcRenderer.send("removeGame", procId); } }, diff --git a/src/renderer/lib/remote-game.ts b/src/renderer/lib/remote-game.ts new file mode 100644 index 00000000..eef51ab2 --- /dev/null +++ b/src/renderer/lib/remote-game.ts @@ -0,0 +1,97 @@ +/** + * Syncs events from main process and maintain renderer-side game instances. + */ + +/** + * Contains a slice of game information at the renderer side. + */ +interface RemoteGameProcess { + id: string; + pid: number; + + status: RemoteGameStatus; + + logs: { + stdout: string[]; + stderr: string[]; + }; +} + +type RemoteGameStatus = "running" | "exited" | "crashed"; + +/** + * Update the given process object based on events from the event target. + */ +function syncEvents(emitter: EventTarget, proc: RemoteGameProcess): RemoteGameProcess { + emitter.addEventListener("exit", () => proc.status = "exited"); + emitter.addEventListener("crash", () => proc.status = "crashed"); + + function clearLogs(buf: string[]) { + const limit = 10000; + const deleteCount = 100; + if (buf.length > limit) { + buf.splice(0, deleteCount); + } + } + + function collect(name: string, buf: string[]) { + emitter.addEventListener(name, (e: Event) => { + if (e instanceof CustomEvent) { + const [s] = e.detail; + buf.push(s); + clearLogs(buf); + } + }); + } + + collect("stdout", proc.logs.stdout); + collect("stderr", proc.logs.stderr); + + return proc; +} + +const procs = new Map(); +const emitters = new Map(); + +async function create(gameId: string): Promise { + const res = await native.launcher.launch(gameId); + const proc: RemoteGameProcess = { + id: res.id, + pid: res.pid, + status: "running", + logs: { + stdout: [], + stderr: [] + } + }; + + const et = await native.launcher.subscribe(res.id); + syncEvents(et, proc); + + const em = new EventTarget(); + + ["exit", "crash", "stdout", "stderr"].forEach(ch => { + et.addEventListener(ch, () => em.dispatchEvent(new CustomEvent("change"))); + }); + + procs.set(res.id, proc); + emitters.set(res.id, em); +} + +/** + * Gets a getter to the game process object and an event target for retrieving the realtime game status. + */ +function subscribe(procId: string): [() => RemoteGameProcess, EventTarget] | null { + const proc = procs.get(procId); + const et = emitters.get(procId); + + if (proc && et) { + return [() => structuredClone(proc), et]; + } + + return null; +} + +export const remoteGame = { + create, subscribe +}; diff --git a/src/renderer/pages/games/GameCard.tsx b/src/renderer/pages/games/GameCard.tsx index b52cd382..460121ac 100644 --- a/src/renderer/pages/games/GameCard.tsx +++ b/src/renderer/pages/games/GameCard.tsx @@ -1,4 +1,4 @@ -import type { GameProfile, GameSummary } from "@/main/game/spec"; +import type { GameProfile, GameProfileDetail } from "@/main/game/spec"; import grassBlock from "@assets/img/grass-block.webp"; import { Alert, @@ -32,7 +32,7 @@ interface GameCardDisplayProps { } export function GameCardDisplay({ gameProfile }: GameCardDisplayProps) { - const [summary, setSummary] = useState(); + const [summary, setSummary] = useState(); const [error, setError] = useState(); const { id, name } = gameProfile; @@ -44,7 +44,7 @@ export function GameCardDisplay({ gameProfile }: GameCardDisplayProps) { }, [id]); if (summary) { - return ; + return ; } if (error) { @@ -99,13 +99,13 @@ function GameCardSkeleton() { } interface GameCardProps { - gameSummary: GameSummary; + detail: GameProfileDetail; } -function GameCard({ gameSummary }: GameCardProps) { +function GameCard({ detail }: GameCardProps) { const { t } = useTranslation("pages", { keyPrefix: "games.game-card" }); - const { name, versionId, gameVersion, installed } = gameSummary; + const { name, versionId, gameVersion, installed } = detail; return