feat: Implemented emulator versions and updating

This commit is contained in:
Simeon Radivoev 2026-04-03 23:02:22 +03:00
parent a69147a4f7
commit 34db717ec5
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
22 changed files with 434 additions and 212 deletions

View file

@ -303,7 +303,8 @@ export default new Elysia()
validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }], validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }],
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
systems: [], systems: [],
gameCount: 0 gameCount: 0,
integrations: []
} satisfies FrontEndGameTypeDetailedEmulator; } satisfies FrontEndGameTypeDetailedEmulator;
} }
else else
@ -313,7 +314,8 @@ export default new Elysia()
logo: "", logo: "",
systems: [], systems: [],
gameCount: 0, gameCount: 0,
validSources: [] validSources: [],
integrations: []
} satisfies FrontEndGameTypeDetailedEmulator; } satisfies FrontEndGameTypeDetailedEmulator;
} }

View file

@ -11,7 +11,7 @@ import { LaunchGameJob } from '../../jobs/launch-game-job';
import { EmulatorPackageType } from '@/shared/constants'; import { EmulatorPackageType } from '@/shared/constants';
import { getStoreEmulatorPackage, getStoreFolder } from '../../store/services/gamesService'; import { getStoreEmulatorPackage, getStoreFolder } from '../../store/services/gamesService';
import { getOrCached } from '../../cache'; import { getOrCached } from '../../cache';
import { getScoopPackage } from '../../store/services/emulatorsService'; import { getOrCachedScoopPackage } from '../../store/services/emulatorsService';
export const varRegex = /%([^%]+)%/g; export const varRegex = /%([^%]+)%/g;
export const assignRegex = /(%\w+%)=(\S+) /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; let bin: string | undefined = (dl as any).bin;
if (!bin && dl.type === 'scoop') if (!bin && dl.type === 'scoop')
{ {
const data = await getScoopPackage(id, dl.url); const data = await getOrCachedScoopPackage(id, dl.url);
if (data) if (data)
{ {

View file

@ -1,4 +1,5 @@
import { AsyncSeriesBailHook } from "tapable"; import { EmulatorDownloadInfoType, EmulatorPackageType } from "@/shared/constants";
import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable";
export class EmulatorHooks export class EmulatorHooks
{ {
@ -7,4 +8,15 @@ export class EmulatorHooks
systems: EmulatorSystem[]; systems: EmulatorSystem[];
biosFolder: string; biosFolder: string;
}], { auth?: string, files: DownloadFileEntry[]; } | undefined>(['ctx']); }], { 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']);
} }

View file

@ -1,5 +1,5 @@
import { EmulatorPackageType, GameListFilterType } from '@/shared/constants'; 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 export class GameHooks
{ {
@ -13,6 +13,7 @@ export class GameHooks
*/ */
emulatorLaunch = new AsyncSeriesBailHook<[ctx: { emulatorLaunch = new AsyncSeriesBailHook<[ctx: {
autoValidCommand: CommandEntry; autoValidCommand: CommandEntry;
dryRun: boolean,
game: { game: {
source: string; source: string;
id: number; id: number;
@ -20,12 +21,13 @@ export class GameHooks
}], string[] | undefined>(['ctx']); }], string[] | undefined>(['ctx']);
/** /**
* Is the given emulator for the given command supported * 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: { emulatorLaunchSupport = new SyncBailHook<[ctx: {
emulator: string; emulator: string;
source?: EmulatorSourceEntryType; source?: EmulatorSourceEntryType;
}], { id: string; possible: boolean; } | undefined>(['ctx']); }], EmulatorSupport | undefined>(['ctx']);
/** /**
* Fetches and returns a list of games converted to frontend. * Fetches and returns a list of games converted to frontend.
* @param ctx.localGameIds This is local game ids in the format '<source>@<sourceId>' * @param ctx.localGameIds This is local game ids in the format '<source>@<sourceId>'

View file

@ -2,17 +2,15 @@ import { EmulatorPackageType } from "@/shared/constants";
import { getStoreEmulatorPackage } from "../store/services/gamesService"; import { getStoreEmulatorPackage } from "../store/services/gamesService";
import { IJob, JobContext } from "../task-queue"; import { IJob, JobContext } from "../task-queue";
import z from "zod"; import z from "zod";
import { Glob } from "bun"; import { config, plugins } from "../app";
import { config } from "../app";
import path from 'node:path'; import path from 'node:path';
import { getOrCachedGithubRelease } from "../cache";
import Seven from 'node-7z'; import Seven from 'node-7z';
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { Downloader } from "@/bun/utils/downloader"; import { Downloader } from "@/bun/utils/downloader";
import { ensureDir, move } from "fs-extra"; import { ensureDir, move } from "fs-extra";
import { simulateProgress } from "@/bun/utils"; import { simulateProgress } from "@/bun/utils";
import { path7za } from "7zip-bin"; import { path7za } from "7zip-bin";
import { getScoopPackage } from "../store/services/emulatorsService"; import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService";
type EmulatorDownloadStates = "download" | "extract"; type EmulatorDownloadStates = "download" | "extract";
@ -23,73 +21,24 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
emulator: string; emulator: string;
downloadSource: string; downloadSource: string;
emulatorPackage?: EmulatorPackageType; 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.emulator = emulator;
this.downloadSource = downloadSource; this.downloadSource = downloadSource;
this.dryRun = init?.dryRun ?? false; this.dryRun = init?.dryRun ?? false;
this.isUpdate = init?.isUpdate ?? false;
} }
async start (context: JobContext<EmulatorDownloadJob, z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>) async start (context: JobContext<EmulatorDownloadJob, z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>)
{ {
this.emulatorPackage = await getStoreEmulatorPackage(this.emulator); this.emulatorPackage = await getStoreEmulatorPackage(this.emulator);
if (!this.emulatorPackage) throw new Error("Emulator not found"); 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}`]; const emulatorsFolder = getEmulatorPath(this.emulator);
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);
if (this.dryRun) 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 tmpFolder = path.join(config.get("downloadPath"), ".tmp");
const downloader = new Downloader(this.emulator, 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, tmpFolder,
{ {
signal: context.abortSignal, 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 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));
} }
} }

View file

@ -29,8 +29,8 @@ export default class UpdateStoreJob implements IJob<never, never>
const storeFolder = getStoreRootFolder(); const storeFolder = getStoreRootFolder();
await ensureDir(storeFolder); await ensureDir(storeFolder);
console.log("Updating Store"); console.log("Adding Store Package");
const proc = Bun.spawn([process.execPath, "install", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], { let proc = Bun.spawn([process.execPath, "add", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], {
cwd: storeFolder, cwd: storeFolder,
stdout: 'pipe', stdout: 'pipe',
stderr: '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); 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) if (stderr)
console.error(stderr); console.error(stderr);
await proc.exited; await proc.exited;

View file

@ -11,7 +11,12 @@ export default class DOLPHINIntegration implements PluginType
ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) =>
{ {
if (ctx.emulator === 'DOLPHIN') 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) => ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) =>

View file

@ -15,13 +15,26 @@ export default class PCSX2Integration implements PluginType
{ {
if (ctx.emulator === 'PCSX2') 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) => 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"]; const args = ["-batch"];
if (config.get('launchInFullscreen')) if (config.get('launchInFullscreen'))
@ -30,32 +43,35 @@ export default class PCSX2Integration implements PluginType
} }
args.push(...["-bigpicture", "-portable", "--", ctx.autoValidCommand.metadata.romPath]); 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 biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2');
const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2');
const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2');
const view = { const view = {
BIOS_PATH: biosFolder, BIOS_PATH: biosFolder,
SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'), SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'),
SAVE_STATES_PATH: path.join(savesFolder, 'states'), SAVE_STATES_PATH: path.join(savesFolder, 'states'),
MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'), MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'),
CACHE_PATH: path.join(storageFolder, 'cache'), CACHE_PATH: path.join(storageFolder, 'cache'),
COVERS_PATH: path.join(storageFolder, 'covers'), COVERS_PATH: path.join(storageFolder, 'covers'),
TEXTURES_PATH: path.join(storageFolder, 'textures'), TEXTURES_PATH: path.join(storageFolder, 'textures'),
RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), 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 = ''; let pscx2Path = '';
if (process.platform === 'win32') if (process.platform === 'win32')
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis');
else else
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis'); 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; return args;
} }

View file

@ -14,18 +14,31 @@ export default class PCSX2Integration implements PluginType
{ {
load (ctx: PluginContextType) load (ctx: PluginContextType)
{ {
ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) =>
{ {
if (ctx.emulator === 'PPSSPP') 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) => 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"]; const args = [ctx.autoValidCommand.metadata.romPath, "--escape-exit", "--pause-menu-exit"];
if (config.get('launchInFullscreen')) if (config.get('launchInFullscreen'))
@ -33,44 +46,47 @@ export default class PCSX2Integration implements PluginType
args.push("--fullscreen"); args.push("--fullscreen");
} }
let confPath: string | undefined = undefined; if (ctx.autoValidCommand.emulatorSource === 'store' && !ctx.dryRun)
let controlsPath: string | undefined = undefined;
switch (process.platform)
{ {
case "win32": let confPath: string | undefined = undefined;
confPath = configFilePathWin32; let controlsPath: string | undefined = undefined;
controlsPath = configControlsFilePathWin32;
break;
case 'linux':
confPath = configFilePathLinux;
controlsPath = configControlsFilePathLinux;
break;
}
let ppssppPath = ''; switch (process.platform)
if (process.platform === 'win32') {
{ case "win32":
ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'); confPath = configFilePathWin32;
} else controlsPath = configControlsFilePathWin32;
{ break;
//TODO: Use way to set custom memstick path when they support it case 'linux':
ensureDir(path.join(homedir(), '.config', 'ppsspp')); confPath = configFilePathLinux;
ppssppPath = path.join(homedir(), '.config', 'ppsspp', 'PSP', 'SYSTEM'); 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) ensureDir(ppssppPath);
{
const configFileContents = await Bun.file(confPath).text();
await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {}));
}
if (controlsPath) if (confPath)
{ {
const controlsFileContents = await Bun.file(controlsPath).text(); const configFileContents = await Bun.file(confPath).text();
await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); 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; return args;

View file

@ -7,6 +7,7 @@ import { cores } from '../emulatorjs/emulatorjs';
import { SERVER_URL } from '@/shared/constants'; import { SERVER_URL } from '@/shared/constants';
import { findExecsByName } from '../games/services/launchGameService'; import { findExecsByName } from '../games/services/launchGameService';
import { host } from '@/bun/utils/host'; import { host } from '@/bun/utils/host';
import { findEmulatorPluginIntegration } from '../store/services/emulatorsService';
/** /**
* Get emulators based on local games. Only the ones we probably need. * 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 ?? '' })), 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, gameCount: 0,
isCritical: false, isCritical: false,
validSources: execPaths validSources: execPaths,
integrations: findEmulatorPluginIntegration(emulator, execPaths)
}; };
return em; return em;
@ -86,7 +88,8 @@ export async function getRelevantEmulators ()
systems: [], systems: [],
gameCount: 0, gameCount: 0,
isCritical: false, isCritical: false,
description: "Embedded Emulator. Uses Retroarch Cores" description: "Embedded Emulator. Uses Retroarch Cores",
integrations: []
}); });
return finalEmulators.map(e => return finalEmulators.map(e =>

View file

@ -1,9 +1,11 @@
import { EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants"; import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants";
import { emulatorsDb, plugins } from "../../app"; import { config, emulatorsDb, plugins } from "../../app";
import * as emulatorSchema from '@schema/emulators'; import * as emulatorSchema from '@schema/emulators';
import { findExecs } from "../../games/services/launchGameService"; import { findExecs } from "../../games/services/launchGameService";
import { eq } from "drizzle-orm"; 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[]) export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: EmulatorSystem[])
{ {
@ -22,21 +24,130 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT
systems, systems,
gameCount, gameCount,
validSources: execPaths, validSources: execPaths,
integration: findEmulatorPluginIntegration(emulator.name, execPaths) integrations: findEmulatorPluginIntegration(emulator.name, execPaths)
}; };
return em; 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 undefined;
return { name: hasSupport[0].id, version: plugins.plugins[hasSupport[0].id]?.description.version, possible: hasSupport.some(s => s.possible) }; }).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 () => const data = await getOrCached(`scoop-dl-${id}`, async () =>
{ {

View file

@ -3,17 +3,16 @@ import Elysia, { status } from "elysia";
import { config, db, taskQueue } from "../app"; import { config, db, taskQueue } from "../app";
import path from "node:path"; import path from "node:path";
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { StoreGameSchema } from "@/shared/constants"; import { EmulatorDownloadInfoSchema, StoreGameSchema } from "@/shared/constants";
import { findExecsByName } from "../games/services/launchGameService"; import { findExecsByName } from "../games/services/launchGameService";
import * as appSchema from '@schema/app'; import * as appSchema from '@schema/app';
import z from "zod"; import z from "zod";
import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
import { getPlatformsApiPlatformsGet } from "@/clients/romm"; 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 { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "./services/gamesService";
import { EmulatorDownloadJob } from "../jobs/emulator-download-job"; import { EmulatorDownloadJob } from "../jobs/emulator-download-job";
import { Glob } from "bun"; import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration, getEmulatorDownload, getExistingStoreEmulatorDownload } from "./services/emulatorsService";
import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration } from "./services/emulatorsService";
import { BiosDownloadJob } from "../jobs/bios-download-job"; import { BiosDownloadJob } from "../jobs/bios-download-job";
export const store = new Elysia({ prefix: '/api/store' }) 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)); return Bun.file(path.join(getStoreFolder(), "media", "screenshots", id, name));
}, },
{ params: z.object({ id: z.string(), name: z.string() }) }) { 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 } }) => .get('/emulator/:id', async ({ params: { id } }) =>
{ {
const emulatorPackage = await getStoreEmulatorPackage(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 screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : [];
const biosDirPath = path.join(config.get('downloadPath'), 'bios', id); const biosDirPath = path.join(config.get('downloadPath'), 'bios', id);
const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : []; const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : [];
const storeDownloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage);
const emulator: FrontEndEmulatorDetailed = { const emulator: FrontEndEmulatorDetailed = {
name: emulatorPackage.name, 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}`), screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`),
gameCount: 0, gameCount: 0,
homepage: emulatorPackage.homepage, 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 download = await getEmulatorDownload(emulatorPackage, d.type).catch(e => undefined);
{ return download?.info;
const release = await getOrCachedGithubRelease(d.path); }) ?? [])).filter(d => !!d).map(d => ({ name: d.type, type: d.type, version: d.version })),
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" };
}) ?? []),
logo: emulatorPackage.logo, logo: emulatorPackage.logo,
sources: execPaths,
biosRequirement: emulatorPackage.bios, biosRequirement: emulatorPackage.bios,
bios: biosFiles, bios: biosFiles,
integration: findEmulatorPluginIntegration(emulatorPackage.name, execPaths) integrations: findEmulatorPluginIntegration(emulatorPackage.name, execPaths),
storeDownloadInfo: storeDownloadInfo,
hasUpdate: storeDownloadInfo?.hasUpdate ?? null
}; };
return emulator; return emulator;
}, { params: z.object({ id: z.string() }) }) }, { 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)) if (taskQueue.hasActiveOfType(EmulatorDownloadJob))
{ {
return status("Conflict", "Installation already running"); 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); return taskQueue.enqueue(EmulatorDownloadJob.id, job);
}, {
body: z.object({ isUpdate: z.boolean().optional() })
}) })
.delete('/emulator/:id', async ({ params: { id } }) => .delete('/emulator/:id', async ({ params: { id } }) =>
{ {

View file

@ -12,7 +12,7 @@ export default function FocusTooltip (data: { parentRef: RefObject<any>; visible
{ {
const dataTooltip = e.getAttribute('data-tooltip'); const dataTooltip = e.getAttribute('data-tooltip');
setHoverText(dataTooltip ?? undefined); setHoverText(dataTooltip ?? undefined);
setHoverTextType(e.getAttribute('data-tooltip_type') ?? 'accent'); setHoverTextType(e.getAttribute('data-tooltip-type') ?? 'accent');
}; };
const { isPointer } = useActiveControl(); const { isPointer } = useActiveControl();
@ -29,7 +29,10 @@ export default function FocusTooltip (data: { parentRef: RefObject<any>; visible
const tooltipStyles = { const tooltipStyles = {
base: 'bg-base-100 text-base-content', base: 'bg-base-100 text-base-content',
accent: 'bg-accent text-accent-content', accent: 'bg-accent text-accent-content',
error: 'bg-error text-error-content' error: 'bg-error text-error-content',
warning: 'bg-warning text-warning-content',
info: 'bg-info text-info-content',
success: 'bg-success text-success-content'
}; };
return !!hoverText && (data.visible ?? true) && !isPointer && <p className={twMerge("flex sm:hidden md:inline py-1 md:py-2 md:px-4 rounded-4xl text-wrap wrap-anywhere text-base", (tooltipStyles as any)[hoverTextType])}>{hoverText}</p>; return !!hoverText && (data.visible ?? true) && !isPointer && <p className={twMerge("flex sm:hidden md:inline py-1 md:py-2 md:px-4 rounded-4xl text-wrap wrap-anywhere text-base", (tooltipStyles as any)[hoverTextType])}>{hoverText}</p>;

View file

@ -29,7 +29,7 @@ export default function StatList (data: {
return <ul ref={ref} className="grid md:grid-cols-[8rem_1fr] sm:px-8 md:px-16 py-4 gap-2 focused:border-y focused:border-dashed focused:border-base-content/40"> return <ul ref={ref} className="grid md:grid-cols-[8rem_1fr] sm:px-8 md:px-16 py-4 gap-2 focused:border-y focused:border-dashed focused:border-base-content/40">
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
{data.stats.map((s, i) => {data.stats.flatMap((s, i) =>
{ {
let content: any = undefined; let content: any = undefined;
if (s.content instanceof Array) if (s.content instanceof Array)
@ -37,13 +37,9 @@ export default function StatList (data: {
content = <div key={`label-items-${i}`} className="flex flex-wrap gap-2">{s.content.map((c, ci) => <span key={`label-items-${i}-${ci}`} className={twMerge("rounded-3xl bg-base-200 px-3 py-1", data.elementClassName)}>{c}</span>)}</div>; content = <div key={`label-items-${i}`} className="flex flex-wrap gap-2">{s.content.map((c, ci) => <span key={`label-items-${i}-${ci}`} className={twMerge("rounded-3xl bg-base-200 px-3 py-1", data.elementClassName)}>{c}</span>)}</div>;
} else } else
{ {
content = <div key={`label-element-${i}`} className={twMerge("flex gap-2 rounded-3xl bg-base-200 px-3 py-1", data.elementClassName)}>{s.icon}{s.content}</div>; content = <div key={`label-element-${i}`} className={twMerge("flex gap-2 rounded-2xl bg-base-200 px-3 py-2", data.elementClassName)}>{s.icon}{s.content}</div>;
} }
const element = <> return [<Label key={`label-${i}`} id={`${data.id}-label-${i}`} label={s.label} />, <div key={`content-${i}`}>{content}</div>];
<Label id={`${data.id}-label-${i}`} key={`label-${i}`} label={s.label} />
{content}
</>;
return element;
})} })}
</FocusContext> </FocusContext>
</ul>; </ul>;

View file

@ -31,7 +31,7 @@ export default function ActionButton (data: {
ref={ref} ref={ref}
onClick={data.onAction} onClick={data.onAction}
data-tooltip={data.tooltip} data-tooltip={data.tooltip}
data-tooltip_type={data.tooltip_type} data-tooltip-type={data.tooltip_type}
className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30 active:bg-base-100 active:transition-none active:text-base-content", className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30 active:bg-base-100 active:transition-none active:text-base-content",
"hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md:size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}> "hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md:size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}>
{data.icon} {data.icon}

View file

@ -137,7 +137,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
mainButton = <ActionButton mainButton = <ActionButton
key="error" key="error"
tooltip={error} tooltip={error}
tooltip_type="error" tooltip-type="error"
type='error' type='error'
onAction={() => onAction={() =>
{ {

View file

@ -33,7 +33,7 @@ export function Button (data: {
focusClassName?: string; focusClassName?: string;
cssStyle?: CSSProperties; cssStyle?: CSSProperties;
tooltip?: string; tooltip?: string;
tooltipType?: "base" | "accent" | "error"; tooltipType?: "base" | "accent" | "error" | "warning";
} & InteractParams & FocusParams) } & InteractParams & FocusParams)
{ {
const handleAction = (e?: any) => const handleAction = (e?: any) =>
@ -58,7 +58,7 @@ export function Button (data: {
onClick={handleAction} onClick={handleAction}
disabled={data.disabled} disabled={data.disabled}
data-tooltip={data.tooltip} data-tooltip={data.tooltip}
data-tooltip_type={data.tooltipType} data-tooltip-type={data.tooltipType}
style={data.cssStyle} style={data.cssStyle}
className={twMerge("flex items-center justify-center px-4 py-2 disabled:bg-base-200/40 disabled:text-base-content/40 not-disabled:cursor-pointer rounded-3xl md:text-lg not-control-mouse:focused:drop-shadow-lg border border-base-content/5 not-control-mouse:focused:bg-base-content not-control-mouse:focused:text-base-100 control-mouse:hover:not-disabled:bg-base-content control-mouse:hover:not-disabled:text-base-100 active:not-disabled:transition-none active:not-disabled:ring-offset-4", className={twMerge("flex items-center justify-center px-4 py-2 disabled:bg-base-200/40 disabled:text-base-content/40 not-disabled:cursor-pointer rounded-3xl md:text-lg not-control-mouse:focused:drop-shadow-lg border border-base-content/5 not-control-mouse:focused:bg-base-content not-control-mouse:focused:text-base-100 control-mouse:hover:not-disabled:bg-base-content control-mouse:hover:not-disabled:text-base-100 active:not-disabled:transition-none active:not-disabled:ring-offset-4",
styles[data.style ?? 'base'], styles[data.style ?? 'base'],

View file

@ -5,11 +5,13 @@ import { Button } from "../options/Button";
import useActiveControl from "@/mainview/scripts/gamepads"; import useActiveControl from "@/mainview/scripts/gamepads";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { BadgeCheck, ChevronRight, EllipsisVertical, FileQuestion, IceCream2, Package, Sparkles, Store, WandSparkles } from "lucide-react"; import { BadgeCheck, ChevronRight, CircleFadingArrowUp, EllipsisVertical, FileQuestion, IceCream2, Package, Sparkles, Store, WandSparkles } from "lucide-react";
import { FOCUS_KEYS } from "@/mainview/scripts/types"; import { FOCUS_KEYS } from "@/mainview/scripts/types";
import { FlatpackIcon } from "@/mainview/scripts/brandIcons"; import { FlatpackIcon } from "@/mainview/scripts/brandIcons";
import { JSX } from "react"; import { JSX } from "react";
import { oneShot } from "@/mainview/scripts/audio/audio"; import { oneShot } from "@/mainview/scripts/audio/audio";
import { useQuery } from "@tanstack/react-query";
import { getUpdateInfoForEmulator } from "@/mainview/scripts/queries/store";
export const emulatorStatusIcons: Record<string, JSX.Element> = { export const emulatorStatusIcons: Record<string, JSX.Element> = {
store: <Store />, store: <Store />,
@ -42,8 +44,9 @@ export function StoreEmulatorCard (data: {
} }
}); });
const { data: updateInfo } = useQuery(getUpdateInfoForEmulator(data.emulator.name));
useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]); useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]);
const { isMouse, isTouch } = useActiveControl();
return ( return (
<div <div
@ -52,8 +55,8 @@ export function StoreEmulatorCard (data: {
tabIndex={0} tabIndex={0}
data-sound-category="emulator" data-sound-category="emulator"
data-installed={data.emulator.validSources.some(s => s.exists)} data-installed={data.emulator.validSources.some(s => s.exists)}
onClick={isTouch ? handleSelect : undefined} onClick={handleSelect}
className={twMerge("relative focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none", data.className)} className={twMerge("relative focusable focusable-info focusable-hover bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none cursor-pointer", data.className)}
> >
<div className="flex flex-col justify-between p-4 gap-2 h-full"> <div className="flex flex-col justify-between p-4 gap-2 h-full">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@ -81,21 +84,27 @@ export function StoreEmulatorCard (data: {
</div> </div>
<div className="flex gap-1 mt-1 h-10 items-center"> <div className="flex gap-1 mt-1 h-10 items-center">
{!!data.emulator.integration && <div aria-disabled={!data.emulator.integration.possible} className="tooltip not-aria-disabled:tooltip-primary" data-tip={data.emulator.integration.possible ? "Has Integration" : "Can Integrate"}> {updateInfo?.hasUpdate && <div className="tooltip" data-tip="Has Update">
<div className="bg-primary in-aria-disabled:bg-base-200 text-primary-content rounded-full p-1.5"><WandSparkles className="size-5" /></div> <div className="flex items-center justify-center rounded-full p-1 size-8 bg-warning text-warning-content">
<CircleFadingArrowUp />
</div>
</div>}
{data.emulator.integrations.length > 0 && <div
aria-disabled={!data.emulator.integrations.some(i => i.supportLevel)}
data-full-support={data.emulator.integrations.some(i => i.supportLevel === 'full')}
className="tooltip not-aria-disabled:tooltip-primary"
data-tip={data.emulator.integrations.some(i => i.supportLevel) ? data.emulator.integrations.some(i => i.supportLevel === 'full') ? "Full Support" : "Partial SUpport" : "Can Integrate"}
>
<div className="bg-primary in-data-[full-support=false]:bg-warning in-data-[full-support=false]:text-warning-content in-aria-disabled:bg-base-200 in-aria-disabled:text-base-content text-primary-content rounded-full p-1.5"><WandSparkles className="size-5" /></div>
</div>} </div>}
{data.emulator.validSources.slice(0, 3).map(s => {data.emulator.validSources.slice(0, 3).map(s =>
{ {
return <div className="tooltip" data-tip={s.type}> return <div className="tooltip" data-tip={s.type}>
<div data-source={s.type} className="flex items-center justify-center rounded-full p-1 size-8 bg-warning text-warning-content data-[source=store]:bg-success data-[source=store]:text-success-content"> <div data-source={s.type} className="flex items-center justify-center rounded-full p-1 size-8 bg-base-300 text-base-content data-[source=store]:bg-success data-[source=store]:text-success-content">
{emulatorStatusIcons[s.type]} {emulatorStatusIcons[s.type]}
</div> </div>
</div>; </div>;
})} })}
{isMouse && <>
<Button onAction={e => data.onSelect?.(data.emulator.name, focusKey)} style="base" className="grow text-base-content/40" id={`${data.emulator.name}-details`} >Details<ChevronRight /></Button>
</>}
</div> </div>
</div> </div>
</div> </div>

View file

@ -10,7 +10,7 @@ import Shortcuts from "@/mainview/components/Shortcuts";
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
import { systemApi } from "@/mainview/scripts/clientApi"; import { systemApi } from "@/mainview/scripts/clientApi";
import { Button } from "@/mainview/components/options/Button"; import { Button } from "@/mainview/components/options/Button";
import { ChevronDown, Cpu, Download, Gamepad2, Info, Puzzle, Settings, Trash2, TriangleAlert, WandSparkles } from "lucide-react"; import { ChevronDown, CircleFadingArrowUp, Cpu, Download, Gamepad2, Info, Puzzle, Settings, Trash2, TriangleAlert, WandSparkles } from "lucide-react";
import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog"; import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog";
import { RPC_URL } from "@/shared/constants"; import { RPC_URL } from "@/shared/constants";
import Screenshots from "@/mainview/components/Screenshots"; import Screenshots from "@/mainview/components/Screenshots";
@ -59,6 +59,7 @@ function HomePageLink (data: { homepage?: string; })
function TitleArea (data: { function TitleArea (data: {
emulator?: FrontEndEmulatorDetailed; emulator?: FrontEndEmulatorDetailed;
onInstall: (source: string) => void; onInstall: (source: string) => void;
onUpdate: (source: string) => void;
}) })
{ {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -70,6 +71,7 @@ function TitleArea (data: {
}, },
}); });
const downloadBios = useMutation(downloadBiosMutation(data.emulator?.name ?? '')); const downloadBios = useMutation(downloadBiosMutation(data.emulator?.name ?? ''));
const updateToVersion = data.emulator?.downloads.find(d => d.version === data.emulator!.storeDownloadInfo?.type)?.version ?? data.emulator?.downloads[0]?.version;
const deleteBios = useMutation({ const deleteBios = useMutation({
...deleteBiosMutation, ...deleteBiosMutation,
onSuccess (data, variables, onMutateResult, context) onSuccess (data, variables, onMutateResult, context)
@ -122,7 +124,7 @@ function TitleArea (data: {
const isInstalling = !!installJob || !!biosInstallJob; const isInstalling = !!installJob || !!biosInstallJob;
const options: DialogEntry[] = []; const options: DialogEntry[] = [];
const installedFromStore = !!data.emulator?.sources.find(s => s.type === 'store' && s.exists); const installedFromStore = !!data.emulator?.validSources.find(s => s.type === 'store' && s.exists);
if (data.emulator) if (data.emulator)
{ {
if (!isInstalling && !installedFromStore) if (!isInstalling && !installedFromStore)
@ -155,6 +157,22 @@ function TitleArea (data: {
id: "delete" id: "delete"
}); });
if ((!data.emulator.storeDownloadInfo || data.emulator.storeDownloadInfo.hasUpdate))
{
options.push({
content: `Update ${data.emulator.storeDownloadInfo?.type}: ${data.emulator.storeDownloadInfo?.version ?? "Unknown"} > ${updateToVersion}`,
type: 'warning',
icon: <CircleFadingArrowUp />,
action (ctx)
{
const source = data.emulator?.storeDownloadInfo?.type ?? data.emulator?.downloads[0]?.type;
if (source) data.onUpdate(source);
ctx.close();
},
id: 'update'
});
}
if (!data.emulator.bios || data.emulator.bios.length <= 0) if (!data.emulator.bios || data.emulator.bios.length <= 0)
{ {
options.push({ options.push({
@ -183,7 +201,6 @@ function TitleArea (data: {
id: "download-bios" id: "download-bios"
}); });
} }
} }
} }
@ -253,13 +270,16 @@ function TitleArea (data: {
{!!data.emulator?.bios?.[0] && <div className="tooltip" data-tip="Has BIOS"> {!!data.emulator?.bios?.[0] && <div className="tooltip" data-tip="Has BIOS">
<div className="flex items-center justify-center bg-base-200 p-2 rounded-full"><Cpu className="size-5" /></div> <div className="flex items-center justify-center bg-base-200 p-2 rounded-full"><Cpu className="size-5" /></div>
</div>} </div>}
{data.emulator && !!data.emulator.integration && data.emulator.validSources.some(s => s.type === 'store') && <div className="tooltip" data-tip="Has Integration"> {data.emulator && data.emulator.integrations.length > 0 && <div className="tooltip" data-tip="Has Integration">
<div className="bg-base-200 rounded-full p-2"><WandSparkles className="size-5" /></div> <div className="bg-base-200 rounded-full p-2"><WandSparkles className="size-5" /></div>
</div>} </div>}
</div> </div>
</div> </div>
<div className="flex relative sm:portrait:grow md:grow-0 justify-center gap-4 items-center"> <div className="flex relative sm:portrait:grow md:grow-0 justify-center gap-4 items-center">
<FocusTooltip visible={hasFocusedChild} parentRef={ref} /> <FocusTooltip visible={hasFocusedChild} parentRef={ref} />
{(data.emulator?.storeDownloadInfo?.hasUpdate || !data.emulator?.storeDownloadInfo) && installedFromStore && !!updateToVersion && <div className="tooltip tooltip-warning" data-tip="Update Available">
<Button id="update-warning-bt" tooltipType="warning" tooltip="Update Available" style="warning" className="rounded-full size-14 focusable focusable-warning shadow-lg" onAction={() => setOpen(true, 'update-warning-bt')}><CircleFadingArrowUp /></Button>
</div>}
{(!data.emulator?.bios || data.emulator.bios.length <= 0) && (data.emulator?.biosRequirement === 'required') && installedFromStore && <div className="tooltip tooltip-error" data-tip="Missing BIOS"> {(!data.emulator?.bios || data.emulator.bios.length <= 0) && (data.emulator?.biosRequirement === 'required') && installedFromStore && <div className="tooltip tooltip-error" data-tip="Missing BIOS">
<Button id="bios-warning-bt" tooltipType="error" tooltip="Missing BIOS" style="error" className="rounded-full size-14 focusable focusable-error shadow-lg" onAction={() => setOpen(true, 'bios-warning-bt')}><TriangleAlert /></Button> <Button id="bios-warning-bt" tooltipType="error" tooltip="Missing BIOS" style="error" className="rounded-full size-14 focusable focusable-error shadow-lg" onAction={() => setOpen(true, 'bios-warning-bt')}><TriangleAlert /></Button>
</div>} </div>}
@ -310,7 +330,8 @@ export function RouteComponent ()
}], [router]); }], [router]);
const installMutation = useMutation({ const installMutation = useMutation({
...installEmulatorMutation(id), onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)), ...installEmulatorMutation(id),
onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)),
}); });
const { shortcuts } = useShortcutContext(); const { shortcuts } = useShortcutContext();
@ -320,21 +341,33 @@ export function RouteComponent ()
{ {
if (emulator.keywords) if (emulator.keywords)
stats.push({ label: "Tags", content: emulator.keywords }); stats.push({ label: "Tags", content: emulator.keywords });
if (emulator.storeDownloadInfo)
stats.push({ label: "Version", content: `${emulator.storeDownloadInfo.version ?? "Unknown"} (${emulator.storeDownloadInfo.type})` });
stats.push({ label: "Systems", content: emulator.systems.map(s => s.name) }); stats.push({ label: "Systems", content: emulator.systems.map(s => s.name) });
stats.push(...emulator.sources.flatMap(s => [{ stats.push(...emulator.validSources.flatMap(s => [{
label: "Source", content: <div className="flex flex-wrap gap-1 p-1"> label: "Source", content: <div className="flex flex-col grow">
<div className="flex gap-1 flex-1">{emulatorStatusIcons[s.type]}{s.type}:</div> <div className="flex grow flex-wrap justify-between gap-1">
<div className="grow text-base-content/40">{s.binPath}</div> <div className="flex gap-1">{emulatorStatusIcons[s.type]}{s.type}</div>
<div className="text-base-content/40">{s.binPath}</div>
</div>
{emulator.integrations.some(i => i.source?.type === s.type) && <div className="divider m-0"></div>}
{emulator.integrations.filter(i => i.source?.type === s.type).map(i =>
{
return <div key={i.id} className="flex flex-wrap justify-between gap-1">
<div className="flex gap-2">
<Puzzle />
<div>{i.id}</div>
</div>
<div className="text-base-content/40">{`${i.capabilities?.join(", ")}`}</div>
</div>;
})}
</div> </div>
}])); }]));
if (emulator.bios) if (emulator.bios)
stats.push({ stats.push({
label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios : <div className="text-warning font-semibold">Missing</div> label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios : <div className="text-warning font-semibold">Missing</div>
}); });
if (emulator.integration)
{
stats.push({ label: "Integration", icon: <Puzzle />, content: `${emulator.integration.name} (${emulator.integration.version})` });
}
} }
return ( return (
@ -344,7 +377,7 @@ export function RouteComponent ()
<StickyHeaderUI ref={ref} /> <StickyHeaderUI ref={ref} />
<div className="flex flex-col z-10"> <div className="flex flex-col z-10">
<div className="w-full sm:px-8 md:px-16 pb-8 pt-12"> <div className="w-full sm:px-8 md:px-16 pb-8 pt-12">
<TitleArea emulator={emulator} onInstall={installMutation.mutate} /> <TitleArea emulator={emulator} onInstall={s => installMutation.mutate({ source: s, isUpdate: false })} onUpdate={s => installMutation.mutate({ source: s, isUpdate: true })} />
<div className='mobile:hidden left-0 top-0 absolute bg-gradient'></div> <div className='mobile:hidden left-0 top-0 absolute bg-gradient'></div>
<div className='mobile:hidden left-0 top-0 absolute bg-noise'></div> <div className='mobile:hidden left-0 top-0 absolute bg-noise'></div>

View file

@ -64,9 +64,9 @@ export const storeGetStatsQuery = queryOptions({
}); });
export const installEmulatorMutation = (id: string) => mutationOptions({ export const installEmulatorMutation = (id: string) => mutationOptions({
mutationKey: ['install', 'emulator', id], mutationKey: ['install', 'emulator', id],
mutationFn: async (source: string) => mutationFn: async (ctx: { source: string, isUpdate: boolean; }) =>
{ {
const { data, error } = await storeApi.api.store.install.emulator({ id })({ source }).post(); const { data, error } = await storeApi.api.store.install.emulator({ id })({ source: ctx.source }).post({ isUpdate: ctx.isUpdate });
if (error) throw error; if (error) throw error;
return data; return data;
} }
@ -86,3 +86,11 @@ export const deleteBiosMutation = mutationOptions({
if (error) throw error; if (error) throw error;
} }
}); });
export const getUpdateInfoForEmulator = (id: string) => queryOptions({
queryKey: ['emulator', 'update'], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.emulator({ id }).update.get();
if (error) throw error;
return data;
}
});

View file

@ -121,7 +121,13 @@ export const EmulatorPackageSchema = z.object({
export const ScoopPackageSchema = z.object({ export const ScoopPackageSchema = z.object({
version: z.string(), version: z.string(),
url: z.url().optional(), url: z.url().optional(),
architecture: z.record(z.string(), z.object({ url: z.url(), hash: z.string().optional() })).optional() description: z.string(),
bin: z.string().optional(),
architecture: z.record(z.string(), z.object({
url: z.url(),
hash: z.string().optional(),
extract_dir: z.string().optional()
})).optional()
}); });
export const SystemInfoSchema = z.object({ export const SystemInfoSchema = z.object({
@ -137,6 +143,10 @@ export const SystemInfoSchema = z.object({
}); });
export const GithubReleaseSchema = z.object({ export const GithubReleaseSchema = z.object({
id: z.number(),
tag_name: z.string().optional(),
url: z.url(),
body: z.string(),
assets: z.array(z.object({ assets: z.array(z.object({
name: z.string(), name: z.string(),
browser_download_url: z.url(), browser_download_url: z.url(),
@ -144,9 +154,19 @@ export const GithubReleaseSchema = z.object({
})) }))
}); });
export const EmulatorDownloadInfoSchema = z.object({
id: z.string(),
version: z.string().optional(),
url: z.url().optional(),
description: z.string().optional(),
downloadDate: z.coerce.date(),
type: z.string()
});
export type EmulatorPackageType = z.infer<typeof EmulatorPackageSchema>; export type EmulatorPackageType = z.infer<typeof EmulatorPackageSchema>;
export type StoreGameType = z.infer<typeof StoreGameSchema>; export type StoreGameType = z.infer<typeof StoreGameSchema>;
export type SettingsType = z.infer<typeof SettingsSchema>; export type SettingsType = z.infer<typeof SettingsSchema>;
export type LocalSettingsType = z.infer<typeof LocalSettingsSchema>; export type LocalSettingsType = z.infer<typeof LocalSettingsSchema>;
export const PlatformSchema = z.object({ slug: z.string() }); export const PlatformSchema = z.object({ slug: z.string() });
export type SystemInfoType = z.infer<typeof SystemInfoSchema>; export type SystemInfoType = z.infer<typeof SystemInfoSchema>;
export type EmulatorDownloadInfoType = z.infer<typeof EmulatorDownloadInfoSchema>;

View file

@ -16,11 +16,7 @@ declare interface FrontEndEmulator
description?: string; description?: string;
gameCount: number; gameCount: number;
validSources: EmulatorSourceEntryType[]; validSources: EmulatorSourceEntryType[];
integration?: { integrations: EmulatorSupport[];
name: string;
version?: string;
possible: boolean;
};
} }
declare interface EmulatorSystem { id: string, romm_slug?: string, name: string, iconUrl: string; } declare interface EmulatorSystem { id: string, romm_slug?: string, name: string, iconUrl: string; }
@ -29,6 +25,7 @@ declare interface FrontEndEmulatorDetailedDownload
{ {
name: string; name: string;
type: string | undefined; type: string | undefined;
version?: string;
} }
declare interface FrontEndEmulatorDetailed extends FrontEndEmulator declare interface FrontEndEmulatorDetailed extends FrontEndEmulator
@ -38,9 +35,9 @@ declare interface FrontEndEmulatorDetailed extends FrontEndEmulator
downloads: FrontEndEmulatorDetailedDownload[]; downloads: FrontEndEmulatorDetailedDownload[];
keywords?: string[]; keywords?: string[];
screenshots: string[]; screenshots: string[];
sources: EmulatorSourceEntryType[];
biosRequirement?: "required" | "optional"; biosRequirement?: "required" | "optional";
bios?: string[]; bios?: string[];
storeDownloadInfo?: { hasUpdate: boolean; version?: string, type: string; };
} }
declare interface FrontEndGameTypeDetailedAchievement declare interface FrontEndGameTypeDetailedAchievement
@ -266,3 +263,13 @@ declare interface FrontEndCollection
path_platform_cover: string | null; path_platform_cover: string | null;
game_count: number; game_count: number;
} }
declare type EmulatorCapabilities = "saves" | "fullscreen" | "resolution" | "batch" | "states" | "config";
declare interface EmulatorSupport
{
id: string;
source?: EmulatorSourceEntryType;
supportLevel?: "partial" | "full";
capabilities?: EmulatorCapabilities[];
}