feat First implementation of plugins system
feat: Added PCSX2 integration feat: Revamped UI a bit made it look better on light mode
This commit is contained in:
parent
d85268fad7
commit
a78e75335f
95 changed files with 2639 additions and 1259 deletions
|
|
@ -8,21 +8,21 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
|||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import Conf from "conf";
|
||||
import projectPackage from '~/package.json';
|
||||
import { Notification, SettingsSchema, SettingsType } from "@shared/constants";
|
||||
import { SettingsSchema, SettingsType } from "@shared/constants";
|
||||
import { client } from "@clients/romm/client.gen";
|
||||
import * as schema from "@schema/app";
|
||||
import cacheSchema from "@schema/cache";
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
import { login, logout } from "./auth";
|
||||
import os from 'node:os';
|
||||
import { ActiveGame } from "../types/types";
|
||||
import EventEmitter from "node:events";
|
||||
import { ErrorLike } from "bun";
|
||||
import { appPath, getErrorMessage } from "../utils";
|
||||
import { appPath } from "../utils";
|
||||
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
||||
import { ensureDir } from "fs-extra";
|
||||
import UpdateStoreJob from "./jobs/update-store";
|
||||
import { getStoreFolder } from "./store/services/gamesService";
|
||||
import { PluginManager } from "./plugins/plugin-manager";
|
||||
import registerPlugins from "./plugins/register-plugins";
|
||||
|
||||
export const config = new Conf<SettingsType>({
|
||||
projectName: projectPackage.name,
|
||||
|
|
@ -31,7 +31,7 @@ export const config = new Conf<SettingsType>({
|
|||
defaults: SettingsSchema.parse({
|
||||
downloadPath: path.join(os.homedir(), "gameflow"),
|
||||
windowSize: { width: 1280, height: 800 }
|
||||
} satisfies SettingsType),
|
||||
}),
|
||||
});
|
||||
export const customEmulators = new Conf<Record<string, string>>({
|
||||
projectName: projectPackage.name,
|
||||
|
|
@ -64,21 +64,9 @@ export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
|
|||
export const taskQueue = new TaskQueue();
|
||||
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
|
||||
await login();
|
||||
export let activeGame: ActiveGame | undefined;
|
||||
export function setActiveGame (game: ActiveGame)
|
||||
{
|
||||
if (activeGame) throw new Error("Only one active game at a time");
|
||||
return activeGame = game;
|
||||
}
|
||||
export const plugins = new PluginManager();
|
||||
registerPlugins(plugins);
|
||||
export const events = new EventEmitter<AppEventMap>();
|
||||
events.addListener('activegameexit', ({ error }) =>
|
||||
{
|
||||
activeGame = undefined;
|
||||
if (error)
|
||||
{
|
||||
events.emit('notification', { message: getErrorMessage(error), type: 'error' });
|
||||
}
|
||||
});
|
||||
config.onDidChange('downloadPath', () => reloadDatabase());
|
||||
taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
|
||||
|
||||
|
|
@ -110,9 +98,3 @@ export async function reloadDatabase ()
|
|||
`);
|
||||
}
|
||||
|
||||
interface AppEventMap
|
||||
{
|
||||
activegameexit: [{ source: string, id: string, subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
|
||||
exitapp: [];
|
||||
notification: [Notification];
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { Drive } from "@/shared/constants";
|
||||
import si from 'systeminformation';
|
||||
import fs from 'node:fs';
|
||||
import os from "node:os";
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { activeGame, config, db, emulatorsDb, events, taskQueue } from "../app";
|
||||
import { and, eq, getTableColumns, inArray, not, or, sql } from "drizzle-orm";
|
||||
import z, { number } from "zod";
|
||||
import { config, db, emulatorsDb, taskQueue } from "../app";
|
||||
import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm";
|
||||
import z from "zod";
|
||||
import * as schema from "@schema/app";
|
||||
import fs from "node:fs/promises";
|
||||
import { FrontEndEmulator, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedEmulator, GameListFilterSchema, SERVER_URL } from "@shared/constants";
|
||||
import { getCurrentUserApiUsersMeGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
|
||||
import { GameListFilterSchema, SERVER_URL } from "@shared/constants";
|
||||
import { getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm";
|
||||
import { InstallJob } from "../jobs/install-job";
|
||||
import path from "node:path";
|
||||
import { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameDetailed, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
|
||||
import { convertLocalToFrontend, convertRomToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
|
||||
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
|
||||
import { errorToResponse } from "elysia/adapter/bun/handler";
|
||||
import { getEmulatorsForSystem, launchCommand } from "./services/launchGameService";
|
||||
import { getErrorMessage, SeededRandom, shuffleInPlace } from "@/bun/utils";
|
||||
import { getErrorMessage, SeededRandom } from "@/bun/utils";
|
||||
import { defaultFormats, defaultPlugins } from 'jimp';
|
||||
import { createJimp } from "@jimp/core";
|
||||
import webp from "@jimp/wasm-webp";
|
||||
import * as emulatorSchema from '@schema/emulators';
|
||||
import { buildStoreFrontendEmulatorSystems, extractStoreGameSourceId, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGame, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
|
||||
import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
|
||||
import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService";
|
||||
import { use } from "react";
|
||||
import { CACHE_KEYS, getOrCached } from "../cache";
|
||||
import { host } from "@/bun/utils/host";
|
||||
import { LaunchGameJob } from "../jobs/launch-game-job";
|
||||
|
||||
// A custom jimp that supports webp
|
||||
const Jimp = createJimp({
|
||||
|
|
@ -31,23 +31,30 @@ const Jimp = createJimp({
|
|||
|
||||
async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height, noBlur }: { blur?: number, width?: number, height?: number; noBlur?: boolean; })
|
||||
{
|
||||
if (blur && !noBlur)
|
||||
{
|
||||
const jimp = await Jimp.read(img);
|
||||
if (width)
|
||||
{
|
||||
jimp.resize({ w: width, h: height });
|
||||
}
|
||||
if (height)
|
||||
{
|
||||
jimp.resize({ w: width, h: height });
|
||||
}
|
||||
if (blur)
|
||||
{
|
||||
jimp.blur(blur);
|
||||
}
|
||||
|
||||
return jimp.getBuffer('image/png');
|
||||
try
|
||||
{
|
||||
if ((blur && !noBlur) || width || height)
|
||||
{
|
||||
const jimp = await Jimp.read(img);
|
||||
|
||||
if (blur && !noBlur)
|
||||
{
|
||||
jimp.blur(blur);
|
||||
}
|
||||
|
||||
if (width)
|
||||
{
|
||||
jimp.resize({ w: width, h: height });
|
||||
} else if (height)
|
||||
{
|
||||
jimp.resize({ w: width, h: height });
|
||||
}
|
||||
return jimp.getBuffer('image/webp');
|
||||
}
|
||||
} catch (e)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
if (typeof img === 'string')
|
||||
|
|
@ -267,7 +274,7 @@ export default new Elysia()
|
|||
{
|
||||
return {
|
||||
name: 'EMULATORJS',
|
||||
validSource: { binPath: SERVER_URL(host), type: 'js', exists: true },
|
||||
validSource: { binPath: SERVER_URL(host), type: 'embedded', exists: true },
|
||||
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
|
||||
systems: [],
|
||||
gameCount: 0
|
||||
|
|
@ -312,11 +319,11 @@ export default new Elysia()
|
|||
})
|
||||
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
||||
{
|
||||
if (!taskQueue.findJob(`install-rom-${source}-${id}`, InstallJob))
|
||||
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
|
||||
{
|
||||
if (source === 'romm' || source === 'store')
|
||||
{
|
||||
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id, { dryRun: true }));
|
||||
taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, id, { dryRun: true }));
|
||||
return status(200);
|
||||
}
|
||||
|
||||
|
|
@ -359,7 +366,7 @@ export default new Elysia()
|
|||
if (validCommand)
|
||||
{
|
||||
// launch command waits for the game to exit, we don't want that.
|
||||
launchCommand(validCommand, source, id, validCommands.gameId);
|
||||
await launchCommand(validCommand, source, id, validCommands.gameId);
|
||||
return { type: 'application', command: null };
|
||||
} else
|
||||
{
|
||||
|
|
@ -380,13 +387,10 @@ export default new Elysia()
|
|||
})
|
||||
.post("/stop", async ({ }) =>
|
||||
{
|
||||
if (activeGame)
|
||||
const job = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob);
|
||||
if (job)
|
||||
{
|
||||
events.emit('activegameexit', {
|
||||
source: 'local', id: String(activeGame.gameId),
|
||||
exitCode: null,
|
||||
signalCode: null
|
||||
});
|
||||
job.abort('cancel');
|
||||
}
|
||||
})
|
||||
.get('/emulatorjs/data/cores/*', async ({ params }) =>
|
||||
|
|
@ -564,6 +568,9 @@ export default new Elysia()
|
|||
if (g.platform_slug === sourceData.platform_slug)
|
||||
rank += 1;
|
||||
|
||||
if (g.id.source === 'local')
|
||||
rank -= 0.2;
|
||||
|
||||
if (g.metadata)
|
||||
{
|
||||
if (g.metadata.companies instanceof Array && g.metadata.companies.some((c: string) => sourceCompaniesSet.has(c)))
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRo
|
|||
import z from "zod";
|
||||
import { and, count, eq, getTableColumns, not } from "drizzle-orm";
|
||||
import { db } from "../app";
|
||||
import { FrontEndPlatformType } from "@shared/constants";
|
||||
import * as schema from "@schema/app";
|
||||
import { CACHE_KEYS, getOrCached } from "../cache";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,103 +3,23 @@ import { which } from 'bun';
|
|||
import fs from 'node:fs/promises';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import * as schema from '@schema/emulators';
|
||||
import * as appSchema from "@schema/app";
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { activeGame, config, customEmulators, db, emulatorsDb, events, setActiveGame } from '../../app';
|
||||
import { config, customEmulators, emulatorsDb, taskQueue } from '../../app';
|
||||
import os from 'node:os';
|
||||
import { $ } from 'bun';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
|
||||
import { CommandEntry, EmulatorSourceType } from '@/shared/constants';
|
||||
import { cores } from '../../emulatorjs/emulatorjs';
|
||||
import { LaunchGameJob } from '../../jobs/launch-game-job';
|
||||
|
||||
export const varRegex = /%([^%]+)%/g;
|
||||
export const assignRegex = /(%\w+%)=(\S+) /g;
|
||||
|
||||
export async function launchCommand (validCommand: { command: string, startDir?: string; }, source: string, sourceId: string, id: number)
|
||||
export async function launchCommand (validCommand: CommandEntry, source: string, sourceId: string, id: number)
|
||||
{
|
||||
if (activeGame && activeGame.process?.killed === false)
|
||||
if (taskQueue.hasActiveOfType(LaunchGameJob))
|
||||
{
|
||||
throw new Error(`${activeGame.name} currently running`);
|
||||
throw new Error(`${id} currently running`);
|
||||
}
|
||||
|
||||
const localGame = await db.query.games.findFirst({
|
||||
where: eq(appSchema.games.id, id), columns: {
|
||||
name: true,
|
||||
source_id: true,
|
||||
source: true
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) =>
|
||||
{
|
||||
const game = spawn(validCommand.command, {
|
||||
shell: true,
|
||||
cwd: validCommand.startDir
|
||||
});
|
||||
game.stdout.on('data', data => console.log(data));
|
||||
game.on('close', (code) =>
|
||||
{
|
||||
events.emit('activegameexit', { source, id: sourceId, exitCode: code, signalCode: null });
|
||||
resolve(code);
|
||||
});
|
||||
game.on('error', e =>
|
||||
{
|
||||
console.error(e);
|
||||
events.emit('notification', { message: e.message, type: 'error' });
|
||||
reject(e);
|
||||
});
|
||||
|
||||
setActiveGame({
|
||||
process: game,
|
||||
name: localGame?.name ?? "Unknown",
|
||||
gameId: id,
|
||||
command: validCommand
|
||||
});
|
||||
|
||||
function updateRommProps (id: number)
|
||||
{
|
||||
updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } });
|
||||
events.emit('notification', { message: "Updated Last Played", type: 'success' });
|
||||
}
|
||||
|
||||
if (source === 'romm')
|
||||
{
|
||||
updateRommProps(Number(sourceId));
|
||||
}
|
||||
else if (localGame?.source === 'romm' && localGame.source_id)
|
||||
{
|
||||
updateRommProps(Number(localGame.source_id));
|
||||
}
|
||||
});
|
||||
|
||||
/* 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');
|
||||
}*/
|
||||
taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -277,11 +197,14 @@ export async function getValidLaunchCommands (data: {
|
|||
let validExec = execs.find(e => e.exists);
|
||||
|
||||
emulator = emulatorName;
|
||||
return [[value, validExec ? validExec.path : undefined], ['%EMUDIR%', validExec ? escapeWindowsArg(path.dirname(validExec.path)) : undefined]];
|
||||
return [
|
||||
[value, validExec ? validExec.binPath : undefined] as [string, string | undefined],
|
||||
[`%EMUSOURCE%`, validExec?.type] as [string, string | undefined],
|
||||
['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined]];
|
||||
}
|
||||
|
||||
const key = value[0].substring(1, value.length - 1);
|
||||
return [[value, process.env[key]]];
|
||||
return [[value, process.env[key]] as [string, string | undefined]];
|
||||
}));
|
||||
|
||||
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
|
||||
|
|
@ -311,7 +234,13 @@ export async function getValidLaunchCommands (data: {
|
|||
label: label ?? undefined,
|
||||
command: formattedCommand,
|
||||
startDir,
|
||||
valid: !invalid, emulator
|
||||
valid: !invalid, emulator,
|
||||
emulatorSource: vars['%EMUSOURCE%'] as any,
|
||||
metadata: {
|
||||
romPath: staticVars['%ROM%'],
|
||||
emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1],
|
||||
emulatorDir: vars['%EMUDIR%']
|
||||
}
|
||||
} satisfies CommandEntry;
|
||||
}));
|
||||
|
||||
|
|
@ -328,7 +257,7 @@ export async function findExecsByName (emulatorName: string)
|
|||
return findExecs(emulatorName, emulator);
|
||||
}
|
||||
|
||||
export function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): EmulatorSourceType | undefined
|
||||
export function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): EmulatorSourceEntryType | undefined
|
||||
{
|
||||
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
|
||||
const storeExecName = emulator?.systempath.find(name => existsSync(path.join(storeEmulatorFolder, name)));
|
||||
|
|
@ -342,7 +271,7 @@ export function findStoreEmulatorExec (id: string, emulator?: { systempath: stri
|
|||
|
||||
export async function findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
|
||||
{
|
||||
const execs: EmulatorSourceType[] = [];
|
||||
const execs: EmulatorSourceEntryType[] = [];
|
||||
|
||||
if (customEmulators.has(id))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { GameInstallProgress, GameStatusType, RPC_URL, } from "@shared/constants";
|
||||
import { activeGame, config, customEmulators, db, events, taskQueue } from "../../app";
|
||||
import { RPC_URL, } from "@shared/constants";
|
||||
import { config, customEmulators, db, taskQueue } from "../../app";
|
||||
import { getValidLaunchCommands } from "./launchGameService";
|
||||
import * as schema from '@schema/app';
|
||||
import { eq } from "drizzle-orm";
|
||||
|
|
@ -7,14 +7,13 @@ import { getErrorMessage } from "@/bun/utils";
|
|||
import { getLocalGameMatch } from "./utils";
|
||||
import { getRomApiRomsIdGet } from "@/clients/romm";
|
||||
import fs from 'node:fs/promises';
|
||||
import { ErrorLike } from "elysia/universal";
|
||||
import { getStoreGameFromId } from "../../store/services/gamesService";
|
||||
import { cores } from "../../emulatorjs/emulatorjs";
|
||||
import { host } from "@/bun/utils/host";
|
||||
import Elysia from "elysia";
|
||||
import z from "zod";
|
||||
import data from "@emulators";
|
||||
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
|
||||
import { LaunchGameJob } from "../../jobs/launch-game-job";
|
||||
|
||||
class CommandSearchError extends Error
|
||||
{
|
||||
|
|
@ -62,7 +61,10 @@ export async function getValidLaunchCommandsForGame (source: string, id: string)
|
|||
label: "Emulator JS",
|
||||
command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`,
|
||||
valid: true,
|
||||
emulator: 'EMULATORJS'
|
||||
emulator: 'EMULATORJS',
|
||||
metadata: {
|
||||
romPath: gameUrl
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -111,19 +113,19 @@ export default function buildStatusResponse ()
|
|||
{
|
||||
if (data === 'cancel')
|
||||
{
|
||||
const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob);
|
||||
const activeTask = taskQueue.findJob(InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }), InstallJob);
|
||||
activeTask?.abort('cancel');
|
||||
}
|
||||
},
|
||||
async open (ws)
|
||||
{
|
||||
sendLatests();
|
||||
const installJobId = InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id });
|
||||
|
||||
async function sendLatests ()
|
||||
{
|
||||
if (ws.readyState > 1) return;
|
||||
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source), columns: { id: true } });
|
||||
const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob);
|
||||
const activeTask = taskQueue.findJob(InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }), InstallJob);
|
||||
if (activeTask)
|
||||
{
|
||||
if (activeTask.status === 'queued')
|
||||
|
|
@ -134,7 +136,7 @@ export default function buildStatusResponse ()
|
|||
ws.send({ status: activeTask.state as InstallJobStates, progress: activeTask.progress });
|
||||
}
|
||||
|
||||
} else if (activeGame && activeGame.gameId === localGame?.id)
|
||||
} else if (taskQueue.hasActiveOfType(LaunchGameJob))
|
||||
{
|
||||
ws.send({ status: 'playing', details: 'Playing' });
|
||||
}
|
||||
|
|
@ -189,7 +191,7 @@ export default function buildStatusResponse ()
|
|||
}
|
||||
|
||||
const dispose: Function[] = [];
|
||||
const handleActiveExit = async (data: { error?: ErrorLike; }) =>
|
||||
const handleActiveExit = async (data: { error?: unknown; }) =>
|
||||
{
|
||||
if (data.error)
|
||||
{
|
||||
|
|
@ -200,38 +202,41 @@ export default function buildStatusResponse ()
|
|||
}
|
||||
await sendLatests();
|
||||
};
|
||||
events.on('activegameexit', handleActiveExit);
|
||||
dispose.push(() => events.off('activegameexit', handleActiveExit));
|
||||
dispose.push(taskQueue.on('progress', (data) =>
|
||||
{
|
||||
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
|
||||
if (data.id === installJobId)
|
||||
{
|
||||
|
||||
ws.send({ status: data.job.state as InstallJobStates, progress: data.progress });
|
||||
}
|
||||
}));
|
||||
dispose.push(taskQueue.on('queued', (data) =>
|
||||
{
|
||||
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
|
||||
if (data.id === installJobId)
|
||||
{
|
||||
ws.send({ status: 'queued' });
|
||||
}
|
||||
}));
|
||||
dispose.push(taskQueue.on('completed', (data) =>
|
||||
dispose.push(taskQueue.on('ended', (data) =>
|
||||
{
|
||||
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
|
||||
if (data.id === installJobId)
|
||||
{
|
||||
ws.send({ status: 'refresh' });
|
||||
} else if (data.job.job instanceof LaunchGameJob)
|
||||
{
|
||||
handleActiveExit({});
|
||||
}
|
||||
}));
|
||||
dispose.push(taskQueue.on('error', (data) =>
|
||||
{
|
||||
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
|
||||
if (data.id === installJobId)
|
||||
{
|
||||
ws.send({
|
||||
status: 'error',
|
||||
error: getErrorMessage(data.error)
|
||||
});
|
||||
} else if (data.job.job instanceof LaunchGameJob)
|
||||
{
|
||||
handleActiveExit({ error: data.error });
|
||||
}
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import path from "node:path";
|
|||
import { config, db, emulatorsDb } from "../../app";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as schema from "@schema/app";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, StoreGameType } from "@shared/constants";
|
||||
import { StoreGameType } from "@shared/constants";
|
||||
import { DetailedRomSchema, getCurrentUserApiUsersMeGet, getRomApiRomsIdGet, SimpleRomSchema } from "@clients/romm";
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
import romm from "@/mainview/scripts/queries/romm";
|
||||
import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService";
|
||||
import { isSteamDeck, isSteamDeckGameMode } from "@/bun/utils";
|
||||
|
||||
export async function calculateSize (installPath: string | null)
|
||||
{
|
||||
|
|
@ -29,9 +29,10 @@ export function getLocalGameMatch (id: string, source: string)
|
|||
|
||||
export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType
|
||||
{
|
||||
const steamDeck = isSteamDeckGameMode();
|
||||
const game: FrontEndGameType = {
|
||||
id: { id: String(rom.id), source: 'romm' },
|
||||
path_cover: `/api/romm/image/romm${rom.path_cover_large}`,
|
||||
path_cover: `/api/romm/image/romm${steamDeck ? rom.path_cover_small : rom.path_cover_large}`,
|
||||
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
|
||||
updated_at: new Date(rom.updated_at),
|
||||
slug: rom.slug,
|
||||
|
|
|
|||
6
src/bun/api/hooks/app.ts
Normal file
6
src/bun/api/hooks/app.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { GameHooks } from "./emulators";
|
||||
|
||||
export class GameflowHooks
|
||||
{
|
||||
games = new GameHooks();
|
||||
}
|
||||
21
src/bun/api/hooks/emulators.ts
Normal file
21
src/bun/api/hooks/emulators.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { SyncBailHook, AsyncSeriesHook, SyncWaterfallHook, AsyncSeriesBailHook } from 'tapable';
|
||||
|
||||
export class GameHooks
|
||||
{
|
||||
/** override the launch command for an emulator
|
||||
* @param ctx.autoValidCommands The auto generated command for example based on the ES-DE listing
|
||||
* @param ctx.emulator The emulator ID if any
|
||||
* @param ctx.game.source The source of the game
|
||||
* @param ctx.game.sourceId The ID of the source. This could be for example the ROMM ID the game was
|
||||
* @returns The argument list to be used when running the emulator.
|
||||
* If no emulator bin in the command entry is found the actual command will be used as the bin.
|
||||
*/
|
||||
emulatorLaunch = new AsyncSeriesBailHook<[ctx: {
|
||||
autoValidCommand: CommandEntry;
|
||||
game: {
|
||||
source: string;
|
||||
sourceId: string;
|
||||
id: number;
|
||||
};
|
||||
}], string[] | undefined>(['ctx']);
|
||||
}
|
||||
85
src/bun/api/jobs/bios-download-job.ts
Normal file
85
src/bun/api/jobs/bios-download-job.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import z from "zod";
|
||||
import { IJob, JobContext } from "../task-queue";
|
||||
import { CACHE_KEYS, getOrCached } from "../cache";
|
||||
import { config } from "../app";
|
||||
import { getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet } from "@/clients/romm";
|
||||
import fs from 'node:fs/promises';
|
||||
import { hashFile, simulateProgress } from "@/bun/utils";
|
||||
import { Downloader, FileEntry } from "@/bun/utils/downloader";
|
||||
import path from 'node:path';
|
||||
import { ensureDir } from "fs-extra";
|
||||
import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
|
||||
|
||||
export class BiosDownloadJob implements IJob<z.infer<typeof BiosDownloadJob.dataSchema>, "download">
|
||||
{
|
||||
static id = "bios-download-job" as const;
|
||||
static dataSchema = z.object({ emulator: z.string() });
|
||||
static query = (q: { id: string; }) => `${BiosDownloadJob.id}-${q.id}`;
|
||||
group: string = "bios-download";
|
||||
emulator: string;
|
||||
dryRun: boolean;
|
||||
|
||||
constructor(emulator: string, init?: { dryRun?: boolean; })
|
||||
{
|
||||
this.emulator = emulator;
|
||||
this.dryRun = init?.dryRun ?? false;
|
||||
}
|
||||
|
||||
async start (context: JobContext<IJob<never, "download">, never, "download">)
|
||||
{
|
||||
const allRommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data);
|
||||
|
||||
const emulator = await getStoreEmulatorPackage(this.emulator);
|
||||
if (!emulator) throw new Error("Could Not Find Emulator");
|
||||
|
||||
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
||||
|
||||
const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
|
||||
await ensureDir(biosFolder);
|
||||
const rommPlatforms = systems.filter(s => s.romm_slug).map(s => allRommPlatforms.find(p => p.slug == s.romm_slug)).filter(r => !!r);
|
||||
|
||||
const firmwaresToDownload: FileEntry[] = [];
|
||||
|
||||
for (const rommPlatform of rommPlatforms)
|
||||
{
|
||||
const firmwares = await getPlatformFirmwareApiFirmwareGet({ query: { platform_id: rommPlatform.id } }).then(d => d.data);
|
||||
if (firmwares)
|
||||
{
|
||||
for (const firmware of firmwares)
|
||||
{
|
||||
const firmwarePath = path.join(biosFolder, firmware.file_name);
|
||||
const exists = await fs.exists(firmwarePath);
|
||||
|
||||
if (exists && await hashFile(firmwarePath, 'sha1'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
firmwaresToDownload.push({ file_name: firmware.file_name, file_path: '', url: new URL(`http://romm.simeonradivoev.com/api/firmware/${firmware.id}/content/${encodeURIComponent(firmware.file_name)}`) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.dryRun)
|
||||
{
|
||||
await simulateProgress((p) => context.setProgress(p, 'download'), context.abortSignal);
|
||||
} else
|
||||
{
|
||||
const downloader = new Downloader('bios-download', firmwaresToDownload, biosFolder, {
|
||||
signal: context.abortSignal,
|
||||
onProgress (stats)
|
||||
{
|
||||
context.setProgress(stats.progress, "download");
|
||||
},
|
||||
});
|
||||
|
||||
await downloader.start();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
exposeData ()
|
||||
{
|
||||
return { emulator: this.emulator };
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import _7z from '7zip-min';
|
|||
import fs from "node:fs/promises";
|
||||
import { Downloader } from "@/bun/utils/downloader";
|
||||
import { move } from "fs-extra";
|
||||
import { simulateProgress } from "@/bun/utils";
|
||||
|
||||
type EmulatorDownloadStates = "download" | "extract";
|
||||
|
||||
|
|
@ -20,11 +21,13 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
|
|||
emulator: string;
|
||||
downloadSource: string;
|
||||
emulatorPackage?: EmulatorPackageType;
|
||||
dryRun?: boolean;
|
||||
|
||||
constructor(emulator: string, downloadSource: string)
|
||||
constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; })
|
||||
{
|
||||
this.emulator = emulator;
|
||||
this.downloadSource = downloadSource;
|
||||
this.dryRun = init?.dryRun ?? false;
|
||||
}
|
||||
|
||||
async start (context: JobContext<EmulatorDownloadJob, z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>)
|
||||
|
|
@ -56,44 +59,53 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
|
|||
throw new Error("Invalid Download Type");
|
||||
}
|
||||
|
||||
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
|
||||
const downloader = new Downloader(this.emulator,
|
||||
[{ url: new URL(downloadUrl), file_name: path.basename(downloadUrl), file_path: this.emulator }],
|
||||
tmpFolder,
|
||||
{
|
||||
onProgress (stats)
|
||||
{
|
||||
context.setProgress(stats.progress, 'download');
|
||||
},
|
||||
});
|
||||
|
||||
const destinationPaths = await downloader.start();
|
||||
if (destinationPaths)
|
||||
if (this.dryRun)
|
||||
{
|
||||
if (isArchive)
|
||||
{
|
||||
if (await downloader.start() && destinationPaths[0])
|
||||
await simulateProgress(p => context.setProgress(p, "download"), context.abortSignal);
|
||||
await simulateProgress(p => context.setProgress(p, "extract"), context.abortSignal);
|
||||
} else
|
||||
{
|
||||
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
|
||||
const downloader = new Downloader(this.emulator,
|
||||
[{ url: new URL(downloadUrl), file_name: path.basename(downloadUrl), file_path: this.emulator }],
|
||||
tmpFolder,
|
||||
{
|
||||
let destinationPath = destinationPaths[0];
|
||||
await _7z.unpack(destinationPath, emulatorsFolder);
|
||||
await fs.rm(destinationPath, { recursive: true });
|
||||
|
||||
// check if 1 root folder we need to get rid of
|
||||
const contents = await fs.readdir(emulatorsFolder);
|
||||
if (contents.length === 1)
|
||||
signal: context.abortSignal,
|
||||
onProgress (stats)
|
||||
{
|
||||
const stat = await fs.stat(path.join(emulatorsFolder, contents[0]));
|
||||
if (stat.isDirectory())
|
||||
context.setProgress(stats.progress, 'download');
|
||||
},
|
||||
});
|
||||
|
||||
const destinationPaths = await downloader.start();
|
||||
if (destinationPaths)
|
||||
{
|
||||
if (isArchive)
|
||||
{
|
||||
if (await downloader.start() && destinationPaths[0])
|
||||
{
|
||||
let destinationPath = destinationPaths[0];
|
||||
await _7z.unpack(destinationPath, emulatorsFolder);
|
||||
await fs.rm(destinationPath, { recursive: true });
|
||||
|
||||
// check if 1 root folder we need to get rid of
|
||||
const contents = await fs.readdir(emulatorsFolder);
|
||||
if (contents.length === 1)
|
||||
{
|
||||
console.log("Found 1 root folder, using that instead");
|
||||
const tmpEmulatorsFolder = `${emulatorsFolder} (1)`;
|
||||
await move(path.join(emulatorsFolder, contents[0]), tmpEmulatorsFolder, { overwrite: true });
|
||||
await move(tmpEmulatorsFolder, emulatorsFolder, { overwrite: true });
|
||||
const stat = await fs.stat(path.join(emulatorsFolder, contents[0]));
|
||||
if (stat.isDirectory())
|
||||
{
|
||||
console.log("Found 1 root folder, using that instead");
|
||||
const tmpEmulatorsFolder = `${emulatorsFolder} (1)`;
|
||||
await move(path.join(emulatorsFolder, contents[0]), tmpEmulatorsFolder, { overwrite: true });
|
||||
await move(tmpEmulatorsFolder, emulatorsFolder, { overwrite: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
exposeData ()
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ import * as schema from "@schema/app";
|
|||
import * as emulatorSchema from "@schema/emulators";
|
||||
import path from 'node:path';
|
||||
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet, PlatformSchema } from "@clients/romm";
|
||||
import { config, db, emulatorsDb, events, jar } from "../app";
|
||||
import { config, db, emulatorsDb, events } from "../app";
|
||||
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
|
||||
import * as igdb from 'ts-igdb-client';
|
||||
import secrets from "../secrets";
|
||||
import { hashFile } from "@/bun/utils";
|
||||
import { hashFile, simulateProgress } from "@/bun/utils";
|
||||
import { Downloader } from "@/bun/utils/downloader";
|
||||
import { sleep } from "bun";
|
||||
import _7z from '7zip-min';
|
||||
import z from "zod";
|
||||
|
||||
interface JobConfig
|
||||
{
|
||||
|
|
@ -25,11 +25,14 @@ export type InstallJobStates = 'download' | 'extract';
|
|||
|
||||
export class InstallJob implements IJob<never, InstallJobStates>
|
||||
{
|
||||
static id = "install-job" as const;
|
||||
static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`;
|
||||
static dataSchema = z.never();
|
||||
public gameId: string;
|
||||
public source: string;
|
||||
public sourceId: string;
|
||||
public config?: JobConfig;
|
||||
static id = "install-job" as const;
|
||||
|
||||
public group = InstallJob.id;
|
||||
|
||||
constructor(id: string, source: string, sourceId: string, config?: JobConfig)
|
||||
|
|
@ -53,7 +56,6 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
|||
file_name: string;
|
||||
size?: number;
|
||||
}[] = [];
|
||||
let cookie: string = '';
|
||||
let screenshotUrls: string[];
|
||||
let coverUrl: string;
|
||||
let rommPlatform: PlatformSchema | undefined;
|
||||
|
|
@ -115,7 +117,6 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
|||
}));
|
||||
|
||||
files.push(...rommFiles.filter(f => f !== undefined));
|
||||
cookie = await jar.getCookieString(config.get('rommAddress') ?? '');
|
||||
break;
|
||||
case 'store':
|
||||
const game = await getStoreGameFromId(this.gameId);
|
||||
|
|
@ -295,12 +296,7 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
|||
});
|
||||
} else
|
||||
{
|
||||
for (let i = 0; i < 10; i++)
|
||||
{
|
||||
cx.setProgress(i * 10, "download");
|
||||
if (cx.abortSignal.aborted) return;
|
||||
await sleep(1000);
|
||||
}
|
||||
await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Elysia from "elysia";
|
||||
import z, { _ZodType, ZodAny, ZodObject, ZodTypeAny } from "zod";
|
||||
import z, { _ZodType } from "zod";
|
||||
import { taskQueue } from "../app";
|
||||
import { LoginJob } from "./login-job";
|
||||
import TwitchLoginJob from "./twitch-login-job";
|
||||
|
|
@ -7,22 +7,27 @@ import UpdateStoreJob from "./update-store";
|
|||
import { EmulatorDownloadJob } from "./emulator-download-job";
|
||||
import { getErrorMessage } from "@/bun/utils";
|
||||
import { IJob } from "../task-queue";
|
||||
import { LaunchGameJob } from "./launch-game-job";
|
||||
import { BiosDownloadJob } from "./bios-download-job";
|
||||
import { InstallJob } from "./install-job";
|
||||
|
||||
function registerJob<
|
||||
const Path extends string,
|
||||
const Schema extends ZodTypeAny,
|
||||
const Schema extends z.ZodTypeAny,
|
||||
const Query extends z.ZodTypeAny,
|
||||
const States extends string,
|
||||
T extends IJob<z.infer<Schema>, States>
|
||||
> (_job: { id: Path; dataSchema: Schema; } & (new (...args: any[]) => T))
|
||||
> (_job: { id: Path; dataSchema: Schema; query?: (q: any) => string; } & (new (...args: any[]) => T))
|
||||
{
|
||||
return new Elysia().ws(_job.id, {
|
||||
body: z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('cancel') })
|
||||
]),
|
||||
query: z.record(z.string(), z.any()),
|
||||
response: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(['data', 'started', 'progress']),
|
||||
status: z.string(),
|
||||
state: z.string().optional(),
|
||||
progress: z.number(),
|
||||
data: _job.dataSchema
|
||||
}),
|
||||
|
|
@ -31,44 +36,45 @@ function registerJob<
|
|||
]),
|
||||
open (ws)
|
||||
{
|
||||
const job = taskQueue.findJob(_job.id, _job);
|
||||
const jobId = (_job.query ? _job.query(ws.data.query) : _job.id);
|
||||
const job = taskQueue.findJob(jobId, _job);
|
||||
if (job)
|
||||
{
|
||||
ws.send({ type: 'data', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
|
||||
}
|
||||
|
||||
(ws.data as any).cleanup = [
|
||||
taskQueue.on('started', ({ id, job }) =>
|
||||
{
|
||||
if (id === _job.id)
|
||||
if (id === jobId)
|
||||
{
|
||||
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
ws.send({ type: 'started', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('progress', ({ id, job }) =>
|
||||
{
|
||||
if (id === _job.id)
|
||||
if (id === jobId)
|
||||
{
|
||||
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
ws.send({ type: 'progress', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('completed', ({ id, job }) =>
|
||||
{
|
||||
if (id === _job.id)
|
||||
if (id === jobId)
|
||||
{
|
||||
ws.send({ type: 'completed', data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('ended', ({ id, job }) =>
|
||||
{
|
||||
if (id === _job.id)
|
||||
if (id === jobId)
|
||||
{
|
||||
ws.send({ type: 'ended', data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('error', ({ id, error }) =>
|
||||
{
|
||||
if (id === _job.id)
|
||||
if (id === jobId)
|
||||
{
|
||||
ws.send({ type: 'error', error: getErrorMessage(error) });
|
||||
}
|
||||
|
|
@ -83,7 +89,8 @@ function registerJob<
|
|||
{
|
||||
if (message.type === 'cancel')
|
||||
{
|
||||
taskQueue.findJob(_job.id, _job)?.abort('cancel');
|
||||
const jobId = (_job.query ? _job.query(this.query) : _job.id);
|
||||
taskQueue.findJob(jobId, _job)?.abort('cancel');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -93,4 +100,7 @@ export const jobs = new Elysia({ prefix: '/api/jobs' })
|
|||
.use(registerJob(LoginJob))
|
||||
.use(registerJob(TwitchLoginJob))
|
||||
.use(registerJob(UpdateStoreJob))
|
||||
.use(registerJob(LaunchGameJob))
|
||||
.use(registerJob(BiosDownloadJob))
|
||||
.use(registerJob(InstallJob))
|
||||
.use(registerJob(EmulatorDownloadJob));
|
||||
|
|
|
|||
121
src/bun/api/jobs/launch-game-job.ts
Normal file
121
src/bun/api/jobs/launch-game-job.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import z from "zod";
|
||||
import { IJob, JobContext } from "../task-queue";
|
||||
import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema";
|
||||
import { db, events, plugins } from "../app";
|
||||
import * as appSchema from "@schema/app";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { spawn } from 'node:child_process';
|
||||
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
|
||||
|
||||
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">
|
||||
{
|
||||
static id = "launch-game" as const;
|
||||
static dataSchema = z.optional(ActiveGameSchema);
|
||||
group = "launch-game";
|
||||
activeGame?: ActiveGameType;
|
||||
gameId: number;
|
||||
validCommand: CommandEntry;
|
||||
gameSource: string;
|
||||
gameSourceId: string;
|
||||
|
||||
constructor(gameId: number, validCommand: CommandEntry, source: string, sourceId: string)
|
||||
{
|
||||
this.gameId = gameId;
|
||||
this.validCommand = validCommand;
|
||||
this.gameSource = source;
|
||||
this.gameSourceId = sourceId;
|
||||
}
|
||||
|
||||
async start (context: JobContext<IJob<ActiveGameType, "playing">, ActiveGameType, "playing">)
|
||||
{
|
||||
const localGame = await db.query.games.findFirst({
|
||||
where: eq(appSchema.games.id, this.gameId), columns: {
|
||||
name: true,
|
||||
source_id: true,
|
||||
source: true
|
||||
}
|
||||
});
|
||||
|
||||
const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({
|
||||
autoValidCommand: this.validCommand,
|
||||
game: { source: this.gameSource, sourceId: this.gameSourceId, id: this.gameId }
|
||||
});
|
||||
const command = commandArgs ? this.validCommand.metadata.emulatorBin ?? this.validCommand.command : this.validCommand.command;
|
||||
|
||||
await new Promise((resolve, reject) =>
|
||||
{
|
||||
const game = spawn(command, commandArgs, {
|
||||
shell: true,
|
||||
cwd: this.validCommand.startDir,
|
||||
signal: context.abortSignal
|
||||
});
|
||||
|
||||
game.stdout.on('data', data => console.log(data));
|
||||
game.on('close', (code) =>
|
||||
{
|
||||
resolve(code);
|
||||
});
|
||||
game.on('error', e =>
|
||||
{
|
||||
console.error(e);
|
||||
reject(e);
|
||||
});
|
||||
|
||||
this.activeGame = {
|
||||
process: game,
|
||||
name: localGame?.name ?? "Unknown",
|
||||
gameId: this.gameId,
|
||||
command: this.validCommand
|
||||
};
|
||||
|
||||
function updateRommProps (id: number)
|
||||
{
|
||||
updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } });
|
||||
events.emit('notification', { message: "Updated Last Played", type: 'success' });
|
||||
}
|
||||
|
||||
if (this.gameSource === 'romm')
|
||||
{
|
||||
updateRommProps(Number(this.gameSourceId));
|
||||
}
|
||||
else if (localGame?.source === 'romm' && localGame.source_id)
|
||||
{
|
||||
updateRommProps(Number(localGame.source_id));
|
||||
}
|
||||
});
|
||||
|
||||
/* 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');
|
||||
}*/
|
||||
}
|
||||
|
||||
exposeData ()
|
||||
{
|
||||
return this.activeGame;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { IJob, JobBase, JobContext, JobContextFromClass } from "../task-queue";
|
||||
import { IJob, JobContext } from "../task-queue";
|
||||
import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
|
||||
import { host, localIp } from "@/bun/utils/host";
|
||||
import cors from "@elysiajs/cors";
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { getStoreRootFolder } from "../store/services/gamesService";
|
|||
import { STORE_VERSION } from "@/shared/constants";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import z from "zod";
|
||||
|
||||
export default class UpdateStoreJob implements IJob<never, never>
|
||||
{
|
||||
static id = "update-store" as const;
|
||||
static dataSchema = z.never();
|
||||
packageName: string;
|
||||
registry: URL;
|
||||
storeVersion: string;
|
||||
|
|
@ -27,7 +29,8 @@ export default class UpdateStoreJob implements IJob<never, never>
|
|||
const storeFolder = getStoreRootFolder();
|
||||
await ensureDir(storeFolder);
|
||||
|
||||
await Bun.spawn([process.execPath, "install", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], {
|
||||
console.log("Updating Store");
|
||||
const proc = Bun.spawn([process.execPath, "add", `${this.packageName}@${this.storeVersion}`, "--production", "--registry", this.registry.href], {
|
||||
cwd: storeFolder,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
|
|
@ -35,6 +38,13 @@ export default class UpdateStoreJob implements IJob<never, never>
|
|||
BUN_BE_BUN: "1",
|
||||
BUN_INSTALL_CACHE_DIR: tempCache
|
||||
}
|
||||
}).exited;
|
||||
});
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
console.log(stdout);
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
if (stderr)
|
||||
console.error(stderr);
|
||||
await proc.exited;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Notification } from '@shared/constants';
|
||||
|
||||
import { events } from './app';
|
||||
|
||||
export default function buildNotificationsStream ()
|
||||
|
|
@ -10,7 +10,7 @@ export default function buildNotificationsStream ()
|
|||
{
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
function enqueue (data: Notification, event?: 'notification')
|
||||
function enqueue (data: FrontendNotification, event?: 'notification')
|
||||
{
|
||||
const evntString = event ? `event: ${event}\n` : '';
|
||||
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
|
||||
|
|
@ -30,7 +30,7 @@ export default function buildNotificationsStream ()
|
|||
}
|
||||
}, 15000);
|
||||
|
||||
const notificationHandler = (notification: Notification) =>
|
||||
const notificationHandler = (notification: FrontendNotification) =>
|
||||
{
|
||||
enqueue(notification, 'notification');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,493 @@
|
|||
[UI]
|
||||
SettingsVersion = 1
|
||||
InhibitScreensaver = true
|
||||
ConfirmShutdown = true
|
||||
StartPaused = false
|
||||
PauseOnFocusLoss = false
|
||||
StartFullscreen = false
|
||||
DoubleClickTogglesFullscreen = true
|
||||
HideMouseCursor = false
|
||||
RenderToSeparateWindow = false
|
||||
HideMainWindowWhenRunning = false
|
||||
DisableWindowResize = false
|
||||
Theme = darkfusion
|
||||
SetupWizardIncomplete = false
|
||||
|
||||
|
||||
[EmuCore]
|
||||
CdvdVerboseReads = false
|
||||
CdvdDumpBlocks = false
|
||||
CdvdShareWrite = false
|
||||
EnablePatches = true
|
||||
EnableCheats = false
|
||||
EnablePINE = false
|
||||
EnableWideScreenPatches = false
|
||||
EnableNoInterlacingPatches = false
|
||||
EnableRecordingTools = true
|
||||
EnableGameFixes = true
|
||||
SaveStateOnShutdown = false
|
||||
EnableDiscordPresence = false
|
||||
InhibitScreensaver = true
|
||||
ConsoleToStdio = false
|
||||
HostFs = false
|
||||
BackupSavestate = true
|
||||
SavestateZstdCompression = true
|
||||
McdEnableEjection = true
|
||||
McdFolderAutoManage = true
|
||||
WarnAboutUnsafeSettings = true
|
||||
GzipIsoIndexTemplate = $(f).pindex.tmp
|
||||
BlockDumpSaveDirectory =
|
||||
EnableFastBoot = true
|
||||
|
||||
|
||||
[EmuCore/Speedhacks]
|
||||
EECycleRate = 0
|
||||
EECycleSkip = 0
|
||||
fastCDVD = false
|
||||
IntcStat = true
|
||||
WaitLoop = true
|
||||
vuFlagHack = true
|
||||
vuThread = true
|
||||
vu1Instant = true
|
||||
|
||||
|
||||
[EmuCore/CPU]
|
||||
FPU.DenormalsAreZero = true
|
||||
FPU.FlushToZero = true
|
||||
FPU.Roundmode = 3
|
||||
AffinityControlMode = 0
|
||||
VU0.DenormalsAreZero = true
|
||||
VU0.FlushToZero = true
|
||||
VU0.Roundmode = 3
|
||||
VU1.DenormalsAreZero = true
|
||||
VU1.FlushToZero = true
|
||||
VU1.Roundmode = 3
|
||||
|
||||
|
||||
[EmuCore/CPU/Recompiler]
|
||||
EnableEE = true
|
||||
EnableIOP = true
|
||||
EnableEECache = false
|
||||
EnableVU0 = true
|
||||
EnableVU1 = true
|
||||
EnableFastmem = true
|
||||
PauseOnTLBMiss = false
|
||||
vu0Overflow = true
|
||||
vu0ExtraOverflow = false
|
||||
vu0SignOverflow = false
|
||||
vu0Underflow = false
|
||||
vu1Overflow = true
|
||||
vu1ExtraOverflow = false
|
||||
vu1SignOverflow = false
|
||||
vu1Underflow = false
|
||||
fpuOverflow = true
|
||||
fpuExtraOverflow = false
|
||||
fpuFullMode = false
|
||||
|
||||
|
||||
[EmuCore/GS]
|
||||
VsyncQueueSize = 2
|
||||
FrameLimitEnable = true
|
||||
VsyncEnable = 0
|
||||
FramerateNTSC = 59.94
|
||||
FrameratePAL = 50
|
||||
SyncToHostRefreshRate = false
|
||||
AspectRatio = Auto 4:3/3:2
|
||||
FMVAspectRatioSwitch = Off
|
||||
ScreenshotSize = 0
|
||||
ScreenshotFormat = 0
|
||||
ScreenshotQuality = 50
|
||||
StretchY = 100
|
||||
CropLeft = 0
|
||||
CropTop = 0
|
||||
CropRight = 0
|
||||
CropBottom = 0
|
||||
pcrtc_antiblur = true
|
||||
disable_interlace_offset = false
|
||||
pcrtc_offsets = false
|
||||
pcrtc_overscan = false
|
||||
IntegerScaling = false
|
||||
UseDebugDevice = false
|
||||
UseBlitSwapChain = false
|
||||
disable_shader_cache = false
|
||||
DisableDualSourceBlend = false
|
||||
DisableFramebufferFetch = false
|
||||
DisableThreadedPresentation = false
|
||||
SkipDuplicateFrames = false
|
||||
OsdShowMessages = true
|
||||
OsdShowSpeed = false
|
||||
OsdShowFPS = false
|
||||
OsdShowCPU = false
|
||||
OsdShowGPU = false
|
||||
OsdShowResolution = false
|
||||
OsdShowGSStats = false
|
||||
OsdShowIndicators = true
|
||||
OsdShowSettings = false
|
||||
OsdShowInputs = false
|
||||
OsdShowFrameTimes = false
|
||||
HWSpinGPUForReadbacks = false
|
||||
HWSpinCPUForReadbacks = false
|
||||
paltex = false
|
||||
autoflush_sw = true
|
||||
preload_frame_with_gs_data = false
|
||||
mipmap = true
|
||||
UserHacks = false
|
||||
UserHacks_align_sprite_X = false
|
||||
UserHacks_AutoFlush = false
|
||||
UserHacks_CPU_FB_Conversion = false
|
||||
UserHacks_ReadTCOnClose = false
|
||||
UserHacks_DisableDepthSupport = false
|
||||
UserHacks_DisablePartialInvalidation = false
|
||||
UserHacks_Disable_Safe_Features = false
|
||||
UserHacks_merge_pp_sprite = false
|
||||
UserHacks_WildHack = false
|
||||
UserHacks_TextureInsideRt = 0
|
||||
UserHacks_TargetPartialInvalidation = false
|
||||
UserHacks_EstimateTextureRegion = false
|
||||
fxaa = false
|
||||
ShadeBoost = false
|
||||
dump = false
|
||||
save = false
|
||||
savef = false
|
||||
savet = false
|
||||
savez = false
|
||||
DumpReplaceableTextures = false
|
||||
DumpReplaceableMipmaps = false
|
||||
DumpTexturesWithFMVActive = false
|
||||
DumpDirectTextures = true
|
||||
DumpPaletteTextures = true
|
||||
LoadTextureReplacements = false
|
||||
LoadTextureReplacementsAsync = true
|
||||
PrecacheTextureReplacements = false
|
||||
EnableVideoCapture = true
|
||||
EnableVideoCaptureParameters = false
|
||||
VideoCaptureAutoResolution = false
|
||||
EnableAudioCapture = true
|
||||
EnableAudioCaptureParameters = false
|
||||
linear_present_mode = 1
|
||||
deinterlace_mode = 0
|
||||
OsdScale = 100
|
||||
Renderer = 14
|
||||
upscale_multiplier = 1
|
||||
mipmap_hw = -1
|
||||
accurate_blending_unit = 1
|
||||
crc_hack_level = -1
|
||||
filter = 2
|
||||
texture_preloading = 2
|
||||
GSDumpCompression = 2
|
||||
HWDownloadMode = 0
|
||||
CASMode = 0
|
||||
CASSharpness = 50
|
||||
dithering_ps2 = 2
|
||||
MaxAnisotropy = 0
|
||||
extrathreads = 3
|
||||
extrathreads_height = 4
|
||||
TVShader = 0
|
||||
UserHacks_SkipDraw_Start = 0
|
||||
UserHacks_SkipDraw_End = 0
|
||||
UserHacks_Half_Bottom_Override = -1
|
||||
UserHacks_HalfPixelOffset = 0
|
||||
UserHacks_round_sprite_offset = 0
|
||||
UserHacks_TCOffsetX = 0
|
||||
UserHacks_TCOffsetY = 0
|
||||
UserHacks_CPUSpriteRenderBW = 0
|
||||
UserHacks_CPUCLUTRender = 0
|
||||
UserHacks_GPUTargetCLUTMode = 0
|
||||
TriFilter = -1
|
||||
OverrideTextureBarriers = -1
|
||||
OverrideGeometryShaders = -1
|
||||
ShadeBoost_Brightness = 50
|
||||
ShadeBoost_Contrast = 50
|
||||
ShadeBoost_Saturation = 50
|
||||
png_compression_level = 1
|
||||
saven = 0
|
||||
savel = 5000
|
||||
CaptureContainer = mp4
|
||||
VideoCaptureCodec =
|
||||
VideoCaptureParameters =
|
||||
AudioCaptureCodec =
|
||||
AudioCaptureParameters =
|
||||
VideoCaptureBitrate = 6000
|
||||
VideoCaptureWidth = 640
|
||||
VideoCaptureHeight = 480
|
||||
AudioCaptureBitrate = 160
|
||||
Adapter = (Default)
|
||||
HWDumpDirectory =
|
||||
SWDumpDirectory =
|
||||
|
||||
|
||||
[SPU2/Debug]
|
||||
Global_Enable = false
|
||||
Show_Messages = false
|
||||
Show_Messages_Key_On_Off = false
|
||||
Show_Messages_Voice_Off = false
|
||||
Show_Messages_DMA_Transfer = false
|
||||
Show_Messages_AutoDMA = false
|
||||
Show_Messages_Overruns = false
|
||||
Show_Messages_CacheStats = false
|
||||
Log_Register_Access = false
|
||||
Log_DMA_Transfers = false
|
||||
Log_WAVE_Output = false
|
||||
Dump_Info = false
|
||||
Dump_Memory = false
|
||||
Dump_Regs = false
|
||||
|
||||
|
||||
[SPU2/Mixing]
|
||||
FinalVolume = 100
|
||||
|
||||
|
||||
[SPU2/Output]
|
||||
OutputModule = cubeb
|
||||
BackendName =
|
||||
DeviceName =
|
||||
Latency = 60
|
||||
OutputLatency = 20
|
||||
OutputLatencyMinimal = false
|
||||
SynchMode = 0
|
||||
SpeakerConfiguration = 0
|
||||
DplDecodingLevel = 0
|
||||
|
||||
|
||||
[DEV9/Eth]
|
||||
EthEnable = false
|
||||
EthApi = Unset
|
||||
EthDevice =
|
||||
EthLogDNS = false
|
||||
InterceptDHCP = false
|
||||
PS2IP = 0.0.0.0
|
||||
Mask = 0.0.0.0
|
||||
Gateway = 0.0.0.0
|
||||
DNS1 = 0.0.0.0
|
||||
DNS2 = 0.0.0.0
|
||||
AutoMask = true
|
||||
AutoGateway = true
|
||||
ModeDNS1 = Auto
|
||||
ModeDNS2 = Auto
|
||||
|
||||
|
||||
[DEV9/Eth/Hosts]
|
||||
Count = 0
|
||||
|
||||
|
||||
[DEV9/Hdd]
|
||||
HddEnable = false
|
||||
HddFile = DEV9hdd.raw
|
||||
HddSizeSectors = 83886080
|
||||
|
||||
|
||||
[EmuCore/Gamefixes]
|
||||
VuAddSubHack = false
|
||||
FpuMulHack = false
|
||||
FpuNegDivHack = false
|
||||
XgKickHack = false
|
||||
EETimingHack = false
|
||||
InstantDMAHack = false
|
||||
SoftwareRendererFMVHack = false
|
||||
SkipMPEGHack = false
|
||||
OPHFlagHack = false
|
||||
DMABusyHack = false
|
||||
VIFFIFOHack = false
|
||||
VIF1StallHack = false
|
||||
GIFFIFOHack = false
|
||||
GoemonTlbHack = false
|
||||
IbitHack = false
|
||||
VUSyncHack = false
|
||||
VUOverflowHack = false
|
||||
BlitInternalFPSHack = false
|
||||
FullVU0SyncHack = false
|
||||
|
||||
|
||||
[EmuCore/Profiler]
|
||||
Enabled = false
|
||||
RecBlocks_EE = true
|
||||
RecBlocks_IOP = true
|
||||
RecBlocks_VU0 = true
|
||||
RecBlocks_VU1 = true
|
||||
|
||||
|
||||
[EmuCore/Debugger]
|
||||
ShowDebuggerOnStart = false
|
||||
AlignMemoryWindowStart = true
|
||||
FontWidth = 8
|
||||
FontHeight = 12
|
||||
WindowWidth = 0
|
||||
WindowHeight = 0
|
||||
MemoryViewBytesPerRow = 16
|
||||
|
||||
|
||||
[EmuCore/TraceLog]
|
||||
Enabled = false
|
||||
EE.bitset = 0
|
||||
IOP.bitset = 0
|
||||
|
||||
|
||||
[USB1]
|
||||
Type = None
|
||||
|
||||
|
||||
[USB2]
|
||||
Type = None
|
||||
|
||||
|
||||
[Achievements]
|
||||
Enabled = false
|
||||
TestMode = false
|
||||
UnofficialTestMode = false
|
||||
RichPresence = true
|
||||
ChallengeMode = false
|
||||
Leaderboards = true
|
||||
Notifications = true
|
||||
SoundEffects = true
|
||||
PrimedIndicators = true
|
||||
|
||||
|
||||
[Filenames]
|
||||
BIOS =
|
||||
|
||||
|
||||
[Framerate]
|
||||
NominalScalar = 1
|
||||
TurboScalar = 2
|
||||
SlomoScalar = 0.5
|
||||
|
||||
|
||||
[MemoryCards]
|
||||
Slot1_Enable = true
|
||||
Slot1_Filename = Mcd001.ps2
|
||||
Slot2_Enable = true
|
||||
Slot2_Filename = Mcd002.ps2
|
||||
Multitap1_Slot2_Enable = false
|
||||
Multitap1_Slot2_Filename = Mcd-Multitap1-Slot02.ps2
|
||||
Multitap1_Slot3_Enable = false
|
||||
Multitap1_Slot3_Filename = Mcd-Multitap1-Slot03.ps2
|
||||
Multitap1_Slot4_Enable = false
|
||||
Multitap1_Slot4_Filename = Mcd-Multitap1-Slot04.ps2
|
||||
Multitap2_Slot2_Enable = false
|
||||
Multitap2_Slot2_Filename = Mcd-Multitap2-Slot02.ps2
|
||||
Multitap2_Slot3_Enable = false
|
||||
Multitap2_Slot3_Filename = Mcd-Multitap2-Slot03.ps2
|
||||
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
|
||||
SDL = true
|
||||
SDLControllerEnhancedMode = false
|
||||
|
||||
|
||||
[Hotkeys]
|
||||
ToggleFullscreen = SDL-0/Start & SDL-0/LeftStick
|
||||
CycleInterlaceMode = Keyboard/F5
|
||||
CycleMipmapMode = Keyboard/Insert
|
||||
GSDumpMultiFrame = Keyboard/Control & Keyboard/Shift & Keyboard/F8
|
||||
Screenshot = Keyboard/F8
|
||||
GSDumpSingleFrame = Keyboard/Shift & Keyboard/F8
|
||||
ZoomIn = Keyboard/Control & Keyboard/Plus
|
||||
ZoomOut = Keyboard/Control & Keyboard/Minus
|
||||
InputRecToggleMode = Keyboard/Shift & Keyboard/R
|
||||
LoadStateFromSlot = SDL-0/Back & SDL-0/LeftShoulder
|
||||
SaveStateToSlot = SDL-0/Back & SDL-0/RightShoulder
|
||||
ShutdownVM = SDL-0/Back & SDL-0/Start
|
||||
ToggleFrameLimit = Keyboard/F4
|
||||
TogglePause = SDL-0/Back & SDL-0/A
|
||||
ToggleSlowMotion = SDL-0/Back & SDL-0/+LeftTrigger
|
||||
ToggleTurbo = SDL-0/Back & SDL-0/+RightTrigger
|
||||
HoldTurbo = Keyboard/Period
|
||||
ResetVM = SDL-0/Back & SDL-0/LeftStick
|
||||
OpenPauseMenu = SDL-0/Back & SDL-0/RightStick
|
||||
IncreaseUpscaleMultiplier = SDL-0/Start & SDL-0/DPadUp
|
||||
DecreaseUpscaleMultiplier = SDL-0/Start & SDL-0/DPadDown
|
||||
CycleAspectRatio = SDL-0/Start & SDL-0/DPadRight
|
||||
ToggleSoftwareRendering = SDL-0/Start & SDL-0/DPadLeft
|
||||
ToggleSoftwareRendering = Keyboard/F9
|
||||
NextSaveStateSlot = SDL-0/Start & SDL-0/RightShoulder
|
||||
PreviousSaveStateSlot = SDL-0/Start & SDL-0/LeftShoulder
|
||||
|
||||
[Pad1]
|
||||
Type = DualShock2
|
||||
Deadzone = 0.000000
|
||||
AxisScale = 1.330000
|
||||
LargeMotorScale = 1.000000
|
||||
SmallMotorScale = 1.000000
|
||||
PressureModifier = 0.5
|
||||
Up = SDL-0/DPadUp
|
||||
Right = SDL-0/DPadRight
|
||||
Down = SDL-0/DPadDown
|
||||
Left = SDL-0/DPadLeft
|
||||
Triangle = SDL-0/Y
|
||||
Circle = SDL-0/B
|
||||
Cross = SDL-0/A
|
||||
Square = SDL-0/X
|
||||
Select = SDL-0/Back
|
||||
Start = SDL-0/Start
|
||||
L1 = SDL-0/LeftShoulder
|
||||
L2 = SDL-0/+LeftTrigger
|
||||
R1 = SDL-0/RightShoulder
|
||||
R2 = SDL-0/+RightTrigger
|
||||
L3 = SDL-0/LeftStick
|
||||
R3 = SDL-0/RightStick
|
||||
LUp = SDL-0/-LeftY
|
||||
LRight = SDL-0/+LeftX
|
||||
LDown = SDL-0/+LeftY
|
||||
LLeft = SDL-0/-LeftX
|
||||
RUp = SDL-0/-RightY
|
||||
RRight = SDL-0/+RightX
|
||||
RDown = SDL-0/+RightY
|
||||
RLeft = SDL-0/-RightX
|
||||
Analog = SDL-0/Guide
|
||||
LargeMotor = SDL-0/LargeMotor
|
||||
SmallMotor = SDL-0/SmallMotor
|
||||
Pressure = Keyboard/S
|
||||
|
||||
[Pad2]
|
||||
Type = DualShock2
|
||||
Deadzone = 0.000000
|
||||
AxisScale = 1.330000
|
||||
LargeMotorScale = 1.000000
|
||||
SmallMotorScale = 1.000000
|
||||
PressureModifier = 0.300000
|
||||
Up = SDL-1/DPadUp
|
||||
Right = SDL-1/DPadRight
|
||||
Down = SDL-1/DPadDown
|
||||
Left = SDL-1/DPadLeft
|
||||
Triangle = SDL-1/Y
|
||||
Circle = SDL-1/B
|
||||
Cross = SDL-1/A
|
||||
Square = SDL-1/X
|
||||
Select = SDL-1/Back
|
||||
Start = SDL-1/Start
|
||||
L1 = SDL-1/LeftShoulder
|
||||
L2 = SDL-1/+LeftTrigger
|
||||
R1 = SDL-1/RightShoulder
|
||||
R2 = SDL-1/+RightTrigger
|
||||
L3 = SDL-1/LeftStick
|
||||
R3 = SDL-1/RightStick
|
||||
Analog = SDL-1/Guide
|
||||
LUp = SDL-1/-LeftY
|
||||
LRight = SDL-1/+LeftX
|
||||
LDown = SDL-1/+LeftY
|
||||
LLeft = SDL-1/-LeftX
|
||||
RUp = SDL-1/-RightY
|
||||
RRight = SDL-1/+RightX
|
||||
RDown = SDL-1/+RightY
|
||||
RLeft = SDL-1/-RightX
|
||||
LargeMotor = SDL-1/LargeMotor
|
||||
SmallMotor = SDL-1/SmallMotor
|
||||
|
||||
[GameList]
|
||||
RecursivePaths = {{{RECURSIVE_PATHS}}}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "com.simeonradivoev.gameflow.pcsx2",
|
||||
"displayName": "PCSX2 Integration",
|
||||
"version": "0.0.1",
|
||||
"description": "PCSX2 Emulator Integration",
|
||||
"main": "./pcsx2.ts",
|
||||
"icon": "https://upload.wikimedia.org/wikipedia/commons/2/2b/PCSX2_logo4.png",
|
||||
"keywords": [
|
||||
"integration",
|
||||
"emulator",
|
||||
"ps2",
|
||||
"pcsx2"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
|
||||
import { config, db } 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 path from 'node:path';
|
||||
import { ensureDir } from "fs-extra";
|
||||
import desc from './package.json';
|
||||
|
||||
export default class PCSX2Integration implements PluginType
|
||||
{
|
||||
load (ctx: PluginContextType)
|
||||
{
|
||||
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) =>
|
||||
{
|
||||
if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir)
|
||||
{
|
||||
const args = ["-batch"];
|
||||
if (config.get('launchInFullscreen'))
|
||||
{
|
||||
args.push("-fullscreen");
|
||||
}
|
||||
args.push(...["-bigpicture", "-portable", "--", ctx.autoValidCommand.metadata.romPath]);
|
||||
|
||||
const configFileContents = await Bun.file(configFile).text();
|
||||
|
||||
const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2');
|
||||
const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2');
|
||||
const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2');
|
||||
|
||||
const view = {
|
||||
BIOS_PATH: biosFolder,
|
||||
SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'),
|
||||
SAVE_STATES_PATH: path.join(savesFolder, 'states'),
|
||||
MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'),
|
||||
CACHE_PATH: path.join(storageFolder, 'cache'),
|
||||
COVERS_PATH: path.join(storageFolder, 'covers'),
|
||||
TEXTURES_PATH: path.join(storageFolder, 'textures'),
|
||||
RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'),
|
||||
};
|
||||
|
||||
await Promise.all(Object.values(view).map(p => ensureDir(p)));
|
||||
|
||||
await Bun.write(path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis', 'PCSX2.ini'), Mustache.render(configFileContents, view));
|
||||
|
||||
return args;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async downloadBios (id: number)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
94
src/bun/api/plugins/plugin-manager.ts
Normal file
94
src/bun/api/plugins/plugin-manager.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { GameflowHooks } from "../hooks/app";
|
||||
import { PluginContextType, PluginDescriptionType, PluginType } from "../../types/typesc.schema";
|
||||
import { config } from "../app";
|
||||
|
||||
export class PluginManager
|
||||
{
|
||||
hooks = new GameflowHooks();
|
||||
plugins: Record<string, {
|
||||
enabled: boolean,
|
||||
loaded: boolean,
|
||||
plugin: PluginType;
|
||||
description: PluginDescriptionType,
|
||||
source: PluginSourceType;
|
||||
|
||||
}> = {};
|
||||
|
||||
async register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (this.plugins[description.name])
|
||||
{
|
||||
console.error("Plugin with name", description.name, "already registered");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (plugin.setup) await plugin.setup();
|
||||
this.plugins[description.name] = {
|
||||
enabled: !config.get('disabledPlugins').includes(description.name),
|
||||
loaded: false, plugin: plugin,
|
||||
source: source,
|
||||
description: description
|
||||
};
|
||||
this.reload(description.name);
|
||||
console.log("Plugin", description.name, "registered");
|
||||
}
|
||||
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.log("Error While Registering plugin");
|
||||
console.error(error);
|
||||
};
|
||||
}
|
||||
|
||||
private reload (name: string)
|
||||
{
|
||||
const plugin = this.plugins[name];
|
||||
if (plugin)
|
||||
{
|
||||
const ctx: PluginContextType = { hooks: this.hooks };
|
||||
|
||||
if (plugin.loaded)
|
||||
{
|
||||
plugin.plugin.onBeforeReload?.(ctx);
|
||||
plugin.loaded = false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (plugin.enabled)
|
||||
{
|
||||
plugin.plugin.load(ctx);
|
||||
plugin.loaded = true;
|
||||
}
|
||||
} catch (error)
|
||||
{
|
||||
console.log("Error for plugin", plugin.description.name, "while loading");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reloadAll ()
|
||||
{
|
||||
this.hooks = new GameflowHooks();
|
||||
Object.keys(this.plugins).forEach(id => this.reload(id));
|
||||
}
|
||||
|
||||
async cleanup ()
|
||||
{
|
||||
await Promise.all(Object.values(this.plugins).filter(p => p.loaded && p.plugin.cleanup).map(async p =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await p.plugin.cleanup!();
|
||||
} catch (error)
|
||||
{
|
||||
console.log("Error for plugin", p.description.name, "while cleaning up");
|
||||
console.error(error);
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
37
src/bun/api/plugins/plugins.ts
Normal file
37
src/bun/api/plugins/plugins.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { plugins } from "../app";
|
||||
import z from "zod";
|
||||
import { toggleElementInConfig } from "@/bun/utils";
|
||||
|
||||
export default new Elysia({ prefix: '/plugins' })
|
||||
.get('/', async () =>
|
||||
{
|
||||
return Object.values(plugins.plugins).map(p =>
|
||||
{
|
||||
const plugin: FrontendPlugin = {
|
||||
enabled: p.enabled,
|
||||
name: p.description.name,
|
||||
displayName: p.description.displayName,
|
||||
description: p.description.description,
|
||||
source: p.source,
|
||||
version: p.description.version,
|
||||
icon: p.description.icon
|
||||
};
|
||||
return plugin;
|
||||
});
|
||||
})
|
||||
.post('/:id', async ({ params: { id }, body: { enabled } }) =>
|
||||
{
|
||||
const plugin = plugins.plugins[id];
|
||||
if (plugin)
|
||||
{
|
||||
plugin.enabled = enabled;
|
||||
toggleElementInConfig('disabledPlugins', plugin.description.name, enabled);
|
||||
plugins.reloadAll();
|
||||
} else
|
||||
{
|
||||
return status("Not Found");
|
||||
}
|
||||
}, {
|
||||
body: z.object({ enabled: z.boolean() })
|
||||
});
|
||||
25
src/bun/api/plugins/register-plugins.ts
Normal file
25
src/bun/api/plugins/register-plugins.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { PluginManager } from "./plugin-manager";
|
||||
|
||||
import pcsx2 from './builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json';
|
||||
import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema";
|
||||
import path from "node:path";
|
||||
|
||||
export default async function register (pluginManager: PluginManager)
|
||||
{
|
||||
|
||||
const plugins: (PluginDescriptionType & { main: string; root: string; })[] = [
|
||||
{ ...pcsx2, root: './builtin/emulators/com.simeonradivoev.gameflow.pcsx2' }
|
||||
];
|
||||
|
||||
await Promise.all(plugins.map(async (pluginPackage) =>
|
||||
{
|
||||
const file = await import(`./${path.join(pluginPackage.root, pluginPackage.main)}`);
|
||||
if (file.default && typeof file.default === 'function')
|
||||
{
|
||||
const pluginInstance = new file.default();
|
||||
const plugin = await PluginSchema.parseAsync(pluginInstance);
|
||||
const description = await PluginDescriptionSchema.parseAsync(pluginPackage);
|
||||
pluginManager.register(plugin, description, 'builtin');
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
|
@ -7,15 +7,17 @@ import { system } from "./system";
|
|||
import { store } from "./store/store";
|
||||
import { host } from "../utils/host";
|
||||
import { jobs } from "./jobs/jobs";
|
||||
import plugins from "./plugins/plugins";
|
||||
|
||||
const api = new Elysia({ serve: {} })
|
||||
.use([cors(), clients, settings, system, store, jobs]);
|
||||
.use([cors(), clients, settings, system, store, jobs, plugins]);
|
||||
|
||||
export type RommAPIType = typeof clients;
|
||||
export type SettingsAPIType = typeof settings;
|
||||
export type SystemAPIType = typeof system;
|
||||
export type StoreAPIType = typeof store;
|
||||
export type JobsAPIType = typeof jobs;
|
||||
export type PluginsAPIType = typeof plugins;
|
||||
|
||||
export function RunAPIServer ()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@
|
|||
import * as appSchema from '@schema/app';
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { customEmulators, db, emulatorsDb } from '../app';
|
||||
import fs from 'node:fs/promises';
|
||||
import { db, emulatorsDb } from '../app';
|
||||
import { cores } from '../emulatorjs/emulatorjs';
|
||||
import { FrontEndEmulator, SERVER_URL } from '@/shared/constants';
|
||||
import { SERVER_URL } from '@/shared/constants';
|
||||
import { findExecsByName } from '../games/services/launchGameService';
|
||||
import { host } from '@/bun/utils/host';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { EmulatorPackageType, EmulatorSourceType, FrontEndEmulator } from "@/shared/constants";
|
||||
import { emulatorsDb } from "../../app";
|
||||
import { EmulatorPackageType } from "@/shared/constants";
|
||||
import { emulatorsDb, plugins } from "../../app";
|
||||
import * as emulatorSchema from '@schema/emulators';
|
||||
import { findExecs } from "../../games/services/launchGameService";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
|
@ -10,7 +10,7 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT
|
|||
icon: string;
|
||||
}[])
|
||||
{
|
||||
let execPath: EmulatorSourceType | undefined;
|
||||
let execPath: EmulatorSourceEntryType | undefined;
|
||||
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) });
|
||||
|
||||
if (esEmulator)
|
||||
|
|
@ -24,8 +24,17 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT
|
|||
logo: emulator.logo,
|
||||
systems,
|
||||
gameCount,
|
||||
validSource: execPath
|
||||
validSource: execPath,
|
||||
integration: findEmulatorPluginIntegration(emulator.name)
|
||||
};
|
||||
|
||||
return em;
|
||||
}
|
||||
|
||||
export function findEmulatorPluginIntegration (name: string)
|
||||
{
|
||||
const lowerCaseName = name.toLowerCase();
|
||||
const integration = Object.entries(plugins.plugins).find(p => p[1].description.keywords?.includes(lowerCaseName));
|
||||
if (!integration) return undefined;
|
||||
return { name: integration[0], version: integration[1].description.version };
|
||||
}
|
||||
|
|
@ -3,17 +3,18 @@ import Elysia, { status } from "elysia";
|
|||
import { config, db, taskQueue } from "../app";
|
||||
import path from "node:path";
|
||||
import fs from 'node:fs/promises';
|
||||
import { FrontEndEmulatorDetailed, FrontEndEmulatorDetailedDownload, StoreGameSchema } from "@/shared/constants";
|
||||
import { StoreGameSchema } from "@/shared/constants";
|
||||
import { findExecsByName } from "../games/services/launchGameService";
|
||||
import * as appSchema from '@schema/app';
|
||||
import z from "zod";
|
||||
import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
|
||||
import { getPlatformsApiPlatformsGet } from "@/clients/romm";
|
||||
import { CACHE_KEYS, getOrCached, getOrCachedGithubRelease } from "../cache";
|
||||
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage } from "./services/gamesService";
|
||||
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "./services/gamesService";
|
||||
import { EmulatorDownloadJob } from "../jobs/emulator-download-job";
|
||||
import { Glob } from "bun";
|
||||
import { convertStoreEmulatorToFrontend } from "./services/emulatorsService";
|
||||
import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration } from "./services/emulatorsService";
|
||||
import { BiosDownloadJob } from "../jobs/bios-download-job";
|
||||
|
||||
export const store = new Elysia({ prefix: '/api/store' })
|
||||
.get('/emulators', async ({ query }) =>
|
||||
|
|
@ -97,13 +98,11 @@ export const store = new Elysia({ prefix: '/api/store' })
|
|||
})
|
||||
.get('/screenshot/emulator/:id/:name', async ({ params: { id, name } }) =>
|
||||
{
|
||||
const downlodDir = config.get('downloadPath');
|
||||
return Bun.file(path.join(downlodDir, "store", "media", "screenshots", id, name));
|
||||
return Bun.file(path.join(getStoreFolder(), "media", "screenshots", id, name));
|
||||
},
|
||||
{ params: z.object({ id: z.string(), name: z.string() }) })
|
||||
.get('/emulator/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
const downlodDir = config.get('downloadPath');
|
||||
const emulatorPackage = await getStoreEmulatorPackage(id);
|
||||
if (!emulatorPackage) return status("Not Found");
|
||||
|
||||
|
|
@ -111,9 +110,12 @@ export const store = new Elysia({ prefix: '/api/store' })
|
|||
|
||||
const execPaths = await findExecsByName(emulatorPackage.name);
|
||||
|
||||
const emulatorScreenshotsPath = path.join(downlodDir, "store", "media", "screenshots", id);
|
||||
const emulatorScreenshotsPath = path.join(getStoreFolder(), "media", "screenshots", id);
|
||||
const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : [];
|
||||
const validExec = execPaths.find(p => p.exists);
|
||||
const biosDirPath = path.join(config.get('downloadPath'), 'bios', id);
|
||||
const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : [];
|
||||
|
||||
const emulator: FrontEndEmulatorDetailed = {
|
||||
name: emulatorPackage.name,
|
||||
description: emulatorPackage.description,
|
||||
|
|
@ -138,7 +140,10 @@ export const store = new Elysia({ prefix: '/api/store' })
|
|||
return { name: d.type, type: "Unknown" };
|
||||
}) ?? []),
|
||||
logo: emulatorPackage.logo,
|
||||
sources: execPaths
|
||||
sources: execPaths,
|
||||
biosRequirement: emulatorPackage.bios,
|
||||
bios: biosFiles,
|
||||
integration: findEmulatorPluginIntegration(emulatorPackage.name)
|
||||
};
|
||||
|
||||
return emulator;
|
||||
|
|
@ -154,7 +159,6 @@ export const store = new Elysia({ prefix: '/api/store' })
|
|||
})
|
||||
.delete('/emulator/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
|
||||
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
|
||||
if (await fs.exists(storeEmulatorFolder))
|
||||
{
|
||||
|
|
@ -162,4 +166,24 @@ export const store = new Elysia({ prefix: '/api/store' })
|
|||
return status("OK");
|
||||
}
|
||||
return status("Not Found");
|
||||
})
|
||||
.post('/download/bios/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
if (taskQueue.findJob(BiosDownloadJob.query({ id }), BiosDownloadJob))
|
||||
{
|
||||
return status("Conflict", "Bios Download Already Active");
|
||||
}
|
||||
|
||||
return taskQueue.enqueue(BiosDownloadJob.query({ id }), new BiosDownloadJob(id));
|
||||
})
|
||||
.delete('/bios/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
const biosFolder = path.join(config.get('downloadPath'), "bios", id);
|
||||
if (await fs.exists(biosFolder))
|
||||
{
|
||||
await fs.rm(biosFolder, { recursive: true });
|
||||
} else
|
||||
{
|
||||
return status("Not Found");
|
||||
}
|
||||
});
|
||||
|
|
@ -7,7 +7,7 @@ import { isSteamDeck, openExternal } from "../utils";
|
|||
import fs from 'node:fs/promises';
|
||||
import buildNotificationsStream from "./notifications";
|
||||
import path, { dirname } from "node:path";
|
||||
import { DirSchema, DownloadsDrive } from "@/shared/constants";
|
||||
import { DirSchema } from "@/shared/constants";
|
||||
import { getDevices, getDevicesCurated } from "./drives";
|
||||
import getFolderSize from "get-folder-size";
|
||||
import si from 'systeminformation';
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
|
||||
import { JobStatus } from '@/shared/constants';
|
||||
|
||||
import EventEmitter from 'node:events';
|
||||
import z, { ZodTypeAny } from 'zod';
|
||||
import z from 'zod';
|
||||
|
||||
export class TaskQueue
|
||||
{
|
||||
private activeQueue: { context: JobContext<any, string, any>, promise?: Promise<void>; }[] = [];
|
||||
private queue?: { context: JobContext<any, string, any>, promise?: Promise<void>; }[] = [];
|
||||
private activeQueue: { context: JobContext<IJob<any, string>, any, string>, promise?: Promise<void>; }[] = [];
|
||||
private queue?: { context: JobContext<IJob<any, string>, any, string>, promise?: Promise<void>; }[] = [];
|
||||
private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>();
|
||||
|
||||
public enqueue<TData, TState extends string, T extends IJob<TData, TState>> (id: string, job: T)
|
||||
|
|
@ -36,6 +36,8 @@ export class TaskQueue
|
|||
{
|
||||
const index = this.activeQueue.indexOf(job.job);
|
||||
this.activeQueue.splice(index, 1);
|
||||
// We need to call it after it has been removed from the queue, so that the has active of type doesn't return true
|
||||
this.events?.emit('ended', { id: job.job.context.id, job: job.job.context });
|
||||
setTimeout(() => this.processQueue(), 0);
|
||||
});
|
||||
});
|
||||
|
|
@ -162,7 +164,7 @@ type JobClassWithStatics = JobClass & {
|
|||
export type JobContextFromClass<C extends JobClassWithStatics> =
|
||||
JobContext<
|
||||
InstanceType<C>,
|
||||
C extends { dataSchema: ZodTypeAny; }
|
||||
C extends { dataSchema: z.ZodAny; }
|
||||
? z.infer<C['dataSchema']>
|
||||
: never,
|
||||
C['id']
|
||||
|
|
@ -215,7 +217,6 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
|
|||
} finally
|
||||
{
|
||||
this.running = false;
|
||||
this.events.emit('ended', { id: this.m_id, job: this });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
21
src/bun/types/types.d.ts
vendored
21
src/bun/types/types.d.ts
vendored
|
|
@ -1,15 +1,4 @@
|
|||
import { ChildProcess } from "node:child_process";
|
||||
|
||||
declare const IS_BINARY: string;
|
||||
|
||||
export type ActiveGame = {
|
||||
process?: ChildProcess;
|
||||
gameId: number;
|
||||
name: string;
|
||||
command: { command: string, startDir?: string; };
|
||||
};
|
||||
|
||||
interface ObjectConstructor
|
||||
declare interface ObjectConstructor
|
||||
{
|
||||
/**
|
||||
* Groups members of an iterable according to the return value of the passed callback.
|
||||
|
|
@ -22,7 +11,7 @@ interface ObjectConstructor
|
|||
): Partial<Record<K, T[]>>;
|
||||
}
|
||||
|
||||
interface MapConstructor
|
||||
declare interface MapConstructor
|
||||
{
|
||||
/**
|
||||
* Groups members of an iterable according to the return value of the passed callback.
|
||||
|
|
@ -33,4 +22,10 @@ interface MapConstructor
|
|||
items: Iterable<T>,
|
||||
keySelector: (item: T, index: number) => K,
|
||||
): Map<K, T[]>;
|
||||
}
|
||||
|
||||
declare interface AppEventMap
|
||||
{
|
||||
exitapp: [];
|
||||
notification: [FrontendNotification];
|
||||
}
|
||||
35
src/bun/types/typesc.schema.ts
Normal file
35
src/bun/types/typesc.schema.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import z from "zod";
|
||||
import { GameflowHooks } from "../api/hooks/app";
|
||||
import { ChildProcess } from "node:child_process";
|
||||
|
||||
export const PluginContextSchema = z.object({
|
||||
hooks: z.instanceof(GameflowHooks)
|
||||
});
|
||||
|
||||
export const PluginDescriptionSchema = z.object({
|
||||
name: z.string(),
|
||||
displayName: z.string(),
|
||||
version: z.string(),
|
||||
description: z.string(),
|
||||
icon: z.url().optional(),
|
||||
keywords: z.array(z.string()).optional()
|
||||
});
|
||||
|
||||
export const PluginSchema = z.object({
|
||||
setup: z.function().output(z.promise(z.void())).optional(),
|
||||
load: z.function().input([PluginContextSchema]).output(z.void()),
|
||||
onBeforeReload: z.function().input([PluginContextSchema]).output(z.void()).optional(),
|
||||
cleanup: z.function().output(z.promise(z.void())).optional()
|
||||
});
|
||||
|
||||
export type PluginType = z.infer<typeof PluginSchema>;
|
||||
export type PluginContextType = z.infer<typeof PluginContextSchema>;
|
||||
export type PluginDescriptionType = z.infer<typeof PluginDescriptionSchema>;
|
||||
|
||||
export const ActiveGameSchema = z.object({
|
||||
process: z.instanceof(ChildProcess).optional(),
|
||||
gameId: z.number(),
|
||||
name: z.string(),
|
||||
command: z.object({ command: z.string(), startDir: z.string().optional() })
|
||||
});
|
||||
export type ActiveGameType = z.infer<typeof ActiveGameSchema>;
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { $ } from 'bun';
|
||||
import { $, sleep } from 'bun';
|
||||
import path from 'node:path';
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { SettingsType } from '@/shared/constants';
|
||||
import { config } from './api/app';
|
||||
|
||||
export function checkRunning (pid: number)
|
||||
{
|
||||
|
|
@ -111,3 +113,33 @@ export function shuffleInPlace (array: any[], startSeed?: number)
|
|||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleElementInConfig<T> (id: KeysWithValueAssignableTo<SettingsType, Array<T>>, element: T, enabled: boolean)
|
||||
{
|
||||
const disabled = config.get(id as any) as T[];
|
||||
if (enabled)
|
||||
{
|
||||
const index = disabled.indexOf(element);
|
||||
if (index < 0)
|
||||
{
|
||||
config.set('disabledPlugins', disabled.concat(element));
|
||||
}
|
||||
} else
|
||||
{
|
||||
const index = disabled.indexOf(element);
|
||||
if (index >= 0)
|
||||
{
|
||||
config.set('disabledPlugins', disabled.toSpliced(index, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function simulateProgress (setProgress: (p: number) => void, signal?: AbortSignal)
|
||||
{
|
||||
for (let i = 0; i < 10; i++)
|
||||
{
|
||||
setProgress(i * 10);
|
||||
if (signal && signal.aborted) return;
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ import fs from 'node:fs/promises';
|
|||
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { config, jar } from "../api/app";
|
||||
import { file } from "bun";
|
||||
|
||||
export interface FileEntry
|
||||
{
|
||||
|
|
@ -24,6 +23,10 @@ interface TmpDownloadMetadata
|
|||
files: FileEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* It download files and reports progress.
|
||||
* It also automatically applies cookies from the jar store.
|
||||
*/
|
||||
export class Downloader
|
||||
{
|
||||
files: FileEntry[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue