fix: Fixed a bunch of issues on linux

fix: Removed archive when unzipping with stream zip fallback
This commit is contained in:
Simeon Radivoev 2026-04-20 02:14:37 +03:00
parent 7065e64722
commit 6aacec2c0d
No known key found for this signature in database
GPG key ID: 6D1F209D0C277D60
22 changed files with 236 additions and 83 deletions

View file

@ -451,7 +451,7 @@ export default new Elysia()
}, { }, {
params: z.object({ id: z.string(), source: z.string() }), params: z.object({ id: z.string(), source: z.string() }),
}) })
.post('/game/:source/:id/install', async ({ params: { id, source }, query: { downloadId } }) => .post('/game/:source/:id/install', async ({ params: { id, source }, body: { downloadId } }) =>
{ {
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob)) if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
{ {
@ -462,7 +462,7 @@ export default new Elysia()
} }
}, { }, {
params: z.object({ id: z.string(), source: z.string() }), params: z.object({ id: z.string(), source: z.string() }),
query: z.object({ downloadId: z.string().optional() }), body: z.object({ downloadId: z.string().optional() }),
response: z.any() response: z.any()
}) })
.delete('/game/:source/:id/install', async ({ params: { id, source } }) => .delete('/game/:source/:id/install', async ({ params: { id, source } }) =>

View file

@ -4,11 +4,11 @@ import { getErrorMessage } from "@/bun/utils";
import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils"; import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils";
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import Elysia from "elysia"; import Elysia from "elysia";
import z from "zod"; import z, { string } from "zod";
import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { InstallJob, InstallJobStates } from "../../jobs/install-job";
import { LaunchGameJob } from "../../jobs/launch-game-job"; import { LaunchGameJob } from "../../jobs/launch-game-job";
import * as appSchema from "@schema/app"; import * as appSchema from "@schema/app";
import { RPC_URL } from "@/shared/constants"; import { DownloadSourceSchema, RPC_URL } from "@/shared/constants";
import { host } from "@/bun/utils/host"; import { host } from "@/bun/utils/host";
export class CommandSearchError extends Error export class CommandSearchError extends Error
@ -205,7 +205,7 @@ export default function buildStatusResponse ()
z.object({ status: z.literal('refresh'), localId: z.number().optional() }), z.object({ status: z.literal('refresh'), localId: z.number().optional() }),
z.object({ status: z.literal(['queued']) }), z.object({ status: z.literal(['queued']) }),
z.object({ status: z.literal('playing'), details: z.string() }), z.object({ status: z.literal('playing'), details: z.string() }),
z.object({ status: z.literal('install'), details: z.string() }), z.object({ status: z.literal('install'), details: z.string(), sources: DownloadSourceSchema.array() }),
z.object({ status: z.literal('present'), details: z.string() }), z.object({ status: z.literal('present'), details: z.string() }),
z.object({ status: z.literal(['download', 'extract']), progress: z.number() }), z.object({ status: z.literal(['download', 'extract']), progress: z.number() }),
]), ]),
@ -261,6 +261,8 @@ export default function buildStatusResponse ()
} else if (!localGame && ws.data.params.source === 'store') } else if (!localGame && ws.data.params.source === 'store')
{ {
const downloads = await plugins.hooks.games.fetchDownloads.promise({ source: ws.data.params.source, id: ws.data.params.id });
const sources = downloads?.map(d => ({ id: d.id, name: d.id })) ?? [];
/*const storeGame = await getStoreGame(ws.data.params.id); /*const storeGame = await getStoreGame(ws.data.params.id);
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
const size = Number(fileResponse.headers.get('content-length')); const size = Number(fileResponse.headers.get('content-length'));
@ -274,19 +276,20 @@ export default function buildStatusResponse ()
ws.send({ status: 'install', details: 'Install' }); ws.send({ status: 'install', details: 'Install' });
}*/ }*/
ws.send({ status: 'install', details: 'Install' }); ws.send({ status: 'install', details: 'Install', sources });
} else if (!localGame) } else if (!localGame)
{ {
const files = await plugins.hooks.games.fetchDownloads.promise({ const files = await plugins.hooks.games.fetchDownloads.promise({
source: ws.data.params.source, source: ws.data.params.source,
id: ws.data.params.id id: ws.data.params.id
}); });
const sources = files?.map(d => ({ id: d.id, name: d.id })) ?? [];
let filesChecked: LocalDownloadFileEntry[] | undefined; let filesChecked: LocalDownloadFileEntry[] | undefined;
if (files) if (files && files.length)
{ {
filesChecked = await checkFiles(files.files, !!files.extract_path); filesChecked = await checkFiles(files[0].files, !!files[0].extract_path);
} }
if (filesChecked && !filesChecked.some(f => f.exists === false || f.matches === false)) if (filesChecked && !filesChecked.some(f => f.exists === false || f.matches === false))
@ -301,11 +304,11 @@ export default function buildStatusResponse ()
ws.send({ status: 'error', error: "Not Enough Free Space" }); ws.send({ status: 'error', error: "Not Enough Free Space" });
} else if (filesChecked?.some(f => f.exists === true && f.matches === false)) } else if (filesChecked?.some(f => f.exists === true && f.matches === false))
{ {
ws.send({ status: 'install', details: 'Some Files Present, Install' }); ws.send({ status: 'install', details: 'Some Files Present, Install', sources });
} }
else else
{ {
ws.send({ status: 'install', details: 'Install' }); ws.send({ status: 'install', details: 'Install', sources });
} }
} }
} else } else

View file

@ -67,7 +67,7 @@ export class GameHooks
source: string; source: string;
id: string; id: string;
downloadId?: string; downloadId?: string;
}], DownloadInfo | undefined>(['ctx']); }], DownloadInfo[] | undefined>(['ctx']);
fetchRomFiles = new AsyncSeriesBailHook<[ctx: { fetchRomFiles = new AsyncSeriesBailHook<[ctx: {
source: string; source: string;
id: string; id: string;

View file

@ -53,7 +53,8 @@ export class InstallJob implements IJob<never, InstallJobStates>
const downloadPath = config.get('downloadPath'); const downloadPath = config.get('downloadPath');
let info: DownloadInfo | undefined; let info: DownloadInfo | undefined;
info = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId }); const allDownloads = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId });
info = allDownloads?.[0];
if (!info) throw new Error(`Could not find downloader for source ${this.source}`); if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
@ -137,12 +138,21 @@ export class InstallJob implements IJob<never, InstallJobStates>
{ {
if (filePath.endsWith('.zip')) if (filePath.endsWith('.zip'))
{ {
cx.setProgress(0, "extract");
console.warn("Could not extract", filePath, "with 7zip trying zip extractor"); console.warn("Could not extract", filePath, "with 7zip trying zip extractor");
await ensureDir(extractPath); await ensureDir(extractPath);
const zip = new StreamZip.async({ file: filePath }); const zip = new StreamZip.async({ file: filePath });
let entryCount = await zip.entriesCount;
let entryCounter = entryCount;
zip.on('extract', (entry, outPath) =>
{
entryCounter--;
cx.setProgress(progress + (1 - (entryCounter / entryCount)) * 100 * progressDelta, "extract");
});
const count = await zip.extract(null, extractPath); const count = await zip.extract(null, extractPath);
console.log(`Extracted ${count} entries`); console.log(`Extracted ${count} entries`);
await zip.close(); await zip.close();
await fs.rm(filePath);
} else } else
{ {
throw e; throw e;

View file

@ -120,7 +120,7 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
// ES-DE commands require shell execution. Some emulators fail otherwise. // ES-DE commands require shell execution. Some emulators fail otherwise.
const spawnGame = spawn(this.validCommand.command, { const spawnGame = spawn(this.validCommand.command, {
shell: true, shell: this.validCommand.shell ?? true,
cwd: this.validCommand.startDir, cwd: this.validCommand.startDir,
signal: context.abortSignal, signal: context.abortSignal,
env: { env: {
@ -151,6 +151,7 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }); await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug });
// We have full control over launching integrated emulators better to use bun spawn // We have full control over launching integrated emulators better to use bun spawn
await fs.chmod(this.validCommand.metadata.emulatorBin, 0o755);
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs.args], { const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs.args], {
cwd: this.validCommand.startDir, cwd: this.validCommand.startDir,
signal: context.abortSignal, signal: context.abortSignal,

View file

@ -3,16 +3,13 @@ import desc from './package.json';
import { config, events } from "@/bun/api/app"; import { config, events } from "@/bun/api/app";
import path, { dirname } from 'node:path'; import path, { dirname } from 'node:path';
import unzip from 'unzip-stream'; import unzip from 'unzip-stream';
import { ensureDir } from "fs-extra"; import { chmodSync, ensureDir } from "fs-extra";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { randomUUIDv7, sleep } from "bun"; import { randomUUIDv7, sleep } from "bun";
import z from "zod"; import z from "zod";
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import { redirect } from "elysia";
import { getErrorMessage } from "@/bun/utils";
import { id } from "zod/v4/locales";
const SettingsSchema = z.object({ const SettingsSchema = z.object({
runWebGui: z.boolean() runWebGui: z.boolean()
@ -75,7 +72,13 @@ export default class RcloneIntegration implements PluginType<SettingsType>
ctx.zodRegistry.add(SettingsSchema.shape.globalConfig, { requiresRestart: true }); ctx.zodRegistry.add(SettingsSchema.shape.globalConfig, { requiresRestart: true });
const toolsPath = path.join(config.get('downloadPath'), "tools"); const toolsPath = path.join(config.get('downloadPath'), "tools");
const existingRclones = await Array.fromAsync(fs.glob('**/rclone.exe', { cwd: toolsPath })); await ensureDir(toolsPath);
const binaryMap: Record<string, string> = {
win32: '**/rclone.exe',
linux: '**/rclone',
darwin: '**/rclone'
};
const existingRclones = await Array.fromAsync(fs.glob(binaryMap[process.platform], { cwd: toolsPath }));
if (existingRclones[0]) if (existingRclones[0])
{ {
this.rclonePath = path.join(toolsPath, existingRclones[0]); this.rclonePath = path.join(toolsPath, existingRclones[0]);
@ -83,13 +86,19 @@ export default class RcloneIntegration implements PluginType<SettingsType>
return; return;
} }
if (await fs.exists(path.join(toolsPath, 'rclone-current-windows-amd64')))
{
return;
}
ctx.setProgress(0.5, "Downloading RClone"); ctx.setProgress(0.5, "Downloading RClone");
const rcCloseZip = await fetch(`https://downloads.rclone.org/rclone-current-windows-amd64.zip`); const platformMap: Record<string, string> = {
linux: "linux",
win32: "windows",
darwin: "osx"
};
const archMap: Record<string, string> = {
x64: "amd64",
arm64: "arm64"
};
const downloadUrl = `https://downloads.rclone.org/rclone-current-${platformMap[process.platform]}-${archMap[process.arch]}.zip`;
console.log("Starting Download", downloadUrl);
const rcCloseZip = await fetch(downloadUrl);
await ensureDir(toolsPath); await ensureDir(toolsPath);
await pipeline(Readable.fromWeb(rcCloseZip.body as any), unzip.Extract({ path: toolsPath })); await pipeline(Readable.fromWeb(rcCloseZip.body as any), unzip.Extract({ path: toolsPath }));
@ -97,6 +106,7 @@ export default class RcloneIntegration implements PluginType<SettingsType>
if (dests[0]) if (dests[0])
{ {
this.rclonePath = path.join(toolsPath, dests[0]); this.rclonePath = path.join(toolsPath, dests[0]);
await fs.chmod(this.rclonePath, 0o755);
await this.startServer(ctx); await this.startServer(ctx);
return; return;
} }
@ -139,7 +149,12 @@ export default class RcloneIntegration implements PluginType<SettingsType>
if (data.level === 'error') if (data.level === 'error')
{ {
console.error(data.msg); console.error(data.msg);
} else } else if (data.level === 'critical')
{
console.error(data.msg);
}
else
{ {
console.log(e); console.log(e);
if (loginTokenUrlRegex.test(data.msg)) if (loginTokenUrlRegex.test(data.msg))
@ -150,7 +165,7 @@ export default class RcloneIntegration implements PluginType<SettingsType>
}); });
await new Promise((resolve) => await new Promise((resolve, reject) =>
{ {
const handleResolve = (line: string) => const handleResolve = (line: string) =>
{ {
@ -160,6 +175,7 @@ export default class RcloneIntegration implements PluginType<SettingsType>
resolve(data); resolve(data);
}; };
rl.on('line', handleResolve); rl.on('line', handleResolve);
setTimeout(() => { reject("Timeout"); }, 5000);
}); });
await this.refresh(); await this.refresh();

View file

@ -31,6 +31,12 @@ export default class RommIntegration implements PluginType<SettingsType>
release: "metadatum.first_release_date" release: "metadatum.first_release_date"
}; };
async checkRemote ()
{
if (!config.has('rommAddress')) return false;
return true;
}
async updateClient () async updateClient ()
{ {
client.setConfig({ client.setConfig({
@ -141,6 +147,7 @@ export default class RommIntegration implements PluginType<SettingsType>
ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) => ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) =>
{ {
if (!await this.checkRemote()) return;
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm')) if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
{ {
@ -173,6 +180,7 @@ export default class RommIntegration implements PluginType<SettingsType>
ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) => ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) =>
{ {
if (!await this.checkRemote()) return;
if (source && source !== 'romm') return; if (source && source !== 'romm') return;
const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true }); const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true });
@ -185,12 +193,14 @@ export default class RommIntegration implements PluginType<SettingsType>
ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) => ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) =>
{ {
if (!await this.checkRemote()) return;
if (service !== 'romm') return; if (service !== 'romm') return;
await this.updateClient(); await this.updateClient();
}); });
ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) => ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) =>
{ {
if (!await this.checkRemote()) return;
if (source !== 'romm') return; if (source !== 'romm') return;
const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } }); const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } });
@ -205,6 +215,7 @@ export default class RommIntegration implements PluginType<SettingsType>
ctx.hooks.games.fetchDownloads.tapPromise(desc.name, async ({ source, id }) => ctx.hooks.games.fetchDownloads.tapPromise(desc.name, async ({ source, id }) =>
{ {
if (!await this.checkRemote()) return;
if (source !== 'romm') return; if (source !== 'romm') return;
const rom = (await getRomApiRomsIdGet({ path: { id: Number(id) }, throwOnError: true })).data; const rom = (await getRomApiRomsIdGet({ path: { id: Number(id) }, throwOnError: true })).data;
@ -260,12 +271,13 @@ export default class RommIntegration implements PluginType<SettingsType>
extract_path extract_path
}; };
return info; return [info];
}); });
ctx.hooks.emulators.fetchBiosDownload.tapPromise(desc.name, async ({ systems, biosFolder }) => ctx.hooks.emulators.fetchBiosDownload.tapPromise(desc.name, async ({ systems, biosFolder }) =>
{ {
if (!await this.checkRemote()) return;
const files: DownloadFileEntry[] = []; const files: DownloadFileEntry[] = [];
const allRommPlatforms = await this.getAllRommPlatforms(); const allRommPlatforms = await this.getAllRommPlatforms();
@ -296,6 +308,7 @@ export default class RommIntegration implements PluginType<SettingsType>
ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) => ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) =>
{ {
if (!await this.checkRemote()) return;
const rommPlatforms = await this.getAllRommPlatforms(); const rommPlatforms = await this.getAllRommPlatforms();
if (rommPlatforms) if (rommPlatforms)
{ {
@ -313,7 +326,7 @@ export default class RommIntegration implements PluginType<SettingsType>
ctx.hooks.games.fetchRecommendedGamesForEmulator.tapPromise(desc.name, async ({ emulator, games, systems }) => ctx.hooks.games.fetchRecommendedGamesForEmulator.tapPromise(desc.name, async ({ emulator, games, systems }) =>
{ {
if (!await this.checkRemote()) return;
const rommPlatforms = await this.getAllRommPlatforms(); const rommPlatforms = await this.getAllRommPlatforms();
const systemsRommSlugSet = new Set(systems.filter(s => s.romm_slug).map(s => s.romm_slug!)); const systemsRommSlugSet = new Set(systems.filter(s => s.romm_slug).map(s => s.romm_slug!));
if (rommPlatforms) if (rommPlatforms)
@ -343,6 +356,7 @@ export default class RommIntegration implements PluginType<SettingsType>
ctx.hooks.games.fetchPlatform.tapPromise(desc.name, async ({ source, id }) => ctx.hooks.games.fetchPlatform.tapPromise(desc.name, async ({ source, id }) =>
{ {
if (!await this.checkRemote()) return;
if (source !== 'romm') return; if (source !== 'romm') return;
const { data: rommPlatform } = await getPlatformApiPlatformsIdGet({ path: { id: Number(id) } }); const { data: rommPlatform } = await getPlatformApiPlatformsIdGet({ path: { id: Number(id) } });
if (rommPlatform) if (rommPlatform)
@ -365,7 +379,13 @@ export default class RommIntegration implements PluginType<SettingsType>
ctx.hooks.games.fetchPlatforms.tapPromise(desc.name, async ({ platforms }) => ctx.hooks.games.fetchPlatforms.tapPromise(desc.name, async ({ platforms }) =>
{ {
const rommPlatforms = await this.getAllRommPlatforms(); if (!await this.checkRemote()) return;
const rommPlatforms = await this.getAllRommPlatforms().catch(e =>
{
console.error(e);
return undefined;
});
if (rommPlatforms) if (rommPlatforms)
{ {
const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p => const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p =>
@ -401,6 +421,7 @@ export default class RommIntegration implements PluginType<SettingsType>
ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderSlots, setProgress }) => ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderSlots, setProgress }) =>
{ {
if (!await this.checkRemote()) return;
if (source !== 'romm' || !ctx.config.get('savesSync')) return; if (source !== 'romm' || !ctx.config.get('savesSync')) return;
if (!saveFolderSlots) return; if (!saveFolderSlots) return;
@ -445,6 +466,7 @@ export default class RommIntegration implements PluginType<SettingsType>
// Should run after emulators decide on saves // Should run after emulators decide on saves
ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) => ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) =>
{ {
if (!await this.checkRemote()) return;
if (source !== 'romm' || !ctx.config.get('savesSync')) return; if (source !== 'romm' || !ctx.config.get('savesSync')) return;
const sourceValidation = await validateGameSource(source, id); const sourceValidation = await validateGameSource(source, id);
@ -529,6 +551,7 @@ export default class RommIntegration implements PluginType<SettingsType>
ctx.hooks.games.fetchCollections.tapPromise(desc.name, async ({ collections }) => ctx.hooks.games.fetchCollections.tapPromise(desc.name, async ({ collections }) =>
{ {
if (!await this.checkRemote()) return;
const rommCollections = await getCollectionsApiCollectionsGet(); const rommCollections = await getCollectionsApiCollectionsGet();
if (rommCollections.response.ok && rommCollections.data) if (rommCollections.response.ok && rommCollections.data)
{ {
@ -549,6 +572,7 @@ export default class RommIntegration implements PluginType<SettingsType>
ctx.hooks.games.fetchCollection.tapPromise(desc.name, async ({ source, id }) => ctx.hooks.games.fetchCollection.tapPromise(desc.name, async ({ source, id }) =>
{ {
if (!await this.checkRemote()) return;
if (source !== 'romm') return; if (source !== 'romm') return;
const collection = await getCollectionApiCollectionsIdGet({ path: { id: Number(id) } }); const collection = await getCollectionApiCollectionsIdGet({ path: { id: Number(id) } });
if (collection.data) if (collection.data)
@ -567,6 +591,7 @@ export default class RommIntegration implements PluginType<SettingsType>
ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) => ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) =>
{ {
if (!await this.checkRemote()) return;
let platform: PlatformSchema | undefined = undefined; let platform: PlatformSchema | undefined = undefined;
if (id && source) if (id && source)
@ -587,6 +612,7 @@ export default class RommIntegration implements PluginType<SettingsType>
ctx.hooks.games.searchGame.tapPromise(desc.name, async ({ source, igdb_id, ra_id }) => ctx.hooks.games.searchGame.tapPromise(desc.name, async ({ source, igdb_id, ra_id }) =>
{ {
if (!await this.checkRemote()) return;
if (source !== 'romm') return; if (source !== 'romm') return;
const roms = await getRomByMetadataProviderApiRomsByMetadataProviderGet({ query: { igdb_id, ra_id } }); const roms = await getRomByMetadataProviderApiRomsByMetadataProviderGet({ query: { igdb_id, ra_id } });
if (roms.error) throw roms.error; if (roms.error) throw roms.error;

View file

@ -50,16 +50,16 @@ function convertStoreMediaToPath (c: string)
export async function convertStoreToFrontend (id: string, storeGame: StoreGameType): Promise<FrontEndGameType> export async function convertStoreToFrontend (id: string, storeGame: StoreGameType): Promise<FrontEndGameType>
{ {
const validDownload = getValidDownload(storeGame); const validDownloads = getValidDownloads(storeGame);
let platform_slug: string | null = null; let platform_slug: string | null = null;
let platform_id: number | null = null; let platform_id: number | null = null;
let platform_display_name: string | null = null; let platform_display_name: string | null = null;
let path_platform_cover: string | null = null; let path_platform_cover: string | null = null;
if (validDownload?.system) if (validDownloads.length > 0 && validDownloads[0].system)
{ {
let system = validDownload.system.split(':')[0]; let system = validDownloads[0].system.split(':')[0];
if (system === 'win32') system = 'win'; if (system === 'win32') system = 'win';
const localPlatform = await db.query.platforms.findFirst({ where: eq(appSchema.platforms.slug, system), columns: { id: true, slug: true, name: true } }); const localPlatform = await db.query.platforms.findFirst({ where: eq(appSchema.platforms.slug, system), columns: { id: true, slug: true, name: true } });
@ -130,13 +130,13 @@ export async function convertStoreToFrontend (id: string, storeGame: StoreGameTy
export async function convertStoreToFrontendDetailed (id: string, storeGame: StoreGameType): Promise<FrontEndGameTypeDetailed> export async function convertStoreToFrontendDetailed (id: string, storeGame: StoreGameType): Promise<FrontEndGameTypeDetailed>
{ {
const validDownload = getValidDownload(storeGame); const validDownloads = getValidDownloads(storeGame);
let size: number | null = null; let size: number | null = null;
if (validDownload?.url) if (validDownloads.length > 0 && validDownloads[0].url)
{ {
try try
{ {
const fileResponse = await fetch(validDownload?.url, { method: 'HEAD' }); const fileResponse = await fetch(validDownloads[0]?.url, { method: 'HEAD' });
size = Number(fileResponse.headers.get('content-length')); size = Number(fileResponse.headers.get('content-length'));
} catch (error) } catch (error)
{ {
@ -167,25 +167,32 @@ export async function convertStoreToFrontendDetailed (id: string, storeGame: Sto
return detailed; return detailed;
} }
export function getValidDownload (game: StoreGameType, downloadId?: string) export function getValidDownloads (game: StoreGameType, downloadId?: string)
{ {
const downloads = Object.entries(game.downloads).map(([k, d]) => ({ id: k, ...d })); const downloads = Object.entries(game.downloads).map(([k, d]) => ({ id: k, ...d }));
const supportedDownloads = downloads.filter(d => d.type === 'direct'); const supportedDownloads = downloads.filter(d => d.type === 'direct');
if (downloadId) if (downloadId)
{ {
return supportedDownloads.find(d => d.id === downloadId); return supportedDownloads.filter(d => d.id === downloadId);
} else } else
{ {
return supportedDownloads.find(d => d.system === `${process.platform}:${process.arch}`) return supportedDownloads.filter(d =>
?? supportedDownloads.find(d => {
{ if (d.system === `${process.platform}:${process.arch}`) return true;
// Linux supports proton, can run windows games
if (process.platform === 'linux') return d.system === `win32:${process.arch}`; // TODO: Add linux proton support
return false; //if (process.platform === 'linux' && d.system === `win32:${process.arch}`) return true;
})
// Fallback to emulator platforms // emulator fallback
?? supportedDownloads.find(d => !d.system.includes(':')); return !d.system.includes(':');
}).toSorted((a, b) =>
{
const bScore = b.system.includes(':') ? 0 : 1;
const aScore = a.system.includes(':') ? 0 : 1;
return bScore - aScore;
});
} }
} }
@ -283,7 +290,8 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT
systems, systems,
gameCount: 0, gameCount: 0,
validSources: execPaths, validSources: execPaths,
integrations: [] integrations: [],
source: "store"
}; };
return em; return em;

View file

@ -1,22 +1,17 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json'; import desc from './package.json';
import path, { basename, dirname } from 'node:path'; import path, { basename, dirname } from 'node:path';
import { StoreDownloadType, StoreGameSchema, StoreGameType } from "@/shared/constants";
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService";
import { Glob, pathToFileURL } from "bun"; import { Glob, pathToFileURL } from "bun";
import { getOrCached } from "@/bun/api/cache";
import { shuffleInPlace } from "@/bun/utils";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import * as emulatorSchema from '@schema/emulators'; import * as emulatorSchema from '@schema/emulators';
import { config, db, emulatorsDb, plugins, taskQueue } from "@/bun/api/app"; import { config, emulatorsDb, taskQueue } from "@/bun/api/app";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { getSourceGameDetailed } from "@/bun/api/games/services/utils"; import { getSourceGameDetailed } from "@/bun/api/games/services/utils";
import mustache from "mustache";
import os from 'node:os';
import UpdateStoreJob from "@/bun/api/jobs/update-store"; import UpdateStoreJob from "@/bun/api/jobs/update-store";
import { getEmulatorDownload } from "@/bun/api/store/services/emulatorsService"; import { getEmulatorDownload } from "@/bun/api/store/services/emulatorsService";
import { buildFilters, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownload } from "./services"; import { buildFilters, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services";
export default class RommIntegration implements PluginType export default class RommIntegration implements PluginType
{ {
@ -29,11 +24,13 @@ export default class RommIntegration implements PluginType
async load (ctx: PluginLoadingContextType) async load (ctx: PluginLoadingContextType)
{ {
await this.setup(ctx);
ctx.hooks.store.fetchDownload.tapPromise(desc.name, async ({ id }) => ctx.hooks.store.fetchDownload.tapPromise(desc.name, async ({ id }) =>
{ {
const emulatorPackage = await getStoreEmulatorPackage(id); const emulatorPackage = await getStoreEmulatorPackage(id);
const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage!); if (!emulatorPackage) return;
const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage);
return downloadInfo; return downloadInfo;
}); });
@ -131,7 +128,7 @@ export default class RommIntegration implements PluginType
ctx.hooks.games.buildLaunchCommands.tapPromise({ name: desc.name, before: 'com.simeonradivoev.gameflow.es' }, async ({ gamePath, source, sourceId, systemSlug, mainGlob }) => ctx.hooks.games.buildLaunchCommands.tapPromise({ name: desc.name, before: 'com.simeonradivoev.gameflow.es' }, async ({ gamePath, source, sourceId, systemSlug, mainGlob }) =>
{ {
if (source !== 'store' || !gamePath || systemSlug !== 'win') return; if (source !== 'store' || !gamePath) return;
const downloadPath = config.get('downloadPath'); const downloadPath = config.get('downloadPath');
const gamePathAbsolute = path.join(downloadPath, gamePath); const gamePathAbsolute = path.join(downloadPath, gamePath);
if (!(await fs.exists(gamePathAbsolute))) return; if (!(await fs.exists(gamePathAbsolute))) return;
@ -139,13 +136,15 @@ export default class RommIntegration implements PluginType
if (gamePathStat.isDirectory()) if (gamePathStat.isDirectory())
{ {
if (!mainGlob && systemSlug !== 'win') return;
const fileGlob = new Glob(mainGlob ?? '**/*.exe'); const fileGlob = new Glob(mainGlob ?? '**/*.exe');
for await (const file of fileGlob.scan({ cwd: path.join(downloadPath, gamePath) })) for await (const file of fileGlob.scan({ cwd: path.join(downloadPath, gamePath) }))
{ {
return [{ return [{
startDir: path.join(downloadPath, gamePath, dirname(file)), startDir: path.join(downloadPath, gamePath, dirname(file)),
command: basename(file), command: `./${basename(file)}`,
id: 'store-win', id: `store-${process.platform}`,
shell: false,
valid: true, valid: true,
env: { env: {
XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '') XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '')
@ -160,12 +159,13 @@ export default class RommIntegration implements PluginType
{ {
return [{ return [{
startDir: path.join(downloadPath, dirname(gamePath)), startDir: path.join(downloadPath, dirname(gamePath)),
command: basename(gamePath), command: `./${basename(gamePath)}`,
env: { env: {
XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '') XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '')
}, },
id: 'store-win', id: `store-${process.platform}`,
valid: true, valid: true,
shell: false,
metadata: { metadata: {
romPath: path.join(downloadPath, gamePath) romPath: path.join(downloadPath, gamePath)
} }
@ -272,14 +272,15 @@ export default class RommIntegration implements PluginType
const game = await getStoreGame(id); const game = await getStoreGame(id);
if (!game) throw new Error("Missing Store Game"); if (!game) throw new Error("Missing Store Game");
const validDownload = getValidDownload(game, downloadId); const validDownloads = getValidDownloads(game, downloadId);
if (validDownload) return validDownloads.map(validDownload =>
{ {
let system = validDownload.system.split(":")[0]; let system = validDownload.system.split(":")[0];
if (system === 'win32') system = 'win'; if (system === 'win32') system = 'win';
const info: DownloadInfo = { const info: DownloadInfo = {
id: validDownload.id,
coverUrl: game.covers?.[0] ? game.covers[0].startsWith('http') ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "", coverUrl: game.covers?.[0] ? game.covers[0].startsWith('http') ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "",
screenshotUrls: game.screenshots ?? [], screenshotUrls: game.screenshots ?? [],
files: [{ files: [{
@ -306,7 +307,7 @@ export default class RommIntegration implements PluginType
}; };
return info; return info;
} });
}); });
} }
} }

View file

@ -126,7 +126,14 @@ export const store = new Elysia({ prefix: '/api/store' })
}) })
.get('/emulator/:id', async ({ params: { id } }) => .get('/emulator/:id', async ({ params: { id } }) =>
{ {
return plugins.hooks.store.fetchEmulator.promise({ id }); const emulator = await plugins.hooks.store.fetchEmulator.promise({ id });
if (!emulator) return status("Not Found");
const sources: EmulatorSourceEntryType[] = [];
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: emulator.name, sources });
const integrations = findEmulatorPluginIntegration(emulator.name, sources);
emulator.validSources = sources;
emulator.integrations = integrations;
return emulator;
}, { params: z.object({ id: z.string() }) }) }, { params: z.object({ id: z.string() }) })
.post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) => .post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) =>
{ {

View file

@ -1,4 +1,4 @@
import { spawnSync } from "bun"; import { Glob, spawnSync } from "bun";
import { platform } from "node:os"; import { platform } from "node:os";
import { RunBrowserType } from "./browser-spawner"; import { RunBrowserType } from "./browser-spawner";
import path from 'node:path'; import path from 'node:path';
@ -48,12 +48,17 @@ const ARCH_MAP: Record<string, Record<string, string>> = {
}; };
/** The expected binary path per platform after extraction */ /** The expected binary path per platform after extraction */
function getBundledBinaryPath (outDir: string, version: string, platform: string, arch: string): string async function getBundledBinaryPath (outDir: string, version: string, platform: string, arch: string): Promise<string | undefined>
{ {
const subFolder = `ungoogled-chromium_${version}_${PLATFORM_MAP[platform]}_${ARCH_MAP[platform][arch]}`; let glob: Glob | undefined = undefined;
if (platform === "linux") return path.join(outDir, subFolder, "chrome"); if (platform === "linux") glob = new Glob(`**/chrome`);
if (platform === "darwin") return path.join(outDir, "Chromium.app"); else if (platform === "darwin") glob = new Glob(`**/Chromium.app`);
return path.join(outDir, subFolder, "chrome.exe"); else glob = new Glob(`**/chrome.exe`);
for await (const bin of glob.scan({ cwd: outDir }))
{
return path.join(outDir, bin);
}
} }
/** /**
@ -101,10 +106,14 @@ export async function getBrowserPath (config?: BrowserPriorityConfig): Promise<B
if (await versionFile.exists()) if (await versionFile.exists())
{ {
const getVerstion = await versionFile.text(); const getVerstion = await versionFile.text();
const binPath = getBundledBinaryPath("./bin/chromium", getVerstion, process.platform, process.arch); const binPath = await getBundledBinaryPath("./bin/chromium", getVerstion, process.platform, process.arch);
if (await Bun.file(binPath).exists()) if (binPath)
{ {
return { path: binPath, type: "chromium", source: "bundled" }; console.log("Found Bundled Chromium Version", binPath);
if (await Bun.file(binPath).exists())
{
return { path: binPath, type: "chromium", source: "bundled" };
}
} }
} }

View file

@ -45,7 +45,7 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara
onFocus={(focusKey, node, details) => onFocus={(focusKey, node, details) =>
{ {
data.game.onFocus?.(focusKey, node, details); data.game.onFocus?.(focusKey, node, details);
data.onFocus?.(focusKey, node, details); data.onFocus?.(focusKey, node, { ...details, id: data.game.id });
}} }}
onAction={handleAction} onAction={handleAction}
preview={preview} preview={preview}

View file

@ -18,7 +18,7 @@ export function ContextList (data: {
{ {
const context = useContext(ContextDialogContext); const context = useContext(ContextDialogContext);
return <ul className={twMerge("list gap-1", data.className)}> return <ul className={twMerge("list gap-1", data.className)}>
{data.options?.map(o => <OptionElement className="list-row" key={o.id} {...o} />)} {data.options?.map((o, i) => <OptionElement className="list-row" key={i} {...o} />)}
{data.showCloseButton !== false && <div className="divider m-0 "></div>} {data.showCloseButton !== false && <div className="divider m-0 "></div>}
{data.showCloseButton !== false && <OptionElement disabled={data.disableCloseButton} className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />} {data.showCloseButton !== false && <OptionElement disabled={data.disableCloseButton} className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />}
</ul>; </ul>;

View file

@ -5,10 +5,11 @@ import { getErrorMessage } from "react-error-boundary";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useLocalStorage } from "usehooks-ts"; import { useLocalStorage } from "usehooks-ts";
import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
import { Clock, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react"; import { Clock, Crosshair, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react";
import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview/scripts/queries/romm"; import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview/scripts/queries/romm";
import ActionButton from "./ActionButton"; import ActionButton from "./ActionButton";
import { useRouter } from "@tanstack/react-router"; import { useRouter } from "@tanstack/react-router";
import { DownloadSourceType } from "@/shared/constants";
export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; }) export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; })
{ {
@ -29,6 +30,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
const [status, setStatus] = useState<string | undefined>(undefined); const [status, setStatus] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
const [details, setDetails] = useState<string | undefined>(undefined); const [details, setDetails] = useState<string | undefined>(undefined);
const [installSources, setInstallSources] = useState<DownloadSourceType[] | undefined>(undefined);
const [commands, setCommands] = useState<CommandEntry[] | undefined>(undefined); const [commands, setCommands] = useState<CommandEntry[] | undefined>(undefined);
const [preferredCommand, setPreferredCommand] = useLocalStorage<string | number | undefined>(`${data.game?.source ?? data.game?.id.source}-${data.game?.source_id ?? data.game?.id.id}-preferred-command`, undefined); const [preferredCommand, setPreferredCommand] = useLocalStorage<string | number | undefined>(`${data.game?.source ?? data.game?.id.source}-${data.game?.source_id ?? data.game?.id.id}-preferred-command`, undefined);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -51,6 +53,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
setProgress((e.data as any).progress); setProgress((e.data as any).progress);
setDetails((e.data as any).details); setDetails((e.data as any).details);
setCommands((e.data as any).commands); setCommands((e.data as any).commands);
setInstallSources((e.data as any).sources);
if (e.data.status === 'refresh') if (e.data.status === 'refresh')
{ {
@ -154,7 +157,11 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
let icon = <span className="loading loading-spinner loading-lg"></span>; let icon = <span className="loading loading-spinner loading-lg"></span>;
if (status === 'install') if (status === 'install')
{ {
icon = <Download />; if (installSources && installSources.length > 1)
icon = <Crosshair />;
else
icon = <Download />;
} else if (status === 'present') } else if (status === 'present')
{ {
icon = <Import />; icon = <Import />;
@ -168,7 +175,14 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
{ {
case 'present': case 'present':
case 'install': case 'install':
installMut.mutate({}); if (installSources && installSources.length > 1)
{
showInstallSource(true, 'mainAction');
} else
{
installMut.mutate({});
}
break; break;
} }
}} }}
@ -211,6 +225,19 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
}]} /> }]} />
}); });
const { dialog: installSourcesDialog, setOpen: showInstallSource } = useContextDialog('install-source-dialog', {
content: <ContextList options={installSources?.map(s => ({
content: s.name,
action (ctx)
{
installMut.mutate({ downloadId: s.id });
ctx.close();
},
type: 'primary',
id: s.id
} satisfies DialogEntry)) ?? []} />
});
return <div className="flex gap-2"> return <div className="flex gap-2">
{mainButton} {mainButton}
<div className="divider divider-horizontal m-0"></div> <div className="divider divider-horizontal m-0"></div>
@ -222,6 +249,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
<progress className="progress progress-secondary w-full" value={progress} max="100"></progress> <progress className="progress progress-secondary w-full" value={progress} max="100"></progress>
</div> </div>
</ActionButton>} </ActionButton>}
{installSourcesDialog}
{installOptionsDialog} {installOptionsDialog}
{allCommandDialog} {allCommandDialog}
</div>; </div>;

View file

@ -44,7 +44,7 @@ export function GamesSection (data: {
<Carousel controlsClassName="z-20" scrollRef={containerRef} className="flex *:w-[18rem] *:min-w-[18rem] *:h-[21rem] overflow-y-hidden overflow-x-auto hide-scrollbar p-4 gap-4 justify-center-safe"> <Carousel controlsClassName="z-20" scrollRef={containerRef} className="flex *:w-[18rem] *:min-w-[18rem] *:h-[21rem] overflow-y-hidden overflow-x-auto hide-scrollbar p-4 gap-4 justify-center-safe">
{data.games?.map((g, i) => <FrontEndGameCard {data.games?.map((g, i) => <FrontEndGameCard
showSource={data.showSources} showSource={data.showSources}
key={g.id.id} key={i}
game={g} game={g}
onAction={() => data.onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id))} onAction={() => data.onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id))}
onFocus={(key, node, details) => scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' })} onFocus={(key, node, details) => scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' })}

View file

@ -155,7 +155,7 @@ function RouteComponent ()
useOnNavigateBack((s) => s.sound = 'returnDetails'); useOnNavigateBack((s) => s.sound = 'returnDetails');
const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists)); const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists) || e.source === 'store');
const { ref: intersct } = useIntersectionObserver({ const { ref: intersct } = useIntersectionObserver({
onChange: (isIntersecting, entry) => onChange: (isIntersecting, entry) =>

View file

@ -156,7 +156,7 @@ function HomeList (data: {
saveChildFocus="session" saveChildFocus="session"
onFocus={(l, n, d) => onFocus={(l, n, d) =>
{ {
const [source, id] = l.split('@', 1); const [source, id] = d.id?.split('@', 2);
queryClient.prefetchQuery(gameQuery(source, id)); queryClient.prefetchQuery(gameQuery(source, id));
handleNodeFocus(l, n, d); handleNodeFocus(l, n, d);
}} }}

View file

@ -114,7 +114,7 @@ function Settings ()
return "settings"; return "settings";
})).map(([cat, data]) => })).map(([cat, data]) =>
{ {
return <div className='flex flex-col gap-1'> return <div key={cat} className='flex flex-col gap-1'>
<div className="divider">{cat !== "settings" ? cat : <><Settings2 className='size-14' /> Settings</>}</div> <div className="divider">{cat !== "settings" ? cat : <><Settings2 className='size-14' /> Settings</>}</div>
{data?.map(([key, prop]) => {data?.map(([key, prop]) =>
{ {

View file

@ -357,6 +357,41 @@ export function RouteComponent ()
const stats: StatEntry[] = []; const stats: StatEntry[] = [];
if (emulator)
{
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.validSources.flatMap(s => [{
label: "Source", content: <div className="flex flex-col grow">
<div className="flex grow flex-wrap justify-between gap-1">
<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="flex flex-wrap text-base-content/40">
{i.capabilities?.map(c => <><div className="divider divider-horizontal"></div><div className="flex gap-1">{capabilityIconMap[c]}{c}</div></>)}
</div>
</div>;
})}
</div>
}]));
if (emulator.bios)
stats.push({
label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios : <div className="text-warning font-semibold">Missing</div>
});
}
return ( return (
<AnimatedBackground ref={ref} className="" scrolling> <AnimatedBackground ref={ref} className="" scrolling>
<AutoFocus focus={focusSelf} /> <AutoFocus focus={focusSelf} />

View file

@ -109,7 +109,7 @@ export const installMutation = (source: string, id: string) => mutationOptions({
mutationKey: ['install', source, id], mutationKey: ['install', source, id],
mutationFn: async (init: { downloadId?: string; }) => mutationFn: async (init: { downloadId?: string; }) =>
{ {
const { data, error } = await rommApi.api.romm.game({ source })({ id }).install.post({ query: { downloadId: init.downloadId } }); const { data, error } = await rommApi.api.romm.game({ source })({ id }).install.post({ downloadId: init.downloadId });
if (error) throw error; if (error) throw error;
return data; return data;
} }

View file

@ -65,6 +65,11 @@ export const GameListFilterSchema = z.object({
keywords: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), keywords: z.union([z.string().array(), z.string().transform(v => [v])]).optional(),
}); });
export const DownloadSourceSchema = z.object({
id: z.string(),
name: z.string()
});
export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() }); export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() });
export type GameListFilterType = z.infer<typeof GameListFilterSchema>; export type GameListFilterType = z.infer<typeof GameListFilterSchema>;
@ -208,3 +213,4 @@ 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>; export type EmulatorDownloadInfoType = z.infer<typeof EmulatorDownloadInfoSchema>;
export type DownloadSourceType = z.infer<typeof DownloadSourceSchema>;

View file

@ -126,6 +126,8 @@ declare interface CommandEntry
startDir?: string; startDir?: string;
/** Is the command valid, for example does the executable exists */ /** Is the command valid, for example does the executable exists */
valid: boolean; valid: boolean;
/** Run the command as shell. Defaults is true */
shell?: boolean;
/** For what emulator is the command */ /** For what emulator is the command */
emulator?: string; emulator?: string;
/** Where the emulator came from */ /** Where the emulator came from */
@ -252,6 +254,7 @@ declare type KeysWithValueAssignableTo<T, Value> = {
declare interface DownloadInfo declare interface DownloadInfo
{ {
id: string;
screenshotUrls: string[]; screenshotUrls: string[];
coverUrl: string; coverUrl: string;
platform?: DownloadPlatform; platform?: DownloadPlatform;