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:
parent
9a3e605625
commit
9141fb35d4
70 changed files with 1922 additions and 560 deletions
|
|
@ -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']);
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue