fix: Fixed tests

feat: Added RClone integration
feat: Implemented plugin settings
feat: Updated minimal store version
test: Fixed tests
feat: Moved store and igdb and es-de to their own plugins
This commit is contained in:
Simeon Radivoev 2026-04-17 21:21:14 +03:00
parent 444d8c4c27
commit c09fbd3dc8
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
115 changed files with 4139 additions and 1502 deletions

View file

@ -18,13 +18,13 @@ import EventEmitter from "node:events";
import { appPath } from "../utils";
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
import { ensureDir } from "fs-extra";
import UpdateStoreJob from "./jobs/update-store";
import { getStoreFolder } from "./store/services/gamesService";
import { PluginManager } from "./plugins/plugin-manager";
import registerPlugins from "./plugins/register-plugins";
import controls from './controls/controls';
import { RunAPIServer } from "./rpc";
import { RunBunServer } from "../server";
import ReloadPluginsJob from "./jobs/reload-plugins-job";
export let config: Conf<SettingsType>;
export let customEmulators: Conf<Record<string, string>>;
@ -72,7 +72,6 @@ export async function load ()
console.log("Config Path Located At: ", config.path);
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
console.log("App Directory is ", process.env.APPDIR);
console.log("Store Directory is ", getStoreFolder());
cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite');
fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
@ -84,14 +83,14 @@ export async function load ()
emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
await reloadDatabase();
plugins = new PluginManager();
await registerPlugins(plugins);
api = await RunAPIServer();
await registerPlugins(plugins);
taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob());
controlsHandle = await controls();
if (!process.env.PUBLIC_ACCESS) bunServer = await RunBunServer();
config.onDidChange('downloadPath', () => reloadDatabase());
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
}
export async function cleanup ()
@ -120,6 +119,7 @@ export async function reloadDatabase ()
db = drizzle(sqlite, { schema });
cache = drizzle(cacheSqlite, { schema: cacheSchema });
migrate(db!, { migrationsFolder: appPath("./drizzle") });
sqlite.run("PRAGMA foreign_keys = ON;");
await cache.run(`
CREATE TABLE IF NOT EXISTS item_cache (
key TEXT PRIMARY KEY,

View file

@ -70,13 +70,13 @@ export default new Elysia({ prefix: '/emulatorjs' })
const localGame = await getLocalGame(source, id);
if (!localGame) return status("Not Found");
const changedSaveFiles: SaveFileChange[] = [];
const changedSaveFiles: Record<string, SaveFileChange> = {};
if (save)
{
const savesPath = path.join(config.get('downloadPath'), 'saves', "EMULATORJS");
const saveFile = path.join(savesPath, save.name);
await Bun.write(saveFile, save);
changedSaveFiles.push({ subPath: save.name, cwd: savesPath });
changedSaveFiles.gameflow = { subPath: save.name, cwd: savesPath, shared: false };
events.emit('notification', { message: "Save Backed Up", type: "success", icon: "save" });
}
await updateLocalLastPlayed(localGame.id);
@ -85,7 +85,7 @@ export default new Elysia({ prefix: '/emulatorjs' })
id,
saveFolderPath: path.join(config.get('downloadPath'), "saves", "EMULATORJS"),
gameInfo: { platformSlug: localGame?.platform.slug },
changedSaveFiles: changedSaveFiles,
changedSaveFiles: [],
validChangedSaveFiles: changedSaveFiles,
command: {
id: "EMULATORJS",

View file

@ -1,26 +1,26 @@
import Elysia, { status } from "elysia";
import { config, db, emulatorsDb, plugins, taskQueue } from "../app";
import { and, eq, getTableColumns, ilike, inArray, like, sql } from "drizzle-orm";
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 { InstallJob } from "../jobs/install-job";
import path from "node:path";
import { convertLocalToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, validateGameSource } from "./services/statusService";
import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService";
import { errorToResponse } from "elysia/adapter/bun/handler";
import { getEmulatorsForSystem, getRomFilePaths, launchCommand } from "./services/launchGameService";
import { launchCommand } from "./services/launchGameService";
import { getErrorMessage, SeededRandom } from "@/bun/utils";
import { defaultFormats, defaultPlugins } from 'jimp';
import { createJimp } from "@jimp/core";
import webp from "@jimp/wasm-webp";
import * as emulatorSchema from '@schema/emulators';
import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService";
import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
import { host } from "@/bun/utils/host";
import { LaunchGameJob } from "../jobs/launch-game-job";
import { cores } from "../emulatorjs/emulatorjs";
import { findEmulatorPluginIntegration } from "../store/services/emulatorsService";
// A custom jimp that supports webp
const Jimp = createJimp({
@ -58,8 +58,15 @@ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width,
if (typeof img === 'string')
{
const rommFetch = await fetch(img);
return rommFetch;
const res = await fetch(img);
return new Response(res.body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("Content-Type") ?? "image/jpeg",
"Cache-Control": "public, max-age=86400",
},
});
}
return img;
@ -135,190 +142,144 @@ export default new Elysia()
.get('/games', async ({ query, set }) =>
{
const games: FrontEndGameType[] = [];
const filterSets: FrontEndFilterSets = {
age_ratings: new Set(),
player_counts: new Set(),
languages: new Set(),
companies: new Set(),
genres: new Set()
};
if (query.source === 'store')
const where: any[] = [];
let localGamesSet: Set<string> | undefined;
if (query.platform_slug)
{
const shuffledGames = await getShuffledStoreGames();
set.headers['x-max-items'] = shuffledGames.length;
const storeGames = await Promise.all(shuffledGames.filter(g =>
where.push(eq(schema.platforms.slug, query.platform_slug));
} else if (query.platform_id && query.platform_source === 'local')
{
where.push(eq(schema.platforms.id, query.platform_id));
}
else if (query.platform_id && query.platform_source)
{
const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: query.platform_id ? String(query.platform_id) : undefined });
if (platform)
{
if (query.search)
return path.basename(g.path).toLocaleLowerCase().includes(query.search.toLocaleLowerCase());
return true;
})
.slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length))
.map(async (e) =>
where.push(eq(schema.platforms.slug, platform?.slug));
}
}
if (query.search)
{
where.push(like(schema.games.name, query.search));
}
if (query.source)
{
where.push(eq(schema.games.source, query.source));
}
const ordering: any[] = [];
if (query.orderBy)
{
switch (query.orderBy)
{
case 'added':
ordering.push(desc(schema.games.created_at));
break;
case 'activity':
ordering.push(sql`MAX(COALESCE(${schema.games.created_at}, '1970-01-01'), COALESCE(${schema.games.last_played}, '1970-01-01')) DESC`);
break;
case 'name':
ordering.push(desc(schema.games.name));
break;
case "release":
ordering.push(sql`json_extract(${schema.games.metadata}, '$.first_release_date') DESC NULLS LAST`);
break;
}
}
const localGames = await db.select({
...getTableColumns(schema.games),
platform: schema.platforms,
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
})
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
.groupBy(schema.games.id)
.orderBy(...ordering)
.where(and(...where));
localGamesSet = new Set(
localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`)
.concat(localGames.filter(g => !!g.igdb_id).map(g => `igdb@${g.igdb_id}`))
);
function localGameExistsPredicate (game: { id: FrontEndId, igdb_id?: number | null, ra_id?: number | null; })
{
if (localGamesSet?.has(`${game.id.source}@${game.id.id}`)) return true;
if (game.igdb_id && localGamesSet?.has(`igdb@${game.igdb_id}`)) return true;
if (game.ra_id && localGamesSet?.has(`ra@${game.ra_id}`)) return true;
return false;
}
if (query.collection_id)
{
// Collections are just a remote thing for now.
const remoteGames: FrontEndGameTypeWithIds[] = [];
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
games.push(...remoteGames.map(g =>
{
if (localGameExistsPredicate(g))
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
return convertLocalToFrontend(localGames.find(g => localGameExistsPredicate({ id: { id: g.source_id ?? '', source: g.source ?? '' }, igdb_id: g.igdb_id, ra_id: g.ra_id }))!);
}
else
{
return g;
}
}));
const localGame = await db.select({
...getTableColumns(schema.games),
platform: schema.platforms,
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
})
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
.groupBy(schema.games.id)
.where(and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)));
if (localGame.length > 0) return convertLocalToFrontend(localGame[0]);
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
}));
games.push(...storeGames.filter(g => g !== undefined));
} else
{
const where: any[] = [];
let localGamesSet: Set<string> | undefined;
if (query.platform_slug)
games.push(...localGames.slice(query.offset, query.limit !== undefined ? ((query.offset ?? 0) + query.limit) : undefined).filter(g =>
{
where.push(eq(schema.platforms.slug, query.platform_slug));
} else if (query.platform_id && query.platform_source === 'local')
{
where.push(eq(schema.platforms.id, query.platform_id));
}
else if (query.platform_id && query.platform_source)
{
const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: String(query.platform_id) });
if (platform)
if (query.genres && query.genres.length > 0)
{
where.push(eq(schema.platforms.slug, platform?.slug));
if (!g.metadata) return false;
if (!g.metadata.genres) return false;
if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false;
}
}
if (query.search)
return true;
}).map(g =>
{
where.push(like(schema.games.name, query.search));
}
return convertLocalToFrontend(g);
}));
if (query.source)
if (query.localOnly !== true)
{
where.push(eq(schema.games.source, query.source));
}
const localGames = await db.select({
...getTableColumns(schema.games),
platform: schema.platforms,
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
})
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
.groupBy(schema.games.id)
.where(and(...where));
localGamesSet = new Set(
localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`)
.concat(localGames.filter(g => !!g.igdb_id).map(g => `igdb@${g.igdb_id}`))
);
function localGameExistsPredicate (game: { id: FrontEndId, igdb_id?: number | null, ra_id?: number | null; })
{
if (localGamesSet?.has(`${game.id.source}@${game.id.id}`)) return true;
if (game.igdb_id && localGamesSet?.has(`igdb@${game.igdb_id}`)) return true;
if (game.ra_id && localGamesSet?.has(`ra@${game.ra_id}`)) return true;
return false;
}
if (query.collection_id)
{
// Collections are just a remote thing for now.
const remoteGames: FrontEndGameTypeWithIds[] = [];
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e));
games.push(...remoteGames.map(g =>
const remoteGameSet = new Set<string>();
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
games.push(...remoteGames.filter(g =>
{
if (localGameExistsPredicate(g))
{
return convertLocalToFrontend(localGames.find(g => localGameExistsPredicate({ id: { id: g.source_id ?? '', source: g.source ?? '' }, igdb_id: g.igdb_id, ra_id: g.ra_id }))!);
return false;
}
else
{
return g;
}
}));
} else
{
games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).filter(g =>
{
if (query.genres && query.genres.length > 0)
if (g.igdb_id)
{
if (!g.metadata) return false;
if (!g.metadata.genres) return false;
if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false;
const igdbId = `igdb@${g.igdb_id}`;
if (remoteGameSet.has(igdbId)) return false;
remoteGameSet.add(igdbId);
}
if (g.ra_id)
{
const raId = `ra@${g.ra_id}`;
if (remoteGameSet.has(raId)) return false;
remoteGameSet.add(raId);
}
return true;
}).map(g =>
{
return convertLocalToFrontend(g);
}));
if (query.localOnly !== true)
{
const remoteGames: FrontEndGameTypeWithIds[] = [];
const remoteGameSet = new Set<string>();
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e));
games.push(...remoteGames.filter(g =>
{
if (localGameExistsPredicate(g))
{
return false;
}
if (g.igdb_id)
{
const igdbId = `igdb@${g.igdb_id}`;
if (remoteGameSet.has(igdbId)) return false;
remoteGameSet.add(igdbId);
}
if (g.ra_id)
{
const raId = `ra@${g.ra_id}`;
if (remoteGameSet.has(raId)) return false;
remoteGameSet.add(raId);
}
return true;
}));
} else
{
await plugins.hooks.games.fetchFilters.promise({ filters: filterSets }).catch(e => console.error(e));
}
localGames.map(g =>
{
const metadata: any = g.metadata;
if (metadata.genres && Array.isArray(metadata.genres))
{
metadata.genres.forEach((g: string) => filterSets.genres.add(g));
}
if (metadata.age_ratings && Array.isArray(metadata.age_ratings))
{
metadata.age_ratings.forEach((g: string) => filterSets.age_ratings.add(g));
}
if (metadata.companies && Array.isArray(metadata.companies))
{
metadata.companies.forEach((g: string) => filterSets.companies.add(g));
}
if (metadata.player_count)
{
filterSets.player_counts.add(metadata.player_count);
}
});
}
}
@ -342,7 +303,37 @@ export default new Elysia()
}
const filterLists: FrontEndFilterLists = {
return { games };
}, {
query: GameListFilterSchema,
})
.get('/games/filters', async ({ query: { source } }) =>
{
const filterSets: FrontEndFilterSets = {
age_ratings: new Set(),
player_counts: new Set(),
languages: new Set(),
companies: new Set(),
genres: new Set()
};
let filter: any = undefined;
if (source) filter = eq(schema.games.source, source);
const local_metadata = await db.query.games.findMany({ columns: { metadata: true }, where: filter });
local_metadata.forEach(game =>
{
game.metadata.age_ratings?.forEach(r => filterSets.age_ratings.add(r));
game.metadata.genres?.forEach(r => filterSets.genres.add(r));
game.metadata.companies?.forEach(r => filterSets.companies.add(r));
if (game.metadata.player_count)
filterSets.player_counts.add(game.metadata.player_count);
});
await plugins.hooks.games.fetchFilters.promise({ filters: filterSets, source });
const filters: FrontEndFilterLists = {
age_ratings: Array.from(filterSets.age_ratings),
player_counts: Array.from(filterSets.player_counts),
languages: Array.from(filterSets.languages),
@ -350,34 +341,21 @@ export default new Elysia()
genres: Array.from(filterSets.genres)
};
return { games, filters: filterLists };
return filters;
}, {
query: GameListFilterSchema,
query: z.object({ source: z.string().optional() })
})
.get('/rom/:source/:id', async ({ params: { id, source } }) =>
{
const localGame = await db.query.games.findFirst({
where: getLocalGameMatch(id, source),
columns: { path_fs: true },
with: { platform: { columns: { es_slug: true } } }
});
const filePaths = await plugins.hooks.games.fetchRomFiles.promise({ source, id });
if (!localGame?.path_fs)
if (!filePaths || filePaths.length <= 0)
{
return status("Not Found");
return status("Not Found", "No Valid Roms Found");
}
const downloadPath = config.get('downloadPath');
const path_fs = path.join(downloadPath, localGame.path_fs);
return Bun.file(filePaths[0]);
const filesPaths = await getRomFilePaths(path_fs, localGame.platform.es_slug ?? undefined);
if (filesPaths.length <= 0)
{
throw new Error("No Valid Roms Found");
}
return Bun.file(filesPaths[0]);
}, {
params: z.object({ source: z.string(), id: z.string() })
})
@ -392,17 +370,12 @@ export default new Elysia()
const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) });
if (systemMapping)
{
const emulatorNames = await getEmulatorsForSystem(systemMapping.system);
const emulators = await Promise.all(emulatorNames.map(n => getStoreEmulatorPackage(n).then(e => ({ name: n, data: e }))));
const emulatorNames: string[] = [];
await plugins.hooks.emulators.findEmulatorForSystem.promise({ system: systemMapping.system, emulators: emulatorNames });
sourceData.emulators = await Promise.all(emulators.map(async ({ name, data }) =>
sourceData.emulators = (await Promise.all(emulatorNames.map(async name =>
{
if (data)
{
const systems = await buildStoreFrontendEmulatorSystems(data);
return { ...await convertStoreEmulatorToFrontend(data, 0, systems), store_exists: true };
}
else if (name === 'EMULATORJS')
if (name === 'EMULATORJS')
{
return {
name: 'EMULATORJS',
@ -424,22 +397,34 @@ export default new Elysia()
return system;
})),
gameCount: 0,
integrations: []
} satisfies FrontEndGameTypeDetailedEmulator;
}
else
{
return {
name: name,
logo: "",
systems: [],
gameCount: 0,
validSources: [],
source: 'local',
integrations: []
} satisfies FrontEndGameTypeDetailedEmulator;
}
}));
const foundEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: name });
const execPaths: EmulatorSourceEntryType[] = [];
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: name, sources: execPaths });
const integrations = findEmulatorPluginIntegration(id, execPaths);
if (foundEmulator)
{
foundEmulator.validSources = execPaths;
foundEmulator.integrations = integrations;
return foundEmulator;
}
return {
name: name,
logo: "",
source: 'local',
systems: [],
gameCount: 0,
validSources: execPaths,
integrations: integrations
} satisfies FrontEndGameTypeDetailedEmulator;
}))).filter(e => !!e);
}
}
@ -466,17 +451,18 @@ export default new Elysia()
}, {
params: z.object({ id: z.string(), source: z.string() }),
})
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
.post('/game/:source/:id/install', async ({ params: { id, source }, query: { downloadId } }) =>
{
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
{
return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source));
return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, { downloadId }));
} else
{
return status('Not Implemented');
}
}, {
params: z.object({ id: z.string(), source: z.string() }),
query: z.object({ downloadId: z.string().optional() }),
response: z.any()
})
.delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
@ -501,6 +487,10 @@ export default new Elysia()
{
return fixSource(source, id);
})
.post('/game/:source/:id/update', async ({ params: { id, source } }) =>
{
return update(source, id);
})
.post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) =>
{
const validCommands = await getValidLaunchCommandsForGame(source, id);
@ -559,8 +549,6 @@ export default new Elysia()
const emulator = await getStoreEmulatorPackage(id);
if (!emulator) return status("Not Found");
const systems = await buildStoreFrontendEmulatorSystems(emulator);
const systemsIdSet = new Set(systems.map(s => s.id));
const games: FrontEndGameType[] = [];
@ -587,28 +575,6 @@ export default new Elysia()
await plugins.hooks.games.fetchRecommendedGamesForEmulator.promise({ emulator, systems, games: remoteGames });
games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`)));
const gamesManifest = await getStoreGameManifest();
const storeGames = await Promise.all(gamesManifest
.filter(g => systemsIdSet.has(path.dirname(g.path)))
.map(async (e) =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) });
if (localGame)
{
return undefined;
}
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
}));
games.push(...storeGames.filter(g => g !== undefined).slice(0, 3));
return games;
})
.get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) =>
@ -619,7 +585,7 @@ export default new Elysia()
const sourceCompaniesSet = new Set(sourceData.metadata.companies);
const sourceGenresSet = new Set(sourceData.metadata.genres);
const esSystem = sourceData.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug)), columns: { system: true } }) : undefined;
const games: (FrontEndGameType & { metadata?: any; })[] = [];
@ -632,35 +598,7 @@ export default new Elysia()
games.push(...localGames.map(g => convertLocalToFrontend(g)));
const shuffledGames = await getShuffledStoreGames();
const storeGames = await Promise.all(shuffledGames
.filter(g =>
{
const system = path.dirname(g.path);
const id = path.basename(g.path, path.extname(g.path));
if (localGamesSourceSet.has(`store@${system}@${id}`))
return false;
if (esSystem)
{
if (path.dirname(g.path) === esSystem.system) return true;
}
return false;
})
.map(async (e) =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
}));
if (storeGames)
{
games.push(...storeGames.slice(0, 3));
}
const remoteGames: (FrontEndGameType & { metadata?: any; })[] = [];
plugins.hooks.games.fetchRecommendedGamesForGame.promise({

View file

@ -1,6 +1,6 @@
import Elysia, { status } from "elysia";
import z from "zod";
import { and, count, eq, getTableColumns, not } from "drizzle-orm";
import { and, count, eq, getTableColumns, not, notExists } from "drizzle-orm";
import { db, plugins } from "../app";
import * as schema from "@schema/app";
@ -93,7 +93,8 @@ export default new Elysia()
if (!remotePlatform) return status("Not Found");
return remotePlatform;
}
}, { params: z.object({ source: z.string(), id: z.string() }) }).get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
}, { params: z.object({ source: z.string(), id: z.string() }) })
.get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
{
set.headers["cross-origin-resource-policy"] = 'cross-origin';
@ -112,4 +113,35 @@ export default new Elysia()
set.headers["content-type"] = coverBlob.cover_type;
}
return status(200, coverBlob.cover);
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) });
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) })
.post('/platform/local/:id/update', async ({ params: { id } }) =>
{
const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, Number(id)) });
if (!localPlatform) return status("Not Found");
const platformLookup = await plugins.hooks.games.platformLookup.promise({
slug: localPlatform.slug
});
let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${localPlatform.slug}.svg`);
if (!platformCover.ok && platformLookup?.url_logo)
{
platformCover = await fetch(platformLookup.url_logo);
}
await db.update(schema.platforms).set({
name: platformLookup?.name,
cover: Buffer.from(await platformCover.arrayBuffer()),
cover_type: platformCover.headers.get('content-type'),
}).where(eq(schema.platforms.id, localPlatform.id));
})
.delete('/platform/local/:id', async ({ params: { id } }) =>
{
const deleted = await db.delete(schema.platforms).where(and(eq(schema.platforms.id, Number(id)),
notExists(
db
.select()
.from(schema.games)
.where(eq(schema.games.platform_id, Number(id)))
))).returning();
if (deleted.length <= 0) return status("Not Found");
});

View file

@ -1,19 +1,12 @@
import path from 'node:path';
import { Glob, which } from 'bun';
import { Glob } from 'bun';
import fs from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import * as schema from '@schema/emulators';
import { eq } from 'drizzle-orm';
import { config, customEmulators, emulatorsDb, taskQueue } from '../../app';
import os from 'node:os';
import { cores } from '../../emulatorjs/emulatorjs';
import { existsSync } from 'node:fs';
import { config, taskQueue } from '../../app';
import { LaunchGameJob } from '../../jobs/launch-game-job';
import { getStoreEmulatorPackage } from '../../store/services/gamesService';
import { getOrCachedScoopPackage } from '../../store/services/emulatorsService';
export const varRegex = /%([^%]+)%/g;
export const assignRegex = /(%\w+%)=(\S+) /g;
export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string)
{
if (taskQueue.hasActiveOfType(LaunchGameJob))
@ -24,285 +17,6 @@ export async function launchCommand (validCommand: CommandEntry, id: FrontEndId,
taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId));
}
/**
* Get the emulators related to the given system
* @param systemSlug the ES-DE slug for the system
*/
export async function getEmulatorsForSystem (systemSlug: string)
{
const system = await emulatorsDb.query.systems.findFirst({
with: { commands: true },
where: eq(schema.systems.name, systemSlug)
});
if (!system)
{
throw new Error(`Could not find system '${systemSlug}'`);
}
const emulators = new Set<string>();
await Promise.all(system.commands.map(async (command, index) =>
{
let cmd = command.command;
const matches = Array.from(cmd.matchAll(varRegex));
matches.forEach(([value]) =>
{
if (value.startsWith("%EMULATOR_"))
{
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
emulators.add(emulatorName);
return;
}
});
}));
if (cores[systemSlug])
{
emulators.add('EMULATORJS');
}
return Array.from(emulators);
}
export async function getRomFilePaths (gamePath: string, systemSlug?: string)
{
if (!existsSync(gamePath))
{
throw new Error(`Provided rom path is missing: '${gamePath}'`);
}
const gamePathStat = await fs.stat(gamePath);
const validFiles: string[] = [];
if (gamePathStat.isDirectory())
{
if (!systemSlug) throw new Error("Needs system to find valid file");
const system = await emulatorsDb.query.systems.findFirst({
with: { commands: true },
where: eq(schema.systems.name, systemSlug)
});
if (!system)
{
throw new Error(`Could not find system '${systemSlug}'`);
}
const extensionList = system.extension.join(',');
for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`)))
{
validFiles.push(file);
}
if (validFiles.length <= 0)
{
throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`);
}
} else if (systemSlug)
{
const system = await emulatorsDb.query.systems.findFirst({
with: { commands: true },
where: eq(schema.systems.name, systemSlug)
});
if (!system)
{
throw new Error(`Could not find system '${systemSlug}'`);
}
if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase())))
{
validFiles.push(gamePath);
}
else
{
const extensionList = system.extension.join(',');
throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`);
}
} else
{
validFiles.push(gamePath);
}
return validFiles;
}
/**
*
* @param data Uses es-de system slug
* @returns
*/
export async function getValidLaunchCommands (data: {
systemSlug: string;
gamePath: string;
}): Promise<CommandEntry[]>
{
const system = await emulatorsDb.query.systems.findFirst({
with: { commands: true },
where: eq(schema.systems.name, data.systemSlug)
});
if (!system)
{
throw new Error(`Could not find system '${data.systemSlug}'`);
}
if (!system.extension || system.extension.length <= 0)
{
throw new Error(`No extensions listed for system '${data.systemSlug}'`);
}
const downloadPath = config.get('downloadPath');
const gamePath = path.join(downloadPath, data.gamePath);
const validFiles: string[] = await getRomFilePaths(gamePath, data.systemSlug);
function escapeWindowsArg (arg: string): string
{
if (process.platform === 'win32')
{
return `"${arg
.replace(/(\\*)"/g, '$1$1\\"') // escape quotes
.replace(/(\\*)$/, '$1$1') // escape trailing backslashes
}"`;
} else
{
if (arg.includes(' '))
{
return `"${arg}"`;
} else
{
return arg;
}
}
}
const formattedCommands = await Promise.all(system.commands
.filter(c => !c.command.includes(`%ENABLESHORTCUTS%`))
.map(async (command, index) =>
{
const label = command.label;
let cmd = command.command;
let emulator: string | undefined = undefined;
let rom = validFiles[0];
if (cmd.includes('%ESCAPESPECIALS%'))
rom = rom.replace(/[&()^=;,]/g, '');
const staticVars: Record<string, string> = {
'%ROM%': escapeWindowsArg(rom),
'%ROMRAW%': validFiles[0],
'%ROMRAWWIN%': escapeWindowsArg(validFiles[0].replaceAll('/', '\\')),
'%ESPATH%': escapeWindowsArg(path.dirname(Bun.main)),
'%ROMPATH%': escapeWindowsArg(gamePath),
'%BASENAME%': escapeWindowsArg(path.basename(validFiles[0], path.extname(validFiles[0]))),
'%FILENAME%': escapeWindowsArg(path.basename(validFiles[0])),
'%ESCAPESPECIALS%': "",
'%HIDEWINDOW%': ""
};
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (_, injectFile: string) =>
{
try
{
const resolvedInjectFile = injectFile.replace(varRegex, (a) =>
{
return staticVars[a] ?? a;
});
if (existsSync(resolvedInjectFile))
{
const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' });
return rawContents.split('\n').map(v => v.replace('\r', '')).join(' ');
}
return '';
} catch (error)
{
return '';
}
});
const matches = Array.from(cmd.matchAll(varRegex));
const varList = await Promise.all(matches.map(async ([value]) =>
{
if (value.startsWith("%EMULATOR_"))
{
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
let execs = await findExecsByName(emulatorName);
let validExec = execs.find(e => e.exists);
emulator = emulatorName;
return [
[value, validExec ? validExec.binPath : undefined] as [string, string | undefined],
[`%EMUSOURCE%`, validExec?.type] as [string, string | undefined],
['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined],
['%EMUDIRRAW%', validExec?.rootPath ?? (validExec ? path.dirname(validExec.binPath) : undefined)] as [string, string | undefined]
];
}
const key = value[0].substring(1, value.length - 1);
return [[value, process.env[key]] as [string, string | undefined]];
}));
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
let startDir: string | undefined = undefined;
if ('%STARTDIR%' in vars)
{
delete vars['%STARTDIR%'];
cmd = cmd.replace(assignRegex, (match, p1, p2) =>
{
if (p1 === '%STARTDIR%')
{
startDir = varRegex.test(p2) ? staticVars[p2] : p2;
}
return "";
});
}
// missing variable
const invalid = Object.entries(vars).find(c => c[1] === undefined);
const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
return {
id: index,
label: label ?? undefined,
command: formattedCommand,
startDir,
valid: !invalid, emulator,
emulatorSource: vars['%EMUSOURCE%'] as any,
metadata: {
romPath: validFiles[0],
emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1],
emulatorDir: vars['%EMUDIRRAW%']
}
} satisfies CommandEntry;
}));
return formattedCommands.filter(c => !!c);
}
export async function findExecsByName (emulatorName: string)
{
const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) });
if (!emulator)
{
throw new Error(`Could not find emulator ${emulatorName}`);
}
return findExecs(emulatorName, emulator);
}
export async function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): Promise<EmulatorSourceEntryType | undefined>
{
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
@ -355,112 +69,3 @@ export async function findStoreEmulatorExec (id: string, emulator?: { systempath
return undefined;
}
export async function findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
{
const execs: EmulatorSourceEntryType[] = [];
if (customEmulators.has(id))
{
execs.push({ binPath: customEmulators.get(id), type: 'custom', exists: await fs.exists(customEmulators.get(id)) });
}
if (emulator && emulator.systempath.length > 0)
{
const storePath = await findStoreEmulatorExec(id, emulator);
if (storePath) execs.push(storePath);
}
if (emulator && os.platform() === 'win32')
{
const regValues = emulator.winregistrypath;
if (regValues.length > 0)
{
for (const node of regValues)
{
const registryValue = await readRegistryValue(node);
if (registryValue)
{
execs.push({ binPath: registryValue, type: 'registry', exists: true });
}
}
}
}
if (emulator && emulator.systempath.length > 0)
{
const systemPath = await resolveSystemPath(emulator.systempath);
if (systemPath)
{
execs.push({ binPath: systemPath, type: 'system', exists: true });
}
}
if (emulator && emulator.staticpath.length > 0)
{
const staticPath = await resolveStaticPath(emulator.staticpath);
if (staticPath)
{
execs.push({ binPath: staticPath, type: 'static', exists: true });
}
}
return execs;
}
async function readRegistryValue (text: string)
{
const params = text.split('|');
const key = path.dirname(params[0]);
const value = path.basename(params[0]);
const bin = params.length > 1 ? params[1] : undefined;
const proc = Bun.spawn({
cmd: ["reg", "QUERY", key, "/v", value],
stdout: "pipe",
stderr: "pipe",
});
const output = await new Response(proc.stdout).text();
await proc.exited;
if (!output.includes(value)) return null;
const lines = output.split("\n");
for (const line of lines)
{
if (line.includes(value))
{
const parts = line.trim().split(/\s{4,}/);
return bin ? path.join(parts[2], bin) : parts[2]; // registry value
}
}
return null;
}
async function resolveStaticPath (entries: string[])
{
for (const entry of entries)
{
const resolved = entry.replace("~", os.homedir());
if (await fs.exists(resolved))
{
return resolved;
}
}
return null;
}
async function resolveSystemPath (entries: string[])
{
for (const entry of entries)
{
try
{
const found = which(entry);
return found;
} catch { }
}
return null;
}

View file

@ -1,21 +1,17 @@
import { RPC_URL, } from "@shared/constants";
import { config, db, emulatorsDb, plugins, taskQueue } from "../../app";
import { findExecs, getValidLaunchCommands } from "./launchGameService";
import * as emulatorSchema from '@schema/emulators';
import { and, eq } from "drizzle-orm";
import { config, db, plugins, taskQueue } from "../../app";
import { eq } from "drizzle-orm";
import { getErrorMessage } from "@/bun/utils";
import { checkFiles, getLocalGameMatch } from "./utils";
import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils";
import fs from 'node:fs/promises';
import { getStoreGameFromId } from "../../store/services/gamesService";
import { cores } from "../../emulatorjs/emulatorjs";
import { host } from "@/bun/utils/host";
import Elysia from "elysia";
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 { RPC_URL } from "@/shared/constants";
import { host } from "@/bun/utils/host";
class CommandSearchError extends Error
export class CommandSearchError extends Error
{
constructor(status: GameStatusType, message: string)
{
@ -33,7 +29,8 @@ export async function getLocalGame (source: string, id: string)
source: true,
source_id: true,
igdb_id: true,
ra_id: true
ra_id: true,
main_glob: true
},
where: getLocalGameMatch(id, source),
with: {
@ -44,6 +41,59 @@ export async function getLocalGame (source: string, id: string)
return localGame;
}
export async function update (source: string, id: string)
{
const localGame = await getLocalGame(source, id);
if (!localGame) throw new Error("Could not find Local Game");
if (!localGame.source || !localGame.source_id) throw new Error("Game has not source defined");
const sourceGame = await getSourceGameDetailed(localGame.source, localGame.source_id, { sourceOnly: true });
if (!sourceGame) throw new Error("Could not find source game");
await db.transaction(async (tx) =>
{
await tx.delete(appSchema.screenshots).where(eq(appSchema.screenshots.game_id, localGame.id));
const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)];
if (paths_screenshots.length <= 0 && sourceGame.igdb_id)
{
const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id) });
if (igdbLookup)
{
paths_screenshots.push(...igdbLookup.screenshotUrls);
}
}
// pre-fetch screenshots
const screenshots = await Promise.all(paths_screenshots.map(s => fetch(s)));
if (screenshots.length > 0)
{
await tx.insert(appSchema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
{
const screenshot: typeof appSchema.screenshots.$inferInsert = {
game_id: localGame.id,
content: Buffer.from(await response.arrayBuffer()),
type: response.headers.get('content-type')
};
return screenshot;
})));
}
await tx.update(appSchema.games).set({
metadata: {
age_ratings: sourceGame.metadata.age_ratings,
genres: sourceGame.metadata.genres,
player_count: sourceGame.metadata.player_count ?? undefined,
companies: sourceGame.metadata.companies,
game_modes: sourceGame.metadata.game_modes,
average_rating: sourceGame.metadata.average_rating ?? undefined,
first_release_date: sourceGame.metadata.first_release_date?.getTime() ?? undefined,
}
}).where(eq(appSchema.games.id, localGame.id));
});
}
export async function fixSource (source: string, id: string)
{
const valid = await validateGameSource(source, id);
@ -94,12 +144,10 @@ export async function validateGameSource (source: string, id: string): Promise<{
if (!localGame) return { valid: true };
if (localGame.source && localGame.source_id)
{
// Store should be immutable
if (localGame.source === 'store') return { valid: true, localGame };
const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id });
if (!sourceGame) return { valid: false, reason: "Source Missing", localGame };
if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined))
// Store should be immutable
if (localGame.source !== 'store' && sourceGame.igdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined))
{
return { valid: false, reason: "Metadata Missmatch", localGame };
}
@ -115,79 +163,34 @@ export async function updateLocalLastPlayed (id: number)
export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined>
{
if (source === 'emulator')
{
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, id) });
const allExecs = await findExecs(id, esEmulator);
return {
commands: allExecs.map(exec => ({
command: exec.binPath,
id: exec.type,
emulator: id,
emulatorSource: exec.type,
metadata: {
emulatorBin: exec.binPath,
emulatorDir: exec.rootPath
},
valid: true
} satisfies CommandEntry)),
gameId: { source: "emulator", id: id }
};
}
const localGame = await getLocalGame(source, id);
if (localGame)
{
const rommPlatform = localGame.platform.slug;
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform), eq(emulatorSchema.systemMappings.source, 'romm')) });
const commands = await plugins.hooks.games.buildLaunchCommands.promise({
source: localGame.source,
sourceId: localGame.source_id,
id: { source: 'local', id: String(localGame.id) },
systemSlug: localGame.platform.slug,
gamePath: localGame.path_fs,
mainGlob: localGame.main_glob,
});
if (esPlatform)
if (commands instanceof Error || !commands) return commands;
const validCommand = commands.find(c => c.valid);
if (validCommand)
{
if (localGame.path_fs)
{
try
{
const commands = await getValidLaunchCommands({ systemSlug: esPlatform.system, gamePath: localGame.path_fs });
if (cores[esPlatform.system])
{
const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`;
commands.push({
id: 'EMULATORJS',
label: "Emulator JS",
command: `core=${cores[esPlatform.system]}&gameUrl=${encodeURIComponent(gameUrl)}`,
valid: true,
emulator: 'EMULATORJS',
metadata: {
romPath: gameUrl
}
});
}
const validCommand = commands.find(c => c.valid);
if (validCommand)
{
return { commands: commands.filter(c => c.valid), gameId: { id: String(localGame.id), source: 'local' }, source: localGame.source ?? source, sourceId: 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(', ')}`);
}
} catch (error)
{
console.error(error);
return new CommandSearchError('error', getErrorMessage(error));
}
} else
{
return new CommandSearchError('error', 'Missing Path');
}
return {
commands: commands.filter(c => c.valid),
gameId: { id: String(localGame.id), source: 'local' },
source: localGame.source ?? source,
sourceId: String(localGame.source_id) ?? id,
};
}
else
{
return new CommandSearchError('error', `Missing Platform ${localGame.platform.slug}`);
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(', ')}`);
}
}
return undefined;
@ -239,6 +242,7 @@ export default function buildStatusResponse ()
}
else
{
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source) });
const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id);
if (validCommand)
{
@ -255,9 +259,9 @@ export default function buildStatusResponse ()
});
}
} else if (ws.data.params.source === 'store')
} else if (!localGame && ws.data.params.source === 'store')
{
const storeGame = await getStoreGameFromId(ws.data.params.id);
/*const storeGame = await getStoreGame(ws.data.params.id);
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
const size = Number(fileResponse.headers.get('content-length'));
const stats = await fs.statfs(config.get('downloadPath'));
@ -268,8 +272,10 @@ export default function buildStatusResponse ()
} else
{
ws.send({ status: 'install', details: 'Install' });
}
} else
}*/
ws.send({ status: 'install', details: 'Install' });
} else if (!localGame)
{
const files = await plugins.hooks.games.fetchDownloads.promise({
source: ws.data.params.source,
@ -302,8 +308,9 @@ export default function buildStatusResponse ()
ws.send({ status: 'install', details: 'Install' });
}
}
} else
{
ws.send({ status: 'error', error: "No Way To Launch" });
}
}
}

View file

@ -4,10 +4,10 @@ import path from "node:path";
import { config, db, emulatorsDb, plugins } from "../../app";
import { and, eq } from "drizzle-orm";
import * as schema from "@schema/app";
import { StoreGameType } from "@shared/constants";
import * as emulatorSchema from "@schema/emulators";
import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService";
import { RPC_URL, StoreGameType } from "@shared/constants";
import { hashFile } from "@/bun/utils";
import { host } from "@/bun/utils/host";
import secrets from "../../secrets";
export async function calculateSize (installPath: string | null)
{
@ -21,6 +21,11 @@ export async function checkInstalled (installPath: string | null)
return fs.exists(path.join(config.get('downloadPath'), installPath));
}
export function getScreenshotLocalGameMatch (id: string, source: string)
{
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id));
}
export function getLocalGameMatch (id: string, source: string)
{
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id));
@ -35,7 +40,7 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & {
platform_display_name: g.platform?.name ?? null,
id: { id: String(g.id), source: 'local' },
updated_at: g.created_at,
path_cover: `/api/romm/game/local/${g.id}/cover`,
path_covers: [`/api/romm/game/local/${g.id}/cover`],
source_id: g.source_id,
source: g.source,
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
@ -67,7 +72,7 @@ export async function convertLocalToFrontendDetailed (g: typeof schema.games.$in
platform_display_name: g.platform?.name ?? "Local",
id: { id: String(g.id), source: 'local' },
updated_at: g.created_at,
path_cover: `/api/romm/game/local/${g.id}/cover`,
path_covers: [`/api/romm/game/local/${g.id}/cover`],
source_id: g.source_id,
source: g.source,
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
@ -82,6 +87,11 @@ export async function convertLocalToFrontendDetailed (g: typeof schema.games.$in
fs_size_bytes: fileSize,
missing: !exists,
local: true,
ra_id: g.ra_id,
version: g.version,
version_source: g.version_source,
version_system: g.version_system,
igdb_id: g.igdb_id,
metadata: {
genres: g.metadata.genres ?? [],
companies: g.metadata.companies ?? [],
@ -96,74 +106,6 @@ export async function convertLocalToFrontendDetailed (g: typeof schema.games.$in
return game;
}
export async function convertStoreToFrontend (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameType>
{
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
where: and(eq(emulatorSchema.systemMappings.system, system), eq(emulatorSchema.systemMappings.source, 'romm'))
});
const platformDef = await emulatorsDb.query.systems.findFirst({
where: eq(emulatorSchema.systems.name, system),
columns: { fullname: true }
});
const gameId = `${system}@${id}`;
const game: FrontEndGameType = {
platform_display_name: platformDef?.fullname ?? system,
path_platform_cover: `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`,
id: { source: 'store', id: gameId },
source: null,
source_id: null,
path_fs: null,
path_cover: `/api/romm/image?url=${encodeURIComponent(storeGame.pictures.titlescreens?.[0])}`,
last_played: null,
updated_at: new Date(),
slug: null,
name: storeGame.title,
platform_id: null,
platform_slug: rommSystem?.sourceSlug ?? system,
paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [],
metadata: {
first_release_date: null
}
};
return game;
}
export async function convertStoreToFrontendDetailed (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameTypeDetailed>
{
let size: number | null = null;
try
{
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
size = Number(fileResponse.headers.get('content-length'));
} catch (error)
{
console.error(error);
}
const detailed: FrontEndGameTypeDetailed = {
...await convertStoreToFrontend(system, id, storeGame),
summary: storeGame.description,
fs_size_bytes: size,
missing: false,
local: false,
metadata: {
genres: storeGame.tags,
companies: [],
game_modes: [],
age_ratings: [],
player_count: "",
average_rating: null,
first_release_date: null
}
};
return detailed;
}
export async function getLocalGameDetailed (match: any)
{
const localGame = await db.query.games.findFirst({
@ -182,7 +124,7 @@ export async function getLocalGameDetailed (match: any)
return undefined;
}
export async function getSourceGameDetailed (source: string, id: string)
export async function getSourceGameDetailed (source: string, id: string, options?: { sourceOnly?: boolean; })
{
if (source === 'local')
{
@ -194,30 +136,13 @@ export async function getSourceGameDetailed (source: string, id: string)
{
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
if (source === 'store')
const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame });
if (localGame && options?.sourceOnly !== true)
{
const gameId = extractStoreGameSourceId(id);
const storeGame = await getStoreGame(gameId.system, gameId.id);
if (!storeGame) return undefined;
const storeFrontendGame = await convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame);
if (localGame)
{
return { ...storeFrontendGame, ...localGame };
}
return storeFrontendGame;
} else
{
const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame });
if (remoteGame)
{
return remoteGame;
} else if (localGame)
{
return localGame;
}
return localGame;
}
return undefined;
return remoteGame;
}
}

View file

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

View file

@ -22,6 +22,8 @@ export class EmulatorHooks
* 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()
{

View file

@ -3,6 +3,14 @@ import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfal
export 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
@ -20,7 +28,7 @@ export class GameHooks
id: FrontEndId;
platformSlug?: string;
};
}], { args: string[], savesPath?: string; } | undefined, { emulator: string; }>(['ctx']);
}], { 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.
@ -37,9 +45,9 @@ export class GameHooks
fetchGames = new AsyncSeriesHook<[ctx: {
query: GameListFilterType;
games: FrontEndGameTypeWithIds[];
filters: FrontEndFilterSets;
}]>(['ctx']);
fetchFilters = new AsyncSeriesHook<[ctx: {
source?: string;
filters: FrontEndFilterSets;
}]>(['ctx']);
fetchGame = new AsyncSeriesBailHook<[ctx: {
@ -58,7 +66,12 @@ export class GameHooks
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; })[];
@ -73,28 +86,39 @@ export class GameHooks
id: string;
}], FrontEndPlatformType | undefined>(['ctx']);
platformLookup = new AsyncSeriesBailHook<[ctx: {
source: string;
id: string;
}], { slug: string; } | undefined>(['ctx']);
source?: string;
id?: string;
slug?: string;
}], {
slug: string;
url_logo?: string | null;
name?: string;
family_name?: string;
} | undefined>(['ctx']);
gameLookup = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], { screenshotUrls: string[]; } | undefined>(['ctx']);
fetchPlatforms = new AsyncSeriesHook<[ctx: {
platforms: FrontEndPlatformType[];
}]>(['ctx']);
prePlay = new AsyncSeriesHook<[ctx: {
source: string,
id: string;
saveFolderPath?: 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;
saveFolderPath?: string;
changedSaveFiles: SaveFileChange[],
validChangedSaveFiles: SaveFileChange[],
saveFolderSlots?: Record<string, { cwd: string; }>;
changedSaveFiles: { subPath: string, cwd: string; }[],
validChangedSaveFiles: Record<string, SaveFileChange>,
command: CommandEntry;
gameInfo: {
platformSlug?: string;

View file

@ -0,0 +1,10 @@
import { EmulatorDownloadInfoType } from "@/shared/constants";
import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable";
export 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

@ -5,7 +5,6 @@ import * as schema from "@schema/app";
import * as emulatorSchema from "@schema/emulators";
import path, { join } from 'node:path';
import { config, db, emulatorsDb, events, plugins } from "../app";
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
import * as igdb from 'ts-igdb-client';
import secrets from "../secrets";
import { simulateProgress } from "@/bun/utils";
@ -13,17 +12,16 @@ import { Downloader } from "@/bun/utils/downloader";
import Seven from 'node-7z';
import z from "zod";
import { checkFiles } from "../games/services/utils";
import { ensureDir, existsSync } from "fs-extra";
import { ensureDir, move } from "fs-extra";
import { path7za } from "7zip-bin";
import slugify from 'slugify';
import StreamZip from 'node-stream-zip';
import { createExtractorFromFile } from 'node-unrar-js';
import { which } from "bun";
interface JobConfig
{
dryRun?: boolean;
dryDownload?: boolean;
downloadId?: string;
}
export type InstallJobStates = 'download' | 'extract';
@ -55,34 +53,7 @@ export class InstallJob implements IJob<never, InstallJobStates>
const downloadPath = config.get('downloadPath');
let info: DownloadInfo | undefined;
switch (this.source)
{
case 'store':
const game = await getStoreGameFromId(this.gameId);
const gameId = extractStoreGameSourceId(this.gameId);
info = {
coverUrl: game.pictures.titlescreens[0],
screenshotUrls: game.pictures.screenshots,
files: [{
url: new URL(game.file),
file_path: `roms/${game.system}`,
file_name: path.basename(decodeURI(game.file)),
size: 0
}],
slug: this.gameId,
source_id: this.gameId,
name: game.title,
summary: game.description,
system_slug: gameId.system,
path_fs: path.join('roms', gameId.system, slugify(game.title)),
extract_path: '.',
};
break;
default:
info = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId });
break;
}
info = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId });
if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
@ -116,9 +87,10 @@ export class InstallJob implements IJob<never, InstallJobStates>
{
let progress = 0;
const progressDelta = 1 / downloadedFiles.length;
const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path);
for (const filePath of downloadedFiles)
{
const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path);
await new Promise(async (resolve, reject) =>
{
let sevenZipPath = process.env.ZIP7_PATH ?? path7za;
@ -176,8 +148,23 @@ export class InstallJob implements IJob<never, InstallJobStates>
throw e;
}
});
progress += progressDelta * 100;
}
// check if 1 root folder we need to get rid of
const contents = await fs.readdir(extractPath);
if (contents.length === 1)
{
const stat = await fs.stat(path.join(extractPath, contents[0]));
if (stat.isDirectory())
{
console.log("Found 1 root folder, using that instead");
const tmpGameFolder = `${extractPath} (1)`;
await move(path.join(extractPath, contents[0]), tmpGameFolder, { overwrite: true });
await move(tmpGameFolder, extractPath, { overwrite: true });
}
}
}
}
@ -221,7 +208,15 @@ export class InstallJob implements IJob<never, InstallJobStates>
if (!existingPlatform)
{
// TODO: use something else than the romm demo as CDN
const platformCover = await fetch(`https://demo.romm.app/assets/platforms/${info.system_slug}.svg`);
const platformLookup = await plugins.hooks.games.platformLookup.promise({
slug: info.platform?.slug ?? info.system_slug
});
let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${info.platform?.slug ?? info.system_slug}.svg`);
if (!platformCover.ok && platformLookup?.url_logo)
{
platformCover = await fetch(platformLookup.url_logo);
}
if (!esPlatform && !info.platform)
{
@ -251,7 +246,7 @@ export class InstallJob implements IJob<never, InstallJobStates>
cover_type: platformCover.headers.get('content-type'),
name: info.platform?.name ?? esPlatform?.system.fullname ?? '',
family_name: info.platform?.family_name,
es_slug: esPlatform?.system.name ?? undefined
es_slug: esPlatform?.system.name ?? undefined,
};
// TODO: add ES slug once I have better way to query ES
@ -278,22 +273,20 @@ export class InstallJob implements IJob<never, InstallJobStates>
name: info.name,
cover,
cover_type: coverResponse.headers.get('content-type'),
metadata: info.metadata
metadata: info.metadata,
main_glob: info.main_glob,
version: info.version,
version_source: info.version_source,
version_system: info.version_system
};
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
if (info.screenshotUrls.length <= 0 && process.env.TWITCH_CLIENT_ID)
if (info.screenshotUrls.length <= 0 && info.igdb_id)
{
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
if (access_token)
{
const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token);
const { data } = await client.request('artworks').pipe(igdb.fields(['game', 'url']), igdb.where('game', '=', info.igdb_id)).execute();
info.screenshotUrls.push(...data.filter(s => s.url).map(s => s.url!));
}
const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(info.igdb_id) });
if (igdbLookup) return igdbLookup.screenshotUrls;
return [];
}
// pre-fetch screenshots

View file

@ -10,6 +10,7 @@ import { IJob } from "../task-queue";
import { LaunchGameJob } from "./launch-game-job";
import { BiosDownloadJob } from "./bios-download-job";
import { InstallJob } from "./install-job";
import ReloadPluginsJob from "./reload-plugins-job";
function registerJob<
const Path extends string,
@ -107,4 +108,5 @@ export const jobs = new Elysia({ prefix: '/api/jobs' })
.use(registerJob(UpdateStoreJob))
.use(registerJob(BiosDownloadJob))
.use(registerJob(InstallJob))
.use(registerJob(ReloadPluginsJob))
.use(registerJob(EmulatorDownloadJob));

View file

@ -19,8 +19,8 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
validCommand: CommandEntry;
gameSource?: string;
gameSourceId?: string;
changedSaveFiles: Map<string, SaveFileChange>;
saveFolderPath?: string;
changedSaveFiles: Map<string, { subPath: string, cwd: string; }>;
saveSlots: SaveSlots = {};
constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string)
{
@ -47,9 +47,8 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
source,
id,
command: this.validCommand,
saveFolderPath: this.saveFolderPath,
changedSaveFiles: Array.from(this.changedSaveFiles.values()),
validChangedSaveFiles: [],
validChangedSaveFiles: {},
gameInfo
}).catch(e => console.error(e));
}
@ -59,7 +58,7 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
return plugins.hooks.games.prePlay.promise({
source: this.gameSource ?? this.gameId.source,
id: this.gameSourceId ?? this.gameId.id,
saveFolderPath: this.saveFolderPath,
saveFolderSlots: this.saveSlots,
command: this.validCommand,
setProgress: setProgress,
gameInfo
@ -125,7 +124,9 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
cwd: this.validCommand.startDir,
signal: context.abortSignal,
env: {
}
...process.env,
...this.validCommand.env
},
});
context.setProgress(0, "playing");
@ -138,14 +139,14 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
spawnGame.on('error', e =>
{
console.error(e);
reject(e);
resolve(1);
});
game = spawnGame;
}
else if (this.validCommand.metadata.emulatorBin)
{
this.saveFolderPath = commandArgs.savesPath;
this.saveSlots = commandArgs.savesPath ?? {};
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug });
@ -154,12 +155,15 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
cwd: this.validCommand.startDir,
signal: context.abortSignal,
env: {
...process.env,
...commandArgs.env
}
});
context.setProgress(0, "playing");
if (commandArgs.savesPath && await fs.exists(commandArgs.savesPath))
// TODO: this isn't really useful, maybe add it later if needed
/*if (commandArgs.savesPath && await fs.exists(commandArgs.savesPath))
{
const savesWatcher = watch(commandArgs.savesPath, { recursive: true, signal: context.abortSignal });
console.log("Starting To Watch", commandArgs.savesPath, "for save file changes");
@ -168,7 +172,7 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
if (typeof filename === 'string')
{
console.log("Save File Changed", filename);
this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath!, shared: true });
this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath! });
}
});
@ -177,7 +181,7 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
savesWatcher.close();
console.log("Closing Save File Watching for", commandArgs.savesPath);
});
}
}*/
bunGame.exited.then(e =>
{

View file

@ -0,0 +1,15 @@
import z from "zod";
import { IJob, JobContext } from "../task-queue";
import { plugins } from "../app";
export default class ReloadPluginsJob implements IJob<never, string>
{
static id = "reload-plugins-job" as const;
static dataSchema = z.never();
group = "reload-plugins";
async start (context: JobContext<IJob<never, string>, never, string>)
{
await plugins.reloadAll(context);
}
}

View file

@ -1,4 +1,4 @@
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import { PluginContextType, PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json';
import path from 'node:path';
import { config } from "@/bun/api/app";
@ -7,7 +7,7 @@ export default class CEMUIntegration implements PluginType
{
emulator = 'CEMU';
load (ctx: PluginContextType)
async load (ctx: PluginLoadingContextType)
{
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{
@ -29,7 +29,7 @@ export default class CEMUIntegration implements PluginType
args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`);
}
return { args, savesPath: savesPath };
return { args, savesPath: { cemu: { cwd: savesPath } } };
});
}
}

