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
This commit is contained in:
Simeon Radivoev 2026-03-31 03:11:02 +03:00
parent 58d3c31c56
commit 8a0be8c913
Signed by: simeonradivoev
GPG key ID: C16C2132A7660C8E
26 changed files with 422 additions and 210 deletions

View file

@ -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<SettingsType>({
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<Record<string, string>>({
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<SettingsType>;
export let customEmulators: Conf<Record<string, string>>;
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<typeof schema>;
export let cache: DrizzleSqliteDODatabase<typeof cacheSchema>;
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<AppEventMap>();
config.onDidChange('downloadPath', () => reloadDatabase());
taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
await controls();
let emulatorsSqlite: Database;
export let emulatorsDb: BunSQLiteDatabase<typeof emulatorSchema> & { $client: Database; };
export let taskQueue: TaskQueue;
export let plugins: PluginManager;
export let events: EventEmitter<AppEventMap>;
let controlsHandle: { cleanup: () => void; };
let api: any;
let bunServer: { stop: () => void; } | undefined;
export async function load ()
{
config = new Conf<SettingsType>({
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<Record<string, string>>({
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<AppEventMap>();
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 ()

View file

@ -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);
}
};
}

View file

@ -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);
}
}

View file

@ -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<ButtonName, boolean> = {
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);
}
}

View file

@ -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');

View file

@ -22,7 +22,7 @@ export class BiosDownloadJob implements IJob<z.infer<typeof BiosDownloadJob.data
this.dryRun = init?.dryRun ?? false;
}
async start (context: JobContext<IJob<never, "download">, never, "download">)
async start (context: JobContext<IJob<z.infer<typeof BiosDownloadJob.dataSchema>, "download">, z.infer<typeof BiosDownloadJob.dataSchema>, "download">)
{
const emulator = await getStoreEmulatorPackage(this.emulator);
if (!emulator) throw new Error("Could Not Find Emulator");

View file

@ -40,25 +40,27 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
if (!validDownloads) throw new Error(`Now downloads in ${this.emulatorPackage.name} for platform ${process.platform}:${process.arch}`);
const validDownload = validDownloads.find(d => d.type === this.downloadSource);
if (!validDownload || !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<z.infer<typeof EmulatorDownload
{
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
const downloader = new Downloader(this.emulator,
[{ url: new URL(downloadUrl), file_name: path.basename(downloadUrl), file_path: this.emulator }],
[{ url: new URL(downloadUrl), file_name: path.basename(downloadUrl.pathname), file_path: this.emulator }],
tmpFolder,
{
signal: context.abortSignal,
@ -80,6 +82,14 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
const destinationPaths = await downloader.start();
if (destinationPaths)
{
const isArchive = destinationPaths[0].endsWith('.7z') || destinationPaths[0].endsWith('.zip');
const isAppImage = destinationPaths[0].endsWith(".AppImage");
if (!isArchive && !isAppImage)
{
throw new Error("Invalid Download Type");
}
if (isArchive)
{
if (destinationPaths[0])

View file

@ -106,15 +106,23 @@ export class InstallJob implements IJob<never, InstallJobStates>
{
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;
}

View file

@ -25,7 +25,7 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
this.gameSourceId = sourceId;
}
async start (context: JobContext<IJob<ActiveGameType, "playing">, ActiveGameType, "playing">)
async start (context: JobContext<IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">, z.infer<typeof LaunchGameJob.dataSchema>, "playing">)
{
const localGame = await db.query.games.findFirst({
where: eq(appSchema.games.id, this.gameId), columns: {

View file

@ -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<void>((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();
}
};
}

View file

@ -5,39 +5,41 @@ import z from 'zod';
export class TaskQueue
{
private activeQueue: { context: JobContext<IJob<any, string>, any, string>, promise?: Promise<void>; }[] = [];
private queue?: { context: JobContext<IJob<any, string>, any, string>, promise?: Promise<void>; }[] = [];
private activeQueue: JobContext<IJob<any, string>, any, string>[] = [];
private queue?: JobContext<IJob<any, string>, any, string>[] = [];
private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>();
public enqueue<TData, TState extends string, T extends IJob<TData, TState>> (id: string, job: T)
public enqueue<T> (id: string, job: T): T extends IJob<infer TData, infer TState extends string>
? Promise<TData>
: 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<any, any, any>(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<void>
{
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<const TData, const TState extends string, const T extends IJob<TData, TState>> (id: string, type: new (...args: any[]) => T): IPublicJob<TData, TState, T> | undefined
public findJob<T> (
id: string,
type: new (...args: any[]) => T
): T extends IJob<infer TData, infer TState extends string>
? IPublicJob<TData, TState, T> | 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<E extends keyof EventsList> (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<T extends IJob<TData, TState>, TData, TState extends str
private error?: any;
private events: EventEmitter<EventsList>;
private abortController: AbortController;
private m_promise: PromiseWithResolvers<TData | undefined>;
private readonly m_job: T;
constructor(id: string, events: EventEmitter<EventsList>, job: T)
@ -194,9 +203,10 @@ export class JobContext<T extends IJob<TData, TState>, 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<void>
public async start ()
{
try
{
@ -204,6 +214,7 @@ export class JobContext<T extends IJob<TData, TState>, 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<T extends IJob<TData, TState>, 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<T extends IJob<TData, TState>, 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; }