feat: Implemented romm saves for dolphin and xenia

feat: Implemented save backups for emulatorjs
fix: Added support for rar archives
fix: Moved to individual ini adjustments for pcsx2 and ppsspp to allow for user editing of configs
This commit is contained in:
Simeon Radivoev 2026-04-09 17:15:37 +03:00
parent 54dd9256e3
commit 7948bd24fa
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
36 changed files with 1103 additions and 243 deletions

View file

@ -23,6 +23,7 @@
"node-disk-info": "^1.3.0", "node-disk-info": "^1.3.0",
"node-downloader-helper": "^2.1.10", "node-downloader-helper": "^2.1.10",
"node-stream-zip": "^1.15.0", "node-stream-zip": "^1.15.0",
"node-unrar-js": "^2.0.2",
"open": "^11.0.0", "open": "^11.0.0",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"slugify": "^1.6.9", "slugify": "^1.6.9",
@ -78,6 +79,7 @@
"howler": "^2.2.4", "howler": "^2.2.4",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"pretty-bytes": "^7.1.0", "pretty-bytes": "^7.1.0",
"pretty-ms": "^9.3.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-error-boundary": "^6.1.0", "react-error-boundary": "^6.1.0",
@ -1278,6 +1280,8 @@
"node-stream-zip": ["node-stream-zip@1.15.0", "", {}, "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw=="], "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-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=="], "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-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": ["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=="], "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-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": ["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=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],

View file

@ -63,6 +63,7 @@
"node-disk-info": "^1.3.0", "node-disk-info": "^1.3.0",
"node-downloader-helper": "^2.1.10", "node-downloader-helper": "^2.1.10",
"node-stream-zip": "^1.15.0", "node-stream-zip": "^1.15.0",
"node-unrar-js": "^2.0.2",
"open": "^11.0.0", "open": "^11.0.0",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"slugify": "^1.6.9", "slugify": "^1.6.9",
@ -118,6 +119,7 @@
"howler": "^2.2.4", "howler": "^2.2.4",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"pretty-bytes": "^7.1.0", "pretty-bytes": "^7.1.0",
"pretty-ms": "^9.3.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-error-boundary": "^6.1.0", "react-error-boundary": "^6.1.0",

View file

@ -181,7 +181,7 @@ export async function tryLoginAndSave ({ host, username, password }: { host: str
body: { body: {
password, password,
username, 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 }, baseUrl: host
}); });

View file

@ -5,9 +5,10 @@ import games from "./games/games";
import platforms from "./games/platforms"; import platforms from "./games/platforms";
import auth from "./auth"; import auth from "./auth";
import collections from "./games/collections"; import collections from "./games/collections";
import emulatorjs from "./emulatorjs/emulatorjs";
export default new Elysia({ prefix: "/api/romm" }) export default new Elysia({ prefix: "/api/romm" })
.use([games, platforms, collections, auth]) .use([games, platforms, collections, auth, emulatorjs])
.all("/*", async ({ request, set }) => .all("/*", async ({ request, set }) =>
{ {
set.headers["cross-origin-resource-policy"] = 'cross-origin'; set.headers["cross-origin-resource-policy"] = 'cross-origin';

View file

@ -14,8 +14,8 @@ export default async function Initialize ()
const launchGameTask = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob); const launchGameTask = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob);
if (launchGameTask) if (launchGameTask)
{ {
launchGameTask.abort('exit');
taskQueue.waitForJob(LaunchGameJob.id).then(() => setTimeout(() => events.emit('focus'), 300)); taskQueue.waitForJob(LaunchGameJob.id).then(() => setTimeout(() => events.emit('focus'), 300));
launchGameTask.abort('exit');
} else } else
{ {
events.emit('focus'); events.emit('focus');

View file

@ -1,4 +1,11 @@
// ES-DE to emulator JS mapping // 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 // TODO: use the retroarch cores based on ES-DE
export const cores: Record<string, string> = { export const cores: Record<string, string> = {
"atari5200": "atari5200", "atari5200": "atari5200",
@ -44,3 +51,56 @@ export const cores: Record<string, string> = {
"vic20": "vic20", "vic20": "vic20",
"dos": "dos" "dos": "dos"
}; };
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()
})
});

View file

@ -13,6 +13,7 @@ import Elysia from "elysia";
import z from "zod"; import z from "zod";
import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { InstallJob, InstallJobStates } from "../../jobs/install-job";
import { LaunchGameJob } from "../../jobs/launch-game-job"; import { LaunchGameJob } from "../../jobs/launch-game-job";
import * as appSchema from "@schema/app";
class CommandSearchError extends Error class CommandSearchError extends Error
{ {
@ -26,7 +27,14 @@ class CommandSearchError extends Error
export async function getLocalGame (source: string, id: string) export async function getLocalGame (source: string, id: string)
{ {
const localGame = await db.query.games.findFirst({ const localGame = await db.query.games.findFirst({
columns: { id: true, path_fs: true, 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), where: getLocalGameMatch(id, source),
with: { with: {
platform: { columns: { slug: true } } platform: { columns: { slug: true } }
@ -36,6 +44,33 @@ export async function getLocalGame (source: string, id: string)
return localGame; 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> export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined>
{ {
if (source === 'emulator') if (source === 'emulator')

View file

@ -18,8 +18,9 @@ export class GameHooks
source?: string; source?: string;
sourceId?: string; sourceId?: string;
id: FrontEndId; 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 * Is the given emulator for the given command supported
* @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects. * @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects.
@ -69,7 +70,27 @@ export class GameHooks
fetchPlatforms = new AsyncSeriesHook<[ctx: { fetchPlatforms = new AsyncSeriesHook<[ctx: {
platforms: FrontEndPlatformType[]; platforms: FrontEndPlatformType[];
}]>(['ctx']); }]>(['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']); fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']);
fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']); fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']);

View file

@ -3,7 +3,7 @@ import { and, eq, or } from 'drizzle-orm';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import * as schema from "@schema/app"; import * as schema from "@schema/app";
import * as emulatorSchema from "@schema/emulators"; 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 { config, db, emulatorsDb, events, plugins } from "../app";
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService"; import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
import * as igdb from 'ts-igdb-client'; import * as igdb from 'ts-igdb-client';
@ -13,9 +13,12 @@ import { Downloader } from "@/bun/utils/downloader";
import Seven from 'node-7z'; import Seven from 'node-7z';
import z from "zod"; import z from "zod";
import { checkFiles } from "../games/services/utils"; import { checkFiles } from "../games/services/utils";
import { ensureDir } from "fs-extra"; import { ensureDir, existsSync } from "fs-extra";
import { path7za } from "7zip-bin"; import { path7za } from "7zip-bin";
import slugify from 'slugify'; import slugify from 'slugify';
import StreamZip from 'node-stream-zip';
import { createExtractorFromFile } from 'node-unrar-js';
import { which } from "bun";
interface JobConfig interface JobConfig
{ {
@ -116,23 +119,62 @@ export class InstallJob implements IJob<never, InstallJobStates>
for (const filePath of downloadedFiles) for (const filePath of downloadedFiles)
{ {
const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path); 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 => seven.on('progress', p =>
{ {
cx.setProgress(progress + p.percent * progressDelta, "extract"); cx.setProgress(progress + p.percent * progressDelta, "extract");
}); });
seven.on('error', e => seven.on('error', e =>
{ {
reject(e); reject(e);
rejected = true;
}); });
seven.on('end', async () => seven.on('end', async () =>
{ {
if (rejected) return;
await fs.rm(filePath); await fs.rm(filePath);
resolve(true); 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; progress += progressDelta * 100;
} }

View file

@ -5,8 +5,11 @@ import { db, events, plugins } from "../app";
import * as appSchema from "@schema/app"; import * as appSchema from "@schema/app";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { spawn } from 'node:child_process'; 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<z.infer<typeof LaunchGameJob.dataSchema>, "playing"> export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, string>
{ {
static id = "launch-game" as const; static id = "launch-game" as const;
static dataSchema = z.nullable(ActiveGameSchema); static dataSchema = z.nullable(ActiveGameSchema);
@ -16,6 +19,8 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
validCommand: CommandEntry; validCommand: CommandEntry;
gameSource?: string; gameSource?: string;
gameSourceId?: string; gameSourceId?: string;
changedSaveFiles: Map<string, SaveFileChange>;
saveFolderPath?: string;
constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string) constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string)
{ {
@ -24,11 +29,46 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
this.gameSource = source; this.gameSource = source;
this.gameSourceId = sourceId; this.gameSourceId = sourceId;
this.activeGame = null; this.activeGame = null;
this.changedSaveFiles = new Map();
} }
async start (context: JobContext<IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">, z.infer<typeof LaunchGameJob.dataSchema>, "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<IJob<z.infer<typeof LaunchGameJob.dataSchema>, string>, z.infer<typeof LaunchGameJob.dataSchema>, string>)
{
let gameInfo: { name?: string, source_id?: string, source?: string; platformSlug?: string; } | undefined = undefined;
if (this.gameId.source === 'emulator') if (this.gameId.source === 'emulator')
{ {
gameInfo = { name: this.gameId.id }; gameInfo = { name: this.gameId.id };
@ -38,125 +78,140 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
where: eq(appSchema.games.id, Number(this.gameId.id)), columns: { where: eq(appSchema.games.id, Number(this.gameId.id)), columns: {
name: true, name: true,
source_id: true, source_id: true,
source: true source: true,
},
with: {
platform: {
columns: {
es_slug: true,
slug: true
}
}
} }
}); });
if (localGame) if (localGame)
gameInfo = { name: localGame.name ?? undefined, source_id: localGame.source_id ?? undefined, source: localGame.source ?? undefined }; gameInfo = {
name: localGame.name ?? undefined,
source_id: localGame.source_id ?? undefined,
source: localGame.source ?? undefined,
platformSlug: localGame.platform.es_slug ?? localGame.platform.slug
};
} }
const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({ const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({
autoValidCommand: this.validCommand, autoValidCommand: this.validCommand,
game: { source: this.gameSource, sourceId: this.gameSourceId, id: this.gameId }, game: {
source: this.gameSource,
sourceId: this.gameSourceId,
id: this.gameId,
platformSlug: gameInfo?.platformSlug
},
dryRun: false dryRun: false
}); });
await new Promise((resolve, reject) => await new Promise(async (resolve, reject) =>
{ {
let game: any; try
if (!commandArgs)
{ {
// ES-DE commands require shell execution. Some emulators fail otherwise. let game: any;
const spawnGame = spawn(this.validCommand.command, { if (!commandArgs)
shell: true, {
cwd: this.validCommand.startDir, await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }).catch(e => reject(e));
signal: context.abortSignal,
env: { // 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)); bunGame.exited.then(e =>
spawnGame.on('close', (code) => {
resolve(true);
}).catch(e =>
{
console.error(e);
reject(e);
});
game = bunGame;
} else
{ {
resolve(code); reject(new Error("No Emulator Bin"));
}); return;
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)));
} }
await plugins.hooks.games.updatePlayed.promise({ source: source ?? id.source, id: sourceId ?? id.id }).then(v => this.activeGame = {
{ process: game,
if (v) events.emit('notification', { message: "Updated Last Played", type: 'success' }); name: gameInfo?.name ?? "Unknown",
}); gameId: this.gameId,
}; source: this.gameSource,
sourceId: this.gameSourceId,
updatePlayed(this.gameId, this.gameSource, this.gameSourceId); command: this.validCommand
};
} catch (e)
{
context.abort(e);
reject(e);
}
}); });
/* Old spawn lanching, cases issues, needs to be ran as shell await this.postPlay({ platformSlug: gameInfo?.platformSlug });
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');
}*/
} }
exposeData () exposeData ()

View file

@ -11,7 +11,7 @@ export default class CEMUIntegration implements PluginType
{ {
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => 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) => 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}`); args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`);
} }
return args; return { args, savesPath: savesPath };
}); });
} }
} }

