feat: Implemented emulator launching

Fixes #1
This commit is contained in:
Simeon Radivoev 2026-04-04 03:13:09 +03:00
parent 04d5856f7d
commit 09b8b9c6f8
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
20 changed files with 351 additions and 231 deletions

View file

@ -389,7 +389,7 @@ export default new Elysia()
if (validCommand) if (validCommand)
{ {
// launch command waits for the game to exit, we don't want that. // launch command waits for the game to exit, we don't want that.
await launchCommand(validCommand, source, id, validCommands.gameId); await launchCommand(validCommand, validCommands.gameId, validCommands.source, validCommands.sourceId);
return { type: 'application', command: null }; return { type: 'application', command: null };
} else } else
{ {

View file

@ -5,22 +5,20 @@ import { existsSync, readFileSync } from 'node:fs';
import * as schema from '@schema/emulators'; import * as schema from '@schema/emulators';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { config, customEmulators, emulatorsDb, taskQueue } from '../../app'; import { config, customEmulators, emulatorsDb, taskQueue } from '../../app';
import os, { platform } from 'node:os'; import os from 'node:os';
import { cores } from '../../emulatorjs/emulatorjs'; import { cores } from '../../emulatorjs/emulatorjs';
import { LaunchGameJob } from '../../jobs/launch-game-job'; import { LaunchGameJob } from '../../jobs/launch-game-job';
import { EmulatorPackageType } from '@/shared/constants'; import { getStoreEmulatorPackage } from '../../store/services/gamesService';
import { getStoreEmulatorPackage, getStoreFolder } from '../../store/services/gamesService';
import { getOrCached } from '../../cache';
import { getOrCachedScoopPackage } from '../../store/services/emulatorsService'; import { getOrCachedScoopPackage } from '../../store/services/emulatorsService';
export const varRegex = /%([^%]+)%/g; export const varRegex = /%([^%]+)%/g;
export const assignRegex = /(%\w+%)=(\S+) /g; export const assignRegex = /(%\w+%)=(\S+) /g;
export async function launchCommand (validCommand: CommandEntry, source: string, sourceId: string, id: number) export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string)
{ {
if (taskQueue.hasActiveOfType(LaunchGameJob)) if (taskQueue.hasActiveOfType(LaunchGameJob))
{ {
throw new Error(`${id} currently running`); throw new Error(`Game currently running`);
} }
taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId)); taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId));

View file

