Skip to content

Commit

Permalink
feat(launch): add launch functions and bindings
Browse files Browse the repository at this point in the history
- Add handlers for game lifecycle managements.
- Add bindings in preload script.
  • Loading branch information
skjsjhb committed Jan 26, 2025
1 parent 8c907cc commit 63555b5
Show file tree
Hide file tree
Showing 5 changed files with 133 additions and 18 deletions.
36 changes: 36 additions & 0 deletions src/main/api/launcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ipcMain } from "@/main/ipc/typed";
import { bl } from "@/main/launch/bl";
import { reg } from "@/main/registry/registry";
import { MessageChannelMain, MessagePortMain } from "electron";
import type EventEmitter from "node:events";

ipcMain.handle("launch", async (_, launchHintId: string) => {
const launchHint = reg.launchHints.get(launchHintId);
if (!launchHint) throw `No such launch hint: ${launchHintId}`;

console.log(`Launching ${launchHintId}`);
const g = await bl.launch(launchHint);

return g.id;
});

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]);
});

ipcMain.on("stopGame", (_, gid: string) => {
bl.getInstance(gid).stop();
});

ipcMain.on("removeGame", (_, gid: string) => {
bl.removeInstance(gid);
});

function forwardGameEvents(src: EventEmitter, dst: MessagePortMain) {
["end", "crash", "exit", "stdout", "stderr"].forEach(ch => {
src.on(ch, (...args) => dst.postMessage({ channel: ch, args }));
});
}
4 changes: 4 additions & 0 deletions src/main/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ export type IpcEvents = {
closeWindow: () => void;
minimizeWindow: () => void;
openUrl: (url: string) => void;
subscribeGameEvents: (gid: string) => void;
stopGame: (id: string) => void;
removeGame: (id: string) => void;
}

export type IpcCommands = {
getConfig: () => UserConfig;
selectDir: () => string;
listGames: () => GameProfile[];
launch: (id: string) => string;
}
23 changes: 20 additions & 3 deletions src/main/launch/bl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
*/
import { jrt } from "@/main/jrt/install";
import { launchArgs } from "@/main/launch/args";
import { GameInstance, proc } from "@/main/launch/proc";
import { GameInstance, gameProc } from "@/main/launch/game-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>();

/**
* Loads necessary information from the launch hint profile and builds launch init params.
*/
Expand Down Expand Up @@ -41,8 +43,23 @@ async function prepare(hint: LaunchHint): Promise<LaunchInit> {
async function launch(hint: LaunchHint): Promise<GameInstance> {
const init = await prepare(hint);
const args = launchArgs.createArguments(init);
return proc.newGameProc(init.jrtExec, args, init.container.gameDir());
const g = gameProc.create(init.jrtExec, args, init.container.gameDir());
games.set(g.id, g);
return g;
}

function getInstance(gid: string): GameInstance {
const g = games.get(gid);
if (!g) throw `No such instance: ${gid}`;
return g;
}

export const bl = { launch };
function removeInstance(gid: string): void {
const g = games.get(gid);
games.delete(gid);
if (g) {
g.detach();
}
}

export const bl = { launch, getInstance, removeInstance };
31 changes: 16 additions & 15 deletions src/main/launch/proc.ts → src/main/launch/game-proc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import EventEmitter from "node:events";
import { Readable } from "node:stream";
import TypedEmitter from "typed-emitter";

type GameInstanceEvents = {
export type GameInstanceEvents = {
/**
* Exits normally.
*/
Expand All @@ -33,17 +33,12 @@ type GameInstanceEvents = {
stderr: (s: string) => void
}

enum GameInstanceStatus {
CREATED = "created",
RUNNING = "running",
EXITED = "exited",
CRASHED = "crashed"
}
type GameInstanceStatus = "created" | "running" | "exited" | "crashed"

export class GameInstance {
id = nanoid();
emitter = new EventEmitter() as TypedEmitter<GameInstanceEvents>;
status = GameInstanceStatus.CREATED;
status: GameInstanceStatus = "created";
logs = {
stdout: [] as string[],
stderr: [] as string[]
Expand All @@ -59,7 +54,7 @@ export class GameInstance {
this.proc = proc;

proc.once("spawn", () => {
this.status = GameInstanceStatus.RUNNING;
this.status = "running";
});

proc.on("error", (e) => {
Expand All @@ -71,10 +66,10 @@ export class GameInstance {
console.log(`Game instance ${this.id} (PID ${this.proc?.pid ?? "UNKNOWN"}) exited with code ${code}.`);
if (code === 0) {
this.emitter.emit("exit");
this.status = GameInstanceStatus.EXITED;
this.status = "exited";
} else {
this.emitter.emit("crash");
this.status = GameInstanceStatus.CRASHED;
this.status = "crashed";
}

this.emitter.emit("end");
Expand Down Expand Up @@ -107,6 +102,14 @@ export class GameInstance {
*/
stop() {
this.proc?.kill();
this.detach();
}

detach() {
this.proc?.stdout?.removeAllListeners();
this.proc?.stderr?.removeAllListeners();
this.emitter.removeAllListeners();
this.proc = null;
}
}

Expand All @@ -115,13 +118,11 @@ const instances = new Map<string, GameInstance>();
/**
* Creates a new game process and saves it for lookups.
*/
function newGameProc(...args: ConstructorParameters<typeof GameInstance>): GameInstance {
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));
return g;
}

export const proc = {
newGameProc
};
export const gameProc = { create };
57 changes: 57 additions & 0 deletions src/preload/preload.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { UserConfig } from "@/main/conf/conf";
import type { GameProfile } 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 @@ -41,6 +42,62 @@ const native = {
}
},

/**
* Game profile managements.
*/
game: {
/**
* Get all available game profiles.
*/
list(): Promise<GameProfile[]> {
return ipcRenderer.invoke("listGames");
}
},

/**
* Launcher and game instance managements.
*/
launcher: {
/**
* Launches the game using the given launch hint.
*/
launch(launchHintId: string): Promise<string> {
return ipcRenderer.invoke("launch", launchHintId);
},

/**
* Creates an event target to receive events from the game.
*/
async subscribe(gameId: string): Promise<EventTarget> {
ipcRenderer.send("subscribeGameEvents", gameId);
const port = await new Promise(res =>
ipcRendererRaw.once(`feedGameEvents:${gameId}`, (e) => res(e.ports[0]))
) as MessagePort;

const et = new EventTarget();
port.onmessage = (e) => {
const { channel, args } = e.data;
et.dispatchEvent(new CustomEvent(channel, { detail: args }));
};

return et;
},

/**
* Terminates the given game.
*/
stop(gameId: string): void {
ipcRenderer.send("stopGame", gameId);
},

/**
* Detaches the given game.
*/
remove(gameId: string): void {
ipcRenderer.send("removeGame", gameId);
}
},

/**
* Configuration sync methods.
*/
Expand Down

0 comments on commit 63555b5

Please sign in to comment.