View file

@ -3,17 +3,18 @@ import { config } from "@/bun/api/app";
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import path from 'node:path'; import path from 'node:path';
import desc from './package.json'; import desc from './package.json';
import { ensureDir } from "fs-extra";
import { getSavePaths, getType } from "./utils";
export default class DOLPHINIntegration implements PluginType export default class DOLPHINIntegration implements PluginType
{ {
emulator = 'DOLPHIN'; emulator = 'DOLPHIN';
load (ctx: PluginContextType) load (ctx: PluginContextType)
{ {
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => 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) => 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.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`);
args.push(`--config=Dolphin.General.WiiSDCardSyncFolder=${path.join(savesPath, 'WiiSDSync')}`); args.push(`--config=Dolphin.General.WiiSDCardSyncFolder=${path.join(savesPath, 'WiiSDSync')}`);
args.push(`--config=Dolphin.GBA.SavesPath=${path.join(savesPath, 'GBA')}`); 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) if (ctx.autoValidCommand.metadata.romPath)
{ {
args.push("--batch"); args.push("--batch");
args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`); 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));
}
}); });
} }
} }

View file

@ -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<DolphinLocation>
{
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<string>
{
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<string>
{
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<SaveFileChange[]>
{
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 }));
}
}

View file

@ -21,7 +21,6 @@ CdvdShareWrite = false
EnablePatches = true EnablePatches = true
EnableCheats = false EnableCheats = false
EnablePINE = false EnablePINE = false
EnableWideScreenPatches = {{ENABLE_WIDESCREEN}}
EnableNoInterlacingPatches = false EnableNoInterlacingPatches = false
EnableRecordingTools = true EnableRecordingTools = true
EnableGameFixes = true EnableGameFixes = true
@ -168,7 +167,6 @@ linear_present_mode = 1
deinterlace_mode = 0 deinterlace_mode = 0
OsdScale = 100 OsdScale = 100
Renderer = 14 Renderer = 14
upscale_multiplier = {{UPSCALE_MULTIPLIER}}
mipmap_hw = -1 mipmap_hw = -1
accurate_blending_unit = 1 accurate_blending_unit = 1
crc_hack_level = -1 crc_hack_level = -1
@ -371,18 +369,6 @@ Multitap2_Slot4_Enable = false
Multitap2_Slot4_Filename = Mcd-Multitap2-Slot04.ps2 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] [InputSources]
Keyboard = true Keyboard = true
Mouse = true Mouse = true
@ -488,6 +474,3 @@ RDown = SDL-1/+RightY
RLeft = SDL-1/-RightX RLeft = SDL-1/-RightX
LargeMotor = SDL-1/LargeMotor LargeMotor = SDL-1/LargeMotor
SmallMotor = SDL-1/SmallMotor SmallMotor = SDL-1/SmallMotor
[GameList]
RecursivePaths = {{{RECURSIVE_PATHS}}}

View file

@ -1,11 +1,11 @@
import { config } from "@/bun/api/app"; import { config } from "@/bun/api/app";
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import configFile from './PCSX2.ini' with { type: 'file' }; import defaultConfig from './PCSX2.ini' with { type: 'file' };
import Mustache from 'mustache';
import path from 'node:path'; import path from 'node:path';
import { ensureDir } from "fs-extra"; import { ensureDir } from "fs-extra";
import desc from './package.json'; import desc from './package.json';
import ini from 'ini';
export default class PCSX2Integration implements PluginType 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) => 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') 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) 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 biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
const storageFolder = path.join(config.get('downloadPath'), "storage", 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'), CACHE_PATH: path.join(storageFolder, 'cache'),
COVERS_PATH: path.join(storageFolder, 'covers'), COVERS_PATH: path.join(storageFolder, 'covers'),
TEXTURES_PATH: path.join(storageFolder, 'textures'), 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'), RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'),
}; };
await Promise.all(Object.values(paths).map(p => ensureDir(p))); await Promise.all(Object.values(paths).map(p => ensureDir(p)));
const view = { configFile.EmuCore ??= {};
...paths, configFile.EmuCore.EnableWideScreenPatches = config.get('emulatorWidescreen');
ENABLE_WIDESCREEN: config.get('emulatorWidescreen'), configFile['EmuCore/GS'] ??= {};
ASPECT_RATIO: config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2", configFile['EmuCore/GS'].AspectRatio = config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2";
UPSCALE_MULTIPLIER: resolutionMapping[config.get('emulatorResolution')] ?? 1 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 = ''; await Bun.write(configPath, ini.stringify(configFile));
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(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); return { args, savesPath: paths.MEMORY_CARDS_PATH };
} }
return args; return { args };
}); });
} }
} }

View file

@ -96,7 +96,6 @@ HardwareTransform = True
SoftwareSkinning = True SoftwareSkinning = True
TextureFiltering = 1 TextureFiltering = 1
BufferFiltering = 1 BufferFiltering = 1
InternalResolution = {{RESOLUTION}}
AndroidHwScale = 1 AndroidHwScale = 1
HighQualityDepth = 1 HighQualityDepth = 1
FrameSkip = 0 FrameSkip = 0
@ -109,7 +108,6 @@ AnisotropyLevel = 4
VertexDecCache = False VertexDecCache = False
TextureBackoffCache = False TextureBackoffCache = False
TextureSecondaryCache = False TextureSecondaryCache = False
FullScreen = {{FULLSCREEN}}
FullScreenMulti = False FullScreenMulti = False
SmallDisplayZoomType = 2 SmallDisplayZoomType = 2
SmallDisplayOffsetX = 0.500000 SmallDisplayOffsetX = 0.500000

