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

@ -1,5 +1,5 @@
import { TaskQueue } from "./task-queue";
import { TaskQueue, AppEventMap } from "@simeonradivoev/gameflow-sdk";
import { Database } from "bun:sqlite";
import { CookieJar } from 'tough-cookie';
import FileCookieStore from 'tough-cookie-file-store';
@ -8,7 +8,7 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
import Conf from "conf";
import projectPackage from '~/package.json';
import { SettingsSchema, SettingsType } from "@shared/constants";
import { SettingsType, SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared';
import { client } from "@clients/romm/client.gen";
import * as schema from "@schema/app";
import cacheSchema from "@schema/cache";
@ -24,7 +24,6 @@ import controls from './controls/controls';
import { RunAPIServer } from "./rpc";
import { RunBunServer } from "../server";
import ReloadPluginsJob from "./jobs/reload-plugins-job";
import { AppEventMap } from "../types/types";
export let config: Conf<SettingsType>;
export let customEmulators: Conf<Record<string, string>>;

View file

@ -1,7 +1,7 @@
import { eq } from "drizzle-orm";
import { cache } from "./app";
import cacheSchema from "@schema/cache";
import { GithubReleaseSchema } from "@/shared/constants";
import { GithubReleaseSchema } from '@simeonradivoev/gameflow-sdk/shared';
import PQueue from "p-queue";
import z from "zod";
@ -11,7 +11,8 @@ export const CACHE_KEYS = {
STORE_GAME_MANIFEST: 'store-game-manifest'
} as const;
export const githubRequestQueue = new PQueue({ intervalCap: 10, interval: 1000 * 60 * 10, strict: true });
// we aggressively cache github data so burst of calls is fine.
export const githubRequestQueue = new PQueue({ intervalCap: 60, interval: 1000 * 60 * 60, strict: true });
export async function getOrCached<T> (key: string, getter: (lastValue: T | undefined) => Promise<T>, options?: { expireMs?: number; force?: boolean; }): Promise<T>
{

View file

@ -1,7 +1,7 @@
import si from 'systeminformation';
import fs from 'node:fs';
import os from "node:os";
import { Drive } from '@/shared/types';
import { Drive } from '@simeonradivoev/gameflow-sdk/shared';
async function getAccess (path: string)
{

View file

@ -5,7 +5,7 @@ import z from "zod";
import path from 'node:path';
import { config, events, plugins } from "../app";
import { getLocalGame, updateLocalLastPlayed } from "../games/services/statusService";
import { SaveFileChange } from "@/shared/types";
import { SaveFileChange } from "@simeonradivoev/gameflow-sdk/shared";
// TODO: use the retroarch cores based on ES-DE
export const cores: Record<string, string> = {

View file

@ -1,6 +1,6 @@
import Elysia, { status } from "elysia";
import { plugins } from "../app";
import { FrontEndCollection } from "@/shared/types";
import { FrontEndCollection } from "@simeonradivoev/gameflow-sdk/shared";
export default new Elysia()
.get('/collections', async () =>

View file

@ -4,7 +4,8 @@ import { and, desc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm"
import z from "zod";
import * as schema from "@schema/app";
import fs from "node:fs/promises";
import { GameListFilterSchema, SERVER_URL } from "@shared/constants";
import { SERVER_URL } from "@shared/constants";
import { GameListFilterSchema } from '@simeonradivoev/gameflow-sdk/shared';
import { InstallJob } from "../jobs/install-job";
import path from "node:path";
import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
@ -22,7 +23,7 @@ import { LaunchGameJob } from "../jobs/launch-game-job";
import { cores } from "../emulatorjs/emulatorjs";
import { findEmulatorPluginIntegration } from "../store/services/emulatorsService";
import { ImportJob } from "../jobs/import-job";
import { EmulatorSourceEntryType, EmulatorSystem, FrontEndFilterLists, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailedEmulator, FrontEndGameTypeWithIds, FrontEndId, GameLookup } from "@/shared/types";
import { EmulatorSourceEntryType, EmulatorSystem, FrontEndFilterLists, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailedEmulator, FrontEndGameTypeWithIds, FrontEndId, GameLookup } from "@simeonradivoev/gameflow-sdk/shared";
// A custom jimp that supports webp
const Jimp = createJimp({

View file

@ -4,7 +4,7 @@ import { and, count, eq, getTableColumns, not, notExists, or } from "drizzle-orm
import { config, db, plugins } from "../app";
import * as schema from "@schema/app";
import { findPlatform } from "./services/utils";
import { FrontEndPlatformType } from "@/shared/types";
import { FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared";
export default new Elysia()
.get('/platforms', async () =>

View file

@ -6,7 +6,7 @@ import { config, taskQueue } from '../../app';
import { LaunchGameJob } from '../../jobs/launch-game-job';
import { getStoreEmulatorPackage } from '../../store/services/gamesService';
import { getOrCachedScoopPackage } from '../../store/services/emulatorsService';
import { CommandEntry, EmulatorSourceEntryType, FrontEndId } from '@/shared/types';
import { CommandEntry, EmulatorSourceEntryType, FrontEndId } from '@simeonradivoev/gameflow-sdk/shared';
export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string)
{

View file

@ -8,9 +8,10 @@ import z from "zod";
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
import { LaunchGameJob } from "../../jobs/launch-game-job";
import * as appSchema from "@schema/app";
import { DownloadSourceSchema, RPC_URL } from "@/shared/constants";
import { RPC_URL } from "@/shared/constants";
import { DownloadSourceSchema } from '@simeonradivoev/gameflow-sdk/shared';
import { host } from "@/bun/utils/host";
import { CommandEntry, FrontEndId, GameLookup, GameStatusType, LocalDownloadFileEntry } from "@/shared/types";
import { CommandEntry, FrontEndId, GameLookup, GameStatusType, LocalDownloadFileEntry } from "@simeonradivoev/gameflow-sdk/shared";
export class CommandSearchError extends Error
{
@ -115,11 +116,15 @@ export async function update (source: string, id: string)
const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)];
if (paths_screenshots.length <= 0 && sourceGame.igdb_id)
{
const matches: GameLookup[] = [];
await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id), matches });
if (matches.length > 0)
const matches = new Map<string, GameLookup[]>();
await plugins.hooks.games.gameLookup.promise(matches, { source: 'igdb', id: String(sourceGame.igdb_id) });
if (matches.size > 0)
{
paths_screenshots.push(...matches[0].screenshotUrls);
const firstMatches = matches.values().next().value;
if (firstMatches && firstMatches.length > 0)
{
paths_screenshots.push(...firstMatches[0].screenshotUrls);
}
}
}
@ -244,7 +249,31 @@ export async function getValidLaunchCommandsForGame (source: string, id: string)
commands: commands.filter(c => c.valid),
gameId: { id: String(localGame.id), source: 'local' },
source: localGame.source ?? source,
sourceId: String(localGame.source_id) ?? id,
sourceId: localGame.source_id ? String(localGame.source_id) : id,
};
}
else
{
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`);
}
} else if (source === 'emulator')
{
const commands = await plugins.hooks.games.buildLaunchCommands.promise({
source,
sourceId: id,
id: { source: source, id: id },
systemSlug: "",
gamePath: null
});
if (commands instanceof Error || !commands) return commands;
const validCommand = commands.find(c => c.valid);
if (validCommand)
{
return {
commands: commands.filter(c => c.valid),
gameId: { id, source }
};
}
else

View file

@ -8,7 +8,7 @@ import { RPC_URL } from "@shared/constants";
import { hashFile } from "@/bun/utils";
import { host } from "@/bun/utils/host";
import * as emulatorSchema from "@schema/emulators";
import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata } from "@/shared/types";
import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata } from "@simeonradivoev/gameflow-sdk/shared";
export async function calculateSize (installPath: string | null)
{

View file

@ -1,12 +0,0 @@
import AuthHooks from "./auth";
import EmulatorHooks from "./emulators";
import GameHooks from "./games";
import StoreHooks from "./store";
export default class GameflowHooks
{
games = new GameHooks();
emulators = new EmulatorHooks();
auth = new AuthHooks();
store = new StoreHooks();
}

View file

@ -1,9 +0,0 @@
import { DownloadFileEntry } from "@/shared/types";
import { AsyncSeriesHook } from "tapable";
export default class AuthHooks
{
loginComplete = new AsyncSeriesHook<[ctx: {
service: string;
}], { auth?: string, files: DownloadFileEntry[]; } | undefined>(['ctx']);
}

View file

@ -1,38 +0,0 @@
import { EmulatorPostInstallContext } from "@/bun/types/types";
import { DownloadFileEntry, EmulatorSourceEntryType, EmulatorSystem } from "@/shared/types";
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: EmulatorPostInstallContext], { 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: EmulatorPostInstallContext, ...rest: any[]) =>
{
if (ctx.emulator === tap.emulator)
{
tap.fn(ctx, ...rest);
}
}
};
},
});
}
}

View file

@ -1,174 +0,0 @@
import { EmulatorPackageType, GameListFilterType } from '@/shared/constants';
import { CommandEntry, DownloadInfo, EmulatorSourceEntryType, EmulatorSupport, EmulatorSystem, FrontEndCollection, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeWithIds, FrontEndId, FrontEndPlatformType, GameLookup, SaveFileChange, SaveSlots } from '@/shared/types';
import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, Hook, 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

@ -1,11 +0,0 @@
import { EmulatorDownloadInfoType } from "@/shared/constants";
import { FrontEndEmulator, FrontEndEmulatorDetailed, FrontEndGameTypeDetailed } from "@/shared/types";
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

@ -1,5 +1,5 @@
import z from "zod";
import { IJob, JobContext } from "../task-queue";
import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue";
import { config, plugins } from "../app";
import { simulateProgress } from "@/bun/utils";
import { Downloader } from "@/bun/utils/downloader";

View file

@ -1,6 +1,6 @@
import { EmulatorPackageType } from "@/shared/constants";
import { EmulatorPackageType } from '@simeonradivoev/gameflow-sdk/shared';
import { getStoreEmulatorPackage } from "../store/services/gamesService";
import { IJob, JobContext } from "../task-queue";
import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue";
import z from "zod";
import { config, plugins } from "../app";
import path from 'node:path';
@ -12,7 +12,7 @@ import { simulateProgress } from "@/bun/utils";
import { path7za } from "7zip-bin";
import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService";
import { $ } from "bun";
import { EmulatorSourceEntryType } from "@/shared/types";
import { EmulatorSourceEntryType } from "@simeonradivoev/gameflow-sdk/shared";
type EmulatorDownloadStates = "download" | "extract";

View file

@ -1,10 +1,10 @@
import { eq, or } from "drizzle-orm";
import { db, plugins } from "../app";
import { createLocalGame } from "../games/services/utils";
import { IJob, JobContext } from "../task-queue";
import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue";
import * as schema from "@schema/app";
import z from "zod";
import { GameLookup } from "@/shared/types";
import { GameLookup } from "@simeonradivoev/gameflow-sdk/shared";
export class ImportJob implements IJob<z.infer<typeof ImportJob.dataSchema>, string>
{

View file

@ -1,4 +1,4 @@
import { IJob, JobContext } from "../task-queue";
import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue";
import fs from 'node:fs/promises';
import path from 'node:path';
import { config, events, plugins } from "../app";
@ -11,7 +11,7 @@ import { ensureDir, move } from "fs-extra";
import { path7za } from "7zip-bin";
import StreamZip from 'node-stream-zip';
import { which } from "bun";
import { DownloadInfo } from "@/shared/types";
import { DownloadInfo } from "@simeonradivoev/gameflow-sdk/shared";
interface JobConfig
{

View file

@ -6,7 +6,7 @@ import TwitchLoginJob from "./twitch-login-job";
import UpdateStoreJob from "./update-store";
import { EmulatorDownloadJob } from "./emulator-download-job";
import { getErrorMessage } from "@/bun/utils";
import { IJob } from "../task-queue";
import { IJob } from "../../../packages/gameflow-sdk/task-queue";
import { LaunchGameJob } from "./launch-game-job";
import { BiosDownloadJob } from "./bios-download-job";
import { InstallJob } from "./install-job";

View file

@ -1,13 +1,13 @@
import z from "zod";
import { IJob, JobContext } from "../task-queue";
import { ActiveGameSchema, ActiveGameType } from "@/bun/types/types.schema";
import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue";
import { ActiveGameSchema, ActiveGameType } from "@simeonradivoev/gameflow-sdk";
import { config, db, events, plugins } from "../app";
import * as appSchema from "@schema/app";
import { eq } from "drizzle-orm";
import { spawn } from 'node:child_process';
import { updateLocalLastPlayed } from "../games/services/statusService";
import { getErrorMessage } from "@/bun/utils";
import { CommandEntry, FrontEndId, SaveSlots } from "@/shared/types";
import { CommandEntry, FrontEndId, SaveSlots } from "@simeonradivoev/gameflow-sdk/shared";
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, string>
{

View file

@ -1,5 +1,5 @@
import Elysia, { status } from "elysia";
import { IJob, JobContext } from "../task-queue";
import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue";
import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
import { host, localIp } from "@/bun/utils/host";
import cors from "@elysiajs/cors";

View file

@ -0,0 +1,62 @@
import z from "zod";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
import { plugins } from "../app";
import { canUninstall, runBunPackageCommand } from "../plugins/services";
import { getPlugin, registerPlugin, unregisterPlugin } from "../plugins/register-plugins";
import { PluginRegistry } from "@/shared/constants";
export default class PluginOperationJob implements IJob<never, string>
{
static id = "plugin-operation-job" as const;
static dataSchema = z.never();
group = "plugin-operations";
operation: "add" | "update" | "remove";
plugin: string;
constructor(operation: "add" | "update" | "remove", plugin: string)
{
this.plugin = plugin;
this.operation = operation;
}
async start (context: JobContext<IJob<never, string>, never, string>)
{
switch (this.operation)
{
case "add":
//TODO: find the latest compatible version with the current sdk version
const addResponse = await runBunPackageCommand(["add", this.plugin, '--omit', 'peer', "--registry", PluginRegistry]);
console.log(addResponse);
const addPlugin = await getPlugin(this.plugin, plugins);
if (!addPlugin) throw new Error(`${this.plugin} Not Found`);
await registerPlugin(addPlugin, 'store', plugins);
break;
case "update":
const existingPlugin = plugins.plugins[this.plugin];
if (!existingPlugin) throw new Error(`${this.plugin} Not Found`);
if (!existingPlugin.update?.new) throw new Error(`No Update Found`);
let updatePlugin = await getPlugin(this.plugin, plugins);
if (!updatePlugin) throw new Error(`${this.plugin} Not Found`);
await unregisterPlugin(this.plugin, plugins);
const updateResponse = await runBunPackageCommand(["update", `${this.plugin}@${existingPlugin.update?.new}`, '--omit', 'peer', "--registry", PluginRegistry, '--latest']);
console.log(updateResponse);
updatePlugin = await getPlugin(this.plugin, plugins);
if (!updatePlugin) throw new Error(`Something Went Wrong during update. Missing Plugin: ${this.plugin}`);
await registerPlugin(updatePlugin, existingPlugin.source, plugins);
break;
case "remove":
const removePlugin = plugins.plugins[this.plugin];
if (!removePlugin) throw new Error(`${this.plugin} Not Found`);
if (!canUninstall(removePlugin.description, removePlugin.source))
{
throw new Error("Uninstall Not Allowed");
}
const response = await runBunPackageCommand(['remove', this.plugin, "--registry", PluginRegistry, '--omit', 'peer']);
console.log(response);
await unregisterPlugin(this.plugin, plugins);
break;
}
}
}

View file

@ -1,5 +1,5 @@
import z from "zod";
import { IJob, JobContext } from "../task-queue";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
import { plugins } from "../app";
export default class ReloadPluginsJob implements IJob<never, string>

View file

@ -1,5 +1,5 @@
import z from "zod";
import { IJob, JobContext } from "../task-queue";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
import { events } from "../app";
import { Downloader } from "@/bun/utils/downloader";
import path from 'node:path';

View file

@ -1,4 +1,4 @@
import { IJob, JobContext } from "../task-queue";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
import secrets from "../secrets";
import open from "open";
import z from "zod";

View file

@ -1,59 +1,57 @@
import { ensureDir } from "fs-extra";
import { IJob, JobContext } from "../task-queue";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
import { getStoreRootFolder } from "../store/services/gamesService";
import { tmpdir } from "node:os";
import path from "node:path";
import z from "zod";
import { runBunPackageCommand } from "../plugins/services";
import { PluginRegistry } from "@/shared/constants";
import path from "node:path";
import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json';
export default class UpdateStoreJob implements IJob<never, never>
export default class UpdateStoreJob implements IJob<never, string>
{
static id = "update-store" as const;
static dataSchema = z.never();
packageName: string;
registry: URL;
storeVersion: string;
constructor()
{
this.packageName = process.env.STORE_PACKAGE_NAME ?? "@simeonradivoev/gameflow-store";
this.registry = new URL(process.env.STORE_REGISTRY ?? "https://registry.npmjs.org");
this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0";
}
async runCommand (commands: string[])
async start (context: JobContext<UpdateStoreJob, never, string>)
{
const tempCache = path.join(tmpdir(), "gameflow-bun-cache");
const storeFolder = getStoreRootFolder();
let proc = Bun.spawn([process.execPath, ...commands, "--registry", this.registry.href, '--json'], {
cwd: storeFolder,
stdout: 'pipe',
stderr: 'pipe',
env: {
BUN_BE_BUN: "1",
BUN_INSTALL_CACHE_DIR: tempCache
}
});
let stdout = await new Response(proc.stdout).text();
console.log(stdout);
let stderr = await new Response(proc.stderr).text();
if (stderr)
console.error(stderr);
await proc.exited;
}
async start (context: JobContext<UpdateStoreJob, never, never>)
{
if (process.env.CUSTOM_STORE_PATH) return;
const storeFolder = getStoreRootFolder();
await ensureDir(storeFolder);
const storePackageFile = Bun.file(path.join(storeFolder, "package.json"));
if (!await storePackageFile.exists())
{
await storePackageFile.write(JSON.stringify({ dependencies: {} }, null, 3));
}
console.log("Adding Store Package");
await this.runCommand(["add", `${this.packageName}@${this.storeVersion}`]);
const storePackage = await Bun.file(path.join(storeFolder, "package.json")).json();
console.log("Updating Store Package");
await this.runCommand(["update", `${this.packageName}@${this.storeVersion}`]);
if (!storePackage.dependencies?.[sdkPkg.name] || storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version)
{
let response = await runBunPackageCommand(["add", `${sdkPkg.name}@${sdkPkg.version}`, "--registry", PluginRegistry, '--omit', 'peer']);
console.log(response);
}
// probably just means we couldn't find a version of the sdk, just install latest
if (storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version)
{
let response = await runBunPackageCommand(["add", '--exact', `${sdkPkg.name}@latest`, "--registry", PluginRegistry, '--omit', 'peer']);
console.log(response);
}
if (process.env.CUSTOM_STORE_PATH) return;
if (!storePackage.dependencies?.['@simeonradivoev/gameflow-store'])
{
context.setProgress(0.5, "Adding Store");
let response = await runBunPackageCommand(["add", `${this.packageName}@${this.storeVersion}`, "--registry", PluginRegistry, '--omit', 'peer']);
console.log(response);
}
}
}

View file

@ -1,5 +1,5 @@
import { FrontendNotification } from '@/shared/types';
import { FrontendNotification } from '@simeonradivoev/gameflow-sdk/shared';
import { events } from './app';
export default function buildNotificationsStream ()

View file

@ -1,4 +1,4 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema";
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import path from 'node:path';
import { config } from "@/bun/api/app";

View file

@ -1,6 +1,6 @@
import { config } from "@/bun/api/app";
import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema";
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import path from 'node:path';
import desc from './package.json';
import { ensureDir } from "fs-extra";

View file

@ -1,12 +1,12 @@
import { config } from "@/bun/api/app";
import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema";
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import defaultConfig from './PCSX2.ini' with { type: 'file' };
import path from 'node:path';
import { ensureDir } from "fs-extra";
import desc from './package.json';
import ini from 'ini';
import { EmulatorCapabilities } from "@/shared/types";
import { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared";
export default class PCSX2Integration implements PluginType
{

View file

@ -1,4 +1,4 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema";
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import { config } from "@/bun/api/app";
import configFilePathWin32 from './win32/ppsspp.ini' with { type: 'file' };
@ -11,7 +11,7 @@ import { ensureDir } from "fs-extra";
import { homedir } from "node:os";
import ini from 'ini';
import fs from 'node:fs/promises';
import { EmulatorCapabilities } from "@/shared/types";
import { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared";
export default class PPSSPPIntegration implements PluginType
{

View file

@ -1,4 +1,4 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema";
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import { config } from "@/bun/api/app";
import path from "node:path";

View file

@ -1,6 +1,6 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema";
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import GameflowHooks from "@/bun/api/hooks/app";
import { GameflowHooks } from "@simeonradivoev/gameflow-sdk";
import { config } from "@/bun/api/app";
import path from "node:path";
import { ensureDir } from "fs-extra";

View file

@ -1,4 +1,4 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema";
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import { config, customEmulators, db, emulatorsDb } from "@/bun/api/app";
import * as emulatorSchema from '@schema/emulators';
@ -13,7 +13,7 @@ import { findStoreEmulatorExec } from "@/bun/api/games/services/launchGameServic
import { which } from "bun";
import os from 'node:os';
import { getLocalGameMatch } from "@/bun/api/games/services/utils";
import { CommandEntry, EmulatorSourceEntryType } from "@/shared/types";
import { CommandEntry, EmulatorSourceEntryType } from "@simeonradivoev/gameflow-sdk/shared";
export default class IgdbIntegration implements PluginType
{

View file

@ -1,4 +1,4 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema";
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import { config, db, events } from "@/bun/api/app";
import path from 'node:path';

View file

@ -1,10 +1,10 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema";
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import secrets from "@/bun/api/secrets";
import PQueue from 'p-queue';
import * as igdb from '@phalcode/ts-igdb-client';
import { checkLoginAndRefreshTwitch } from "@/bun/api/auth";
import { GameLookup } from "@/shared/types";
import { GameLookup } from "@simeonradivoev/gameflow-sdk/shared";
export default class IgdbIntegration implements PluginType
{

View file

@ -1,6 +1,6 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema";
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, PlatformSchema, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
import { config, events } from "@/bun/api/app";
@ -14,7 +14,7 @@ import { client } from "@/clients/romm/client.gen";
import { validateGameSource } from "@/bun/api/games/services/statusService";
import z from "zod";
import { checkLoginAndRefreshRomm } from "@/bun/api/auth";
import { DownloadFileEntry, DownloadInfo, FrontEndCollection, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, FrontEndGameTypeWithIds, FrontEndPlatformType } from "@/shared/types";
import { DownloadFileEntry, DownloadInfo, FrontEndCollection, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, FrontEndGameTypeWithIds, FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared";
import Conf from "conf";
const SettingsSchema = z.object({

View file

@ -1,8 +1,8 @@
{
"name": "com.simeonradivoev.gameflow.store",
"displayName": "Gameflow Store",
"displayName": "Gameflow Store Integration",
"version": "0.0.1",
"description": "The internal gameflow store",
"description": "The internal gameflow store integration. This is the logic of the store that uses the data only store package",
"main": "./store.ts",
"category": "sources",
"canDisable": false,

View file

@ -1,5 +1,4 @@
import { getStoreFolder } from "@/bun/api/store/services/gamesService";
import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, StoreDownloadType, StoreGameSchema, StoreGameType } from "@/shared/constants";
import os from 'node:os';
import path from "node:path";
import * as appSchema from '@schema/app';
@ -12,7 +11,7 @@ import { shuffleInPlace } from "@/bun/utils";
import mustache from "mustache";
import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService";
import fs from "node:fs/promises";
import { CommandEntry, EmulatorSourceEntryType, EmulatorSystem, FrontEndEmulator, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, SaveFileChange } from "@/shared/types";
import { CommandEntry, EmulatorSourceEntryType, EmulatorSystem, FrontEndEmulator, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, SaveFileChange, EmulatorDownloadInfoType, StoreDownloadType, StoreGameType, EmulatorPackageType, EmulatorDownloadInfoSchema, StoreGameSchema } from "@simeonradivoev/gameflow-sdk/shared";
export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; })
{

View file

@ -1,4 +1,4 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema";
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import path, { } from 'node:path';
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService";
@ -12,7 +12,7 @@ import { getSourceGameDetailed } from "@/bun/api/games/services/utils";
import UpdateStoreJob from "@/bun/api/jobs/update-store";
import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService";
import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services";
import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@/shared/types";
import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@simeonradivoev/gameflow-sdk/shared";
export default class RommIntegration implements PluginType
{
@ -151,7 +151,8 @@ export default class RommIntegration implements PluginType
if (!validDownload || !validDownload.bin) return;
const glob = new Glob(validDownload.bin);
const files = await Array.fromAsync(glob.scan({ cwd: emulatorPath }));
if (files.length > 0)
// es-de also searches for store executables so there might be duplicates, check first.
if (files.length > 0 && !sources.find(s => s.type === 'store'))
{
sources.push({ binPath: path.join(emulatorPath, files[0]), exists: true, rootPath: emulatorPath, type: 'store' });
}

View file

@ -1,10 +1,13 @@
import GameflowHooks from "../hooks/app";
import { PluginDescriptionType, PluginLoadingContextType, PluginType } from "../../types/types.schema";
import { config } from "../app";
import { GameflowHooks } from "@simeonradivoev/gameflow-sdk";
import { PluginDescriptionType, PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import { config, events, taskQueue } from "../app";
import Conf from "conf";
import projectPackage from '~/package.json';
import z from "zod";
import { PluginSourceType } from "@/shared/types";
import { PluginSourceType, PluginUpdateCheck } from "@simeonradivoev/gameflow-sdk/shared";
import { getUpdates } from "./services";
import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json';
import { semver } from "bun";
export const pluginZodRegistry = z.registry<{
requiresRestart?: boolean;
@ -21,9 +24,19 @@ export class PluginManager
description: PluginDescriptionType,
source: PluginSourceType;
config?: Conf;
update?: PluginUpdateCheck;
incompatible?: boolean;
}> = {};
unregister (id: string)
{
if (!this.plugins[id]) return false;
delete this.plugins[id];
console.log("Plugin", id, "unregistered");
return true;
}
register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType)
{
try
@ -68,16 +81,33 @@ export class PluginManager
};
}
private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; })
checkValidity (plugin: PluginDescriptionType)
{
const sdkDep = plugin.peerDependencies?.[sdkPkg.name];
if (sdkDep)
{
return semver.satisfies(sdkPkg.version, sdkDep);
}
return true;
}
private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; }, update: string | undefined)
{
const plugin = this.plugins[name];
if (plugin)
{
plugin.update = update && !semver.satisfies(plugin.description.version, update) ? { current: plugin.description.version, new: update } : undefined;
const ctx: PluginLoadingContextType = {
hooks: this.hooks,
setProgress: reloadCtx.setProgress.bind(reloadCtx),
config: plugin.config as any,
zodRegistry: pluginZodRegistry
zodRegistry: pluginZodRegistry,
app: {
config,
events,
taskQueue
}
};
if (plugin.loaded)
@ -88,7 +118,14 @@ export class PluginManager
try
{
if (plugin.enabled || plugin.description.canDisable === false)
plugin.incompatible = !this.checkValidity(plugin.description);
if (plugin.incompatible)
{
console.error(plugin.description.name, "Incompatible sdk verison");
return;
}
if (plugin.enabled || plugin.description.canDisable === false || plugin.description.name === '@simeonradivoev/gameflow-store')
{
console.log("Loading Plugin", plugin.description.name);
await plugin.plugin.load(ctx);
@ -106,10 +143,13 @@ export class PluginManager
async reloadAll (ctx: { setProgress: (progress: number, state: string) => void; })
{
this.hooks = new GameflowHooks();
const outdated = await getUpdates();
for await (const id of Object.keys(this.plugins))
{
ctx.setProgress(0, `Loading ${id}`);
await this.reload(id, ctx);
await this.reload(id, ctx, outdated?.[id]);
}
}

View file

@ -3,7 +3,9 @@ import { plugins, taskQueue } from "../app";
import z from "zod";
import { toggleElementInConfig } from "@/bun/utils";
import ReloadPluginsJob from "../jobs/reload-plugins-job";
import { FrontendPlugin } from "@/shared/types";
import { FrontendPlugin } from "@simeonradivoev/gameflow-sdk/shared";
import { canDisable, canUninstall } from "./services";
import PluginOperationJob from "../jobs/plugin-operation-job";
export default new Elysia({ prefix: '/plugins' })
.get('/', async () =>
@ -17,25 +19,27 @@ export default new Elysia({ prefix: '/plugins' })
description: p.description.description,
source: p.source,
version: p.description.version,
canDisable: p.description.canDisable ?? true,
canDisable: canDisable(p.description),
icon: p.description.icon,
category: p.description.category,
hasSettings: !!p.config || !!p.plugin.eventsNames
hasSettings: !!p.config || !!p.plugin.eventsNames,
canUninstall: canUninstall(p.description, p.source),
update: p.update
};
return plugin;
});
})
.get('/:id', async ({ params: { id } }) =>
{
const plugin = plugins.plugins[id];
return plugin.description;
const plugin = plugins.plugins[decodeURIComponent(id)];
return { ...plugin.description, update: plugin.update };
})
.post('/:id', async ({ params: { id }, body: { enabled } }) =>
{
const plugin = plugins.plugins[id];
const plugin = plugins.plugins[decodeURIComponent(id)];
if (plugin)
{
if (plugin.description.canDisable === false)
if (!canDisable(plugin.description))
{
return status("Forbidden");
}
@ -48,4 +52,26 @@ export default new Elysia({ prefix: '/plugins' })
}
}, {
body: z.object({ enabled: z.boolean() })
}).post('/install', async ({ body: { id } }) =>
{
if (taskQueue.hasActiveOfType(PluginOperationJob) || taskQueue.hasActiveOfType(ReloadPluginsJob)) return;
await taskQueue.enqueue(PluginOperationJob.id, new PluginOperationJob("add", id));
await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob());
}, {
body: z.object({ id: z.string() })
}).post('/update', async ({ body: { id } }) =>
{
if (taskQueue.hasActiveOfType(PluginOperationJob) || taskQueue.hasActiveOfType(ReloadPluginsJob)) return;
await taskQueue.enqueue(PluginOperationJob.id, new PluginOperationJob("update", id));
await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob());
}, {
body: z.object({ id: z.string() })
})
.post('/uninstall', async ({ body: { id } }) =>
{
if (taskQueue.hasActiveOfType(PluginOperationJob) || taskQueue.hasActiveOfType(ReloadPluginsJob)) return;
await taskQueue.enqueue(PluginOperationJob.id, new PluginOperationJob("remove", id));
await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob());
}, {
body: z.object({ id: z.string() })
});

View file

@ -11,12 +11,78 @@ import igdb from './builtin/sources/com.simeonradivoev.gameflow.igdb/package.jso
import store from './builtin/sources/com.simeonradivoev.gameflow.store/package.json';
import es from './builtin/launchers/com.simeonradivoev.gameflow.es/package.json';
import rclone from './builtin/other/com.simeonradivoev.gameflow.rclone/package.json';
import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/types.schema";
import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@simeonradivoev/gameflow-sdk";
import path from 'node:path';
import { getStoreRootFolder } from "../store/services/gamesService";
import { getUpdates } from "./services";
import { PluginSourceType } from "@simeonradivoev/gameflow-sdk/shared";
import { taskQueue } from "../app";
import UpdateStoreJob from "../jobs/update-store";
type PluginEntry = PluginDescriptionType & { load: () => Promise<any>; };
const blacklist = new Set(['@simeonradivoev/gameflow-sdk']);
export async function getPlugin (id: string, pluginManager: PluginManager)
{
const pluginPath = path.join(getStoreRootFolder(), 'node_modules', id);
const pluginPackageFile = Bun.file(path.join(pluginPath, 'package.json'));
if (await pluginPackageFile.exists())
{
const pluginPackage = await PluginDescriptionSchema.safeParseAsync(await pluginPackageFile.json());
if (pluginPackage.success)
{
const mainPath = path.join(pluginPath, pluginPackage.data.main);
if (await Bun.file(mainPath).exists())
{
const entry: PluginEntry = { ...pluginPackage.data, load: () => import(mainPath) };
return entry;
} else
{
console.error("Main file for", id, "does not exist");
}
} else
{
console.error("Invalid Package for", id, pluginPackage.error.message);
}
} else
{
console.error("Package for", id, "does not exist");
}
}
export async function unregisterPlugin (id: string, pluginManager: PluginManager)
{
return pluginManager.unregister(id);
}
export async function registerPlugin (plugin: PluginEntry, source: PluginSourceType, pluginManager: PluginManager)
{
if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(plugin.name))
{
console.log("Skipping", plugin.name, "missing in whitelist");
return;
}
if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(plugin.name))
{
console.log("Skipping", plugin.name, "found in whitelist");
return;
}
const file = await plugin.load();
if (file.default && typeof file.default === 'function')
{
const pluginInstance = new file.default();
await PluginSchema.parseAsync(pluginInstance);
const description = await PluginDescriptionSchema.parseAsync(plugin);
pluginManager.register(pluginInstance, description, source);
} else
{
console.log("Skipping", plugin.name, "invalid main. Has to be class with load method");
}
}
export default async function register (pluginManager: PluginManager)
{
const plugins: PluginEntry[] = [
@ -33,53 +99,41 @@ export default async function register (pluginManager: PluginManager)
{ ...rclone, load: () => import('./builtin/other/com.simeonradivoev.gameflow.rclone/rclone') },
];
const storePackageFile = path.join(getStoreRootFolder(), 'package.json');
const storePackage = await Bun.file(storePackageFile).json();
await Promise.all(plugins.map(p => registerPlugin(p, 'builtin', pluginManager)));
if (storePackage.dependencies)
const storePackageFilePath = path.join(getStoreRootFolder(), 'package.json');
if (!await Bun.file(storePackageFilePath).exists())
{
const storePlugins = await Promise.all(Object.keys(storePackage.dependencies).map(async p =>
console.log("Store is missing. Updating it.");
await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
console.log("Store Updated");
}
const storePackage = await Bun.file(storePackageFilePath).json();
if (storePackage?.dependencies)
{
const storePlugins = await Promise.all(Object.keys(storePackage.dependencies).filter(p => !blacklist.has(p)).map(async p =>
{
const pluginPath = path.join(getStoreRootFolder(), 'node_modules', p);
const pluginPackageFile = Bun.file(path.join(pluginPath, 'package.json'));
if (await pluginPackageFile.exists())
{
const pluginPackage = await PluginDescriptionSchema.safeParseAsync(await pluginPackageFile.json());
if (pluginPackage.success)
{
const mainPath = path.join(pluginPath, pluginPackage.data.main);
if (await Bun.file(mainPath).exists())
{
const entry: PluginEntry = { ...pluginPackage.data, load: () => import(mainPath) };
return entry;
}
}
}
return getPlugin(p, pluginManager);
}));
plugins.push(...storePlugins.filter(p => !!p));
}
console.log("Checking for outdated packages");
const outdated = await getUpdates();
await Promise.all(plugins.filter(p =>
{
if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(p.name))
const validPlugins = storePlugins.filter(p => !!p);
if (outdated)
{
return false;
validPlugins.forEach(p =>
{
const newVersion = outdated[p.name];
if (newVersion)
{
console.log("Plugin", p.name, "has update", p.version, "=>", newVersion);
}
});
}
if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(p.name))
{
return false;
}
return true;
}).map(async (pluginPackage) =>
{
const file = await pluginPackage.load();
if (file.default && typeof file.default === 'function')
{
const pluginInstance = new file.default();
await PluginSchema.parseAsync(pluginInstance);
const description = await PluginDescriptionSchema.parseAsync(pluginPackage);
pluginManager.register(pluginInstance, description, 'builtin');
}
}));
await Promise.all(validPlugins.map(p => registerPlugin(p, 'store', pluginManager)));
}
}

View file

@ -0,0 +1,62 @@
import path from 'node:path';
import os from 'node:os';
import { getStoreRootFolder } from '../store/services/gamesService';
import { PluginDescriptionType } from '@simeonradivoev/gameflow-sdk';
import { run } from 'npm-check-updates';
export function canDisable (description: PluginDescriptionType)
{
if (description.name === '@simeonradivoev/gameflow-store')
{
return false;
}
return description.canDisable ?? true;
}
export async function getUpdates ()
{
const updated = await run({ packageManager: 'bun', peer: true, cwd: getStoreRootFolder(), jsonUpgraded: true, reject: ['@simeonradivoev/gameflow-sdk'] });
return updated as Record<string, string>;
}
export function canUninstall (description: PluginDescriptionType, source: string)
{
if (description.name === '@simeonradivoev/gameflow-store')
{
return false;
}
return source !== 'builtin';
}
export async function runBunPackageCommand (commands: string[])
{
const tempCache = path.join(os.tmpdir(), "gameflow-bun-cache");
const storeFolder = getStoreRootFolder();
let proc = Bun.spawn([process.execPath, ...commands, '--json'], {
cwd: storeFolder,
stdout: 'pipe',
stderr: 'pipe',
env: {
BUN_BE_BUN: "1",
BUN_INSTALL_CACHE_DIR: tempCache
}
});
let stdout = await new Response(proc.stdout).text();
let stderr = await new Response(proc.stderr).text();
if (stderr)
console.error(stderr);
await proc.exited;
return stdout;
}
export async function hasPackage (id: string)
{
const storeFolder = getStoreRootFolder();
const packagePath = path.join(storeFolder, 'package.json');
const packageFile = Bun.file(packagePath);
if (!await packageFile.exists()) return false;
const pkg = await packageFile.json();
return !!pkg.dependencies?.[id];
}

View file

@ -1,4 +1,5 @@
import { LocalGameMetadata } from "@/shared/types";
import { LocalGameMetadata } from "@simeonradivoev/gameflow-sdk/shared";
import { sql, relations } from "drizzle-orm";
import { integer, text, sqliteTable, blob } from "drizzle-orm/sqlite-core";

View file

@ -7,7 +7,7 @@ import { cores } from '../emulatorjs/emulatorjs';
import { SERVER_URL } from '@/shared/constants';
import { host } from '@/bun/utils/host';
import { findEmulatorPluginIntegration } from '../store/services/emulatorsService';
import { EmulatorSourceEntryType, FrontEndEmulator } from '@/shared/types';
import { EmulatorSourceEntryType, FrontEndEmulator } from '@simeonradivoev/gameflow-sdk/shared';
/**
* Get emulators based on local games. Only the ones we probably need.

View file

@ -1,5 +1,5 @@
import z from "zod";
import { SettingsSchema } from "@shared/constants";
import { SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared';
import Elysia, { status } from "elysia";
import { config, customEmulators, plugins, taskQueue } from "../app";
import fs from 'node:fs/promises';
@ -96,27 +96,27 @@ export const settings = new Elysia({ prefix: '/api/settings' })
})
.get('/definitions/:source', async ({ params: { source } }) =>
{
return plugins.plugins[source].plugin.settingsSchema?.toJSONSchema() as JSONSchema7;
return plugins.plugins[decodeURIComponent(source)].plugin.settingsSchema?.toJSONSchema() as JSONSchema7;
})
.get('/actions/:source', async ({ params: { source } }) =>
{
const plugin = plugins.plugins[source]?.plugin;
const plugin = plugins.plugins[decodeURIComponent(source)]?.plugin;
if (!plugin.eventsNames) return [];
return plugin.eventsNames;
})
.post('/actions/:source/:id', async ({ params: { source, id } }) =>
{
return await plugins.plugins[source]?.plugin.onEvent?.(id);
return await plugins.plugins[decodeURIComponent(source)]?.plugin.onEvent?.(decodeURIComponent(id));
})
.get('/:source/:id', async ({ params: { source, id } }) =>
{
return { value: plugins.plugins[source].config?.get(id) };
return { value: plugins.plugins[decodeURIComponent(source)].config?.get(decodeURIComponent(id)) };
})
.put('/:source/:id', async ({ params: { source, id }, body: { value } }) =>
{
const plugin = plugins.plugins[source];
const plugin = plugins.plugins[decodeURIComponent(source)];
if (!plugin.config) return status("Not Found", "Plugin has no config");
const settingSchema = plugin.plugin.settingsSchema?.shape[id] as z.ZodObject;
const settingSchema = plugin.plugin.settingsSchema?.shape[decodeURIComponent(id)] as z.ZodObject;
if (!settingSchema) return status("Not Found", "Could not find setting");
const meta = pluginZodRegistry.get(settingSchema);

View file

@ -1,8 +1,7 @@
import { EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants";
import { config, plugins } from "../../app";
import { getOrCached, getOrCachedGithubRelease } from "../../cache";
import path from "node:path";
import { EmulatorSourceEntryType, EmulatorSupport } from "@/shared/types";
import { EmulatorSourceEntryType, EmulatorSupport, ScoopPackageSchema, EmulatorPackageType, EmulatorDownloadInfoType } from "@simeonradivoev/gameflow-sdk/shared";
export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]): EmulatorSupport[]
{

View file

@ -1,10 +1,9 @@
import { EmulatorPackageSchema, EmulatorPackageType } from "@/shared/constants";
import { and, eq, or } from "drizzle-orm";
import { config, emulatorsDb } from '../../app';
import path from "node:path";
import fs from 'node:fs/promises';
import * as emulatorSchema from '@schema/emulators';
import { EmulatorSystem } from "@/shared/types";
import { EmulatorSystem, EmulatorPackageType, EmulatorPackageSchema } from "@simeonradivoev/gameflow-sdk/shared";
export function getStoreRootFolder ()
{

View file

@ -3,7 +3,6 @@ import Elysia, { status } from "elysia";
import { config, db, plugins, taskQueue } from "../app";
import path from "node:path";
import fs from 'node:fs/promises';
import { EmulatorDownloadInfoSchema } from "@/shared/constants";
import * as appSchema from '@schema/app';
import z from "zod";
import { convertLocalToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
@ -13,7 +12,17 @@ import { getStoreFolder } from "./services/gamesService";
import { EmulatorDownloadJob } from "../jobs/emulator-download-job";
import { BiosDownloadJob } from "../jobs/bios-download-job";
import { findEmulatorPluginIntegration, getEmulatorPath } from "./services/emulatorsService";
import { EmulatorSourceEntryType, FrontEndEmulator, FrontEndGameTypeDetailed } from "@/shared/types";
import { EmulatorSourceEntryType, FrontEndEmulator, FrontEndGameTypeDetailed, PluginBunDetailsSchema, PluginEntrySchema, EmulatorDownloadInfoSchema } from "@simeonradivoev/gameflow-sdk/shared";
import PQueue from "p-queue";
import { hasPackage, runBunPackageCommand } from "../plugins/services";
import { semver } from "bun";
const npmQueue = new PQueue({ intervalCap: 60, interval: 1000 * 60, strict: true });
const pluginsResponseSchema = z.object({
objects: z.array(PluginEntrySchema),
total: z.number(),
time: z.coerce.date()
});
export const store = new Elysia({ prefix: '/api/store' })
.get('/emulators', async ({ query }) =>
@ -109,6 +118,49 @@ export const store = new Elysia({ prefix: '/api/store' })
gameCount
};
})
.get('/plugin', async ({ query: { plugin } }) =>
{
const pluginsRes = await runBunPackageCommand(['info', plugin]);
const pluginData = await PluginBunDetailsSchema.parseAsync(JSON.parse(pluginsRes));
const existingVersion = plugins.plugins[plugin]?.description.version;
return {
...pluginData,
installed: !!plugins.plugins[plugin] || await hasPackage(plugin),
update: existingVersion && semver.order(pluginData.version, existingVersion) > 0 ? { from: existingVersion } : undefined
};
},
{
query: z.object({ plugin: z.string() })
})
.get('/plugins', async ({ query: { search } }) =>
{
//TODO: Find a better way to search keywords and a search term at the same time
const pluginsRes = await npmQueue.add(() => fetch(`https://registry.npmjs.com/-/v1/search?text=keywords:gameflow-plugin`));
if (!pluginsRes.ok) return status(pluginsRes.status, pluginsRes.statusText);
const data: z.infer<typeof pluginsResponseSchema> = await pluginsRes.json();
if (search)
{
data.objects = data.objects.filter(o =>
{
if (o.package.description && o.package.description.includes(search)) return true;
if (o.package.name.includes(search)) return true;
if (o.package.keywords.includes(search)) return true;
return false;
});
data.total = data.objects.length;
}
await Promise.all(data.objects.map(async o =>
{
const existingVersion = plugins.plugins[o.package.name]?.description.version;
o.installed = !!plugins.plugins[o.package.name] || await hasPackage(o.package.name);
o.update = existingVersion && semver.order(o.package.version, existingVersion) > 0 ? { from: existingVersion } : undefined;
}));
return data as any;
}, {
query: z.object({ search: z.string().optional() }),
response: pluginsResponseSchema
})
.get('/media/*', async ({ params }) =>
{
return Bun.file(path.join(getStoreFolder(), params["*"]));

View file

@ -7,7 +7,7 @@ import { getAppVersion, isSteamDeck, openExternal } from "../utils";
import fs from 'node:fs/promises';
import buildNotificationsStream from "./notifications";
import path, { dirname } from "node:path";
import { DirSchema, SystemInfoSchema } from "@/shared/constants";
import { SystemInfoSchema, DirSchema, DownloadsDrive } from '@simeonradivoev/gameflow-sdk/shared';
import { getDevices, getDevicesCurated } from "./drives";
import getFolderSize from "get-folder-size";
import si from 'systeminformation';
@ -16,7 +16,6 @@ import ReloadPluginsJob from "./jobs/reload-plugins-job";
import { semver } from "bun";
import { getOrCachedGithubRelease } from "./cache";
import SelfUpdateJob from "./jobs/self-update-job";
import { DownloadsDrive } from "@/shared/types";
async function checkUpdate (force?: boolean)
{

View file

@ -1,306 +0,0 @@
import { JobStatus } from '@/shared/types';
import EventEmitter from 'node:events';
import z from 'zod';
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);
}
}

View file

@ -1,63 +0,0 @@
import z from "zod";
import GameflowHooks from "../api/hooks/app";
import Conf from "conf";
import { $ZodRegistry } from "zod/v4/core";
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")
}).extend(PluginContextSchema.shape);
export const PluginDescriptionSchema = z.object({
name: z.string(),
displayName: z.string(),
version: z.string(),
description: z.string(),
icon: z.url().optional().describe("Can be an external URL to an image or a data url"),
keywords: z.array(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 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 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 PluginDescriptionType = z.infer<typeof PluginDescriptionSchema>;
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 type ActiveGameType = z.infer<typeof ActiveGameSchema>;

View file

@ -1,18 +0,0 @@
import { EmulatorDownloadInfoType, EmulatorPackageType } from "@/shared/constants";
import { FrontendNotification } from "@/shared/types";
export interface AppEventMap
{
exitapp: [];
notification: [FrontendNotification];
focus: [];
}
export interface EmulatorPostInstallContext
{
emulator: string;
emulatorPackage?: EmulatorPackageType;
path: string;
update: boolean;
info: EmulatorDownloadInfoType;
}

View file

@ -1,10 +1,9 @@
import { $, sleep } from 'bun';
import path from 'node:path';
import { SettingsType } from '@/shared/constants';
import { SettingsType, KeysWithValueAssignableTo } from '@simeonradivoev/gameflow-sdk/shared';
import { config } from './api/app';
import fs from 'node:fs/promises';
import packageDef from '~/package.json';
import { KeysWithValueAssignableTo } from '@/shared/types';
export function checkRunning (pid: number)
{

View file

@ -5,7 +5,7 @@ import fs from 'node:fs/promises';
import { createWriteStream } from "node:fs";
import { config, jar } from "../api/app";
import { moveAllFiles } from "../utils";
import { DownloadFileEntry } from "@/shared/types";
import { DownloadFileEntry } from "@simeonradivoev/gameflow-sdk/shared";
export interface ProgressStats
{