feat: Implemented public plugin system accessible from the store.
feat: Implemented external ryujinx integration plugin refactor: moved sdk types and schemas to own workspace package fix: Fixed emulator launch with no game
This commit is contained in:
parent
9051834ace
commit
38cb752552
124 changed files with 1918 additions and 1067 deletions
30
src/packages/gameflow-sdk/README.md
Normal file
30
src/packages/gameflow-sdk/README.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Gameflow Deck SDK
|
||||
|
||||
This is the type definitions for Gameflow Deck plugins.
|
||||
|
||||
## Developing a plugin
|
||||
|
||||
The plugin must have a default export class of type `PluginType`. It exposes the context and all the hooks to be tapped.
|
||||
Gameflow uses the [Tapable Hooks](https://github.com/webpack/tapable).
|
||||
|
||||
The package must expose a main script gameflow will import and validate. It must implement the type fields on `PluginDescriptionType`.
|
||||
|
||||
## Publishing
|
||||
|
||||
For the plugin to show up in the UI for download. It must be published to NPM with the `gameflow-plugin` keyword. Gameflow uses bun to install plugins as packages from npmjs.
|
||||
Follow publishing instruction check the [NPM Docs](https://docs.npmjs.com/packages-and-modules/contributing-packages-to-the-registry)
|
||||
|
||||
## Dependencies
|
||||
|
||||
Peer dependencies will not be installed when the run adds the plugin package. They are provided by gameflow.
|
||||
All peer dependencies can be marked as external as gameflow provides it. There is a helper build script that does all that for you, to run it use.
|
||||
|
||||
`bunx gameflow-build --entry=index.ts`
|
||||
|
||||
supported arguments are
|
||||
`--entry` the entry of the app to build
|
||||
`--outdir` Where to build. Default is 'dist'
|
||||
`--minify` Minify the code. Default is 'false'
|
||||
`--sourcemap` Include a source map. Default is 'none'
|
||||
|
||||
If you want to include dependencies that gameflow does not provide you have to bundle them in. Gameflow does not load dependencies for you.
|
||||
27
src/packages/gameflow-sdk/build.ts
Normal file
27
src/packages/gameflow-sdk/build.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import pkg from './package.json';
|
||||
|
||||
import { parseArgs } from "util";
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
outdir: { type: "string", default: "dist" },
|
||||
minify: { type: "boolean", default: false },
|
||||
sourcemap: { type: "string", default: "none" }, // "none" | "inline" | "external"
|
||||
entry: { type: "string", default: "src/index.ts" },
|
||||
},
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
await Bun.build({
|
||||
entrypoints: [values.entry],
|
||||
outdir: values.outdir,
|
||||
minify: values.minify,
|
||||
sourcemap: values.sourcemap as any,
|
||||
external: [...Object.keys(pkg.peerDependencies), pkg.name],
|
||||
target: "bun",
|
||||
});
|
||||
|
||||
console.log(`✅ Built to ${values.outdir}`);
|
||||
12
src/packages/gameflow-sdk/hooks/app.ts
Normal file
12
src/packages/gameflow-sdk/hooks/app.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import AuthHooks from "./auth";
|
||||
import EmulatorHooks from "./emulators";
|
||||
import GameHooks from "./games";
|
||||
import StoreHooks from "./store";
|
||||
|
||||
export class GameflowHooks
|
||||
{
|
||||
games = new GameHooks();
|
||||
emulators = new EmulatorHooks();
|
||||
auth = new AuthHooks();
|
||||
store = new StoreHooks();
|
||||
}
|
||||
10
src/packages/gameflow-sdk/hooks/auth.ts
Normal file
10
src/packages/gameflow-sdk/hooks/auth.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
|
||||
import { AsyncSeriesHook } from "tapable";
|
||||
import { DownloadFileEntry } from "../shared";
|
||||
|
||||
export default class AuthHooks
|
||||
{
|
||||
loginComplete = new AsyncSeriesHook<[ctx: {
|
||||
service: string;
|
||||
}], { auth?: string, files: DownloadFileEntry[]; } | undefined>(['ctx']);
|
||||
}
|
||||
39
src/packages/gameflow-sdk/hooks/emulators.ts
Normal file
39
src/packages/gameflow-sdk/hooks/emulators.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
|
||||
import { EmulatorPostInstallContextType } from "../index";
|
||||
import { DownloadFileEntry, EmulatorSourceEntryType, EmulatorSystem } from "../shared";
|
||||
import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable";
|
||||
|
||||
export default class EmulatorHooks
|
||||
{
|
||||
fetchBiosDownload = new AsyncSeriesBailHook<[ctx: {
|
||||
emulator: string;
|
||||
systems: EmulatorSystem[];
|
||||
biosFolder: string;
|
||||
}], { auth?: string, files: DownloadFileEntry[]; } | undefined>(['ctx']);
|
||||
|
||||
/**
|
||||
* Triggered when emulator is downloaded or updated
|
||||
*/
|
||||
emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContextType], { emulator: string; }>(['ctx']);
|
||||
findEmulatorSource = new AsyncSeriesHook<[ctx: { emulator: string; sources: EmulatorSourceEntryType[]; }]>(['ctx']);
|
||||
findEmulatorForSystem = new AsyncSeriesHook<[ctx: { system: string; emulators: string[]; }]>(['ctx']);
|
||||
|
||||
constructor()
|
||||
{
|
||||
this.emulatorPostInstall.intercept({
|
||||
register (tap)
|
||||
{
|
||||
return {
|
||||
...tap,
|
||||
fn: async (ctx: EmulatorPostInstallContextType, ...rest: any[]) =>
|
||||
{
|
||||
if (ctx.emulator === tap.emulator)
|
||||
{
|
||||
tap.fn(ctx, ...rest);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
174
src/packages/gameflow-sdk/hooks/games.ts
Normal file
174
src/packages/gameflow-sdk/hooks/games.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
|
||||
import { EmulatorPackageType, GameListFilterType, CommandEntry, DownloadInfo, EmulatorSourceEntryType, EmulatorSupport, EmulatorSystem, FrontEndCollection, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeWithIds, FrontEndId, FrontEndPlatformType, GameLookup, SaveFileChange, SaveSlots } from '../shared';
|
||||
import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable';
|
||||
|
||||
export default class GameHooks
|
||||
{
|
||||
buildLaunchCommands = new AsyncSeriesBailHook<[ctx: {
|
||||
source: string | null;
|
||||
sourceId: string | null;
|
||||
id: FrontEndId;
|
||||
systemSlug: string;
|
||||
gamePath: string | null,
|
||||
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: {
|
||||
autoValidCommand: CommandEntry;
|
||||
dryRun: boolean,
|
||||
game: {
|
||||
source?: string;
|
||||
sourceId?: string;
|
||||
id: FrontEndId;
|
||||
platformSlug?: string;
|
||||
};
|
||||
}], { args: string[], savesPath?: SaveSlots; env?: Record<string, string>; } | undefined, { emulator: string; }>(['ctx']);
|
||||
/**
|
||||
* Is the given emulator for the given command supported
|
||||
* @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects.
|
||||
*
|
||||
*/
|
||||
emulatorLaunchSupport = new SyncBailHook<[ctx: {
|
||||
emulator: string;
|
||||
source?: EmulatorSourceEntryType;
|
||||
}], 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']);
|
||||
fetchFilters = new AsyncSeriesHook<[ctx: {
|
||||
source?: string;
|
||||
filters: FrontEndFilterSets;
|
||||
}]>(['ctx']);
|
||||
fetchGame = new AsyncSeriesBailHook<[ctx: {
|
||||
source: string;
|
||||
localGame?: FrontEndGameTypeDetailed;
|
||||
id: string;
|
||||
}], FrontEndGameTypeDetailed | undefined>(['ctx']);
|
||||
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
|
||||
*/
|
||||
fetchDownloads = new AsyncSeriesBailHook<[ctx: {
|
||||
source: string;
|
||||
id: string;
|
||||
downloadId?: string;
|
||||
}], DownloadInfo[] | undefined>(['ctx']);
|
||||
fetchRomFiles = new AsyncSeriesBailHook<[ctx: {
|
||||
source: string;
|
||||
id: string;
|
||||
}], string[] | undefined>(['ctx']);
|
||||
fetchRecommendedGamesForGame = new AsyncSeriesHook<[ctx: {
|
||||
game: FrontEndGameTypeDetailed,
|
||||
games: (FrontEndGameType & { metadata?: any; })[];
|
||||
}]>(['ctx']);
|
||||
fetchRecommendedGamesForEmulator = new AsyncSeriesHook<[cts: {
|
||||
emulator: EmulatorPackageType;
|
||||
systems: EmulatorSystem[];
|
||||
games: FrontEndGameType[];
|
||||
}]>(['ctx']);
|
||||
fetchPlatform = new AsyncSeriesBailHook<[ctx: {
|
||||
source: string;
|
||||
id: string;
|
||||
}], FrontEndPlatformType | undefined>(['ctx']);
|
||||
platformLookup = new AsyncSeriesBailHook<[ctx: {
|
||||
source?: string;
|
||||
id?: string;
|
||||
slug?: string;
|
||||
}], {
|
||||
slug: string;
|
||||
url_logo?: string | null;
|
||||
name?: string;
|
||||
family_name?: string;
|
||||
} | undefined>(['ctx']);
|
||||
gameLookup = new AsyncSeriesWaterfallHook<[matches: Map<string, GameLookup[]>, ctx: {
|
||||
source?: string,
|
||||
id?: string;
|
||||
search?: string;
|
||||
}]>(['matches', 'ctx']);
|
||||
fetchPlatforms = new AsyncSeriesHook<[ctx: {
|
||||
platforms: FrontEndPlatformType[];
|
||||
}]>(['ctx']);
|
||||
prePlay = new AsyncSeriesHook<[ctx: {
|
||||
source: string,
|
||||
id: string;
|
||||
saveFolderSlots: Record<string, { cwd: string; }>;
|
||||
setProgress: (progress: number, state: string) => void,
|
||||
command: CommandEntry;
|
||||
gameInfo: {
|
||||
platformSlug?: string;
|
||||
};
|
||||
}]>(["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
|
||||
*/
|
||||
postPlay = new AsyncSeriesHook<[ctx: {
|
||||
source: string,
|
||||
id: string;
|
||||
saveFolderSlots?: SaveSlots;
|
||||
changedSaveFiles: { subPath: string, cwd: string; }[],
|
||||
validChangedSaveFiles: Record<string, SaveFileChange>,
|
||||
command: CommandEntry;
|
||||
gameInfo: {
|
||||
platformSlug?: string;
|
||||
};
|
||||
}]>(["ctx"]);
|
||||
postInstall = new AsyncSeriesHook<[ctx: {
|
||||
source: string,
|
||||
id: string;
|
||||
files: string[];
|
||||
info: DownloadInfo;
|
||||
}]>(['ctx']);
|
||||
fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']);
|
||||
fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']);
|
||||
|
||||
constructor()
|
||||
{
|
||||
this.emulatorLaunchSupport.intercept({
|
||||
register (tap)
|
||||
{
|
||||
return {
|
||||
...tap,
|
||||
fn: (e: any, ...rest: any[]) =>
|
||||
{
|
||||
if (e.emulator === tap.emulator)
|
||||
{
|
||||
return tap.fn(e, ...rest);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
this.emulatorLaunch.intercept({
|
||||
register (tap)
|
||||
{
|
||||
return {
|
||||
...tap,
|
||||
fn: async (e: any, ...rest: any[]) =>
|
||||
{
|
||||
if ((e.autoValidCommand as CommandEntry).emulator === tap.emulator)
|
||||
{
|
||||
return tap.fn(e, ...rest);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
10
src/packages/gameflow-sdk/hooks/store.ts
Normal file
10
src/packages/gameflow-sdk/hooks/store.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { FrontEndEmulator, FrontEndEmulatorDetailed, FrontEndGameTypeDetailed, EmulatorDownloadInfoType } from "../shared";
|
||||
import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable";
|
||||
|
||||
export default class StoreHooks
|
||||
{
|
||||
fetchFeaturedGames = new AsyncSeriesHook<[ctx: { games: FrontEndGameTypeDetailed[]; }]>(['ctx']);
|
||||
fetchEmulators = new AsyncSeriesHook<[ctx: { emulators: FrontEndEmulator[]; search?: string; }]>(['ctx']);
|
||||
fetchEmulator = new AsyncSeriesBailHook<[ctx: { id: string; }], FrontEndEmulatorDetailed | undefined>(['ctx']);
|
||||
fetchDownload = new AsyncSeriesBailHook<[ctx: { id: string; }], (EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined>(['ctx']);
|
||||
}
|
||||
92
src/packages/gameflow-sdk/index.ts
Normal file
92
src/packages/gameflow-sdk/index.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import z from "zod";
|
||||
import { GameflowHooks } from "./hooks/app";
|
||||
import { EmulatorDownloadInfoSchema, EmulatorPackageSchema, FrontendNotification, SettingsType } from "./shared";
|
||||
import { $ZodRegistry } from "zod/v4/core";
|
||||
import Conf from "conf";
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { TaskQueue } from "./task-queue";
|
||||
|
||||
export * from "./hooks/app";
|
||||
export * from "./task-queue";
|
||||
|
||||
export interface AppEventMap
|
||||
{
|
||||
exitapp: [];
|
||||
notification: [FrontendNotification];
|
||||
focus: [];
|
||||
}
|
||||
|
||||
export const PluginContextSchema = z.object({
|
||||
hooks: z.instanceof(GameflowHooks)
|
||||
});
|
||||
|
||||
export const PluginLoadingContextSchema = z.object({
|
||||
setProgress: z.function().input([z.number(), z.string()]).output(z.void()),
|
||||
config: z.instanceof(Conf).describe("Per plugin config. It will use the settings schema defined in the plugin class"),
|
||||
zodRegistry: z.instanceof($ZodRegistry).describe("Used by the settings to register metadata for the UI"),
|
||||
app: z.object({
|
||||
config: z.instanceof(Conf<SettingsType>),
|
||||
events: z.instanceof(EventEmitter<AppEventMap>),
|
||||
taskQueue: z.instanceof(TaskQueue)
|
||||
})
|
||||
}).extend(PluginContextSchema.shape);
|
||||
|
||||
export const PluginDescriptionSchema = z.object({
|
||||
name: z.string(),
|
||||
displayName: z.string().optional(),
|
||||
version: z.string(),
|
||||
description: z.string().optional(),
|
||||
icon: z.url().optional().describe("Can be an external URL to an image or a data url"),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
peerDependencies: z.record(z.string(), z.string()).optional(),
|
||||
category: z.string().default("other"),
|
||||
main: z.string().describe("The main entry. It must export a default class implementing PluginType"),
|
||||
canDisable: z.boolean().default(true).optional().describe("Can the plugin be disabled or enabled by the user")
|
||||
});
|
||||
|
||||
export const PluginSchema = z.object({
|
||||
load: z.function().input([PluginLoadingContextSchema]).output(z.promise(z.void())).describe("Called when the plugin is loaded or reloaded"),
|
||||
cleanup: z.function().output(z.promise(z.void())).optional().describe("Called when the plugin is unloaded or before it's reloaded"),
|
||||
settingsSchema: z.instanceof(z.ZodObject).optional().describe("The settings schema. Gameflow will show settings in the UI."),
|
||||
settingsMigrations: z.record(z.string(), z.function().input([z.instanceof(Conf)]).output(z.void())).optional(),
|
||||
eventsNames: z.object({
|
||||
id: z.string(),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
action: z.string()
|
||||
}).array().optional().describe("Events will be called when the user presses the button in plugin settings. Each event creates a button."),
|
||||
onEvent: z.function().input([z.string()]).output(z.object({
|
||||
openTab: z.string().optional(),
|
||||
reload: z.boolean().optional()
|
||||
}).or(z.record(z.string(), z.any()))).optional()
|
||||
});
|
||||
|
||||
export const ActiveGameSchema = z.object({
|
||||
process: z.any().optional(),
|
||||
gameId: z.object({ id: z.string(), source: z.string() }),
|
||||
source: z.string().optional(),
|
||||
sourceId: z.string().optional(),
|
||||
name: z.string(),
|
||||
command: z.object({ command: z.string().or(z.string().array()), startDir: z.string().optional() })
|
||||
});
|
||||
|
||||
export const EmulatorPostInstallContextSchema = z.object({
|
||||
emulator: z.string(),
|
||||
emulatorPackage: EmulatorPackageSchema.optional(),
|
||||
path: z.string(),
|
||||
update: z.boolean(),
|
||||
info: EmulatorDownloadInfoSchema,
|
||||
});
|
||||
|
||||
export type ActiveGameType = z.infer<typeof ActiveGameSchema>;
|
||||
export type PluginDescriptionType = z.infer<typeof PluginDescriptionSchema>;
|
||||
export type PluginContextType = z.infer<typeof PluginContextSchema>;
|
||||
export type PluginLoadingContextType<TSettings extends Record<string, any> = Record<string, any>> = z.infer<typeof PluginLoadingContextSchema> & {
|
||||
config: Conf<TSettings>;
|
||||
};
|
||||
export type PluginType<T extends Record<string, any> = Record<string, any>> = Omit<z.infer<typeof PluginSchema>, "load" | 'settingsMigrations'> & {
|
||||
load: (ctx: PluginLoadingContextType<T>) => Promise<void>;
|
||||
settingsMigrations?: Record<string, (conf: Conf<T>) => void>;
|
||||
};
|
||||
export type EmulatorPostInstallContextType = z.infer<typeof EmulatorPostInstallContextSchema>;
|
||||
|
||||
51
src/packages/gameflow-sdk/package.json
Normal file
51
src/packages/gameflow-sdk/package.json
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"name": "@simeonradivoev/gameflow-sdk",
|
||||
"version": "1.5.3",
|
||||
"types": "index.d.ts",
|
||||
"description": "plugin SDK for the Gameflow Deck Launcher",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./shared": "./shared.ts"
|
||||
},
|
||||
"bin": {
|
||||
"gameflow-build": "build.ts"
|
||||
},
|
||||
"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",
|
||||
"elysia": "^1.4.28",
|
||||
"fs-extra": "^11.3.5",
|
||||
"get-folder-size": "^5.0.0",
|
||||
"ini": "^6.0.0",
|
||||
"jimp": "^1.6.1",
|
||||
"mustache": "^4.2.0",
|
||||
"node-7z": "^3.0.0",
|
||||
"node-disk-info": "^1.3.0",
|
||||
"node-downloader-helper": "^2.1.11",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"node-unrar-js": "^2.0.2",
|
||||
"open": "^11.0.0",
|
||||
"p-queue": "^9.2.0",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
22
src/packages/gameflow-sdk/sdk.tsconfig.json
Normal file
22
src/packages/gameflow-sdk/sdk.tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2024"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"outDir": "../../dist-sdk",
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
}
|
||||
}
|
||||
631
src/packages/gameflow-sdk/shared.ts
Normal file
631
src/packages/gameflow-sdk/shared.ts
Normal file
|
|
@ -0,0 +1,631 @@
|
|||
import * as z from "zod";
|
||||
|
||||
export const settingRegistry = z.registry<{
|
||||
dev?: boolean;
|
||||
}>();
|
||||
|
||||
export const SettingsSchema = z.object({
|
||||
rommAddress: z.url().optional(),
|
||||
rommUser: z.string().default('admin').optional(),
|
||||
windowSize: z.object({ width: z.number(), height: z.number() }).optional(),
|
||||
windowPosition: z.object({ x: z.number(), y: z.number() }).optional(),
|
||||
downloadPath: z.string(),
|
||||
launchInFullscreen: z.boolean().default(true),
|
||||
disabledPlugins: z.array(z.string()).default([]),
|
||||
emulatorResolution: z.enum(['720p', '1080p', '1440p', '4k']).default('720p'),
|
||||
emulatorWidescreen: z.boolean().default(true)
|
||||
}); export const LocalSettingsSchema = z.object({
|
||||
backgroundBlur: z.boolean().default(true).meta({ title: "Background Blur" }),
|
||||
backgroundAnimation: z.boolean().default(true).meta({ title: "Background Animation" }),
|
||||
theme: z.enum(['dark', 'light', 'auto']).default('auto').meta({ title: "Theme" }),
|
||||
soundEffects: z.boolean().default(true).meta({ title: "Sounds" }),
|
||||
soundEffectsVolume: z.number().min(0).max(100).default(50).meta({ title: "Sound Volume" }),
|
||||
hapticsEffects: z.boolean().default(true).meta({ title: "Haptics" }),
|
||||
showRouterDevOptions: z.boolean().default(false).meta({ title: "Show Router Options" }).register(settingRegistry, { dev: true }),
|
||||
showQueryDevOptions: z.boolean().default(false).meta({ title: "Show Query Options" }).register(settingRegistry, { dev: true }),
|
||||
useGameflowKeyboard: z.boolean().default(true).describe("Show the gameflow on screen keyboard when using a controller").meta({ title: "Use Gameflow Keyboard" }),
|
||||
autoKeybaord: z.boolean().default(true).describe("Open on screen keybaord automatically").meta({ title: "Auto Keyboard" })
|
||||
});
|
||||
export const GameListFilterSchema = z.object({
|
||||
platform_source: z.string().optional(),
|
||||
platform_slug: z.string().optional(),
|
||||
platform_id: z.coerce.number().optional(),
|
||||
collection_id: z.coerce.number().optional(),
|
||||
collection_source: z.string().optional(),
|
||||
limit: z.coerce.number().optional(),
|
||||
search: z.string().optional(),
|
||||
offset: z.coerce.number().optional(),
|
||||
source: z.string().optional(),
|
||||
localOnly: z.coerce.boolean().optional(),
|
||||
orderBy: z.literal(['added', 'activity', 'name', 'release']).optional(),
|
||||
age_ratings: z.union([z.string().array(), z.string().transform(v => [v])]).optional(),
|
||||
genres: z.union([z.string().array(), z.string().transform(v => [v])]).optional(),
|
||||
keywords: z.union([z.string().array(), z.string().transform(v => [v])]).optional(),
|
||||
});
|
||||
export const DownloadSourceSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string()
|
||||
});
|
||||
export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() });
|
||||
export type GameListFilterType = z.infer<typeof GameListFilterSchema>;
|
||||
export const DirSchema = z.object({ name: z.string(), parentPath: z.string(), isDirectory: z.boolean() });
|
||||
export type DirType = z.infer<typeof DirSchema>;
|
||||
export const CustomEmulatorSchema = z.record(z.string(), z.string());
|
||||
export const GithubManifestSchema = z.object({
|
||||
sha: z.hash('sha1'),
|
||||
url: z.url(),
|
||||
tree: z.array(z.object({
|
||||
path: z.string(),
|
||||
mode: z.string(),
|
||||
type: z.enum(['blob', 'tree']),
|
||||
sha: z.hash('sha1'),
|
||||
url: z.url()
|
||||
}))
|
||||
});
|
||||
export const StoreGameSaveSchema = z.object({
|
||||
cwd: z.string(),
|
||||
globs: z.string().array()
|
||||
});
|
||||
export const StoreDownloadSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('direct'),
|
||||
url: z.url(),
|
||||
name: z.string().optional(),
|
||||
system: z.string(),
|
||||
main: z.string().optional(),
|
||||
saves: z.record(z.string(), StoreGameSaveSchema).optional()
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("itch"),
|
||||
path: z.string(),
|
||||
name: z.string().optional(),
|
||||
system: z.string(),
|
||||
saves: z.record(z.string(), StoreGameSaveSchema).optional()
|
||||
})
|
||||
]);
|
||||
export const NewGameSchema = z.object({
|
||||
name: z.string(),
|
||||
summary: z.string(),
|
||||
genres: z.string().regex(/^$|^(\s*\S[^,]*)(\s*,\s*\S[^,]*)*\s*$/, {
|
||||
message: "Must be a comma-separated list",
|
||||
})
|
||||
});
|
||||
export const StoreGameSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
version: z.string(),
|
||||
homepage: z.string().optional(),
|
||||
keywords: z.string().array().optional(),
|
||||
genres: z.string().array().optional(),
|
||||
companies: z.string().array().optional(),
|
||||
screenshots: z.string().array().optional(),
|
||||
covers: z.string().array().optional(),
|
||||
igdb_id: z.number().optional(),
|
||||
ra_id: z.number().optional(),
|
||||
sgdb_id: z.number().optional(),
|
||||
first_release_date: z.union([z.number(), z.date()]).optional(),
|
||||
player_count: z.string().optional(),
|
||||
saves: z.record(z.string(), z.record(z.string(), StoreGameSaveSchema)).optional(),
|
||||
downloads: z.record(z.string(), StoreDownloadSchema)
|
||||
});
|
||||
export const EmulatorPackageSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
homepage: z.url(),
|
||||
logo: z.url(),
|
||||
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.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(['github', 'gitlab']),
|
||||
pattern: z.string(),
|
||||
path: z.string(),
|
||||
bin: z.string().optional()
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('direct'),
|
||||
url: z.url(),
|
||||
bin: z.string().optional()
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('scoop'),
|
||||
url: z.url(),
|
||||
bin: z.string().optional()
|
||||
})
|
||||
]))).optional(),
|
||||
systems: z.array(z.string()),
|
||||
bios: z.literal(["required", "optional"]).optional()
|
||||
});
|
||||
export const ScoopPackageSchema = z.object({
|
||||
version: z.string(),
|
||||
url: z.url().optional(),
|
||||
description: z.string(),
|
||||
bin: z.string().optional(),
|
||||
architecture: z.record(z.string(), z.object({
|
||||
url: z.url(),
|
||||
hash: z.string().optional(),
|
||||
extract_dir: z.string().optional()
|
||||
})).optional()
|
||||
});
|
||||
export const SystemInfoSchema = z.object({
|
||||
battery: z.object({
|
||||
percent: z.number(),
|
||||
isCharging: z.boolean(),
|
||||
acConnected: z.boolean(),
|
||||
hasBattery: z.boolean()
|
||||
}),
|
||||
wifiConnections: z.array(z.object({ signalLevel: z.number() })),
|
||||
bluetoothDevices: z.array(z.object({ connected: z.boolean() }))
|
||||
});
|
||||
export const GithubReleaseSchema = z.object({
|
||||
id: z.number(),
|
||||
tag_name: z.string().optional(),
|
||||
url: z.url(),
|
||||
body: z.string(),
|
||||
assets: z.array(z.object({
|
||||
name: z.string(),
|
||||
browser_download_url: z.url(),
|
||||
content_type: z.string().optional()
|
||||
}))
|
||||
});
|
||||
export const EmulatorDownloadInfoSchema = z.object({
|
||||
id: z.string(),
|
||||
version: z.string().optional(),
|
||||
url: z.url().optional(),
|
||||
description: z.string().optional(),
|
||||
downloadDate: z.coerce.date(),
|
||||
type: z.string()
|
||||
});
|
||||
export const PluginEntrySchema = z.object({
|
||||
downloads: z.object({
|
||||
monthly: z.number(),
|
||||
weekly: z.number()
|
||||
}),
|
||||
searchScore: z.number(),
|
||||
installed: z.boolean(),
|
||||
update: z.object({ from: z.string() }).optional(),
|
||||
package: z.object({
|
||||
name: z.string(),
|
||||
keywords: z.string().array(),
|
||||
version: z.string(),
|
||||
description: z.string().optional(),
|
||||
sanitized_name: z.string(),
|
||||
license: z.string().optional(),
|
||||
publisher: z.object({
|
||||
email: z.string(),
|
||||
username: z.string(),
|
||||
trustedPublisher: z.object({
|
||||
id: z.string(),
|
||||
oidcConfigId: z.string()
|
||||
}).optional()
|
||||
}),
|
||||
date: z.coerce.date(),
|
||||
links: z.object({
|
||||
homepage: z.string().optional(),
|
||||
repository: z.string().optional(),
|
||||
bugs: z.string().optional(),
|
||||
npm: z.url()
|
||||
})
|
||||
})
|
||||
});
|
||||
export const PluginBunDetailsSchema = z.object({
|
||||
name: z.string(),
|
||||
keywords: z.string().array(),
|
||||
version: z.string(),
|
||||
author: z.object({ name: z.string().optional() }).optional(),
|
||||
license: z.string().optional(),
|
||||
devDependencies: z.record(z.string(), z.string()).optional(),
|
||||
dependencies: z.record(z.string(), z.string()).optional(),
|
||||
maintainers: z.object({ name: z.string() }).array().optional(),
|
||||
dist: z.object({ unpackedSize: z.number() }),
|
||||
description: z.string().optional(),
|
||||
_npmUser: z.object({ name: z.string() }).optional()
|
||||
});
|
||||
export type EmulatorPackageType = z.infer<typeof EmulatorPackageSchema>;
|
||||
export type StoreGameType = z.infer<typeof StoreGameSchema>;
|
||||
export type StoreDownloadType = z.infer<typeof StoreDownloadSchema>;
|
||||
export type SettingsType = z.infer<typeof SettingsSchema>;
|
||||
export type LocalSettingsType = z.infer<typeof LocalSettingsSchema>;
|
||||
export const PlatformSchema = z.object({ slug: z.string() });
|
||||
export type SystemInfoType = z.infer<typeof SystemInfoSchema>;
|
||||
export type EmulatorDownloadInfoType = z.infer<typeof EmulatorDownloadInfoSchema>;
|
||||
export type DownloadSourceType = z.infer<typeof DownloadSourceSchema>;
|
||||
export type PluginEntryType = z.infer<typeof PluginEntrySchema>;
|
||||
export type PluginBunDetailsType = z.infer<typeof PluginBunDetailsSchema>;
|
||||
|
||||
export interface SaveFileChange
|
||||
{
|
||||
subPath: string | string[];
|
||||
isGlob?: true;
|
||||
cwd: string;
|
||||
shared: boolean;
|
||||
fixedSize?: boolean;
|
||||
}
|
||||
|
||||
export type EmulatorSourceType = 'custom' | 'store' | 'registry' | 'system' | 'static' | 'embedded';
|
||||
|
||||
export interface EmulatorSourceEntryType
|
||||
{
|
||||
binPath: string;
|
||||
rootPath?: string;
|
||||
type: EmulatorSourceType;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export interface FrontEndEmulator
|
||||
{
|
||||
name: string;
|
||||
source: string;
|
||||
logo: string;
|
||||
systems: EmulatorSystem[];
|
||||
description?: string;
|
||||
gameCount: number;
|
||||
validSources: EmulatorSourceEntryType[];
|
||||
integrations: EmulatorSupport[];
|
||||
}
|
||||
|
||||
export interface EmulatorSystem { id: string, romm_slug?: string, name: string, iconUrl: string; }
|
||||
|
||||
export interface FrontEndEmulatorDetailedDownload
|
||||
{
|
||||
name: string;
|
||||
type: string | undefined;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface FrontEndEmulatorDetailed extends FrontEndEmulator
|
||||
{
|
||||
homepage: string;
|
||||
description: string;
|
||||
downloads: FrontEndEmulatorDetailedDownload[];
|
||||
keywords?: string[];
|
||||
screenshots: string[];
|
||||
biosRequirement?: "required" | "optional";
|
||||
bios?: string[];
|
||||
storeDownloadInfo?: { hasUpdate: boolean; version?: string, type: string; description?: string; };
|
||||
}
|
||||
|
||||
export interface FrontEndGameTypeDetailedAchievement
|
||||
{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
date?: Date;
|
||||
date_hardcode?: Date;
|
||||
badge_url?: string;
|
||||
display_order: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
export interface FrontEndGameTypeDetailed extends Exclude<FrontEndGameTypeWithIds, "metadata">
|
||||
{
|
||||
summary: string | null;
|
||||
fs_size_bytes: number | null;
|
||||
missing: boolean;
|
||||
local: boolean;
|
||||
version?: string | null;
|
||||
version_system?: string | null;
|
||||
version_source?: string | null;
|
||||
metadata: FrontEndGameMetadataDetailed,
|
||||
emulators?: FrontEndGameTypeDetailedEmulator[],
|
||||
achievements?: {
|
||||
unlocked: number;
|
||||
total: number;
|
||||
entires: FrontEndGameTypeDetailedAchievement[];
|
||||
};
|
||||
};
|
||||
|
||||
export interface Drive
|
||||
{
|
||||
parent: string | null;
|
||||
device: string;
|
||||
label: string;
|
||||
mountPoint: string | null;
|
||||
type: string;
|
||||
size: number;
|
||||
used: number;
|
||||
isRemovable: boolean;
|
||||
interfaceType: string | null;
|
||||
hasWriteAccess: boolean;
|
||||
hasReadAccess: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadsDrive
|
||||
{
|
||||
device: string;
|
||||
label: string;
|
||||
mountPoint: string | null;
|
||||
isRemovable: boolean;
|
||||
size: number;
|
||||
used: number;
|
||||
isCurrentlyUsed: boolean;
|
||||
unusableReason: 'not_enough_space' | 'already_used' | null;
|
||||
}
|
||||
|
||||
export interface FrontendNotification
|
||||
{
|
||||
title?: string;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info' | 'custom';
|
||||
icon?: "save" | "upload" | "clock";
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface CommandEntry
|
||||
{
|
||||
/** The ID of the command. Could be just an index or a string */
|
||||
id: string | number;
|
||||
/** The front end label for the command. Mainly gotten from ES-DE list */
|
||||
label?: string;
|
||||
/** Compiled command to be executed */
|
||||
command: string | string[];
|
||||
/** Environment variables */
|
||||
env?: Record<string, string>,
|
||||
/** The path the spawned process will start at */
|
||||
startDir?: string;
|
||||
/** Is the command valid, for example does the executable exists */
|
||||
valid: boolean;
|
||||
/** Run the command as shell. Defaults is true */
|
||||
shell?: boolean;
|
||||
/** For what emulator is the command */
|
||||
emulator?: string;
|
||||
/** Where the emulator came from */
|
||||
emulatorSource?: EmulatorSourceType;
|
||||
/** Metadata for the command */
|
||||
metadata: {
|
||||
romPath?: string;
|
||||
emulatorBin?: string;
|
||||
/** The root directory of the emulator */
|
||||
emulatorDir?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FrontEndId
|
||||
{
|
||||
id: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
// Stuff stored in the local sqlite metadata field
|
||||
export interface LocalGameMetadata
|
||||
{
|
||||
genres?: string[],
|
||||
companies?: string[],
|
||||
game_modes?: string[],
|
||||
age_ratings?: string[];
|
||||
player_count?: string;
|
||||
first_release_date?: number;
|
||||
average_rating?: number;
|
||||
}
|
||||
|
||||
export interface FrontEndPlatformType
|
||||
{
|
||||
id: FrontEndId;
|
||||
slug: string;
|
||||
name: string;
|
||||
family_name?: string | null;
|
||||
path_cover: string | null;
|
||||
game_count: number;
|
||||
updated_at: Date;
|
||||
hasLocal: boolean;
|
||||
paths_screenshots: string[];
|
||||
}
|
||||
|
||||
export interface FrontEndGameTypeWithIds extends FrontEndGameType
|
||||
{
|
||||
igdb_id: number | null;
|
||||
ra_id: number | null;
|
||||
}
|
||||
|
||||
export interface FrontEndFilterSets
|
||||
{
|
||||
age_ratings: Set<string>,
|
||||
player_counts: Set<string>,
|
||||
languages: Set<string>,
|
||||
companies: Set<string>,
|
||||
genres: Set<string>;
|
||||
}
|
||||
|
||||
export interface FrontEndFilterLists
|
||||
{
|
||||
age_ratings: string[],
|
||||
player_counts: string[],
|
||||
languages: string[],
|
||||
companies: string[],
|
||||
genres: string[];
|
||||
}
|
||||
|
||||
export interface FrontEndGameMetadata
|
||||
{
|
||||
first_release_date: Date | null;
|
||||
}
|
||||
|
||||
export interface FrontEndGameMetadataDetailed extends FrontEndGameMetadata
|
||||
{
|
||||
genres: string[],
|
||||
companies: string[],
|
||||
game_modes: string[],
|
||||
age_ratings: string[];
|
||||
player_count: string | null;
|
||||
average_rating: number | null;
|
||||
}
|
||||
|
||||
export interface FrontEndGameType
|
||||
{
|
||||
platform_display_name: string | null,
|
||||
path_platform_cover: string | null;
|
||||
id: FrontEndId,
|
||||
source: string | null,
|
||||
source_id: string | null,
|
||||
path_fs: string | null,
|
||||
path_covers: string[],
|
||||
last_played: Date | null,
|
||||
updated_at: Date,
|
||||
metadata: FrontEndGameMetadata,
|
||||
slug: string | null,
|
||||
name: string | null,
|
||||
platform_id: number | null,
|
||||
platform_slug: string | null,
|
||||
paths_screenshots: string[];
|
||||
};
|
||||
|
||||
export type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing' | 'queued';
|
||||
|
||||
export interface GameInstallProgress
|
||||
{
|
||||
progress?: number;
|
||||
status?: GameStatusType;
|
||||
details?: string;
|
||||
commands?: CommandEntry[];
|
||||
error?: any;
|
||||
}
|
||||
|
||||
export type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted';
|
||||
export type GameInstallProgressEvent = 'refresh';
|
||||
|
||||
export interface FrontendPlugin
|
||||
{
|
||||
name: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
enabled: boolean;
|
||||
canDisable: boolean;
|
||||
canUninstall: boolean;
|
||||
source: PluginSourceType;
|
||||
hasSettings: boolean;
|
||||
version: string;
|
||||
icon?: string;
|
||||
update?: PluginUpdateCheck;
|
||||
}
|
||||
|
||||
export interface PluginUpdateCheck
|
||||
{
|
||||
current: string;
|
||||
new: string;
|
||||
}
|
||||
|
||||
export type PluginSourceType = "builtin" | "store";
|
||||
|
||||
export type KeysWithValueAssignableTo<T, Value> = {
|
||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
export interface DownloadInfo
|
||||
{
|
||||
id: string;
|
||||
screenshotUrls: string[];
|
||||
coverUrl: string;
|
||||
platform?: DownloadPlatform;
|
||||
slug?: string;
|
||||
path_fs?: string;
|
||||
main_glob?: string;
|
||||
summary?: string;
|
||||
name: string;
|
||||
last_played?: Date;
|
||||
igdb_id?: number;
|
||||
ra_id?: number;
|
||||
source_id: string;
|
||||
system_slug: string;
|
||||
extract_path?: string;
|
||||
metadata?: any;
|
||||
files: DownloadFileEntry[];
|
||||
auth?: string;
|
||||
version?: string;
|
||||
version_source?: string;
|
||||
version_system?: string;
|
||||
}
|
||||
|
||||
export interface DownloadPlatform
|
||||
{
|
||||
id: string;
|
||||
source: string;
|
||||
igdb_id?: number;
|
||||
igdb_slug?: string;
|
||||
ra_id?: number;
|
||||
moby_id?: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
/** Like Sony or Nintendo */
|
||||
family_name?: string;
|
||||
}
|
||||
|
||||
export interface DownloadFileEntry
|
||||
{
|
||||
url: URL;
|
||||
/** The path of the file, excluding the name */
|
||||
file_path: string;
|
||||
/** Just the name of the file including the extension */
|
||||
file_name: string;
|
||||
/** Checksum of the file */
|
||||
sha1?: string;
|
||||
/** Size in bytes */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface LocalDownloadFileEntry extends DownloadFileEntry
|
||||
{
|
||||
/** Exists on the file system */
|
||||
exists: boolean;
|
||||
/** Matches the checksum */
|
||||
matches: boolean;
|
||||
}
|
||||
|
||||
export interface FrontEndCollection
|
||||
{
|
||||
id: FrontEndId;
|
||||
name: string;
|
||||
description: string;
|
||||
path_platform_cover: string | null;
|
||||
game_count: number;
|
||||
}
|
||||
|
||||
export type EmulatorCapabilities = "saves" | "fullscreen" | "resolution" | "batch" | "states" | "config";
|
||||
|
||||
export interface EmulatorSupport
|
||||
{
|
||||
id: string;
|
||||
source?: EmulatorSourceEntryType;
|
||||
supportLevel?: "partial" | "full";
|
||||
capabilities?: EmulatorCapabilities[];
|
||||
}
|
||||
|
||||
export interface GameLookup
|
||||
{
|
||||
source: string;
|
||||
id: string;
|
||||
coverUrl: string | null | undefined;
|
||||
slug: string | null | undefined;
|
||||
screenshotUrls: string[];
|
||||
name: string;
|
||||
summary: string | null | undefined;
|
||||
genres: string[];
|
||||
companies: string[];
|
||||
game_modes: string[];
|
||||
age_ratings: string[];
|
||||
player_count: string | undefined;
|
||||
first_release_date: number | undefined;
|
||||
average_rating: number | undefined;
|
||||
keywords: string[];
|
||||
igdb_id: number | undefined;
|
||||
platforms: {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
displayName: string;
|
||||
slug: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface AutoSaveChange
|
||||
{
|
||||
subPath: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export type SaveSlots = Record<string, { cwd: string; }>;
|
||||
307
src/packages/gameflow-sdk/task-queue.ts
Normal file
307
src/packages/gameflow-sdk/task-queue.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
|
||||
import EventEmitter from 'node:events';
|
||||
import z from 'zod';
|
||||
import { JobStatus } from './shared';
|
||||
|
||||
export class TaskQueue
|
||||
{
|
||||
private activeQueue: JobContext<IJob<any, string>, any, string>[] = [];
|
||||
private queue?: JobContext<IJob<any, string>, any, string>[] = [];
|
||||
private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>();
|
||||
|
||||
constructor()
|
||||
{
|
||||
// we need a default error listener or app crashes
|
||||
this.events?.addListener('error', e =>
|
||||
{
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
|
||||
public enqueue<T> (id: string, job: T, throwOnError?: 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);
|
||||
this.queue.push(context as any);
|
||||
this.events?.emit('queued', { id: context.id, job: context });
|
||||
this.processQueue();
|
||||
return context.promise.promise as any;
|
||||
}
|
||||
|
||||
private processQueue ()
|
||||
{
|
||||
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 }));
|
||||
|
||||
next.reverse().forEach(({ i }) => this.queue!.splice(i, 1));
|
||||
|
||||
next.forEach(job =>
|
||||
{
|
||||
job.job.start();
|
||||
this.activeQueue.push(job.job);
|
||||
job.job.promise.promise.catch(e => { }).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.id, job: job.job });
|
||||
setTimeout(() => this.processQueue(), 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private disposeSafeguard ()
|
||||
{
|
||||
if (!this.queue) throw new Error("Queue disposed");
|
||||
}
|
||||
|
||||
public hasActive ()
|
||||
{
|
||||
return this.activeQueue.length > 0;
|
||||
}
|
||||
|
||||
public hasActiveOfType (type: any)
|
||||
{
|
||||
for (const entry of this.activeQueue)
|
||||
{
|
||||
if (entry.job instanceof type)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public waitForJob (id: string): Promise<void>
|
||||
{
|
||||
const job = this.queue?.find(j => j.id === id) ?? this.activeQueue?.find(j => j.id === id);
|
||||
return job?.promise.promise ?? Promise.resolve();
|
||||
}
|
||||
|
||||
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.id === id)
|
||||
?? this.activeQueue?.find(j => j.id === id);
|
||||
|
||||
if (job?.job instanceof type)
|
||||
{
|
||||
return job as any;
|
||||
}
|
||||
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
|
||||
{
|
||||
this.events?.on(event, listener);
|
||||
return () => this.events?.removeListener(event, listener);
|
||||
}
|
||||
|
||||
public once<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never)
|
||||
{
|
||||
this.events?.once(event, listener);
|
||||
}
|
||||
|
||||
public async close ()
|
||||
{
|
||||
this.queue = [];
|
||||
this.activeQueue.forEach(c => c.abort());
|
||||
return Promise.all(this.activeQueue.map(c =>
|
||||
{
|
||||
return new Promise(resolve =>
|
||||
{
|
||||
c.promise.promise.then(resolve).catch(e =>
|
||||
{
|
||||
console.error("Error During Task Queue Closing");
|
||||
resolve(false);
|
||||
});
|
||||
setTimeout(resolve, 5000);
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export interface EventsList
|
||||
{
|
||||
started: [e: BaseEvent];
|
||||
progress: [e: ProgressEvent];
|
||||
abort: [e: AbortEvent];
|
||||
/** Called when the job successfully completes */
|
||||
completed: [e: CompletedEvent];
|
||||
error: [e: ErrorEvent];
|
||||
ended: [e: BaseEvent];
|
||||
queued: [e: BaseEvent];
|
||||
}
|
||||
|
||||
export interface BaseEvent
|
||||
{
|
||||
id: string;
|
||||
job: IPublicJob<any, string, any>;
|
||||
}
|
||||
|
||||
export interface ErrorEvent extends BaseEvent
|
||||
{
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export interface AbortEvent extends BaseEvent
|
||||
{
|
||||
reason?: any;
|
||||
}
|
||||
|
||||
export interface ProgressEvent extends BaseEvent
|
||||
{
|
||||
progress: number;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
export interface CompletedEvent extends BaseEvent
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
export interface IJob<TData, TState extends string>
|
||||
{
|
||||
group?: string;
|
||||
start (context: JobContext<IJob<TData, TState>, TData, TState>): Promise<any>;
|
||||
exposeData?(): TData;
|
||||
}
|
||||
|
||||
export interface IPublicJob<TData, TState extends string, T extends IJob<TData, TState>>
|
||||
{
|
||||
progress: number;
|
||||
state?: string;
|
||||
status: JobStatus;
|
||||
job: T;
|
||||
abort: (reason?: any) => void;
|
||||
}
|
||||
|
||||
type JobClass = new (...args: any[]) => IJob<any, any>;
|
||||
type JobClassWithStatics = JobClass & {
|
||||
id: string;
|
||||
dataSchema?: any;
|
||||
};
|
||||
export type JobContextFromClass<C extends JobClassWithStatics> =
|
||||
JobContext<
|
||||
InstanceType<C>,
|
||||
C extends { dataSchema: z.ZodAny; }
|
||||
? z.infer<C['dataSchema']>
|
||||
: never,
|
||||
C['id']
|
||||
>;
|
||||
|
||||
export class JobContext<T extends IJob<TData, TState>, TData, TState extends string> implements IPublicJob<TData, TState, T>
|
||||
{
|
||||
private m_id: string;
|
||||
private m_progress: number = 0;
|
||||
private m_state?: TState;
|
||||
private running: boolean = false;
|
||||
private aborted: boolean = false;
|
||||
private completed: boolean = false;
|
||||
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)
|
||||
{
|
||||
this.m_id = id;
|
||||
this.m_job = job;
|
||||
this.abortController = new AbortController();
|
||||
this.abortController.signal.addEventListener('abort', () =>
|
||||
{
|
||||
this.aborted = true;
|
||||
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 ()
|
||||
{
|
||||
try
|
||||
{
|
||||
this.events.emit('started', { id: this.m_id, job: this });
|
||||
await this.m_job.start(this);
|
||||
if (!this.abortSignal.aborted)
|
||||
{
|
||||
this.completed = true;
|
||||
this.events.emit('completed', { id: this.m_id, job: this });
|
||||
this.m_promise.resolve(this.m_job.exposeData?.());
|
||||
} else
|
||||
{
|
||||
this.m_promise.resolve(undefined);
|
||||
}
|
||||
} catch (error)
|
||||
{
|
||||
if (error instanceof Event)
|
||||
{
|
||||
if (error.target instanceof AbortSignal)
|
||||
{
|
||||
this.m_promise.resolve(undefined);
|
||||
} else
|
||||
{
|
||||
console.error(error);
|
||||
this.m_promise.reject(error);
|
||||
}
|
||||
} else
|
||||
{
|
||||
this.events.emit('error', { id: this.m_id, job: this, error });
|
||||
this.error = error;
|
||||
this.m_promise.reject(error);
|
||||
}
|
||||
|
||||
} finally
|
||||
{
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
public get status (): JobStatus
|
||||
{
|
||||
if (this.completed) return 'completed';
|
||||
if (this.error) return 'error';
|
||||
if (this.aborted) return 'aborted';
|
||||
if (this.running) return 'running';
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
public get id () { return this.m_id; }
|
||||
|
||||
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; }
|
||||
|
||||
public get state () { return this.m_state; }
|
||||
|
||||
/**
|
||||
* @param progress The 0 to 100 progress
|
||||
* @param state what type of progress is this. Is it really progress. I humanity even advancing.
|
||||
*/
|
||||
public setProgress (progress: number, state?: TState)
|
||||
{
|
||||
this.m_progress = progress;
|
||||
if (state)
|
||||
this.m_state = state;
|
||||
this.events.emit('progress', { id: this.m_id, progress, state: state ?? this.m_state, job: this });
|
||||
}
|
||||
|
||||
public abort (reason?: any)
|
||||
{
|
||||
this.error = reason;
|
||||
this.abortController.abort(reason);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue