diff --git a/bun.lock b/bun.lock index 1514d12..0b550d6 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "node-disk-info": "^1.3.0", "node-downloader-helper": "^2.1.10", "node-stream-zip": "^1.15.0", + "node-unrar-js": "^2.0.2", "open": "^11.0.0", "pathe": "^2.0.3", "slugify": "^1.6.9", @@ -78,6 +79,7 @@ "howler": "^2.2.4", "lucide-react": "^0.563.0", "pretty-bytes": "^7.1.0", + "pretty-ms": "^9.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-error-boundary": "^6.1.0", @@ -1278,6 +1280,8 @@ "node-stream-zip": ["node-stream-zip@1.15.0", "", {}, "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw=="], + "node-unrar-js": ["node-unrar-js@2.0.2", "", {}, "sha512-hLNmoJzqaKJnod8yiTVGe9hnlNRHotUi0CreSv/8HtfRi/3JnRC8DvsmKfeGGguRjTEulhZK6zXX5PXoVuDZ2w=="], + "normalize-package-data": ["normalize-package-data@3.0.3", "", { "dependencies": { "hosted-git-info": "^4.0.1", "is-core-module": "^2.5.0", "semver": "^7.3.4", "validate-npm-package-license": "^3.0.1" } }, "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -1322,6 +1326,8 @@ "parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], @@ -1376,6 +1382,8 @@ "pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], diff --git a/package.json b/package.json index 6869a4b..3d5f135 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "node-disk-info": "^1.3.0", "node-downloader-helper": "^2.1.10", "node-stream-zip": "^1.15.0", + "node-unrar-js": "^2.0.2", "open": "^11.0.0", "pathe": "^2.0.3", "slugify": "^1.6.9", @@ -118,6 +119,7 @@ "howler": "^2.2.4", "lucide-react": "^0.563.0", "pretty-bytes": "^7.1.0", + "pretty-ms": "^9.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-error-boundary": "^6.1.0", diff --git a/src/bun/api/auth.ts b/src/bun/api/auth.ts index 6cf37eb..a0740bc 100644 --- a/src/bun/api/auth.ts +++ b/src/bun/api/auth.ts @@ -181,7 +181,7 @@ export async function tryLoginAndSave ({ host, username, password }: { host: str body: { password, username, - scope: 'me.read roms.read platforms.read assets.read firmware.read roms.user.read collections.read me.write roms.user.write' + scope: 'me.read roms.read platforms.read assets.read assets.write firmware.read roms.user.read collections.read me.write roms.user.write' }, baseUrl: host }); diff --git a/src/bun/api/clients.ts b/src/bun/api/clients.ts index 7d117d3..470faf8 100644 --- a/src/bun/api/clients.ts +++ b/src/bun/api/clients.ts @@ -5,9 +5,10 @@ import games from "./games/games"; import platforms from "./games/platforms"; import auth from "./auth"; import collections from "./games/collections"; +import emulatorjs from "./emulatorjs/emulatorjs"; export default new Elysia({ prefix: "/api/romm" }) - .use([games, platforms, collections, auth]) + .use([games, platforms, collections, auth, emulatorjs]) .all("/*", async ({ request, set }) => { set.headers["cross-origin-resource-policy"] = 'cross-origin'; diff --git a/src/bun/api/controls/controls.ts b/src/bun/api/controls/controls.ts index 4aa417a..cc3c455 100644 --- a/src/bun/api/controls/controls.ts +++ b/src/bun/api/controls/controls.ts @@ -14,8 +14,8 @@ export default async function Initialize () const launchGameTask = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob); if (launchGameTask) { - launchGameTask.abort('exit'); taskQueue.waitForJob(LaunchGameJob.id).then(() => setTimeout(() => events.emit('focus'), 300)); + launchGameTask.abort('exit'); } else { events.emit('focus'); diff --git a/src/bun/api/emulatorjs/emulatorjs.ts b/src/bun/api/emulatorjs/emulatorjs.ts index c8018de..247ce6a 100644 --- a/src/bun/api/emulatorjs/emulatorjs.ts +++ b/src/bun/api/emulatorjs/emulatorjs.ts @@ -1,4 +1,11 @@ // ES-DE to emulator JS mapping + +import Elysia, { status } from "elysia"; +import z from "zod"; +import path from 'node:path'; +import { config, events, plugins } from "../app"; +import { getLocalGame, updateLocalLastPlayed } from "../games/services/statusService"; + // TODO: use the retroarch cores based on ES-DE export const cores: Record = { "atari5200": "atari5200", @@ -43,4 +50,57 @@ export const cores: Record = { "plus4": "plus4", "vic20": "vic20", "dos": "dos" -}; \ No newline at end of file +}; + +export default new Elysia({ prefix: '/emulatorjs' }) + .put('/save', async ({ body: { save, screenshot } }) => + { + await Bun.write(path.join(config.get('downloadPath'), 'saves', "EMULATORJS", save.name), save); + }, { + body: z.object({ + save: z.file(), + screenshot: z.file().optional() + }) + }).get('/load', async ({ query: { filePath } }) => + { + return Bun.file(path.join(config.get('downloadPath'), 'saves', "EMULATORJS", filePath)); + }, { query: z.object({ filePath: z.string() }) }) + .post('/post_play/:source/:id', async ({ params: { source, id }, body: { save } }) => + { + const localGame = await getLocalGame(source, id); + if (!localGame) return status("Not Found"); + + const changedSaveFiles: SaveFileChange[] = []; + if (save) + { + const savesPath = path.join(config.get('downloadPath'), 'saves', "EMULATORJS"); + const saveFile = path.join(savesPath, save.name); + await Bun.write(saveFile, save); + changedSaveFiles.push({ subPath: save.name, cwd: savesPath }); + events.emit('notification', { message: "Save Backed Up", type: "success", icon: "save" }); + } + await updateLocalLastPlayed(localGame.id); + await plugins.hooks.games.postPlay.promise({ + source, + id, + saveFolderPath: path.join(config.get('downloadPath'), "saves", "EMULATORJS"), + gameInfo: { platformSlug: localGame?.platform.slug }, + changedSaveFiles: changedSaveFiles, + validChangedSaveFiles: changedSaveFiles, + command: { + id: "EMULATORJS", + command: "", + emulator: "EMULATORJS", + valid: true, + metadata: { + romPath: localGame?.path_fs ?? undefined, + emulatorBin: undefined, + emulatorDir: undefined + } + } + }); + }, { + body: z.object({ + save: z.file().optional() + }) + }); \ No newline at end of file diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 79aaa10..97f0d8a 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -13,6 +13,7 @@ import Elysia from "elysia"; import z from "zod"; import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { LaunchGameJob } from "../../jobs/launch-game-job"; +import * as appSchema from "@schema/app"; class CommandSearchError extends Error { @@ -26,7 +27,14 @@ 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, source: true, source_id: true }, + columns: { + id: true, + path_fs: true, + source: true, + source_id: true, + igdb_id: true, + ra_id: true + }, where: getLocalGameMatch(id, source), with: { platform: { columns: { slug: true } } @@ -36,6 +44,33 @@ export async function getLocalGame (source: string, id: string) return localGame; } +export async function validateGameSource (source: string, id: string): Promise<{ valid: boolean, reason?: string; }> +{ + const localGame = await getLocalGame(source, id); + if (!localGame) throw new Error("Could not find local game"); + if (localGame.source && localGame.source_id) + { + const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id }); + if (!sourceGame) return { valid: false, reason: "Source Missing" }; + if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined)) + { + return { valid: false, reason: "IGDB Miss Match" }; + } + + if (sourceGame.ra_id !== (localGame.ra_id ?? undefined)) + { + return { valid: false, reason: "RA Miss Match" }; + } + } + + return { valid: true }; +} + +export async function updateLocalLastPlayed (id: number) +{ + await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(id))); +} + export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined> { if (source === 'emulator') diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index d276463..38016aa 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -18,8 +18,9 @@ export class GameHooks source?: string; sourceId?: string; id: FrontEndId; + platformSlug?: string; }; - }], string[] | undefined, { emulator: string; }>(['ctx']); + }], { args: string[], savesPath?: 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. @@ -69,7 +70,27 @@ export class GameHooks fetchPlatforms = new AsyncSeriesHook<[ctx: { platforms: FrontEndPlatformType[]; }]>(['ctx']); - updatePlayed = new AsyncSeriesWaterfallHook<[ctx: { source: string, id: string; }], boolean>(["ctx"]); + prePlay = new AsyncSeriesHook<[ctx: { + source: string, + id: string; + saveFolderPath?: string; + setProgress: (progress: number, state: string) => void, + command: CommandEntry; + gameInfo: { + platformSlug?: string; + }; + }]>(["ctx"]); + postPlay = new AsyncSeriesHook<[ctx: { + source: string, + id: string; + saveFolderPath?: string; + changedSaveFiles: SaveFileChange[], + validChangedSaveFiles: SaveFileChange[], + command: CommandEntry; + gameInfo: { + platformSlug?: string; + }; + }]>(["ctx"]); fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']); fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']); diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 18407ea..0564111 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -3,7 +3,7 @@ import { and, eq, or } from 'drizzle-orm'; import fs from 'node:fs/promises'; import * as schema from "@schema/app"; import * as emulatorSchema from "@schema/emulators"; -import path from 'node:path'; +import path, { join } from 'node:path'; import { config, db, emulatorsDb, events, plugins } from "../app"; import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService"; import * as igdb from 'ts-igdb-client'; @@ -13,9 +13,12 @@ import { Downloader } from "@/bun/utils/downloader"; import Seven from 'node-7z'; import z from "zod"; import { checkFiles } from "../games/services/utils"; -import { ensureDir } from "fs-extra"; +import { ensureDir, existsSync } from "fs-extra"; import { path7za } from "7zip-bin"; import slugify from 'slugify'; +import StreamZip from 'node-stream-zip'; +import { createExtractorFromFile } from 'node-unrar-js'; +import { which } from "bun"; interface JobConfig { @@ -116,23 +119,62 @@ export class InstallJob implements IJob for (const filePath of downloadedFiles) { const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path); - await new Promise((resolve, reject) => + await new Promise(async (resolve, reject) => { - const seven = Seven.extractFull(filePath, extractPath, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true }); + let sevenZipPath = process.env.ZIP7_PATH ?? path7za; + + if (filePath.endsWith('.rar')) + { + let newPath: string | undefined; + if (process.platform === 'win32' && await fs.exists("C:\\Program Files\\7-Zip\\7z.exe")) + { + newPath = "C:\\Program Files\\7-Zip\\7z.exe"; + } else + { + newPath = which('7z') ?? undefined; + } + + if (!newPath) + { + await fs.rm(filePath); + reject(new Error("No RAR Support")); + return; + } + + sevenZipPath = newPath; + } + + let rejected = false; + const seven = Seven.extractFull(filePath, extractPath, { $bin: sevenZipPath, $progress: true }); seven.on('progress', p => { cx.setProgress(progress + p.percent * progressDelta, "extract"); }); - seven.on('error', e => { reject(e); + rejected = true; }); seven.on('end', async () => { + if (rejected) return; await fs.rm(filePath); resolve(true); }); + }).catch(async e => + { + if (filePath.endsWith('.zip')) + { + console.warn("Could not extract", filePath, "with 7zip trying zip extractor"); + await ensureDir(extractPath); + const zip = new StreamZip.async({ file: filePath }); + const count = await zip.extract(null, extractPath); + console.log(`Extracted ${count} entries`); + await zip.close(); + } else + { + throw e; + } }); progress += progressDelta * 100; } diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 183e985..b60cb76 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -5,8 +5,11 @@ import { db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; import { eq } from "drizzle-orm"; import { spawn } from 'node:child_process'; +import { watch } from "node:fs"; +import fs from "node:fs/promises"; +import { updateLocalLastPlayed } from "../games/services/statusService"; -export class LaunchGameJob implements IJob, "playing"> +export class LaunchGameJob implements IJob, string> { static id = "launch-game" as const; static dataSchema = z.nullable(ActiveGameSchema); @@ -16,6 +19,8 @@ export class LaunchGameJob implements IJob; + saveFolderPath?: string; constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string) { @@ -24,11 +29,46 @@ export class LaunchGameJob implements IJob, "playing">, z.infer, "playing">) + async postPlay (gameInfo: { platformSlug?: string; }) { - let gameInfo: { name?: string, source_id?: string, source?: string; }; + if (this.gameId.source === 'local') + { + await updateLocalLastPlayed(Number(this.gameId.id)); + } + + const source = this.gameSource ?? this.gameId.source; + const id = this.gameSourceId ?? this.gameId.id; + + await plugins.hooks.games.postPlay.promise( + { + source, + id, + command: this.validCommand, + saveFolderPath: this.saveFolderPath, + changedSaveFiles: Array.from(this.changedSaveFiles.values()), + validChangedSaveFiles: [], + gameInfo + }).catch(e => console.error(e)); + } + + prePlay (setProgress: (progress: number, state: string) => void, gameInfo: { platformSlug?: string; }) + { + return plugins.hooks.games.prePlay.promise({ + source: this.gameSource ?? this.gameId.source, + id: this.gameSourceId ?? this.gameId.id, + saveFolderPath: this.saveFolderPath, + command: this.validCommand, + setProgress: setProgress, + gameInfo + }); + } + + async start (context: JobContext, string>, z.infer, string>) + { + let gameInfo: { name?: string, source_id?: string, source?: string; platformSlug?: string; } | undefined = undefined; if (this.gameId.source === 'emulator') { gameInfo = { name: this.gameId.id }; @@ -38,125 +78,140 @@ export class LaunchGameJob implements IJob + await new Promise(async (resolve, reject) => { - let game: any; - if (!commandArgs) + try { - // ES-DE commands require shell execution. Some emulators fail otherwise. - const spawnGame = spawn(this.validCommand.command, { - shell: true, - cwd: this.validCommand.startDir, - signal: context.abortSignal, - env: { + let game: any; + if (!commandArgs) + { + await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }).catch(e => reject(e)); + + // ES-DE commands require shell execution. Some emulators fail otherwise. + const spawnGame = spawn(this.validCommand.command, { + shell: true, + cwd: this.validCommand.startDir, + signal: context.abortSignal, + env: { + } + }); + + context.setProgress(0, "playing"); + + spawnGame.stdout.on('data', data => console.log(data)); + spawnGame.on('close', (code) => + { + resolve(code); + }); + spawnGame.on('error', e => + { + console.error(e); + reject(e); + }); + + game = spawnGame; + } + else if (this.validCommand.metadata.emulatorBin) + { + this.saveFolderPath = commandArgs.savesPath; + + await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }); + + // We have full control over launching integrated emulators better to use bun spawn + const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs.args], { + cwd: this.validCommand.startDir, + signal: context.abortSignal, + env: { + } + }); + + context.setProgress(0, "playing"); + + if (commandArgs.savesPath && await fs.exists(commandArgs.savesPath)) + { + const savesWatcher = watch(commandArgs.savesPath, { recursive: true, signal: context.abortSignal }); + console.log("Starting To Watch", commandArgs.savesPath, "for save file changes"); + savesWatcher.on('change', (type, filename) => + { + if (typeof filename === 'string') + { + console.log("Save File Changed", filename); + this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath! }); + } + }); + + bunGame.exited.then(() => + { + savesWatcher.close(); + console.log("Closing Save File Watching for", commandArgs.savesPath); + }); } - }); - spawnGame.stdout.on('data', data => console.log(data)); - spawnGame.on('close', (code) => + bunGame.exited.then(e => + { + resolve(true); + }).catch(e => + { + console.error(e); + reject(e); + }); + + game = bunGame; + + } else { - resolve(code); - }); - spawnGame.on('error', e => - { - console.error(e); - reject(e); - }); - - game = spawnGame; - } - else if (this.validCommand.metadata.emulatorBin) - { - // We have full control over launching integrated emulators better to use bun spawn - const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs], { - cwd: this.validCommand.startDir, - signal: context.abortSignal, - env: { - } - }); - - context.abortSignal.addEventListener('abort', reject); - - bunGame.exited.then(e => - { - resolve(true); - }).catch(e => - { - console.error(e); - reject(e); - }); - game = bunGame; - } else - { - reject(new Error("No Emulator Bin")); - return; - } - - this.activeGame = { - process: game, - name: gameInfo?.name ?? "Unknown", - gameId: this.gameId, - source: this.gameSource, - sourceId: this.gameSourceId, - command: this.validCommand - }; - - const updatePlayed = async (id: FrontEndId, source?: string, sourceId?: string) => - { - if (this.gameId.source === 'local') - { - await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(this.gameId.id))); + reject(new Error("No Emulator Bin")); + return; } - 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' }); - }); - }; - - updatePlayed(this.gameId, this.gameSource, this.gameSourceId); + this.activeGame = { + process: game, + name: gameInfo?.name ?? "Unknown", + gameId: this.gameId, + source: this.gameSource, + sourceId: this.gameSourceId, + command: this.validCommand + }; + } catch (e) + { + context.abort(e); + reject(e); + } }); - /* Old spawn lanching, cases issues, needs to be ran as shell - - const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]); - const game = setActiveGame({ - process: Bun.spawn({ - cmd, - env: { - ...process.env - }, - onExit (subprocess, exitCode, signalCode, error) - { - events.emit('activegameexit', { subprocess, exitCode, signalCode, error }); - }, - stdin: "ignore", - stdout: "inherit", - stderr: "inherit", - }), - name: localGame?.name ?? "Unknown", - gameId: validCommand.gameId, - command: validCommand.command.command - }); - - await game.process.exited; - if (game.process.exitCode && game.process.exitCode > 0) - { - return status('Internal Server Error'); - }*/ + await this.postPlay({ platformSlug: gameInfo?.platformSlug }); } exposeData () diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts index f42e221..cc3cfc7 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts @@ -11,7 +11,7 @@ export default class CEMUIntegration implements PluginType { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] }; }); ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => @@ -29,7 +29,7 @@ export default class CEMUIntegration implements PluginType args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`); } - return args; + return { args, savesPath: savesPath }; }); } } \ No newline at end of file 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 038cdfb..aa993a3 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 @@ -3,17 +3,18 @@ import { config } from "@/bun/api/app"; import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import path from 'node:path'; import desc from './package.json'; +import { ensureDir } from "fs-extra"; +import { getSavePaths, getType } from "./utils"; export default class DOLPHINIntegration implements PluginType { emulator = 'DOLPHIN'; - load (ctx: PluginContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "resolution", "fullscreen", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "resolution", "fullscreen", "saves"] }; }); ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => @@ -51,14 +52,33 @@ export default class DOLPHINIntegration implements PluginType 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')}`); + args.push(`--config=Dolphin.Core.GCIFolderAPath=${path.join(savesPath, 'GC')}`); + if (!ctx.dryRun) + { + await ensureDir(path.join(savesPath, 'GC', "JAP")); + await ensureDir(path.join(savesPath, 'GC', "EUR")); + await ensureDir(path.join(savesPath, 'GC', "USA")); + } + + let finalSavesPath: string | undefined = undefined; if (ctx.autoValidCommand.metadata.romPath) { args.push("--batch"); args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`); + + finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder; } - return args; + return { args, savesPath: finalSavesPath }; + }); + + ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) => + { + if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath) + { + validChangedSaveFiles.push(...await getSavePaths(command.metadata.romPath, saveFolderPath, command.metadata.emulatorDir)); + } }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts new file mode 100644 index 0000000..2514790 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts @@ -0,0 +1,164 @@ +import { join } from "path"; +import { platform } from "os"; +import fs from "node:fs/promises"; +import path from "node:path"; + +type DolphinLocation = + | { type: "path"; toolPath: string; } + | { type: "appimage"; appImagePath: string; }; + +async function findDolphinTool (bundledDir?: string): Promise +{ + const os = platform(); + const toolName = os === "win32" ? "DolphinTool.exe" : "dolphin-tool"; + + if (bundledDir) + { + if (os === "linux") + { + const glob = new Bun.Glob("*.AppImage"); + for await (const file of glob.scan(bundledDir)) + { + return { type: "appimage", appImagePath: join(bundledDir, file) }; + } + throw new Error(`No AppImage found in ${bundledDir}`); + } else + { + return { type: "path", toolPath: join(bundledDir, toolName) }; + } + } + + // Fallback 1: check PATH + const inPath = Bun.which(toolName); + if (inPath) return { type: "path", toolPath: inPath }; + + // Fallback 2: platform default install locations + if (os === "win32") + { + const candidates = [ + "C:/Program Files/Dolphin/DolphinTool.exe", + "C:/Program Files (x86)/Dolphin/DolphinTool.exe", + ]; + for (const candidate of candidates) + { + if (await Bun.file(candidate).exists()) + { + return { type: "path", toolPath: candidate }; + } + } + } else if (os === "darwin") + { + const candidate = "/Applications/Dolphin.app/Contents/MacOS/dolphin-tool"; + if (await Bun.file(candidate).exists()) + { + return { type: "path", toolPath: candidate }; + } + } else if (os === "linux") + { + const home = process.env.HOME ?? ""; + const candidates = [ + join(home, "Applications/Dolphin-x86_64.AppImage"), + join(home, "Applications/Dolphin.AppImage"), + "/opt/Dolphin-x86_64.AppImage", + ]; + for (const candidate of candidates) + { + if (await Bun.file(candidate).exists()) + { + return { type: "appimage", appImagePath: candidate }; + } + } + } + + throw new Error(`Could not find ${toolName}. Install Dolphin or pass its folder path explicitly.`); +} + +async function runDolphinTool (args: string[], location: DolphinLocation): Promise +{ + if (location.type === "path") + { + const proc = Bun.spawnSync([location.toolPath, ...args]); + if (!proc.success) throw new Error(`dolphin-tool failed: ${proc.stderr.toString()}`); + return proc.stdout.toString(); + } else + { + const mount = Bun.spawn([location.appImagePath, "--appimage-mount"], { + stdout: "pipe", + stderr: "pipe", + }); + const mountPoint = (await new Response(mount.stdout).text()).trim(); + try + { + const proc = Bun.spawnSync([`${mountPoint}/usr/bin/dolphin-tool`, ...args]); + if (!proc.success) throw new Error(`dolphin-tool failed: ${proc.stderr.toString()}`); + return proc.stdout.toString(); + } finally + { + mount.kill(); + } + } +} + +async function readGameId (romPath: string, location: DolphinLocation): Promise +{ + const output = await runDolphinTool(["header", "-i", romPath], location); + const match = output.match(/Game ID:\s*(\w{6})/); + if (!match) throw new Error("Could not read game ID"); + return match[1]; +} + +function getRegion (regionCode: string) +{ + switch (regionCode) + { + case "E": return "USA"; + case "P": return "EUR"; + case "J": return "JAP"; + default: return "USA"; + } +} + +async function getGCSavePaths (romPath: string, savesPath: string, location: DolphinLocation) +{ + const gameId = await readGameId(romPath, location); + const region = getRegion(gameId[3]); + + const makerCode = gameId.slice(4, 6); // e.g. "01" or "7D" — already the right format + const gameCode = gameId.slice(0, 4); // e.g. "GZLE" or "GM5E" + const cardPath = join(savesPath, "GC", region); + + const glob = new Bun.Glob(`${makerCode}-${gameCode}-*.gci`); + const saves: SaveFileChange[] = []; + for await (const file of glob.scan(cardPath)) + { + saves.push({ subPath: path.join("GC", region, file), cwd: savesPath, shared: false }); + } + + return saves; +} + +export async function getType (romPath: string, bundledEmulatorDir?: string): Promise<"gamecube" | "wii"> +{ + const location = await findDolphinTool(bundledEmulatorDir); + const gameId = await readGameId(romPath, location); + const isGameCube = gameId[0] === "G" || gameId[0] === "D"; + return isGameCube ? "gamecube" : "wii"; +} + +export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise +{ + const location = await findDolphinTool(bundledEmulatorDir); + const gameId = await readGameId(romPath, location); + const isGameCube = gameId[0] === "G" || gameId[0] === "D"; + + if (isGameCube) + { + return getGCSavePaths(romPath, savesPath, location); + } else + { + const folder = Buffer.from(gameId.slice(0, 4), "ascii").toString("hex").toUpperCase(); + const rootFolder = join(savesPath, "Wii", "title", "00010000", folder); + const files = await fs.readdir(rootFolder, { recursive: true }); + return files.map(f => ({ subPath: path.join("Wii", "title", "00010000", f), cwd: savesPath, shared: false })); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini index 72985fb..e1403c5 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini @@ -21,7 +21,6 @@ CdvdShareWrite = false EnablePatches = true EnableCheats = false EnablePINE = false -EnableWideScreenPatches = {{ENABLE_WIDESCREEN}} EnableNoInterlacingPatches = false EnableRecordingTools = true EnableGameFixes = true @@ -168,7 +167,6 @@ linear_present_mode = 1 deinterlace_mode = 0 OsdScale = 100 Renderer = 14 -upscale_multiplier = {{UPSCALE_MULTIPLIER}} mipmap_hw = -1 accurate_blending_unit = 1 crc_hack_level = -1 @@ -371,18 +369,6 @@ Multitap2_Slot4_Enable = false Multitap2_Slot4_Filename = Mcd-Multitap2-Slot04.ps2 -[Folders] -Bios = {{{BIOS_PATH}}} -Snapshots = {{{SNAPSHOTS_PATH}}} -SaveStates = {{{SAVE_STATES_PATH}}} -MemoryCards = {{{MEMORY_CARDS_PATH}}} -Cache = {{{CACHE_PATH}}} -Covers = {{{COVERS_PATH}}} -Logs = logs -Textures = {{{TEXTURES_PATH}}} -Videos = videos - - [InputSources] Keyboard = true Mouse = true @@ -488,6 +474,3 @@ RDown = SDL-1/+RightY RLeft = SDL-1/-RightX LargeMotor = SDL-1/LargeMotor SmallMotor = SDL-1/SmallMotor - -[GameList] -RecursivePaths = {{{RECURSIVE_PATHS}}} 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 5317395..db405a2 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 @@ -1,11 +1,11 @@ import { config } from "@/bun/api/app"; import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; -import configFile from './PCSX2.ini' with { type: 'file' }; -import Mustache from 'mustache'; +import defaultConfig from './PCSX2.ini' with { type: 'file' }; import path from 'node:path'; import { ensureDir } from "fs-extra"; import desc from './package.json'; +import ini from 'ini'; export default class PCSX2Integration implements PluginType { @@ -15,7 +15,7 @@ export default class PCSX2Integration implements PluginType { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; + const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"]; if (ctx.source?.type === 'store') { @@ -47,7 +47,16 @@ export default class PCSX2Integration implements PluginType if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun) { - const configFileContents = await Bun.file(configFile).text(); + let pscx2Path = ''; + if (process.platform === 'win32') + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); + else + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis'); + + const configPath = path.join(pscx2Path, 'PCSX2.ini'); + const existingConfigFile = Bun.file(configPath); + + const configFile = await existingConfigFile.exists() ? ini.parse(await existingConfigFile.text()) : ini.parse(await Bun.file(defaultConfig).text()); const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator); @@ -67,28 +76,37 @@ export default class PCSX2Integration implements PluginType CACHE_PATH: path.join(storageFolder, 'cache'), COVERS_PATH: path.join(storageFolder, 'covers'), TEXTURES_PATH: path.join(storageFolder, 'textures'), + VIDEOS_PATH: path.join(storageFolder, 'videos'), + LOGS_PATH: path.join(storageFolder, 'logs'), RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), }; await Promise.all(Object.values(paths).map(p => ensureDir(p))); - const view = { - ...paths, - ENABLE_WIDESCREEN: config.get('emulatorWidescreen'), - ASPECT_RATIO: config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2", - UPSCALE_MULTIPLIER: resolutionMapping[config.get('emulatorResolution')] ?? 1 - }; + configFile.EmuCore ??= {}; + configFile.EmuCore.EnableWideScreenPatches = config.get('emulatorWidescreen'); + configFile['EmuCore/GS'] ??= {}; + configFile['EmuCore/GS'].AspectRatio = config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2"; + configFile['EmuCore/GS'].upscale_multiplier = resolutionMapping[config.get('emulatorResolution')] ?? 1; + configFile.Folders ??= {}; + configFile.Folders.Bios = paths.BIOS_PATH; + configFile.Folders.Snapshots = paths.SNAPSHOTS_PATH; + configFile.Folders.SaveStates = paths.SAVE_STATES_PATH; + configFile.Folders.MemoryCards = paths.MEMORY_CARDS_PATH; + configFile.Folders.Cache = paths.CACHE_PATH; + configFile.Folders.Covers = paths.COVERS_PATH; + configFile.Folders.Textures = paths.TEXTURES_PATH; + configFile.Folders.Videos = paths.VIDEOS_PATH; + configFile.Folders.Logs = paths.LOGS_PATH; + configFile.GameList ??= {}; + configFile.GameList.RecursivePaths = paths.RECURSIVE_PATHS; - let pscx2Path = ''; - if (process.platform === 'win32') - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); - else - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis'); + await Bun.write(configPath, ini.stringify(configFile)); - await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); + return { args, savesPath: paths.MEMORY_CARDS_PATH }; } - return args; + return { args }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini index afc914c..c138918 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini @@ -96,7 +96,6 @@ HardwareTransform = True SoftwareSkinning = True TextureFiltering = 1 BufferFiltering = 1 -InternalResolution = {{RESOLUTION}} AndroidHwScale = 1 HighQualityDepth = 1 FrameSkip = 0 @@ -109,7 +108,6 @@ AnisotropyLevel = 4 VertexDecCache = False TextureBackoffCache = False TextureSecondaryCache = False -FullScreen = {{FULLSCREEN}} FullScreenMulti = False SmallDisplayZoomType = 2 SmallDisplayOffsetX = 0.500000 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 1f6572f..b6ff93d 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 @@ -9,6 +9,7 @@ import path from "node:path"; import Mustache from "mustache"; import { ensureDir } from "fs-extra"; import { homedir } from "node:os"; +import ini from 'ini'; export default class PPSSPPIntegration implements PluginType { @@ -27,7 +28,7 @@ export default class PPSSPPIntegration implements PluginType ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; + const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"]; if (ctx.source?.type === 'store') { @@ -59,18 +60,18 @@ export default class PPSSPPIntegration implements PluginType if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun) { - let confPath: string | undefined = undefined; - let controlsPath: string | undefined = undefined; + let defaultConfigPath: string | undefined = undefined; + let defaultControlsPath: string | undefined = undefined; switch (process.platform) { case "win32": - confPath = configFilePathWin32; - controlsPath = configControlsFilePathWin32; + defaultConfigPath = configFilePathWin32; + defaultControlsPath = configControlsFilePathWin32; break; case 'linux': - confPath = configFilePathLinux; - controlsPath = configControlsFilePathLinux; + defaultConfigPath = configFilePathLinux; + defaultControlsPath = configControlsFilePathLinux; break; } @@ -87,29 +88,36 @@ export default class PPSSPPIntegration implements PluginType ensureDir(ppssppPath); - if (confPath) + if (defaultConfigPath) { - const resolutionMapping = { - "720p": "2", - "1080p": "4", - "1440p": "6", - "4k": "8" + const resolutionMapping: Record = { + "720p": 2, + "1080p": 4, + "1440p": 6, + "4k": 8 }; - const configFileContents = await Bun.file(confPath).text(); - await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, { - RESOLUTION: resolutionMapping[config.get('emulatorResolution')] ?? 0, - FULLSCREEN: config.get('launchInFullscreen') ? "True" : "False" - })); + const configPath = path.join(ppssppPath, 'ppsspp.ini'); + const configFile = Bun.file(configPath); + + const ppssppConfig = await configFile.exists() ? ini.parse(await configFile.text()) : ini.parse(await Bun.file(defaultConfigPath).text()); + + ppssppConfig.Graphics ??= {}; + ppssppConfig.Graphics.InternalResolution = resolutionMapping[config.get('emulatorResolution')] ?? 0; + ppssppConfig.Graphics.FullScreen = config.get('launchInFullscreen'); + + await Bun.write(configPath, ini.stringify(ppssppConfig)); } - if (controlsPath) + if (defaultControlsPath) { - const controlsFileContents = await Bun.file(controlsPath).text(); + const controlsFileContents = await Bun.file(defaultControlsPath).text(); await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); } + + return { args, savesPath: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") }; } - return args; + return { args }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini index 21a71c3..f448165 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini @@ -96,7 +96,6 @@ HardwareTransform = True SoftwareSkinning = True TextureFiltering = 1 BufferFiltering = 1 -InternalResolution = {{RESOLUTION}} AndroidHwScale = 1 HighQualityDepth = 1 FrameSkip = 0 @@ -109,7 +108,6 @@ AnisotropyLevel = 4 VertexDecCache = False TextureBackoffCache = False TextureSecondaryCache = False -FullScreen = {{FULLSCREEN}} FullScreenMulti = False SmallDisplayZoomType = 2 SmallDisplayOffsetX = 0.500000 diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts index 49e56f3..010430c 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts @@ -14,7 +14,7 @@ export default class XEMUIntegration implements PluginType { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] }; }); ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => @@ -68,7 +68,7 @@ export default class XEMUIntegration implements PluginType } - return args; + return { args }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/utils.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/utils.ts new file mode 100644 index 0000000..ceef07e --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/utils.ts @@ -0,0 +1,141 @@ +import { join } from "path"; +import { platform } from "os"; + +const SECTOR_SIZE = 0x800; +const MAGIC = "MICROSOFT*XBOX*MEDIA"; + +const PARTITION_OFFSETS: Record = { + XSF: 0x0, + GDF: 0xFD90000, + XGD3: 0x2080000, +}; + +async function readBytes (file: ReturnType, offset: number, length: number): Promise +{ + return Buffer.from(await file.slice(offset, offset + length).arrayBuffer()); +} + +async function parseTitleIdFromXexReader ( + read: (offset: number, length: number) => Promise +): Promise +{ + // Read just the fixed header (magic + flags + offsets + header count) + const header = await read(0, 0x18); + if (header.toString("ascii", 0, 4) !== "XEX2") + { + throw new Error("Not a valid XEX2 file"); + } + + const headerCount = header.readUInt32BE(0x14); + const EXEC_INFO_KEY = 0x40006; + + // Read the optional header table + const table = await read(0x18, headerCount * 8); + + for (let i = 0; i < headerCount; i++) + { + const key = table.readUInt32BE(i * 8); + const valueOrOffset = table.readUInt32BE(i * 8 + 4); + + if (key === EXEC_INFO_KEY) + { + // valueOrOffset is a file offset — read the exec info struct there + // TitleID is at +0x0C within it + const execInfo = await read(valueOrOffset, 0x18); + return execInfo.readUInt32BE(0x0C) + .toString(16).toUpperCase().padStart(8, "0"); + } + } + + throw new Error("Execution info header not found in XEX"); +} + +async function titleIdFromXexFile (xexPath: string): Promise +{ + const file = Bun.file(xexPath); + return parseTitleIdFromXexReader((offset, length) => + readBytes(file, offset, length) + ); +} + +async function titleIdFromIso (isoPath: string): Promise +{ + const file = Bun.file(isoPath); + const fileSize = file.size; + + for (const partitionOffset of Object.values(PARTITION_OFFSETS)) + { + const vdOffset = partitionOffset + 0x20 * SECTOR_SIZE; + if (vdOffset + 28 > fileSize) continue; + + const vd = await readBytes(file, vdOffset, 28); + if (vd.toString("ascii", 0, 20) !== MAGIC) continue; + + const rootSector = vd.readUInt32LE(20); + const rootSize = vd.readUInt32LE(24); + const rootOffset = partitionOffset + rootSector * SECTOR_SIZE; + const dir = await readBytes(file, rootOffset, rootSize); + + let pos = 0; + while (pos < dir.length) + { + if (dir[pos] === 0xFF) break; + if (pos + 14 > dir.length) break; + + const nameLen = dir[pos + 13]; + if (nameLen === 0 || nameLen === 0xFF) break; + if (pos + 14 + nameLen > dir.length) break; + + const name = dir.toString("ascii", pos + 14, pos + 14 + nameLen); + const fileSector = dir.readUInt32LE(pos + 4); + + if (name.toLowerCase() === "default.xex") + { + const xexBase = partitionOffset + fileSector * SECTOR_SIZE; + // Reader that translates relative XEX offsets to absolute ISO offsets + return parseTitleIdFromXexReader((offset, length) => + readBytes(file, xexBase + offset, length) + ); + } + + const entryLen = 14 + nameLen; + pos += (entryLen + 3) & ~3; + } + } + + throw new Error("Not a valid Xbox 360 ISO or default.xex not found"); +} + +async function titleIdFromFolder (folderPath: string): Promise +{ + return titleIdFromXexFile(join(folderPath, "default.xex")); +} + +type XeniaRomType = "iso" | "xex" | "folder"; + +function detectRomType (romPath: string): XeniaRomType +{ + const lower = romPath.toLowerCase(); + if (lower.endsWith(".iso")) return "iso"; + if (lower.endsWith(".xex")) return "xex"; + return "folder"; // extracted game folder containing default.xex +} + +async function getTitleId (romPath: string): Promise +{ + switch (detectRomType(romPath)) + { + case "iso": return titleIdFromIso(romPath); + case "xex": return titleIdFromXexFile(romPath); + case "folder": return titleIdFromFolder(romPath); + } +} + +export async function getXeniaSavePaths ( + romPath: string, + xeniaDir: string +): Promise +{ + const titleId = await getTitleId(romPath); + return join(xeniaDir, titleId); +}; \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts index 7257559..6a021da 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { ensureDir } from "fs-extra"; import toml, { TomlTable } from 'smol-toml'; import fs from 'node:fs/promises'; +import { getXeniaSavePaths } from "./utils"; export default class XENIAIntegration implements PluginType { @@ -17,7 +18,8 @@ export default class XENIAIntegration implements PluginType await Bun.write(path.join(ctx.path, "portable.txt"), ""); } - async handleLaunch (ctx: Parameters['0']) + async handleLaunch (ctx: Parameters['0']): + ReturnType { const args: string[] = []; @@ -28,6 +30,13 @@ export default class XENIAIntegration implements PluginType const configPath = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, `${ctx.autoValidCommand.emulator}.toml`); + args.push(`--config`, configPath); + + if (config.get('launchInFullscreen')) + { + args.push(`--fullscreen`); + } + if (!ctx.dryRun) { await ensureDir(path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!)); @@ -47,28 +56,30 @@ export default class XENIAIntegration implements PluginType configFile.Display.fullscreen = config.get('launchInFullscreen'); configFile.GPU.draw_resolution_scale_x = resolutionMapping[config.get('emulatorResolution')] ?? 1; configFile.GPU.draw_resolution_scale_y = resolutionMapping[config.get('emulatorResolution')] ?? 1; - await ensureDir(path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!)); + const savesPath = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!); + await ensureDir(savesPath); configFile.Storage.content_root = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!); configFile.Storage.storage_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'config'); configFile.Storage.cache_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'cache'); await Bun.write(configPath, toml.stringify(configFile)); + + let finalSavesPath: string | undefined = undefined; + if (ctx.autoValidCommand.metadata.romPath) + { + finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath); + } + + return { args, savesPath: finalSavesPath }; }; - args.push(`--config`, configPath); - - if (config.get('launchInFullscreen')) - { - args.push(`--fullscreen`); - } - - return args; + return { args }; } handleEmulatorLaunchSupport (ctx: Parameters['0']): ReturnType { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves"] }; } load (ctx: PluginContextType) @@ -78,5 +89,14 @@ export default class XENIAIntegration implements PluginType ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, this.handleLaunch); ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulatorEdge }, this.handleLaunch); + + ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) => + { + if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath) + { + const files = await fs.readdir(saveFolderPath, { recursive: true }); + validChangedSaveFiles.push(...files.map(f => ({ subPath: f, cwd: saveFolderPath, shared: false } satisfies SaveFileChange))); + } + }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 2b3621c..46c64db 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -2,8 +2,8 @@ import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; -import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; -import { config } from "@/bun/api/app"; +import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; +import { config, events } from "@/bun/api/app"; import path from 'node:path'; import fs from 'node:fs/promises'; import { hashFile, isSteamDeckGameMode } from "@/bun/utils"; @@ -11,6 +11,7 @@ import { CACHE_KEYS, getOrCached } from "@/bun/api/cache"; import secrets from "@/bun/api/secrets"; import { getAuthToken } from "@/clients/romm/core/auth.gen"; import { client } from "@/clients/romm/client.gen"; +import { validateGameSource } from "@/bun/api/games/services/statusService"; export default class RommIntegration implements PluginType { @@ -75,7 +76,9 @@ export default class RommIntegration implements PluginType missing: rom.missing_from_fs, genres: rom.metadatum.genres, companies: rom.metadatum.companies, - release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined + release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined, + imdb_id: rom.igdb_id ?? undefined, + ra_id: rom.ra_id ?? undefined }; const userData = await getCurrentUserApiUsersMeGet(); @@ -371,12 +374,143 @@ export default class RommIntegration implements PluginType } }); - ctx.hooks.games.updatePlayed.tapPromise(desc.name, async ({ source, id }) => + ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderPath, setProgress }) => { - if (source !== 'romm') return false; + if (source !== 'romm') return; + if (saveFolderPath) + { + setProgress(0, "saves"); + + const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } }); + if (saveFiles.error) + { + console.error(saveFiles.error); + } else + { + for (let i = 0; i < saveFiles.data.slots.length; i++) + { + const slot = saveFiles.data.slots[i]; + const savePath = path.join(saveFolderPath, slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`); + if (await fs.exists(savePath)) + { + const existingSaveSync = await fs.stat(savePath); + const updatedAtTime = new Date(slot.latest.updated_at).getTime(); + + if (existingSaveSync.mtimeMs > updatedAtTime) + { + console.log("Newer save file", savePath, "Server:", new Date(slot.latest.updated_at), "Local:", existingSaveSync.mtime); + // Newer file + continue; + } else if (updatedAtTime === existingSaveSync.mtimeMs) + { + //TODO: do checksum comparison when that works on romm + console.log("Same save file", savePath); + continue; + } + } + + const auth = await this.getAuthToken(); + const headers: Record = {}; + if (auth) + headers['Authorization'] = auth; + + const saveResponse = await fetch(`${config.get('rommAddress')}${slot.latest.download_path}`, { headers }); + if (!saveResponse.ok) + { + console.error("Error downloading save", saveResponse.statusText); + break; + } + await Bun.write(savePath, saveResponse); + console.log("Loaded", savePath); + setProgress((i / saveFiles.data.slots.length) * 100, "saves"); + } + } + + setProgress(1, "saves"); + await Bun.sleep(1000); + } + }); + + ctx.hooks.games.postPlay.tapPromise(desc.name, async ({ source, id, validChangedSaveFiles, saveFolderPath, command }) => + { + if (source !== 'romm') return; + + const sourceValidation = await validateGameSource(source, id); + if (!sourceValidation.valid) + { + console.warn("Invalid Source", sourceValidation.reason, "Skipping updates"); + return; + } + + const finalSavePaths = validChangedSaveFiles.filter(f => !f.shared); + + const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } }); + if (saveFiles.error) + { + console.error(saveFiles.error); + } else if (saveFolderPath) + { + for (let i = 0; i < saveFiles.data.slots.length; i++) + { + const slot = saveFiles.data.slots[i]; + const savePath = path.join(saveFolderPath, slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`); + if (await fs.exists(savePath)) + { + const stat = await fs.stat(savePath); + if (stat.mtimeMs > new Date(slot.latest.updated_at).getTime()) + { + const subPath = path.join(slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`); + if (!finalSavePaths.some(f => f.subPath === subPath)) + { + // Add newer files to the list, maybe they were changed offscreen. + finalSavePaths.push({ subPath, cwd: saveFolderPath, shared: false }); + } + } + } + } + } + + if (finalSavePaths.length > 0) + { + console.log("Files Changed:", finalSavePaths.map(f => f.subPath)?.join(", ")); + + await Promise.all(finalSavePaths.map(async f => + { + const absolutePath = path.join(f.cwd, f.subPath); + if (!await fs.exists(absolutePath)) return; + const stat = await fs.stat(absolutePath); + if (stat.isDirectory()) return; + const data: FormData = new FormData(); + data.append('saveFile', Bun.file(absolutePath), path.basename(f.subPath)); + + const url = new URL(`${config.get('rommAddress')}/api/saves`); + url.searchParams.set('rom_id', id); + url.searchParams.set('slot', path.dirname(f.subPath)); + url.searchParams.set('autocleanup', "true"); + url.searchParams.set('autocleanup_limit', "2"); + if (command.emulator) + url.searchParams.set('emulator', command.emulator); + url.searchParams.set('overwrite', "true"); + + const auth = await this.getAuthToken(); + const headers: Record = {}; + if (auth) + headers['Authorization'] = auth; + + const response = await fetch(url, { + body: data, + method: "POST", + headers + }); + if (!response.ok) console.error(response.statusText); + })); + + events.emit('notification', { message: "Saves Uploaded", icon: 'upload', type: "success" }); + } + const resp = await updateRomUserApiRomsIdPropsPut({ path: { id: Number(id) }, body: { update_last_played: true } }); if (resp.error) console.error(resp.error); - return resp.response.ok; + events.emit('notification', { message: "Updated Played", type: "success", icon: "clock" }); }); ctx.hooks.games.fetchCollections.tapPromise(desc.name, async ({ collections }) => diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index f331bb6..308f217 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -223,14 +223,28 @@ export class JobContext, TData, TState extends str } } catch (error) { - if (error !== 'cancel') + try { - console.error(error); + if (error instanceof Event) + { + if (error.target instanceof AbortSignal) + { + + } else + { + console.error(error); + } + } else + { + console.error(error); + this.events.emit('error', { id: this.m_id, job: this, error }); + this.error = error; + } + } finally + { + this.m_promise.resolve(undefined); } - this.events.emit('error', { id: this.m_id, job: this, error }); - this.error = error; - this.m_promise.resolve(undefined); } finally { this.running = false; diff --git a/src/mainview/components/Notifications.tsx b/src/mainview/components/Notifications.tsx index 37edb26..a069069 100644 --- a/src/mainview/components/Notifications.tsx +++ b/src/mainview/components/Notifications.tsx @@ -1,7 +1,15 @@ import { RPC_URL } from "@/shared/constants"; +import { Clock, CloudUpload, Save } from "lucide-react"; import { useEffect } from "react"; import toast, { ToastOptions } from "react-hot-toast"; + +const customIconMap = { + save: , + upload: , + clock: +}; + export default function Notifications (data: {}) { useEffect(() => @@ -10,7 +18,13 @@ export default function Notifications (data: {}) es.addEventListener('notification', (e) => { const notification = JSON.parse(e.data) as FrontendNotification; - const options: ToastOptions = { removeDelay: notification.duration }; + const options: ToastOptions = { + removeDelay: notification.duration, + style: { + borderRadius: "64px" + } + }; + if (notification.icon) options.icon = customIconMap[notification.icon]; if (notification.type === 'error') { toast.error(notification.message, options); diff --git a/src/mainview/components/game/Details.tsx b/src/mainview/components/game/Details.tsx index 5523f52..c3b1fcc 100644 --- a/src/mainview/components/game/Details.tsx +++ b/src/mainview/components/game/Details.tsx @@ -2,16 +2,16 @@ import { scrollIntoViewHandler } from "@/mainview/scripts/utils"; import { RPC_URL } from "@/shared/constants"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; -import { Clock, CloudDownload, HardDrive, Store, TriangleAlert } from "lucide-react"; +import { Clock, CloudBackup, CloudDownload, CloudUpload, HardDrive, Store, TriangleAlert } from "lucide-react"; import prettyBytes from "pretty-bytes"; import { JSX } from "react"; import ActionButtons from "./ActionButtons"; +import prettyMilliseconds from 'pretty-ms'; - -export function DetailElement (data: { icon: JSX.Element; children?: any | any[]; }) +export function DetailElement (data: { icon: JSX.Element; tooltip?: string | null, children?: any | any[]; }) { return ( -
+
{data.icon} {data.children}
@@ -62,15 +62,14 @@ export default function Details (data: { }
-
- } >{data.game?.last_played ? new Date(data.game.last_played).toDateString() : "Never"} +
+ } >{data.game?.last_played ? `${prettyMilliseconds(new Date().getTime() - new Date(data.game.last_played).getTime(), { compact: true, verbose: true })} ago` : "Never"} {!!data.game && (data.game.fs_size_bytes !== null || data.game.missing) && -
-
- {data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)} -
+
+ {data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)}
} :
} >{data.game?.platform_display_name ??
}
+ {data.game?.emulators?.some(e => e.integrations.some(i => i.capabilities?.includes('saves'))) && } />} } > diff --git a/src/mainview/emulatorjs/emulator.ts b/src/mainview/emulatorjs/emulator.ts index b5a730e..48ba808 100644 --- a/src/mainview/emulatorjs/emulator.ts +++ b/src/mainview/emulatorjs/emulator.ts @@ -10,10 +10,11 @@ Array.from(params.entries()).forEach(([key, value]) => window.addEventListener('message', (e) => { - switch (e.data.type) + const data = e.data as EmulatorJsMessage; + switch (data.type) { case 'pause': - if (e.data.data === true) + if (data.paused) { window.EJS_emulator.pause(); } else @@ -24,14 +25,51 @@ window.addEventListener('message', (e) => case 'restart': window.EJS_emulator.elements.bottomBar.restart[0].click(); break; + case 'requestSave': + window.EJS_emulator.elements.bottomBar.saveSavFiles[0].click(); + break; } }); +function postMessage (m: EmulatorJsMessage) +{ + window.parent.postMessage( + m, + "*" + ); +} + +export function loadEmulatorJSSave (save: Uint8Array) +{ + const FS = window.EJS_emulator.gameManager.FS; + const path = window.EJS_emulator.gameManager.getSaveFilePath(); + const paths = path.split("/"); + let cp = ""; + for (let i = 0; i < paths.length - 1; i++) + { + if (paths[i] === "") continue; + cp += "/" + paths[i]; + if (!FS.analyzePath(cp).exists) FS.mkdir(cp); + } + if (FS.analyzePath(path).exists) FS.unlink(path); + FS.writeFile(path, save); + window.EJS_emulator.gameManager.loadSaveFiles(); +} + window.EJS_threads = !__PUBLIC__; window.EJS_player = "#game"; window.EJS_lightgun = false; window.EJS_startOnLoaded = true; +window.EJS_onGameStart = async () => +{ + const savesResponse = await fetch(`${RPC_URL(__HOST__)}/api/romm/emulatorjs/load?filePath=${encodeURIComponent(window.EJS_emulator.gameManager.getSaveFilePath())}`); + if (savesResponse.ok) + { + loadEmulatorJSSave(new Uint8Array(await savesResponse.arrayBuffer())); + postMessage({ type: "loaded" }); + } +}; // For core downloads, it either redirects to CDN or uses local if downloaded window.EJS_pathtodata = `${RPC_URL(__HOST__)}/api/romm/emulatorjs/data`; window.EJS_Buttons = { @@ -40,10 +78,8 @@ window.EJS_Buttons = { displayName: "Exit", callback: () => { - window.parent.postMessage( - { type: "exit" }, - "*" - ); + const saveFile = window.EJS_emulator.gameManager.getSaveFile(false); + postMessage({ type: "exit", save: saveFile ? new File([saveFile], window.EJS_emulator.gameManager.getSaveFilePath()) : undefined }); } } }; @@ -58,7 +94,18 @@ const moduleUrls = import.meta.glob import: 'default', }); +function handeSave (ctx: { save: ArrayBuffer, screenshot: ArrayBuffer | undefined, format: string; }) +{ + window.parent.postMessage({ type: 'save', save: new File([ctx.save], window.EJS_emulator.gameManager.getSaveFilePath()) }); +} + // emulatorjs expects basenames instead of paths for some reason window.EJS_paths = Object.fromEntries(await Promise.all(Object.entries(moduleUrls).map(async ([key, value]) => [basename(key), await value()]))); +window.EJS_onSaveUpdate = (ctx: { hash: string, save: ArrayBuffer, screenshot: ArrayBuffer | undefined, format: string; }) => handeSave(ctx); +window.EJS_onSaveSave = (ctx: { + save: ArrayBuffer; + screenshot: ArrayBuffer; + format: string; +}) => handeSave(ctx); await import('@emulatorjs/emulatorjs/data/loader.js' as any); \ No newline at end of file diff --git a/src/mainview/emulatorjs/types.d.ts b/src/mainview/emulatorjs/types.d.ts index 11b8f1f..4021f5d 100644 --- a/src/mainview/emulatorjs/types.d.ts +++ b/src/mainview/emulatorjs/types.d.ts @@ -14,6 +14,7 @@ export declare global EJS_cheats: string[][], EJS_fullscreenOnLoaded: boolean, EJS_startOnLoaded: boolean, + EJS_onGameStart, EJS_core: string, EJS_lightgun: boolean, EJS_biosUrl: string, @@ -56,7 +57,9 @@ export declare global EJS_browserMode, EJS_shaders, EJS_fixedSaveInterval, + EJS_onSaveUpdate, EJS_disableAutoUnload, EJS_disableBatchBootup; + EJS_onSaveSave; } } \ No newline at end of file diff --git a/src/mainview/routes/embedded.$source.$id.tsx b/src/mainview/routes/embedded.$source.$id.tsx index df6ec54..e1704c3 100644 --- a/src/mainview/routes/embedded.$source.$id.tsx +++ b/src/mainview/routes/embedded.$source.$id.tsx @@ -5,20 +5,24 @@ import z from 'zod'; import { RefObject, useEffect, useRef, useState } from 'react'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { ButtonStyle } from '../components/options/Button'; -import { DoorOpen, RefreshCw, Undo } from 'lucide-react'; -import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; -import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts'; +import { CloudDownload, DoorOpen, RefreshCw, Save, Undo } from 'lucide-react'; +import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts'; +import { FloatingShortcuts } from '../components/Shortcuts'; import { useEventListener } from 'usehooks-ts'; import useActiveControl from '../scripts/gamepads'; import { twMerge } from 'tailwind-merge'; import { HeaderAccounts, HeaderStatusBar } from '../components/Header'; import { RoundButton } from '../components/RoundButton'; import { gameQuery } from '@queries/romm'; +import { rommApi } from '../scripts/clientApi'; +import toast from 'react-hot-toast'; +import { getErrorMessage } from 'react-error-boundary'; export const Route = createFileRoute('/embedded/$source/$id')({ component: RouteComponent, staticData: { - enterSound: 'launch' + enterSound: 'launch', + missNavSound: false }, loader: async (ctx) => { @@ -45,7 +49,7 @@ function OverlayButton (data: { function Overlay (data: { open: boolean; - iframeRef: RefObject; + postMessage: (m: EmulatorJsMessage) => void; close: () => void; goBack: () => void; }) @@ -64,7 +68,6 @@ function Overlay (data: { }, [data.open]); const { isPointer } = useActiveControl(); - const handleEvent = (type: string, value?: any) => data.iframeRef.current?.contentWindow?.postMessage({ type, data: value }); return
@@ -78,7 +81,7 @@ function Overlay (data: { { data.close(); - handleEvent('restart'); + data.postMessage({ type: 'restart' }); }} > @@ -132,6 +135,7 @@ function RouteComponent () }); const iframeRef = useRef(null); const [overlayOpen, setOverlayOpen] = useState(false); + const postMessage = (m: EmulatorJsMessage) => iframeRef.current?.contentWindow?.postMessage(m); const { source, id } = Route.useParams(); function HandleGoBack () @@ -147,9 +151,23 @@ function RouteComponent () useEventListener('message', e => { - if (e.data.type === 'exit') + const data = e.data as EmulatorJsMessage; + switch (data.type) { - HandleGoBack(); + case "exit": + rommApi.api.romm.emulatorjs.post_play({ source })({ id }).post({ save: data.save }); + HandleGoBack(); + break; + case "loaded": + toast.success("Save Loaded", { icon: }); + break; + case "save": + rommApi.api.romm.emulatorjs.save.put({ save: data.save }).then(r => + { + if (r.error) toast.error(getErrorMessage(r.error.value) ?? "Error While Saving"); + else toast.success("Save Backed Up"); + }); + break; } }); @@ -173,11 +191,11 @@ function RouteComponent () const setPaused = (paused: boolean) => { - if (paused) iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: true }); + if (paused) postMessage({ type: 'pause', paused: true }); else { // we want to prevent input from closing the overlay spilling - setTimeout(() => iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: false }), 100); + setTimeout(() => postMessage({ type: 'pause', paused: false }), 100); } }; useEffect(() => setPaused(overlayOpen), [overlayOpen]); @@ -191,7 +209,7 @@ function RouteComponent ()
- +
diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index ab8c857..f77fd9d 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -104,6 +104,8 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; }) stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: }); if (data.game.emulators) stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) }); + const integrations = new Set(data.game.emulators?.flatMap(e => e.integrations).flatMap(i => i.capabilities).filter(c => !!c)); + stats.push({ label: "Integrations", content: Array.from(integrations) }); } return ; diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index 63f0907..bba950b 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -5,6 +5,7 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/ import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts'; import { useJobStatus } from '../scripts/utils'; +import { useRef } from 'react'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, @@ -13,6 +14,10 @@ export const Route = createFileRoute('/launcher/$source/$id')({ }, }); +const stateLookup: Record = { + saves: "Syncing Saves" +}; + function RouteComponent () { const router = useRouter(); @@ -27,12 +32,18 @@ function RouteComponent () } } + const progressRef = useRef(null); const { source, id } = Route.useParams(); const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` }); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); - const { data } = useJobStatus('launch-game', { + const { data, state } = useJobStatus('launch-game', { + onProgress (process, data) + { + if (progressRef.current) + progressRef.current.value = process; + }, onEnded (data) { HandleGoBack(); @@ -41,14 +52,19 @@ function RouteComponent () { HandleGoBack(); }, - }); + }, [progressRef.current, HandleGoBack]); useBlocker({ shouldBlockFn: () => !!data }); return
-

Launching {data?.name} ...

+ {!!state && !!stateLookup[state] ? + <> +

Launching {data?.name} ...

+ + : +

Launching {data?.name} ...

}
; diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 889ee00..ad65539 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -10,7 +10,7 @@ import Shortcuts, { FloatingShortcuts } from "@/mainview/components/Shortcuts"; import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; import { rommApi, systemApi } from "@/mainview/scripts/clientApi"; 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, CloudUpload, Cpu, Download, Fullscreen, Gamepad2, Info, Monitor, Puzzle, Save, Settings, Settings2, Terminal, Trash2, TriangleAlert, WandSparkles } from "lucide-react"; import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog"; import { RPC_URL } from "@/shared/constants"; import Screenshots from "@/mainview/components/Screenshots"; @@ -283,6 +283,9 @@ function TitleArea (data: { {data.emulator && data.emulator.integrations.length > 0 &&
} + {data.emulator?.integrations.some(s => s.capabilities?.includes('saves')) &&
+
+
}
@@ -319,6 +322,14 @@ function Description (data: { emulator?: FrontEndEmulatorDetailed; })
; } +const capabilityIconMap: Record = { + saves: , + fullscreen: , + resolution: , + config: , + batch: +}; + export function RouteComponent () { const { id } = Route.useParams(); @@ -366,7 +377,9 @@ export function RouteComponent ()
{i.id}
-
{`${i.capabilities?.join(", ")}`}
+
+ {i.capabilities?.map(c => <>
{capabilityIconMap[c]}{c}
)} +
; })}
diff --git a/src/mainview/scripts/audio/audio.ts b/src/mainview/scripts/audio/audio.ts index bbf8712..743b4ea 100644 --- a/src/mainview/scripts/audio/audio.ts +++ b/src/mainview/scripts/audio/audio.ts @@ -28,6 +28,7 @@ declare module '@tanstack/react-router' { enterSound?: keyof typeof soundMap | null; enterHaptic?: keyof typeof hapticMap | null; goBackSound?: keyof typeof soundMap | null; + missNavSound?: boolean; } } diff --git a/src/mainview/scripts/gamepads.ts b/src/mainview/scripts/gamepads.ts index 15d48a6..251f000 100644 --- a/src/mainview/scripts/gamepads.ts +++ b/src/mainview/scripts/gamepads.ts @@ -3,6 +3,7 @@ import { GetFocusedElement } from "./spatialNavigation"; import { useEffect, useState } from "react"; import { getLocalSetting, mobileCheck } from "./utils"; import { oneShot } from "./audio/audio"; +import { Router } from "@/mainview"; let loopStarted = false; let isTouching = false; @@ -108,7 +109,13 @@ function throttleNav (key: string, dir: string, event: Event) const currentFocusKey = getCurrentFocusKey(); navigateByDirection(dir, { event }); if (currentFocusKey === getCurrentFocusKey()) - oneShot('invalidNavigation'); + { + const routes = Router.matchRoutes(Router.history.location.pathname); + if (!routes.some(r => r.staticData.missNavSound === false)) + { + oneShot('invalidNavigation'); + } + } throttleMap.set(key, currentDate.getTime()); throttleAcceleration.set(key, acceleration + 1); return true; diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index e84b7b7..428be6e 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -1,5 +1,5 @@ import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants"; -import { RefObject, useEffect, useRef, useState } from "react"; +import { DependencyList, RefObject, useEffect, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; import { jobsApi } from "./clientApi"; import { JobsAPIType } from "@/bun/api/rpc"; @@ -272,7 +272,8 @@ export function useJobStatus, "completed" | "ended", 'data'>) => void; onCompleted?: (data: ExtractField, "completed" | "ended", 'data'>) => void; onError?: (error: string) => void; - } + }, + deps?: DependencyList ) { type Response = JobResponse; @@ -325,7 +326,7 @@ export function useJobStatus