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
|
|
@ -181,7 +181,7 @@ export async function tryLoginAndSave ({ host, username, password }: { host: str
|
|||
body: {
|
||||
password,
|
||||
username,
|
||||
scope: 'me.read roms.read platforms.read assets.read firmware.read roms.user.read collections.read me.write roms.user.write'
|
||||
scope: 'me.read roms.read platforms.read assets.read assets.write firmware.read roms.user.read collections.read me.write roms.user.write'
|
||||
}, baseUrl: host
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ import games from "./games/games";
|
|||
import platforms from "./games/platforms";
|
||||
import auth from "./auth";
|
||||
import collections from "./games/collections";
|
||||
import emulatorjs from "./emulatorjs/emulatorjs";
|
||||
|
||||
export default new Elysia({ prefix: "/api/romm" })
|
||||
.use([games, platforms, collections, auth])
|
||||
.use([games, platforms, collections, auth, emulatorjs])
|
||||
.all("/*", async ({ request, set }) =>
|
||||
{
|
||||
set.headers["cross-origin-resource-policy"] = 'cross-origin';
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ export default async function Initialize ()
|
|||
const launchGameTask = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob);
|
||||
if (launchGameTask)
|
||||
{
|
||||
launchGameTask.abort('exit');
|
||||
taskQueue.waitForJob(LaunchGameJob.id).then(() => setTimeout(() => events.emit('focus'), 300));
|
||||
launchGameTask.abort('exit');
|
||||
} else
|
||||
{
|
||||
events.emit('focus');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
// ES-DE to emulator JS mapping
|
||||
|
||||
import Elysia, { status } from "elysia";
|
||||
import z from "zod";
|
||||
import path from 'node:path';
|
||||
import { config, events, plugins } from "../app";
|
||||
import { getLocalGame, updateLocalLastPlayed } from "../games/services/statusService";
|
||||
|
||||
// TODO: use the retroarch cores based on ES-DE
|
||||
export const cores: Record<string, string> = {
|
||||
"atari5200": "atari5200",
|
||||
|
|
@ -43,4 +50,57 @@ export const cores: Record<string, string> = {
|
|||
"plus4": "plus4",
|
||||
"vic20": "vic20",
|
||||
"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 { InstallJob, InstallJobStates } from "../../jobs/install-job";
|
||||
import { LaunchGameJob } from "../../jobs/launch-game-job";
|
||||
import * as appSchema from "@schema/app";
|
||||
|
||||
class CommandSearchError extends Error
|
||||
{
|
||||
|
|
@ -26,7 +27,14 @@ class CommandSearchError extends Error
|
|||
export async function getLocalGame (source: string, id: string)
|
||||
{
|
||||
const localGame = await db.query.games.findFirst({
|
||||
columns: { id: true, path_fs: true, source: true, source_id: true },
|
||||
columns: {
|
||||
id: true,
|
||||
path_fs: true,
|
||||
source: true,
|
||||
source_id: true,
|
||||
igdb_id: true,
|
||||
ra_id: true
|
||||
},
|
||||
where: getLocalGameMatch(id, source),
|
||||
with: {
|
||||
platform: { columns: { slug: true } }
|
||||
|
|
@ -36,6 +44,33 @@ export async function getLocalGame (source: string, id: string)
|
|||
return localGame;
|
||||
}
|
||||
|
||||
export async function validateGameSource (source: string, id: string): Promise<{ valid: boolean, reason?: string; }>
|
||||
{
|
||||
const localGame = await getLocalGame(source, id);
|
||||
if (!localGame) throw new Error("Could not find local game");
|
||||
if (localGame.source && localGame.source_id)
|
||||
{
|
||||
const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id });
|
||||
if (!sourceGame) return { valid: false, reason: "Source Missing" };
|
||||
if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined))
|
||||
{
|
||||
return { valid: false, reason: "IGDB Miss Match" };
|
||||
}
|
||||
|
||||
if (sourceGame.ra_id !== (localGame.ra_id ?? undefined))
|
||||
{
|
||||
return { valid: false, reason: "RA Miss Match" };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export async function updateLocalLastPlayed (id: number)
|
||||
{
|
||||
await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(id)));
|
||||
}
|
||||
|
||||
export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined>
|
||||
{
|
||||
if (source === 'emulator')
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@ export class GameHooks
|
|||
source?: string;
|
||||
sourceId?: string;
|
||||
id: FrontEndId;
|
||||
platformSlug?: string;
|
||||
};
|
||||
}], string[] | undefined, { emulator: string; }>(['ctx']);
|
||||
}], { args: string[], savesPath?: string; } | undefined, { emulator: string; }>(['ctx']);
|
||||
/**
|
||||
* Is the given emulator for the given command supported
|
||||
* @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects.
|
||||
|
|
@ -69,7 +70,27 @@ export class GameHooks
|
|||
fetchPlatforms = new AsyncSeriesHook<[ctx: {
|
||||
platforms: FrontEndPlatformType[];
|
||||
}]>(['ctx']);
|
||||
updatePlayed = new AsyncSeriesWaterfallHook<[ctx: { source: string, id: string; }], boolean>(["ctx"]);
|
||||
prePlay = new AsyncSeriesHook<[ctx: {
|
||||
source: string,
|
||||
id: string;
|
||||
saveFolderPath?: string;
|
||||
setProgress: (progress: number, state: string) => void,
|
||||
command: CommandEntry;
|
||||
gameInfo: {
|
||||
platformSlug?: string;
|
||||
};
|
||||
}]>(["ctx"]);
|
||||
postPlay = new AsyncSeriesHook<[ctx: {
|
||||
source: string,
|
||||
id: string;
|
||||
saveFolderPath?: string;
|
||||
changedSaveFiles: SaveFileChange[],
|
||||
validChangedSaveFiles: SaveFileChange[],
|
||||
command: CommandEntry;
|
||||
gameInfo: {
|
||||
platformSlug?: string;
|
||||
};
|
||||
}]>(["ctx"]);
|
||||
fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']);
|
||||
fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { and, eq, or } from 'drizzle-orm';
|
|||
import fs from 'node:fs/promises';
|
||||
import * as schema from "@schema/app";
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
import path from 'node:path';
|
||||
import path, { join } from 'node:path';
|
||||
import { config, db, emulatorsDb, events, plugins } from "../app";
|
||||
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
|
||||
import * as igdb from 'ts-igdb-client';
|
||||
|
|
@ -13,9 +13,12 @@ import { Downloader } from "@/bun/utils/downloader";
|
|||
import Seven from 'node-7z';
|
||||
import z from "zod";
|
||||
import { checkFiles } from "../games/services/utils";
|
||||
import { ensureDir } from "fs-extra";
|
||||
import { ensureDir, existsSync } from "fs-extra";
|
||||
import { path7za } from "7zip-bin";
|
||||
import slugify from 'slugify';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
import { createExtractorFromFile } from 'node-unrar-js';
|
||||
import { which } from "bun";
|
||||
|
||||
interface JobConfig
|
||||
{
|
||||
|
|
@ -116,23 +119,62 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
|||
for (const filePath of downloadedFiles)
|
||||
{
|
||||
const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path);
|
||||
await new Promise((resolve, reject) =>
|
||||
await new Promise(async (resolve, reject) =>
|
||||
{
|
||||
const seven = Seven.extractFull(filePath, extractPath, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true });
|
||||
let sevenZipPath = process.env.ZIP7_PATH ?? path7za;
|
||||
|
||||
if (filePath.endsWith('.rar'))
|
||||
{
|
||||
let newPath: string | undefined;
|
||||
if (process.platform === 'win32' && await fs.exists("C:\\Program Files\\7-Zip\\7z.exe"))
|
||||
{
|
||||
newPath = "C:\\Program Files\\7-Zip\\7z.exe";
|
||||
} else
|
||||
{
|
||||
newPath = which('7z') ?? undefined;
|
||||
}
|
||||
|
||||
if (!newPath)
|
||||
{
|
||||
await fs.rm(filePath);
|
||||
reject(new Error("No RAR Support"));
|
||||
return;
|
||||
}
|
||||
|
||||
sevenZipPath = newPath;
|
||||
}
|
||||
|
||||
let rejected = false;
|
||||
const seven = Seven.extractFull(filePath, extractPath, { $bin: sevenZipPath, $progress: true });
|
||||
seven.on('progress', p =>
|
||||
{
|
||||
cx.setProgress(progress + p.percent * progressDelta, "extract");
|
||||
});
|
||||
|
||||
seven.on('error', e =>
|
||||
{
|
||||
reject(e);
|
||||
rejected = true;
|
||||
});
|
||||
seven.on('end', async () =>
|
||||
{
|
||||
if (rejected) return;
|
||||
await fs.rm(filePath);
|
||||
resolve(true);
|
||||
});
|
||||
}).catch(async e =>
|
||||
{
|
||||
if (filePath.endsWith('.zip'))
|
||||
{
|
||||
console.warn("Could not extract", filePath, "with 7zip trying zip extractor");
|
||||
await ensureDir(extractPath);
|
||||
const zip = new StreamZip.async({ file: filePath });
|
||||
const count = await zip.extract(null, extractPath);
|
||||
console.log(`Extracted ${count} entries`);
|
||||
await zip.close();
|
||||
} else
|
||||
{
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
progress += progressDelta * 100;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ import { db, events, plugins } from "../app";
|
|||
import * as appSchema from "@schema/app";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { spawn } from 'node:child_process';
|
||||
import { watch } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { updateLocalLastPlayed } from "../games/services/statusService";
|
||||
|
||||
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">
|
||||
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, string>
|
||||
{
|
||||
static id = "launch-game" as const;
|
||||
static dataSchema = z.nullable(ActiveGameSchema);
|
||||
|
|
@ -16,6 +19,8 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
|||
validCommand: CommandEntry;
|
||||
gameSource?: string;
|
||||
gameSourceId?: string;
|
||||
changedSaveFiles: Map<string, SaveFileChange>;
|
||||
saveFolderPath?: 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.gameSourceId = sourceId;
|
||||
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')
|
||||
{
|
||||
gameInfo = { name: this.gameId.id };
|
||||
|
|
@ -38,125 +78,140 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
|||
where: eq(appSchema.games.id, Number(this.gameId.id)), columns: {
|
||||
name: true,
|
||||
source_id: true,
|
||||
source: true
|
||||
source: true,
|
||||
},
|
||||
with: {
|
||||
platform: {
|
||||
columns: {
|
||||
es_slug: true,
|
||||
slug: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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({
|
||||
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
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) =>
|
||||
await new Promise(async (resolve, reject) =>
|
||||
{
|
||||
let game: any;
|
||||
if (!commandArgs)
|
||||
try
|
||||
{
|
||||
// ES-DE commands require shell execution. Some emulators fail otherwise.
|
||||
const spawnGame = spawn(this.validCommand.command, {
|
||||
shell: true,
|
||||
cwd: this.validCommand.startDir,
|
||||
signal: context.abortSignal,
|
||||
env: {
|
||||
let game: any;
|
||||
if (!commandArgs)
|
||||
{
|
||||
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }).catch(e => reject(e));
|
||||
|
||||
// ES-DE commands require shell execution. Some emulators fail otherwise.
|
||||
const spawnGame = spawn(this.validCommand.command, {
|
||||
shell: true,
|
||||
cwd: this.validCommand.startDir,
|
||||
signal: context.abortSignal,
|
||||
env: {
|
||||
}
|
||||
});
|
||||
|
||||
context.setProgress(0, "playing");
|
||||
|
||||
spawnGame.stdout.on('data', data => console.log(data));
|
||||
spawnGame.on('close', (code) =>
|
||||
{
|
||||
resolve(code);
|
||||
});
|
||||
spawnGame.on('error', e =>
|
||||
{
|
||||
console.error(e);
|
||||
reject(e);
|
||||
});
|
||||
|
||||
game = spawnGame;
|
||||
}
|
||||
else if (this.validCommand.metadata.emulatorBin)
|
||||
{
|
||||
this.saveFolderPath = commandArgs.savesPath;
|
||||
|
||||
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug });
|
||||
|
||||
// We have full control over launching integrated emulators better to use bun spawn
|
||||
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs.args], {
|
||||
cwd: this.validCommand.startDir,
|
||||
signal: context.abortSignal,
|
||||
env: {
|
||||
}
|
||||
});
|
||||
|
||||
context.setProgress(0, "playing");
|
||||
|
||||
if (commandArgs.savesPath && await fs.exists(commandArgs.savesPath))
|
||||
{
|
||||
const savesWatcher = watch(commandArgs.savesPath, { recursive: true, signal: context.abortSignal });
|
||||
console.log("Starting To Watch", commandArgs.savesPath, "for save file changes");
|
||||
savesWatcher.on('change', (type, filename) =>
|
||||
{
|
||||
if (typeof filename === 'string')
|
||||
{
|
||||
console.log("Save File Changed", filename);
|
||||
this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath! });
|
||||
}
|
||||
});
|
||||
|
||||
bunGame.exited.then(() =>
|
||||
{
|
||||
savesWatcher.close();
|
||||
console.log("Closing Save File Watching for", commandArgs.savesPath);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
spawnGame.stdout.on('data', data => console.log(data));
|
||||
spawnGame.on('close', (code) =>
|
||||
bunGame.exited.then(e =>
|
||||
{
|
||||
resolve(true);
|
||||
}).catch(e =>
|
||||
{
|
||||
console.error(e);
|
||||
reject(e);
|
||||
});
|
||||
|
||||
game = bunGame;
|
||||
|
||||
} else
|
||||
{
|
||||
resolve(code);
|
||||
});
|
||||
spawnGame.on('error', e =>
|
||||
{
|
||||
console.error(e);
|
||||
reject(e);
|
||||
});
|
||||
|
||||
game = spawnGame;
|
||||
}
|
||||
else if (this.validCommand.metadata.emulatorBin)
|
||||
{
|
||||
// We have full control over launching integrated emulators better to use bun spawn
|
||||
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs], {
|
||||
cwd: this.validCommand.startDir,
|
||||
signal: context.abortSignal,
|
||||
env: {
|
||||
}
|
||||
});
|
||||
|
||||
context.abortSignal.addEventListener('abort', reject);
|
||||
|
||||
bunGame.exited.then(e =>
|
||||
{
|
||||
resolve(true);
|
||||
}).catch(e =>
|
||||
{
|
||||
console.error(e);
|
||||
reject(e);
|
||||
});
|
||||
game = bunGame;
|
||||
} else
|
||||
{
|
||||
reject(new Error("No Emulator Bin"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeGame = {
|
||||
process: game,
|
||||
name: gameInfo?.name ?? "Unknown",
|
||||
gameId: this.gameId,
|
||||
source: this.gameSource,
|
||||
sourceId: this.gameSourceId,
|
||||
command: this.validCommand
|
||||
};
|
||||
|
||||
const updatePlayed = async (id: FrontEndId, source?: string, sourceId?: string) =>
|
||||
{
|
||||
if (this.gameId.source === 'local')
|
||||
{
|
||||
await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(this.gameId.id)));
|
||||
reject(new Error("No Emulator Bin"));
|
||||
return;
|
||||
}
|
||||
|
||||
await plugins.hooks.games.updatePlayed.promise({ source: source ?? id.source, id: sourceId ?? id.id }).then(v =>
|
||||
{
|
||||
if (v) events.emit('notification', { message: "Updated Last Played", type: 'success' });
|
||||
});
|
||||
};
|
||||
|
||||
updatePlayed(this.gameId, this.gameSource, this.gameSourceId);
|
||||
this.activeGame = {
|
||||
process: game,
|
||||
name: gameInfo?.name ?? "Unknown",
|
||||
gameId: this.gameId,
|
||||
source: this.gameSource,
|
||||
sourceId: this.gameSourceId,
|
||||
command: this.validCommand
|
||||
};
|
||||
} catch (e)
|
||||
{
|
||||
context.abort(e);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
/* Old spawn lanching, cases issues, needs to be ran as shell
|
||||
|
||||
const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]);
|
||||
const game = setActiveGame({
|
||||
process: Bun.spawn({
|
||||
cmd,
|
||||
env: {
|
||||
...process.env
|
||||
},
|
||||
onExit (subprocess, exitCode, signalCode, error)
|
||||
{
|
||||
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
|
||||
},
|
||||
stdin: "ignore",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
}),
|
||||
name: localGame?.name ?? "Unknown",
|
||||
gameId: validCommand.gameId,
|
||||
command: validCommand.command.command
|
||||
});
|
||||
|
||||
await game.process.exited;
|
||||
if (game.process.exitCode && game.process.exitCode > 0)
|
||||
{
|
||||
return status('Internal Server Error');
|
||||
}*/
|
||||
await this.postPlay({ platformSlug: gameInfo?.platformSlug });
|
||||
}
|
||||
|
||||
exposeData ()
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export default class CEMUIntegration implements PluginType
|
|||
{
|
||||
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
||||
{
|
||||
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] };
|
||||
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] };
|
||||
});
|
||||
|
||||
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||
|
|
@ -29,7 +29,7 @@ export default class CEMUIntegration implements PluginType
|
|||
args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`);
|
||||
}
|
||||
|
||||
return args;
|
||||
return { args, savesPath: savesPath };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -3,17 +3,18 @@ import { config } from "@/bun/api/app";
|
|||
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||
import path from 'node:path';
|
||||
import desc from './package.json';
|
||||
import { ensureDir } from "fs-extra";
|
||||
import { getSavePaths, getType } from "./utils";
|
||||
|
||||
export default class DOLPHINIntegration implements PluginType
|
||||
{
|
||||
emulator = 'DOLPHIN';
|
||||
|
||||
|
||||
load (ctx: PluginContextType)
|
||||
{
|
||||
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
||||
{
|
||||
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "resolution", "fullscreen", "states"] };
|
||||
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "resolution", "fullscreen", "saves"] };
|
||||
});
|
||||
|
||||
ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||
|
|
@ -51,14 +52,33 @@ export default class DOLPHINIntegration implements PluginType
|
|||
args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`);
|
||||
args.push(`--config=Dolphin.General.WiiSDCardSyncFolder=${path.join(savesPath, 'WiiSDSync')}`);
|
||||
args.push(`--config=Dolphin.GBA.SavesPath=${path.join(savesPath, 'GBA')}`);
|
||||
args.push(`--config=Dolphin.Core.GCIFolderAPath=${path.join(savesPath, 'GC')}`);
|
||||
|
||||
if (!ctx.dryRun)
|
||||
{
|
||||
await ensureDir(path.join(savesPath, 'GC', "JAP"));
|
||||
await ensureDir(path.join(savesPath, 'GC', "EUR"));
|
||||
await ensureDir(path.join(savesPath, 'GC', "USA"));
|
||||
}
|
||||
|
||||
let finalSavesPath: string | undefined = undefined;
|
||||
if (ctx.autoValidCommand.metadata.romPath)
|
||||
{
|
||||
args.push("--batch");
|
||||
args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`);
|
||||
|
||||
finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder;
|
||||
}
|
||||
|
||||
return args;
|
||||
return { args, savesPath: finalSavesPath };
|
||||
});
|
||||
|
||||
ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) =>
|
||||
{
|
||||
if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath)
|
||||
{
|
||||
validChangedSaveFiles.push(...await getSavePaths(command.metadata.romPath, saveFolderPath, command.metadata.emulatorDir));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
EnableCheats = false
|
||||
EnablePINE = false
|
||||
EnableWideScreenPatches = {{ENABLE_WIDESCREEN}}
|
||||
EnableNoInterlacingPatches = false
|
||||
EnableRecordingTools = true
|
||||
EnableGameFixes = true
|
||||
|
|
@ -168,7 +167,6 @@ linear_present_mode = 1
|
|||
deinterlace_mode = 0
|
||||
OsdScale = 100
|
||||
Renderer = 14
|
||||
upscale_multiplier = {{UPSCALE_MULTIPLIER}}
|
||||
mipmap_hw = -1
|
||||
accurate_blending_unit = 1
|
||||
crc_hack_level = -1
|
||||
|
|
@ -371,18 +369,6 @@ Multitap2_Slot4_Enable = false
|
|||
Multitap2_Slot4_Filename = Mcd-Multitap2-Slot04.ps2
|
||||
|
||||
|
||||
[Folders]
|
||||
Bios = {{{BIOS_PATH}}}
|
||||
Snapshots = {{{SNAPSHOTS_PATH}}}
|
||||
SaveStates = {{{SAVE_STATES_PATH}}}
|
||||
MemoryCards = {{{MEMORY_CARDS_PATH}}}
|
||||
Cache = {{{CACHE_PATH}}}
|
||||
Covers = {{{COVERS_PATH}}}
|
||||
Logs = logs
|
||||
Textures = {{{TEXTURES_PATH}}}
|
||||
Videos = videos
|
||||
|
||||
|
||||
[InputSources]
|
||||
Keyboard = true
|
||||
Mouse = true
|
||||
|
|
@ -488,6 +474,3 @@ RDown = SDL-1/+RightY
|
|||
RLeft = SDL-1/-RightX
|
||||
LargeMotor = SDL-1/LargeMotor
|
||||
SmallMotor = SDL-1/SmallMotor
|
||||
|
||||
[GameList]
|
||||
RecursivePaths = {{{RECURSIVE_PATHS}}}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
|
||||
import { config } from "@/bun/api/app";
|
||||
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||
import configFile from './PCSX2.ini' with { type: 'file' };
|
||||
import Mustache from 'mustache';
|
||||
import defaultConfig from './PCSX2.ini' with { type: 'file' };
|
||||
import path from 'node:path';
|
||||
import { ensureDir } from "fs-extra";
|
||||
import desc from './package.json';
|
||||
import ini from 'ini';
|
||||
|
||||
export default class PCSX2Integration implements PluginType
|
||||
{
|
||||
|
|
@ -15,7 +15,7 @@ export default class PCSX2Integration implements PluginType
|
|||
{
|
||||
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
||||
{
|
||||
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"];
|
||||
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"];
|
||||
|
||||
if (ctx.source?.type === 'store')
|
||||
{
|
||||
|
|
@ -47,7 +47,16 @@ export default class PCSX2Integration implements PluginType
|
|||
|
||||
if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun)
|
||||
{
|
||||
const configFileContents = await Bun.file(configFile).text();
|
||||
let pscx2Path = '';
|
||||
if (process.platform === 'win32')
|
||||
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis');
|
||||
else
|
||||
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis');
|
||||
|
||||
const configPath = path.join(pscx2Path, 'PCSX2.ini');
|
||||
const existingConfigFile = Bun.file(configPath);
|
||||
|
||||
const configFile = await existingConfigFile.exists() ? ini.parse(await existingConfigFile.text()) : ini.parse(await Bun.file(defaultConfig).text());
|
||||
|
||||
const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
|
||||
const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator);
|
||||
|
|
@ -67,28 +76,37 @@ export default class PCSX2Integration implements PluginType
|
|||
CACHE_PATH: path.join(storageFolder, 'cache'),
|
||||
COVERS_PATH: path.join(storageFolder, 'covers'),
|
||||
TEXTURES_PATH: path.join(storageFolder, 'textures'),
|
||||
VIDEOS_PATH: path.join(storageFolder, 'videos'),
|
||||
LOGS_PATH: path.join(storageFolder, 'logs'),
|
||||
RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'),
|
||||
};
|
||||
|
||||
await Promise.all(Object.values(paths).map(p => ensureDir(p)));
|
||||
|
||||
const view = {
|
||||
...paths,
|
||||
ENABLE_WIDESCREEN: config.get('emulatorWidescreen'),
|
||||
ASPECT_RATIO: config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2",
|
||||
UPSCALE_MULTIPLIER: resolutionMapping[config.get('emulatorResolution')] ?? 1
|
||||
};
|
||||
configFile.EmuCore ??= {};
|
||||
configFile.EmuCore.EnableWideScreenPatches = config.get('emulatorWidescreen');
|
||||
configFile['EmuCore/GS'] ??= {};
|
||||
configFile['EmuCore/GS'].AspectRatio = config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2";
|
||||
configFile['EmuCore/GS'].upscale_multiplier = resolutionMapping[config.get('emulatorResolution')] ?? 1;
|
||||
configFile.Folders ??= {};
|
||||
configFile.Folders.Bios = paths.BIOS_PATH;
|
||||
configFile.Folders.Snapshots = paths.SNAPSHOTS_PATH;
|
||||
configFile.Folders.SaveStates = paths.SAVE_STATES_PATH;
|
||||
configFile.Folders.MemoryCards = paths.MEMORY_CARDS_PATH;
|
||||
configFile.Folders.Cache = paths.CACHE_PATH;
|
||||
configFile.Folders.Covers = paths.COVERS_PATH;
|
||||
configFile.Folders.Textures = paths.TEXTURES_PATH;
|
||||
configFile.Folders.Videos = paths.VIDEOS_PATH;
|
||||
configFile.Folders.Logs = paths.LOGS_PATH;
|
||||
configFile.GameList ??= {};
|
||||
configFile.GameList.RecursivePaths = paths.RECURSIVE_PATHS;
|
||||
|
||||
let pscx2Path = '';
|
||||
if (process.platform === 'win32')
|
||||
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis');
|
||||
else
|
||||
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis');
|
||||
await Bun.write(configPath, ini.stringify(configFile));
|
||||
|
||||
await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view));
|
||||
return { args, savesPath: paths.MEMORY_CARDS_PATH };
|
||||
}
|
||||
|
||||
return args;
|
||||
return { args };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -96,7 +96,6 @@ HardwareTransform = True
|
|||
SoftwareSkinning = True
|
||||
TextureFiltering = 1
|
||||
BufferFiltering = 1
|
||||
InternalResolution = {{RESOLUTION}}
|
||||
AndroidHwScale = 1
|
||||
HighQualityDepth = 1
|
||||
FrameSkip = 0
|
||||
|
|
@ -109,7 +108,6 @@ AnisotropyLevel = 4
|
|||
VertexDecCache = False
|
||||
TextureBackoffCache = False
|
||||
TextureSecondaryCache = False
|
||||
FullScreen = {{FULLSCREEN}}
|
||||
FullScreenMulti = False
|
||||
SmallDisplayZoomType = 2
|
||||
SmallDisplayOffsetX = 0.500000
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import path from "node:path";
|
|||
import Mustache from "mustache";
|
||||
import { ensureDir } from "fs-extra";
|
||||
import { homedir } from "node:os";
|
||||
import ini from 'ini';
|
||||
|
||||
export default class PPSSPPIntegration implements PluginType
|
||||
{
|
||||
|
|
@ -27,7 +28,7 @@ export default class PPSSPPIntegration implements PluginType
|
|||
|
||||
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
||||
{
|
||||
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"];
|
||||
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"];
|
||||
|
||||
if (ctx.source?.type === 'store')
|
||||
{
|
||||
|
|
@ -59,18 +60,18 @@ export default class PPSSPPIntegration implements PluginType
|
|||
|
||||
if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun)
|
||||
{
|
||||
let confPath: string | undefined = undefined;
|
||||
let controlsPath: string | undefined = undefined;
|
||||
let defaultConfigPath: string | undefined = undefined;
|
||||
let defaultControlsPath: string | undefined = undefined;
|
||||
|
||||
switch (process.platform)
|
||||
{
|
||||
case "win32":
|
||||
confPath = configFilePathWin32;
|
||||
controlsPath = configControlsFilePathWin32;
|
||||
defaultConfigPath = configFilePathWin32;
|
||||
defaultControlsPath = configControlsFilePathWin32;
|
||||
break;
|
||||
case 'linux':
|
||||
confPath = configFilePathLinux;
|
||||
controlsPath = configControlsFilePathLinux;
|
||||
defaultConfigPath = configFilePathLinux;
|
||||
defaultControlsPath = configControlsFilePathLinux;
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -87,29 +88,36 @@ export default class PPSSPPIntegration implements PluginType
|
|||
|
||||
ensureDir(ppssppPath);
|
||||
|
||||
if (confPath)
|
||||
if (defaultConfigPath)
|
||||
{
|
||||
const resolutionMapping = {
|
||||
"720p": "2",
|
||||
"1080p": "4",
|
||||
"1440p": "6",
|
||||
"4k": "8"
|
||||
const resolutionMapping: Record<string, number> = {
|
||||
"720p": 2,
|
||||
"1080p": 4,
|
||||
"1440p": 6,
|
||||
"4k": 8
|
||||
};
|
||||
const configFileContents = await Bun.file(confPath).text();
|
||||
await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {
|
||||
RESOLUTION: resolutionMapping[config.get('emulatorResolution')] ?? 0,
|
||||
FULLSCREEN: config.get('launchInFullscreen') ? "True" : "False"
|
||||
}));
|
||||
const configPath = path.join(ppssppPath, 'ppsspp.ini');
|
||||
const configFile = Bun.file(configPath);
|
||||
|
||||
const ppssppConfig = await configFile.exists() ? ini.parse(await configFile.text()) : ini.parse(await Bun.file(defaultConfigPath).text());
|
||||
|
||||
ppssppConfig.Graphics ??= {};
|
||||
ppssppConfig.Graphics.InternalResolution = resolutionMapping[config.get('emulatorResolution')] ?? 0;
|
||||
ppssppConfig.Graphics.FullScreen = config.get('launchInFullscreen');
|
||||
|
||||
await Bun.write(configPath, ini.stringify(ppssppConfig));
|
||||
}
|
||||
|
||||
if (controlsPath)
|
||||
if (defaultControlsPath)
|
||||
{
|
||||
const controlsFileContents = await Bun.file(controlsPath).text();
|
||||
const controlsFileContents = await Bun.file(defaultControlsPath).text();
|
||||
await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {}));
|
||||
}
|
||||
|
||||
return { args, savesPath: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") };
|
||||
}
|
||||
|
||||
return args;
|
||||
return { args };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -96,7 +96,6 @@ HardwareTransform = True
|
|||
SoftwareSkinning = True
|
||||
TextureFiltering = 1
|
||||
BufferFiltering = 1
|
||||
InternalResolution = {{RESOLUTION}}
|
||||
AndroidHwScale = 1
|
||||
HighQualityDepth = 1
|
||||
FrameSkip = 0
|
||||
|
|
@ -109,7 +108,6 @@ AnisotropyLevel = 4
|
|||
VertexDecCache = False
|
||||
TextureBackoffCache = False
|
||||
TextureSecondaryCache = False
|
||||
FullScreen = {{FULLSCREEN}}
|
||||
FullScreenMulti = False
|
||||
SmallDisplayZoomType = 2
|
||||
SmallDisplayOffsetX = 0.500000
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export default class XEMUIntegration implements PluginType
|
|||
{
|
||||
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
||||
{
|
||||
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] };
|
||||
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] };
|
||||
});
|
||||
|
||||
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||
|
|
@ -68,7 +68,7 @@ export default class XEMUIntegration implements PluginType
|
|||
}
|
||||
|
||||
|
||||
return args;
|
||||
return { args };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 toml, { TomlTable } from 'smol-toml';
|
||||
import fs from 'node:fs/promises';
|
||||
import { getXeniaSavePaths } from "./utils";
|
||||
|
||||
export default class XENIAIntegration implements PluginType
|
||||
{
|
||||
|
|
@ -17,7 +18,8 @@ export default class XENIAIntegration implements PluginType
|
|||
await Bun.write(path.join(ctx.path, "portable.txt"), "");
|
||||
}
|
||||
|
||||
async handleLaunch (ctx: Parameters<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[] = [];
|
||||
|
||||
|
|
@ -28,6 +30,13 @@ export default class XENIAIntegration implements PluginType
|
|||
|
||||
const configPath = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, `${ctx.autoValidCommand.emulator}.toml`);
|
||||
|
||||
args.push(`--config`, configPath);
|
||||
|
||||
if (config.get('launchInFullscreen'))
|
||||
{
|
||||
args.push(`--fullscreen`);
|
||||
}
|
||||
|
||||
if (!ctx.dryRun)
|
||||
{
|
||||
await ensureDir(path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!));
|
||||
|
|
@ -47,28 +56,30 @@ export default class XENIAIntegration implements PluginType
|
|||
configFile.Display.fullscreen = config.get('launchInFullscreen');
|
||||
configFile.GPU.draw_resolution_scale_x = resolutionMapping[config.get('emulatorResolution')] ?? 1;
|
||||
configFile.GPU.draw_resolution_scale_y = resolutionMapping[config.get('emulatorResolution')] ?? 1;
|
||||
await ensureDir(path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!));
|
||||
const savesPath = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!);
|
||||
await ensureDir(savesPath);
|
||||
configFile.Storage.content_root = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!);
|
||||
configFile.Storage.storage_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'config');
|
||||
configFile.Storage.cache_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'cache');
|
||||
|
||||
await Bun.write(configPath, toml.stringify(configFile));
|
||||
|
||||
let finalSavesPath: string | undefined = undefined;
|
||||
if (ctx.autoValidCommand.metadata.romPath)
|
||||
{
|
||||
finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath);
|
||||
}
|
||||
|
||||
return { args, savesPath: finalSavesPath };
|
||||
};
|
||||
|
||||
args.push(`--config`, configPath);
|
||||
|
||||
if (config.get('launchInFullscreen'))
|
||||
{
|
||||
args.push(`--fullscreen`);
|
||||
}
|
||||
|
||||
return args;
|
||||
return { args };
|
||||
}
|
||||
|
||||
handleEmulatorLaunchSupport (ctx: Parameters<typeof GameflowHooks.prototype.games.emulatorLaunchSupport.callAsync>['0']):
|
||||
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)
|
||||
|
|
@ -78,5 +89,14 @@ export default class XENIAIntegration implements PluginType
|
|||
|
||||
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, this.handleLaunch);
|
||||
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulatorEdge }, this.handleLaunch);
|
||||
|
||||
ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) =>
|
||||
{
|
||||
if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath)
|
||||
{
|
||||
const files = await fs.readdir(saveFolderPath, { recursive: true });
|
||||
validChangedSaveFiles.push(...files.map(f => ({ subPath: f, cwd: saveFolderPath, shared: false } satisfies SaveFileChange)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||
import desc from './package.json';
|
||||
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
|
||||
import { config } from "@/bun/api/app";
|
||||
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
|
||||
import { config, events } from "@/bun/api/app";
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { hashFile, isSteamDeckGameMode } from "@/bun/utils";
|
||||
|
|
@ -11,6 +11,7 @@ import { CACHE_KEYS, getOrCached } from "@/bun/api/cache";
|
|||
import secrets from "@/bun/api/secrets";
|
||||
import { getAuthToken } from "@/clients/romm/core/auth.gen";
|
||||
import { client } from "@/clients/romm/client.gen";
|
||||
import { validateGameSource } from "@/bun/api/games/services/statusService";
|
||||
|
||||
export default class RommIntegration implements PluginType
|
||||
{
|
||||
|
|
@ -75,7 +76,9 @@ export default class RommIntegration implements PluginType
|
|||
missing: rom.missing_from_fs,
|
||||
genres: rom.metadatum.genres,
|
||||
companies: rom.metadatum.companies,
|
||||
release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined
|
||||
release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined,
|
||||
imdb_id: rom.igdb_id ?? undefined,
|
||||
ra_id: rom.ra_id ?? undefined
|
||||
};
|
||||
|
||||
const userData = await getCurrentUserApiUsersMeGet();
|
||||
|
|
@ -371,12 +374,143 @@ export default class RommIntegration implements PluginType
|
|||
}
|
||||
});
|
||||
|
||||
ctx.hooks.games.updatePlayed.tapPromise(desc.name, async ({ source, id }) =>
|
||||
ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderPath, setProgress }) =>
|
||||
{
|
||||
if (source !== 'romm') return false;
|
||||
if (source !== 'romm') return;
|
||||
if (saveFolderPath)
|
||||
{
|
||||
setProgress(0, "saves");
|
||||
|
||||
const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } });
|
||||
if (saveFiles.error)
|
||||
{
|
||||
console.error(saveFiles.error);
|
||||
} else
|
||||
{
|
||||
for (let i = 0; i < saveFiles.data.slots.length; i++)
|
||||
{
|
||||
const slot = saveFiles.data.slots[i];
|
||||
const savePath = path.join(saveFolderPath, slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`);
|
||||
if (await fs.exists(savePath))
|
||||
{
|
||||
const existingSaveSync = await fs.stat(savePath);
|
||||
const updatedAtTime = new Date(slot.latest.updated_at).getTime();
|
||||
|
||||
if (existingSaveSync.mtimeMs > updatedAtTime)
|
||||
{
|
||||
console.log("Newer save file", savePath, "Server:", new Date(slot.latest.updated_at), "Local:", existingSaveSync.mtime);
|
||||
// Newer file
|
||||
continue;
|
||||
} else if (updatedAtTime === existingSaveSync.mtimeMs)
|
||||
{
|
||||
//TODO: do checksum comparison when that works on romm
|
||||
console.log("Same save file", savePath);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const auth = await this.getAuthToken();
|
||||
const headers: Record<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 } });
|
||||
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 }) =>
|
||||
|
|
|
|||
|
|
@ -223,14 +223,28 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
|
|||
}
|
||||
} catch (error)
|
||||
{
|
||||
if (error !== 'cancel')
|
||||
try
|
||||
{
|
||||
console.error(error);
|
||||
if (error instanceof Event)
|
||||
{
|
||||
if (error.target instanceof AbortSignal)
|
||||
{
|
||||
|
||||
} else
|
||||
{
|
||||
console.error(error);
|
||||
}
|
||||
} else
|
||||
{
|
||||
console.error(error);
|
||||
this.events.emit('error', { id: this.m_id, job: this, error });
|
||||
this.error = error;
|
||||
}
|
||||
} finally
|
||||
{
|
||||
this.m_promise.resolve(undefined);
|
||||
}
|
||||
|
||||
this.events.emit('error', { id: this.m_id, job: this, error });
|
||||
this.error = error;
|
||||
this.m_promise.resolve(undefined);
|
||||
} finally
|
||||
{
|
||||
this.running = false;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue