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:
Simeon Radivoev 2026-05-10 01:46:57 +03:00
parent 9051834ace
commit 38cb752552
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
124 changed files with 1918 additions and 1067 deletions

View 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.

View 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}`);

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

View 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']);
}

View 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);
}
}
};
},
});
}
}

View 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);
}
}
};
},
});
}
}

View 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']);
}

View 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>;

View 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"
]
}

View 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"
]
}
}

View 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; }>;

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