@ -1,6 +1,6 @@
import { RPC_URL, } from "@shared/constants"; import { RPC_URL, } from "@shared/constants";
import { config, customEmulators, db, emulatorsDb, plugins, taskQueue } from "../../app"; import { config, customEmulators, db, emulatorsDb, plugins, taskQueue } from "../../app";
import { getValidLaunchCommands } from "./launchGameService"; import { findExecs, getValidLaunchCommands } from "./launchGameService";
import * as emulatorSchema from '@schema/emulators'; import * as emulatorSchema from '@schema/emulators';
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { getErrorMessage, hashFile } from "@/bun/utils"; import { getErrorMessage, hashFile } from "@/bun/utils";
@ -26,7 +26,7 @@ class CommandSearchError extends Error
export async function getLocalGame (source: string, id: string) export async function getLocalGame (source: string, id: string)
{ {
const localGame = await db.query.games.findFirst({ const localGame = await db.query.games.findFirst({
columns: { id: true, path_fs: true }, columns: { id: true, path_fs: true, source: true, source_id: true },
where: getLocalGameMatch(id, source), where: getLocalGameMatch(id, source),
with: { with: {
platform: { columns: { slug: true } } platform: { columns: { slug: true } }
@ -36,8 +36,27 @@ export async function getLocalGame (source: string, id: string)
return localGame; return localGame;
} }
export async function getValidLaunchCommandsForGame (source: string, id: string) export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined>
{ {
if (source === 'emulator')
{
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, id) });
const allExecs = await findExecs(id, esEmulator);
return {
commands: allExecs.map(exec => ({
command: exec.binPath,
id: exec.type,
emulator: id,
emulatorSource: exec.type,
metadata: {
emulatorBin: exec.binPath,
emulatorDir: exec.rootPath
},
valid: true
} satisfies CommandEntry)),
gameId: { source: "emulator", id: id }
};
}
const localGame = await getLocalGame(source, id); const localGame = await getLocalGame(source, id);
if (localGame) if (localGame)
{ {
@ -70,7 +89,7 @@ export async function getValidLaunchCommandsForGame (source: string, id: string)
const validCommand = commands.find(c => c.valid); const validCommand = commands.find(c => c.valid);
if (validCommand) if (validCommand)
{ {
return { commands: commands.filter(c => c.valid), gameId: localGame.id, source: source, sourceId: id }; return { commands: commands.filter(c => c.valid), gameId: { id: String(localGame.id), source: 'local' }, source: localGame.source ?? source, sourceId: String(localGame.source_id) ?? id };
} }
else else
{ {

View file

@ -1,5 +1,15 @@
import { EmulatorDownloadInfoType, EmulatorPackageType } from "@/shared/constants"; import { EmulatorDownloadInfoType, EmulatorPackageType } from "@/shared/constants";
import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable";
import { any } from "zod";
interface EmulatorPostInstallContext
{
emulator: string;
emulatorPackage?: EmulatorPackageType;
path: string;
update: boolean;
info: EmulatorDownloadInfoType;
}
export class EmulatorHooks export class EmulatorHooks
{ {
@ -12,11 +22,24 @@ export class EmulatorHooks
/** /**
* Triggered when emulator is downloaded or updated * Triggered when emulator is downloaded or updated
*/ */
emulatorPostInstall = new AsyncSeriesHook<[ctx: { emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContext], { emulator: string; }>(['ctx']);
emulator: string;
emulatorPackage?: EmulatorPackageType; constructor()
path: string; {
update: boolean; this.emulatorPostInstall.intercept({
info: EmulatorDownloadInfoType; register (tap)
}]>(['ctx']); {
return {
...tap,
fn: async (ctx: EmulatorPostInstallContext, ...rest: any[]) =>
{
if (ctx.emulator === tap.emulator)
{
tap.fn(ctx, ...rest);
}
}
};
},
});
}
} }

View file

@ -15,10 +15,11 @@ export class GameHooks
autoValidCommand: CommandEntry; autoValidCommand: CommandEntry;
dryRun: boolean, dryRun: boolean,
game: { game: {
source: string; source?: string;
id: number; sourceId?: string;
id: FrontEndId;
}; };
}], string[] | undefined>(['ctx']); }], string[] | undefined, { emulator: string; }>(['ctx']);
/** /**
* Is the given emulator for the given command supported * Is the given emulator for the given command supported
* @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects. * @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects.
@ -27,7 +28,7 @@ export class GameHooks
emulatorLaunchSupport = new SyncBailHook<[ctx: { emulatorLaunchSupport = new SyncBailHook<[ctx: {
emulator: string; emulator: string;
source?: EmulatorSourceEntryType; source?: EmulatorSourceEntryType;
}], EmulatorSupport | undefined>(['ctx']); }], EmulatorSupport | undefined, { emulator: string; }>(['ctx']);
/** /**
* Fetches and returns a list of games converted to frontend. * Fetches and returns a list of games converted to frontend.
* @param ctx.localGameIds This is local game ids in the format '<source>@<sourceId>' * @param ctx.localGameIds This is local game ids in the format '<source>@<sourceId>'
@ -71,4 +72,38 @@ export class GameHooks
updatePlayed = new AsyncSeriesWaterfallHook<[ctx: { source: string, id: string; }], boolean>(["ctx"]); updatePlayed = new AsyncSeriesWaterfallHook<[ctx: { source: string, id: string; }], boolean>(["ctx"]);
fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']); fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']);
fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']); fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']);
constructor()
{
this.emulatorLaunchSupport.intercept({
register (tap)
{
return {
...tap,
fn: (e: any, ...rest: any[]) =>
{
if (e.emulator === tap.emulator)
{
return tap.fn(e, ...rest);
}
}
};
},
});
this.emulatorLaunch.intercept({
register (tap)
{
return {
...tap,
fn: async (e: any, ...rest: any[]) =>
{
if ((e.autoValidCommand as CommandEntry).emulator === tap.emulator)
{
return tap.fn(e, ...rest);
}
}
};
},
});
}
} }

View file

@ -32,6 +32,7 @@ function registerJob<
data: _job.dataSchema data: _job.dataSchema
}), }),
z.object({ type: z.literal(['completed', 'ended']), data: _job.dataSchema }), z.object({ type: z.literal(['completed', 'ended']), data: _job.dataSchema }),
z.object({ type: z.literal('waiting') }),
z.object({ type: z.literal('error'), error: z.string() }) z.object({ type: z.literal('error'), error: z.string() })
]), ]),
open (ws) open (ws)
@ -41,6 +42,9 @@ function registerJob<
if (job) if (job)
{ {
ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() }); ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
} else
{
ws.send({ type: 'waiting' });
} }
(ws.data as any).cleanup = [ (ws.data as any).cleanup = [
@ -97,10 +101,10 @@ function registerJob<
} }
export const jobs = new Elysia({ prefix: '/api/jobs' }) export const jobs = new Elysia({ prefix: '/api/jobs' })
.use(registerJob(LaunchGameJob))
.use(registerJob(LoginJob)) .use(registerJob(LoginJob))
.use(registerJob(TwitchLoginJob)) .use(registerJob(TwitchLoginJob))
.use(registerJob(UpdateStoreJob)) .use(registerJob(UpdateStoreJob))
.use(registerJob(LaunchGameJob))
.use(registerJob(BiosDownloadJob)) .use(registerJob(BiosDownloadJob))
.use(registerJob(InstallJob)) .use(registerJob(InstallJob))
.use(registerJob(EmulatorDownloadJob)); .use(registerJob(EmulatorDownloadJob));

View file

@ -4,40 +4,51 @@ import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema";
import { db, events, plugins } from "../app"; import { db, events, plugins } from "../app";
import * as appSchema from "@schema/app"; import * as appSchema from "@schema/app";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing"> export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">
{ {
static id = "launch-game" as const; static id = "launch-game" as const;
static dataSchema = z.optional(ActiveGameSchema); static dataSchema = z.nullable(ActiveGameSchema);
group = "launch-game"; group = "launch-game";
activeGame?: ActiveGameType; activeGame: ActiveGameType | null;
gameId: number; gameId: FrontEndId;
validCommand: CommandEntry; validCommand: CommandEntry;
gameSource: string; gameSource?: string;
gameSourceId: string; gameSourceId?: string;
constructor(gameId: number, validCommand: CommandEntry, source: string, sourceId: string) constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string)
{ {
this.gameId = gameId; this.gameId = gameId;
this.validCommand = validCommand; this.validCommand = validCommand;
this.gameSource = source; this.gameSource = source;
this.gameSourceId = sourceId; this.gameSourceId = sourceId;
this.activeGame = null;
} }
async start (context: JobContext<IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">, z.infer<typeof LaunchGameJob.dataSchema>, "playing">) async start (context: JobContext<IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">, z.infer<typeof LaunchGameJob.dataSchema>, "playing">)
{
let gameInfo: { name?: string, source_id?: string, source?: string; };
if (this.gameId.source === 'emulator')
{
gameInfo = { name: this.gameId.id };
} else
{ {
const localGame = await db.query.games.findFirst({ const localGame = await db.query.games.findFirst({
where: eq(appSchema.games.id, this.gameId), columns: { where: eq(appSchema.games.id, Number(this.gameId.id)), columns: {
name: true, name: true,
source_id: true, source_id: true,
source: true source: true
} }
}); });
if (localGame)
gameInfo = { name: localGame.name ?? undefined, source_id: localGame.source_id ?? undefined, source: localGame.source ?? undefined };
}
const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({ const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({
autoValidCommand: this.validCommand, autoValidCommand: this.validCommand,
game: { source: this.gameSource, id: this.gameId } game: { source: this.gameSource, sourceId: this.gameSourceId, id: this.gameId },
dryRun: false
}); });
await new Promise((resolve, reject) => await new Promise((resolve, reject) =>
@ -70,10 +81,15 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
// We have full control over launching integrated emulators better to use bun spawn // We have full control over launching integrated emulators better to use bun spawn
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs], { const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs], {
cwd: this.validCommand.startDir, cwd: this.validCommand.startDir,
signal: context.abortSignal signal: context.abortSignal,
}); });
bunGame.exited.then(resolve).catch(e => context.abortSignal.addEventListener('abort', reject);
bunGame.exited.then(e =>
{
resolve(true);
}).catch(e =>
{ {
console.error(e); console.error(e);
reject(e); reject(e);
@ -87,28 +103,27 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
this.activeGame = { this.activeGame = {
process: game, process: game,
name: localGame?.name ?? "Unknown", name: gameInfo?.name ?? "Unknown",
gameId: this.gameId, gameId: this.gameId,
source: this.gameSource,
sourceId: this.gameSourceId,
command: this.validCommand command: this.validCommand
}; };
const updatePlayed = async (source: string, id: string) => const updatePlayed = async (id: FrontEndId, source?: string, sourceId?: string) =>
{ {
await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, this.gameId)); if (this.gameId.source === 'local')
await plugins.hooks.games.updatePlayed.promise({ source, id }).then(v => {
await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(this.gameId.id)));
}
await plugins.hooks.games.updatePlayed.promise({ source: source ?? id.source, id: sourceId ?? id.id }).then(v =>
{ {
if (v) events.emit('notification', { message: "Updated Last Played", type: 'success' }); if (v) events.emit('notification', { message: "Updated Last Played", type: 'success' });
}); });
}; };
if (this.gameSource !== 'local') updatePlayed(this.gameId, this.gameSource, this.gameSourceId);
{
updatePlayed(this.gameSource, this.gameSourceId);
}
else if (localGame?.source && localGame?.source !== 'local' && localGame.source_id)
{
updatePlayed(localGame.source, localGame.source_id);
}
}); });
/* Old spawn lanching, cases issues, needs to be ran as shell /* Old spawn lanching, cases issues, needs to be ran as shell

View file

@ -6,28 +6,28 @@ import desc from './package.json';
export default class DOLPHINIntegration implements PluginType export default class DOLPHINIntegration implements PluginType
{ {
emulator = 'DOLPHIN';
load (ctx: PluginContextType) load (ctx: PluginContextType)
{ {
ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{ {
if (ctx.emulator === 'DOLPHIN')
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] }; return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] };
}); });
ctx.hooks.emulators.emulatorPostInstall.tapPromise(desc.name, async (ctx) => ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
{ {
await Bun.write(path.join(ctx.path, "portable.txt"), ""); await Bun.write(path.join(ctx.path, "portable.txt"), "");
}); });
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
{ {
if (ctx.autoValidCommand.emulator === 'DOLPHIN' && ctx.autoValidCommand.metadata.emulatorDir) const args: string[] = [];
{
const args = ["--batch"];
const storageFolder = path.join(config.get('downloadPath'), "saves", 'DOLPHIN'); const storageFolder = path.join(config.get('downloadPath'), "storage", 'DOLPHIN');
args.push(`--user=${storageFolder}`);
args.push(...[`--user=${storageFolder}`, `--exec=${ctx.autoValidCommand.metadata.romPath}`]);
args.push(`--config=Dolphin.Display.Fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); args.push(`--config=Dolphin.Display.Fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`);
args.push(`--config=Dolphin.General.ISOPath0=${path.join(config.get('downloadPath'), 'roms', 'gc')}`); args.push(`--config=Dolphin.General.ISOPath0=${path.join(config.get('downloadPath'), 'roms', 'gc')}`);
args.push(`--config=Dolphin.General.ISOPath1=${path.join(config.get('downloadPath'), 'roms', 'wii')}`); args.push(`--config=Dolphin.General.ISOPath1=${path.join(config.get('downloadPath'), 'roms', 'wii')}`);
@ -35,8 +35,19 @@ export default class DOLPHINIntegration implements PluginType
args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`); args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`);
args.push(`--config=Dolphin.Analytics.PermissionAsked=True`); args.push(`--config=Dolphin.Analytics.PermissionAsked=True`);
return args; const savesPath = path.join(config.get('downloadPath'), "saves", 'DOLPHIN');
args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`);
args.push(`--config=Dolphin.General.WiiSDCardSyncFolder=${path.join(savesPath, 'WiiSDSync')}`);
args.push(`--config=Dolphin.GBA.SavesPath=${path.join(savesPath, 'GBA')}`);
if (ctx.autoValidCommand.metadata.romPath)
{
args.push("--batch");
args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`);
} }
return args;
}); });
} }
} }

View file

@ -9,11 +9,11 @@ import desc from './package.json';
export default class PCSX2Integration implements PluginType export default class PCSX2Integration implements PluginType
{ {
emulator = "PCSX2";
load (ctx: PluginContextType) load (ctx: PluginContextType)
{ {
ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{
if (ctx.emulator === 'PCSX2')
{ {
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"];
@ -29,21 +29,23 @@ export default class PCSX2Integration implements PluginType
{ {
return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] };
} }
}
}); });
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
{ {
if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.metadata.emulatorDir) const args: string[] = [];
if (ctx.autoValidCommand.metadata.romPath)
{ {
const args = ["-batch"]; args.push(ctx.autoValidCommand.metadata.romPath);
args.push("-batch");
}
if (config.get('launchInFullscreen')) if (config.get('launchInFullscreen'))
{ {
args.push("-fullscreen"); args.push("-fullscreen");
} }
args.push(...["-bigpicture", "-portable", "--", ctx.autoValidCommand.metadata.romPath]); args.push(...["-bigpicture", "-portable", "--"]);
if (ctx.autoValidCommand.emulatorSource === 'store' && !ctx.dryRun) if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun)
{ {
const configFileContents = await Bun.file(configFile).text(); const configFileContents = await Bun.file(configFile).text();
@ -74,7 +76,6 @@ export default class PCSX2Integration implements PluginType
} }
return args; return args;
}
}); });
} }
} }

View file

@ -12,11 +12,11 @@ import { homedir } from "node:os";
export default class PCSX2Integration implements PluginType export default class PCSX2Integration implements PluginType
{ {
emulator = "PPSSPP";
load (ctx: PluginContextType) load (ctx: PluginContextType)
{ {
ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{
if (ctx.emulator === 'PPSSPP')
{ {
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"];
@ -32,21 +32,23 @@ export default class PCSX2Integration implements PluginType
{ {
return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] };
} }
}
}); });
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
{ {
if (ctx.autoValidCommand.emulator === 'PPSSPP' && ctx.autoValidCommand.metadata.emulatorDir) const args: string[] = [];
if (ctx.autoValidCommand.metadata.romPath)
{ {
const args = [ctx.autoValidCommand.metadata.romPath, "--escape-exit", "--pause-menu-exit"]; args.push(ctx.autoValidCommand.metadata.romPath);
}
args.push("--escape-exit", "--pause-menu-exit");
if (config.get('launchInFullscreen')) if (config.get('launchInFullscreen'))
{ {
args.push("--fullscreen"); args.push("--fullscreen");
} }
if (ctx.autoValidCommand.emulatorSource === 'store' && !ctx.dryRun) if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun)
{ {
let confPath: string | undefined = undefined; let confPath: string | undefined = undefined;
let controlsPath: string | undefined = undefined; let controlsPath: string | undefined = undefined;
@ -90,7 +92,6 @@ export default class PCSX2Integration implements PluginType
} }
return args; return args;
}
}); });
} }
} }

View file

@ -147,8 +147,7 @@ export const store = new Elysia({ prefix: '/api/store' })
biosRequirement: emulatorPackage.bios, biosRequirement: emulatorPackage.bios,
bios: biosFiles, bios: biosFiles,
integrations: findEmulatorPluginIntegration(emulatorPackage.name, execPaths), integrations: findEmulatorPluginIntegration(emulatorPackage.name, execPaths),
storeDownloadInfo: storeDownloadInfo, storeDownloadInfo: storeDownloadInfo
hasUpdate: storeDownloadInfo?.hasUpdate ?? null
}; };
return emulator; return emulator;

View file

@ -28,7 +28,9 @@ export type PluginDescriptionType = z.infer<typeof PluginDescriptionSchema>;
export const ActiveGameSchema = z.object({ export const ActiveGameSchema = z.object({
process: z.any().optional(), process: z.any().optional(),
gameId: z.number(), gameId: z.object({ id: z.string(), source: z.string() }),
source: z.string().optional(),
sourceId: z.string().optional(),
name: z.string(), name: z.string(),
command: z.object({ command: z.string(), startDir: z.string().optional() }) command: z.object({ command: z.string(), startDir: z.string().optional() })
}); });

View file

@ -46,7 +46,8 @@ function Error (data: ErrorComponentProps)
{ {
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" }); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" });
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); const router = useRouter();
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }]);
const { shortcuts } = useShortcutContext(); const { shortcuts } = useShortcutContext();
return <AnimatedBackground ref={ref} backgroundKey="game-details"> return <AnimatedBackground ref={ref} backgroundKey="game-details">

View file

@ -1,13 +1,10 @@
import { AnimatedBackground } from '@/mainview/components/AnimatedBackground'; import { AnimatedBackground } from '@/mainview/components/AnimatedBackground';
import { createFileRoute, useRouter } from '@tanstack/react-router'; import { createFileRoute, useBlocker, useRouter } from '@tanstack/react-router';
import DotsLoading from '../components/backgrounds/dots'; import DotsLoading from '../components/backgrounds/dots';
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import Shortcuts from '../components/Shortcuts'; import Shortcuts from '../components/Shortcuts';
import { gameQuery } from '@queries/romm'; import { useJobStatus } from '../scripts/utils';
import { rommApi } from '../scripts/clientApi';
export const Route = createFileRoute('/launcher/$source/$id')({ export const Route = createFileRoute('/launcher/$source/$id')({
component: RouteComponent, component: RouteComponent,
@ -17,35 +14,34 @@ function RouteComponent ()
{ {
const router = useRouter(); const router = useRouter();
function HandleGoBack () function HandleGoBack ()
{
if (router.history.canGoBack())
{
router.history.back();
} else
{ {
router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true });
} }
}
const { source, id } = Route.useParams(); const { source, id } = Route.useParams();
const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` }); const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` });
const { data } = useQuery(gameQuery(source, id));
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext(); const { shortcuts } = useShortcutContext();
useEffect(() => const { data } = useJobStatus('launch-game', {
{ onEnded (data)
if (!data) return;
const sub = rommApi.api.romm.status({ source: data.id.source })({ id: data.id.id }).subscribe();
sub.subscribe((e) =>
{
if (e.data.status !== 'playing')
{ {
HandleGoBack(); HandleGoBack();
} },
onWaiting ()
{
HandleGoBack();
},
}); });
return () => useBlocker({ shouldBlockFn: () => !!data });
{
sub.close();
};
}, [data?.id]);
return <AnimatedBackground ref={ref} backgroundKey='game-details'> return <AnimatedBackground ref={ref} backgroundKey='game-details'>
<div className='flex shadow-2xs shadow-black flex-col absolute w-screen h-screen overflow-hidden justify-center items-center gap-4'> <div className='flex shadow-2xs shadow-black flex-col absolute w-screen h-screen overflow-hidden justify-center items-center gap-4'>

View file

@ -116,7 +116,7 @@ function SettingsMenu (data: {})
const { ref, focusKey } = useFocusable({ const { ref, focusKey } = useFocusable({
focusable: true, focusable: true,
focusKey: 'settings-menu', focusKey: 'settings-menu',
preferredChildFocusKey: location.hash.replaceAll(/#|(\?.+)/g, '') preferredChildFocusKey: `menu-item-${location.hash.replaceAll(/#|(\?.+)/g, '')}`
}); });
return <ul return <ul

View file

@ -4,11 +4,11 @@ import
useFocusable, useFocusable,
FocusContext, FocusContext,
} from "@noriginmedia/norigin-spatial-navigation"; } from "@noriginmedia/norigin-spatial-navigation";
import { createFileRoute, useRouter } from "@tanstack/react-router"; import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import Shortcuts from "@/mainview/components/Shortcuts"; import Shortcuts from "@/mainview/components/Shortcuts";
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
import { systemApi } from "@/mainview/scripts/clientApi"; import { rommApi, systemApi } from "@/mainview/scripts/clientApi";
import { Button } from "@/mainview/components/options/Button"; import { Button } from "@/mainview/components/options/Button";
import { ChevronDown, CircleFadingArrowUp, Cpu, Download, Gamepad2, Info, Puzzle, Settings, Trash2, TriangleAlert, WandSparkles } from "lucide-react"; import { ChevronDown, CircleFadingArrowUp, Cpu, Download, Gamepad2, Info, Puzzle, Settings, Trash2, TriangleAlert, WandSparkles } from "lucide-react";
import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog"; import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog";
@ -62,6 +62,7 @@ function TitleArea (data: {
onUpdate: (source: string) => void; onUpdate: (source: string) => void;
}) })
{ {
const navigation = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const deleteMutation = useMutation({ const deleteMutation = useMutation({
...storeEmulatorDeleteMutation, ...storeEmulatorDeleteMutation,
@ -202,6 +203,15 @@ function TitleArea (data: {
}); });
} }
} }
options.push(...data.emulator.validSources.filter(s => s.exists).map(s => ({
content: `Launch: ${s.type}`, type: 'primary', icon: emulatorStatusIcons[s.type], action (ctx)
{
if (!data.emulator) return;
rommApi.api.romm.game({ source: 'emulator' })({ id: data.emulator.name }).play.post({ command_id: s.type });
navigation({ to: '/launcher/$source/$id', params: { source: 'emulator', id: data.emulator.name } });
}, id: `open-${s.type}`
} satisfies DialogEntry)));
} }
const { ref, focusKey, hasFocusedChild } = useFocusable({ const { ref, focusKey, hasFocusedChild } = useFocusable({

View file

@ -3,7 +3,7 @@ import { RefObject, useEffect, useRef, useState } from "react";
import { useLocalStorage } from "usehooks-ts"; import { useLocalStorage } from "usehooks-ts";
import { jobsApi } from "./clientApi"; import { jobsApi } from "./clientApi";
import { JobsAPIType } from "@/bun/api/rpc"; import { JobsAPIType } from "@/bun/api/rpc";
import { AnyRouter, Router, useRouter } from "@tanstack/react-router"; import { AnyRouter, useRouter } from "@tanstack/react-router";
import { soundMap } from "./audio/audio"; import { soundMap } from "./audio/audio";
export type ScrollSaveParams = { export type ScrollSaveParams = {
@ -267,6 +267,7 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
init?: { init?: {
query?: Record<string, any>, query?: Record<string, any>,
onProgress?: (process: number, data: ExtractField<JobResponse<JOB>, "data" | "started" | "progress" | "completed" | "ended", 'data'>) => void, onProgress?: (process: number, data: ExtractField<JobResponse<JOB>, "data" | "started" | "progress" | "completed" | "ended", 'data'>) => void,
onWaiting?: () => void,
onEnded?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void; onEnded?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
onCompleted?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void; onCompleted?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
onError?: (error: string) => void; onError?: (error: string) => void;
@ -306,6 +307,11 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
setData(undefined); setData(undefined);
init?.onCompleted?.(data.data as any); init?.onCompleted?.(data.data as any);
break; break;
case 'waiting':
setState(undefined);
setData(undefined);
init?.onWaiting?.();
break;
default: default:
setData(data.data as DataPayload); setData(data.data as DataPayload);
setState(data.state); setState(data.state);

View file

@ -127,7 +127,7 @@ declare interface CommandEntry
emulatorSource?: EmulatorSourceType; emulatorSource?: EmulatorSourceType;
/** Metadata for the command */ /** Metadata for the command */
metadata: { metadata: {
romPath: string; romPath?: string;
emulatorBin?: string; emulatorBin?: string;
/** The root directory of the emulator */ /** The root directory of the emulator */
emulatorDir?: string; emulatorDir?: string;

View file

@ -1,4 +1,4 @@
import { expect, test, describe, beforeEach, afterAll, beforeAll, jest } from 'bun:test'; import { expect, test, describe, afterAll, beforeAll, jest } from 'bun:test';
import { client } from './client'; import { client } from './client';
import * as app from '@/bun/api/app'; import * as app from '@/bun/api/app';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';

View file

@ -1,8 +1,7 @@
import { afterAll, beforeAll, beforeEach, afterEach } from 'bun:test'; import { beforeAll, beforeEach, afterEach } from 'bun:test';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import * as app from '@/bun/api/app'; import * as app from '@/bun/api/app';
import { remove } from 'fs-extra'; import { remove } from 'fs-extra';
import { spawnSync } from "child_process";
export async function LoadApp () export async function LoadApp ()
{ {