diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index b7abb35..bf64aaf 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -451,7 +451,7 @@ export default new Elysia() }, { 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)) { @@ -462,7 +462,7 @@ export default new Elysia() } }, { 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() }) .delete('/game/:source/:id/install', async ({ params: { id, source } }) => diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 82f69d4..3c46f23 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -4,11 +4,11 @@ import { getErrorMessage } from "@/bun/utils"; import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils"; import fs from 'node:fs/promises'; import Elysia from "elysia"; -import z from "zod"; +import z, { string } from "zod"; import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { LaunchGameJob } from "../../jobs/launch-game-job"; 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"; 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(['queued']) }), 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(['download', 'extract']), progress: z.number() }), ]), @@ -261,6 +261,8 @@ export default function buildStatusResponse () } 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 fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); 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', sources }); } else if (!localGame) { const files = await plugins.hooks.games.fetchDownloads.promise({ source: ws.data.params.source, id: ws.data.params.id }); + const sources = files?.map(d => ({ id: d.id, name: d.id })) ?? []; 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)) @@ -301,11 +304,11 @@ export default function buildStatusResponse () ws.send({ status: 'error', error: "Not Enough Free Space" }); } 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 { - ws.send({ status: 'install', details: 'Install' }); + ws.send({ status: 'install', details: 'Install', sources }); } } } else diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index f4ae463..aa715ca 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -67,7 +67,7 @@ export class GameHooks source: string; id: string; downloadId?: string; - }], DownloadInfo | undefined>(['ctx']); + }], DownloadInfo[] | undefined>(['ctx']); fetchRomFiles = new AsyncSeriesBailHook<[ctx: { source: string; id: string; diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 07f4fb6..166af46 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -53,7 +53,8 @@ export class InstallJob implements IJob const downloadPath = config.get('downloadPath'); 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}`); @@ -137,12 +138,21 @@ export class InstallJob implements IJob { if (filePath.endsWith('.zip')) { + cx.setProgress(0, "extract"); console.warn("Could not extract", filePath, "with 7zip trying zip extractor"); await ensureDir(extractPath); 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); console.log(`Extracted ${count} entries`); await zip.close(); + await fs.rm(filePath); } else { throw e; diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 9159002..1a430e5 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -120,7 +120,7 @@ export class LaunchGameJob implements IJob ctx.zodRegistry.add(SettingsSchema.shape.globalConfig, { requiresRestart: true }); 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 = { + win32: '**/rclone.exe', + linux: '**/rclone', + darwin: '**/rclone' + }; + const existingRclones = await Array.fromAsync(fs.glob(binaryMap[process.platform], { cwd: toolsPath })); if (existingRclones[0]) { this.rclonePath = path.join(toolsPath, existingRclones[0]); @@ -83,13 +86,19 @@ export default class RcloneIntegration implements PluginType return; } - if (await fs.exists(path.join(toolsPath, 'rclone-current-windows-amd64'))) - { - return; - } - ctx.setProgress(0.5, "Downloading RClone"); - const rcCloseZip = await fetch(`https://downloads.rclone.org/rclone-current-windows-amd64.zip`); + const platformMap: Record = { + linux: "linux", + win32: "windows", + darwin: "osx" + }; + const archMap: Record = { + 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 pipeline(Readable.fromWeb(rcCloseZip.body as any), unzip.Extract({ path: toolsPath })); @@ -97,6 +106,7 @@ export default class RcloneIntegration implements PluginType if (dests[0]) { this.rclonePath = path.join(toolsPath, dests[0]); + await fs.chmod(this.rclonePath, 0o755); await this.startServer(ctx); return; } @@ -139,7 +149,12 @@ export default class RcloneIntegration implements PluginType if (data.level === 'error') { console.error(data.msg); - } else + } else if (data.level === 'critical') + { + console.error(data.msg); + } + + else { console.log(e); if (loginTokenUrlRegex.test(data.msg)) @@ -150,7 +165,7 @@ export default class RcloneIntegration implements PluginType }); - await new Promise((resolve) => + await new Promise((resolve, reject) => { const handleResolve = (line: string) => { @@ -160,6 +175,7 @@ export default class RcloneIntegration implements PluginType resolve(data); }; rl.on('line', handleResolve); + setTimeout(() => { reject("Timeout"); }, 5000); }); await this.refresh(); diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 6bfbe2d..c0d754e 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -31,6 +31,12 @@ export default class RommIntegration implements PluginType release: "metadatum.first_release_date" }; + async checkRemote () + { + if (!config.has('rommAddress')) return false; + return true; + } + async updateClient () { client.setConfig({ @@ -141,6 +147,7 @@ export default class RommIntegration implements PluginType 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')) { @@ -173,6 +180,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) => { + if (!await this.checkRemote()) return; if (source && source !== 'romm') return; const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true }); @@ -185,12 +193,14 @@ export default class RommIntegration implements PluginType ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) => { + if (!await this.checkRemote()) return; if (service !== 'romm') return; await this.updateClient(); }); ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) => { + if (!await this.checkRemote()) return; if (source !== 'romm') return; const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } }); @@ -205,6 +215,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchDownloads.tapPromise(desc.name, async ({ source, id }) => { + if (!await this.checkRemote()) return; if (source !== 'romm') return; const rom = (await getRomApiRomsIdGet({ path: { id: Number(id) }, throwOnError: true })).data; @@ -260,12 +271,13 @@ export default class RommIntegration implements PluginType extract_path }; - return info; + return [info]; }); ctx.hooks.emulators.fetchBiosDownload.tapPromise(desc.name, async ({ systems, biosFolder }) => { + if (!await this.checkRemote()) return; const files: DownloadFileEntry[] = []; const allRommPlatforms = await this.getAllRommPlatforms(); @@ -296,6 +308,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) => { + if (!await this.checkRemote()) return; const rommPlatforms = await this.getAllRommPlatforms(); if (rommPlatforms) { @@ -313,7 +326,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchRecommendedGamesForEmulator.tapPromise(desc.name, async ({ emulator, games, systems }) => { - + if (!await this.checkRemote()) return; const rommPlatforms = await this.getAllRommPlatforms(); const systemsRommSlugSet = new Set(systems.filter(s => s.romm_slug).map(s => s.romm_slug!)); if (rommPlatforms) @@ -343,6 +356,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchPlatform.tapPromise(desc.name, async ({ source, id }) => { + if (!await this.checkRemote()) return; if (source !== 'romm') return; const { data: rommPlatform } = await getPlatformApiPlatformsIdGet({ path: { id: Number(id) } }); if (rommPlatform) @@ -365,7 +379,13 @@ export default class RommIntegration implements PluginType 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) { const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p => @@ -401,6 +421,7 @@ export default class RommIntegration implements PluginType 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 (!saveFolderSlots) return; @@ -445,6 +466,7 @@ export default class RommIntegration implements PluginType // Should run after emulators decide on saves 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; const sourceValidation = await validateGameSource(source, id); @@ -529,6 +551,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchCollections.tapPromise(desc.name, async ({ collections }) => { + if (!await this.checkRemote()) return; const rommCollections = await getCollectionsApiCollectionsGet(); if (rommCollections.response.ok && rommCollections.data) { @@ -549,6 +572,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchCollection.tapPromise(desc.name, async ({ source, id }) => { + if (!await this.checkRemote()) return; if (source !== 'romm') return; const collection = await getCollectionApiCollectionsIdGet({ path: { id: Number(id) } }); if (collection.data) @@ -567,6 +591,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) => { + if (!await this.checkRemote()) return; let platform: PlatformSchema | undefined = undefined; if (id && source) @@ -587,6 +612,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.searchGame.tapPromise(desc.name, async ({ source, igdb_id, ra_id }) => { + if (!await this.checkRemote()) return; if (source !== 'romm') return; const roms = await getRomByMetadataProviderApiRomsByMetadataProviderGet({ query: { igdb_id, ra_id } }); if (roms.error) throw roms.error; diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts index 59e0432..852284a 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts @@ -50,16 +50,16 @@ function convertStoreMediaToPath (c: string) export async function convertStoreToFrontend (id: string, storeGame: StoreGameType): Promise { - const validDownload = getValidDownload(storeGame); + const validDownloads = getValidDownloads(storeGame); let platform_slug: string | null = null; let platform_id: number | null = null; let platform_display_name: 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'; 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 { - const validDownload = getValidDownload(storeGame); + const validDownloads = getValidDownloads(storeGame); let size: number | null = null; - if (validDownload?.url) + if (validDownloads.length > 0 && validDownloads[0].url) { 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')); } catch (error) { @@ -167,25 +167,32 @@ export async function convertStoreToFrontendDetailed (id: string, storeGame: Sto 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 supportedDownloads = downloads.filter(d => d.type === 'direct'); if (downloadId) { - return supportedDownloads.find(d => d.id === downloadId); + return supportedDownloads.filter(d => d.id === downloadId); } else { - return supportedDownloads.find(d => d.system === `${process.platform}:${process.arch}`) - ?? supportedDownloads.find(d => - { - // Linux supports proton, can run windows games - if (process.platform === 'linux') return d.system === `win32:${process.arch}`; - return false; - }) - // Fallback to emulator platforms - ?? supportedDownloads.find(d => !d.system.includes(':')); + return supportedDownloads.filter(d => + { + if (d.system === `${process.platform}:${process.arch}`) return true; + + // TODO: Add linux proton support + //if (process.platform === 'linux' && d.system === `win32:${process.arch}`) return true; + + // emulator fallback + 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, gameCount: 0, validSources: execPaths, - integrations: [] + integrations: [], + source: "store" }; return em; diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts index e3dec17..59e3b26 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts @@ -1,22 +1,17 @@ import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; 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 { Glob, pathToFileURL } from "bun"; -import { getOrCached } from "@/bun/api/cache"; -import { shuffleInPlace } from "@/bun/utils"; import { and, eq } from "drizzle-orm"; 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 { 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 { 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 { @@ -29,11 +24,13 @@ export default class RommIntegration implements PluginType async load (ctx: PluginLoadingContextType) { + await this.setup(ctx); ctx.hooks.store.fetchDownload.tapPromise(desc.name, async ({ id }) => { const emulatorPackage = await getStoreEmulatorPackage(id); - const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage!); + if (!emulatorPackage) return; + const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage); 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 }) => { - if (source !== 'store' || !gamePath || systemSlug !== 'win') return; + if (source !== 'store' || !gamePath) return; const downloadPath = config.get('downloadPath'); const gamePathAbsolute = path.join(downloadPath, gamePath); if (!(await fs.exists(gamePathAbsolute))) return; @@ -139,13 +136,15 @@ export default class RommIntegration implements PluginType if (gamePathStat.isDirectory()) { + if (!mainGlob && systemSlug !== 'win') return; const fileGlob = new Glob(mainGlob ?? '**/*.exe'); for await (const file of fileGlob.scan({ cwd: path.join(downloadPath, gamePath) })) { return [{ startDir: path.join(downloadPath, gamePath, dirname(file)), - command: basename(file), - id: 'store-win', + command: `./${basename(file)}`, + id: `store-${process.platform}`, + shell: false, valid: true, env: { XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '') @@ -160,12 +159,13 @@ export default class RommIntegration implements PluginType { return [{ startDir: path.join(downloadPath, dirname(gamePath)), - command: basename(gamePath), + command: `./${basename(gamePath)}`, env: { XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '') }, - id: 'store-win', + id: `store-${process.platform}`, valid: true, + shell: false, metadata: { romPath: path.join(downloadPath, gamePath) } @@ -272,14 +272,15 @@ export default class RommIntegration implements PluginType const game = await getStoreGame(id); 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]; if (system === 'win32') system = 'win'; 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 : "", screenshotUrls: game.screenshots ?? [], files: [{ @@ -306,7 +307,7 @@ export default class RommIntegration implements PluginType }; return info; - } + }); }); } } \ No newline at end of file diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index b15f5b6..e70da91 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -126,7 +126,14 @@ export const store = new Elysia({ prefix: '/api/store' }) }) .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() }) }) .post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) => { diff --git a/src/bun/utils/get-browser.ts b/src/bun/utils/get-browser.ts index ffcf07c..c489dcc 100644 --- a/src/bun/utils/get-browser.ts +++ b/src/bun/utils/get-browser.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "bun"; +import { Glob, spawnSync } from "bun"; import { platform } from "node:os"; import { RunBrowserType } from "./browser-spawner"; import path from 'node:path'; @@ -48,12 +48,17 @@ const ARCH_MAP: Record> = { }; /** 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 { - const subFolder = `ungoogled-chromium_${version}_${PLATFORM_MAP[platform]}_${ARCH_MAP[platform][arch]}`; - if (platform === "linux") return path.join(outDir, subFolder, "chrome"); - if (platform === "darwin") return path.join(outDir, "Chromium.app"); - return path.join(outDir, subFolder, "chrome.exe"); + let glob: Glob | undefined = undefined; + if (platform === "linux") glob = new Glob(`**/chrome`); + else if (platform === "darwin") glob = new Glob(`**/Chromium.app`); + 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 { data.game.onFocus?.(focusKey, node, details); - data.onFocus?.(focusKey, node, details); + data.onFocus?.(focusKey, node, { ...details, id: data.game.id }); }} onAction={handleAction} preview={preview} diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index b0acd8f..0511b6f 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -18,7 +18,7 @@ export function ContextList (data: { { const context = useContext(ContextDialogContext); return
    - {data.options?.map(o => )} + {data.options?.map((o, i) => )} {data.showCloseButton !== false &&
    } {data.showCloseButton !== false && } action={() => context.close()} id="close-context-dialog" content="Close" />}
; diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index 681afd2..c70369a 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -5,10 +5,11 @@ import { getErrorMessage } from "react-error-boundary"; import toast from "react-hot-toast"; import { useLocalStorage } from "usehooks-ts"; 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 ActionButton from "./ActionButton"; import { useRouter } from "@tanstack/react-router"; +import { DownloadSourceType } from "@/shared/constants"; 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(undefined); const [error, setError] = useState(undefined); const [details, setDetails] = useState(undefined); + const [installSources, setInstallSources] = useState(undefined); const [commands, setCommands] = useState(undefined); const [preferredCommand, setPreferredCommand] = useLocalStorage(`${data.game?.source ?? data.game?.id.source}-${data.game?.source_id ?? data.game?.id.id}-preferred-command`, undefined); const queryClient = useQueryClient(); @@ -51,6 +53,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so setProgress((e.data as any).progress); setDetails((e.data as any).details); setCommands((e.data as any).commands); + setInstallSources((e.data as any).sources); if (e.data.status === 'refresh') { @@ -154,7 +157,11 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so let icon = ; if (status === 'install') { - icon = ; + if (installSources && installSources.length > 1) + icon = ; + else + icon = ; + } else if (status === 'present') { icon = ; @@ -168,7 +175,14 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so { case 'present': case 'install': - installMut.mutate({}); + if (installSources && installSources.length > 1) + { + showInstallSource(true, 'mainAction'); + } else + { + installMut.mutate({}); + } + break; } }} @@ -211,6 +225,19 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so }]} /> }); + const { dialog: installSourcesDialog, setOpen: showInstallSource } = useContextDialog('install-source-dialog', { + content: ({ + content: s.name, + action (ctx) + { + installMut.mutate({ downloadId: s.id }); + ctx.close(); + }, + type: 'primary', + id: s.id + } satisfies DialogEntry)) ?? []} /> + }); + return
{mainButton}
@@ -222,6 +249,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
} + {installSourcesDialog} {installOptionsDialog} {allCommandDialog} ; diff --git a/src/mainview/components/store/GamesSection.tsx b/src/mainview/components/store/GamesSection.tsx index 843e8e7..0a1f4cb 100644 --- a/src/mainview/components/store/GamesSection.tsx +++ b/src/mainview/components/store/GamesSection.tsx @@ -44,7 +44,7 @@ export function GamesSection (data: { {data.games?.map((g, i) => data.onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id))} onFocus={(key, node, details) => scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' })} diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 24c8126..103d90f 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -155,7 +155,7 @@ function RouteComponent () 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({ onChange: (isIntersecting, entry) => diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 2e20385..12d7411 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -156,7 +156,7 @@ function HomeList (data: { saveChildFocus="session" onFocus={(l, n, d) => { - const [source, id] = l.split('@', 1); + const [source, id] = d.id?.split('@', 2); queryClient.prefetchQuery(gameQuery(source, id)); handleNodeFocus(l, n, d); }} diff --git a/src/mainview/routes/settings/plugin.$source.tsx b/src/mainview/routes/settings/plugin.$source.tsx index e6c1d3e..ecd46af 100644 --- a/src/mainview/routes/settings/plugin.$source.tsx +++ b/src/mainview/routes/settings/plugin.$source.tsx @@ -114,7 +114,7 @@ function Settings () return "settings"; })).map(([cat, data]) => { - return
+ return
{cat !== "settings" ? cat : <> Settings}
{data?.map(([key, prop]) => { diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index c93eab4..5b3beeb 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -357,6 +357,41 @@ export function RouteComponent () 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:
+
+
{emulatorStatusIcons[s.type]}{s.type}
+
{s.binPath}
+
+ {emulator.integrations.some(i => i.source?.type === s.type) &&
} + {emulator.integrations.filter(i => i.source?.type === s.type).map(i => + { + return
+
+ +
{i.id}
+
+
+ {i.capabilities?.map(c => <>
{capabilityIconMap[c]}{c}
)} +
+
; + })} +
+ }])); + if (emulator.bios) + stats.push({ + label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios :
Missing
+ }); + + } + return ( diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts index ded28d1..6b185c3 100644 --- a/src/mainview/scripts/queries/romm.ts +++ b/src/mainview/scripts/queries/romm.ts @@ -109,7 +109,7 @@ export const installMutation = (source: string, id: string) => mutationOptions({ mutationKey: ['install', source, id], 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; return data; } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5523dac..74670f8 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -65,6 +65,11 @@ export const GameListFilterSchema = z.object({ 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 type GameListFilterType = z.infer; @@ -208,3 +213,4 @@ export type LocalSettingsType = z.infer; export const PlatformSchema = z.object({ slug: z.string() }); export type SystemInfoType = z.infer; export type EmulatorDownloadInfoType = z.infer; +export type DownloadSourceType = z.infer; diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index 7d2ca5f..c2396ba 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -126,6 +126,8 @@ declare interface CommandEntry startDir?: string; /** Is the command valid, for example does the executable exists */ valid: boolean; + /** Run the command as shell. Defaults is true */ + shell?: boolean; /** For what emulator is the command */ emulator?: string; /** Where the emulator came from */ @@ -252,6 +254,7 @@ declare type KeysWithValueAssignableTo = { declare interface DownloadInfo { + id: string; screenshotUrls: string[]; coverUrl: string; platform?: DownloadPlatform;