From 34db717ec5cbcf8b1ae54fbda33bf9a78f01bd17 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Fri, 3 Apr 2026 23:02:22 +0300 Subject: [PATCH] feat: Implemented emulator versions and updating --- src/bun/api/games/games.ts | 6 +- .../api/games/services/launchGameService.ts | 4 +- src/bun/api/hooks/emulators.ts | 14 +- src/bun/api/hooks/games.ts | 8 +- src/bun/api/jobs/emulator-download-job.ts | 79 +++-------- src/bun/api/jobs/update-store.ts | 26 +++- .../dolphin.ts | 7 +- .../pcsx2.ts | 62 +++++---- .../ppsspp.ts | 88 +++++++----- src/bun/api/settings/services.ts | 7 +- .../api/store/services/emulatorsService.ts | 129 ++++++++++++++++-- src/bun/api/store/store.ts | 46 ++++--- src/mainview/components/FocusTooltip.tsx | 7 +- src/mainview/components/StatList.tsx | 10 +- src/mainview/components/game/ActionButton.tsx | 2 +- src/mainview/components/game/MainActions.tsx | 2 +- src/mainview/components/options/Button.tsx | 4 +- .../components/store/StoreEmulatorCard.tsx | 31 +++-- .../routes/store/details.emulator.$id.tsx | 61 +++++++-- src/mainview/scripts/queries/store.ts | 12 +- src/shared/constants.ts | 22 ++- src/shared/types..d.ts | 19 ++- 22 files changed, 434 insertions(+), 212 deletions(-) diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 9666d44..73b9e1f 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -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; } diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index 894f6db..a91e6ca 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -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) { diff --git a/src/bun/api/hooks/emulators.ts b/src/bun/api/hooks/emulators.ts index f48ea9f..ed2d742 100644 --- a/src/bun/api/hooks/emulators.ts +++ b/src/bun/api/hooks/emulators.ts @@ -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']); } \ No newline at end of file diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index 824c59c..ea50476 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -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 '@' diff --git a/src/bun/api/jobs/emulator-download-job.ts b/src/bun/api/jobs/emulator-download-job.ts index 42d20b6..74e13d2 100644 --- a/src/bun/api/jobs/emulator-download-job.ts +++ b/src/bun/api/jobs/emulator-download-job.ts @@ -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, 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 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 } }); - 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; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index dc0e28d..af12a7d 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -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) => diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index e4c2cbd..c2de7e3 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -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; } diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index 7fb3fd9..fddf25c 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -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; diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index afaa5fe..7b7a89e 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -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 => diff --git a/src/bun/api/store/services/emulatorsService.ts b/src/bun/api/store/services/emulatorsService.ts index d326fe6..1682ecb 100644 --- a/src/bun/api/store/services/emulatorsService.ts +++ b/src/bun/api/store/services/emulatorsService.ts @@ -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 () => { diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 074d8bd..2736fe2 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -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 } }) => { diff --git a/src/mainview/components/FocusTooltip.tsx b/src/mainview/components/FocusTooltip.tsx index 5a165b1..6916c1e 100644 --- a/src/mainview/components/FocusTooltip.tsx +++ b/src/mainview/components/FocusTooltip.tsx @@ -12,7 +12,7 @@ export default function FocusTooltip (data: { parentRef: RefObject; visible { const dataTooltip = e.getAttribute('data-tooltip'); setHoverText(dataTooltip ?? undefined); - setHoverTextType(e.getAttribute('data-tooltip_type') ?? 'accent'); + setHoverTextType(e.getAttribute('data-tooltip-type') ?? 'accent'); }; const { isPointer } = useActiveControl(); @@ -29,7 +29,10 @@ export default function FocusTooltip (data: { parentRef: RefObject; visible const tooltipStyles = { base: 'bg-base-100 text-base-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 &&

{hoverText}

; diff --git a/src/mainview/components/StatList.tsx b/src/mainview/components/StatList.tsx index 3ff22f9..de1c231 100644 --- a/src/mainview/components/StatList.tsx +++ b/src/mainview/components/StatList.tsx @@ -29,7 +29,7 @@ export default function StatList (data: { return
    - {data.stats.map((s, i) => + {data.stats.flatMap((s, i) => { let content: any = undefined; if (s.content instanceof Array) @@ -37,13 +37,9 @@ export default function StatList (data: { content =
    {s.content.map((c, ci) => {c})}
    ; } else { - content =
    {s.icon}{s.content}
    ; + content =
    {s.icon}{s.content}
    ; } - const element = <> -
; diff --git a/src/mainview/components/game/ActionButton.tsx b/src/mainview/components/game/ActionButton.tsx index c0f3b78..3e5ebed 100644 --- a/src/mainview/components/game/ActionButton.tsx +++ b/src/mainview/components/game/ActionButton.tsx @@ -31,7 +31,7 @@ export default function ActionButton (data: { ref={ref} onClick={data.onAction} 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", "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} diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index e89f910..2ee89c6 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -137,7 +137,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so mainButton = { diff --git a/src/mainview/components/options/Button.tsx b/src/mainview/components/options/Button.tsx index faef3fa..c9ba6a3 100644 --- a/src/mainview/components/options/Button.tsx +++ b/src/mainview/components/options/Button.tsx @@ -33,7 +33,7 @@ export function Button (data: { focusClassName?: string; cssStyle?: CSSProperties; tooltip?: string; - tooltipType?: "base" | "accent" | "error"; + tooltipType?: "base" | "accent" | "error" | "warning"; } & InteractParams & FocusParams) { const handleAction = (e?: any) => @@ -58,7 +58,7 @@ export function Button (data: { onClick={handleAction} disabled={data.disabled} data-tooltip={data.tooltip} - data-tooltip_type={data.tooltipType} + data-tooltip-type={data.tooltipType} 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", styles[data.style ?? 'base'], diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index 2102503..a9f7720 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -5,11 +5,13 @@ import { Button } from "../options/Button"; import useActiveControl from "@/mainview/scripts/gamepads"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; 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 { FlatpackIcon } from "@/mainview/scripts/brandIcons"; import { JSX } from "react"; import { oneShot } from "@/mainview/scripts/audio/audio"; +import { useQuery } from "@tanstack/react-query"; +import { getUpdateInfoForEmulator } from "@/mainview/scripts/queries/store"; export const emulatorStatusIcons: Record = { 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]); - const { isMouse, isTouch } = useActiveControl(); return (
s.exists)} - onClick={isTouch ? handleSelect : undefined} - 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)} + onClick={handleSelect} + 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)} >
@@ -81,21 +84,27 @@ export function StoreEmulatorCard (data: {
- {!!data.emulator.integration &&
-
+ {updateInfo?.hasUpdate &&
+
+ +
+
} + {data.emulator.integrations.length > 0 &&
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"} + > +
} {data.emulator.validSources.slice(0, 3).map(s => { return
-
+
{emulatorStatusIcons[s.type]}
; })} - {isMouse && <> - - } -
diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 9ad4678..a40e2b0 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -10,7 +10,7 @@ import Shortcuts from "@/mainview/components/Shortcuts"; import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; import { systemApi } from "@/mainview/scripts/clientApi"; 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 { RPC_URL } from "@/shared/constants"; import Screenshots from "@/mainview/components/Screenshots"; @@ -59,6 +59,7 @@ function HomePageLink (data: { homepage?: string; }) function TitleArea (data: { emulator?: FrontEndEmulatorDetailed; onInstall: (source: string) => void; + onUpdate: (source: string) => void; }) { const queryClient = useQueryClient(); @@ -70,6 +71,7 @@ function TitleArea (data: { }, }); 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({ ...deleteBiosMutation, onSuccess (data, variables, onMutateResult, context) @@ -122,7 +124,7 @@ function TitleArea (data: { const isInstalling = !!installJob || !!biosInstallJob; 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 (!isInstalling && !installedFromStore) @@ -155,6 +157,22 @@ function TitleArea (data: { 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: , + 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) { options.push({ @@ -183,7 +201,6 @@ function TitleArea (data: { id: "download-bios" }); } - } } @@ -253,13 +270,16 @@ function TitleArea (data: { {!!data.emulator?.bios?.[0] &&
} - {data.emulator && !!data.emulator.integration && data.emulator.validSources.some(s => s.type === 'store') &&
+ {data.emulator && data.emulator.integrations.length > 0 &&
}
+ {(data.emulator?.storeDownloadInfo?.hasUpdate || !data.emulator?.storeDownloadInfo) && installedFromStore && !!updateToVersion &&
+ +
} {(!data.emulator?.bios || data.emulator.bios.length <= 0) && (data.emulator?.biosRequirement === 'required') && installedFromStore &&
} @@ -310,7 +330,8 @@ export function RouteComponent () }], [router]); 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(); @@ -320,21 +341,33 @@ export function RouteComponent () { if (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(...emulator.sources.flatMap(s => [{ - label: "Source", content:
-
{emulatorStatusIcons[s.type]}{s.type}:
-
{s.binPath}
+ stats.push(...emulator.validSources.flatMap(s => [{ + label: "Source", content:
+
+
{emulatorStatusIcons[s.type]}{s.type}
+
{s.binPath}
+
+ {emulator.integrations.some(i => i.source?.type === s.type) &&
} + {emulator.integrations.filter(i => i.source?.type === s.type).map(i => + { + return
+
+ +
{i.id}
+
+
{`${i.capabilities?.join(", ")}`}
+
; + })}
}])); if (emulator.bios) stats.push({ label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios :
Missing
}); - if (emulator.integration) - { - stats.push({ label: "Integration", icon: , content: `${emulator.integration.name} (${emulator.integration.version})` }); - } + } return ( @@ -344,7 +377,7 @@ export function RouteComponent ()
- + installMutation.mutate({ source: s, isUpdate: false })} onUpdate={s => installMutation.mutate({ source: s, isUpdate: true })} />
diff --git a/src/mainview/scripts/queries/store.ts b/src/mainview/scripts/queries/store.ts index bdd6337..4a881cd 100644 --- a/src/mainview/scripts/queries/store.ts +++ b/src/mainview/scripts/queries/store.ts @@ -64,9 +64,9 @@ export const storeGetStatsQuery = queryOptions({ }); export const installEmulatorMutation = (id: string) => mutationOptions({ 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; return data; } @@ -85,4 +85,12 @@ export const deleteBiosMutation = mutationOptions({ const { error } = await storeApi.api.store.bios({ id }).delete(); 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; + } }); \ No newline at end of file diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 33531f7..3eff7d5 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -121,7 +121,13 @@ export const EmulatorPackageSchema = z.object({ export const ScoopPackageSchema = z.object({ version: z.string(), 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({ @@ -137,6 +143,10 @@ export const SystemInfoSchema = 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({ name: z.string(), 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; export type StoreGameType = z.infer; export type SettingsType = z.infer; export type LocalSettingsType = z.infer; export const PlatformSchema = z.object({ slug: z.string() }); export type SystemInfoType = z.infer; +export type EmulatorDownloadInfoType = z.infer; diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index 7bc8ba7..85812b2 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -16,11 +16,7 @@ declare interface FrontEndEmulator description?: string; gameCount: number; validSources: EmulatorSourceEntryType[]; - integration?: { - name: string; - version?: string; - possible: boolean; - }; + integrations: EmulatorSupport[]; } declare interface EmulatorSystem { id: string, romm_slug?: string, name: string, iconUrl: string; } @@ -29,6 +25,7 @@ declare interface FrontEndEmulatorDetailedDownload { name: string; type: string | undefined; + version?: string; } declare interface FrontEndEmulatorDetailed extends FrontEndEmulator @@ -38,9 +35,9 @@ declare interface FrontEndEmulatorDetailed extends FrontEndEmulator downloads: FrontEndEmulatorDetailedDownload[]; keywords?: string[]; screenshots: string[]; - sources: EmulatorSourceEntryType[]; biosRequirement?: "required" | "optional"; bios?: string[]; + storeDownloadInfo?: { hasUpdate: boolean; version?: string, type: string; }; } declare interface FrontEndGameTypeDetailedAchievement @@ -265,4 +262,14 @@ declare interface FrontEndCollection description: string; path_platform_cover: string | null; game_count: number; +} + +declare type EmulatorCapabilities = "saves" | "fullscreen" | "resolution" | "batch" | "states" | "config"; + +declare interface EmulatorSupport +{ + id: string; + source?: EmulatorSourceEntryType; + supportLevel?: "partial" | "full"; + capabilities?: EmulatorCapabilities[]; } \ No newline at end of file