View file

@ -9,6 +9,7 @@ import path from "node:path";
import Mustache from "mustache"; import Mustache from "mustache";
import { ensureDir } from "fs-extra"; import { ensureDir } from "fs-extra";
import { homedir } from "node:os"; import { homedir } from "node:os";
import ini from 'ini';
export default class PPSSPPIntegration implements PluginType 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) => 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') 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) if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun)
{ {
let confPath: string | undefined = undefined; let defaultConfigPath: string | undefined = undefined;
let controlsPath: string | undefined = undefined; let defaultControlsPath: string | undefined = undefined;
switch (process.platform) switch (process.platform)
{ {
case "win32": case "win32":
confPath = configFilePathWin32; defaultConfigPath = configFilePathWin32;
controlsPath = configControlsFilePathWin32; defaultControlsPath = configControlsFilePathWin32;
break; break;
case 'linux': case 'linux':
confPath = configFilePathLinux; defaultConfigPath = configFilePathLinux;
controlsPath = configControlsFilePathLinux; defaultControlsPath = configControlsFilePathLinux;
break; break;
} }
@ -87,29 +88,36 @@ export default class PPSSPPIntegration implements PluginType
ensureDir(ppssppPath); ensureDir(ppssppPath);
if (confPath) if (defaultConfigPath)
{ {
const resolutionMapping = { const resolutionMapping: Record<string, number> = {
"720p": "2", "720p": 2,
"1080p": "4", "1080p": 4,
"1440p": "6", "1440p": 6,
"4k": "8" "4k": 8
}; };
const configFileContents = await Bun.file(confPath).text(); const configPath = path.join(ppssppPath, 'ppsspp.ini');
await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, { const configFile = Bun.file(configPath);
RESOLUTION: resolutionMapping[config.get('emulatorResolution')] ?? 0,
FULLSCREEN: config.get('launchInFullscreen') ? "True" : "False" 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, {})); 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 };
}); });
} }
} }

View file

@ -96,7 +96,6 @@ HardwareTransform = True
SoftwareSkinning = True SoftwareSkinning = True
TextureFiltering = 1 TextureFiltering = 1
BufferFiltering = 1 BufferFiltering = 1
InternalResolution = {{RESOLUTION}}
AndroidHwScale = 1 AndroidHwScale = 1
HighQualityDepth = 1 HighQualityDepth = 1
FrameSkip = 0 FrameSkip = 0
@ -109,7 +108,6 @@ AnisotropyLevel = 4
VertexDecCache = False VertexDecCache = False
TextureBackoffCache = False TextureBackoffCache = False
TextureSecondaryCache = False TextureSecondaryCache = False
FullScreen = {{FULLSCREEN}}
FullScreenMulti = False FullScreenMulti = False
SmallDisplayZoomType = 2 SmallDisplayZoomType = 2
SmallDisplayOffsetX = 0.500000 SmallDisplayOffsetX = 0.500000

View file

@ -14,7 +14,7 @@ export default class XEMUIntegration implements PluginType
{ {
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => 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) => 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 };
}); });
} }
} }

View file

@ -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<string, number> = {
XSF: 0x0,
GDF: 0xFD90000,
XGD3: 0x2080000,
};
async function readBytes (file: ReturnType<typeof Bun.file>, offset: number, length: number): Promise<Buffer>
{
return Buffer.from(await file.slice(offset, offset + length).arrayBuffer());
}
async function parseTitleIdFromXexReader (
read: (offset: number, length: number) => Promise<Buffer>
): Promise<string>
{
// 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<string>
{
const file = Bun.file(xexPath);
return parseTitleIdFromXexReader((offset, length) =>
readBytes(file, offset, length)
);
}
async function titleIdFromIso (isoPath: string): Promise<string>
{
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<string>
{
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<string>
{
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<string>
{
const titleId = await getTitleId(romPath);
return join(xeniaDir, titleId);
};

View file

@ -6,6 +6,7 @@ import path from "node:path";
import { ensureDir } from "fs-extra"; import { ensureDir } from "fs-extra";
import toml, { TomlTable } from 'smol-toml'; import toml, { TomlTable } from 'smol-toml';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { getXeniaSavePaths } from "./utils";
export default class XENIAIntegration implements PluginType 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"), ""); await Bun.write(path.join(ctx.path, "portable.txt"), "");
} }
async handleLaunch (ctx: Parameters<typeof GameflowHooks.prototype.games.emulatorLaunch.callAsync>['0']) async handleLaunch (ctx: Parameters<typeof GameflowHooks.prototype.games.emulatorLaunch.callAsync>['0']):
ReturnType<typeof GameflowHooks.prototype.games.emulatorLaunch.promise>
{ {
const args: string[] = []; 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`); 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) if (!ctx.dryRun)
{ {
await ensureDir(path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!)); 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.Display.fullscreen = config.get('launchInFullscreen');
configFile.GPU.draw_resolution_scale_x = resolutionMapping[config.get('emulatorResolution')] ?? 1; configFile.GPU.draw_resolution_scale_x = resolutionMapping[config.get('emulatorResolution')] ?? 1;
configFile.GPU.draw_resolution_scale_y = 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.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.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'); configFile.Storage.cache_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'cache');
await Bun.write(configPath, toml.stringify(configFile)); 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); return { args };
if (config.get('launchInFullscreen'))
{
args.push(`--fullscreen`);
}
return args;
} }
handleEmulatorLaunchSupport (ctx: Parameters<typeof GameflowHooks.prototype.games.emulatorLaunchSupport.callAsync>['0']): handleEmulatorLaunchSupport (ctx: Parameters<typeof GameflowHooks.prototype.games.emulatorLaunchSupport.callAsync>['0']):
ReturnType<typeof GameflowHooks.prototype.games.emulatorLaunchSupport.call> ReturnType<typeof GameflowHooks.prototype.games.emulatorLaunchSupport.call>
{ {
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves"] };
} }
load (ctx: PluginContextType) 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.emulator }, this.handleLaunch);
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulatorEdge }, 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)));
}
});
} }
} }

View file

@ -2,8 +2,8 @@
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json'; import desc from './package.json';
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
import { config } from "@/bun/api/app"; import { config, events } from "@/bun/api/app";
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { hashFile, isSteamDeckGameMode } from "@/bun/utils"; 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 secrets from "@/bun/api/secrets";
import { getAuthToken } from "@/clients/romm/core/auth.gen"; import { getAuthToken } from "@/clients/romm/core/auth.gen";
import { client } from "@/clients/romm/client.gen"; import { client } from "@/clients/romm/client.gen";
import { validateGameSource } from "@/bun/api/games/services/statusService";
export default class RommIntegration implements PluginType export default class RommIntegration implements PluginType
{ {
@ -75,7 +76,9 @@ export default class RommIntegration implements PluginType
missing: rom.missing_from_fs, missing: rom.missing_from_fs,
genres: rom.metadatum.genres, genres: rom.metadatum.genres,
companies: rom.metadatum.companies, 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(); 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<string, string> = {};
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<string, string> = {};
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 } }); const resp = await updateRomUserApiRomsIdPropsPut({ path: { id: Number(id) }, body: { update_last_played: true } });
if (resp.error) console.error(resp.error); 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 }) => ctx.hooks.games.fetchCollections.tapPromise(desc.name, async ({ collections }) =>

View file

@ -223,14 +223,28 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
} }
} catch (error) } 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 } finally
{ {
this.running = false; this.running = false;

View file

@ -1,7 +1,15 @@
import { RPC_URL } from "@/shared/constants"; import { RPC_URL } from "@/shared/constants";
import { Clock, CloudUpload, Save } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import toast, { ToastOptions } from "react-hot-toast"; import toast, { ToastOptions } from "react-hot-toast";
const customIconMap = {
save: <Save />,
upload: <CloudUpload />,
clock: <Clock />
};
export default function Notifications (data: {}) export default function Notifications (data: {})
{ {
useEffect(() => useEffect(() =>
@ -10,7 +18,13 @@ export default function Notifications (data: {})
es.addEventListener('notification', (e) => es.addEventListener('notification', (e) =>
{ {
const notification = JSON.parse(e.data) as FrontendNotification; 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') if (notification.type === 'error')
{ {
toast.error(notification.message, options); toast.error(notification.message, options);

View file

@ -2,16 +2,16 @@ import { scrollIntoViewHandler } from "@/mainview/scripts/utils";
import { RPC_URL } from "@/shared/constants"; import { RPC_URL } from "@/shared/constants";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames"; 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 prettyBytes from "pretty-bytes";
import { JSX } from "react"; import { JSX } from "react";
import ActionButtons from "./ActionButtons"; import ActionButtons from "./ActionButtons";
import prettyMilliseconds from 'pretty-ms';
export function DetailElement (data: { icon: JSX.Element; tooltip?: string | null, children?: any | any[]; })
export function DetailElement (data: { icon: JSX.Element; children?: any | any[]; })
{ {
return ( return (
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center tooltip" data-tip={data.tooltip}>
{data.icon} {data.icon}
{data.children} {data.children}
</div> </div>
@ -62,15 +62,14 @@ export default function Details (data: {
} }
</div> </div>
<div className="flex-2 flex flex-col sm:gap-1 md:gap-6 sm:pt-2 md:pt-16 min-h-0"> <div className="flex-2 flex flex-col sm:gap-1 md:gap-6 sm:pt-2 md:pt-16 min-h-0">
<div className="flex flex-wrap sm:gap-4 md:gap-6 shrink-0"> <div className="flex flex-wrap items-center sm:gap-4 md:gap-6 shrink-0">
<DetailElement icon={<Clock />} >{data.game?.last_played ? new Date(data.game.last_played).toDateString() : "Never"}</DetailElement> <DetailElement icon={<Clock />} >{data.game?.last_played ? `${prettyMilliseconds(new Date().getTime() - new Date(data.game.last_played).getTime(), { compact: true, verbose: true })} ago` : "Never"}</DetailElement>
{!!data.game && (data.game.fs_size_bytes !== null || data.game.missing) && {!!data.game && (data.game.fs_size_bytes !== null || data.game.missing) &&
<div className={classNames({ "text-error": data.game.missing })}> <div className={classNames("flex items-center", { "text-error": data.game.missing })}>
<div className="tooltip" data-tip={data.game.path_fs}> <DetailElement tooltip={data.game.path_fs} icon={fileSizeIcon} >{data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)}</DetailElement>
<DetailElement icon={fileSizeIcon} >{data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)}</DetailElement>
</div>
</div>} </div>}
<DetailElement icon={platformCoverImg ? <img className="size-6" src={platformCoverImg.href}></img> : <div className="skeleton size-6 rounded-full shrink-0"></div>} >{data.game?.platform_display_name ?? <div className="skeleton h-4 w-32"></div>}</DetailElement> <DetailElement icon={platformCoverImg ? <img className="size-6" src={platformCoverImg.href}></img> : <div className="skeleton size-6 rounded-full shrink-0"></div>} >{data.game?.platform_display_name ?? <div className="skeleton h-4 w-32"></div>}</DetailElement>
{data.game?.emulators?.some(e => e.integrations.some(i => i.capabilities?.includes('saves'))) && <DetailElement tooltip={"Save Backup"} icon={<CloudUpload />} />}
<DetailElement icon={ <DetailElement icon={
<Store /> <Store />
} > } >

View file

@ -10,10 +10,11 @@ Array.from(params.entries()).forEach(([key, value]) =>
window.addEventListener('message', (e) => window.addEventListener('message', (e) =>
{ {
switch (e.data.type) const data = e.data as EmulatorJsMessage;
switch (data.type)
{ {
case 'pause': case 'pause':
if (e.data.data === true) if (data.paused)
{ {
window.EJS_emulator.pause(); window.EJS_emulator.pause();
} else } else
@ -24,14 +25,51 @@ window.addEventListener('message', (e) =>
case 'restart': case 'restart':
window.EJS_emulator.elements.bottomBar.restart[0].click(); window.EJS_emulator.elements.bottomBar.restart[0].click();
break; 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_threads = !__PUBLIC__;
window.EJS_player = "#game"; window.EJS_player = "#game";
window.EJS_lightgun = false; window.EJS_lightgun = false;
window.EJS_startOnLoaded = true; 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 // 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_pathtodata = `${RPC_URL(__HOST__)}/api/romm/emulatorjs/data`;
window.EJS_Buttons = { window.EJS_Buttons = {
@ -40,10 +78,8 @@ window.EJS_Buttons = {
displayName: "Exit", displayName: "Exit",
callback: () => callback: () =>
{ {
window.parent.postMessage( const saveFile = window.EJS_emulator.gameManager.getSaveFile(false);
{ type: "exit" }, 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', 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 // 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_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); await import('@emulatorjs/emulatorjs/data/loader.js' as any);

View file

@ -14,6 +14,7 @@ export declare global
EJS_cheats: string[][], EJS_cheats: string[][],
EJS_fullscreenOnLoaded: boolean, EJS_fullscreenOnLoaded: boolean,
EJS_startOnLoaded: boolean, EJS_startOnLoaded: boolean,
EJS_onGameStart,
EJS_core: string, EJS_core: string,
EJS_lightgun: boolean, EJS_lightgun: boolean,
EJS_biosUrl: string, EJS_biosUrl: string,
@ -56,7 +57,9 @@ export declare global
EJS_browserMode, EJS_browserMode,
EJS_shaders, EJS_shaders,
EJS_fixedSaveInterval, EJS_fixedSaveInterval,
EJS_onSaveUpdate,
EJS_disableAutoUnload, EJS_disableAutoUnload,
EJS_disableBatchBootup; EJS_disableBatchBootup;
EJS_onSaveSave;
} }
} }

View file

@ -5,20 +5,24 @@ import z from 'zod';
import { RefObject, useEffect, useRef, useState } from 'react'; import { RefObject, useEffect, useRef, useState } from 'react';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { ButtonStyle } from '../components/options/Button'; import { ButtonStyle } from '../components/options/Button';
import { DoorOpen, RefreshCw, Undo } from 'lucide-react'; import { CloudDownload, DoorOpen, RefreshCw, Save, Undo } from 'lucide-react';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts';
import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts'; import { FloatingShortcuts } from '../components/Shortcuts';
import { useEventListener } from 'usehooks-ts'; import { useEventListener } from 'usehooks-ts';
import useActiveControl from '../scripts/gamepads'; import useActiveControl from '../scripts/gamepads';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { HeaderAccounts, HeaderStatusBar } from '../components/Header'; import { HeaderAccounts, HeaderStatusBar } from '../components/Header';
import { RoundButton } from '../components/RoundButton'; import { RoundButton } from '../components/RoundButton';
import { gameQuery } from '@queries/romm'; 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')({ export const Route = createFileRoute('/embedded/$source/$id')({
component: RouteComponent, component: RouteComponent,
staticData: { staticData: {
enterSound: 'launch' enterSound: 'launch',
missNavSound: false
}, },
loader: async (ctx) => loader: async (ctx) =>
{ {
@ -45,7 +49,7 @@ function OverlayButton (data: {
function Overlay (data: { function Overlay (data: {
open: boolean; open: boolean;
iframeRef: RefObject<HTMLIFrameElement | null>; postMessage: (m: EmulatorJsMessage) => void;
close: () => void; close: () => void;
goBack: () => void; goBack: () => void;
}) })
@ -64,7 +68,6 @@ function Overlay (data: {
}, [data.open]); }, [data.open]);
const { isPointer } = useActiveControl(); const { isPointer } = useActiveControl();
const handleEvent = (type: string, value?: any) => data.iframeRef.current?.contentWindow?.postMessage({ type, data: value });
return <div data-open={data.open} className='flex group w-full flex-col gap-2 transition-opacity p-4 not-data-[open=true]:pointer-events-none not-data-[open=true]:opacity-0'> return <div data-open={data.open} className='flex group w-full flex-col gap-2 transition-opacity p-4 not-data-[open=true]:pointer-events-none not-data-[open=true]:opacity-0'>
<div className='grid grid-cols-3 justify-between items-start'> <div className='grid grid-cols-3 justify-between items-start'>
@ -78,7 +81,7 @@ function Overlay (data: {
<OverlayButton id="restart" style='secondary' tooltip='Restart' setTooltip={setTooltip} onAction={() => <OverlayButton id="restart" style='secondary' tooltip='Restart' setTooltip={setTooltip} onAction={() =>
{ {
data.close(); data.close();
handleEvent('restart'); data.postMessage({ type: 'restart' });
}} ><RefreshCw /></OverlayButton> }} ><RefreshCw /></OverlayButton>
<OverlayButton id="exit" style='warning' tooltip='Exit' setTooltip={setTooltip} onAction={data.goBack} ><DoorOpen /></OverlayButton> <OverlayButton id="exit" style='warning' tooltip='Exit' setTooltip={setTooltip} onAction={data.goBack} ><DoorOpen /></OverlayButton>
</FocusContext> </FocusContext>
@ -132,6 +135,7 @@ function RouteComponent ()
}); });
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
const [overlayOpen, setOverlayOpen] = useState(false); const [overlayOpen, setOverlayOpen] = useState(false);
const postMessage = (m: EmulatorJsMessage) => iframeRef.current?.contentWindow?.postMessage(m);
const { source, id } = Route.useParams(); const { source, id } = Route.useParams();
function HandleGoBack () function HandleGoBack ()
@ -147,9 +151,23 @@ function RouteComponent ()
useEventListener('message', e => 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: <CloudDownload /> });
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) => const setPaused = (paused: boolean) =>
{ {
if (paused) iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: true }); if (paused) postMessage({ type: 'pause', paused: true });
else else
{ {
// we want to prevent input from closing the overlay spilling // 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]); useEffect(() => setPaused(overlayOpen), [overlayOpen]);
@ -191,7 +209,7 @@ function RouteComponent ()
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
<Frame ref={iframeRef} /> <Frame ref={iframeRef} />
<div className='flex fixed left-0 right-0 top-0'> <div className='flex fixed left-0 right-0 top-0'>
<Overlay iframeRef={iframeRef} goBack={HandleGoBack} open={overlayOpen} close={handleClose} /> <Overlay postMessage={postMessage} goBack={HandleGoBack} open={overlayOpen} close={handleClose} />
</div> </div>
<FloatingShortcuts /> <FloatingShortcuts />
</FocusContext> </FocusContext>

View file

@ -104,6 +104,8 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; })
stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: <Calendar /> }); stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: <Calendar /> });
if (data.game.emulators) if (data.game.emulators)
stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) }); stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) });
const integrations = new Set<string>(data.game.emulators?.flatMap(e => e.integrations).flatMap(i => i.capabilities).filter(c => !!c));
stats.push({ label: "Integrations", content: Array.from(integrations) });
} }
return <StatList elementClassName="bg-base-300" stats={stats} id="game-detail-stats" />; return <StatList elementClassName="bg-base-300" stats={stats} id="game-detail-stats" />;

View file

@ -5,6 +5,7 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts'; import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts';
import { useJobStatus } from '../scripts/utils'; import { useJobStatus } from '../scripts/utils';
import { useRef } from 'react';
export const Route = createFileRoute('/launcher/$source/$id')({ export const Route = createFileRoute('/launcher/$source/$id')({
component: RouteComponent, component: RouteComponent,
@ -13,6 +14,10 @@ export const Route = createFileRoute('/launcher/$source/$id')({
}, },
}); });
const stateLookup: Record<string, string> = {
saves: "Syncing Saves"
};
function RouteComponent () function RouteComponent ()
{ {
const router = useRouter(); const router = useRouter();
@ -27,12 +32,18 @@ function RouteComponent ()
} }
} }
const progressRef = useRef<HTMLProgressElement>(null);
const { source, id } = Route.useParams(); const { source, id } = Route.useParams();
const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` }); const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` });
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); 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) onEnded (data)
{ {
HandleGoBack(); HandleGoBack();
@ -41,14 +52,19 @@ function RouteComponent ()
{ {
HandleGoBack(); HandleGoBack();
}, },
}); }, [progressRef.current, HandleGoBack]);
useBlocker({ shouldBlockFn: () => !!data }); useBlocker({ shouldBlockFn: () => !!data });
return <AnimatedBackground ref={ref} backgroundKey='game-details'> return <AnimatedBackground ref={ref} backgroundKey='game-details'>
<div className='flex shadow-2xs shadow-black flex-col absolute w-screen h-screen overflow-hidden justify-center items-center gap-4'> <div className='flex shadow-2xs shadow-black flex-col absolute w-screen h-screen overflow-hidden justify-center items-center gap-4'>
<DotsLoading /> <DotsLoading />
<h1 className='font-semibold'>Launching {data?.name} ...</h1> {!!state && !!stateLookup[state] ?
<>
<h1 className='font-semibold'>Launching {data?.name} ...</h1> <progress ref={progressRef} className="progress w-56" value={0} max="100"></progress>
</>
:
<h1 className='font-semibold'>Launching {data?.name} ...</h1>}
</div> </div>
<FloatingShortcuts /> <FloatingShortcuts />
</AnimatedBackground>; </AnimatedBackground>;

View file

@ -10,7 +10,7 @@ import Shortcuts, { FloatingShortcuts } from "@/mainview/components/Shortcuts";
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
import { rommApi, systemApi } from "@/mainview/scripts/clientApi"; import { rommApi, systemApi } from "@/mainview/scripts/clientApi";
import { Button } from "@/mainview/components/options/Button"; import { Button } from "@/mainview/components/options/Button";
import { ChevronDown, CircleFadingArrowUp, Cpu, Download, Gamepad2, Info, Puzzle, Settings, Trash2, TriangleAlert, WandSparkles } from "lucide-react"; import { ChevronDown, CircleFadingArrowUp, 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 { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog";
import { RPC_URL } from "@/shared/constants"; import { RPC_URL } from "@/shared/constants";
import Screenshots from "@/mainview/components/Screenshots"; import Screenshots from "@/mainview/components/Screenshots";
@ -283,6 +283,9 @@ function TitleArea (data: {
{data.emulator && data.emulator.integrations.length > 0 && <div className="tooltip" data-tip="Has Integration"> {data.emulator && data.emulator.integrations.length > 0 && <div className="tooltip" data-tip="Has Integration">
<div className="bg-base-200 rounded-full p-2"><WandSparkles className="size-5" /></div> <div className="bg-base-200 rounded-full p-2"><WandSparkles className="size-5" /></div>
</div>} </div>}
{data.emulator?.integrations.some(s => s.capabilities?.includes('saves')) && <div className="tooltip" data-tip="Save Support">
<div className="bg-base-200 rounded-full p-2"><CloudUpload className="size-5" /></div>
</div>}
</div> </div>
</div> </div>
<div className="flex relative sm:portrait:grow md:grow-0 justify-center gap-4 items-center"> <div className="flex relative sm:portrait:grow md:grow-0 justify-center gap-4 items-center">
@ -319,6 +322,14 @@ function Description (data: { emulator?: FrontEndEmulatorDetailed; })
</div>; </div>;
} }
const capabilityIconMap: Record<string, any> = {
saves: <CloudUpload />,
fullscreen: <Fullscreen />,
resolution: <Monitor />,
config: <Settings2 />,
batch: <Terminal />
};
export function RouteComponent () export function RouteComponent ()
{ {
const { id } = Route.useParams(); const { id } = Route.useParams();
@ -366,7 +377,9 @@ export function RouteComponent ()
<Puzzle /> <Puzzle />
<div>{i.id}</div> <div>{i.id}</div>
</div> </div>
<div className="text-base-content/40">{`${i.capabilities?.join(", ")}`}</div> <div className="flex flex-wrap text-base-content/40">
{i.capabilities?.map(c => <><div className="divider divider-horizontal"></div><div className="flex gap-1">{capabilityIconMap[c]}{c}</div></>)}
</div>
</div>; </div>;
})} })}
</div> </div>

View file

@ -28,6 +28,7 @@ declare module '@tanstack/react-router' {
enterSound?: keyof typeof soundMap | null; enterSound?: keyof typeof soundMap | null;
enterHaptic?: keyof typeof hapticMap | null; enterHaptic?: keyof typeof hapticMap | null;
goBackSound?: keyof typeof soundMap | null; goBackSound?: keyof typeof soundMap | null;
missNavSound?: boolean;
} }
} }

View file

@ -3,6 +3,7 @@ import { GetFocusedElement } from "./spatialNavigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getLocalSetting, mobileCheck } from "./utils"; import { getLocalSetting, mobileCheck } from "./utils";
import { oneShot } from "./audio/audio"; import { oneShot } from "./audio/audio";
import { Router } from "@/mainview";
let loopStarted = false; let loopStarted = false;
let isTouching = false; let isTouching = false;
@ -108,7 +109,13 @@ function throttleNav (key: string, dir: string, event: Event)
const currentFocusKey = getCurrentFocusKey(); const currentFocusKey = getCurrentFocusKey();
navigateByDirection(dir, { event }); navigateByDirection(dir, { event });
if (currentFocusKey === getCurrentFocusKey()) 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()); throttleMap.set(key, currentDate.getTime());
throttleAcceleration.set(key, acceleration + 1); throttleAcceleration.set(key, acceleration + 1);
return true; return true;

View file

@ -1,5 +1,5 @@
import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants"; 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 { useLocalStorage } from "usehooks-ts";
import { jobsApi } from "./clientApi"; import { jobsApi } from "./clientApi";
import { JobsAPIType } from "@/bun/api/rpc"; import { JobsAPIType } from "@/bun/api/rpc";
@ -272,7 +272,8 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
onEnded?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void; onEnded?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
onCompleted?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void; onCompleted?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
onError?: (error: string) => void; onError?: (error: string) => void;
} },
deps?: DependencyList
) )
{ {
type Response = JobResponse<JOB>; type Response = JobResponse<JOB>;
@ -325,7 +326,7 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
sub.close(); sub.close();
ref.current = null; ref.current = null;
}; };
}, [id, init?.query, init?.onEnded, init?.onCompleted, init?.onProgress, init?.onError]); }, [id, init?.query, init?.onEnded, init?.onCompleted, init?.onProgress, init?.onError, ...(deps ?? [])]);
return { data, state, error, wsRef: ref }; return { data, state, error, wsRef: ref };
} }

View file

@ -61,3 +61,10 @@ declare interface FilterOption extends FocusParams, InteractParams
selected: boolean; selected: boolean;
icon?: any; icon?: any;
} }
declare type EmulatorJsMessage = { type: 'restart'; } |
{ type: 'pause'; paused: boolean; } |
{ type: 'exit'; save?: File; } |
{ type: 'save', save: File, screenshot?: File, type: string; } |
{ type: 'loaded'; } |
{ type: 'requestSave'; };

View file

@ -66,6 +66,8 @@ declare interface FrontEndGameTypeDetailed extends FrontEndGameType
genres?: string[]; genres?: string[];
companies?: string[]; companies?: string[];
release_date?: Date; release_date?: Date;
imdb_id?: number;
ra_id?: number;
emulators?: FrontEndGameTypeDetailedEmulator[], emulators?: FrontEndGameTypeDetailedEmulator[],
achievements?: { achievements?: {
unlocked: number; unlocked: number;
@ -105,7 +107,8 @@ declare interface FrontendNotification
{ {
title?: string; title?: string;
message: string; message: string;
type: 'success' | 'error' | 'info'; type: 'success' | 'error' | 'info' | 'custom';
icon?: "save" | "upload" | "clock";
duration?: number; duration?: number;
} }
@ -279,3 +282,10 @@ declare interface EmulatorSupport
supportLevel?: "partial" | "full"; supportLevel?: "partial" | "full";
capabilities?: EmulatorCapabilities[]; capabilities?: EmulatorCapabilities[];
} }
declare interface SaveFileChange
{
subPath: string;
cwd: string;
shared: boolean;
}