feat: Implemented link game importing

feat: Implemented download page for downloading roms from various sources using plugins. Added support for internet archive external plugin.
feat: Added tasks page to track running tasks/downloads
feat: Added tanstack caching
feat: Added quick play action Fixes #6
feat: Added quick emulator launch action
fix: Made task queue only support 1 task per group and task ID should now be unique
This commit is contained in:
Simeon Radivoev 2026-05-15 13:50:55 +03:00
parent 9a3e605625
commit 9141fb35d4
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
70 changed files with 1922 additions and 560 deletions

View file

@ -1,7 +1,9 @@
import { AsyncSeriesBailHook } from "tapable";
import AuthHooks from "./auth";
import EmulatorHooks from "./emulators";
import GameHooks from "./games";
import StoreHooks from "./store";
import { DownloadFileEntry, ProgressStats } from "../shared";
export class GameflowHooks
{
@ -9,4 +11,39 @@ export class GameflowHooks
emulators = new EmulatorHooks();
auth = new AuthHooks();
store = new StoreHooks();
/** Download the given files and return their final paths. */
downloadFiles = new AsyncSeriesBailHook<[ctx: {
/** Unique ID of the download */
id: string,
/** The root download path. Each file has it's own download sub path */
downloadPath: string,
abortSignal?: AbortSignal,
/** Authentication needed for download. Should be put in the headers. */
auth?: string,
/** The files to download */
files: DownloadFileEntry[];
/** Call it to update progress in the UI */
updateProgress: (stats: ProgressStats) => void;
}], {
/** What downloaded the files. Will be passed to {@link postDownloadFiles} files hook. */
source: string,
/** The file paths ot the downloaded files. */
files: string[];
} | undefined>(['ctx']);
/** Called after {@link downloadFiles} has finished downloading.
* @returns The modified file paths.
*/
postDownloadFiles = new AsyncSeriesBailHook<[ctx: {
/** Who downloaded the files. Passed from the {@link downloadFiles} hook. */
source: string;
/** Can be directories or files */
files: string[];
/** The root downloads folder. */
downloadPath: string,
/** The sub path where the archive should be extracted to. This will be a sub path of `path_fs` */
extract_path?: string;
/** This is the parent path for the extracted files. */
path_fs?: string;
}], string[] | undefined>(['ctx']);
}

View file

@ -5,6 +5,7 @@ import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable";
export default class EmulatorHooks
{
/** Download emulator bios files */
fetchBiosDownload = new AsyncSeriesBailHook<[ctx: {
emulator: string;
systems: EmulatorSystem[];
@ -15,7 +16,9 @@ export default class EmulatorHooks
* Triggered when emulator is downloaded or updated
*/
emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContextType], { emulator: string; }>(['ctx']);
/** Find locations of emulators on the system. Be it already installed ones or ones downloaded by the store. */
findEmulatorSource = new AsyncSeriesHook<[ctx: { emulator: string; sources: EmulatorSourceEntryType[]; }]>(['ctx']);
/** Match emulators for a given system */
findEmulatorForSystem = new AsyncSeriesHook<[ctx: { system: string; emulators: string[]; }]>(['ctx']);
constructor()

View file

@ -1,30 +1,32 @@
import { EmulatorPackageType, GameListFilterType, CommandEntry, DownloadInfo, EmulatorSourceEntryType, EmulatorSupport, EmulatorSystem, FrontEndCollection, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeWithIds, FrontEndId, FrontEndPlatformType, GameLookup, SaveFileChange, SaveSlots } from '../shared';
import { EmulatorPackageType, GameListFilterType, CommandEntry, DownloadInfo, EmulatorSourceEntryType, EmulatorSupport, EmulatorSystem, FrontEndCollection, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeWithIds, FrontEndId, FrontEndPlatformType, GameLookup, SaveFileChange, SaveSlots, DownloadLookupEntry, DownloadLookupDetails, DownloadsLookupFilterValues, DownloadsLookupFilter } from '../shared';
import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable';
export default class GameHooks
{
/** Build commands the game can be launched with. */
buildLaunchCommands = new AsyncSeriesBailHook<[ctx: {
source: string | null;
sourceId: string | null;
id: FrontEndId;
systemSlug: string;
gamePath: string | null,
/** The glob pattern for the main executable of the game */
mainGlob?: string | null,
}], CommandEntry[] | Error | undefined>(['ctx']);
/** override the launch command for an emulator
* @param ctx.autoValidCommands The auto generated command for example based on the ES-DE listing
* @param ctx.emulator The emulator ID if any
* @param ctx.game.source The source of the game
* @param ctx.game.sourceId The ID of the source. This could be for example the ROMM ID the game was
* @returns The argument list to be used when running the emulator.
* If no emulator bin in the command entry is found the actual command will be used as the bin.
*/
emulatorLaunch = new AsyncSeriesBailHook<[ctx: {
/** The auto generated command for example based on the ES-DE listing */
autoValidCommand: CommandEntry;
/** Don't actually launch just see if it can be launched */
dryRun: boolean,
game: {
/** The source of the game */
source?: string;
/** The ID of the source. This could be for example the ROMM ID the game was */
sourceId?: string;
id: FrontEndId;
platformSlug?: string;
@ -41,34 +43,36 @@ export default class GameHooks
}], EmulatorSupport | undefined, { emulator: string; }>(['ctx']);
/**
* Fetches and returns a list of games converted to frontend.
* @param ctx.localGameIds This is local game ids in the format '<source>@<sourceId>'
*/
fetchGames = new AsyncSeriesHook<[ctx: {
query: GameListFilterType;
games: FrontEndGameTypeWithIds[];
}]>(['ctx']);
/** Return all filters the users can apply for a give source. */
fetchFilters = new AsyncSeriesHook<[ctx: {
source?: string;
filters: FrontEndFilterSets;
}]>(['ctx']);
/** Get game metadata */
fetchGame = new AsyncSeriesBailHook<[ctx: {
source: string;
localGame?: FrontEndGameTypeDetailed;
id: string;
}], FrontEndGameTypeDetailed | undefined>(['ctx']);
/** Search for a given game based on the igdb id or ra id. */
searchGame = new AsyncSeriesBailHook<[ctx: {
source: string;
igdb_id?: number;
ra_id?: number;
}], FrontEndGameTypeDetailed | undefined>(['ctx']);
/** Get download file URLs
* @param ctx.checksum Check if file already exists using checksums
*/
/** Get download file URLs */
fetchDownloads = new AsyncSeriesBailHook<[ctx: {
source: string;
id: string;
/** If there are multiple downloads, use the one with same ID */
downloadId?: string;
}], DownloadInfo[] | undefined>(['ctx']);
/** Get the paths to rom files. This is mainly used for emulator js. */
fetchRomFiles = new AsyncSeriesBailHook<[ctx: {
source: string;
id: string;
@ -86,6 +90,7 @@ export default class GameHooks
source: string;
id: string;
}], FrontEndPlatformType | undefined>(['ctx']);
/** Lookup a given platform with a given slug or id. This may or may not exist. */
platformLookup = new AsyncSeriesBailHook<[ctx: {
source?: string;
id?: string;
@ -96,6 +101,23 @@ export default class GameHooks
name?: string;
family_name?: string;
} | undefined>(['ctx']);
/** Lookup downloads based on a search pattern.
* This is just downloads. Doesn't actually have to be a game.
* This is mainly used to manually add games from outside sources */
downloadsLookup = new AsyncSeriesWaterfallHook<[matches: Map<string, {
count: number;
items: DownloadLookupEntry[];
}>, ctx: {
page?: number;
rows?: number;
} & DownloadsLookupFilter]>(['matches', 'ctx']);
/** List all available filters */
downloadsLookupFilters = new AsyncSeriesHook<[ctx: {
filters: DownloadsLookupFilterValues;
}]>(['ctx']);
/** Look for the files for a download the user can pick from */
downloadLookup = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], DownloadLookupDetails | undefined>(['ctx']);
/** Look up game metadata based on a search */
gameLookup = new AsyncSeriesWaterfallHook<[matches: Map<string, GameLookup[]>, ctx: {
source?: string,
id?: string;
@ -104,6 +126,7 @@ export default class GameHooks
fetchPlatforms = new AsyncSeriesHook<[ctx: {
platforms: FrontEndPlatformType[];
}]>(['ctx']);
/** Called before the game is played. */
prePlay = new AsyncSeriesHook<[ctx: {
source: string,
id: string;
@ -115,20 +138,25 @@ export default class GameHooks
};
}]>(["ctx"]);
/**
* @param changedSaveFiles Auto detected changed files. This is mainly used to see what changed during gameplay
* @param validChangedSaveFiles This will be final valid changes to be saved using save integrations like rclone
* Called after the game process has finished.
*/
postPlay = new AsyncSeriesHook<[ctx: {
source: string,
id: string;
saveFolderSlots?: SaveSlots;
/** Auto detected changed files. This is mainly used to see what changed during gameplay */
changedSaveFiles: { subPath: string, cwd: string; }[],
/** This will be final valid changes to be saved using save integrations like rclone */
validChangedSaveFiles: Record<string, SaveFileChange>,
/** The command that was used to launch the game */
command: CommandEntry;
gameInfo: {
platformSlug?: string;
};
}]>(["ctx"]);
/** Called after game install
* This includes game being downloaded and registered in the database.
*/
postInstall = new AsyncSeriesHook<[ctx: {
source: string,
id: string;

View file

@ -13,10 +13,6 @@
"peerDependencies": {
"7zip-bin": "^5.2.0",
"@auth/core": "^0.34.3",
"@elysiajs/cors": "^1.4.2",
"@elysiajs/eden": "^1.4.9",
"@jimp/wasm-webp": "^1.6.1",
"@phalcode/ts-igdb-client": "^1.0.26",
"cheerio": "^1.2.0",
"conf": "^15.1.0",
"drizzle-orm": "^0.45.2",
@ -36,16 +32,12 @@
"pathe": "^2.0.3",
"slugify": "^1.6.9",
"smol-toml": "^1.6.1",
"systeminformation": "^5.31.5",
"tapable": "^2.3.3",
"tough-cookie": "^6.0.1",
"tough-cookie-file-store": "^3.3.0",
"unzip-stream": "^0.3.4",
"webview-bun": "^2.4.0",
"zod": "^4.4.3"
},
"keywords": [
"gameflow",
"sdk"
]
}
}

View file

@ -250,6 +250,7 @@ export interface EmulatorSourceEntryType
binPath: string;
rootPath?: string;
type: EmulatorSourceType;
/** Does the emulator exist in the file system */
exists: boolean;
}
@ -489,6 +490,15 @@ export interface GameInstallProgress
export type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted';
export type GameInstallProgressEvent = 'refresh';
export interface FrontEndJob
{
id: string;
data: any;
progress: number;
state?: string;
status: string;
}
export interface FrontendPlugin
{
name: string;
@ -622,10 +632,79 @@ export interface GameLookup
}[];
}
export interface DownloadLookupEntry
{
source: string;
id: string;
cover_url: string | null | undefined;
name: string;
summary: string | null | undefined;
size: number | null | undefined;
date: Date | null | undefined;
rating: number | null | undefined;
view_count: number | null | undefined;
download_count: number | null | undefined;
comment_count: number | null | undefined;
}
export interface DownloadLookupDetailsFile
{
id: string;
format: string | null | undefined;
mtime: Date | null | undefined;
size: number | null | undefined;
download_url: string;
}
export interface DownloadLookupDetails
{
source: string;
id: string;
cover_url: string | null | undefined;
name: string;
summary: string | null | undefined;
date: Date | null | undefined;
files: DownloadLookupDetailsFile[];
}
export interface AutoSaveChange
{
subPath: string;
cwd: string;
}
export interface AppInfoContext
{
activeTaskProgress: number | null;
}
export type SaveSlots = Record<string, { cwd: string; }>;
/** Jobs that are downloading stuff can implement this data interface to show up in the downloads screen */
export interface DownloadJobData extends Partial<Omit<ProgressStats, 'progress'>>
{
preview_url?: string | null;
name?: string;
}
export interface ProgressStats
{
progress: number;
speed: number;
total: number;
downloaded: number;
}
export interface DownloadsLookupFilter
{
source?: string,
orderBy?: string,
search?: string;
sortDirection?: "desc" | "asc";
}
export interface DownloadsLookupFilterValues
{
orderBy: string[],
source: string[];
}

View file

@ -18,14 +18,24 @@ export class TaskQueue
});
}
public enqueue<T> (id: string, job: T, throwOnError?: boolean): T extends IJob<infer TData, infer TState extends string>
public enqueue<T> (id: string, job: T, options?: { throwOnCancel?: boolean; }): 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<any, any, any>(id, this.events, job);
if (this.activeQueue.some(j => j.id === id)) throw new Error(`Job with ID ${id} already active`);
if (this.queue.some(j => j.id === id)) throw new Error(`Job with ${id} already queued`);
const context = new JobContext<any, any, any>(id, this.events, job, options);
this.queue.push(context as any);
context.abortSignal.addEventListener('abort', () =>
{
const queueIndex = this.queue?.findIndex(c => c === context);
if (queueIndex !== undefined && queueIndex >= 0)
{
this.queue?.splice(queueIndex, 1);
}
});
this.events?.emit('queued', { id: context.id, job: context });
this.processQueue();
return context.promise.promise as any;
@ -35,7 +45,24 @@ export class TaskQueue
{
if (!this.queue) return Promise.resolve();
const next = this.queue.filter(j => !j.job.group || !this.activeQueue.some(a => a.job.group === j.job.group)).map((job, i) => ({ i, job }));
let activeGroupsSet = new Set(this.activeQueue.filter(j => j.job.group).map(j => j.job.group));
const next = this.queue.filter(j =>
{
if (j.job.group)
{
// Only take one task per group to be active
if (!activeGroupsSet.has(j.job.group))
{
activeGroupsSet.add(j.job.group);
return true;
}
} else
{
return true;
}
return false;
}).map((job, i) => ({ i, job }));
next.reverse().forEach(({ i }) => this.queue!.splice(i, 1));
@ -82,6 +109,14 @@ export class TaskQueue
return job?.promise.promise ?? Promise.resolve();
}
public cancelJob (id: string)
{
const job = this.queue?.find(j => j.id === id)
?? this.activeQueue?.find(j => j.id === id);
job?.abort('cancel');
}
public findJob<T> (
id: string,
type: new (...args: any[]) => T
@ -99,6 +134,16 @@ export class TaskQueue
return undefined as any;
}
public getActiveJobs ()
{
return this.activeQueue;
}
public getQueuedJobs ()
{
return this.queue;
}
public on<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void
{
this.events?.on(event, listener);
@ -170,6 +215,7 @@ export interface CompletedEvent extends BaseEvent
export interface IJob<TData, TState extends string>
{
/** What group does the job belong to. Grouped jobs can only have 1 active job per group */
group?: string;
start (context: JobContext<IJob<TData, TState>, TData, TState>): Promise<any>;
exposeData?(): TData;
@ -210,12 +256,14 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
private events: EventEmitter<EventsList>;
private abortController: AbortController;
private m_promise: PromiseWithResolvers<TData | undefined>;
private throwOnCancel: boolean;
private readonly m_job: T;
constructor(id: string, events: EventEmitter<EventsList>, job: T)
constructor(id: string, events: EventEmitter<EventsList>, job: T, options?: { throwOnCancel?: boolean; })
{
this.m_id = id;
this.m_job = job;
this.throwOnCancel = options?.throwOnCancel ?? false;
this.abortController = new AbortController();
this.abortController.signal.addEventListener('abort', () =>
{
@ -247,7 +295,13 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
{
if (error.target instanceof AbortSignal)
{
this.m_promise.resolve(undefined);
if (this.throwOnCancel)
{
this.m_promise.reject(this.abortSignal.reason);
} else
{
this.m_promise.resolve(undefined);
}
} else
{
console.error(error);