From 8a0be8c913ee600bc2145c48c9a3d6c4ddf86320 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Tue, 31 Mar 2026 03:11:02 +0300 Subject: [PATCH] test: Added download test and made app more testable in general fix: Store Downloads not properly working on steam deck fix: Removed linux shortcuts implementation --- bun.lock | 6 + package.json | 2 + src/bun/api/app.ts | 116 ++++++++----- src/bun/api/controls/controls.ts | 9 +- src/bun/api/controls/gamepad.ts | 4 - src/bun/api/controls/linux.ts | 70 +------- src/bun/api/games/games.ts | 8 +- src/bun/api/jobs/bios-download-job.ts | 2 +- src/bun/api/jobs/emulator-download-job.ts | 42 +++-- src/bun/api/jobs/install-job.ts | 18 ++- src/bun/api/jobs/launch-game-job.ts | 2 +- src/bun/api/rpc.ts | 28 +++- src/bun/api/task-queue.ts | 58 ++++--- src/bun/index.ts | 18 +-- src/mainview/components/game/MainActions.tsx | 2 +- src/mainview/gen/static-icon-assets.gen.ts | 2 +- src/mainview/scripts/clientApi.ts | 2 +- src/mainview/scripts/queries/romm.ts | 3 +- src/mainview/scripts/spatialNavigation.ts | 1 - src/shared/constants.ts | 17 +- src/tests/client.ts | 20 +++ src/tests/downloads.test.ts | 162 +++++++++++++++++++ src/tests/game-launching.test.ts | 10 +- src/tests/mock-roms/mock-emulator.exe | 0 src/tests/mock-roms/mock-rom.iso | 0 src/tests/preload.ts | 30 +++- 26 files changed, 422 insertions(+), 210 deletions(-) create mode 100644 src/tests/client.ts create mode 100644 src/tests/downloads.test.ts delete mode 100644 src/tests/mock-roms/mock-emulator.exe delete mode 100644 src/tests/mock-roms/mock-rom.iso diff --git a/bun.lock b/bun.lock index a73c2b2..6188919 100644 --- a/bun.lock +++ b/bun.lock @@ -49,6 +49,7 @@ "@tanstack/react-router-ssr-query": "^1.157.17", "@tanstack/router-plugin": "^1.157.16", "@tanstack/zod-adapter": "^1.162.4", + "@types/adm-zip": "^0.5.8", "@types/bun": "latest", "@types/fs-extra": "^11.0.4", "@types/ini": "^4.1.1", @@ -58,6 +59,7 @@ "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", "@vitejs/plugin-react": "^5.1.2", + "adm-zip": "^0.5.16", "animate.css": "^4.1.1", "app-builder-bin": "^5.0.0-alpha.13", "babel-plugin-react-compiler": "^1.0.0", @@ -584,6 +586,8 @@ "@trysound/sax": ["@trysound/sax@0.2.0", "", {}, "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="], + "@types/adm-zip": ["@types/adm-zip@0.5.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -636,6 +640,8 @@ "add-stream": ["add-stream@1.0.0", "", {}, "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ=="], + "adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="], + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], diff --git a/package.json b/package.json index 0d5efe3..934d2a5 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@tanstack/react-router-ssr-query": "^1.157.17", "@tanstack/router-plugin": "^1.157.16", "@tanstack/zod-adapter": "^1.162.4", + "@types/adm-zip": "^0.5.8", "@types/bun": "latest", "@types/fs-extra": "^11.0.4", "@types/ini": "^4.1.1", @@ -96,6 +97,7 @@ "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", "@vitejs/plugin-react": "^5.1.2", + "adm-zip": "^0.5.16", "animate.css": "^4.1.1", "app-builder-bin": "^5.0.0-alpha.13", "babel-plugin-react-compiler": "^1.0.0", diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index cd96182..1465dd0 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -5,7 +5,7 @@ import { CookieJar } from 'tough-cookie'; import FileCookieStore from 'tough-cookie-file-store'; import path from 'node:path'; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; -import { drizzle } from "drizzle-orm/bun-sqlite"; +import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite"; import Conf from "conf"; import projectPackage from '~/package.json'; import { SettingsSchema, SettingsType } from "@shared/constants"; @@ -23,60 +23,88 @@ import { getStoreFolder } from "./store/services/gamesService"; import { PluginManager } from "./plugins/plugin-manager"; import registerPlugins from "./plugins/register-plugins"; import controls from './controls/controls'; +import { RunAPIServer } from "./rpc"; +import { RunBunServer } from "../server"; -export const config = new Conf({ - projectName: projectPackage.name, - projectSuffix: 'bun', - cwd: process.env.CONFIG_CWD, - schema: Object.fromEntries(Object.entries(SettingsSchema.shape).map(([key, schema]) => [key, schema.toJSONSchema() as any])) as any, - defaults: SettingsSchema.parse({ - downloadPath: path.join(os.homedir(), "gameflow"), - windowSize: { width: 1280, height: 800 } - }), -}); -export const customEmulators = new Conf>({ - projectName: projectPackage.name, - projectSuffix: 'bun', - cwd: process.env.CONFIG_CWD, - configName: 'custom-emulators', - rootSchema: { - "type": "object", - "additionalProperties": { - "type": "string" - } - } -}); - -console.log("Config Path Located At: ", config.path); -console.log("Custom Emulator Paths Located At: ", customEmulators.path); -console.log("App Directory is ", process.env.APPDIR); -console.log("Store Directory is ", getStoreFolder()); - -const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json')); -console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath); -export const jar = new CookieJar(fileCookieStore); +export let config: Conf; +export let customEmulators: Conf>; +export let fileCookieStore: FileCookieStore; +export let jar: CookieJar; let sqlite: Database; -export const cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite'); +export let cachePath: string; let cacheSqlite: Database; export let db: DrizzleSqliteDODatabase; export let cache: DrizzleSqliteDODatabase; -await reloadDatabase(); -const emulatorsSqlite = new Database(appPath(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`), { readonly: true }); -export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema }); -export const taskQueue = new TaskQueue(); -config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v })); -export const plugins = new PluginManager(); -registerPlugins(plugins); -export const events = new EventEmitter(); -config.onDidChange('downloadPath', () => reloadDatabase()); -taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); -await controls(); +let emulatorsSqlite: Database; +export let emulatorsDb: BunSQLiteDatabase & { $client: Database; }; +export let taskQueue: TaskQueue; +export let plugins: PluginManager; +export let events: EventEmitter; +let controlsHandle: { cleanup: () => void; }; +let api: any; +let bunServer: { stop: () => void; } | undefined; + +export async function load () +{ + config = new Conf({ + projectName: projectPackage.name, + projectSuffix: 'bun', + cwd: process.env.CONFIG_CWD, + schema: Object.fromEntries(Object.entries(SettingsSchema.shape).map(([key, schema]) => [key, schema.toJSONSchema() as any])) as any, + defaults: SettingsSchema.parse({ + downloadPath: process.env.DEFAULT_DOWNLOAD_PATH ?? path.join(os.homedir(), "gameflow"), + windowSize: { width: 1280, height: 800 } + }), + }); + customEmulators = new Conf>({ + projectName: projectPackage.name, + projectSuffix: 'bun', + cwd: process.env.CONFIG_CWD, + configName: 'custom-emulators', + rootSchema: { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }); + + console.log("Config Path Located At: ", config.path); + console.log("Custom Emulator Paths Located At: ", customEmulators.path); + console.log("App Directory is ", process.env.APPDIR); + console.log("Store Directory is ", getStoreFolder()); + + cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite'); + fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json')); + console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath); + jar = new CookieJar(fileCookieStore); + taskQueue = new TaskQueue(); + events = new EventEmitter(); + emulatorsSqlite = new Database(appPath(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`), { readonly: true }); + emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema }); + await reloadDatabase(); + plugins = new PluginManager(); + await registerPlugins(plugins); + api = await RunAPIServer(); + controlsHandle = await controls(); + if (!process.env.PUBLIC_ACCESS) bunServer = await RunBunServer(); + + config.onDidChange('downloadPath', () => reloadDatabase()); + config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v })); + taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); +} export async function cleanup () { + console.log("Cleaning Up"); + bunServer?.stop(); + await api.apiServer.stop(true); + await api.cleanup(); await taskQueue.close(); + controlsHandle.cleanup(); sqlite.close(); emulatorsSqlite.close(); + console.log("Finished Cleaning Up"); } export async function reloadDatabase () diff --git a/src/bun/api/controls/controls.ts b/src/bun/api/controls/controls.ts index 320c12b..4aa417a 100644 --- a/src/bun/api/controls/controls.ts +++ b/src/bun/api/controls/controls.ts @@ -22,7 +22,7 @@ export default async function Initialize () } } - setInterval(() => + const loop = setInterval(() => { for (const pad of manager.getGamepads()) { @@ -56,4 +56,11 @@ export default async function Initialize () endPressed = false; } }, 100); + + return { + cleanup: () => + { + clearInterval(loop); + } + }; } \ No newline at end of file diff --git a/src/bun/api/controls/gamepad.ts b/src/bun/api/controls/gamepad.ts index 7e94a3b..2589864 100644 --- a/src/bun/api/controls/gamepad.ts +++ b/src/bun/api/controls/gamepad.ts @@ -19,10 +19,6 @@ export class Gamepad { const { GamepadWindows } = await import("./windows"); this.backend = new GamepadWindows(this.index); - } else - { - const { GamepadLinux } = await import("./linux"); - this.backend = new GamepadLinux(this.index); } } diff --git a/src/bun/api/controls/linux.ts b/src/bun/api/controls/linux.ts index fead3a0..436d438 100644 --- a/src/bun/api/controls/linux.ts +++ b/src/bun/api/controls/linux.ts @@ -1,87 +1,23 @@ -import { IGamepadBackend, GamepadState, ButtonName } from "./types"; -import { openSync, readSync, closeSync, readdirSync } from "fs"; +import { IGamepadBackend, GamepadState } from "./types"; export class GamepadLinux implements IGamepadBackend { - private fd: number; - private buttons: boolean[]; - private axes: number[]; - private buttonsCount = 16; - private axesCount = 4; - constructor(index = 0) { - const devices = readdirSync("/dev/input").filter(f => f.startsWith("js")); - if (!devices[index]) throw new Error("No gamepad found"); - const path = `/dev/input/${devices[index]}`; - this.fd = openSync(path, "r"); - this.buttons = Array(this.buttonsCount).fill(false); - this.axes = Array(this.axesCount).fill(0); } update (): GamepadState | null { - const buf = Buffer.alloc(8); - let bytesRead; - try - { - bytesRead = readSync(this.fd, buf, 0, 8, null); - } catch - { - return null; - } - if (bytesRead !== 8) return null; - - const [time, value, type, number] = [ - buf.readUInt32LE(0), - buf.readInt16LE(4), - buf[6], - buf[7], - ]; - - if (type === 1) this.buttons[number] = value !== 0; - else if (type === 2 && number < 4) this.axes[number] = value / 32767; - - const btnMap: Record = { - A: this.buttons[0] ?? false, - B: this.buttons[1] ?? false, - X: this.buttons[2] ?? false, - Y: this.buttons[3] ?? false, - UP: this.buttons[4] ?? false, - DOWN: this.buttons[5] ?? false, - LEFT: this.buttons[6] ?? false, - RIGHT: this.buttons[7] ?? false, - LB: this.buttons[8] ?? false, - RB: this.buttons[9] ?? false, - START: this.buttons[10] ?? false, - SELECT: this.buttons[11] ?? false, - L3: this.buttons[12] ?? false, - R3: this.buttons[13] ?? false, - }; - - return { - buttons: btnMap, - leftStick: { x: this.axes[0] ?? 0, y: this.axes[1] ?? 0 }, - rightStick: { x: this.axes[2] ?? 0, y: this.axes[3] ?? 0 }, - triggers: { left: 0, right: 0 }, - }; + return null; } isConnected () { - try - { - readSync(this.fd, Buffer.alloc(1), 0, 1, null); - return true; - } catch - { - return false; // file disappeared or read failed - } + return false; } close () { - closeSync(this.fd); } } \ No newline at end of file diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 563bab0..9666d44 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -348,13 +348,7 @@ export default new Elysia() { if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob)) { - if (source === 'romm' || source === 'store') - { - taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source)); - return status(200); - } - - return status('Not Implemented'); + return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source)); } else { return status('Not Implemented'); diff --git a/src/bun/api/jobs/bios-download-job.ts b/src/bun/api/jobs/bios-download-job.ts index 0259c95..be46c5f 100644 --- a/src/bun/api/jobs/bios-download-job.ts +++ b/src/bun/api/jobs/bios-download-job.ts @@ -22,7 +22,7 @@ export class BiosDownloadJob implements IJob, never, "download">) + async start (context: JobContext, "download">, z.infer, "download">) { const emulator = await getStoreEmulatorPackage(this.emulator); if (!emulator) throw new Error("Could Not Find Emulator"); diff --git a/src/bun/api/jobs/emulator-download-job.ts b/src/bun/api/jobs/emulator-download-job.ts index da87ccc..d1c9161 100644 --- a/src/bun/api/jobs/emulator-download-job.ts +++ b/src/bun/api/jobs/emulator-download-job.ts @@ -40,25 +40,27 @@ export class EmulatorDownloadJob implements IJob d.type === this.downloadSource); - if (!validDownload || !validDownload.path) throw new Error(`Download type ${this.downloadSource} not found`); + if (!validDownload) throw new Error(`Download type ${this.downloadSource} not found`); - 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"); - const downloadUrl = validAsset.browser_download_url; - const emulatorsFolder = path.join(config.get('downloadPath'), "emulators", this.emulator); - - const isArchive = validAsset.content_type === 'application/x-7z-compressed' || validAsset.name.endsWith('.7z') || validAsset.content_type === 'application/zip' || validAsset.name.endsWith('.zip'); - - const isAppImage = validAsset.name.endsWith(".AppImage"); - - if (!isArchive && !isAppImage) + let downloadUrl: URL; + if (validDownload.type === 'github') { - throw new Error("Invalid Download Type"); + 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 + { + throw new Error("Download Type Unsupported"); } + const emulatorsFolder = path.join(config.get('downloadPath'), "emulators", this.emulator); + if (this.dryRun) { await simulateProgress(p => context.setProgress(p, "download"), context.abortSignal); @@ -67,7 +69,7 @@ export class EmulatorDownloadJob implements IJob { let progress = 0; const progressDelta = 1 / downloadedFiles.length; - for (const path of downloadedFiles) + for (const filePath of downloadedFiles) { - const extractPath = info.extract_path; + const extractPath = path.join(config.get('downloadPath'), info.extract_path); await new Promise((resolve, reject) => { - const seven = Seven.extractFull(path, extractPath, { $bin: process.env.ZIP7_PATH, $progress: true }); - seven.on('progress', p => cx.setProgress(progress + p.percent * progressDelta, "extract")); + const seven = Seven.extractFull(filePath, extractPath, { $bin: process.env.ZIP7_PATH, $progress: true }); + seven.on('progress', p => + { + cx.setProgress(progress + p.percent * progressDelta, "extract"); + }); + seven.on('error', e => reject(e)); - seven.on('end', () => resolve(true)); + seven.on('end', async () => + { + await fs.rm(filePath); + resolve(true); + }); }); progress += progressDelta * 100; } diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 06ddbb3..91004bb 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -25,7 +25,7 @@ export class LaunchGameJob implements IJob, ActiveGameType, "playing">) + async start (context: JobContext, "playing">, z.infer, "playing">) { const localGame = await db.query.games.findFirst({ where: eq(appSchema.games.id, this.gameId), columns: { diff --git a/src/bun/api/rpc.ts b/src/bun/api/rpc.ts index 55ef126..721822d 100644 --- a/src/bun/api/rpc.ts +++ b/src/bun/api/rpc.ts @@ -9,7 +9,7 @@ import { host } from "../utils/host"; import { jobs } from "./jobs/jobs"; import plugins from "./plugins/plugins"; -const api = new Elysia({ serve: {} }) +const api = new Elysia() .use([cors(), clients, settings, system, store, jobs, plugins]); export type RommAPIType = typeof clients; @@ -19,18 +19,30 @@ export type StoreAPIType = typeof store; export type JobsAPIType = typeof jobs; export type PluginsAPIType = typeof plugins; -export function RunAPIServer () +export async function RunAPIServer () { - console.log("Launching API Server on port ", RPC_PORT); - return { - apiServer: api.listen({ + await new Promise((resolve, reject) => + { + const timeout = setTimeout(() => reject(new Error("Server startup timed out")), 5000); + + api.listen({ port: RPC_PORT, - hostname: host, + ...(host && host !== 'localhost' && { hostname: host }), development: process.env.NODE_ENV === 'development' - }), + }, s => + { + clearTimeout(timeout); + console.log("Launching API Server on", s.url.href); + resolve(); + }); + }); + + await api.modules; + return { + apiServer: api, async cleanup () { - + await api.stop(); } }; } \ No newline at end of file diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index e4d8ff5..017b75b 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -5,39 +5,41 @@ import z from 'zod'; export class TaskQueue { - private activeQueue: { context: JobContext, any, string>, promise?: Promise; }[] = []; - private queue?: { context: JobContext, any, string>, promise?: Promise; }[] = []; + private activeQueue: JobContext, any, string>[] = []; + private queue?: JobContext, any, string>[] = []; private events?: EventEmitter = new EventEmitter(); - public enqueue> (id: string, job: T) + public enqueue (id: string, job: T): T extends IJob + ? Promise + : never { this.disposeSafeguard(); if (!this.queue || !this.events) throw new Error("Queue disposed"); - const context = new JobContext(id, this.events, job); - this.queue.push({ context }); + const context = new JobContext(id, this.events, job); + this.queue.push(context as any); this.events?.emit('queued', { id: context.id, job: context }); - return this.processQueue(); + this.processQueue(); + return context.promise.promise as any; } private processQueue () { if (!this.queue) return Promise.resolve(); - const next = this.queue.filter(j => !j.context.job.group || !this.activeQueue.some(a => a.context.job.group === j.context.job.group)).map((job, i) => ({ i, job })); + const next = this.queue.filter(j => !j.job.group || !this.activeQueue.some(a => a.job.group === j.job.group)).map((job, i) => ({ i, job })); next.reverse().forEach(({ i }) => this.queue!.splice(i, 1)); next.forEach(job => { - const promise = job.job.context.start(); - job.job.promise = promise; + job.job.start(); this.activeQueue.push(job.job); - promise.finally(() => + job.job.promise.promise.finally(() => { const index = this.activeQueue.indexOf(job.job); this.activeQueue.splice(index, 1); // We need to call it after it has been removed from the queue, so that the has active of type doesn't return true - this.events?.emit('ended', { id: job.job.context.id, job: job.job.context }); + this.events?.emit('ended', { id: job.job.id, job: job.job }); setTimeout(() => this.processQueue(), 0); }); }); @@ -57,7 +59,7 @@ export class TaskQueue { for (const entry of this.activeQueue) { - if (entry.context.job instanceof type) + if (entry.job instanceof type) { return true; } @@ -67,19 +69,25 @@ export class TaskQueue public waitForJob (id: string): Promise { - const job = this.queue?.find(j => j.context.id === id) ?? this.activeQueue?.find(j => j.context.id === id); - return job?.promise ?? Promise.resolve(); + const job = this.queue?.find(j => j.id === id) ?? this.activeQueue?.find(j => j.id === id); + return job?.promise.promise ?? Promise.resolve(); } - - public findJob> (id: string, type: new (...args: any[]) => T): IPublicJob | undefined + public findJob ( + id: string, + type: new (...args: any[]) => T + ): T extends IJob + ? IPublicJob | undefined + : undefined { - const job = this.queue?.find(j => j.context.id === id) ?? this.activeQueue?.find(j => j.context.id === id); - if (job?.context.job instanceof type) + const job = this.queue?.find(j => j.id === id) + ?? this.activeQueue?.find(j => j.id === id); + + if (job?.job instanceof type) { - return job?.context; + return job as any; } - return undefined; + return undefined as any; } public on (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void @@ -96,7 +104,7 @@ export class TaskQueue public async close () { this.queue = []; - this.activeQueue.forEach(c => c.context.abort()); + this.activeQueue.forEach(c => c.abort()); return Promise.all(this.activeQueue.map(c => c.promise)); } } @@ -181,6 +189,7 @@ export class JobContext, TData, TState extends str private error?: any; private events: EventEmitter; private abortController: AbortController; + private m_promise: PromiseWithResolvers; private readonly m_job: T; constructor(id: string, events: EventEmitter, job: T) @@ -194,9 +203,10 @@ export class JobContext, TData, TState extends str this.events.emit('abort', { id: this.m_id, reason: this.abortController.signal.reason, job: this } satisfies AbortEvent); }); this.events = events; + this.m_promise = Promise.withResolvers(); } - public async start (): Promise + public async start () { try { @@ -204,6 +214,7 @@ export class JobContext, TData, TState extends str await this.m_job.start(this); this.completed = true; this.events.emit('completed', { id: this.m_id, job: this }); + this.m_promise.resolve(this.m_job.exposeData?.()); } catch (error) { @@ -214,6 +225,7 @@ export class JobContext, TData, TState extends str this.events.emit('error', { id: this.m_id, job: this, error }); this.error = error; + this.m_promise.reject(error); } finally { this.running = false; @@ -233,6 +245,8 @@ export class JobContext, TData, TState extends str public get job () { return this.m_job; } + public get promise () { return this.m_promise; } + public get abortSignal () { return this.abortController.signal; } public get progress () { return this.m_progress; } diff --git a/src/bun/index.ts b/src/bun/index.ts index ded9f6c..146c195 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -1,30 +1,18 @@ -import { RunBunServer } from './server'; -import { RunAPIServer } from './api/rpc'; + import * as app from './api/app'; import init from './browser'; import { dirname } from 'pathe'; import { createInterface } from 'readline'; import { isSteamDeckGameMode } from './utils'; -const api = RunAPIServer(); -let bunServer: { stop: () => void; } | undefined; - -if (!process.env.PUBLIC_ACCESS) -{ - bunServer = await RunBunServer(); -} - async function cleanup () { - console.log("Cleaning Up"); await app.cleanup(); - bunServer?.stop(); - await api.apiServer.stop(true); - await api.cleanup(); - console.log("Finished Cleaning Up"); process.exit(0); } +await app.load(); + if (process.env.HEADLESS) { const rl = createInterface({ input: process.stdin }); diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index b2ae320..1e82ea1 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -161,9 +161,9 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so } mainButton = { + if (installMut.isPending) return; switch (status) { case 'present': diff --git a/src/mainview/gen/static-icon-assets.gen.ts b/src/mainview/gen/static-icon-assets.gen.ts index 1d1a4aa..cb3fe1b 100644 --- a/src/mainview/gen/static-icon-assets.gen.ts +++ b/src/mainview/gen/static-icon-assets.gen.ts @@ -464,7 +464,7 @@ const assets = new Set([ ]); // Store basePath resolved from Vite config -const BASE_PATH = "/"; +const BASE_PATH = "./"; /** diff --git a/src/mainview/scripts/clientApi.ts b/src/mainview/scripts/clientApi.ts index 81799d3..d53bc04 100644 --- a/src/mainview/scripts/clientApi.ts +++ b/src/mainview/scripts/clientApi.ts @@ -1,5 +1,5 @@ import { Treaty, treaty } from "@elysiajs/eden"; -import { JobsAPIType, PluginsAPIType, RommAPIType, SettingsAPIType, StoreAPIType, SystemAPIType } from "../../bun/api/rpc"; +import type { JobsAPIType, PluginsAPIType, RommAPIType, SettingsAPIType, StoreAPIType, SystemAPIType } from "../../bun/api/rpc"; import { RPC_URL } from "../../shared/constants"; const options: Treaty.Config = { diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts index 67dcc86..4ecf6ca 100644 --- a/src/mainview/scripts/queries/romm.ts +++ b/src/mainview/scripts/queries/romm.ts @@ -109,8 +109,9 @@ export const installMutation = (source: string, id: string) => mutationOptions({ mutationKey: ['install', source, id], mutationFn: async () => { - const { error } = await rommApi.api.romm.game({ source })({ id }).install.post(); + const { data, error } = await rommApi.api.romm.game({ source })({ id }).install.post(); if (error) throw error; + return data; } }); export const cancelInstallMutation = (source: string, id: string) => mutationOptions({ diff --git a/src/mainview/scripts/spatialNavigation.ts b/src/mainview/scripts/spatialNavigation.ts index b4aab7d..3d4b182 100644 --- a/src/mainview/scripts/spatialNavigation.ts +++ b/src/mainview/scripts/spatialNavigation.ts @@ -10,7 +10,6 @@ import } from "@noriginmedia/norigin-spatial-navigation"; import { RefObject, useEffect, useState } from "react"; import { focusQueue, Router } from ".."; -import { scrollIntoViewHandler } from "./utils"; init({ shouldFocusDOMNode: false, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 0185eba..a1ffeda 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -98,12 +98,17 @@ export const EmulatorPackageSchema = z.object({ type: z.enum(['emulator']), os: z.array(z.enum(['darwin', 'linux', 'win32', 'android'])), keywords: z.array(z.string()).optional(), - downloads: z.record(z.string(), z.array(z.object({ - type: z.string(), - url: z.url().optional(), - pattern: z.string(), - path: z.string().optional() - }))).optional(), + downloads: z.record(z.string(), z.array(z.discriminatedUnion('type', [ + z.object({ + type: z.literal('github'), + pattern: z.string(), + path: z.string() + }), + z.object({ + type: z.literal('direct'), + url: z.url(), + }) + ]))).optional(), systems: z.array(z.string()), bios: z.literal(["required", "optional"]).optional() }); diff --git a/src/tests/client.ts b/src/tests/client.ts new file mode 100644 index 0000000..446b0f0 --- /dev/null +++ b/src/tests/client.ts @@ -0,0 +1,20 @@ +import type { JobsAPIType, PluginsAPIType, RommAPIType, SettingsAPIType, StoreAPIType, SystemAPIType } from "@/bun/api/rpc"; +import { Treaty, treaty } from '@elysiajs/eden'; +import { RPC_URL } from '@/shared/constants'; + +const host = "localhost"; +const options: Treaty.Config = { + keepDomain: true, + fetch: { + credentials: 'include', + } +}; + +export const client = { + rommApi: treaty(RPC_URL(host), options), + settingsApi: treaty(RPC_URL(host), options), + systemApi: treaty(RPC_URL(host), options), + storeApi: treaty(RPC_URL(host), options), + jobsApi: treaty(RPC_URL(host), options), + pluginsApi: treaty(RPC_URL(host), options), +}; \ No newline at end of file diff --git a/src/tests/downloads.test.ts b/src/tests/downloads.test.ts new file mode 100644 index 0000000..504c2b4 --- /dev/null +++ b/src/tests/downloads.test.ts @@ -0,0 +1,162 @@ +import { expect, test, describe, beforeEach, afterAll, beforeAll, jest } from 'bun:test'; +import { client } from './client'; +import * as app from '@/bun/api/app'; +import fs from 'node:fs/promises'; +import path from "node:path"; +import AdmZip from "adm-zip"; + +describe("Download Tests", () => +{ + let server: Bun.Server; + beforeAll(async () => + { + server = server = Bun.serve({ + routes: { + '/download/single_file.txt': new Response("Test File", { + headers: { + "Content-Type": "text/plain", + "Content-Disposition": 'attachment; filename="Test File.txt"', + } + }), + '/download/single_file_2.txt': new Response("Test File 2", { + headers: { + "Content-Type": "text/plain", + "Content-Disposition": 'attachment; filename="Test File.txt"', + } + }), + "/download/zip_file_with_single_file.zip": (req) => + { + const url = new URL(req.url); + const zip = new AdmZip(); + zip.addFile(path.join(url.searchParams.get('root') ?? '', "Unzip Test File.txt"), Buffer.from("hello world")); + + return new Response(zip.toBuffer(), { + headers: { + "Content-Type": "application/zip", + "Content-Disposition": 'attachment; filename="zip_file_with_single_file.zip"', + } + }); + } + } + }); + }); + + afterAll(() => + { + server.stop(); + }); + + test("Download Single Non Archive File", async () => + { + const mock = jest.fn(); + app.plugins.hooks.games.fetchDownloads.tap('test2', mock); + app.plugins.hooks.games.fetchDownloads.tapPromise('test', async ({ source, id }) => + { + if (source !== 'test') return; + return { + files: [{ file_name: "Test File.txt", file_path: 'test/files', url: new URL(`${server.url.href}download/single_file.txt`) }], + coverUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/SIPI_Jelly_Beans_4.1.07.tiff/lossy-page1-256px-SIPI_Jelly_Beans_4.1.07.tiff.jpg", + name: "Test Game", + screenshotUrls: [], + system_slug: 'ps2', + source_id: "0" + }; + }); + + const res = await client.rommApi.api.romm.game({ source: 'test' })({ id: '0' }).install.post(); + if (res.error) throw res.error; + expect(mock).toHaveBeenCalled(); + expect(await fs.exists(path.join(app.config.get('downloadPath'), 'test/files/Test File.txt'))).toBeTrue(); + expect(res.response.ok).toBeTrue(); + }); + + test("Download Multiple Non Archive Files", async () => + { + const mock = jest.fn(); + app.plugins.hooks.games.fetchDownloads.tap('test2', mock); + app.plugins.hooks.games.fetchDownloads.tapPromise('test', async ({ source, id }) => + { + if (source !== 'test') return; + return { + files: [ + { file_name: "Test File.txt", file_path: 'test/files', url: new URL(`${server.url.href}download/single_file.txt`) }, + { file_name: "Test File 2.txt", file_path: 'test/files', url: new URL(`${server.url.href}download/single_file_2.txt`) }], + coverUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/SIPI_Jelly_Beans_4.1.07.tiff/lossy-page1-256px-SIPI_Jelly_Beans_4.1.07.tiff.jpg", + name: "Test Game", + screenshotUrls: [], + system_slug: 'ps2', + source_id: "0" + }; + }); + + const res = await client.rommApi.api.romm.game({ source: 'test' })({ id: '0' }).install.post(); + if (res.error) throw res.error; + expect(mock).toHaveBeenCalled(); + expect(await fs.exists(path.join(app.config.get('downloadPath'), 'test/files/Test File.txt'))).toBeTrue(); + expect(await fs.exists(path.join(app.config.get('downloadPath'), 'test/files/Test File 2.txt'))).toBeTrue(); + expect(res.response.ok).toBeTrue(); + }); + + test("Download Single File Archived", async () => + { + const mock = jest.fn(); + app.plugins.hooks.games.fetchDownloads.tap('test2', mock); + app.plugins.hooks.games.fetchDownloads.tapPromise('test', async ({ source, id }) => + { + if (source !== 'test') return; + return { + files: [ + { file_name: "zip_file_with_single_file.zip", file_path: 'test', url: new URL(`${server.url.href}download/zip_file_with_single_file.zip`) }], + coverUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/SIPI_Jelly_Beans_4.1.07.tiff/lossy-page1-256px-SIPI_Jelly_Beans_4.1.07.tiff.jpg", + name: "Test Game", + screenshotUrls: [], + system_slug: 'ps2', + source_id: "0", + extract_path: 'test/files' + }; + }); + + const res = await client.rommApi.api.romm.game({ source: 'test' })({ id: '0' }).install.post(); + if (res.error) throw res.error; + expect(mock).toHaveBeenCalled(); + expect(await fs.exists(path.join(app.config.get('downloadPath'), 'test/files/Unzip Test File.txt'))).toBeTrue(); + expect(res.response.ok).toBeTrue(); + }); + + test("Download Emulator Archive With 1 root Sub Folder", async () => + { + const mockEmulator = { + name: "TEST", + description: "Test Mock emlator", + homepage: "http://localhost", + logo: "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/SIPI_Jelly_Beans_4.1.07.tiff/lossy-page1-256px-SIPI_Jelly_Beans_4.1.07.tiff.jpg", + downloads: { + "linux:x64": [ + { + type: "direct", + url: `${server.url.href}download/zip_file_with_single_file.zip?root=test` + } + ] + }, + keywords: [ + "test" + ], + aliases: {}, + type: "emulator", + systems: [ + "ps2" + ], + os: [ + "win32", + "linux" + ] + }; + + await Bun.write('./src/tests/mock-store/buckets/emulators/TEST.json', JSON.stringify(mockEmulator)); + + const deleteRes = await client.storeApi.api.store.install.emulator({ id: "TEST" })({ source: 'direct' }).post(); + if (deleteRes.error) throw deleteRes.error; + expect(await fs.exists(path.join(app.config.get('downloadPath'), 'emulators/TEST/Unzip Test File.txt'))).toBeTrue(); + expect(deleteRes.response.ok).toBeTrue(); + }); +}); \ No newline at end of file diff --git a/src/tests/game-launching.test.ts b/src/tests/game-launching.test.ts index 04430ab..3ac7d8a 100644 --- a/src/tests/game-launching.test.ts +++ b/src/tests/game-launching.test.ts @@ -1,11 +1,10 @@ import { expect, test } from 'bun:test'; -import { resolve } from 'node:path'; -import './preload'; +import path, { resolve } from 'node:path'; +import * as app from '@/bun/api/app'; test("uses custom emulator", async () => { - const { customEmulators } = await import('@/bun/api/app'); - customEmulators.set('PCSX2', resolve("./src/tests/mock-roms/mock-emulator.exe")); + app.customEmulators.set('PCSX2', resolve("./src/tests/mock-roms/mock-emulator.exe")); const { getValidLaunchCommands: getLaunchCommands } = await import('@/bun/api/games/services/launchGameService'); const commands = await getLaunchCommands({ @@ -13,6 +12,9 @@ test("uses custom emulator", async () => gamePath: './mock-rom.iso' }); + await Bun.write(path.join(app.config.get('downloadPath'), 'mock-rom.iso'), "This is a mock Rom"); + await Bun.write(path.join(app.config.get('downloadPath'), 'mock-emulator.exe'), "This is a mock Emulator"); + expect(commands) .toSatisfy((d) => { diff --git a/src/tests/mock-roms/mock-emulator.exe b/src/tests/mock-roms/mock-emulator.exe deleted file mode 100644 index e69de29..0000000 diff --git a/src/tests/mock-roms/mock-rom.iso b/src/tests/mock-roms/mock-rom.iso deleted file mode 100644 index e69de29..0000000 diff --git a/src/tests/preload.ts b/src/tests/preload.ts index 83dc4a2..38b9856 100644 --- a/src/tests/preload.ts +++ b/src/tests/preload.ts @@ -1,11 +1,33 @@ -import { beforeAll } from 'bun:test'; +import { afterAll, beforeAll, beforeEach, afterEach } from 'bun:test'; import { resolve } from 'node:path'; +import * as app from '@/bun/api/app'; +import { remove } from 'fs-extra'; + +export async function LoadApp () +{ + console.log("Loading App"); + await app.load(); +} + +export async function CleanupApp () +{ + console.log("Cleaning Up App"); + app.cleanup(); +} beforeAll(async () => { process.env.CUSTOM_STORE_PATH = resolve('./src/tests/mock-store'); process.env.CONFIG_CWD = resolve('./src/tests/mock-config'); + process.env.DEFAULT_DOWNLOAD_PATH = resolve('./src/tests/mock-roms'); +}); - const { config } = await import('@/bun/api/app'); - config.set('downloadPath', resolve('./src/tests/mock-roms')); -}); \ No newline at end of file +afterEach(async () => +{ + await remove(resolve('./src/tests/mock-config')); + await remove(resolve('./src/tests/mock-roms')); + await remove(resolve('./src/tests/mock-store')); +}); + +beforeEach(LoadApp, { timeout: 30000 }); +afterEach(CleanupApp); \ No newline at end of file