View file

@ -5,6 +5,7 @@
"description": "CEMU Emulator Integration",
"main": "./cemu.ts",
"icon": "https://upload.wikimedia.org/wikipedia/commons/9/9e/Cemu_Emulator_Official_Logo.png",
"category": "emulators",
"keywords": [
"integration",
"emulator",

View file

@ -1,6 +1,6 @@
import { config } from "@/bun/api/app";
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
import path from 'node:path';
import desc from './package.json';
import { ensureDir } from "fs-extra";
@ -10,7 +10,7 @@ export default class DOLPHINIntegration implements PluginType
{
emulator = 'DOLPHIN';
load (ctx: PluginContextType)
async load (ctx: PluginLoadingContextType)
{
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{
@ -70,14 +70,18 @@ export default class DOLPHINIntegration implements PluginType
finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder;
}
return { args, savesPath: finalSavesPath };
return { args, savesPath: { dolphin: { cwd: finalSavesPath } } };
});
ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) =>
ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderSlots, command, gameInfo }) =>
{
if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath)
if (command.emulator === this.emulator && saveFolderSlots && command.metadata.romPath)
{
validChangedSaveFiles.push(...await getSavePaths(command.metadata.romPath, saveFolderPath, command.metadata.emulatorDir));
validChangedSaveFiles.dolphin = {
cwd: saveFolderSlots.dolphin.cwd,
subPath: await getSavePaths(command.metadata.romPath, saveFolderSlots.dolphin.cwd, command.metadata.emulatorDir),
shared: false
};
}
});
}

View file

@ -5,6 +5,7 @@
"description": "DOLPHIN Emulator Integration",
"main": "./dolphin.ts",
"icon": "https://upload.wikimedia.org/wikipedia/commons/5/53/Dolphin_Emulator_Logo_Refresh.svg",
"category": "emulators",
"keywords": [
"integration",
"emulator",

View file

@ -128,10 +128,10 @@ async function getGCSavePaths (romPath: string, savesPath: string, location: Dol
const cardPath = join(savesPath, "GC", region);
const glob = new Bun.Glob(`${makerCode}-${gameCode}-*.gci`);
const saves: SaveFileChange[] = [];
const saves: string[] = [];
for await (const file of glob.scan(cardPath))
{
saves.push({ subPath: path.join("GC", region, file), cwd: savesPath, shared: false });
saves.push(path.join("GC", region, file));
}
return saves;
@ -145,7 +145,7 @@ export async function getType (romPath: string, bundledEmulatorDir?: string): Pr
return isGameCube ? "gamecube" : "wii";
}
export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise<SaveFileChange[]>
export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise<string[]>
{
const location = await findDolphinTool(bundledEmulatorDir);
const gameId = await readGameId(romPath, location);
@ -159,6 +159,6 @@ export async function getSavePaths (romPath: string, savesPath: string, bundledE
const folder = Buffer.from(gameId.slice(0, 4), "ascii").toString("hex").toUpperCase();
const rootFolder = join(savesPath, "Wii", "title", "00010000", folder);
const files = await fs.readdir(rootFolder, { recursive: true });
return files.map(f => ({ subPath: path.join("Wii", "title", "00010000", f), cwd: savesPath, shared: false }));
return files.map(f => path.join("Wii", "title", "00010000", f));
}
}

View file

@ -5,6 +5,7 @@
"description": "PCSX2 Emulator Integration",
"main": "./pcsx2.ts",
"icon": "https://upload.wikimedia.org/wikipedia/commons/2/2b/PCSX2_logo4.png",
"category": "emulators",
"keywords": [
"integration",
"emulator",

View file

@ -1,6 +1,6 @@
import { config } from "@/bun/api/app";
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
import defaultConfig from './PCSX2.ini' with { type: 'file' };
import path from 'node:path';
import { ensureDir } from "fs-extra";
@ -11,7 +11,7 @@ export default class PCSX2Integration implements PluginType
{
emulator = "PCSX2";
load (ctx: PluginContextType)
async load (ctx: PluginLoadingContextType)
{
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{
@ -103,7 +103,7 @@ export default class PCSX2Integration implements PluginType
await Bun.write(configPath, ini.stringify(configFile));
return { args, savesPath: paths.MEMORY_CARDS_PATH };
return { args, savesPath: { pcsx2: { cwd: paths.MEMORY_CARDS_PATH } } };
}
return { args };

View file

@ -5,6 +5,7 @@
"description": "PPSSPP Emulator Integration",
"main": "./ppsspp.ts",
"icon": "https://www.ppsspp.org/static/img/platform/ppsspp-icon.png",
"category": "emulators",
"keywords": [
"integration",
"emulator",

View file

@ -1,4 +1,4 @@
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json';
import { config } from "@/bun/api/app";
import configFilePathWin32 from './win32/ppsspp.ini' with { type: 'file' };
@ -15,7 +15,7 @@ export default class PPSSPPIntegration implements PluginType
{
emulator = "PPSSPP";
load (ctx: PluginContextType)
async load (ctx: PluginLoadingContextType)
{
ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
{
@ -114,7 +114,7 @@ export default class PPSSPPIntegration implements PluginType
await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {}));
}
return { args, savesPath: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") };
return { args, savesPath: { ppsspp: { cwd: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") } } };
}
return { args };

View file

@ -5,6 +5,7 @@
"description": "XEMU Emulator Integration",
"main": "./xemu.ts",
"icon": "https://upload.wikimedia.org/wikipedia/commons/8/8e/Xemu_logo_green.svg",
"category": "emulators",
"keywords": [
"integration",
"emulator",

View file

@ -1,4 +1,4 @@
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json';
import { config } from "@/bun/api/app";
import path from "node:path";
@ -10,7 +10,7 @@ export default class XEMUIntegration implements PluginType
{
emulator = 'XEMU';
load (ctx: PluginContextType)
async load (ctx: PluginLoadingContextType)
{
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{

View file

@ -5,6 +5,7 @@
"description": "XENIA Emulator Integration",
"main": "./xenia.ts",
"icon": "https://xenia.jp/images/logo-256x256.png",
"category": "emulators",
"keywords": [
"integration",
"emulator",

View file

@ -1,4 +1,4 @@
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json';
import { GameflowHooks } from "@/bun/api/hooks/app";
import { config } from "@/bun/api/app";
@ -68,9 +68,10 @@ export default class XENIAIntegration implements PluginType
if (ctx.autoValidCommand.metadata.romPath)
{
finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath);
return { args, savesPath: { xenia: { cwd: finalSavesPath } } };
}
return { args, savesPath: finalSavesPath };
return { args };
};
return { args };
@ -82,7 +83,7 @@ export default class XENIAIntegration implements PluginType
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves"] };
}
load (ctx: PluginContextType)
async load (ctx: PluginLoadingContextType)
{
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, this.handleEmulatorLaunchSupport);
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulatorEdge }, this.handleEmulatorLaunchSupport);
@ -95,7 +96,7 @@ export default class XENIAIntegration implements PluginType
if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath)
{
const files = await fs.readdir(saveFolderPath, { recursive: true });
validChangedSaveFiles.push(...files.map(f => ({ subPath: f, cwd: saveFolderPath, shared: false } satisfies SaveFileChange)));
validChangedSaveFiles.gameflow = { cwd: saveFolderPath, subPath: files, shared: false };
}
});
}

View file

@ -0,0 +1,520 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json';
import { config, customEmulators, db, emulatorsDb } from "@/bun/api/app";
import * as emulatorSchema from '@schema/emulators';
import { and, eq } from "drizzle-orm";
import { cores } from "@/bun/api/emulatorjs/emulatorjs";
import { RPC_URL } from "@/shared/constants";
import { host } from "@/bun/utils/host";
import path from 'node:path';
import { existsSync, readFileSync } from "node:fs";
import fs from "node:fs/promises";
import { findStoreEmulatorExec } from "@/bun/api/games/services/launchGameService";
import { which } from "bun";
import os from 'node:os';
import { getLocalGameMatch } from "@/bun/api/games/services/utils";
export default class IgdbIntegration implements PluginType
{
varRegex = /%([^%]+)%/g;
assignRegex = /(%\w+%)=(\S+) /g;
/**
* Get the emulators related to the given system
* @param systemSlug the ES-DE slug for the system
*/
async getEmulatorsForSystem (systemSlug: string)
{
const system = await emulatorsDb.query.systems.findFirst({
with: { commands: true },
where: eq(emulatorSchema.systems.name, systemSlug)
});
if (!system)
{
throw new Error(`Could not find system '${systemSlug}'`);
}
const emulators = new Set<string>();
await Promise.all(system.commands.map(async (command, index) =>
{
let cmd = command.command;
const matches = Array.from(cmd.matchAll(this.varRegex));
matches.forEach(([value]) =>
{
if (value.startsWith("%EMULATOR_"))
{
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
emulators.add(emulatorName);
return;
}
});
}));
if (cores[systemSlug])
{
emulators.add('EMULATORJS');
}
return Array.from(emulators);
}
async findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
{
const execs: EmulatorSourceEntryType[] = [];
if (customEmulators.has(id))
{
execs.push({ binPath: customEmulators.get(id), type: 'custom', exists: await fs.exists(customEmulators.get(id)) });
}
if (emulator && emulator.systempath.length > 0)
{
const storePath = await findStoreEmulatorExec(id, emulator);
if (storePath) execs.push(storePath);
}
if (emulator && process.platform === 'win32')
{
const regValues = emulator.winregistrypath;
if (regValues.length > 0)
{
for (const node of regValues)
{
const registryValue = await this.readRegistryValue(node);
if (registryValue)
{
execs.push({ binPath: registryValue, type: 'registry', exists: true });
}
}
}
}
if (emulator && emulator.systempath.length > 0)
{
const systemPath = await this.resolveSystemPath(emulator.systempath);
if (systemPath)
{
execs.push({ binPath: systemPath, type: 'system', exists: true });
}
}
if (emulator && emulator.staticpath.length > 0)
{
const staticPath = await this.resolveStaticPath(emulator.staticpath);
if (staticPath)
{
execs.push({ binPath: staticPath, type: 'static', exists: true });
}
}
return execs;
}
async readRegistryValue (text: string)
{
const params = text.split('|');
const key = path.dirname(params[0]);
const value = path.basename(params[0]);
const bin = params.length > 1 ? params[1] : undefined;
const proc = Bun.spawn({
cmd: ["reg", "QUERY", key, "/v", value],
stdout: "pipe",
stderr: "pipe",
});
const output = await new Response(proc.stdout).text();
await proc.exited;
if (!output.includes(value)) return null;
const lines = output.split("\n");
for (const line of lines)
{
if (line.includes(value))
{
const parts = line.trim().split(/\s{4,}/);
return bin ? path.join(parts[2], bin) : parts[2]; // registry value
}
}
return null;
}
async resolveStaticPath (entries: string[])
{
for (const entry of entries)
{
const resolved = entry.replace("~", os.homedir());
if (await fs.exists(resolved))
{
return resolved;
}
}
return null;
}
async resolveSystemPath (entries: string[])
{
for (const entry of entries)
{
try
{
const found = which(entry);
return found;
} catch { }
}
return null;
}
async findExecsByName (emulatorName: string)
{
const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulatorName) });
if (!emulator)
{
throw new Error(`Could not find emulator ${emulatorName}`);
}
return this.findExecs(emulatorName, emulator);
}
async getRomFilePaths (gamePath: string, config: { systemSlug?: string; mainGlob?: string | null; })
{
if (!existsSync(gamePath))
{
throw new Error(`Provided rom path is missing: '${gamePath}'`);
}
const gamePathStat = await fs.stat(gamePath);
const validFiles: string[] = [];
if (gamePathStat.isDirectory())
{
if (config.mainGlob)
{
const files = await Array.fromAsync(fs.glob(config.mainGlob, { cwd: gamePath }));
if (files.length > 1)
{
throw new Error("Found multiple rom files");
} else if (files.length === 0)
{
throw new Error("Found no valid roms");
}
validFiles.push(path.join(gamePath, files[0]));
} else
{
if (!config.systemSlug) throw new Error("Needs system to find valid file");
const system = await emulatorsDb.query.systems.findFirst({
with: { commands: true },
where: eq(emulatorSchema.systems.name, config.systemSlug)
});
if (!system)
{
throw new Error(`Could not find system '${config.systemSlug}'`);
}
const extensionList = system.extension.join(',');
for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`)))
{
validFiles.push(file);
}
if (validFiles.length <= 0)
{
throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`);
}
}
} else if (config.systemSlug)
{
const system = await emulatorsDb.query.systems.findFirst({
with: { commands: true },
where: eq(emulatorSchema.systems.name, config.systemSlug)
});
if (!system)
{
throw new Error(`Could not find system '${config.systemSlug}'`);
}
if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase())))
{
validFiles.push(gamePath);
}
else
{
const extensionList = system.extension.join(',');
throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`);
}
} else
{
validFiles.push(gamePath);
}
return validFiles;
}
/**
*
* @param data Uses es-de system slug
* @param mainGlob The main file glob supported pattern to search for if game path is a directory
* @returns
*/
async getValidLaunchCommands (data: {
systemSlug: string;
gamePath: string;
mainGlob?: string | null;
}): Promise<CommandEntry[]>
{
const system = await emulatorsDb.query.systems.findFirst({
with: { commands: true },
where: eq(emulatorSchema.systems.name, data.systemSlug)
});
if (!system)
{
throw new Error(`Could not find system '${data.systemSlug}'`);
}
if (!system.extension || system.extension.length <= 0)
{
throw new Error(`No extensions listed for system '${data.systemSlug}'`);
}
const downloadPath = config.get('downloadPath');
const gamePath = path.join(downloadPath, data.gamePath);
const validFiles: string[] = await this.getRomFilePaths(gamePath, { systemSlug: data.systemSlug, mainGlob: data.mainGlob });
function escapeWindowsArg (arg: string): string
{
if (process.platform === 'win32')
{
return `"${arg
.replace(/(\\*)"/g, '$1$1\\"') // escape quotes
.replace(/(\\*)$/, '$1$1') // escape trailing backslashes
}"`;
} else
{
if (arg.includes(' '))
{
return `"${arg}"`;
} else
{
return arg;
}
}
}
const formattedCommands = await Promise.all(system.commands
.filter(c => !c.command.includes(`%ENABLESHORTCUTS%`))
.map(async (command, index) =>
{
const label = command.label;
let cmd = command.command;
let emulator: string | undefined = undefined;
let rom = validFiles[0];
if (cmd.includes('%ESCAPESPECIALS%'))
rom = rom.replace(/[&()^=;,]/g, '');
const staticVars: Record<string, string> = {
'%ROM%': escapeWindowsArg(rom),
'%ROMRAW%': validFiles[0],
'%ROMRAWWIN%': escapeWindowsArg(validFiles[0].replaceAll('/', '\\')),
'%ESPATH%': escapeWindowsArg(path.dirname(Bun.main)),
'%ROMPATH%': escapeWindowsArg(gamePath),
'%BASENAME%': escapeWindowsArg(path.basename(validFiles[0], path.extname(validFiles[0]))),
'%FILENAME%': escapeWindowsArg(path.basename(validFiles[0])),
'%ESCAPESPECIALS%': "",
'%HIDEWINDOW%': ""
};
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (_, injectFile: string) =>
{
try
{
const resolvedInjectFile = injectFile.replace(this.varRegex, (a) =>
{
return staticVars[a] ?? a;
});
if (existsSync(resolvedInjectFile))
{
const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' });
return rawContents.split('\n').map(v => v.replace('\r', '')).join(' ');
}
return '';
} catch (error)
{
return '';
}
});
const matches = Array.from(cmd.matchAll(this.varRegex));
const varList = await Promise.all(matches.map(async ([value]) =>
{
if (value.startsWith("%EMULATOR_"))
{
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
let execs = await this.findExecsByName(emulatorName);
let validExec = execs.find(e => e.exists);
emulator = emulatorName;
return [
[value, validExec ? validExec.binPath : undefined] as [string, string | undefined],
[`%EMUSOURCE%`, validExec?.type] as [string, string | undefined],
['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined],
['%EMUDIRRAW%', validExec?.rootPath ?? (validExec ? path.dirname(validExec.binPath) : undefined)] as [string, string | undefined]
];
}
const key = value[0].substring(1, value.length - 1);
return [[value, process.env[key]] as [string, string | undefined]];
}));
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
let startDir: string | undefined = undefined;
if ('%STARTDIR%' in vars)
{
delete vars['%STARTDIR%'];
cmd = cmd.replace(this.assignRegex, (match, p1, p2) =>
{
if (p1 === '%STARTDIR%')
{
startDir = this.varRegex.test(p2) ? staticVars[p2] : p2;
}
return "";
});
}
// missing variable
const invalid = Object.entries(vars).find(c => c[1] === undefined);
const formattedCommand = cmd.replace(this.varRegex, (s) => vars[s] ?? '').trim();
return {
id: index,
label: label ?? undefined,
command: formattedCommand,
startDir,
valid: !invalid, emulator,
emulatorSource: vars['%EMUSOURCE%'] as any,
metadata: {
romPath: validFiles[0],
emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1],
emulatorDir: vars['%EMUDIRRAW%']
}
} satisfies CommandEntry;
}));
return formattedCommands.filter(c => !!c);
}
async load (ctx: PluginLoadingContextType)
{
ctx.hooks.emulators.findEmulatorSource.tapPromise(desc.name, async ({ sources, emulator }) =>
{
sources.push(...await this.findExecsByName(emulator));
});
ctx.hooks.emulators.findEmulatorForSystem.tapPromise(desc.name, async ({ system, emulators }) =>
{
emulators.push(...await this.getEmulatorsForSystem(system));
});
ctx.hooks.games.fetchRomFiles.tapPromise(desc.name, async ({ source, id }) =>
{
const localGame = await db.query.games.findFirst({
where: getLocalGameMatch(id, source),
columns: { path_fs: true, main_glob: true },
with: { platform: { columns: { es_slug: true } } }
});
if (!localGame?.path_fs)
{
return;
}
const downloadPath = config.get('downloadPath');
const path_fs = path.join(downloadPath, localGame.path_fs);
return this.getRomFilePaths(path_fs, { systemSlug: localGame.platform.es_slug ?? undefined, mainGlob: localGame.main_glob });
});
ctx.hooks.games.buildLaunchCommands.tapPromise(desc.name, async ({ systemSlug, source, id, gamePath, mainGlob }) =>
{
if (source === 'emulator')
{
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, id.id) });
const allExecs = await this.findExecs(id.id, esEmulator);
return allExecs.map(exec => ({
command: exec.binPath,
id: exec.type,
emulator: id.id,
emulatorSource: exec.type,
metadata: {
emulatorBin: exec.binPath,
emulatorDir: exec.rootPath
},
valid: true
} satisfies CommandEntry));
}
const rommPlatform = systemSlug;
let esSystem: string | undefined = undefined;
const systemMapping = await emulatorsDb.query.systemMappings.findFirst({
where: and(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform), eq(emulatorSchema.systemMappings.source, 'romm'))
});
if (systemMapping) esSystem = systemMapping.system;
if (!esSystem)
{
const system = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.systems.name, systemSlug), columns: { name: true } });
if (system) esSystem = system.name;
}
if (esSystem && gamePath)
{
try
{
const commands = await this.getValidLaunchCommands({ systemSlug: esSystem, gamePath, mainGlob });
if (cores[esSystem])
{
const gameUrl = `${RPC_URL(host)}/api/romm/rom/${id.source}/${id.id}`;
commands.push({
id: 'EMULATORJS',
label: "Emulator JS",
command: `core=${cores[esSystem]}&gameUrl=${encodeURIComponent(gameUrl)}`,
valid: true,
emulator: 'EMULATORJS',
metadata: {
romPath: gameUrl
}
});
}
return commands;
} catch (error)
{
console.error(error);
if (error instanceof Error) return error;
}
}
});
}
}

