feat: Implemented public plugin system accessible from the store.
feat: Implemented external ryujinx integration plugin refactor: moved sdk types and schemas to own workspace package fix: Fixed emulator launch with no game
This commit is contained in:
parent
9051834ace
commit
38cb752552
124 changed files with 1918 additions and 1067 deletions
|
|
@ -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>>;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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 () =>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 () =>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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']);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
62
src/bun/api/jobs/plugin-operation-job.ts
Normal file
62
src/bun/api/jobs/plugin-operation-job.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
|
||||
import { FrontendNotification } from '@/shared/types';
|
||||
import { FrontendNotification } from '@simeonradivoev/gameflow-sdk/shared';
|
||||
import { events } from './app';
|
||||
|
||||
export default function buildNotificationsStream ()
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; })
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
});
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
62
src/bun/api/plugins/services.ts
Normal file
62
src/bun/api/plugins/services.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 ()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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["*"]));
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue