Skip to content

Commit

Permalink
feat(launch): add remote module to manage game status
Browse files Browse the repository at this point in the history
- Add remote game manager.
- Add event handlers for game managing.
- Standardize namings.
  • Loading branch information
skjsjhb committed Jan 27, 2025
1 parent 1ddc16e commit 8b22b50
Show file tree
Hide file tree
Showing 9 changed files with 163 additions and 46 deletions.
20 changes: 14 additions & 6 deletions src/main/api/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions src/main/game/inspect.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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";

/**
* Creates a detailed summary object for the specified game.
*/
async function tellGame(gameId: string): Promise<GameSummary> {
async function tellGame(gameId: string): Promise<GameProfileDetail> {
const game = reg.games.get(gameId);
const container = containers.get(game.launchHint.containerId);
const profile = await profileLoader.fromContainer(game.launchHint.profileId, container);
Expand Down
5 changes: 4 additions & 1 deletion src/main/game/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions src/main/ipc/channels.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
}
8 changes: 4 additions & 4 deletions src/main/launch/bl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, GameInstance>();
const games = new Map<string, GameProcess>();

/**
* Loads necessary information from the launch hint profile and builds launch init params.
Expand Down Expand Up @@ -40,15 +40,15 @@ async function prepare(hint: LaunchHint): Promise<LaunchInit> {
};
}

async function launch(hint: LaunchHint): Promise<GameInstance> {
async function launch(hint: LaunchHint): Promise<GameProcess> {
const init = await prepare(hint);
const args = launchArgs.createArguments(init);
const g = gameProc.create(init.jrtExec, args, init.container.gameDir());
games.set(g.id, g);
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;
Expand Down
33 changes: 20 additions & 13 deletions src/main/launch/game-proc.ts → src/main/launch/proc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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<GameInstanceEvents>;
status: GameInstanceStatus = "created";
emitter = new EventEmitter() as TypedEmitter<GameProcessEvents>;
status: GameProcessStatus = "created";
logs = {
stdout: [] as string[],
stderr: [] as string[]
Expand All @@ -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");
Expand Down Expand Up @@ -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.
*/
Expand All @@ -113,15 +120,15 @@ export class GameInstance {
}
}

const instances = new Map<string, GameInstance>();
const procs = new Map<string, GameProcess>();

/**
* Creates a new game process and saves it for lookups.
*/
function create(...args: ConstructorParameters<typeof GameInstance>): GameInstance {
const g = new GameInstance(...args);
instances.set(g.id, g);
g.emitter.once("exit", () => instances.delete(g.id));
function create(...args: ConstructorParameters<typeof GameProcess>): GameProcess {
const g = new GameProcess(...args);
procs.set(g.id, g);
g.emitter.once("exit", () => procs.delete(g.id));
return g;
}

Expand Down
23 changes: 12 additions & 11 deletions src/preload/preload.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -56,7 +57,7 @@ const native = {
/**
* Gets detailed information for the given game.
*/
tell(gameId: string): Promise<GameSummary> {
tell(gameId: string): Promise<GameProfileDetail> {
return ipcRenderer.invoke("tellGame", gameId);
}
},
Expand All @@ -68,17 +69,17 @@ const native = {
/**
* Launches the game using the given launch hint.
*/
launch(launchHintId: string): Promise<string> {
return ipcRenderer.invoke("launch", launchHintId);
launch(gameId: string): Promise<LaunchGameResult> {
return ipcRenderer.invoke("launch", gameId);
},

/**
* Creates an event target to receive events from the game.
*/
async subscribe(gameId: string): Promise<EventTarget> {
ipcRenderer.send("subscribeGameEvents", gameId);
async subscribe(procId: string): Promise<EventTarget> {
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();
Expand All @@ -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);
}
},

Expand Down
97 changes: 97 additions & 0 deletions src/renderer/lib/remote-game.ts
Original file line number Diff line number Diff line change
@@ -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<string, RemoteGameProcess>();
const emitters = new Map<string, EventTarget>();

async function create(gameId: string): Promise<void> {
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
};
12 changes: 6 additions & 6 deletions src/renderer/pages/games/GameCard.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -32,7 +32,7 @@ interface GameCardDisplayProps {
}

export function GameCardDisplay({ gameProfile }: GameCardDisplayProps) {
const [summary, setSummary] = useState<GameSummary>();
const [summary, setSummary] = useState<GameProfileDetail>();
const [error, setError] = useState();

const { id, name } = gameProfile;
Expand All @@ -44,7 +44,7 @@ export function GameCardDisplay({ gameProfile }: GameCardDisplayProps) {
}, [id]);

if (summary) {
return <GameCard gameSummary={summary}/>;
return <GameCard detail={summary}/>;
}

if (error) {
Expand Down Expand Up @@ -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 <Card>
<CardBody>
Expand Down

0 comments on commit 8b22b50

Please sign in to comment.