View file

@ -0,0 +1,13 @@
{
"name": "com.simeonradivoev.gameflow.es",
"displayName": "ES-DE Launcher",
"version": "0.0.1",
"description": "ES-DE Launch Configurations. Used as fallback",
"main": "./es-de.ts",
"icon": "https://impro.usercontent.one/appid/oneComWsb/domain/es-de.org/media/es-de.org/onewebmedia/ES-DE_logo.png",
"category": "launchers",
"keywords": [
"integration",
"es-de"
]
}

View file

@ -0,0 +1,13 @@
{
"name": "com.simeonradivoev.gameflow.rclone",
"displayName": "Rclone Integration",
"version": "0.0.1",
"description": "Rclone integration for syncing saves",
"main": "./rclone.ts",
"icon": "https://forum.rclone.org/uploads/default/original/2X/8/8a14ccd453604987a64820f56c6afa75c229aa17.png",
"category": "saves",
"keywords": [
"integration",
"rclone"
]
}

View file

@ -0,0 +1,292 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json';
import { config, events } from "@/bun/api/app";
import path, { dirname } from 'node:path';
import unzip from 'unzip-stream';
import { ensureDir } from "fs-extra";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import fs from 'node:fs/promises';
import { randomUUIDv7, sleep } from "bun";
import z from "zod";
import { createInterface } from "node:readline";
import { redirect } from "elysia";
import { getErrorMessage } from "@/bun/utils";
import { id } from "zod/v4/locales";
const SettingsSchema = z.object({
runWebGui: z.boolean()
.default(false)
.describe("Run the Web GUI that can be accessed at http://localhost:5572")
.meta({ title: "Run Web GUI" }),
globalConfig: z.boolean().default(false).describe("Use the Global Config file if already setup"),
webGuiPassword: z.string().optional().readonly().describe("Randomly Generated. Read Only. Username is gameflow"),
remoteName: z.string().default(""),
verboseLog: z.boolean()
.default(false)
.describe("Show detailed log of operation for debugging")
.meta({ $comment: JSON.stringify({ category: "debug" }) })
});
type SettingsType = z.infer<typeof SettingsSchema>;
const loginTokenUrlRegex = /http:\/\/[\w\d:\-@\[\]\.\/?=]+/gm;
export default class RcloneIntegration implements PluginType<SettingsType>
{
settingsSchema = SettingsSchema;
rclonePath: string | undefined;
server: Bun.Subprocess | undefined;
password: string;
user = "gameflow";
loginUrl: string | undefined = undefined;
eventsNames = [{
id: "open-web-gui",
title: "Open Web GUI",
description: "Open Web GUI",
action: "Open"
}, {
id: "refresh",
title: "Refresh Sources",
action: "Refresh"
}];
constructor()
{
this.password = randomUUIDv7();
}
async onEvent (id: string)
{
switch (id)
{
case "open-web-gui":
return { openTab: this.loginUrl };
break;
case "refresh":
await this.refresh();
return { reload: true };
break;
}
}
async setup (ctx: PluginLoadingContextType<SettingsType>)
{
ctx.zodRegistry.add(SettingsSchema.shape.runWebGui, { requiresRestart: true });
ctx.zodRegistry.add(SettingsSchema.shape.globalConfig, { requiresRestart: true });
const toolsPath = path.join(config.get('downloadPath'), "tools");
const existingRclones = await Array.fromAsync(fs.glob('**/rclone.exe', { cwd: toolsPath }));
if (existingRclones[0])
{
this.rclonePath = path.join(toolsPath, existingRclones[0]);
await this.startServer(ctx);
return;
}
if (await fs.exists(path.join(toolsPath, 'rclone-current-windows-amd64')))
{
return;
}
ctx.setProgress(0.5, "Downloading RClone");
const rcCloseZip = await fetch(`https://downloads.rclone.org/rclone-current-windows-amd64.zip`);
await ensureDir(toolsPath);
await pipeline(Readable.fromWeb(rcCloseZip.body as any), unzip.Extract({ path: toolsPath }));
const dests = await Array.fromAsync(fs.glob('**/rclone.exe', { cwd: toolsPath }));
if (dests[0])
{
this.rclonePath = path.join(toolsPath, dests[0]);
await this.startServer(ctx);
return;
}
}
async refresh ()
{
const data = await this.request('/config/listremotes', {});
z.globalRegistry.add(SettingsSchema.shape.remoteName, { examples: data.remotes, description: "The name of the remote to sync with" });
}
async startServer (ctx: PluginLoadingContextType<SettingsType>)
{
const args: string[] = [];
if (ctx.config.get('runWebGui'))
{
args.push("--rc-web-gui");
args.push("--rc-web-gui-no-open-browser");
}
if (ctx.config.get(''))
{
args.push('-vv');
}
let env: Record<string, string> | undefined = undefined;
if (!ctx.config.get('globalConfig'))
{
env = { RCLONE_CONFIG: path.join(config.get('downloadPath'), 'tools', 'config', 'rclone', 'rclone.conf') };
}
ctx.config.set('webGuiPassword', this.password);
this.server = Bun.spawn([this.rclonePath!, "rcd", '--use-json-log', `--rc-user=${this.user}`, ...args, `--rc-pass=${this.password}`, "--rc-addr", "localhost:5572"], {
stdout: "pipe",
stderr: "pipe",
env
});
const rl = createInterface({ input: Readable.fromWeb(this.server.stderr as any) });
rl.on('line', e =>
{
const data = JSON.parse(e);
if (data.level === 'error')
{
console.error(data.msg);
} else
{
console.log(e);
if (loginTokenUrlRegex.test(data.msg))
{
this.loginUrl = (data.msg as string).match(loginTokenUrlRegex)?.find(e => e);
}
}
});
await new Promise((resolve) =>
{
const handleResolve = (line: string) =>
{
const data = JSON.parse(line);
if (!loginTokenUrlRegex.test(data.msg)) return;
rl.off('line', handleResolve);
resolve(data);
};
rl.on('line', handleResolve);
});
await this.refresh();
}
async request (path: string, body: any)
{
const response = await fetch(`http://localhost:5572${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${Buffer.from(`${this.user}:${this.password}`).toString('base64')}`
},
body: JSON.stringify(body)
});
const data = await response.json();
if (response.ok)
{
return data;
} else
{
throw new Error(response.statusText, { cause: data });
}
}
async cleanup ()
{
await this.request('/core/quit', {}).catch(e =>
{
this.server?.kill("SIGKILL");
});
await this.server?.exited;
}
async load (ctx: PluginLoadingContextType<SettingsType>)
{
await this.setup(ctx);
ctx.hooks.games.prePlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, setProgress, saveFolderSlots }) =>
{
if (source !== 'store' || !this.rclonePath || !saveFolderSlots) return;
for await (const [slot, { cwd }] of Object.entries(saveFolderSlots))
{
let src: string;
if (ctx.config.get('remoteName'))
{
src = `${ctx.config.get('remoteName')}:gameflow/saves/${source}/${id}/${slot}`;
const exists = await this.request('/operations/stat', {
fs: `${ctx.config.get('remoteName')}:`,
remote: `gameflow/saves/${source}/${id}/${slot}`
}).catch(e => undefined);
if (!exists || !exists.item) return;
} else
{
src = path.join(config.get('downloadPath'), 'saves', source, id, slot);
if (!await fs.exists(path.join(config.get('downloadPath'), 'saves', source, id, slot))) return;
}
setProgress(0.5, "RClone: Syncing Saves");
const data = await this.request('/sync/copy', {
srcFs: src,
dstFs: cwd,
createEmptySrcDirs: true,
_config: {
UseJSONLog: true,
LogLevel: "DEBUG",
HumanReadable: true,
Progress: true,
DryRun: true
}
});
console.log(data);
}
});
ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles }) =>
{
if (source !== 'store' || !this.rclonePath) return;
console.log("Save Files", Object.values(validChangedSaveFiles).flatMap(c => Array.isArray(c.subPath) ? c.subPath : [c.subPath]).join(","));
await Promise.all(Object.entries(validChangedSaveFiles).map(async ([slot, change]) =>
{
let dest: string;
if (ctx.config.get('remoteName'))
{
dest = `${ctx.config.get('remoteName')}:gameflow/saves/${source}/${id}/${slot}`;
} else
{
dest = path.join(config.get('downloadPath'), 'saves', source, id, slot);
}
const data = await this.request('/sync/sync', {
srcFs: change.cwd,
dstFs: dest,
createEmptySrcDirs: true,
_config: {
UseJSONLog: true,
LogLevel: "DEBUG",
HumanReadable: true,
Progress: true
},
_filter: {
IncludeRule: Array.isArray(change.subPath) ? change.subPath.map(s =>
{
if (change.isGlob) return s;
else s.replaceAll('\\', '/');
}) : change.isGlob ? change.subPath : change.subPath.replaceAll('\\', '/')
}
}).catch(e =>
{
events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' });
return undefined;
});
if (data)
{
events.emit('notification', { message: "RClone: Save Synced", type: 'success', icon: 'save' });
}
}));
});
}
}

View file

@ -0,0 +1,83 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json';
import secrets from "@/bun/api/secrets";
import PQueue from 'p-queue';
import * as igdb from '@phalcode/ts-igdb-client';
export default class IgdbIntegration implements PluginType
{
queue: PQueue;
constructor()
{
this.queue = new PQueue({ concurrency: 8, interval: 1000, intervalCap: 4, strict: true });
}
async apiCall<T> (subPath: string, query: string)
{
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
const headers = new Headers({
"Client-ID": process.env.TWITCH_CLIENT_ID ?? '',
Authorization: `Bearer ${access_token}`,
Accept: "application/json"
});
const response = await this.queue.add(() => fetch(`https://api.igdb.com/v4${subPath}`, {
headers: headers,
method: "POST",
body: query
}));
if (response.ok)
{
return response.json() as T;
}
}
async cleanup ()
{
this.queue.clear();
}
async load (ctx: PluginLoadingContextType)
{
ctx.hooks.games.gameLookup.tapPromise(desc.name, async ({ source, id }) =>
{
if (!process.env.TWITCH_CLIENT_ID) return;
if (source !== 'igdb') return;
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
if (access_token)
{
const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token);
const { data } = await client.request('screenshots').pipe(igdb.fields(['game', 'url', 'image_id']), igdb.where('game', '=', Number(id))).execute();
return { screenshotUrls: data.filter(s => s.url).map(s => `https://images.igdb.com/igdb/image/upload/t_720p/${s.image_id}.webp`) };
}
});
ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) =>
{
let query: string | undefined = undefined;
if (source && id)
{
if (source !== 'igdb') return;
query = `fields name, slug, platform_logo.image_id, platform_logo.url, platform_family.name; where id = ${id};`;
}
else if (slug)
{
query = `fields name, slug, platform_logo.image_id, platform_logo.url, platform_family.name; where slug = "${slug}";`;
}
if (query)
{
const data = await this.apiCall<[any]>('/platforms', query);
if (!data || data.length <= 0) return;
return {
slug: data[0].slug,
url_logo: `https://images.igdb.com/igdb/image/upload/t_logo_med/${data[0].platform_logo.image_id}.png`,
name: data[0].name,
family_name: data[0].platform_family?.name
};
}
});
}
}

View file

@ -0,0 +1,13 @@
{
"name": "com.simeonradivoev.gameflow.igdb",
"displayName": "IGDB Integration",
"version": "0.0.1",
"description": "IGDB Metadata Integration",
"main": "./igdb.ts",
"icon": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/IGDB_logo.svg/1920px-IGDB_logo.svg.png",
"category": "sources",
"keywords": [
"integration",
"igdb"
]
}

View file

