feat: Implemented emulator versions and updating
This commit is contained in:
parent
a69147a4f7
commit
34db717ec5
22 changed files with 434 additions and 212 deletions
|
|
@ -303,7 +303,8 @@ export default new Elysia()
|
||||||
validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }],
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
}
|
}
|
||||||
|
|
@ -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>'
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 =>
|
||||||
|
|
|
||||||
|
|
@ -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 () =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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 } }) =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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={() =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
19
src/shared/types..d.ts
vendored
19
src/shared/types..d.ts
vendored
|
|
@ -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[];
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue