From 09b8b9c6f850cea3b897308925faf9be02cefa1a Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sat, 4 Apr 2026 03:13:09 +0300 Subject: [PATCH] feat: Implemented emulator launching Fixes #1 --- src/bun/api/games/games.ts | 2 +- .../api/games/services/launchGameService.ts | 10 +- src/bun/api/games/services/statusService.ts | 27 +++- src/bun/api/hooks/emulators.ts | 37 ++++- src/bun/api/hooks/games.ts | 43 +++++- src/bun/api/jobs/jobs.ts | 6 +- src/bun/api/jobs/launch-game-job.ts | 73 +++++---- .../dolphin.ts | 49 +++--- .../pcsx2.ts | 113 +++++++------- .../ppsspp.ts | 139 +++++++++--------- src/bun/api/store/store.ts | 3 +- src/bun/types/typesc.schema.ts | 4 +- src/mainview/routes/game/$source.$id.tsx | 3 +- src/mainview/routes/launcher.$source.$id.tsx | 42 +++--- src/mainview/routes/settings/route.tsx | 2 +- .../routes/store/details.emulator.$id.tsx | 14 +- src/mainview/scripts/utils.ts | 8 +- src/shared/types..d.ts | 2 +- src/tests/downloads.test.ts | 2 +- src/tests/preload.ts | 3 +- 20 files changed, 351 insertions(+), 231 deletions(-) diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 73b9e1f..7c91954 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -389,7 +389,7 @@ export default new Elysia() if (validCommand) { // 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 }; } else { diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index a91e6ca..362ff41 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -5,22 +5,20 @@ import { existsSync, readFileSync } from 'node:fs'; import * as schema from '@schema/emulators'; import { eq } from 'drizzle-orm'; import { config, customEmulators, emulatorsDb, taskQueue } from '../../app'; -import os, { platform } from 'node:os'; +import os from 'node:os'; import { cores } from '../../emulatorjs/emulatorjs'; import { LaunchGameJob } from '../../jobs/launch-game-job'; -import { EmulatorPackageType } from '@/shared/constants'; -import { getStoreEmulatorPackage, getStoreFolder } from '../../store/services/gamesService'; -import { getOrCached } from '../../cache'; +import { getStoreEmulatorPackage } from '../../store/services/gamesService'; import { getOrCachedScoopPackage } from '../../store/services/emulatorsService'; export const varRegex = /%([^%]+)%/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)) { - throw new Error(`${id} currently running`); + throw new Error(`Game currently running`); } taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId)); diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index af9e62a..4f1d99b 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -1,6 +1,6 @@ import { RPC_URL, } from "@shared/constants"; 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 { and, eq } from "drizzle-orm"; import { getErrorMessage, hashFile } from "@/bun/utils"; @@ -26,7 +26,7 @@ class CommandSearchError extends Error export async function getLocalGame (source: string, id: string) { 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), with: { platform: { columns: { slug: true } } @@ -36,8 +36,27 @@ export async function getLocalGame (source: string, id: string) 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); if (localGame) { @@ -70,7 +89,7 @@ export async function getValidLaunchCommandsForGame (source: string, id: string) const validCommand = commands.find(c => c.valid); 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 { diff --git a/src/bun/api/hooks/emulators.ts b/src/bun/api/hooks/emulators.ts index ed2d742..b968197 100644 --- a/src/bun/api/hooks/emulators.ts +++ b/src/bun/api/hooks/emulators.ts @@ -1,5 +1,15 @@ import { EmulatorDownloadInfoType, EmulatorPackageType } from "@/shared/constants"; import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; +import { any } from "zod"; + +interface EmulatorPostInstallContext +{ + emulator: string; + emulatorPackage?: EmulatorPackageType; + path: string; + update: boolean; + info: EmulatorDownloadInfoType; +} export class EmulatorHooks { @@ -12,11 +22,24 @@ export class EmulatorHooks /** * Triggered when emulator is downloaded or updated */ - emulatorPostInstall = new AsyncSeriesHook<[ctx: { - emulator: string; - emulatorPackage?: EmulatorPackageType; - path: string; - update: boolean; - info: EmulatorDownloadInfoType; - }]>(['ctx']); + emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContext], { emulator: string; }>(['ctx']); + + constructor() + { + this.emulatorPostInstall.intercept({ + register (tap) + { + return { + ...tap, + fn: async (ctx: EmulatorPostInstallContext, ...rest: any[]) => + { + if (ctx.emulator === tap.emulator) + { + tap.fn(ctx, ...rest); + } + } + }; + }, + }); + } } \ No newline at end of file diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index ea50476..dd893d1 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -15,10 +15,11 @@ export class GameHooks autoValidCommand: CommandEntry; dryRun: boolean, game: { - source: string; - id: number; + source?: string; + sourceId?: string; + id: FrontEndId; }; - }], string[] | undefined>(['ctx']); + }], string[] | undefined, { emulator: string; }>(['ctx']); /** * 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. @@ -27,7 +28,7 @@ export class GameHooks emulatorLaunchSupport = new SyncBailHook<[ctx: { emulator: string; source?: EmulatorSourceEntryType; - }], EmulatorSupport | undefined>(['ctx']); + }], EmulatorSupport | undefined, { emulator: string; }>(['ctx']); /** * Fetches and returns a list of games converted to frontend. * @param ctx.localGameIds This is local game ids in the format '@' @@ -71,4 +72,38 @@ export class GameHooks updatePlayed = new AsyncSeriesWaterfallHook<[ctx: { source: string, id: string; }], boolean>(["ctx"]); fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['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); + } + } + }; + }, + }); + } } \ No newline at end of file diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index 8f836a5..7bc92c7 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -32,6 +32,7 @@ function registerJob< 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() }) ]), open (ws) @@ -41,6 +42,9 @@ function registerJob< if (job) { ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() }); + } else + { + ws.send({ type: 'waiting' }); } (ws.data as any).cleanup = [ @@ -97,10 +101,10 @@ function registerJob< } export const jobs = new Elysia({ prefix: '/api/jobs' }) + .use(registerJob(LaunchGameJob)) .use(registerJob(LoginJob)) .use(registerJob(TwitchLoginJob)) .use(registerJob(UpdateStoreJob)) - .use(registerJob(LaunchGameJob)) .use(registerJob(BiosDownloadJob)) .use(registerJob(InstallJob)) .use(registerJob(EmulatorDownloadJob)); diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 91004bb..18b4fd1 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -4,40 +4,51 @@ import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema"; import { db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; import { eq, sql } from "drizzle-orm"; -import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; +import { spawn } from 'node:child_process'; export class LaunchGameJob implements IJob, "playing"> { static id = "launch-game" as const; - static dataSchema = z.optional(ActiveGameSchema); + static dataSchema = z.nullable(ActiveGameSchema); group = "launch-game"; - activeGame?: ActiveGameType; - gameId: number; + activeGame: ActiveGameType | null; + gameId: FrontEndId; validCommand: CommandEntry; - gameSource: string; - gameSourceId: string; + gameSource?: 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.validCommand = validCommand; this.gameSource = source; this.gameSourceId = sourceId; + this.activeGame = null; } async start (context: JobContext, "playing">, z.infer, "playing">) { - const localGame = await db.query.games.findFirst({ - where: eq(appSchema.games.id, this.gameId), columns: { - name: true, - source_id: true, - source: true - } - }); + 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({ + where: eq(appSchema.games.id, Number(this.gameId.id)), columns: { + name: true, + source_id: 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({ 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) => @@ -70,10 +81,15 @@ export class LaunchGameJob implements IJob + context.abortSignal.addEventListener('abort', reject); + + bunGame.exited.then(e => + { + resolve(true); + }).catch(e => { console.error(e); reject(e); @@ -87,28 +103,27 @@ export class LaunchGameJob implements IJob + 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)); - await plugins.hooks.games.updatePlayed.promise({ source, id }).then(v => + if (this.gameId.source === 'local') + { + 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 (this.gameSource !== 'local') - { - updatePlayed(this.gameSource, this.gameSourceId); - } - else if (localGame?.source && localGame?.source !== 'local' && localGame.source_id) - { - updatePlayed(localGame.source, localGame.source_id); - } + updatePlayed(this.gameId, this.gameSource, this.gameSourceId); }); /* Old spawn lanching, cases issues, needs to be ran as shell diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index af12a7d..59cc505 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -6,37 +6,48 @@ import desc from './package.json'; export default class DOLPHINIntegration implements PluginType { + emulator = 'DOLPHIN'; + + 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"), ""); }); - 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 storageFolder = path.join(config.get('downloadPath'), "storage", 'DOLPHIN'); + args.push(`--user=${storageFolder}`); + + 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.ISOPath1=${path.join(config.get('downloadPath'), 'roms', 'wii')}`); + args.push(`--config=Dolphin.Interface.ConfirmStop=False`); + args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`); + args.push(`--config=Dolphin.Analytics.PermissionAsked=True`); + + 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) { - const args = ["--batch"]; - - const storageFolder = path.join(config.get('downloadPath'), "saves", 'DOLPHIN'); - - args.push(...[`--user=${storageFolder}`, `--exec=${ctx.autoValidCommand.metadata.romPath}`]); - 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.ISOPath1=${path.join(config.get('downloadPath'), 'roms', 'wii')}`); - args.push(`--config=Dolphin.Interface.ConfirmStop=False`); - args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`); - args.push(`--config=Dolphin.Analytics.PermissionAsked=True`); - - return args; + args.push("--batch"); + args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`); } + + return args; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index c2de7e3..2e944d2 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -9,72 +9,73 @@ import desc from './package.json'; export default class PCSX2Integration implements PluginType { + emulator = "PCSX2"; + 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"]; - if (ctx.source?.type === 'store') - { - return { - id: desc.name, - supportLevel: "full", - capabilities: [...baseCapabilities, "resolution", "config"] - }; - } - else - { - return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; - } + if (ctx.source?.type === 'store') + { + return { + id: desc.name, + supportLevel: "full", + capabilities: [...baseCapabilities, "resolution", "config"] + }; + } + else + { + 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"]; - if (config.get('launchInFullscreen')) - { - args.push("-fullscreen"); - } - args.push(...["-bigpicture", "-portable", "--", ctx.autoValidCommand.metadata.romPath]); - - if (ctx.autoValidCommand.emulatorSource === 'store' && !ctx.dryRun) - { - const configFileContents = await Bun.file(configFile).text(); - - const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2'); - const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); - const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); - - const view = { - BIOS_PATH: biosFolder, - SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'), - SAVE_STATES_PATH: path.join(savesFolder, 'states'), - MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'), - CACHE_PATH: path.join(storageFolder, 'cache'), - COVERS_PATH: path.join(storageFolder, 'covers'), - TEXTURES_PATH: path.join(storageFolder, 'textures'), - RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), - }; - - await Promise.all(Object.values(view).map(p => ensureDir(p))); - - let pscx2Path = ''; - if (process.platform === 'win32') - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); - else - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis'); - - await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); - } - - return args; + args.push(ctx.autoValidCommand.metadata.romPath); + args.push("-batch"); } + if (config.get('launchInFullscreen')) + { + args.push("-fullscreen"); + } + args.push(...["-bigpicture", "-portable", "--"]); + + if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun) + { + const configFileContents = await Bun.file(configFile).text(); + + const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2'); + const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); + const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); + + const view = { + BIOS_PATH: biosFolder, + SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'), + SAVE_STATES_PATH: path.join(savesFolder, 'states'), + MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'), + CACHE_PATH: path.join(storageFolder, 'cache'), + COVERS_PATH: path.join(storageFolder, 'covers'), + TEXTURES_PATH: path.join(storageFolder, 'textures'), + RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), + }; + + await Promise.all(Object.values(view).map(p => ensureDir(p))); + + let pscx2Path = ''; + if (process.platform === 'win32') + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); + else + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis'); + + await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); + } + + return args; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index fddf25c..b0d0a44 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -12,85 +12,86 @@ import { homedir } from "node:os"; export default class PCSX2Integration implements PluginType { + emulator = "PPSSPP"; + 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"]; + + if (ctx.source?.type === 'store') { - const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; - - if (ctx.source?.type === 'store') - { - return { - id: desc.name, - supportLevel: "full", - capabilities: [...baseCapabilities, "resolution", "config"] - }; - } - else - { - return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; - } - + return { + id: desc.name, + supportLevel: "full", + capabilities: [...baseCapabilities, "resolution", "config"] + }; + } + else + { + 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"]; - if (config.get('launchInFullscreen')) - { - args.push("--fullscreen"); - } - - if (ctx.autoValidCommand.emulatorSource === 'store' && !ctx.dryRun) - { - let confPath: string | undefined = undefined; - let controlsPath: string | undefined = undefined; - - switch (process.platform) - { - case "win32": - confPath = configFilePathWin32; - controlsPath = configControlsFilePathWin32; - break; - case 'linux': - confPath = configFilePathLinux; - controlsPath = configControlsFilePathLinux; - break; - } - - let ppssppPath = ''; - if (process.platform === 'win32') - { - ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'); - } else - { - //TODO: Use way to set custom memstick path when they support it - ensureDir(path.join(homedir(), '.config', 'ppsspp')); - ppssppPath = path.join(homedir(), '.config', 'ppsspp', 'PSP', 'SYSTEM'); - } - - ensureDir(ppssppPath); - - if (confPath) - { - const configFileContents = await Bun.file(confPath).text(); - await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {})); - } - - if (controlsPath) - { - const controlsFileContents = await Bun.file(controlsPath).text(); - await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); - } - } - - return args; + args.push(ctx.autoValidCommand.metadata.romPath); } + + args.push("--escape-exit", "--pause-menu-exit"); + if (config.get('launchInFullscreen')) + { + args.push("--fullscreen"); + } + + if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun) + { + let confPath: string | undefined = undefined; + let controlsPath: string | undefined = undefined; + + switch (process.platform) + { + case "win32": + confPath = configFilePathWin32; + controlsPath = configControlsFilePathWin32; + break; + case 'linux': + confPath = configFilePathLinux; + controlsPath = configControlsFilePathLinux; + break; + } + + let ppssppPath = ''; + if (process.platform === 'win32') + { + ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'); + } else + { + //TODO: Use way to set custom memstick path when they support it + ensureDir(path.join(homedir(), '.config', 'ppsspp')); + ppssppPath = path.join(homedir(), '.config', 'ppsspp', 'PSP', 'SYSTEM'); + } + + ensureDir(ppssppPath); + + if (confPath) + { + const configFileContents = await Bun.file(confPath).text(); + await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {})); + } + + if (controlsPath) + { + const controlsFileContents = await Bun.file(controlsPath).text(); + await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); + } + } + + return args; }); } } \ No newline at end of file diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 2736fe2..2a1c42e 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -147,8 +147,7 @@ export const store = new Elysia({ prefix: '/api/store' }) biosRequirement: emulatorPackage.bios, bios: biosFiles, integrations: findEmulatorPluginIntegration(emulatorPackage.name, execPaths), - storeDownloadInfo: storeDownloadInfo, - hasUpdate: storeDownloadInfo?.hasUpdate ?? null + storeDownloadInfo: storeDownloadInfo }; return emulator; diff --git a/src/bun/types/typesc.schema.ts b/src/bun/types/typesc.schema.ts index fba1435..ee1da2b 100644 --- a/src/bun/types/typesc.schema.ts +++ b/src/bun/types/typesc.schema.ts @@ -28,7 +28,9 @@ export type PluginDescriptionType = z.infer; export const ActiveGameSchema = z.object({ 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(), command: z.object({ command: z.string(), startDir: z.string().optional() }) }); diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index ec57e71..511ed9b 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -46,7 +46,8 @@ function Error (data: ErrorComponentProps) { 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(); return diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index 49011d7..8eac18d 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -1,13 +1,10 @@ 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 { useEffect } from 'react'; -import { useQuery } from '@tanstack/react-query'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import Shortcuts from '../components/Shortcuts'; -import { gameQuery } from '@queries/romm'; -import { rommApi } from '../scripts/clientApi'; +import { useJobStatus } from '../scripts/utils'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, @@ -18,34 +15,33 @@ function RouteComponent () const router = useRouter(); function HandleGoBack () { - router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); + if (router.history.canGoBack()) + { + router.history.back(); + } else + { + router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); + } } const { source, id } = Route.useParams(); const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` }); - const { data } = useQuery(gameQuery(source, id)); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); const { shortcuts } = useShortcutContext(); - useEffect(() => - { - if (!data) return; - const sub = rommApi.api.romm.status({ source: data.id.source })({ id: data.id.id }).subscribe(); - - sub.subscribe((e) => + const { data } = useJobStatus('launch-game', { + onEnded (data) { - if (e.data.status !== 'playing') - { - HandleGoBack(); - } - }); - - return () => + HandleGoBack(); + }, + onWaiting () { - sub.close(); - }; - }, [data?.id]); + HandleGoBack(); + }, + }); + + useBlocker({ shouldBlockFn: () => !!data }); return
diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index 5c76442..89168ea 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -116,7 +116,7 @@ function SettingsMenu (data: {}) const { ref, focusKey } = useFocusable({ focusable: true, focusKey: 'settings-menu', - preferredChildFocusKey: location.hash.replaceAll(/#|(\?.+)/g, '') + preferredChildFocusKey: `menu-item-${location.hash.replaceAll(/#|(\?.+)/g, '')}` }); return
    void; }) { + const navigation = useNavigate(); const queryClient = useQueryClient(); const deleteMutation = useMutation({ ...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({ diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index a9666be..a1f325d 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -3,7 +3,7 @@ import { RefObject, useEffect, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; import { jobsApi } from "./clientApi"; 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"; export type ScrollSaveParams = { @@ -267,6 +267,7 @@ export function useJobStatus, onProgress?: (process: number, data: ExtractField, "data" | "started" | "progress" | "completed" | "ended", 'data'>) => void, + onWaiting?: () => void, onEnded?: (data: ExtractField, "completed" | "ended", 'data'>) => void; onCompleted?: (data: ExtractField, "completed" | "ended", 'data'>) => void; onError?: (error: string) => void; @@ -306,6 +307,11 @@ export function useJobStatus