@ -5,6 +5,7 @@
"description": "ROMM Server Integration",
"main": "./romm.ts",
"icon": "https://romm.app/_ipx/q_80/images/blocks/logos/romm.svg",
"category": "sources",
"keywords": [
"integration",
"romm"

View file

@ -1,8 +1,8 @@
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json';
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
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";
import path from 'node:path';
import fs from 'node:fs/promises';
@ -12,9 +12,17 @@ import secrets from "@/bun/api/secrets";
import { getAuthToken } from "@/clients/romm/core/auth.gen";
import { client } from "@/clients/romm/client.gen";
import { validateGameSource } from "@/bun/api/games/services/statusService";
import z from "zod";
export default class RommIntegration implements PluginType
const SettingsSchema = z.object({
savesSync: z.boolean().default(false).describe("Experimental save sync support")
});
type SettingsType = z.infer<typeof SettingsSchema>;
export default class RommIntegration implements PluginType<SettingsType>
{
settingsSchema = SettingsSchema;
isSteamDeck = false;
orderByMap: Record<string, string> = {
added: "created_at",
@ -54,7 +62,7 @@ export default class RommIntegration implements PluginType
{
const game: FrontEndGameType = {
id: { id: String(rom.id), source: 'romm' },
path_cover: `/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`,
path_covers: [`/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`],
last_played: rom.rom_user.last_played !== null ? new Date(rom.rom_user.last_played) : null,
updated_at: new Date(rom.created_at),
metadata: {
@ -83,8 +91,8 @@ export default class RommIntegration implements PluginType
fs_size_bytes: rom.fs_size_bytes,
local: false,
missing: rom.missing_from_fs,
imdb_id: rom.igdb_id ?? undefined,
ra_id: rom.ra_id ?? undefined,
igdb_id: rom.igdb_id,
ra_id: rom.ra_id,
metadata: {
age_ratings: rom.metadatum.age_ratings,
genres: rom.metadatum.genres,
@ -126,15 +134,12 @@ export default class RommIntegration implements PluginType
return detailed;
}
async setup ()
async load (ctx: PluginLoadingContextType<SettingsType>)
{
this.isSteamDeck = isSteamDeckGameMode();
await this.updateClient();
}
load (ctx: PluginContextType)
{
ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games, filters }) =>
ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) =>
{
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
{
@ -146,7 +151,7 @@ export default class RommIntegration implements PluginType
limit: query.limit,
offset: query.offset,
order_by: this.orderByMap[query.orderBy ?? ''],
with_filter_values: true,
with_filter_values: false,
genres: query.genres,
genres_logic: "all",
age_ratings: query.age_ratings,
@ -154,12 +159,6 @@ export default class RommIntegration implements PluginType
}, throwOnError: true
});
rommGames.data.filter_values.age_ratings.forEach(r => filters.age_ratings.add(r));
rommGames.data.filter_values.companies.forEach(r => filters.companies.add(r));
rommGames.data.filter_values.languages.forEach(r => filters.languages.add(r));
rommGames.data.filter_values.player_counts.forEach(r => filters.player_counts.add(r));
rommGames.data.filter_values.genres.forEach(r => filters.genres.add(r));
games.push(...rommGames.data.items.map(g =>
{
const game: FrontEndGameTypeWithIds = {
@ -172,8 +171,10 @@ export default class RommIntegration implements PluginType
}
});
ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters }) =>
ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) =>
{
if (source && source !== 'romm') return;
const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true });
rommFilters.data.age_ratings.forEach(r => filters.age_ratings.add(r));
rommFilters.data.companies.forEach(r => filters.companies.add(r));
@ -188,7 +189,7 @@ export default class RommIntegration implements PluginType
await this.updateClient();
});
ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id, localGame }) =>
ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) =>
{
if (source !== 'romm') return;
@ -196,13 +197,6 @@ export default class RommIntegration implements PluginType
if (rom.data)
{
const romGame = await this.convertRomToFrontendDetailed(rom.data);
if (localGame)
{
return {
...romGame,
...localGame,
};
}
return romGame;
}
@ -405,10 +399,12 @@ export default class RommIntegration implements PluginType
}
});
ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderPath, setProgress }) =>
ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderSlots, setProgress }) =>
{
if (source !== 'romm') return;
if (saveFolderPath)
if (source !== 'romm' || !ctx.config.get('savesSync')) return;
if (!saveFolderSlots) return;
for await (const [slot, { cwd }] of Object.entries(saveFolderSlots))
{
setProgress(0, "saves");
@ -418,53 +414,38 @@ export default class RommIntegration implements PluginType
console.error(saveFiles.error);
} else
{
for (let i = 0; i < saveFiles.data.slots.length; i++)
const rommSlot = saveFiles.data.slots.find(s => s.slot === 'gameflow' && s.latest.file_name_no_tags === slot);
if (rommSlot)
{
const slot = saveFiles.data.slots[i];
const savePath = path.join(saveFolderPath, slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`);
if (await fs.exists(savePath))
{
const existingSaveSync = await fs.stat(savePath);
const updatedAtTime = new Date(slot.latest.updated_at).getTime();
if (existingSaveSync.mtimeMs > updatedAtTime)
{
console.log("Newer save file", savePath, "Server:", new Date(slot.latest.updated_at), "Local:", existingSaveSync.mtime);
// Newer file
continue;
} else if (updatedAtTime === existingSaveSync.mtimeMs)
{
//TODO: do checksum comparison when that works on romm
console.log("Same save file", savePath);
continue;
}
}
const auth = await this.getAuthToken();
const headers: Record<string, string> = {};
if (auth)
headers['Authorization'] = auth;
const saveResponse = await fetch(`${config.get('rommAddress')}${slot.latest.download_path}`, { headers });
const saveResponse = await fetch(`${config.get('rommAddress')}${rommSlot.latest.download_path}`, { headers });
if (!saveResponse.ok)
{
console.error("Error downloading save", saveResponse.statusText);
break;
return;
}
await Bun.write(savePath, saveResponse);
console.log("Loaded", savePath);
setProgress((i / saveFiles.data.slots.length) * 100, "saves");
const saveArchive = new Bun.Archive(await saveResponse.blob());
setProgress(50, "saves");
const count = await saveArchive.extract(cwd);
setProgress(100, "saves");
console.log("Loaded", count, "save files");
}
}
setProgress(1, "saves");
setProgress(100, "saves");
await Bun.sleep(1000);
}
});
ctx.hooks.games.postPlay.tapPromise(desc.name, async ({ source, id, validChangedSaveFiles, saveFolderPath, command }) =>
// Should run after emulators decide on saves
ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) =>
{
if (source !== 'romm') return;
if (source !== 'romm' || !ctx.config.get('savesSync')) return;
const sourceValidation = await validateGameSource(source, id);
if (!sourceValidation.valid)
@ -473,7 +454,7 @@ export default class RommIntegration implements PluginType
return;
}
const finalSavePaths = validChangedSaveFiles.filter(f => !f.shared);
/*const finalSavePaths = validChangedSaveFiles.filter(f => !f.shared && !f.isGlob).flatMap(s => Array.isArray(s.subPath) ? s.subPath.map(p => ({ cwd: s.cwd, subPath: p })) : [{ cwd: s.cwd, subPath: s.subPath }]);
const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } });
if (saveFiles.error)
@ -494,29 +475,31 @@ export default class RommIntegration implements PluginType
if (!finalSavePaths.some(f => f.subPath === subPath))
{
// Add newer files to the list, maybe they were changed offscreen.
finalSavePaths.push({ subPath, cwd: saveFolderPath, shared: false });
finalSavePaths.push({ subPath, cwd: saveFolderPath });
}
}
}
}
}
}*/
const finalSavePaths = Object.entries(validChangedSaveFiles).filter(([slot, change]) => !change.isGlob && !change.shared);
if (finalSavePaths.length > 0)
{
console.log("Files Changed:", finalSavePaths.map(f => f.subPath)?.join(", "));
console.log("Files Changed:", finalSavePaths.map(([slot, change]) => Array.isArray(change.subPath) ? change.subPath.join(',') : change.subPath)?.join(", "));
await Promise.all(finalSavePaths.map(async f =>
await Promise.all(finalSavePaths.map(async ([slot, change]) =>
{
const absolutePath = path.join(f.cwd, f.subPath);
if (!await fs.exists(absolutePath)) return;
const stat = await fs.stat(absolutePath);
if (stat.isDirectory()) return;
const savesArray = Array.isArray(change.subPath) ? change.subPath : [change.subPath];
// TODO: handle directories
const archive = new Bun.Archive(Object.fromEntries(savesArray.map(s => [s, Bun.file(path.join(change.cwd, s))])));
const data: FormData = new FormData();
data.append('saveFile', Bun.file(absolutePath), path.basename(f.subPath));
data.append('saveFile', await archive.blob(), slot);
const url = new URL(`${config.get('rommAddress')}/api/saves`);
url.searchParams.set('rom_id', id);
url.searchParams.set('slot', path.dirname(f.subPath));
url.searchParams.set('slot', slot);
url.searchParams.set('autocleanup', "true");
url.searchParams.set('autocleanup_limit', "2");
if (command.emulator)
@ -582,11 +565,24 @@ export default class RommIntegration implements PluginType
});
ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id }) =>
ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) =>
{
if (source !== 'romm') return;
const platforms = await this.getAllRommPlatforms();
return platforms.find(p => p.id === Number(id));
let platform: PlatformSchema | undefined = undefined;
if (id && source)
{
if (source !== 'romm') return;
const platforms = await this.getAllRommPlatforms();
platform = platforms.find(p => p.id === Number(id));
} else if (slug)
{
const platforms = await this.getAllRommPlatforms();
platform = platforms.find(p => p.slug === slug);
}
if (!platform) return;
return { slug: platform?.slug, url_logo: platform.url_logo, name: platform.display_name, family_name: platform.family_name ?? undefined };
});
ctx.hooks.games.searchGame.tapPromise(desc.name, async ({ source, igdb_id, ra_id }) =>

View file

@ -0,0 +1,13 @@
{
"name": "com.simeonradivoev.gameflow.store",
"displayName": "Gameflow Store",
"version": "0.0.1",
"description": "The internal gameflow store",
"main": "./store.ts",
"category": "sources",
"canDisable": false,
"keywords": [
"internal",
"store"
]
}

View file

@ -0,0 +1,313 @@
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';
import * as emulatorSchema from '@schema/emulators';
import { db, emulatorsDb, plugins } from "@/bun/api/app";
import { and, eq } from "drizzle-orm";
import { getOrCached } from "@/bun/api/cache";
import { Glob } from "bun";
import { shuffleInPlace } from "@/bun/utils";
import mustache from "mustache";
import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService";
import fs from "node:fs/promises";
export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; })
{
const offset = filter?.offset ?? 0;
const limit = Math.min(50, filter?.limit ?? 10);
const games = await Promise.all(gamesManifest.slice(offset, Math.min(offset + limit, gamesManifest.length)).map((e: any) =>
{
return fetch(e.url).then(e => e.json()).then(game => StoreGameSchema.parseAsync(JSON.parse(atob(game.content.replace(/\n/g, "")))));
}));
return games;
}
export async function getStoreGame (id: string)
{
const file = Bun.file(path.join(getStoreFolder(), 'buckets', 'games', `${id}.json`));
if (!(await file.exists())) return undefined;
const game = file
.json()
.then(g => StoreGameSchema.parseAsync(g))
.then(g => ({ ...g, id }));
return game;
}
function convertStoreMediaToPath (c: string)
{
if (c.startsWith('http'))
{
return `/api/romm/image?url=${encodeURIComponent(c)}`;
} else
{
return `/api/store/media/${c}`;
}
}
export async function convertStoreToFrontend (id: string, storeGame: StoreGameType): Promise<FrontEndGameType>
{
const validDownload = getValidDownload(storeGame);
let platform_slug: string | null = null;
let platform_id: number | null = null;
let platform_display_name: string | null = null;
let path_platform_cover: string | null = null;
if (validDownload?.system)
{
let system = validDownload.system.split(':')[0];
if (system === 'win32') system = 'win';
const localPlatform = await db.query.platforms.findFirst({ where: eq(appSchema.platforms.slug, system), columns: { id: true, slug: true, name: true } });
if (localPlatform)
{
platform_id = localPlatform.id;
platform_slug = localPlatform.slug;
path_platform_cover = `/api/romm/platform/local/${localPlatform.id}/cover`;
platform_display_name = localPlatform.name;
}
if (platform_slug === null)
{
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
where: and(eq(emulatorSchema.systemMappings.sourceSlug, system), eq(emulatorSchema.systemMappings.source, 'romm'))
});
if (rommSystem?.system)
{
const platformDef = await emulatorsDb.query.systems.findFirst({
where: eq(emulatorSchema.systems.name, rommSystem?.system),
columns: { fullname: true }
});
platform_slug = rommSystem.system;
platform_display_name = platformDef?.fullname ?? null;
path_platform_cover = `/api/romm/image/romm/assets/platforms/${rommSystem.sourceSlug}.svg`;
} else
{
const platformDef = await emulatorsDb.query.systems.findFirst({
where: eq(emulatorSchema.systems.name, system),
columns: { fullname: true }
});
platform_slug = system;
platform_display_name = platformDef?.fullname ?? null;
}
platform_slug ??= system;
}
}
const game: FrontEndGameType = {
platform_display_name,
path_platform_cover,
id: { source: 'store', id: id },
source: null,
source_id: null,
path_fs: null,
path_covers: storeGame.covers?.map(convertStoreMediaToPath) ?? [],
last_played: null,
updated_at: new Date(),
slug: id,
name: storeGame.name,
platform_id,
platform_slug,
paths_screenshots: storeGame.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [],
metadata: {
first_release_date: typeof storeGame.first_release_date === 'number' ? new Date(storeGame.first_release_date) : storeGame.first_release_date ?? null
}
};
return game;
}
export async function convertStoreToFrontendDetailed (id: string, storeGame: StoreGameType): Promise<FrontEndGameTypeDetailed>
{
const validDownload = getValidDownload(storeGame);
let size: number | null = null;
if (validDownload?.url)
{
try
{
const fileResponse = await fetch(validDownload?.url, { method: 'HEAD' });
size = Number(fileResponse.headers.get('content-length'));
} catch (error)
{
console.error(error);
}
}
const detailed: FrontEndGameTypeDetailed = {
...await convertStoreToFrontend(id, storeGame),
summary: storeGame.description,
fs_size_bytes: size,
missing: false,
local: false,
version: storeGame.version,
igdb_id: storeGame.igdb_id ?? null,
ra_id: storeGame.ra_id ?? null,
metadata: {
genres: storeGame.genres ?? [],
companies: storeGame.companies ?? [],
game_modes: [],
age_ratings: [],
player_count: storeGame.player_count ?? null,
average_rating: null,
first_release_date: typeof storeGame.first_release_date === 'number' ? new Date(storeGame.first_release_date) : storeGame.first_release_date ?? null
}
};
return detailed;
}
export function getValidDownload (game: StoreGameType, downloadId?: string)
{
const downloads = Object.entries(game.downloads).map(([k, d]) => ({ id: k, ...d }));
const supportedDownloads = downloads.filter(d => d.type === 'direct');
if (downloadId)
{
return supportedDownloads.find(d => d.id === downloadId);
} else
{
return supportedDownloads.find(d => d.system === `${process.platform}:${process.arch}`)
?? supportedDownloads.find(d =>
{
// Linux supports proton, can run windows games
if (process.platform === 'linux') return d.system === `win32:${process.arch}`;
return false;
})
// Fallback to emulator platforms
?? supportedDownloads.find(d => !d.system.includes(':'));
}
}
export async function getShuffledStoreGames ()
{
return getOrCached('shuffled-store-games', async () =>
{
const files = new Glob(path.join(getStoreFolder(), 'buckets', 'games', '*.json')).scan();
const allGamePaths = await Array.fromAsync(files);
const allStoreGames = await Promise.all(allGamePaths.map(p => Bun.file(p).json().then(g => StoreGameSchema.parseAsync(g)).then(g => ({ ...g, id: path.basename(p, '.json') }))));
shuffleInPlace(allStoreGames, Math.round(new Date().getTime() / 1000 / 60 / 60));
return allStoreGames;
}, { expireMs: 1000 / 60 / 60 });
}
export async function buildFilters (filters: FrontEndFilterSets)
{
const filtersFile = Bun.file(path.join(getStoreFolder(), 'manifests', 'filters.json'));
if (!await filtersFile.exists()) return;
const storeFilters = await filtersFile.json();
storeFilters.genres?.forEach((g: string) => filters.genres.add(g));
storeFilters.age_ratings?.forEach((g: string) => filters.age_ratings.add(g));
if (storeFilters.player_count)
filters.player_counts.add(storeFilters.player_count);
storeFilters.companies?.forEach((g: string) => filters.companies.add(g));
}
function getAppData ()
{
if (process.platform === "win32") return process.env.APPDATA!;
if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support");
// linux
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
}
function getLocalAppData ()
{
if (process.platform === "win32") return process.env.LOCALAPPDATA!;
if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Caches");
// Linux / Unix
return process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
}
export function buildSaves (command: CommandEntry, storeGame: StoreGameType, download?: StoreDownloadType)
{
let saveFileGlobs: Record<string, {
cwd: string;
globs: string[];
}> | undefined = undefined;
if (download && download.saves)
{
saveFileGlobs = download.saves;
} else if (storeGame.saves)
{
const platformSaves = storeGame.saves[`${process.platform}:${process.arch}`];
if (platformSaves)
{
saveFileGlobs = platformSaves;
}
}
const view = {
GAMEDIR: command.startDir,
HOMEDIR: os.homedir(),
TMPDIR: os.tmpdir(),
APPDATA: getAppData(),
LOCALAPPDATA: getLocalAppData(),
};
if (!saveFileGlobs) return;
return Object.entries(saveFileGlobs).map(([slot, save]) =>
{
const cwd = mustache.render(save.cwd, view);
const change: SaveFileChange = {
cwd,
shared: false,
isGlob: true,
subPath: save.globs
};
return [slot, change] as [string, SaveFileChange];
});
}
export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, systems: EmulatorSystem[])
{
const execPaths: EmulatorSourceEntryType[] = [];
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: emulator.name, sources: execPaths });
const em: FrontEndEmulator = {
name: emulator.name,
logo: emulator.logo,
systems,
gameCount: 0,
validSources: execPaths,
integrations: []
};
return em;
}
export async function getExistingStoreEmulatorDownload (emulator: EmulatorPackageType): Promise<(EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined>
{
const existingPackagePath = `${getEmulatorPath(emulator.name)}.json`;
if (await fs.exists(existingPackagePath))
{
const existingPackage = await EmulatorDownloadInfoSchema.parseAsync(await Bun.file(existingPackagePath).json());
const download = await getEmulatorDownload(emulator, existingPackage.type).catch(d => undefined);
if (!download) return { ...existingPackage, hasUpdate: false };
if (download.info.version)
{
if (existingPackage.version !== download.info.version) return { ...existingPackage, hasUpdate: true };
} else if (existingPackage.id !== download.info.id)
{
return { ...existingPackage, hasUpdate: true };
}
return { ...existingPackage, hasUpdate: false };
}
// this should only happen if download info is missing maybe manually deleted or wasn't saved.
return undefined;
}

View file

@ -0,0 +1,312 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json';
import path, { basename, dirname } from 'node:path';
import { StoreDownloadType, StoreGameSchema, StoreGameType } from "@/shared/constants";
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService";
import { Glob, pathToFileURL } from "bun";
import { getOrCached } from "@/bun/api/cache";
import { shuffleInPlace } from "@/bun/utils";
import { and, eq } from "drizzle-orm";
import * as emulatorSchema from '@schema/emulators';
import { config, db, emulatorsDb, plugins, taskQueue } from "@/bun/api/app";
import fs from "node:fs/promises";
import { getSourceGameDetailed } from "@/bun/api/games/services/utils";
import mustache from "mustache";
import os from 'node:os';
import UpdateStoreJob from "@/bun/api/jobs/update-store";
import { getEmulatorDownload } from "@/bun/api/store/services/emulatorsService";
import { buildFilters, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownload } from "./services";
export default class RommIntegration implements PluginType
{
async setup (ctx: PluginLoadingContextType)
{
console.log("Store Directory is ", getStoreFolder());
ctx.setProgress(0, "Updating Store");
await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
}
async load (ctx: PluginLoadingContextType)
{
ctx.hooks.store.fetchDownload.tapPromise(desc.name, async ({ id }) =>
{
const emulatorPackage = await getStoreEmulatorPackage(id);
const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage!);
return downloadInfo;
});
ctx.hooks.store.fetchEmulator.tapPromise(desc.name, async ({ id }) =>
{
const emulatorPackage = await getStoreEmulatorPackage(id);
if (!emulatorPackage) return undefined;
const systems = await buildStoreFrontendEmulatorSystems(emulatorPackage);
const emulatorScreenshotsPath = path.join(getStoreFolder(), "media", "screenshots", id);
const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : [];
const biosDirPath = path.join(config.get('downloadPath'), 'bios', id);
const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : [];
const storeDownloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage);
const emulator: FrontEndEmulatorDetailed = {
name: emulatorPackage.name,
description: emulatorPackage.description,
source: "store",
systems,
validSources: [],
screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`),
gameCount: 0,
homepage: emulatorPackage.homepage,
downloads: (await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d =>
{
const download = await getEmulatorDownload(emulatorPackage, d.type).catch(e => undefined);
return download?.info;
}) ?? [])).filter(d => !!d).map(d => ({ name: d.type, type: d.type, version: d.version })),
logo: emulatorPackage.logo,
biosRequirement: emulatorPackage.bios,
bios: biosFiles,
integrations: [],
storeDownloadInfo: storeDownloadInfo
};
return emulator;
});
ctx.hooks.store.fetchEmulators.tapPromise(desc.name, async ({ emulators, search }) =>
{
const emulatesParsed = await getAllStoreEmulatorPackages();
emulators.push(...await Promise.all(emulatesParsed
.filter(e =>
{
if (!e.os.includes(process.platform as any)) return false;
if (search)
{
if (e.name.toLocaleLowerCase().includes(search) || e.systems.some(s => s.toLocaleLowerCase().includes(search)) || e.keywords?.some(k => k.toLocaleLowerCase().includes(search)))
{
return true;
}
return false;
}
return true;
})
.map(async (emulator) =>
{
const systems = await buildStoreFrontendEmulatorSystems(emulator);
return convertStoreEmulatorToFrontend(emulator, systems);
})));
});
ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderSlots, command }) =>
{
if (source !== 'store') return;
const storeGame = await getStoreGame(id);
const localGame = await getSourceGameDetailed(source, id);
if (!localGame || !storeGame) return;
if (!localGame.version_source) return;
const download = storeGame.downloads[localGame.version_source];
const saves = buildSaves(command, storeGame, download);
saves?.forEach(([slot, save]) => saveFolderSlots[slot] = { cwd: save.cwd });
});
ctx.hooks.games.postPlay.tapPromise(desc.name, async ({ validChangedSaveFiles, source, id, command }) =>
{
if (source !== 'store') return;
const storeGame = await getStoreGame(id);
const localGame = await getSourceGameDetailed(source, id);
if (!localGame || !storeGame) return;
if (!localGame.version_source) return;
const download = storeGame.downloads[localGame.version_source];
const saves = buildSaves(command, storeGame, download);
saves?.forEach(([key, val]) => validChangedSaveFiles[key] = val);
});
ctx.hooks.games.buildLaunchCommands.tapPromise({ name: desc.name, before: 'com.simeonradivoev.gameflow.es' }, async ({ gamePath, source, sourceId, systemSlug, mainGlob }) =>
{
if (source !== 'store' || !gamePath || systemSlug !== 'win') return;
const downloadPath = config.get('downloadPath');
const gamePathAbsolute = path.join(downloadPath, gamePath);
if (!(await fs.exists(gamePathAbsolute))) return;
const gamePathStat = await fs.stat(gamePathAbsolute);
if (gamePathStat.isDirectory())
{
const fileGlob = new Glob(mainGlob ?? '**/*.exe');
for await (const file of fileGlob.scan({ cwd: path.join(downloadPath, gamePath) }))
{
return [{
startDir: path.join(downloadPath, gamePath, dirname(file)),
command: basename(file),
id: 'store-win',
valid: true,
env: {
XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '')
},
metadata: {
romPath: path.join(downloadPath, gamePath, file)
}
}];
}
} else
{
return [{
startDir: path.join(downloadPath, dirname(gamePath)),
command: basename(gamePath),
env: {
XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '')
},
id: 'store-win',
valid: true,
metadata: {
romPath: path.join(downloadPath, gamePath)
}
}];
}
});
ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) =>
{
if (!source || source !== 'store') return;
await buildFilters(filters);
});
ctx.hooks.store.fetchFeaturedGames.tapPromise(desc.name, async ({ games }) =>
{
const allGames = await getShuffledStoreGames();
const convertedGames = await Promise.all(allGames.slice(0, 3).map(async g =>
{
return convertStoreToFrontendDetailed(g.id, g);
}));
games.push(...convertedGames);
});
ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) =>
{
if (!query.source || query.source !== 'store') return;
if (query.collection_source || query.collection_id) return;
const shuffledGames = await getShuffledStoreGames();
const storeGames = await Promise.all(shuffledGames.filter(g =>
{
if (query.search)
return path.basename(g.name).toLocaleLowerCase().includes(query.search.toLocaleLowerCase());
return true;
})
.slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length))
.map(async (e) =>
{
const game: FrontEndGameTypeWithIds = {
...await convertStoreToFrontend(e.id, e),
igdb_id: e.igdb_id ?? null,
ra_id: e.ra_id ?? null
};
return game;
}));
games.push(...storeGames.filter(g => g !== undefined));
});
ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) =>
{
const esSystem = game.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, game.platform_slug)), columns: { system: true } }) : undefined;
const shuffledGames = await getShuffledStoreGames();
const storeGames = await Promise.all(shuffledGames
.filter(g =>
{
if (esSystem)
{
if (Object.values(g.downloads).some(d => d.system === esSystem.system)) return true;
}
return false;
})
.map(async (e) =>
{
return convertStoreToFrontend(e.id, e);
}));
if (storeGames)
{
games.push(...storeGames.slice(0, 3));
}
});
ctx.hooks.games.fetchRecommendedGamesForEmulator.tapPromise(desc.name, async ({ emulator, games, systems }) =>
{
const systemsIdSet = new Set(systems.map(s => s.id));
const gamesManifest = await getShuffledStoreGames();
const storeGames = await Promise.all(gamesManifest
.filter(g => Object.values(g.downloads).some(d => systemsIdSet.has(d.system)))
.map(async (e) =>
{
return convertStoreToFrontend(e.id, e);
}));
games.push(...storeGames.filter(g => g !== undefined).slice(0, 3));
});
ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) =>
{
if (source !== 'store') return;
const storeGame = await getStoreGame(id);
if (storeGame)
{
return convertStoreToFrontendDetailed(id, storeGame);
}
});
ctx.hooks.games.fetchDownloads.tapPromise(desc.name, async ({ source, id, downloadId }) =>
{
if (source !== 'store') return;
const game = await getStoreGame(id);
if (!game) throw new Error("Missing Store Game");
const validDownload = getValidDownload(game, downloadId);
if (validDownload)
{
let system = validDownload.system.split(":")[0];
if (system === 'win32') system = 'win';
const info: DownloadInfo = {
coverUrl: game.covers?.[0] ? game.covers[0].startsWith('http') ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "",
screenshotUrls: game.screenshots ?? [],
files: [{
url: new URL(validDownload.url),
file_path: `roms/${system}`,
file_name: path.basename(decodeURI(validDownload.url)),
size: 0
}],
slug: id,
source_id: id,
name: game.name,
summary: game.description,
system_slug: system,
path_fs: path.join('roms', system, game.id),
extract_path: '.',
main_glob: validDownload.main,
version: game.version,
version_system: validDownload.system,
version_source: validDownload.id,
platform: {
slug: system,
name: system
}
};
return info;
}
});
}
}

View file

@ -1,6 +1,15 @@
import { GameflowHooks } from "../hooks/app";
import { PluginContextType, PluginDescriptionType, PluginType } from "../../types/typesc.schema";
import { PluginDescriptionType, PluginLoadingContextType, PluginType } from "../../types/typesc.schema";
import { config } from "../app";
import Conf from "conf";
import projectPackage from '~/package.json';
import z from "zod";
import { EventEmitter } from "node:stream";
export const pluginZodRegistry = z.registry<{
requiresRestart?: boolean;
readOnly?: boolean;
}>();
export class PluginManager
{
@ -11,10 +20,11 @@ export class PluginManager
plugin: PluginType;
description: PluginDescriptionType,
source: PluginSourceType;
config?: Conf;
}> = {};
async register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType)
register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType)
{
try
{
@ -24,15 +34,29 @@ export class PluginManager
}
else
{
if (plugin.setup) await plugin.setup();
let pluginConfig: Conf | undefined = undefined;
if (plugin.settingsSchema)
{
pluginConfig = new Conf({
projectName: projectPackage.name,
configName: description.name,
projectSuffix: 'bun',
cwd: process.env.CONFIG_CWD,
schema: Object.fromEntries(Object.entries(plugin.settingsSchema.shape).map(([key, schema]) => [key, (schema as z.ZodObject).toJSONSchema() as any])) as any,
defaults: plugin.settingsSchema.parse({}),
migrations: plugin.settingsMigrations as any,
projectVersion: description.version
});
}
this.plugins[description.name] = {
enabled: !config.get('disabledPlugins').includes(description.name),
loaded: false,
plugin: plugin,
source: source,
description: description
description: description,
config: pluginConfig
};
this.reload(description.name);
console.log("Plugin", description.name, "registered");
}
@ -44,24 +68,29 @@ export class PluginManager
};
}
private reload (name: string)
private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; })
{
const plugin = this.plugins[name];
if (plugin)
{
const ctx: PluginContextType = { hooks: this.hooks };
const ctx: PluginLoadingContextType = {
hooks: this.hooks,
setProgress: reloadCtx.setProgress.bind(reloadCtx),
config: plugin.config as any,
zodRegistry: pluginZodRegistry
};
if (plugin.loaded)
{
plugin.plugin.onBeforeReload?.(ctx);
await plugin.plugin.cleanup?.();
plugin.loaded = false;
}
try
{
if (plugin.enabled)
if (plugin.enabled || plugin.description.canDisable === false)
{
plugin.plugin.load(ctx);
await plugin.plugin.load(ctx);
plugin.loaded = true;
}
} catch (error)
@ -72,10 +101,14 @@ export class PluginManager
}
}
reloadAll ()
async reloadAll (ctx: { setProgress: (progress: number, state: string) => void; })
{
this.hooks = new GameflowHooks();
Object.keys(this.plugins).forEach(id => this.reload(id));
for await (const id of Object.keys(this.plugins))
{
ctx.setProgress(0, `Loading ${id}`);
await this.reload(id, ctx);
}
}
async cleanup ()
@ -84,7 +117,10 @@ export class PluginManager
{
try
{
await p.plugin.cleanup!();
if (p.loaded)
{
await p.plugin.cleanup!();
}
} catch (error)
{
console.log("Error for plugin", p.description.name, "while cleaning up");

View file

@ -1,7 +1,8 @@
import Elysia, { status } from "elysia";
import { plugins } from "../app";
import { plugins, taskQueue } from "../app";
import z from "zod";
import { toggleElementInConfig } from "@/bun/utils";
import ReloadPluginsJob from "../jobs/reload-plugins-job";
export default new Elysia({ prefix: '/plugins' })
.get('/', async () =>
@ -15,19 +16,31 @@ export default new Elysia({ prefix: '/plugins' })
description: p.description.description,
source: p.source,
version: p.description.version,
icon: p.description.icon
canDisable: p.description.canDisable ?? true,
icon: p.description.icon,
category: p.description.category,
hasSettings: !!p.config
};
return plugin;
});
})
.get('/:id', async ({ params: { id } }) =>
{
const plugin = plugins.plugins[id];
return plugin.description;
})
.post('/:id', async ({ params: { id }, body: { enabled } }) =>
{
const plugin = plugins.plugins[id];
if (plugin)
{
if (plugin.description.canDisable === false)
{
return status("Forbidden");
}
plugin.enabled = enabled;
toggleElementInConfig('disabledPlugins', plugin.description.name, enabled);
plugins.reloadAll();
await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob());
} else
{
return status("Not Found");

View file

@ -7,11 +7,14 @@ import cemu from './builtin/emulators/com.simeonradivoev.gameflow.cemu/package.j
import xenia from './builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json';
import xemu from './builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json';
import romm from './builtin/sources/com.simeonradivoev.gameflow.romm/package.json';
import igdb from './builtin/sources/com.simeonradivoev.gameflow.igdb/package.json';
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/typesc.schema";
export default async function register (pluginManager: PluginManager)
{
const plugins: (PluginDescriptionType & { main: string; load: () => Promise<any>; })[] = [
{ ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') },
{ ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') },
@ -20,9 +23,24 @@ export default async function register (pluginManager: PluginManager)
{ ...xenia, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia') },
{ ...xemu, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu') },
{ ...romm, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.romm/romm') },
{ ...igdb, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.igdb/igdb') },
{ ...es, load: () => import('./builtin/launchers/com.simeonradivoev.gameflow.es/es-de') },
{ ...store, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.store/store') },
{ ...rclone, load: () => import('./builtin/other/com.simeonradivoev.gameflow.rclone/rclone') },
];
await Promise.all(plugins.map(async (pluginPackage) =>
await Promise.all(plugins.filter(p =>
{
if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(p.name))
{
return false;
}
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')

View file

@ -9,6 +9,7 @@ export const games = sqliteTable('games', {
name: text("name"),
ra_id: integer('ra_id').unique(),
path_fs: text("path_fs"),
main_glob: text("main_glob"),
last_played: integer("last_played", { mode: 'timestamp' }),
created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(),
metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type<{
@ -24,7 +25,10 @@ export const games = sqliteTable('games', {
platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(),
cover: blob("cover", { mode: 'buffer' }),
cover_type: text('type'),
summary: text("summary")
summary: text("summary"),
version: text('version'),
version_source: text("version_source"),
version_system: text("version_system"),
});
export const gamesRelations = relations(games, ({ many, one }) => ({

View file

@ -2,10 +2,9 @@
import * as appSchema from '@schema/app';
import * as emulatorSchema from "@schema/emulators";
import { eq, inArray } from 'drizzle-orm';
import { db, emulatorsDb } from '../app';
import { db, emulatorsDb, plugins } from '../app';
import { cores } from '../emulatorjs/emulatorjs';
import { SERVER_URL } from '@/shared/constants';
import { findExecsByName } from '../games/services/launchGameService';
import { host } from '@/bun/utils/host';
import { findEmulatorPluginIntegration } from '../store/services/emulatorsService';
@ -54,7 +53,18 @@ export async function getRelevantEmulators ()
const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator);
const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) =>
{
const execPaths = await findExecsByName(emulator);
const execPaths: EmulatorSourceEntryType[] = [];
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator, sources: execPaths });
const integrations = findEmulatorPluginIntegration(emulator, execPaths);
const storeEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: emulator });
if (storeEmulator)
{
storeEmulator.validSources = execPaths;
storeEmulator.integrations = integrations;
return storeEmulator;
}
let platform: number | null | undefined = null;
const validSystemSlug = system_slug.find(s => s.system);
@ -75,7 +85,7 @@ export async function getRelevantEmulators ()
gameCount: 0,
isCritical: false,
validSources: execPaths,
integrations: findEmulatorPluginIntegration(emulator, execPaths)
integrations
};
return em;

View file

@ -1,12 +1,15 @@
import z from "zod";
import { SettingsSchema } from "@shared/constants";
import Elysia, { status } from "elysia";
import { config, customEmulators, taskQueue } from "../app";
import { config, customEmulators, plugins, taskQueue } from "../app";
import fs from 'node:fs/promises';
import { existsSync } from "node:fs";
import { InstallJob } from "../jobs/install-job";
import { move } from "fs-extra";
import { getRelevantEmulators } from "./services";
import type { JSONSchema7 } from "json-schema";
import ReloadPluginsJob from "../jobs/reload-plugins-job";
import { pluginZodRegistry } from "../plugins/plugin-manager";
export const settings = new Elysia({ prefix: '/api/settings' })
.get('/emulators/automatic', async () =>
@ -77,18 +80,59 @@ export const settings = new Elysia({ prefix: '/api/settings' })
drive: z.string().optional()
})
})
.get("/:id", async ({ params: { id } }) =>
.get("local/:id", async ({ params: { id } }) =>
{
const value = config.get(id);
return { value: value };
}, {
params: z.object({ id: z.keyof(SettingsSchema) }),
}).post('/:id',
}).post('local/:id',
async ({ params: { id }, body: { value }, }) =>
{
config.set(id, value);
}, {
params: z.object({ id: z.keyof(SettingsSchema) }),
body: z.object({ value: z.any() }),
});
})
.get('/definitions/:source', async ({ params: { source } }) =>
{
return plugins.plugins[source].plugin.settingsSchema?.toJSONSchema() as JSONSchema7;
})
.get('/actions/:source', async ({ params: { source } }) =>
{
const plugin = plugins.plugins[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);
})
.get('/:source/:id', async ({ params: { source, id } }) =>
{
return { value: plugins.plugins[source].config?.get(id) };
})
.put('/:source/:id', async ({ params: { source, id }, body: { value } }) =>
{
const plugin = plugins.plugins[source];
if (!plugin.config) return status("Not Found", "Plugin has no config");
const settingSchema = plugin.plugin.settingsSchema?.shape[id] as z.ZodObject;
if (!settingSchema) return status("Not Found", "Could not find setting");
const meta = pluginZodRegistry.get(settingSchema);
if (meta?.readOnly)
{
return;
}
plugin.config?.set(id, value);
if (meta?.requiresRestart)
{
await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob());
}
},
{
body: z.object({ value: z.any() })
});

View file

@ -1,34 +1,7 @@
import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants";
import { config, emulatorsDb, plugins } from "../../app";
import * as emulatorSchema from '@schema/emulators';
import { findExecs } from "../../games/services/launchGameService";
import { eq } from "drizzle-orm";
import { EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants";
import { config, plugins } from "../../app";
import { getOrCached, getOrCachedGithubRelease } from "../../cache";
import path from "node:path";
import fs from "node:fs/promises";
export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: EmulatorSystem[])
{
const execPaths: EmulatorSourceEntryType[] = [];
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) });
if (esEmulator)
{
const allExecs = await findExecs(emulator.name, esEmulator);
execPaths.push(...allExecs);
}
const em: FrontEndEmulator = {
name: emulator.name,
logo: emulator.logo,
systems,
gameCount,
validSources: execPaths,
integrations: findEmulatorPluginIntegration(emulator.name, execPaths)
};
return em;
}
export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]): EmulatorSupport[]
{
@ -52,29 +25,6 @@ export function getEmulatorPath (emulator: string)
return path.join(config.get('downloadPath'), "emulators", emulator);
}
export async function getExistingStoreEmulatorDownload (emulator: EmulatorPackageType): Promise<(EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined>
{
const existingPackagePath = `${getEmulatorPath(emulator.name)}.json`;
if (await fs.exists(existingPackagePath))
{
const existingPackage = await EmulatorDownloadInfoSchema.parseAsync(await Bun.file(existingPackagePath).json());
const download = await getEmulatorDownload(emulator, existingPackage.type).catch(d => undefined);
if (!download) return { ...existingPackage, hasUpdate: false };
if (download.info.version)
{
if (existingPackage.version !== download.info.version) return { ...existingPackage, hasUpdate: true };
} else if (existingPackage.id !== download.info.id)
{
return { ...existingPackage, hasUpdate: true };
}
return { ...existingPackage, hasUpdate: false };
}
// this should only happen if download info is missing maybe manually deleted or wasn't saved.
return undefined;
}
export async function getEmulatorDownload (emulator: EmulatorPackageType, source: string)
{
if (!emulator.downloads) throw new Error("Emulator has no downloads");

View file

@ -6,74 +6,9 @@ import path from "node:path";
import fs from 'node:fs/promises';
import * as emulatorSchema from '@schema/emulators';
import { shuffleInPlace } from "@/bun/utils";
import { Glob } from "bun";
export async function getShuffledStoreGames ()
{
return getOrCached('shuffled-store-games', async () =>
{
const gamesManifest = await getStoreGameManifest();
const allStoreGames = gamesManifest.filter(g => g.type === 'blob');
shuffleInPlace(allStoreGames, Math.round(new Date().getTime() / 1000 / 60 / 60));
return allStoreGames;
}, { expireMs: 1000 / 60 / 60 });
}
export async function getStoreGameManifest ()
{
return getOrCached(CACHE_KEYS.STORE_GAME_MANIFEST, async () =>
{
const store = await fetch('https://api.github.com/repos/dragoonDorise/EmuDeck/git/trees/50261b66d69c1758efa28c6d7c54e45259a0c9c5?recursive=true').then(r => r.json()).then(data => GithubManifestSchema.parseAsync(data));
return store.tree.filter((e: any) =>
{
if (e.type === 'blob' && e.path !== "featured.json")
{
return true;
}
return false;
});
});
}
export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; })
{
const offset = filter?.offset ?? 0;
const limit = Math.min(50, filter?.limit ?? 10);
const games = await Promise.all(gamesManifest.slice(offset, Math.min(offset + limit, gamesManifest.length)).map((e: any) =>
{
return fetch(e.url).then(e => e.json()).then(game => StoreGameSchema.parseAsync(JSON.parse(atob(game.content.replace(/\n/g, "")))));
}));
return games;
}
export function extractStoreGameSourceId (id: string)
{
const gameId = id.split('@');
if (gameId.length !== 2)
throw new Error("Store ID should include platform and name with @ separator");
return { system: gameId[0], id: gameId[1] };
}
export function getStoreGameFromId (id: string)
{
const data = extractStoreGameSourceId(id);
return getStoreGame(data.system, data.id);
}
export async function getStoreGame (system: string, id: string)
{
return getStoreGameFromPath(`${system}/${encodeURIComponent(id)}.json`);
}
export async function getStoreGameFromPath (path: string)
{
const game = await getOrCached(CACHE_KEYS.STORE_GAME(path), () => fetch(`https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/${path}`)
.then(e => e.json())
.then(g => StoreGameSchema.parseAsync(g)));
return game;
}
export function getStoreRootFolder ()
{

View file

@ -1,19 +1,18 @@
import Elysia, { status } from "elysia";
import { config, db, taskQueue } from "../app";
import { config, db, plugins, taskQueue } from "../app";
import path from "node:path";
import fs from 'node:fs/promises';
import { EmulatorDownloadInfoSchema, StoreGameSchema } from "@/shared/constants";
import { findExecsByName } from "../games/services/launchGameService";
import { EmulatorDownloadInfoSchema } from "@/shared/constants";
import * as appSchema from '@schema/app';
import z from "zod";
import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
import { convertLocalToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
import { getPlatformsApiPlatformsGet } from "@/clients/romm";
import { CACHE_KEYS, getOrCached } from "../cache";
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "./services/gamesService";
import { getStoreFolder } from "./services/gamesService";
import { EmulatorDownloadJob } from "../jobs/emulator-download-job";
import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration, getEmulatorDownload, getExistingStoreEmulatorDownload } from "./services/emulatorsService";
import { BiosDownloadJob } from "../jobs/bios-download-job";
import { findEmulatorPluginIntegration } from "./services/emulatorsService";
export const store = new Elysia({ prefix: '/api/store' })
.get('/emulators', async ({ query }) =>
@ -23,42 +22,32 @@ export const store = new Elysia({ prefix: '/api/store' })
console.error(e);
return undefined;
});
const emulatesParsed = await getAllStoreEmulatorPackages();
let frontEndEmulators = await Promise.all(emulatesParsed
.filter(e =>
let frontEndEmulators: FrontEndEmulator[] = [];
await plugins.hooks.store.fetchEmulators.promise({ emulators: frontEndEmulators, search: query.search });
await Promise.all(frontEndEmulators.map(async e =>
{
const gameCounts = e.systems.map((s) =>
{
if (!e.os.includes(process.platform as any)) return false;
if (query.search)
const romPlatform = rommPlatforms?.find(p => p.slug === (s.romm_slug ?? s.id));
if (romPlatform)
{
const lowerCaseSearch = query.search.toLocaleLowerCase();
if (e.name.toLocaleLowerCase().includes(lowerCaseSearch) || e.systems.some(s => s.toLocaleLowerCase().includes(lowerCaseSearch)) || e.keywords?.some(k => k.toLocaleLowerCase().includes(lowerCaseSearch)))
{
return true;
}
return false;
return romPlatform.rom_count;
}
return true;
})
.map(async (emulator) =>
{
const systems = await buildStoreFrontendEmulatorSystems(emulator);
const gameCounts = await Promise.all(systems.map(async (s) =>
{
const romPlatform = rommPlatforms?.find(p => p.slug === (s.romm_slug ?? s.id));
if (romPlatform)
{
return romPlatform.rom_count;
}
return 0;
return 0;
}));
});
const gameCount = gameCounts.reduce((a, c) => a + c);
return convertStoreEmulatorToFrontend(emulator, gameCount, systems);
}));
const execPaths: EmulatorSourceEntryType[] = [];
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: e.name, sources: execPaths });
const integrations = findEmulatorPluginIntegration(e.name, execPaths);
e.gameCount = gameCounts.reduce((a, c) => a + c);
e.integrations = integrations;
}));
if (query.missing)
{
@ -98,25 +87,31 @@ export const store = new Elysia({ prefix: '/api/store' })
})
.get('/games/featured', async () =>
{
const response = await fetch('https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/featured.json');
const games = await z.object({ featured: z.array(StoreGameSchema) }).parseAsync(await response.json());
return Promise.all(games.featured.map(async g =>
const games: FrontEndGameTypeDetailed[] = [];
await plugins.hooks.store.fetchFeaturedGames.promise({ games });
return Promise.all(games.map(async g =>
{
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(`${g.system}@${g.title}`, 'store') });
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(g.id.id, g.id.source) });
if (localGame) return convertLocalToFrontendDetailed(localGame);
return convertStoreToFrontendDetailed(g.system, g.title, g);
return g;
}));
})
.get('/stats', async () =>
{
const emulatesParsed = await getAllStoreEmulatorPackages();
const storeEmulatorCount = emulatesParsed.filter(e => e.os.includes(process.platform as any)).length;
let frontEndEmulators: FrontEndEmulator[] = [];
await plugins.hooks.store.fetchEmulators.promise({ emulators: frontEndEmulators });
const storeEmulatorCount = frontEndEmulators.length;
const gameCount = await db.$count(appSchema.games);
return {
storeEmulatorCount,
gameCount
};
})
.get('/media/*', async ({ params }) =>
{
return Bun.file(path.join(getStoreFolder(), params["*"]));
})
.get('/screenshot/emulator/:id/:name', async ({ params: { id, name } }) =>
{
return Bun.file(path.join(getStoreFolder(), "media", "screenshots", id, name));
@ -124,49 +119,14 @@ export const store = new Elysia({ prefix: '/api/store' })
{ params: z.object({ id: z.string(), name: z.string() }) })
.get('/emulator/:id/update', async ({ params: { id } }) =>
{
const emulatorPackage = await getStoreEmulatorPackage(id);
const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage!);
return downloadInfo;
return plugins.hooks.store.fetchDownload.promise({ id });
},
{
response: z.union([z.intersection(EmulatorDownloadInfoSchema, z.object({ hasUpdate: z.boolean() })), z.undefined()])
})
.get('/emulator/:id', async ({ params: { id } }) =>
{
const emulatorPackage = await getStoreEmulatorPackage(id);
if (!emulatorPackage) return status("Not Found");
const systems = await buildStoreFrontendEmulatorSystems(emulatorPackage);
const execPaths = await findExecsByName(emulatorPackage.name);
const emulatorScreenshotsPath = path.join(getStoreFolder(), "media", "screenshots", id);
const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : [];
const biosDirPath = path.join(config.get('downloadPath'), 'bios', id);
const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : [];
const storeDownloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage);
const emulator: FrontEndEmulatorDetailed = {
name: emulatorPackage.name,
description: emulatorPackage.description,
systems,
validSources: execPaths,
screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`),
gameCount: 0,
homepage: emulatorPackage.homepage,
downloads: (await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d =>
{
const download = await getEmulatorDownload(emulatorPackage, d.type).catch(e => undefined);
return download?.info;
}) ?? [])).filter(d => !!d).map(d => ({ name: d.type, type: d.type, version: d.version })),
logo: emulatorPackage.logo,
biosRequirement: emulatorPackage.bios,
bios: biosFiles,
integrations: findEmulatorPluginIntegration(emulatorPackage.name, execPaths),
storeDownloadInfo: storeDownloadInfo
};
return emulator;
return plugins.hooks.store.fetchEmulator.promise({ id });
}, { params: z.object({ id: z.string() }) })
.post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) =>
{

View file

@ -2,7 +2,7 @@ import Elysia from "elysia";
import open from 'open';
import z from "zod";
import os from 'node:os';
import { cachePath, config, events } from "./app";
import { cachePath, config, events, taskQueue } from "./app";
import { isSteamDeck, openExternal } from "../utils";
import fs from 'node:fs/promises';
import buildNotificationsStream from "./notifications";
@ -12,6 +12,22 @@ import { getDevices, getDevicesCurated } from "./drives";
import getFolderSize from "get-folder-size";
import si from 'systeminformation';
import { getStoreFolder } from "./store/services/gamesService";
import ReloadPluginsJob from "./jobs/reload-plugins-job";
import { semver } from "bun";
import packageDef from '~/package.json';
async function checkUpdate ()
{
const latest = await fetch('https://api.github.com/repos/simeonradivoev/gameflow-deck/releases/latest');
if (latest.ok)
{
const data = await latest.json();
const hasUpdate = semver.order(data.tag_name, packageDef.version);
return hasUpdate;
}
return 0;
}
export const system = new Elysia({ prefix: '/api/system' })
.post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) =>
@ -60,29 +76,64 @@ export const system = new Elysia({ prefix: '/api/system' })
set.headers["cache-control"] = 'no-cache';
set.headers['connection'] = 'keep-alive';
return new Response(buildNotificationsStream());
})
.get('/notifications/all', ({ }) =>
{
})
.ws('/info/system', {
response: z.discriminatedUnion('type', [
z.object({ type: z.literal('info'), data: SystemInfoSchema }),
z.object({ type: z.literal('focus') })
z.object({ type: z.literal('focus') }),
z.object({ type: z.literal('loading'), progress: z.number(), state: z.string().optional() }),
z.object({ type: z.literal('loaded') }),
]),
async open (ws)
{
const battery = await si.battery();
const wifi = await si.wifiConnections();
const bluetooth = await si.bluetoothDevices();
ws.send({
type: 'info',
data: {
battery: battery,
wifiConnections: wifi,
bluetoothDevices: bluetooth
}
}, true);
const existingLoading = taskQueue.findJob(ReloadPluginsJob.id, ReloadPluginsJob);
if (existingLoading) ws.send({ type: 'loading', progress: existingLoading.progress, state: existingLoading.state });
else ws.send({ type: 'loaded' });
const startInfo = async () =>
{
const battery = await si.battery();
const wifi = await si.wifiConnections();
const bluetooth = await si.bluetoothDevices();
ws.send({
type: 'info',
data: {
battery: battery,
wifiConnections: wifi,
bluetoothDevices: bluetooth
}
}, true);
};
startInfo();
const handleFocus = () => ws.send({ type: 'focus' });
events.on('focus', handleFocus);
(ws.data as any).dispose = [() => events.removeListener('focus', handleFocus)];
const dispose: (() => void)[] = [];
dispose.push(taskQueue.on('progress', e =>
{
if (e.id !== ReloadPluginsJob.id) return;
ws.send({ type: "loading", progress: e.progress, state: e.state });
}));
dispose.push(taskQueue.on('started', e =>
{
if (e.id !== ReloadPluginsJob.id) return;
ws.send({ type: "loading", progress: 0 });
}));
dispose.push(taskQueue.on('ended', e =>
{
if (e.id !== ReloadPluginsJob.id) return;
ws.send({ type: "loaded" });
}));
(ws.data as any).dispose = [...dispose, () =>
{
events.removeListener('focus', handleFocus);
}];
(ws.data as any).observer = setInterval(async () =>
{
const battery = await si.battery();
@ -209,4 +260,8 @@ export const system = new Elysia({ prefix: '/api/system' })
await openExternal(url);
}, {
body: z.object({ url: z.string() })
})
.get('/update', async () =>
{
return checkUpdate();
});

View file

@ -1,7 +1,8 @@
import { and } from 'drizzle-orm';
import EventEmitter from 'node:events';
import z from 'zod';
import z, { any } from 'zod';
export class TaskQueue
{
@ -121,29 +122,29 @@ export interface EventsList
queued: [e: BaseEvent];
}
interface BaseEvent
export interface BaseEvent
{
id: string;
job: IPublicJob<any, string, any>;
}
interface ErrorEvent extends BaseEvent
export interface ErrorEvent extends BaseEvent
{
error: unknown;
}
interface AbortEvent extends BaseEvent
export interface AbortEvent extends BaseEvent
{
reason?: any;
}
interface ProgressEvent extends BaseEvent
export interface ProgressEvent extends BaseEvent
{
progress: number;
state?: string;
}
interface CompletedEvent extends BaseEvent
export interface CompletedEvent extends BaseEvent
{
}