feat: Implemented emulator versions and updating
This commit is contained in:
parent
a69147a4f7
commit
34db717ec5
22 changed files with 434 additions and 212 deletions
|
|
@ -303,7 +303,8 @@ export default new Elysia()
|
|||
validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }],
|
||||
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
|
||||
systems: [],
|
||||
gameCount: 0
|
||||
gameCount: 0,
|
||||
integrations: []
|
||||
} satisfies FrontEndGameTypeDetailedEmulator;
|
||||
}
|
||||
else
|
||||
|
|
@ -313,7 +314,8 @@ export default new Elysia()
|
|||
logo: "",
|
||||
systems: [],
|
||||
gameCount: 0,
|
||||
validSources: []
|
||||
validSources: [],
|
||||
integrations: []
|
||||
} satisfies FrontEndGameTypeDetailedEmulator;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { LaunchGameJob } from '../../jobs/launch-game-job';
|
|||
import { EmulatorPackageType } from '@/shared/constants';
|
||||
import { getStoreEmulatorPackage, getStoreFolder } from '../../store/services/gamesService';
|
||||
import { getOrCached } from '../../cache';
|
||||
import { getScoopPackage } from '../../store/services/emulatorsService';
|
||||
import { getOrCachedScoopPackage } from '../../store/services/emulatorsService';
|
||||
|
||||
export const varRegex = /%([^%]+)%/g;
|
||||
export const assignRegex = /(%\w+%)=(\S+) /g;
|
||||
|
|
@ -293,7 +293,7 @@ export async function findStoreEmulatorExec (id: string, emulator?: { systempath
|
|||
let bin: string | undefined = (dl as any).bin;
|
||||
if (!bin && dl.type === 'scoop')
|
||||
{
|
||||
const data = await getScoopPackage(id, dl.url);
|
||||
const data = await getOrCachedScoopPackage(id, dl.url);
|
||||
|
||||
if (data)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { AsyncSeriesBailHook } from "tapable";
|
||||
import { EmulatorDownloadInfoType, EmulatorPackageType } from "@/shared/constants";
|
||||
import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable";
|
||||
|
||||
export class EmulatorHooks
|
||||
{
|
||||
|
|
@ -7,4 +8,15 @@ export class EmulatorHooks
|
|||
systems: EmulatorSystem[];
|
||||
biosFolder: string;
|
||||
}], { auth?: string, files: DownloadFileEntry[]; } | undefined>(['ctx']);
|
||||
|
||||
/**
|
||||
* Triggered when emulator is downloaded or updated
|
||||
*/
|
||||
emulatorPostInstall = new AsyncSeriesHook<[ctx: {
|
||||
emulator: string;
|
||||
emulatorPackage?: EmulatorPackageType;
|
||||
path: string;
|
||||
update: boolean;
|
||||
info: EmulatorDownloadInfoType;
|
||||
}]>(['ctx']);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { EmulatorPackageType, GameListFilterType } from '@/shared/constants';
|
||||
import { SyncBailHook, AsyncSeriesHook, SyncWaterfallHook, AsyncSeriesBailHook, AsyncHook, AsyncParallelHook, SyncHook, AsyncSeriesWaterfallHook } from 'tapable';
|
||||
import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable';
|
||||
|
||||
export class GameHooks
|
||||
{
|
||||
|
|
@ -13,6 +13,7 @@ export class GameHooks
|
|||
*/
|
||||
emulatorLaunch = new AsyncSeriesBailHook<[ctx: {
|
||||
autoValidCommand: CommandEntry;
|
||||
dryRun: boolean,
|
||||
game: {
|
||||
source: string;
|
||||
id: number;
|
||||
|
|
@ -20,12 +21,13 @@ export class GameHooks
|
|||
}], string[] | undefined>(['ctx']);
|
||||
/**
|
||||
* Is the given emulator for the given command supported
|
||||
* @returns The possible value is if it can support it but not right now. To show grayed out icon.
|
||||
* @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.
|
||||
*
|
||||
*/
|
||||
emulatorLaunchSupport = new SyncBailHook<[ctx: {
|
||||
emulator: string;
|
||||
source?: EmulatorSourceEntryType;
|
||||
}], { id: string; possible: boolean; } | undefined>(['ctx']);
|
||||
}], EmulatorSupport | undefined>(['ctx']);
|
||||
/**
|
||||
* Fetches and returns a list of games converted to frontend.
|
||||
* @param ctx.localGameIds This is local game ids in the format '<source>@<sourceId>'
|
||||
|
|
|
|||
|
|
@ -2,17 +2,15 @@ import { EmulatorPackageType } from "@/shared/constants";
|
|||
import { getStoreEmulatorPackage } from "../store/services/gamesService";
|
||||
import { IJob, JobContext } from "../task-queue";
|
||||
import z from "zod";
|
||||
import { Glob } from "bun";
|
||||
import { config } from "../app";
|
||||
import { config, plugins } from "../app";
|
||||
import path from 'node:path';
|
||||
import { getOrCachedGithubRelease } from "../cache";
|
||||
import Seven from 'node-7z';
|
||||
import fs from "node:fs/promises";
|
||||
import { Downloader } from "@/bun/utils/downloader";
|
||||
import { ensureDir, move } from "fs-extra";
|
||||
import { simulateProgress } from "@/bun/utils";
|
||||
import { path7za } from "7zip-bin";
|
||||
import { getScoopPackage } from "../store/services/emulatorsService";
|
||||
import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService";
|
||||
|
||||
type EmulatorDownloadStates = "download" | "extract";
|
||||
|
||||
|
|
@ -23,73 +21,24 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
|
|||
emulator: string;
|
||||
downloadSource: string;
|
||||
emulatorPackage?: EmulatorPackageType;
|
||||
dryRun?: boolean;
|
||||
dryRun: boolean;
|
||||
isUpdate: boolean;
|
||||
|
||||
constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; })
|
||||
constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; isUpdate?: boolean; })
|
||||
{
|
||||
this.emulator = emulator;
|
||||
this.downloadSource = downloadSource;
|
||||
this.dryRun = init?.dryRun ?? false;
|
||||
this.isUpdate = init?.isUpdate ?? false;
|
||||
}
|
||||
|
||||
async start (context: JobContext<EmulatorDownloadJob, z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>)
|
||||
{
|
||||
this.emulatorPackage = await getStoreEmulatorPackage(this.emulator);
|
||||
if (!this.emulatorPackage) throw new Error("Emulator not found");
|
||||
if (!this.emulatorPackage.downloads) throw new Error("Emulator has no downloads");
|
||||
const { url, info } = await getEmulatorDownload(this.emulatorPackage, this.downloadSource);
|
||||
|
||||
const validDownloads = this.emulatorPackage.downloads[`${process.platform}:${process.arch}`];
|
||||
if (!validDownloads) throw new Error(`Now downloads in ${this.emulatorPackage.name} for platform ${process.platform}:${process.arch}`);
|
||||
|
||||
const validDownload = validDownloads.find(d => d.type === this.downloadSource);
|
||||
if (!validDownload) throw new Error(`Download type ${this.downloadSource} not found`);
|
||||
|
||||
let downloadUrl: URL;
|
||||
if (validDownload.type === 'github')
|
||||
{
|
||||
console.log("Trying To Download from ", `https://api.github.com/repos/${validDownload.path}/releases/latest`);
|
||||
const latestRelease = await getOrCachedGithubRelease(validDownload.path);
|
||||
const glob = new Glob(validDownload.pattern);
|
||||
const validAsset = latestRelease.assets.find(a => glob.match(a.name));
|
||||
if (!validAsset) throw new Error("Could Not Find Valid Asset");
|
||||
downloadUrl = new URL(validAsset.browser_download_url);
|
||||
} else if (validDownload.type === 'direct')
|
||||
{
|
||||
downloadUrl = new URL(validDownload.url);
|
||||
} else if (validDownload.type === 'scoop')
|
||||
{
|
||||
const data = await getScoopPackage(this.emulator, validDownload.url);
|
||||
let scoopDownload: URL | undefined;
|
||||
if (data)
|
||||
{
|
||||
if (data.url)
|
||||
{
|
||||
scoopDownload = new URL(data.url);
|
||||
} else if (data.architecture)
|
||||
{
|
||||
if (process.arch === 'x64' && data.architecture["64bit"])
|
||||
{
|
||||
scoopDownload = new URL(data.architecture["64bit"].url);
|
||||
} else if (process.arch === "arm64" && data.architecture["arm64"])
|
||||
{
|
||||
scoopDownload = new URL(data.architecture["arm64"].url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scoopDownload)
|
||||
{
|
||||
downloadUrl = scoopDownload;
|
||||
} else
|
||||
{
|
||||
throw new Error("Could not find scoop download");
|
||||
}
|
||||
} else
|
||||
{
|
||||
throw new Error("Download Type Unsupported");
|
||||
}
|
||||
|
||||
const emulatorsFolder = path.join(config.get('downloadPath'), "emulators", this.emulator);
|
||||
const emulatorsFolder = getEmulatorPath(this.emulator);
|
||||
|
||||
if (this.dryRun)
|
||||
{
|
||||
|
|
@ -99,7 +48,7 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
|
|||
{
|
||||
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
|
||||
const downloader = new Downloader(this.emulator,
|
||||
[{ url: new URL(downloadUrl), file_name: path.basename(downloadUrl.pathname), file_path: this.emulator }],
|
||||
[{ url, file_name: path.basename(url.pathname), file_path: this.emulator }],
|
||||
tmpFolder,
|
||||
{
|
||||
signal: context.abortSignal,
|
||||
|
|
@ -156,6 +105,16 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
|
|||
await fs.rename(destPath, path.join(emulatorsFolder, path.basename(destPath)));
|
||||
}
|
||||
}
|
||||
|
||||
await plugins.hooks.emulators.emulatorPostInstall.promise({
|
||||
emulator: this.emulator,
|
||||
emulatorPackage: this.emulatorPackage,
|
||||
path: emulatorsFolder,
|
||||
info,
|
||||
update: this.isUpdate
|
||||
});
|
||||
|
||||
await Bun.write(`${emulatorsFolder}.json`, JSON.stringify(info, null, 3));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ export default class UpdateStoreJob implements IJob<never, never>
|
|||
const storeFolder = getStoreRootFolder();
|
||||
await ensureDir(storeFolder);
|
||||
|
||||
console.log("Updating Store");
|
||||
const proc = Bun.spawn([process.execPath, "install", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], {
|
||||
console.log("Adding Store Package");
|
||||
let proc = Bun.spawn([process.execPath, "add", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], {
|
||||
cwd: storeFolder,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
|
|
@ -40,9 +40,27 @@ export default class UpdateStoreJob implements IJob<never, never>
|
|||
}
|
||||
});
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
let stdout = await new Response(proc.stdout).text();
|
||||
console.log(stdout);
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
let stderr = await new Response(proc.stderr).text();
|
||||
if (stderr)
|
||||
console.error(stderr);
|
||||
await proc.exited;
|
||||
|
||||
console.log("Updating Store Package");
|
||||
proc = Bun.spawn([process.execPath, "update", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], {
|
||||
cwd: storeFolder,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
BUN_BE_BUN: "1",
|
||||
BUN_INSTALL_CACHE_DIR: tempCache
|
||||
}
|
||||
});
|
||||
|
||||
stdout = await new Response(proc.stdout).text();
|
||||
console.log(stdout);
|
||||
stderr = await new Response(proc.stderr).text();
|
||||
if (stderr)
|
||||
console.error(stderr);
|
||||
await proc.exited;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@ export default class DOLPHINIntegration implements PluginType
|
|||
ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) =>
|
||||
{
|
||||
if (ctx.emulator === 'DOLPHIN')
|
||||
return { id: desc.name, possible: !!ctx.source };
|
||||
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] };
|
||||
});
|
||||
|
||||
ctx.hooks.emulators.emulatorPostInstall.tapPromise(desc.name, async (ctx) =>
|
||||
{
|
||||
await Bun.write(path.join(ctx.path, "portable.txt"), "");
|
||||
});
|
||||
|
||||
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) =>
|
||||
|
|
|
|||
|
|
@ -15,13 +15,26 @@ export default class PCSX2Integration implements PluginType
|
|||
{
|
||||
if (ctx.emulator === 'PCSX2')
|
||||
{
|
||||
return { id: desc.name, possible: ctx.source?.type === 'store' };
|
||||
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"];
|
||||
|
||||
if (ctx.source?.type === 'store')
|
||||
{
|
||||
return {
|
||||
id: desc.name,
|
||||
supportLevel: "full",
|
||||
capabilities: [...baseCapabilities, "resolution", "config"]
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) =>
|
||||
{
|
||||
if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir)
|
||||
if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.metadata.emulatorDir)
|
||||
{
|
||||
const args = ["-batch"];
|
||||
if (config.get('launchInFullscreen'))
|
||||
|
|
@ -30,32 +43,35 @@ export default class PCSX2Integration implements PluginType
|
|||
}
|
||||
args.push(...["-bigpicture", "-portable", "--", ctx.autoValidCommand.metadata.romPath]);
|
||||
|
||||
const configFileContents = await Bun.file(configFile).text();
|
||||
if (ctx.autoValidCommand.emulatorSource === 'store' && !ctx.dryRun)
|
||||
{
|
||||
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 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'),
|
||||
};
|
||||
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 Promise.all(Object.values(view).map(p => ensureDir(p)));
|
||||
|
||||
let pscx2Path = '';
|
||||
if (process.platform === 'win32')
|
||||
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis');
|
||||
else
|
||||
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis');
|
||||
let pscx2Path = '';
|
||||
if (process.platform === 'win32')
|
||||
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis');
|
||||
else
|
||||
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis');
|
||||
|
||||
await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view));
|
||||
await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view));
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,18 +14,31 @@ export default class PCSX2Integration implements PluginType
|
|||
{
|
||||
load (ctx: PluginContextType)
|
||||
{
|
||||
|
||||
ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) =>
|
||||
{
|
||||
if (ctx.emulator === 'PPSSPP')
|
||||
{
|
||||
return { id: desc.name, possible: ctx.source?.type === 'store' };
|
||||
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"];
|
||||
|
||||
if (ctx.source?.type === 'store')
|
||||
{
|
||||
return {
|
||||
id: desc.name,
|
||||
supportLevel: "full",
|
||||
capabilities: [...baseCapabilities, "resolution", "config"]
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] };
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) =>
|
||||
{
|
||||
if (ctx.autoValidCommand.emulator === 'PPSSPP' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir)
|
||||
if (ctx.autoValidCommand.emulator === 'PPSSPP' && ctx.autoValidCommand.metadata.emulatorDir)
|
||||
{
|
||||
const args = [ctx.autoValidCommand.metadata.romPath, "--escape-exit", "--pause-menu-exit"];
|
||||
if (config.get('launchInFullscreen'))
|
||||
|
|
@ -33,44 +46,47 @@ export default class PCSX2Integration implements PluginType
|
|||
args.push("--fullscreen");
|
||||
}
|
||||
|
||||
let confPath: string | undefined = undefined;
|
||||
let controlsPath: string | undefined = undefined;
|
||||
|
||||
switch (process.platform)
|
||||
if (ctx.autoValidCommand.emulatorSource === 'store' && !ctx.dryRun)
|
||||
{
|
||||
case "win32":
|
||||
confPath = configFilePathWin32;
|
||||
controlsPath = configControlsFilePathWin32;
|
||||
break;
|
||||
case 'linux':
|
||||
confPath = configFilePathLinux;
|
||||
controlsPath = configControlsFilePathLinux;
|
||||
break;
|
||||
}
|
||||
let confPath: string | undefined = undefined;
|
||||
let controlsPath: string | undefined = undefined;
|
||||
|
||||
let ppssppPath = '';
|
||||
if (process.platform === 'win32')
|
||||
{
|
||||
ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM');
|
||||
} else
|
||||
{
|
||||
//TODO: Use way to set custom memstick path when they support it
|
||||
ensureDir(path.join(homedir(), '.config', 'ppsspp'));
|
||||
ppssppPath = path.join(homedir(), '.config', 'ppsspp', 'PSP', 'SYSTEM');
|
||||
}
|
||||
switch (process.platform)
|
||||
{
|
||||
case "win32":
|
||||
confPath = configFilePathWin32;
|
||||
controlsPath = configControlsFilePathWin32;
|
||||
break;
|
||||
case 'linux':
|
||||
confPath = configFilePathLinux;
|
||||
controlsPath = configControlsFilePathLinux;
|
||||
break;
|
||||
}
|
||||
|
||||
ensureDir(ppssppPath);
|
||||
let ppssppPath = '';
|
||||
if (process.platform === 'win32')
|
||||
{
|
||||
ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM');
|
||||
} else
|
||||
{
|
||||
//TODO: Use way to set custom memstick path when they support it
|
||||
ensureDir(path.join(homedir(), '.config', 'ppsspp'));
|
||||
ppssppPath = path.join(homedir(), '.config', 'ppsspp', 'PSP', 'SYSTEM');
|
||||
}
|
||||
|
||||
if (confPath)
|
||||
{
|
||||
const configFileContents = await Bun.file(confPath).text();
|
||||
await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {}));
|
||||
}
|
||||
ensureDir(ppssppPath);
|
||||
|
||||
if (controlsPath)
|
||||
{
|
||||
const controlsFileContents = await Bun.file(controlsPath).text();
|
||||
await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {}));
|
||||
if (confPath)
|
||||
{
|
||||
const configFileContents = await Bun.file(confPath).text();
|
||||
await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {}));
|
||||
}
|
||||
|
||||
if (controlsPath)
|
||||
{
|
||||
const controlsFileContents = await Bun.file(controlsPath).text();
|
||||
await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {}));
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { cores } from '../emulatorjs/emulatorjs';
|
|||
import { SERVER_URL } from '@/shared/constants';
|
||||
import { findExecsByName } from '../games/services/launchGameService';
|
||||
import { host } from '@/bun/utils/host';
|
||||
import { findEmulatorPluginIntegration } from '../store/services/emulatorsService';
|
||||
|
||||
/**
|
||||
* Get emulators based on local games. Only the ones we probably need.
|
||||
|
|
@ -73,7 +74,8 @@ export async function getRelevantEmulators ()
|
|||
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s).map(e => ({ iconUrl: `/api/romm/image/romm/assets/platforms/${e.es_slug}.svg`, name: e.platform_name ?? 'Unknown', id: e.es_slug ?? '' })),
|
||||
gameCount: 0,
|
||||
isCritical: false,
|
||||
validSources: execPaths
|
||||
validSources: execPaths,
|
||||
integrations: findEmulatorPluginIntegration(emulator, execPaths)
|
||||
};
|
||||
|
||||
return em;
|
||||
|
|
@ -86,7 +88,8 @@ export async function getRelevantEmulators ()
|
|||
systems: [],
|
||||
gameCount: 0,
|
||||
isCritical: false,
|
||||
description: "Embedded Emulator. Uses Retroarch Cores"
|
||||
description: "Embedded Emulator. Uses Retroarch Cores",
|
||||
integrations: []
|
||||
});
|
||||
|
||||
return finalEmulators.map(e =>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants";
|
||||
import { emulatorsDb, plugins } from "../../app";
|
||||
import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants";
|
||||
import { config, emulatorsDb, plugins } from "../../app";
|
||||
import * as emulatorSchema from '@schema/emulators';
|
||||
import { findExecs } from "../../games/services/launchGameService";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getOrCached } from "../../cache";
|
||||
import { getOrCached, getOrCachedGithubRelease } from "../../cache";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: EmulatorSystem[])
|
||||
{
|
||||
|
|
@ -22,21 +24,130 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT
|
|||
systems,
|
||||
gameCount,
|
||||
validSources: execPaths,
|
||||
integration: findEmulatorPluginIntegration(emulator.name, execPaths)
|
||||
integrations: findEmulatorPluginIntegration(emulator.name, execPaths)
|
||||
};
|
||||
|
||||
return em;
|
||||
}
|
||||
|
||||
export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[])
|
||||
export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]): EmulatorSupport[]
|
||||
{
|
||||
const hasSupport = validSources.concat(undefined).map(s => plugins.hooks.games.emulatorLaunchSupport.call({ emulator: name, source: s })).filter(s => !!s);
|
||||
const hasSupport = validSources.concat(undefined).map(s =>
|
||||
{
|
||||
const support = plugins.hooks.games.emulatorLaunchSupport.call({ emulator: name, source: s });
|
||||
if (support)
|
||||
{
|
||||
return { ...support, source: s };
|
||||
}
|
||||
|
||||
if (hasSupport.length <= 0) return undefined;
|
||||
return { name: hasSupport[0].id, version: plugins.plugins[hasSupport[0].id]?.description.version, possible: hasSupport.some(s => s.possible) };
|
||||
return undefined;
|
||||
}).filter(s => !!s);
|
||||
|
||||
if (hasSupport.length <= 0) return [];
|
||||
return hasSupport;
|
||||
}
|
||||
|
||||
export async function getScoopPackage (id: string, url: string)
|
||||
export function getEmulatorPath (emulator: string)
|
||||
{
|
||||
return path.join(config.get('downloadPath'), "emulators", emulator);
|
||||
}
|
||||
|
||||
export async function getExistingStoreEmulatorDownload (emulator: EmulatorPackageType): Promise<(EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined>
|
||||
{
|
||||
const existingPackagePath = `${getEmulatorPath(emulator.name)}.json`;
|
||||
if (await fs.exists(existingPackagePath))
|
||||
{
|
||||
const existingPackage = await EmulatorDownloadInfoSchema.parseAsync(await Bun.file(existingPackagePath).json());
|
||||
const download = await getEmulatorDownload(emulator, existingPackage.type).catch(d => undefined);
|
||||
if (!download) return { ...existingPackage, hasUpdate: false };
|
||||
if (download.info.version)
|
||||
{
|
||||
if (existingPackage.version !== download.info.version) return { ...existingPackage, hasUpdate: true };
|
||||
} else if (existingPackage.id !== download.info.id)
|
||||
{
|
||||
return { ...existingPackage, hasUpdate: true };
|
||||
}
|
||||
|
||||
return { ...existingPackage, hasUpdate: false };
|
||||
}
|
||||
|
||||
// this should only happen if download info is missing maybe manually deleted or wasn't saved.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function getEmulatorDownload (emulator: EmulatorPackageType, source: string)
|
||||
{
|
||||
if (!emulator.downloads) throw new Error("Emulator has no downloads");
|
||||
|
||||
const validDownloads = emulator.downloads[`${process.platform}:${process.arch}`];
|
||||
if (!validDownloads) throw new Error(`Now downloads in ${emulator.name} for platform ${process.platform}:${process.arch}`);
|
||||
|
||||
const validDownload = validDownloads.find(d => d.type === source);
|
||||
if (!validDownload) throw new Error(`Download type ${source} not found`);
|
||||
|
||||
let downloadUrl: URL;
|
||||
let versionInfo: EmulatorDownloadInfoType = {
|
||||
id: "",
|
||||
downloadDate: new Date(),
|
||||
type: validDownload.type
|
||||
};
|
||||
if (validDownload.type === 'github')
|
||||
{
|
||||
const latestRelease = await getOrCachedGithubRelease(validDownload.path);
|
||||
const glob = new Bun.Glob(validDownload.pattern);
|
||||
const validAsset = latestRelease.assets.find(a => glob.match(a.name));
|
||||
if (!validAsset) throw new Error("Could Not Find Valid Asset");
|
||||
downloadUrl = new URL(validAsset.browser_download_url);
|
||||
versionInfo.version = latestRelease.tag_name;
|
||||
versionInfo.url = latestRelease.url;
|
||||
versionInfo.id = String(latestRelease.id);
|
||||
versionInfo.description = latestRelease.body;
|
||||
|
||||
} else if (validDownload.type === 'direct')
|
||||
{
|
||||
downloadUrl = new URL(validDownload.url);
|
||||
versionInfo.id = validDownload.url;
|
||||
versionInfo.url = validDownload.url;
|
||||
} else if (validDownload.type === 'scoop')
|
||||
{
|
||||
const data = await getOrCachedScoopPackage(emulator.name, validDownload.url);
|
||||
let scoopDownload: URL | undefined;
|
||||
if (data)
|
||||
{
|
||||
if (data.url)
|
||||
{
|
||||
scoopDownload = new URL(data.url);
|
||||
} else if (data.architecture)
|
||||
{
|
||||
if (process.arch === 'x64' && data.architecture["64bit"])
|
||||
{
|
||||
scoopDownload = new URL(data.architecture["64bit"].url);
|
||||
} else if (process.arch === "arm64" && data.architecture["arm64"])
|
||||
{
|
||||
scoopDownload = new URL(data.architecture["arm64"].url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scoopDownload)
|
||||
{
|
||||
downloadUrl = scoopDownload;
|
||||
versionInfo.version = data?.version;
|
||||
versionInfo.url = data?.url;
|
||||
versionInfo.description = data?.description;
|
||||
} else
|
||||
{
|
||||
throw new Error("Could not find scoop download");
|
||||
}
|
||||
} else
|
||||
{
|
||||
throw new Error("Download Type Unsupported");
|
||||
}
|
||||
|
||||
return { url: downloadUrl, info: versionInfo };
|
||||
}
|
||||
|
||||
export async function getOrCachedScoopPackage (id: string, url: string)
|
||||
{
|
||||
const data = await getOrCached(`scoop-dl-${id}`, async () =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,17 +3,16 @@ import Elysia, { status } from "elysia";
|
|||
import { config, db, taskQueue } from "../app";
|
||||
import path from "node:path";
|
||||
import fs from 'node:fs/promises';
|
||||
import { StoreGameSchema } from "@/shared/constants";
|
||||
import { EmulatorDownloadInfoSchema, 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 { CACHE_KEYS, getOrCached } from "../cache";
|
||||
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "./services/gamesService";
|
||||
import { EmulatorDownloadJob } from "../jobs/emulator-download-job";
|
||||
import { Glob } from "bun";
|
||||
import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration } from "./services/emulatorsService";
|
||||
import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration, getEmulatorDownload, getExistingStoreEmulatorDownload } from "./services/emulatorsService";
|
||||
import { BiosDownloadJob } from "../jobs/bios-download-job";
|
||||
|
||||
export const store = new Elysia({ prefix: '/api/store' })
|
||||
|
|
@ -107,6 +106,15 @@ export const store = new Elysia({ prefix: '/api/store' })
|
|||
return Bun.file(path.join(getStoreFolder(), "media", "screenshots", id, name));
|
||||
},
|
||||
{ params: z.object({ id: z.string(), name: z.string() }) })
|
||||
.get('/emulator/:id/update', async ({ params: { id } }) =>
|
||||
{
|
||||
const emulatorPackage = await getStoreEmulatorPackage(id);
|
||||
const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage!);
|
||||
return downloadInfo;
|
||||
},
|
||||
{
|
||||
response: z.union([z.intersection(EmulatorDownloadInfoSchema, z.object({ hasUpdate: z.boolean() })), z.undefined()])
|
||||
})
|
||||
.get('/emulator/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
const emulatorPackage = await getStoreEmulatorPackage(id);
|
||||
|
|
@ -120,6 +128,7 @@ export const store = new Elysia({ prefix: '/api/store' })
|
|||
const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : [];
|
||||
const biosDirPath = path.join(config.get('downloadPath'), 'bios', id);
|
||||
const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : [];
|
||||
const storeDownloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage);
|
||||
|
||||
const emulator: FrontEndEmulatorDetailed = {
|
||||
name: emulatorPackage.name,
|
||||
|
|
@ -129,38 +138,31 @@ export const store = new Elysia({ prefix: '/api/store' })
|
|||
screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`),
|
||||
gameCount: 0,
|
||||
homepage: emulatorPackage.homepage,
|
||||
downloads: await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d =>
|
||||
downloads: (await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d =>
|
||||
{
|
||||
if (d.type === 'github' && d.path)
|
||||
{
|
||||
const release = await getOrCachedGithubRelease(d.path);
|
||||
const glob = new Glob(d.pattern);
|
||||
const download: FrontEndEmulatorDetailedDownload = {
|
||||
name: d.type,
|
||||
type: release.assets.find(a => glob.match(a.name))?.content_type
|
||||
};
|
||||
return download;
|
||||
};
|
||||
|
||||
return { name: d.type, type: "Unknown" };
|
||||
}) ?? []),
|
||||
const download = await getEmulatorDownload(emulatorPackage, d.type).catch(e => undefined);
|
||||
return download?.info;
|
||||
}) ?? [])).filter(d => !!d).map(d => ({ name: d.type, type: d.type, version: d.version })),
|
||||
logo: emulatorPackage.logo,
|
||||
sources: execPaths,
|
||||
biosRequirement: emulatorPackage.bios,
|
||||
bios: biosFiles,
|
||||
integration: findEmulatorPluginIntegration(emulatorPackage.name, execPaths)
|
||||
integrations: findEmulatorPluginIntegration(emulatorPackage.name, execPaths),
|
||||
storeDownloadInfo: storeDownloadInfo,
|
||||
hasUpdate: storeDownloadInfo?.hasUpdate ?? null
|
||||
};
|
||||
|
||||
return emulator;
|
||||
}, { params: z.object({ id: z.string() }) })
|
||||
.post('/install/emulator/:id/:source', async ({ params: { source, id } }) =>
|
||||
.post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) =>
|
||||
{
|
||||
if (taskQueue.hasActiveOfType(EmulatorDownloadJob))
|
||||
{
|
||||
return status("Conflict", "Installation already running");
|
||||
}
|
||||
const job = new EmulatorDownloadJob(id, source);
|
||||
const job = new EmulatorDownloadJob(id, source, { isUpdate });
|
||||
return taskQueue.enqueue(EmulatorDownloadJob.id, job);
|
||||
}, {
|
||||
body: z.object({ isUpdate: z.boolean().optional() })
|
||||
})
|
||||
.delete('/emulator/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue