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:
parent
54dd9256e3
commit
7948bd24fa
36 changed files with 1103 additions and 243 deletions
8
bun.lock
8
bun.lock
|
|
@ -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=="],
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,24 +78,47 @@ 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) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
let game: any;
|
let game: any;
|
||||||
if (!commandArgs)
|
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.
|
// ES-DE commands require shell execution. Some emulators fail otherwise.
|
||||||
const spawnGame = spawn(this.validCommand.command, {
|
const spawnGame = spawn(this.validCommand.command, {
|
||||||
shell: true,
|
shell: true,
|
||||||
|
|
@ -65,6 +128,8 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
context.setProgress(0, "playing");
|
||||||
|
|
||||||
spawnGame.stdout.on('data', data => console.log(data));
|
spawnGame.stdout.on('data', data => console.log(data));
|
||||||
spawnGame.on('close', (code) =>
|
spawnGame.on('close', (code) =>
|
||||||
{
|
{
|
||||||
|
|
@ -80,15 +145,39 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
}
|
}
|
||||||
else if (this.validCommand.metadata.emulatorBin)
|
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
|
// We have full control over launching integrated emulators better to use bun spawn
|
||||||
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs], {
|
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs.args], {
|
||||||
cwd: this.validCommand.startDir,
|
cwd: this.validCommand.startDir,
|
||||||
signal: context.abortSignal,
|
signal: context.abortSignal,
|
||||||
env: {
|
env: {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
context.abortSignal.addEventListener('abort', reject);
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
bunGame.exited.then(e =>
|
bunGame.exited.then(e =>
|
||||||
{
|
{
|
||||||
|
|
@ -98,7 +187,9 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
console.error(e);
|
console.error(e);
|
||||||
reject(e);
|
reject(e);
|
||||||
});
|
});
|
||||||
|
|
||||||
game = bunGame;
|
game = bunGame;
|
||||||
|
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
reject(new Error("No Emulator Bin"));
|
reject(new Error("No Emulator Bin"));
|
||||||
|
|
@ -113,50 +204,14 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
sourceId: this.gameSourceId,
|
sourceId: this.gameSourceId,
|
||||||
command: this.validCommand
|
command: this.validCommand
|
||||||
};
|
};
|
||||||
|
} catch (e)
|
||||||
const updatePlayed = async (id: FrontEndId, source?: string, sourceId?: string) =>
|
|
||||||
{
|
{
|
||||||
if (this.gameId.source === 'local')
|
context.abort(e);
|
||||||
{
|
reject(e);
|
||||||
await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(this.gameId.id)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await plugins.hooks.games.updatePlayed.promise({ source: source ?? id.source, id: sourceId ?? id.id }).then(v =>
|
|
||||||
{
|
|
||||||
if (v) events.emit('notification', { message: "Updated Last Played", type: 'success' });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
updatePlayed(this.gameId, this.gameSource, this.gameSourceId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/* 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 ()
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}}}
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -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));
|
||||||
};
|
|
||||||
|
|
||||||
args.push(`--config`, configPath);
|
let finalSavesPath: string | undefined = undefined;
|
||||||
|
if (ctx.autoValidCommand.metadata.romPath)
|
||||||
if (config.get('launchInFullscreen'))
|
|
||||||
{
|
{
|
||||||
args.push(`--fullscreen`);
|
finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
return args;
|
return { args, savesPath: finalSavesPath };
|
||||||
|
};
|
||||||
|
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 }) =>
|
||||||
|
|
|
||||||
|
|
@ -223,14 +223,28 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
|
||||||
}
|
}
|
||||||
} catch (error)
|
} catch (error)
|
||||||
{
|
{
|
||||||
if (error !== 'cancel')
|
try
|
||||||
|
{
|
||||||
|
if (error instanceof Event)
|
||||||
|
{
|
||||||
|
if (error.target instanceof AbortSignal)
|
||||||
|
{
|
||||||
|
|
||||||
|
} else
|
||||||
{
|
{
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
console.error(error);
|
||||||
this.events.emit('error', { id: this.m_id, job: this, error });
|
this.events.emit('error', { id: this.m_id, job: this, error });
|
||||||
this.error = error;
|
this.error = error;
|
||||||
|
}
|
||||||
|
} finally
|
||||||
|
{
|
||||||
this.m_promise.resolve(undefined);
|
this.m_promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
} finally
|
} finally
|
||||||
{
|
{
|
||||||
this.running = false;
|
this.running = false;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
} >
|
} >
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
3
src/mainview/emulatorjs/types.d.ts
vendored
3
src/mainview/emulatorjs/types.d.ts
vendored
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
case "exit":
|
||||||
|
rommApi.api.romm.emulatorjs.post_play({ source })({ id }).post({ save: data.save });
|
||||||
HandleGoBack();
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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" />;
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
{
|
||||||
|
const routes = Router.matchRoutes(Router.history.location.pathname);
|
||||||
|
if (!routes.some(r => r.staticData.missNavSound === false))
|
||||||
|
{
|
||||||
oneShot('invalidNavigation');
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
src/mainview/types.d.ts
vendored
7
src/mainview/types.d.ts
vendored
|
|
@ -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'; };
|
||||||
12
src/shared/types..d.ts
vendored
12
src/shared/types..d.ts
vendored
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue