Compare commits

...

7 commits

Author SHA1 Message Date
5593985884
chore: Fixed tests 2026-05-15 15:07:51 +03:00
9141fb35d4
feat: Implemented link game importing
feat: Implemented download page for downloading roms from various sources using plugins. Added support for internet archive external plugin.
feat: Added tasks page to track running tasks/downloads
feat: Added tanstack caching
feat: Added quick play action Fixes #6
feat: Added quick emulator launch action
fix: Made task queue only support 1 task per group and task ID should now be unique
2026-05-15 13:50:55 +03:00
9a3e605625
chore(release): 1.6.0
All checks were successful
Build and Upload Canary / build (push) Successful in 5m30s
2026-05-10 02:53:05 +03:00
2e78ddf08e
refactor: moved to commit-and-tag-version 2026-05-10 02:51:49 +03:00
38cb752552
feat: Implemented public plugin system accessible from the store.
feat: Implemented external ryujinx integration plugin
refactor: moved sdk types and schemas to own workspace package
fix: Fixed emulator launch with no game
2026-05-10 02:21:01 +03:00
9051834ace
chore: Updated packages 2026-05-07 14:43:48 +03:00
f82bf1215a
doc: Added discord link 2026-05-07 04:08:17 +03:00
153 changed files with 4222 additions and 1913 deletions

1
.gitignore vendored
View file

@ -28,6 +28,7 @@ downloads
gameflow-deck.code-workspace
.env.local
src/tests/mock-roms/db.sqlite
src/tests/mock-roms/store
src/tests/mock-config
bin
.config/flatpak/repo

18
.versionrc Normal file
View file

@ -0,0 +1,18 @@
{
"packageFiles": [
{
"filename": "package.json",
"type": "json"
}
],
"bumpFiles": [
{
"filename": "package.json",
"type": "json"
},
{
"filename": "src/packages/gameflow-sdk/package.json",
"type": "json"
}
]
}

View file

@ -1,6 +1,13 @@
# Changelog
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
## [1.6.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.5.0...v1.6.0) (2026-05-09)
### Features
* Implemented public plugin system accessible from the store. ([38cb752](https://github.com/simeonradivoev/gameflow-deck/commit/38cb7525527b5ad4f6eb284cdad0001fd87eaf7e))
## [1.5.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.4.0...v1.5.0) (2026-05-05)

View file

@ -1,4 +1,4 @@
# <img src="src/mainview/public/icon.svg" title="Home Screen Showing games sorted by latest activity" style="width:2.5rem;margin-bottom:-1rem" /> Gameflow Deck
# Gameflow Deck
A Cross-Platform open source Retro gaming frontend designed for handheld and controllers.
Focused on building a simple user experience and intuitive UI as a curated community driven experience.
@ -7,6 +7,12 @@ Focused on building a simple user experience and intuitive UI as a curated commu
> This app is actively in development, it is constantly changing and improving.
> It will have an opinionated design and will be used as an experiment in discovering a good UX.
## Community
Join us on Discord, where you can ask questions, submit ideas and get help.
[![](https://invidget.switchblade.xyz/R9KakhY67d)](https://discord.gg/R9KakhY67d)
## Features
### Integrations

698
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@
"email": "work@simeonradivoev.com",
"url": "https://simeonradivoev.com"
},
"version": "1.5.0",
"version": "1.6.0",
"description": "Game Launcher",
"icon": "./src/mainview/assets/icon.svg",
"main": "./src/bun/index.ts",
@ -18,6 +18,9 @@
},
"packageManager": "bun@1.3.9",
"type": "module",
"workspaces": [
"./src/packages/gameflow-sdk"
],
"scripts": {
"dev": "NODE_ENV=development bun run build:vite && conc 'bun run ./scripts/dev.ts'",
"dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run ./scripts/dev.ts'",
@ -27,6 +30,7 @@
"build:prod:vite": "NODE_ENV=production bun run build:vite",
"build:dev:vite": "NODE_ENV=development bun run build:vite",
"build": "bun run build:vite && bun run ./scripts/package-bun.ts",
"build:non-compiled": "bun run build:vite && NON_COMPILED=true bun run ./scripts/package-bun.ts",
"build:prod": "NODE_ENV=production bun run build",
"build:linux": "TARGET=bun-linux-x64 bun run build",
"openapi-ts": "bun run ./scripts/romm/openapi-ts.ts",
@ -42,66 +46,67 @@
"flatpak:install": "bun run flatpak:build && flatpak --user install --reinstall \"$PWD/.config/flatpak/repo\" com.simeonradivoev.gameflow-deck",
"build:prod:appimage": "bun run build:prod && bun run ./scripts/build-appimage.ts",
"build:dev:appimage": "bun run build && bun run ./scripts/build-appimage.ts",
"version:generate": "standard-version --sign",
"version:generate": "commit-and-tag-version --sign",
"package:Linux": "bun run build:prod:appimage",
"package:Windows": "bun run build:prod",
"download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium",
"download:nwjs": "bun scripts/download-nw.ts",
"build:audiosprites": "bun ./scripts/generate-audio-sprites.ts",
"tsc": "tsc --noEmit",
"build:sdk": "bun ./scripts/build-sdk.ts",
"publish:sdk": "bun build:sdk && bun publish --cwd ./dist-sdk/ --access public"
"publish:sdk": "bun publish --cwd ./src/packages/gameflow-sdk/ --access public"
},
"dependencies": {
"7zip-bin": "^5.2.0",
"@auth/core": "^0.34.3",
"@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.6",
"@jimp/wasm-webp": "^1.6.0",
"@elysiajs/cors": "^1.4.2",
"@elysiajs/eden": "^1.4.9",
"@jimp/wasm-webp": "^1.6.1",
"@phalcode/ts-igdb-client": "^1.0.26",
"cheerio": "^1.2.0",
"conf": "^15.0.2",
"drizzle-orm": "^0.45.1",
"elysia": "^1.4.22",
"fs-extra": "^11.3.3",
"conf": "^15.1.0",
"drizzle-orm": "^0.45.2",
"elysia": "^1.4.28",
"fs-extra": "^11.3.5",
"get-folder-size": "^5.0.0",
"ini": "^6.0.0",
"jimp": "^1.6.0",
"jimp": "^1.6.1",
"mustache": "^4.2.0",
"node-7z": "^3.0.0",
"node-disk-info": "^1.3.0",
"node-downloader-helper": "^2.1.10",
"node-downloader-helper": "^2.1.11",
"node-stream-zip": "^1.15.0",
"node-unrar-js": "^2.0.2",
"npm-check-updates": "^22.2.0",
"open": "^11.0.0",
"p-queue": "^9.1.2",
"p-queue": "^9.2.0",
"pathe": "^2.0.3",
"slugify": "^1.6.9",
"smol-toml": "^1.6.1",
"systeminformation": "^5.31.5",
"tapable": "^2.3.0",
"tough-cookie": "^6.0.0",
"systeminformation": "^5.31.6",
"tapable": "^2.3.3",
"tough-cookie": "^6.0.1",
"tough-cookie-file-store": "^3.3.0",
"unzip-stream": "^0.3.4",
"webview-bun": "^2.4.0",
"zod": "^4.3.6"
"zod": "^4.4.3"
},
"devDependencies": {
"@ap0nia/eden": "^1.0.0-next.22",
"@ap0nia/eden": "^1.6.1",
"@ap0nia/eden-tanstack-query": "^1.0.0-next.22",
"@emulatorjs/emulatorjs": "^4.2.3",
"@hey-api/openapi-ts": "^0.91.0",
"@hey-api/openapi-ts": "^0.91.1",
"@noriginmedia/norigin-spatial-navigation": "^3.1.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-form": "^1.28.0",
"@tanstack/react-query": "^5.90.20",
"@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-router": "^1.157.16",
"@tanstack/react-router-devtools": "^1.154.12",
"@tanstack/react-router-ssr-query": "^1.157.17",
"@tanstack/router-plugin": "^1.157.16",
"@tanstack/zod-adapter": "^1.162.4",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-form": "^1.32.0",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-query-devtools": "^5.100.10",
"@tanstack/react-query-persist-client": "^5.100.10",
"@tanstack/react-router": "^1.169.2",
"@tanstack/react-router-devtools": "^1.166.13",
"@tanstack/react-router-ssr-query": "^1.166.12",
"@tanstack/router-plugin": "^1.167.35",
"@tanstack/zod-adapter": "^1.166.9",
"@types/adm-zip": "^0.5.8",
"@types/audiosprite": "^0.7.3",
"@types/bun": "latest",
@ -112,43 +117,42 @@
"@types/mustache": "^4.2.6",
"@types/node-7z": "^2.1.11",
"@types/rclone.js": "^0.6.3",
"@types/react": "^19.2.9",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/unzip-stream": "^0.3.4",
"@vitejs/plugin-react": "^5.1.2",
"adm-zip": "^0.5.16",
"@vitejs/plugin-react": "^5.2.0",
"adm-zip": "^0.5.17",
"animate.css": "^4.1.1",
"app-builder-bin": "^5.0.0-alpha.13",
"audiosprite": "^0.7.2",
"babel-plugin-react-compiler": "^1.0.0",
"classnames": "^2.5.1",
"commit-and-tag-version": "^12.7.3",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"daisyui": "^5.5.14",
"drizzle-kit": "^0.31.9",
"dts-bundle-generator": "^9.5.1",
"daisyui": "^5.5.19",
"drizzle-kit": "^0.31.10",
"eden-tanstack-query": "^0.0.9",
"howler": "^2.2.4",
"idb-keyval": "^6.2.2",
"lucide-react": "^0.563.0",
"pretty-bytes": "^7.1.0",
"pretty-ms": "^9.3.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-error-boundary": "^6.1.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-error-boundary": "^6.1.1",
"react-hot-toast": "^2.6.0",
"react-markdown": "^10.1.0",
"react-qr-code": "^2.0.18",
"sass-embedded": "^1.97.3",
"standard-version": "^9.5.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"react-qr-code": "^2.0.21",
"sass-embedded": "^1.99.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.3",
"usehooks-ts": "^3.1.1",
"vite": "^7.3.1",
"vite-plugin-svg-icons-ng": "^1.5.2",
"vite": "^7.3.3",
"vite-plugin-svg-icons-ng": "^1.9.1",
"vite-static-assets-plugin": "^1.2.2",
"vite-tsconfig-paths": "^6.1.1",
"zod-to-ts": "^2.0.0"
"vite-tsconfig-paths": "^6.1.1"
}
}

View file

@ -1,64 +0,0 @@
import path from 'node:path';
import appPkg from '../package.json';
import sdkTsConfig from './sdk/sdk.tsconfig.json';
import sdkPackage from './sdk/package.json';
import { emptyDir } from 'fs-extra';
import { generateDtsBundle } from 'dts-bundle-generator';
import { zodToTs, createAuxiliaryTypeStore, printNode } from 'zod-to-ts';
import fs from 'node:fs/promises';
import * as types from './sdk/sdk';
const zodTypeRegex = /z\.infer<typeof? ([\w\d]+)>/gm;
async function generateApiDeclarations ()
{
const tmpConfigPath = "./scripts/sdk/sdk.tsconfig.json";
const outDir = path.join(path.dirname(tmpConfigPath), sdkTsConfig.compilerOptions.outDir);
await emptyDir(outDir);
const results = generateDtsBundle([{
filePath: './scripts/sdk/sdk.ts',
output: {
inlineDeclareGlobals: true,
sortNodes: true,
}
},], { preferredConfigPath: './scripts/sdk/sdk.tsconfig.json' });
const auxiliaryTypeStore = createAuxiliaryTypeStore();
await Bun.write('./dist-sdk/index.d.ts', results.map(r =>
{
const result = r;
return result.replaceAll(zodTypeRegex, (e, name) =>
{
const schema = types[name as keyof typeof types];
if (schema)
{
try
{
const { node } = zodToTs(schema as any, { auxiliaryTypeStore, unrepresentable: 'any' });
return printNode(node);
} catch (error)
{
console.error(error);
return e;
}
}
return e;
});
}));
const pkg = {
...sdkPackage,
license: appPkg.license,
version: appPkg.version,
repository: appPkg.repository,
author: appPkg.author,
peerDependencies: appPkg.dependencies
};
await Bun.write(path.join(outDir, 'package.json'), JSON.stringify(pkg, null, 3));
await fs.cp('./scripts/sdk/README.md', path.join(outDir, 'README.md'));
}
await generateApiDeclarations();

View file

@ -85,6 +85,13 @@ watch("./src/bun", { recursive: true }, (event, filename) =>
restart();
});
watch("./src/packages", { recursive: true }, (event, filename) =>
{
if (restarting) return;
console.log(`[watcher] ${event}: ${filename} — restarting...`);
restart();
});
let server: Bun.Subprocess | undefined = spawnServer();
if (!process.env.HEADLESS)
{

View file

@ -1,9 +0,0 @@
{
"name": "@simeonradivoev/gameflow-sdk",
"types": "index.d.ts",
"description": "plugin SDK for the Gameflow Deck Launcher",
"keywords": [
"gameflow",
"sdk"
]
}

View file

@ -1,18 +0,0 @@
import { SettingsType } from '@/shared/constants';
import Conf from 'conf';
import { AppEventMap } from '../../src/bun/types/types';
import EventEmitter from "node:events";
import { TaskQueue } from '@/bun/api/task-queue';
export * from '../../src/bun/types/types.schema';
export * from '../../src/bun/types/types';
export * from '../../src/bun/api/hooks/app';
export * from '../../src/shared/constants';
export * from '../../src/shared/types';
export * from '../../src/shared/utils';
export declare const config: Conf<SettingsType>;
export declare let events: EventEmitter<AppEventMap>;
export declare let taskQueue: TaskQueue;
export { };

View file

@ -1,5 +1,5 @@
import { TaskQueue } from "./task-queue";
import { TaskQueue, AppEventMap } from "@simeonradivoev/gameflow-sdk";
import { Database } from "bun:sqlite";
import { CookieJar } from 'tough-cookie';
import FileCookieStore from 'tough-cookie-file-store';
@ -8,7 +8,7 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
import Conf from "conf";
import projectPackage from '~/package.json';
import { SettingsSchema, SettingsType } from "@shared/constants";
import { SettingsType, SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared';
import { client } from "@clients/romm/client.gen";
import * as schema from "@schema/app";
import cacheSchema from "@schema/cache";
@ -24,7 +24,6 @@ import controls from './controls/controls';
import { RunAPIServer } from "./rpc";
import { RunBunServer } from "../server";
import ReloadPluginsJob from "./jobs/reload-plugins-job";
import { AppEventMap } from "../types/types";
export let config: Conf<SettingsType>;
export let customEmulators: Conf<Record<string, string>>;
@ -117,6 +116,13 @@ export async function cleanup ()
cleannedUp = true;
}
/** Reset the cleanup flags. This is mainly used by tests since they run the same app. */
export async function resetCleanup ()
{
cleaningUp = false;
cleannedUp = false;
}
export async function reloadDatabase ()
{
await ensureDir(config.get('downloadPath'));

View file

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

View file

@ -72,7 +72,6 @@ export class GamepadWindows implements IGamepadBackend
private index: number;
private buffer = new ArrayBuffer(16);
private view = new DataView(this.buffer);
private prevButtons = 0;
private currButtons = 0;
constructor(index = 0) { this.index = index; }

View file

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

View file

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

View file

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

View file

@ -4,7 +4,8 @@ import { and, desc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm"
import z from "zod";
import * as schema from "@schema/app";
import fs from "node:fs/promises";
import { GameListFilterSchema, SERVER_URL } from "@shared/constants";
import { SERVER_URL } from "@shared/constants";
import { CommandEntry, DownloadLookupEntry, DownloadsLookupFilterValues, GameListFilterSchema } from '@simeonradivoev/gameflow-sdk/shared';
import { InstallJob } from "../jobs/install-job";
import path from "node:path";
import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
@ -22,7 +23,7 @@ import { LaunchGameJob } from "../jobs/launch-game-job";
import { cores } from "../emulatorjs/emulatorjs";
import { findEmulatorPluginIntegration } from "../store/services/emulatorsService";
import { ImportJob } from "../jobs/import-job";
import { EmulatorSourceEntryType, EmulatorSystem, FrontEndFilterLists, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailedEmulator, FrontEndGameTypeWithIds, FrontEndId, GameLookup } from "@/shared/types";
import { EmulatorSourceEntryType, EmulatorSystem, FrontEndFilterLists, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailedEmulator, FrontEndGameTypeWithIds, FrontEndId, GameLookup } from "@simeonradivoev/gameflow-sdk/shared";
// A custom jimp that supports webp
const Jimp = createJimp({
@ -453,18 +454,18 @@ export default new Elysia()
}, {
params: z.object({ id: z.string(), source: z.string() }),
})
.post('/game/:source/:id/install', async ({ params: { id, source }, body: { downloadId } }) =>
.post('/game/:source/:id/install', async ({ params: { id, source }, body }) =>
{
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
{
return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, { downloadId }));
return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, body));
} else
{
return status('Not Implemented');
}
}, {
params: z.object({ id: z.string(), source: z.string() }),
body: z.object({ downloadId: z.string().optional() }),
body: z.object({ downloadId: z.string().optional() }).optional(),
response: z.any()
})
.delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
@ -511,7 +512,25 @@ export default new Elysia()
await plugins.hooks.games.gameLookup.promise(matches, { source, id });
return Array.from(matches.values()).flatMap(m => m);
})
.post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) =>
.get('/game/:source/:id/commands', async ({ params: { id, source }, set }) =>
{
const validCommands = await getValidLaunchCommandsForGame(source, id);
if (validCommands instanceof Error)
{
return errorToResponse(validCommands, set);
}
return validCommands as {
commands: CommandEntry[];
gameId: FrontEndId;
source?: string;
sourceId?: string;
} | undefined;
}, {
response: z.object({
commands: z.custom<CommandEntry>().array()
})
})
.post('/game/:source/:id/play', async ({ params: { id, source }, body: { command_id }, set }) =>
{
const validCommands = await getValidLaunchCommandsForGame(source, id);
if (validCommands)
@ -524,7 +543,7 @@ export default new Elysia()
{
try
{
const validCommand = body.command_id ? validCommands.commands.find(c => c.id === body.command_id) : validCommands.commands[0];
const validCommand = command_id ? validCommands.commands.find(c => c.id === command_id) : validCommands.commands[0];
if (validCommand)
{
// launch command waits for the game to exit, we don't want that.
@ -675,7 +694,10 @@ export default new Elysia()
.post('/add/custom', async ({ body: { source, id, platformId, gamePath } }) =>
{
if (taskQueue.hasActiveOfType(ImportJob)) return status("Conflict", "Import Job Already Running");
const data = await taskQueue.enqueue(ImportJob.id, new ImportJob(source, id, gamePath, platformId), true);
const data = await taskQueue.enqueue(ImportJob.query({ source, id }), new ImportJob(source, id, gamePath, platformId), {
throwOnCancel: true
});
return { source: 'local', id: data.localId };
}, {
body: z.object({
@ -684,4 +706,41 @@ export default new Elysia()
gamePath: z.string(),
platformId: z.number()
})
}).get('/downloads/lookup', async ({ query: { search, page, rows, orderBy, sortDirection, source } }) =>
{
const matches = new Map<string, { count: number, items: DownloadLookupEntry[]; }>();
await plugins.hooks.games.downloadsLookup.promise(matches, { search, page, rows, orderBy, sortDirection, source });
const allValues = Array.from(matches.values());
return { hadMatchers: matches.size > 0, matches: allValues.flatMap(m => m.items), totalCount: allValues.reduce((p, c) => p + c.count, 0) };
}, {
query: z.object({
search: z.string().optional(),
page: z.coerce.number().optional(),
rows: z.coerce.number().optional(),
orderBy: z.string().optional(),
sortDirection: z.literal(["desc", "asc"]).optional(),
source: z.string().optional()
})
}).get('/download/lookup/:source/:id', async ({ params: { source, id } }) =>
{
const match = await plugins.hooks.games.downloadLookup.promise({ source, id });
if (!match) return status("Not Found");
return match;
}).get('/download/file/info', async ({ query: { file_url } }) =>
{
const response = await fetch(file_url, { method: "HEAD" });
if (!response.ok) return status('Internal Server Error', response.statusText);
return { size: Number(response.headers.get('content-length')), content_type: response.headers.get('content-type') };
}, {
query: z.object({ file_url: z.url() })
}).get('/download/lookup/filters', async () =>
{
const filters: DownloadsLookupFilterValues = {
source: [],
orderBy: []
};
await plugins.hooks.games.downloadsLookupFilters.promise({ filters });
return filters;
});

View file

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

View file

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

View file

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

View file

@ -8,7 +8,7 @@ import { RPC_URL } from "@shared/constants";
import { hashFile } from "@/bun/utils";
import { host } from "@/bun/utils/host";
import * as emulatorSchema from "@schema/emulators";
import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata } from "@/shared/types";
import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared";
export async function calculateSize (installPath: string | null)
{
@ -468,3 +468,39 @@ export async function createLocalGame (info: {
return id;
}
export async function downloadGame (ctx: {
downloads: DownloadFileEntry[],
auth?: string,
id: string,
abortSignal?: AbortSignal,
setProgress?: (progress: number, state: "download" | "extract", info: Partial<Omit<ProgressStats, 'progress'>>) => void,
extract_path?: string;
path_fs?: string;
}): Promise<string[] | undefined>
{
const downloadedFiles = await plugins.hooks.downloadFiles.promise({
id: ctx.id,
auth: ctx.auth,
files: ctx.downloads,
downloadPath: config.get('downloadPath'),
abortSignal: ctx.abortSignal,
updateProgress: (stats) => ctx.setProgress?.(stats.progress, 'download', stats)
});
if (!downloadedFiles)
{
return;
}
const finalFiles = await plugins.hooks.postDownloadFiles.promise({
files: downloadedFiles.files,
source: downloadedFiles.source,
extract_path: ctx.extract_path,
downloadPath: config.get('downloadPath'),
path_fs: ctx.path_fs
}) ?? downloadedFiles.files;
return finalFiles;
}

View file

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

View file

@ -1,35 +1,44 @@
import z from "zod";
import { IJob, JobContext } from "../task-queue";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
import { config, plugins } from "../app";
import { simulateProgress } from "@/bun/utils";
import { Downloader } from "@/bun/utils/downloader";
import path from 'node:path';
import { ensureDir } from "fs-extra";
import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
import { DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
export class BiosDownloadJob implements IJob<z.infer<typeof BiosDownloadJob.dataSchema>, "download">
interface BiosDownloadJobData extends DownloadJobData
{
emulator: string;
}
export class BiosDownloadJob implements IJob<BiosDownloadJobData, "download">
{
static id = "bios-download-job" as const;
static dataSchema = z.object({ emulator: z.string() });
static query = (q: { id: string; }) => `${BiosDownloadJob.id}-${q.id}`;
group: string = "bios-download";
emulator: string;
data: BiosDownloadJobData;
dryRun: boolean;
constructor(emulator: string, init?: { dryRun?: boolean; })
{
this.emulator = emulator;
this.data = {
emulator,
name: "Download Emulator Bios"
};
this.dryRun = init?.dryRun ?? false;
}
async start (context: JobContext<IJob<z.infer<typeof BiosDownloadJob.dataSchema>, "download">, z.infer<typeof BiosDownloadJob.dataSchema>, "download">)
async start (context: JobContext<IJob<BiosDownloadJobData, "download">, BiosDownloadJobData, "download">)
{
const emulator = await getStoreEmulatorPackage(this.emulator);
const emulator = await getStoreEmulatorPackage(this.data.emulator);
if (!emulator) throw new Error("Could Not Find Emulator");
this.data.name = `${emulator.name} Bios`;
this.data.preview_url = emulator.logo;
const systems = await buildStoreFrontendEmulatorSystems(emulator);
const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
const biosFolder = path.join(config.get('downloadPath'), "bios", this.data.emulator);
await ensureDir(biosFolder);
const files = await plugins.hooks.emulators.fetchBiosDownload.promise({ emulator: this.emulator, systems, biosFolder });
const files = await plugins.hooks.emulators.fetchBiosDownload.promise({ emulator: this.data.emulator, systems, biosFolder });
if (!files) throw new Error("Could not find source to download from");
@ -45,9 +54,12 @@ export class BiosDownloadJob implements IJob<z.infer<typeof BiosDownloadJob.data
const downloader = new Downloader('bios-download', files.files, biosFolder, {
signal: context.abortSignal,
headers,
onProgress (stats)
onProgress: (stats) =>
{
context.setProgress(stats.progress, "download");
this.data.downloaded = stats.downloaded;
this.data.speed = stats.speed;
this.data.total = stats.total;
},
});
@ -57,6 +69,6 @@ export class BiosDownloadJob implements IJob<z.infer<typeof BiosDownloadJob.data
exposeData ()
{
return { emulator: this.emulator };
return this.data;
}
}

View file

@ -1,46 +1,54 @@
import { EmulatorPackageType } from "@/shared/constants";
import { DownloadJobData, EmulatorPackageType } from '@simeonradivoev/gameflow-sdk/shared';
import { getStoreEmulatorPackage } from "../store/services/gamesService";
import { IJob, JobContext } from "../task-queue";
import z from "zod";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
import { config, plugins } from "../app";
import path from 'node:path';
import Seven from 'node-7z';
import fs from "node:fs/promises";
import { Downloader } from "@/bun/utils/downloader";
import { ensureDir, move } from "fs-extra";
import { simulateProgress } from "@/bun/utils";
import { isArchive, simulateProgress } from "@/bun/utils";
import { path7za } from "7zip-bin";
import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService";
import { $ } from "bun";
import { EmulatorSourceEntryType } from "@/shared/types";
import { EmulatorSourceEntryType } from "@simeonradivoev/gameflow-sdk/shared";
type EmulatorDownloadStates = "download" | "extract";
export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>
interface EmulatorDownloadJobData extends DownloadJobData
{
emulator: string;
}
export class EmulatorDownloadJob implements IJob<EmulatorDownloadJobData, EmulatorDownloadStates>
{
static id = "download-emulator" as const;
static dataSchema = z.object({ emulator: z.string() });
emulator: string;
downloadSource: string;
emulatorPackage?: EmulatorPackageType;
dryRun: boolean;
isUpdate: boolean;
data: EmulatorDownloadJobData = {
name: "Download Emulator",
emulator: ""
};
constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; isUpdate?: boolean; })
{
this.emulator = emulator;
this.data.emulator = emulator;
this.downloadSource = downloadSource;
this.dryRun = init?.dryRun ?? false;
this.isUpdate = init?.isUpdate ?? false;
}
async start (context: JobContext<EmulatorDownloadJob, z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>)
async start (context: JobContext<EmulatorDownloadJob, EmulatorDownloadJobData, EmulatorDownloadStates>)
{
this.emulatorPackage = await getStoreEmulatorPackage(this.emulator);
this.emulatorPackage = await getStoreEmulatorPackage(this.data.emulator);
if (!this.emulatorPackage) throw new Error("Emulator not found");
this.data.name = this.emulatorPackage.name;
this.data.preview_url = this.emulatorPackage.logo;
const { url, info } = await getEmulatorDownload(this.emulatorPackage, this.downloadSource);
const emulatorsFolder = getEmulatorPath(this.emulator);
const emulatorsFolder = getEmulatorPath(this.data.emulator);
if (this.dryRun)
{
@ -49,29 +57,33 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
} else
{
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
const downloader = new Downloader(this.emulator,
[{ url, file_name: path.basename(url.pathname), file_path: this.emulator }],
const downloader = new Downloader(this.data.emulator,
[{ url, file_name: path.basename(url.pathname), file_path: this.data.emulator }],
tmpFolder,
{
signal: context.abortSignal,
onProgress (stats)
onProgress: (stats) =>
{
context.setProgress(stats.progress, 'download');
this.data.total = stats.total;
this.data.downloaded = stats.downloaded;
this.data.speed = stats.speed;
},
});
const destinationPaths = await downloader.start();
context.abortSignal.throwIfAborted();
if (destinationPaths)
{
const isArchive = destinationPaths[0].endsWith('.7z') || destinationPaths[0].endsWith('.zip') || destinationPaths[0].endsWith('.tar');
const archive = isArchive(destinationPaths[0]);
const isAppImage = destinationPaths[0].endsWith(".AppImage");
if (!isArchive && !isAppImage)
if (!archive && !isAppImage)
{
throw new Error("Invalid Download Type");
}
if (isArchive)
if (archive)
{
if (destinationPaths[0])
{
@ -120,10 +132,10 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
await Bun.write(`${emulatorsFolder}.json`, JSON.stringify(info, null, 3));
const execs: EmulatorSourceEntryType[] = [];
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: this.emulator, sources: execs });
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: this.data.emulator, sources: execs });
await plugins.hooks.emulators.emulatorPostInstall.promise({
emulator: this.emulator,
emulator: this.data.emulator,
emulatorPackage: this.emulatorPackage,
path: execs.find(e => e.type === 'store')?.binPath ?? emulatorsFolder,
info,
@ -136,7 +148,7 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
exposeData ()
{
return { emulator: this.emulator };
return this.data;
}
}

View file

@ -0,0 +1,64 @@
import { ensureDir } from "fs-extra";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
import { getStoreRootFolder } from "../store/services/gamesService";
import z from "zod";
import { runBunPackageCommand } from "../plugins/services";
import { PluginRegistry } from "@/shared/constants";
import path from "node:path";
import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json';
import { IsPluginAllowed } from "@/bun/utils";
export default class EnsureStore implements IJob<never, string>
{
static id = "update-store" as const;
static dataSchema = z.never();
packageName: string;
storeVersion: string;
constructor()
{
this.packageName = process.env.STORE_PACKAGE_NAME ?? "@simeonradivoev/gameflow-store";
this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0";
}
async start (context: JobContext<EnsureStore, never, string>)
{
const storeFolder = getStoreRootFolder();
await ensureDir(storeFolder);
const storePackageFile = Bun.file(path.join(storeFolder, "package.json"));
if (!await storePackageFile.exists())
{
await storePackageFile.write(JSON.stringify({ dependencies: {} }, null, 3));
}
const storePackage = await Bun.file(path.join(storeFolder, "package.json")).json();
if (IsPluginAllowed(sdkPkg.name))
{
if (!storePackage.dependencies?.[sdkPkg.name] || storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version)
{
let response = await runBunPackageCommand(["add", `${sdkPkg.name}@${sdkPkg.version}`, "--registry", PluginRegistry, '--omit', 'peer']);
console.log(response);
}
// probably just means we couldn't find a version of the sdk, just install latest
if (storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version)
{
let response = await runBunPackageCommand(["add", '--exact', `${sdkPkg.name}@latest`, "--registry", PluginRegistry, '--omit', 'peer']);
console.log(response);
}
} else
{
console.log("Ignoring SDK package");
}
if (process.env.CUSTOM_STORE_PATH) return;
if (!storePackage.dependencies?.['@simeonradivoev/gameflow-store'])
{
context.setProgress(0.5, "Adding Store");
let response = await runBunPackageCommand(["add", `${this.packageName}@${this.storeVersion}`, "--registry", PluginRegistry, '--omit', 'peer']);
console.log(response);
}
}
}

View file

@ -1,21 +1,32 @@
import { eq, or } from "drizzle-orm";
import { eq, inArray, or } from "drizzle-orm";
import { db, plugins } from "../app";
import { createLocalGame } from "../games/services/utils";
import { IJob, JobContext } from "../task-queue";
import { createLocalGame, downloadGame } from "../games/services/utils";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
import * as schema from "@schema/app";
import z from "zod";
import { GameLookup } from "@/shared/types";
import { DownloadJobData, GameLookup } from "@simeonradivoev/gameflow-sdk/shared";
import { isUrl } from "@/shared/utils";
import { basename } from "node:path";
import path from 'node:path';
import { isArchive } from "@/bun/utils";
export class ImportJob implements IJob<z.infer<typeof ImportJob.dataSchema>, string>
interface ImportJobData extends DownloadJobData
{
localId: number | null;
}
export class ImportJob implements IJob<ImportJobData, string>
{
static id = "import-job" as const;
static dataSchema = z.object({ localId: z.number().nullable() });
static query = (q: { source: string; id: string; }) => `${ImportJob.id}-${q.source}-${q.id}`;
data: ImportJobData = {
localId: null,
name: "Import Game"
};
group?: 'import-job';
gamePath: string;
source: string;
id: string;
platformId: number;
localId: number | null = null;
constructor(source: string, id: string, gamePath: string, platformId: number)
{
@ -25,18 +36,20 @@ export class ImportJob implements IJob<z.infer<typeof ImportJob.dataSchema>, str
this.platformId = platformId;
}
exposeData (): z.infer<typeof ImportJob.dataSchema>
exposeData ()
{
return { localId: this.localId };
return this.data;
}
async start (context: JobContext<IJob<z.infer<typeof ImportJob.dataSchema>, string>, z.infer<typeof ImportJob.dataSchema>, string>): Promise<any>
async start (context: JobContext<IJob<ImportJobData, string>, ImportJobData, string>): Promise<any>
{
const matchesMap = new Map<string, GameLookup[]>();
await plugins.hooks.games.gameLookup.promise(matchesMap, { source: this.source, id: this.id });
const matches = matchesMap.values().next().value;
if (!matches || matches.length <= 0) throw Error("Could not Find Game");
const match = matches[0];
this.data.name = match.name;
this.data.preview_url = match.coverUrl;
let cover: Buffer<ArrayBufferLike> | undefined = undefined;
let coverType: string | undefined = undefined;
@ -50,24 +63,56 @@ export class ImportJob implements IJob<z.infer<typeof ImportJob.dataSchema>, str
}
}
const platformMatch = match.platforms.find(p => p.id === this.platformId);
const finalFiles: string[] = [];
if (isUrl(this.gamePath))
{
const archive = isArchive(this.gamePath);
const downloadedFiles = await downloadGame({
downloads: [{
file_path: this.id,
file_name: basename(this.gamePath),
url: new URL(this.gamePath)
}],
extract_path: archive ? '.tmp' : undefined,
path_fs: path.join('roms', platformMatch?.slug ?? this.source, this.id),
abortSignal: context.abortSignal,
id: `game-${this.source}-${this.id}`,
setProgress: (progress, state, info) =>
{
context.setProgress(progress, state);
this.data.speed = info.speed;
this.data.total = info.total;
this.data.downloaded = info.downloaded;
},
});
if (downloadedFiles)
finalFiles.push(...downloadedFiles);
} else
{
finalFiles.push(this.gamePath);
}
const localSearchFilters: any[] = [];
if (match.igdb_id) localSearchFilters.push(eq(schema.games.igdb_id, match.igdb_id));
if (match.slug) localSearchFilters.push(eq(schema.games.slug, match.slug));
localSearchFilters.push(eq(schema.games.name, match.name));
localSearchFilters.push(eq(schema.games.path_fs, this.gamePath));
localSearchFilters.push(inArray(schema.games.path_fs, finalFiles));
const existingLocalGame = await db.query.games.findFirst({ where: or(...localSearchFilters) });
context.abortSignal.throwIfAborted();
if (existingLocalGame) throw new Error("Game Already Exists");
const platformMatch = match.platforms.find(p => p.id === this.platformId);
this.localId = await createLocalGame({
this.data.localId = await createLocalGame({
name: match.name,
system_slug: platformMatch?.slug,
source: undefined,
source_id: undefined,
slug: match.slug,
path_fs: this.gamePath,
path_fs: finalFiles[0],
summary: match.summary,
igdb_id: match.igdb_id,
ra_id: undefined,

View file

@ -1,17 +1,12 @@
import { IJob, JobContext } from "../task-queue";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
import fs from 'node:fs/promises';
import path from 'node:path';
import { config, events, plugins } from "../app";
import { simulateProgress } from "@/bun/utils";
import { Downloader } from "@/bun/utils/downloader";
import Seven from 'node-7z';
import z from "zod";
import { checkFiles, createLocalGame } from "../games/services/utils";
import { ensureDir, move } from "fs-extra";
import { path7za } from "7zip-bin";
import StreamZip from 'node-stream-zip';
import { which } from "bun";
import { DownloadInfo } from "@/shared/types";
import { checkFiles, createLocalGame, downloadGame } from "../games/services/utils";
import { ensureDir } from "fs-extra";
import { DownloadInfo, DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
interface JobConfig
{
@ -22,7 +17,7 @@ interface JobConfig
export type InstallJobStates = 'download' | 'extract';
export class InstallJob implements IJob<never, InstallJobStates>
export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
{
static id = "install-job" as const;
static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`;
@ -34,6 +29,9 @@ export class InstallJob implements IJob<never, InstallJobStates>
public localGameId?: number;
public group = InstallJob.id;
public localPath?: string;
data: DownloadJobData = {
name: "Install Game"
};
constructor(id: string, source: string, config?: JobConfig)
{
@ -42,7 +40,7 @@ export class InstallJob implements IJob<never, InstallJobStates>
this.source = source;
}
public async start (cx: JobContext<InstallJob, never, InstallJobStates>)
public async start (cx: JobContext<InstallJob, DownloadJobData, InstallJobStates>)
{
cx.setProgress(0, 'download');
await fs.mkdir(config.get('downloadPath'), { recursive: true });
@ -58,132 +56,32 @@ export class InstallJob implements IJob<never, InstallJobStates>
if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
const files = await checkFiles(info.files, !!info.extract_path);
this.data.name = info.name;
this.data.preview_url = info.coverUrl;
const files = await checkFiles(info.files, !!info.extract_path);
if (this.config?.dryDownload !== true && files.some(f => !f.exists || !f.matches))
{
const headers: Record<string, string> = {};
if (info.auth)
headers['Authorization'] = info.auth;
const downloader = new Downloader(`game-${this.source}-${this.gameId}`,
files.filter(f => !f.exists || !f.matches),
config.get('downloadPath'),
const downloadedFiles = await downloadGame({
downloads: files.filter(f => !f.exists || !f.matches),
extract_path: info.extract_path,
path_fs: info.path_fs,
abortSignal: cx.abortSignal,
auth: info.auth,
id: `game-${this.source}-${this.gameId}`,
setProgress: (process, state, info) =>
{
signal: cx.abortSignal,
headers,
onProgress (stats)
{
cx.setProgress(stats.progress, 'download');
cx.setProgress(process, state);
this.data.downloaded = info.downloaded;
this.data.speed = info.speed;
this.data.total = info.total;
},
});
const downloadedFiles = await downloader.start();
if (!downloadedFiles)
{
return;
}
if (info.extract_path && downloadedFiles)
{
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)
{
await new Promise(async (resolve, reject) =>
{
let sevenZipPath = process.env.ZIP7_PATH ?? path7za;
if (filePath.endsWith('.rar'))
{
let newPath: string | undefined;
if (process.platform === 'win32' && await fs.exists("C:\\Program Files\\7-Zip\\7z.exe"))
{
newPath = "C:\\Program Files\\7-Zip\\7z.exe";
} else
{
newPath = which('7z') ?? undefined;
}
if (!newPath)
{
await fs.rm(filePath);
reject(new Error("No RAR Support"));
return;
}
sevenZipPath = newPath;
}
let rejected = false;
const seven = Seven.extractFull(filePath, extractPath, { $bin: sevenZipPath, $progress: true });
seven.on('progress', p =>
{
cx.setProgress(progress + p.percent * progressDelta, "extract");
});
seven.on('error', e =>
{
reject(e);
rejected = true;
});
seven.on('end', async () =>
{
if (rejected) return;
await fs.rm(filePath);
resolve(true);
});
}).catch(async e =>
{
if (filePath.endsWith('.zip'))
{
cx.setProgress(0, "extract");
console.error(e);
console.warn("Could not extract", filePath, "with 7zip trying zip extractor");
await ensureDir(extractPath);
const zip = new StreamZip.async({ file: filePath });
let entryCount = await zip.entriesCount;
let entryCounter = entryCount;
zip.on('extract', (entry, outPath) =>
{
entryCounter--;
cx.setProgress(progress + (1 - (entryCounter / entryCount)) * 100 * progressDelta, "extract");
});
const count = await zip.extract(null, extractPath);
console.log(`Extracted ${count} entries`);
await zip.close();
await fs.rm(filePath);
} else
{
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 });
}
}
finalFiles.push(extractPath);
} else
{
if (downloadedFiles)
finalFiles.push(...downloadedFiles);
}
}
if (this.config?.dryDownload === true && info.extract_path)
{
@ -193,7 +91,7 @@ export class InstallJob implements IJob<never, InstallJobStates>
const coverResponse = await fetch(info.coverUrl);
const cover = Buffer.from(await coverResponse.arrayBuffer());
if (cx.abortSignal.aborted) return;
cx.abortSignal.throwIfAborted();
this.localGameId = await createLocalGame({
cover,

View file

@ -3,22 +3,24 @@ import z, { _ZodType } from "zod";
import { taskQueue } from "../app";
import { LoginJob } from "./login-job";
import TwitchLoginJob from "./twitch-login-job";
import UpdateStoreJob from "./update-store";
import EnsureStore from "./ensure-store";
import { EmulatorDownloadJob } from "./emulator-download-job";
import { getErrorMessage } from "@/bun/utils";
import { IJob } from "../task-queue";
import { BaseEvent, IJob } from "@simeonradivoev/gameflow-sdk/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";
import { FrontEndJob } from "@simeonradivoev/gameflow-sdk/shared";
function registerJob<
const Path extends string,
const Schema extends z.ZodTypeAny,
const Query extends z.ZodTypeAny,
Schema,
const States extends string,
T extends IJob<z.infer<Schema>, States>
> (_job: { id: Path; dataSchema: Schema; query?: (q: any) => string; } & (new (...args: any[]) => T))
> (_job: {
id: Path;
query?: (q: any) => string;
} & (new (...args: any[]) => IJob<Schema, States>))
{
return new Elysia().ws(_job.id, {
body: z.discriminatedUnion('type', [
@ -30,9 +32,9 @@ function registerJob<
type: z.literal(['data', 'started', 'progress']),
state: z.string().optional(),
progress: z.number(),
data: _job.dataSchema
data: z.custom<Schema>()
}),
z.object({ type: z.literal(['completed', 'ended']), data: _job.dataSchema }),
z.object({ type: z.literal(['completed', 'ended']), data: z.custom<Schema>() }),
z.object({ type: z.literal('waiting') }),
z.object({ type: z.literal('error'), error: z.string() })
]),
@ -42,7 +44,7 @@ function registerJob<
const job = taskQueue.findJob(jobId, _job);
if (job)
{
ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() as Schema });
} else
{
ws.send({ type: 'waiting' });
@ -102,10 +104,87 @@ function registerJob<
}
export const jobs = new Elysia({ prefix: '/api/jobs' })
.ws('/list', {
response: z.discriminatedUnion('type', [
z.object({ type: z.literal("allJobs"), active: z.custom<FrontEndJob>().array(), queued: z.custom<FrontEndJob>().array() }),
z.object({ type: z.literal("started"), job: z.custom<FrontEndJob>() }),
z.object({ type: z.literal("progress"), job: z.custom<FrontEndJob>() }),
z.object({ type: z.literal("queued"), job: z.custom<FrontEndJob>() }),
z.object({ type: z.literal("aborted"), id: z.string() }),
z.object({ type: z.literal("ended"), id: z.string() }),
]),
body: z.discriminatedUnion('type', [
z.object({ type: z.literal("cancel"), id: z.string() })
]),
message (ws, message)
{
switch (message.type)
{
case "cancel":
taskQueue.cancelJob(message.id);
break;
}
},
open (ws)
{
ws.send({
type: 'allJobs',
active: taskQueue.getActiveJobs().map(j =>
{
const job: FrontEndJob = {
id: j.id,
data: j.job.exposeData?.(),
progress: j.progress,
state: j.state,
status: j.status
};
return job;
}),
queued: taskQueue.getQueuedJobs()?.map(j =>
{
const job: FrontEndJob = {
id: j.id,
data: j.job.exposeData?.(),
progress: j.progress,
state: j.state,
status: j.status
};
return job;
}) ?? []
});
(ws.data as any).dispose = [taskQueue.on('started', (e: BaseEvent) =>
{
ws.send({ type: "started", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
}),
taskQueue.on('progress', (e: BaseEvent) =>
{
ws.send({ type: "progress", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
}),
taskQueue.on('queued', (e: BaseEvent) =>
{
ws.send({ type: "queued", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
}),
taskQueue.on('abort', (e: BaseEvent) =>
{
ws.send({ type: "aborted", id: e.id });
}),
taskQueue.on('ended', (e: BaseEvent) =>
{
ws.send({ type: "ended", id: e.id });
})];
},
close (ws, code, reason)
{
(ws.data as any).dispose.forEach((d: any) => d());
},
})
.use(registerJob(LaunchGameJob))
.use(registerJob(LoginJob))
.use(registerJob(TwitchLoginJob))
.use(registerJob(UpdateStoreJob))
.use(registerJob(EnsureStore))
.use(registerJob(BiosDownloadJob))
.use(registerJob(InstallJob))
.use(registerJob(ReloadPluginsJob))

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,30 @@
import { DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
import { sleep } from "bun";
export class TestDownloadJob implements IJob<DownloadJobData, string>
{
data: DownloadJobData = {
speed: 1686,
downloaded: 0,
total: 6615841,
name: "Test Download Job"
};
group = "test-download";
async start (context: JobContext<IJob<DownloadJobData, string>, DownloadJobData, string>): Promise<any>
{
for (let i = 0; i < 10; i++)
{
await sleep(1000);
context.setProgress(i / 10 * 100, 'download');
if (context.abortSignal.aborted) return;
}
}
exposeData (): DownloadJobData
{
return this.data;
}
}

View file

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

View file

@ -1,67 +0,0 @@
import { ensureDir } from "fs-extra";
import { IJob, JobContext } from "../task-queue";
import { getStoreRootFolder } from "../store/services/gamesService";
import { tmpdir } from "node:os";
import path from "node:path";
import z from "zod";
export default class UpdateStoreJob implements IJob<never, never>
{
static id = "update-store" as const;
static dataSchema = z.never();
packageName: string;
registry: URL;
storeVersion: string;
constructor()
{
this.packageName = process.env.STORE_PACKAGE_NAME ?? "@simeonradivoev/gameflow-store";
this.registry = new URL(process.env.STORE_REGISTRY ?? "https://registry.npmjs.org");
this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0";
}
async start (context: JobContext<UpdateStoreJob, never, never>)
{
if (process.env.CUSTOM_STORE_PATH) return;
const tempCache = path.join(tmpdir(), "gameflow-bun-cache");
const storeFolder = getStoreRootFolder();
await ensureDir(storeFolder);
console.log("Adding Store Package");
let proc = Bun.spawn([process.execPath, "add", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], {
cwd: storeFolder,
stdout: 'pipe',
stderr: 'pipe',
env: {
BUN_BE_BUN: "1",
BUN_INSTALL_CACHE_DIR: tempCache
}
});
let stdout = await new Response(proc.stdout).text();
console.log(stdout);
let stderr = await new Response(proc.stderr).text();
if (stderr)
console.error(stderr);
await proc.exited;
console.log("Updating Store Package");
proc = Bun.spawn([process.execPath, "update", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], {
cwd: storeFolder,
stdout: 'pipe',
stderr: 'pipe',
env: {
BUN_BE_BUN: "1",
BUN_INSTALL_CACHE_DIR: tempCache
}
});
stdout = await new Response(proc.stdout).text();
console.log(stdout);
stderr = await new Response(proc.stderr).text();
if (stderr)
console.error(stderr);
await proc.exited;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,12 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema";
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, PlatformSchema, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
import { config, events } from "@/bun/api/app";
import path from 'node:path';
import fs from 'node:fs/promises';
import { hashFile, isSteamDeckGameMode } from "@/bun/utils";
import { hashFile, isArchive, isSteamDeckGameMode } from "@/bun/utils";
import { CACHE_KEYS, getOrCached } from "@/bun/api/cache";
import secrets from "@/bun/api/secrets";
import { getAuthToken } from "@/clients/romm/core/auth.gen";
@ -14,7 +14,7 @@ import { client } from "@/clients/romm/client.gen";
import { validateGameSource } from "@/bun/api/games/services/statusService";
import z from "zod";
import { checkLoginAndRefreshRomm } from "@/bun/api/auth";
import { DownloadFileEntry, DownloadInfo, FrontEndCollection, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, FrontEndGameTypeWithIds, FrontEndPlatformType } from "@/shared/types";
import { DownloadFileEntry, DownloadInfo, FrontEndCollection, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, FrontEndGameTypeWithIds, FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared";
import Conf from "conf";
const SettingsSchema = z.object({
@ -254,8 +254,7 @@ export default class RommIntegration implements PluginType<SettingsType>
let path_fs = path.join(rom.fs_path, rom.fs_name);
if (files.length === 1)
{
const name = files[0].file_name.toLocaleLowerCase();
if (name.endsWith('.zip') || name.endsWith('.7z') || name.endsWith('.rar'))
if (isArchive(files[0].file_name))
{
extract_path = '.';
path_fs = path.join(rom.fs_path, rom.slug ?? rom.fs_name_no_ext);

View file

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

View file

@ -1,5 +1,4 @@
import { getStoreFolder } from "@/bun/api/store/services/gamesService";
import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, StoreDownloadType, StoreGameSchema, StoreGameType } from "@/shared/constants";
import os from 'node:os';
import path from "node:path";
import * as appSchema from '@schema/app';
@ -12,7 +11,8 @@ import { shuffleInPlace } from "@/bun/utils";
import mustache from "mustache";
import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService";
import fs from "node:fs/promises";
import { CommandEntry, EmulatorSourceEntryType, EmulatorSystem, FrontEndEmulator, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, SaveFileChange } from "@/shared/types";
import { CommandEntry, EmulatorSourceEntryType, EmulatorSystem, FrontEndEmulator, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, SaveFileChange, EmulatorDownloadInfoType, StoreDownloadType, StoreGameType, EmulatorPackageType, EmulatorDownloadInfoSchema, StoreGameSchema } from "@simeonradivoev/gameflow-sdk/shared";
import { isUrl } from "@/shared/utils";
export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; })
{
@ -40,7 +40,7 @@ export async function getStoreGame (id: string)
function convertStoreMediaToPath (c: string)
{
if (c.startsWith('http'))
if (isUrl(c))
{
return `/api/romm/image?url=${encodeURIComponent(c)}`;
} else

View file

@ -1,20 +1,26 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema";
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import path, { } from 'node:path';
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService";
import { Glob, pathToFileURL } from "bun";
import { Glob, pathToFileURL, sleep, which } from "bun";
import { and, eq } from "drizzle-orm";
import * as emulatorSchema from '@schema/emulators';
import { config, emulatorsDb, taskQueue } from "@/bun/api/app";
import fs from "node:fs/promises";
import { getSourceGameDetailed } from "@/bun/api/games/services/utils";
import UpdateStoreJob from "@/bun/api/jobs/update-store";
import EnsureStore from "@/bun/api/jobs/ensure-store";
import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService";
import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services";
import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@/shared/types";
import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@simeonradivoev/gameflow-sdk/shared";
import { isUrl } from "@/shared/utils";
import { Downloader } from "@/bun/utils/downloader";
import { ensureDir, move } from "fs-extra";
import StreamZip from "node-stream-zip";
import { path7za } from "7zip-bin";
import Seven from 'node-7z';
export default class RommIntegration implements PluginType
export default class StoreIntegration implements PluginType
{
eventsNames = [{ id: 'updateStore', title: "Update Store", description: "Update the Store Manifest", action: "Update" }];
@ -23,7 +29,7 @@ export default class RommIntegration implements PluginType
switch (e)
{
case 'updateStore':
await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
await taskQueue.enqueue(EnsureStore.id, new EnsureStore());
return { reload: true };
}
}
@ -32,7 +38,7 @@ export default class RommIntegration implements PluginType
{
console.log("Store Directory is ", getStoreFolder());
ctx.setProgress(0, "Updating Store");
await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
await taskQueue.enqueue(EnsureStore.id, new EnsureStore());
}
async load (ctx: PluginLoadingContextType)
@ -151,7 +157,8 @@ export default class RommIntegration implements PluginType
if (!validDownload || !validDownload.bin) return;
const glob = new Glob(validDownload.bin);
const files = await Array.fromAsync(glob.scan({ cwd: emulatorPath }));
if (files.length > 0)
// es-de also searches for store executables so there might be duplicates, check first.
if (files.length > 0 && !sources.find(s => s.type === 'store'))
{
sources.push({ binPath: path.join(emulatorPath, files[0]), exists: true, rootPath: emulatorPath, type: 'store' });
}
@ -294,7 +301,7 @@ export default class RommIntegration implements PluginType
const info: DownloadInfo = {
id: validDownload.id,
coverUrl: game.covers?.[0] ? game.covers[0].startsWith('http') ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "",
coverUrl: game.covers?.[0] ? isUrl(game.covers[0]) ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "",
screenshotUrls: game.screenshots ?? [],
files: [{
url: new URL(validDownload.url),
@ -324,5 +331,129 @@ export default class RommIntegration implements PluginType
return info;
});
});
ctx.hooks.downloadFiles.tapPromise(desc.name, async ({ id, files, downloadPath, abortSignal, auth, updateProgress }) =>
{
const headers: Record<string, string> = {};
if (auth)
headers['Authorization'] = auth;
const downloader = new Downloader(id,
files,
downloadPath,
{
signal: abortSignal,
headers,
onProgress: updateProgress,
});
const downloadedFiles = await downloader.start();
if (downloadedFiles)
{
return { source: desc.name, files: downloadedFiles };
}
});
ctx.hooks.postDownloadFiles.tapPromise(desc.name, async ({ files, extract_path, source, downloadPath, path_fs }) =>
{
if (extract_path && files && source === desc.name)
{
let progress = 0;
const progressDelta = 1 / files.length;
const extractPath = path.join(downloadPath, path_fs ?? '', extract_path);
for (const filePath of files)
{
await new Promise(async (resolve, reject) =>
{
let sevenZipPath = process.env.ZIP7_PATH ?? path7za;
if (filePath.endsWith('.rar'))
{
let newPath: string | undefined;
if (process.platform === 'win32' && await fs.exists("C:\\Program Files\\7-Zip\\7z.exe"))
{
newPath = "C:\\Program Files\\7-Zip\\7z.exe";
} else
{
newPath = which('7z') ?? undefined;
}
if (!newPath)
{
await fs.rm(filePath);
reject(new Error("No RAR Support"));
return;
}
sevenZipPath = newPath;
}
let rejected = false;
const seven = Seven.extractFull(filePath, extractPath, { $bin: sevenZipPath, $progress: true });
seven.on('progress', p =>
{
ctx.setProgress?.(progress + p.percent * progressDelta, "extract", {
speed: 0,
total: 0,
downloaded: 0
});
});
seven.on('error', e =>
{
reject(e);
rejected = true;
});
seven.on('end', async () =>
{
if (rejected) return;
await fs.rm(filePath);
resolve(true);
});
}).catch(async e =>
{
if (filePath.endsWith('.zip'))
{
ctx.setProgress?.(0, "extract", {});
console.error(e);
console.warn("Could not extract", filePath, "with 7zip trying zip extractor");
await ensureDir(extractPath);
const zip = new StreamZip.async({ file: filePath });
let entryCount = await zip.entriesCount;
let entryCounter = entryCount;
zip.on('extract', (entry, outPath) =>
{
entryCounter--;
ctx.setProgress?.(progress + (1 - (entryCounter / entryCount)) * 100 * progressDelta, "extract", {});
});
const count = await zip.extract(null, extractPath);
console.log(`Extracted ${count} entries`);
await zip.close();
await fs.rm(filePath);
} else
{
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 });
}
}
return [extractPath];
}
});
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import z from "zod";
import { SettingsSchema } from "@shared/constants";
import { SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared';
import Elysia, { status } from "elysia";
import { config, customEmulators, plugins, taskQueue } from "../app";
import fs from 'node:fs/promises';
@ -10,6 +10,8 @@ import { getRelevantEmulators } from "./services";
import type { JSONSchema7 } from "json-schema";
import ReloadPluginsJob from "../jobs/reload-plugins-job";
import { pluginZodRegistry } from "../plugins/plugin-manager";
import { TestDownloadJob } from "../jobs/test-download-job";
import { randomUUIDv7 } from "bun";
export const settings = new Elysia({ prefix: '/api/settings' })
.get('/emulators/automatic', async () =>
@ -96,27 +98,31 @@ export const settings = new Elysia({ prefix: '/api/settings' })
})
.get('/definitions/:source', async ({ params: { source } }) =>
{
return plugins.plugins[source].plugin.settingsSchema?.toJSONSchema() as JSONSchema7;
return plugins.plugins[decodeURIComponent(source)].plugin.settingsSchema?.toJSONSchema() as JSONSchema7;
})
.get('/actions/:source', async ({ params: { source } }) =>
{
const plugin = plugins.plugins[source]?.plugin;
const plugin = plugins.plugins[decodeURIComponent(source)]?.plugin;
if (!plugin.eventsNames) return [];
return plugin.eventsNames;
})
.post('/actions/:source/:id', async ({ params: { source, id } }) =>
{
return await plugins.plugins[source]?.plugin.onEvent?.(id);
return await plugins.plugins[decodeURIComponent(source)]?.plugin.onEvent?.(decodeURIComponent(id));
})
.get('/:source/:id', async ({ params: { source, id } }) =>
{
return { value: plugins.plugins[source].config?.get(id) };
return { value: plugins.plugins[decodeURIComponent(source)].config?.get(decodeURIComponent(id)) };
})
.post('/test/download', async () =>
{
taskQueue.enqueue(randomUUIDv7(), new TestDownloadJob());
})
.put('/:source/:id', async ({ params: { source, id }, body: { value } }) =>
{
const plugin = plugins.plugins[source];
const plugin = plugins.plugins[decodeURIComponent(source)];
if (!plugin.config) return status("Not Found", "Plugin has no config");
const settingSchema = plugin.plugin.settingsSchema?.shape[id] as z.ZodObject;
const settingSchema = plugin.plugin.settingsSchema?.shape[decodeURIComponent(id)] as z.ZodObject;
if (!settingSchema) return status("Not Found", "Could not find setting");
const meta = pluginZodRegistry.get(settingSchema);

View file

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

View file

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

View file

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

View file

@ -7,7 +7,7 @@ import { getAppVersion, isSteamDeck, openExternal } from "../utils";
import fs from 'node:fs/promises';
import buildNotificationsStream from "./notifications";
import path, { dirname } from "node:path";
import { DirSchema, SystemInfoSchema } from "@/shared/constants";
import { SystemInfoSchema, DirSchema, DownloadsDrive } from '@simeonradivoev/gameflow-sdk/shared';
import { getDevices, getDevicesCurated } from "./drives";
import getFolderSize from "get-folder-size";
import si from 'systeminformation';
@ -16,7 +16,6 @@ import ReloadPluginsJob from "./jobs/reload-plugins-job";
import { semver } from "bun";
import { getOrCachedGithubRelease } from "./cache";
import SelfUpdateJob from "./jobs/self-update-job";
import { DownloadsDrive } from "@/shared/types";
async function checkUpdate (force?: boolean)
{
@ -87,6 +86,7 @@ export const system = new Elysia({ prefix: '/api/system' })
z.object({ type: z.literal('info'), data: SystemInfoSchema }),
z.object({ type: z.literal('focus') }),
z.object({ type: z.literal('loading'), progress: z.number(), state: z.string().optional() }),
z.object({ type: z.literal('activeTask'), progress: z.number().nullable() }),
z.object({ type: z.literal('loaded') }),
]),
async open (ws)
@ -95,6 +95,8 @@ export const system = new Elysia({ prefix: '/api/system' })
if (existingLoading) ws.send({ type: 'loading', progress: existingLoading.progress, state: existingLoading.state });
else ws.send({ type: 'loaded' });
ws.send({ type: 'activeTask', progress: taskQueue.getActiveJobs()[0]?.progress });
const startInfo = async () =>
{
const battery = await si.battery();
@ -117,6 +119,8 @@ export const system = new Elysia({ prefix: '/api/system' })
dispose.push(taskQueue.on('progress', e =>
{
ws.send({ type: 'activeTask', progress: e.progress });
if (e.id === ReloadPluginsJob.id)
{
ws.send({ type: "loading", progress: e.progress, state: e.state });
@ -128,6 +132,8 @@ export const system = new Elysia({ prefix: '/api/system' })
}));
dispose.push(taskQueue.on('started', e =>
{
ws.send({ type: 'activeTask', progress: 0 });
if (e.id === ReloadPluginsJob.id)
ws.send({ type: "loading", progress: e.job.progress, state: e.job.state });
else if (e.id === SelfUpdateJob.id)
@ -135,6 +141,7 @@ export const system = new Elysia({ prefix: '/api/system' })
}));
dispose.push(taskQueue.on('ended', e =>
{
ws.send({ type: 'activeTask', progress: null });
if (e.id !== ReloadPluginsJob.id && e.id !== SelfUpdateJob.id) return;
ws.send({ type: "loaded" });
}));

View file

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

View file

@ -1,10 +1,11 @@
import { $, sleep } from 'bun';
import path from 'node:path';
import { SettingsType } from '@/shared/constants';
import { SettingsType, KeysWithValueAssignableTo } from '@simeonradivoev/gameflow-sdk/shared';
import { config } from './api/app';
import fs from 'node:fs/promises';
import packageDef from '~/package.json';
import { KeysWithValueAssignableTo } from '@/shared/types';
const archiveRegex = /.(zip|rar|7zip|7z|tar|tar.gz)$/i;
export function checkRunning (pid: number)
{
@ -180,3 +181,23 @@ export function getAppVersion ()
{
return process.env.VERSION_OVERRIDE ?? packageDef.version;
}
export function isArchive (path: string)
{
return archiveRegex.test(path);
}
export function IsPluginAllowed (id: string)
{
if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(id))
{
return false;
}
if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(id))
{
return false;
}
return true;
}

View file

@ -5,12 +5,7 @@ import fs from 'node:fs/promises';
import { createWriteStream } from "node:fs";
import { config, jar } from "../api/app";
import { moveAllFiles } from "../utils";
import { DownloadFileEntry } from "@/shared/types";
export interface ProgressStats
{
progress: number;
}
import { DownloadFileEntry, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared";
interface TmpDownloadMetadata
{
@ -32,6 +27,7 @@ export class Downloader
id: string;
tmpPath: string;
tmpPathMeta: string;
downloadSpeed: number = 0;
/**
*
@ -163,9 +159,6 @@ export class Downloader
});
const totalBytes = totalSize || Number(res.headers.get("content-length")) || 0;
if (totalSize <= 0)
bytesReceived = 0;
else
bytesReceived += start;
const reader = res.body!.getReader();
@ -181,10 +174,11 @@ export class Downloader
if (totalBytes > 0 && this.onProgress)
{
const percent = (bytesReceived / totalBytes) * 100;
if (Date.now() - lastUpdate > 100)
const timeDelta = Date.now() - lastUpdate;
if (timeDelta > 100)
{
this.onProgress({ progress: percent });
this.downloadSpeed = this.downloadSpeed * 0.8 + Math.round(value.length / (timeDelta / 1000)) * 0.2;
this.onProgress({ progress: percent, downloaded: bytesReceived, total: totalBytes, speed: this.downloadSpeed });
lastUpdate = Date.now();
}
}
@ -194,7 +188,7 @@ export class Downloader
if (this.signal.reason === 'cancel')
{
console.log("Canceling Download and cleaning up files");
await fs.rm(this.tmpPath, { recursive: true });
await fs.rm(this.tmpPath, { recursive: true, maxRetries: 3, retryDelay: 3 });
await fs.rm(this.tmpPathMeta);
return;
}

View file

@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { SystemInfoContext } from "../scripts/contexts";
import { AppContext, SystemInfoContext } from "../scripts/contexts";
import { systemApi } from "../scripts/clientApi";
import { SystemInfoType } from "@/shared/constants";
import { AppInfoContext, SystemInfoType } from '@simeonradivoev/gameflow-sdk/shared';
import LoadingScreen from "./LoadingScreen";
import { GamepadKeyboard } from "./GamepadKeyboard";
export default function AppCommunication (data: { children: any; })
{
const [systemInfo, setSystemInfo] = useState<SystemInfoType | undefined>();
const [appContext, setAppContext] = useState<AppInfoContext>({} as AppInfoContext);
const [loadingInfo, setLoadingInfo] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState(true);
const loadingProgressBarRef = useRef<HTMLProgressElement>(null);
@ -25,6 +26,9 @@ export default function AppCommunication (data: { children: any; })
case "focus":
window.focus();
break;
case "activeTask":
setAppContext(c => ({ ...c, activeTaskProgress: data.progress }));
break;
case "loading":
setLoadingInfo(data.state);
if (loadingProgressBarRef.current)
@ -45,6 +49,7 @@ export default function AppCommunication (data: { children: any; })
}, []);
return <SystemInfoContext value={systemInfo}>
<AppContext value={appContext}>
{loading ?
<LoadingScreen>
<div className="flex flex-col items-center gap-4">
@ -57,5 +62,6 @@ export default function AppCommunication (data: { children: any; })
</LoadingScreen>
: data.children}
<GamepadKeyboard />
</AppContext>
</SystemInfoContext>;
}

View file

@ -6,7 +6,7 @@ import
import CardElement, { GameCardParams } from "./CardElement";
import { JSX } from "react";
import { twMerge } from "tailwind-merge";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
import { oneShot } from "../scripts/audio/audio";
export interface GameMetaExtra extends GameMeta
@ -16,7 +16,7 @@ export interface GameMetaExtra extends GameMeta
focusKey: string;
}
function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusParams & InteractParams)
function LocalCardElement (data: { game: GameMetaExtra, i: number; onQuickAction?: (ctx: InteractParamsArgs) => void; } & FocusParams & InteractParams)
{
let preview: GameCardParams['preview'] = data.game.preview;
if (!preview && data.game.previewUrls)
@ -31,7 +31,28 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara
oneShot('click');
};
useShortcuts(data.game.focusKey, () => [{ label: "Details", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]);
const handleAltAction = (ctx: InteractParamsArgs) =>
{
data.game.onQuickAction?.();
data.onQuickAction?.({ event, focusKey: data.game.focusKey });
oneShot('click');
};
useShortcuts(data.game.focusKey, () =>
{
const options: Shortcut[] = [{
label: "Details",
button: GamePadButtonCode.A,
action: event => handleAction({ event, focusKey: data.game.focusKey })
}];
if (data.onQuickAction || data.game.onQuickAction)
{
options.push({ label: "Play", button: GamePadButtonCode.X, action: event => handleAltAction({ event, focusKey: data.game.focusKey }) });
}
return options;
}, [data.onQuickAction, data.game.onQuickAction, data.game.focusKey]);
return (
<CardElement
@ -91,7 +112,12 @@ export function CardList (data: {
>
<FocusContext.Provider value={focusKey}>
{data.games.map((g, i) => <LocalCardElement
key={g.id} onFocus={data.onFocus} game={g} onAction={() => data.onSelectGame?.(g.id)} i={i} />)}
key={g.id}
onFocus={data.onFocus}
game={g}
onAction={() => data.onSelectGame?.(g.id)}
i={i}
/>)}
{data.finalElement}
</FocusContext.Provider>
</ul>

View file

@ -5,7 +5,7 @@ import { JSX, Suspense } from 'react';
import { FloatingShortcuts } from './Shortcuts';
import { AutoFocus } from './AutoFocus';
import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts';
import { GameListFilterType } from '@/shared/constants';
import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared';
import { HandleGoBack } from '../scripts/utils';
import LoadingCardList from './LoadingCardList';
import { useQuery, useQueryClient } from '@tanstack/react-query';

View file

@ -64,7 +64,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
className={
twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}>
<FocusContext value={focusKey}>
<div className={twMerge("flex bg-base-200 in-data-[selected=true]:border-4 in-focused:border-4 border-base-300 w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl gap-2 in-focused:font-semibold focusable not-active:control-mouse:hover:bg-base-300 in-focused:z-100",
<div className={twMerge("flex bg-base-200 in-data-[selected=true]:border-4 in-focused:border-4 border-base-300 w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl gap-2 in-focused:font-semibold focusable light:not-in-data-[selected=true]:control-mouse:hover:bg-base-100 dark:not-in-data-[selected=true]:control-mouse:hover:bg-base-300 in-focused:z-100",
data.className,
colors[data.type],
"in-focused:bg-base-content in-focused:text-base-100")}>
@ -166,7 +166,7 @@ export function ContextDialog (data: {
}] : [], [data.open]);
return <dialog ref={ref} open={data.open} closedby="any" className={
twMerge("fixed modal cursor-pointer bg-base-300/80 not-mobile:backdrop-blur-md backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
twMerge("fixed modal cursor-pointer bg-base-300/60 not-mobile:backdrop-blur-md backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
classNames({ "opacity-0": !data.open }), data.backdropClassName)
}
onClick={handleClose}>
@ -174,7 +174,7 @@ export function ContextDialog (data: {
<ContextDialogContext value={{ id: data.id, close: handleClose }} >
<div
className={twMerge(
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] max-h-[80vh] overflow-y-auto cursor-auto not-mobile:backdrop-blur-2xl",
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] max-h-[80vh] overflow-y-auto cursor-auto not-mobile:backdrop-blur-2xl not-mobile:drop-shadow-2xl",
data.open ? "animate-scale-delayed" : "opacity-0",
data.className)
}

View file

@ -4,7 +4,7 @@ import { FocusEventHandler, useContext, useRef, useState } from "react";
import path from "pathe";
import { Check, File, FileInput, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { DirType } from "@/shared/constants";
import { DirType } from '@simeonradivoev/gameflow-sdk/shared';
import classNames from "classnames";
import { twMerge } from "tailwind-merge";
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";

View file

@ -116,7 +116,7 @@ export function FilterUI (data: {
style={{ viewTransitionName: `filter-${data.id}` }}
>
<FocusContext.Provider value={focusKey}>
<ul className={twMerge("flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm sm:portrait:h-12 sm:landscape:h-9 md:h-14!", data.className)}>
<ul className={twMerge("flex flex-row bg-base-100 rounded-full gap-0.5 p-1 drop-shadow-sm sm:portrait:h-12 sm:landscape:h-9 md:h-14!", data.className)}>
{!!data.rootFocusKey && (data.showShortcuts ?? true) && <li className=" flex px-4 items-center justify-center rounded-full">
<SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_l1_outline" />
</li>}

View file

@ -4,7 +4,7 @@ import { FileQuestion, HardDrive, Store } from "lucide-react";
import { JSX } from "react";
import { FOCUS_KEYS } from "../scripts/types";
import { useRouter } from "@tanstack/react-router";
import { FrontEndGameType, FrontEndId } from "@/shared/types";
import { FrontEndGameType, FrontEndId } from "@simeonradivoev/gameflow-sdk/shared";
export default function FrontEndGameCard (data: { index: number, game: FrontEndGameType; showSource?: boolean; } & FocusParams & InteractParams)
{

View file

@ -1,13 +1,16 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { GameMetaExtra, CardList } from "./CardList";
import { DefaultRommStaleTime, GameListFilterType, RPC_URL } from "@shared/constants";
import { useNavigate } from "@tanstack/react-router";
import { DefaultRommStaleTime, RPC_URL } from "@shared/constants";
import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared';
import { useNavigate, useRouter } from "@tanstack/react-router";
import { HardDrive } from "lucide-react";
import { JSX, useContext } from "react";
import { useLocalSetting } from "../scripts/utils";
import { AnimatedBackgroundContext } from "../scripts/contexts";
import { allGamesQuery } from "@queries/romm";
import { FrontEndGameType, FrontEndId } from "@/shared/types";
import { FrontEndGameType, FrontEndId } from "@simeonradivoev/gameflow-sdk/shared";
import { isUrl } from "@/shared/utils";
import { FOCUS_KEYS } from "../scripts/types";
export interface GameListParams extends FocusParams
{
@ -16,6 +19,7 @@ export interface GameListParams extends FocusParams
grid?: boolean,
setBackground?: (url: string) => void;
onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void;
onQuickAction?: (id: FrontEndId, source: string | null, sourceId: string | null) => void;
focus?: string;
className?: string;
finalElement?: JSX.Element | JSX.Element[];
@ -96,7 +100,7 @@ export function GameList (data: GameListParams)
const previewUrls = g.path_covers.map(c =>
{
const url = c.startsWith("http") ? new URL(c) : new URL(`${RPC_URL(__HOST__)}${c}`);
const url = isUrl(c) ? new URL(c) : new URL(`${RPC_URL(__HOST__)}${c}`);
url.searchParams.delete('ts');
return url;
});
@ -104,13 +108,13 @@ export function GameList (data: GameListParams)
let platformUrl: URL | undefined = undefined;
if (g.path_platform_cover)
{
platformUrl = g.path_platform_cover.startsWith("http") ? new URL(g.path_platform_cover) : new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
platformUrl = isUrl(g.path_platform_cover) ? new URL(g.path_platform_cover) : new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
platformUrl.searchParams.set('width', "64");
}
return {
id: `${g.id.source}@${g.id.id}`,
focusKey: `${data.id}-${g.id.source}@${g.id.id}`,
focusKey: FOCUS_KEYS.GAME_LIST_CARD(data.id, g.id),
title: g.name ?? "",
subtitle: (
<div className="flex gap-1 items-center">
@ -121,6 +125,7 @@ export function GameList (data: GameListParams)
previewUrls: previewUrls,
badges: badges,
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g),
onQuickAction: data.onQuickAction ? () => data.onQuickAction?.(g.id, g.source, g.source_id) : undefined,
onFocus: () => handleFocus(g.id, g.source, g.source_id)
} satisfies GameMetaExtra;
},

View file

@ -60,7 +60,7 @@ function buildWheel (side: 0 | 1, shift: boolean, characters: boolean)
const elements: JSX.Element[] = [];
const refs: RefObject<HTMLSpanElement | null>[] = [];
const positions: { left: string; top: string; }[] = [];
const W = 258, C = 129, R2 = 107, R1 = 42, n = GetKeys(characters)[side].length, GAP = 0.028;
const n = GetKeys(characters)[side].length, GAP = 0.028;
for (let i = 0; i < n; i++)
{
@ -387,10 +387,6 @@ export function GamepadKeyboard ()
const magnitudeSqr = (x * x) + (y * y);
const magnitude = Math.sqrt(magnitudeSqr);
const elementPos = keyIndex < 0 ? undefined : elements[side].positions[keyIndex];
//const lerpX = (element?.left ?? 0);
//const lerpY = (element?.top ?? 0);
const size = 12;
circle.style.left = `calc(50% + ${50 * x}% - 16px)`;
circle.style.top = `calc(50% + ${50 * y}% - 16px)`;
circle.style.opacity = `${1 - Math.pow(magnitude, 2)}`;

View file

@ -0,0 +1,28 @@
import { useState } from "react";
import { GlobalDialogContext } from "../scripts/contexts";
import { useContextDialog } from "./ContextDialog";
export default function GlobalContextDialog (data: { children: any; })
{
const [currentContext, setCurrentContext] = useState<any | undefined>(undefined);
const [preferredChildFocusKey, setPreferredChildFocusKey] = useState<string | undefined>(undefined);
const [onCloseCallback, setOnCloseCallback] = useState<(() => void) | undefined>(undefined);
const { dialog, setOpen } = useContextDialog('global-context-dialog', {
content: currentContext,
onClose: onCloseCallback,
preferredChildFocusKey: preferredChildFocusKey
});
return <GlobalDialogContext value={{
openContext (context, focusKey)
{
setCurrentContext(context.content);
setPreferredChildFocusKey(context.preferredChildFocusKey);
setOnCloseCallback(context.onClose);
setOpen(true, focusKey);
},
}}>
{data.children}
{dialog}
</GlobalDialogContext>;
}

View file

@ -29,10 +29,11 @@ import { twMerge } from "tailwind-merge";
import { TwitchIcon } from "../scripts/brandIcons";
import { rommLoggedInQuery } from "../scripts/queries/romm";
import { twitchLoginVerificationQuery } from "../scripts/queries/settings";
import { SystemInfoContext } from "../scripts/contexts";
import { AppContext, SystemInfoContext } from "../scripts/contexts";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { oneShot } from "../scripts/audio/audio";
import { hasUpdateQuery } from "../scripts/queries/system";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
function HeaderAvatar (data: {
id: string;
@ -73,6 +74,7 @@ export interface HeaderButton
external?: boolean;
action?: () => void;
className?: string;
shortcutLabel?: string;
}
export interface HeaderAccount
@ -111,14 +113,22 @@ function NotificationStatus ()
function ClockStatus ()
{
const ref = useRef<HTMLSpanElement>(null);
const navigate = useNavigate();
const app = useContext(AppContext);
const refClock = useRef<HTMLSpanElement>(null);
const activeTaskProgress = app.activeTaskProgress;
const handleTaskClick = () =>
{
navigate({ to: '/settings/tasks' });
};
const { ref, focusKey } = useFocusable({ focusKey: 'tasks-indicator', focusable: !!activeTaskProgress, onEnterPress: handleTaskClick });
useEffect(() =>
{
function update ()
{
if (ref.current)
if (refClock.current)
{
ref.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
refClock.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
}
@ -142,7 +152,16 @@ function ClockStatus ()
return () => clearTimeout(timeout);
}, []);
return <div className="flex gap-3 sm:text-xs md:text-2xl items-center"><span ref={ref}></span><Clock className="sm:size-4 md:size-8" /></div>;
useShortcuts(focusKey, () => [{
label: "Downloads", button: GamePadButtonCode.A, action (e)
{
handleTaskClick();
},
}]);
return <div ref={ref} className="flex gap-3 sm:text-xs md:text-2xl items-center">
<span ref={refClock}></span>
{activeTaskProgress ? <div onClick={handleTaskClick} className={twMerge("radial-progress bg-primary text-primary-content border-primary border-4 in-focused:ring-7 in-focused:ring-primary in-focused:bg-base-content in-focused:text-base-200 in-focused:border-base-content", activeTaskProgress ? "cursor-pointer" : "")} style={{ "--value": activeTaskProgress, "--size": "2rem", "--thickness": "0.3rem" }} role="progressbar"></div> : <Clock className="sm:size-4 md:size-8" />}</div>;
}
function BluetoothStatus ()
@ -288,6 +307,7 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
{data.buttonElements}
{data.buttons?.map(b => <RoundButton
key={b.id}
shortcutLabel={b.shortcutLabel}
className={twMerge("header-icon sm:size-10 md:size-14", b.className)}
id={b.id}
external={b.external}
@ -327,7 +347,19 @@ export function HeaderUI (data: HeaderUIParams)
<FocusContext value={focusKey}>
<HeaderAccounts key={"header-accounts"} accounts={data.accounts} />
{data.title}
<HeaderStatusBar key={"header-status-bar"} buttonElements={data.buttonElements} buttons={[...data.buttons ?? [], { icon: <Settings />, id: "header-settings-btn", action: goToSettings, external: true }]} />
<HeaderStatusBar
key={"header-status-bar"}
buttonElements={data.buttonElements}
buttons={[
...data.buttons ?? [],
{
icon: <Settings />,
id: "header-settings-btn",
action: goToSettings,
external: true,
shortcutLabel: "Settings"
}
]} />
</FocusContext>
</header >

View file

@ -5,7 +5,6 @@ import { oneShot } from "../scripts/audio/audio";
import { Search } from "lucide-react";
import { RoundButton } from "./RoundButton";
import { useEventListener } from "usehooks-ts";
import useActiveControl from "../scripts/gamepads";
import { twMerge } from "tailwind-merge";
function SearchInput (data: {
@ -97,10 +96,10 @@ export default function HeaderSearchField (data: {
isFocusBoundary: data.compact && showInput
});
return <div ref={ref} className='flex items-center'>
return <div ref={ref} className='flex items-center' style={{ viewTransitionName: 'header-search' }}>
<FocusContext value={focusKey}>
{(!data.compact || showInput) && <SearchInput className={data.className} autoSearch={data.autoSearch} onFocus={data.onFocus} id={`${data.id}-field`} search={data.search} onSubmit={data.onSubmit} compact={data.compact} setShowInput={setShowInput} onInputFocus={focusSelf} />}
{data.compact && !showInput && <RoundButton onAction={e => setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} ><Search /></RoundButton>}
{data.compact && !showInput && <RoundButton cssStyle={{ viewTransitionName: 'search-button' }} onAction={e => setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} ><Search /></RoundButton>}
</FocusContext>
</div>;
}

View file

@ -1,6 +1,7 @@
import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { FOCUS_KEYS } from "../scripts/types";
import { useIntersectionObserver } from "usehooks-ts";
import { FrontEndId } from "@simeonradivoev/gameflow-sdk/shared";
export default function LoadMoreButton (data: { isFetching: boolean; hidden?: boolean, lastId?: FrontEndId; } & FocusParams & InteractParams)
{

View file

@ -1,5 +1,5 @@
import { RPC_URL } from "@/shared/constants";
import { FrontendNotification } from "@/shared/types";
import { FrontendNotification } from "@simeonradivoev/gameflow-sdk/shared";
import { Clock, CloudUpload, Save } from "lucide-react";
import { useEffect } from "react";
import toast, { ToastOptions } from "react-hot-toast";

View file

@ -9,10 +9,11 @@ export function RoundButton (data: {
external?: boolean;
style?: ButtonStyle;
cssStyle?: CSSProperties;
shortcutLabel?: string;
} & InteractParams & FocusParams)
{
return (
<Button cssStyle={data.cssStyle} onFocus={data.onFocus} id={data.id} style={data.style} className={twMerge("rounded-full aspect-square", data.external && "focusable focusable-primary focusable-hover", data.className)} onAction={data.onAction}>
<Button shortcutLabel={data.shortcutLabel} cssStyle={data.cssStyle} onFocus={data.onFocus} id={data.id} style={data.style} className={twMerge("rounded-full aspect-square", data.external && "focusable focusable-primary focusable-hover", data.className)} onAction={data.onAction}>
{data.children}
</Button>

View file

@ -8,6 +8,7 @@ import Carousel from "./Carousel";
import { ContextDialog } from "./ContextDialog";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { twMerge } from "tailwind-merge";
import { isUrl } from "@/shared/utils";
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams)
{
@ -21,8 +22,9 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n
scrollIntoNearestParent(ref.current, { behavior: details.instant ? 'instant' : 'smooth' });
}
}); 4096;
const url = isUrl(data.path) ? data.path : `${RPC_URL(__HOST__)}${data.path}`;
return <div ref={ref} className="group relative flex min-w-fit aspect-video max-h-[60vh] rounded-3xl focusable focusable-accent not-focused:cursor-pointer overflow-hidden">
<img ref={imageRef} draggable={false} className="object-cover w-full h-full" onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" decoding="async" />
<img ref={imageRef} draggable={false} className="object-cover w-full h-full" onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} src={url} loading="lazy" decoding="async" />
<div className="absolute flex justify-center items-center bottom-2 right-2 size-10 rounded-full bg-base-100 hover:bg-base-content hover:text-base-300 cursor-pointer opacity-60 not-control-mouse:hidden invisible group-has-hover:visible" onClick={e => data.onAction?.({ event: e.nativeEvent, focusKey })}> <Fullscreen /> </div>
</div>;
}
@ -59,8 +61,9 @@ function Preview (data: { id: string; screenshots?: string[]; preview: number; s
}
}
], [data.preview, focusKey, data.screenshots?.length ?? 0]);
const url = isUrl(data.screenshots?.[data.preview]) ? data.screenshots?.[data.preview] : `${RPC_URL(__HOST__)}${data.screenshots?.[data.preview]}`;
return <img ref={ref} draggable={false} className="object-cover w-full h-full rounded-2xl" src={`${RPC_URL(__HOST__)}${data.screenshots?.[data.preview]}`} loading="lazy" />;
return <img ref={ref} draggable={false} className="object-cover w-full h-full rounded-2xl" src={url} loading="lazy" />;
}
export default function Screenshots (data: { screenshots?: string[]; className?: string; } & FocusParams)

View file

@ -2,7 +2,7 @@ import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { useMatchRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { DoorOpen, Gamepad2, Puzzle, RefreshCcw, Settings, Store } from "lucide-react";
import { DoorOpen, Gamepad2, Home, Puzzle, RefreshCcw, Settings, Store } from "lucide-react";
import { systemApi } from "../scripts/clientApi";
import { FOCUS_KEYS } from "../scripts/types";
@ -15,7 +15,7 @@ export default function SelectMenu (data: { rootFocusKey: string; })
const options: DialogEntry[] = [
{
content: "Home",
icon: <Gamepad2 />,
icon: <Home />,
action (ctx)
{
setOpen(false);

View file

@ -1,25 +1,25 @@
import { GameListFilterType } from "@/shared/constants";
import { DownloadsLookupFilter, DownloadsLookupFilterValues, GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared';
import { RoundButton } from "./RoundButton";
import classNames from "classnames";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { useFocusable, FocusContext } from "@noriginmedia/norigin-spatial-navigation";
import { ArrowDownAz, ClockArrowDown, CalendarArrowDown, Rocket, HardDrive, SortDesc, User, Drama, FunnelX, Store } from "lucide-react";
import { ArrowDownAz, ClockArrowDown, CalendarArrowDown, Rocket, HardDrive, SortDesc, User, Drama, FunnelX, Store, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react";
import { sourceIconMap } from "./Constants";
import { useContextDialog, ContextList, DialogEntry } from "./ContextDialog";
import { FrontEndFilterLists } from "@/shared/types";
import { ContextList, DialogEntry } from "./ContextDialog";
import { FrontEndFilterLists } from "@simeonradivoev/gameflow-sdk/shared";
import { useContext } from 'react';
import { GlobalDialogContext } from '../scripts/contexts';
function FilterButton (data: {
id: string,
filters?: GameListFilterType,
tooltip: string,
icon: any;
dialog: {
setToggle: (focNewSourceFocusKey?: string | undefined) => void;
};
dialog: (focNewSourceFocusKey: string) => void;
isActive: boolean;
})
{
const handleAction = () => data.dialog.setToggle(data.id);
const handleAction = () => data.dialog(data.id);
useShortcuts(data.id, () => [{ label: data.tooltip, action: handleAction, button: GamePadButtonCode.A }]);
return <div className="tooltip tooltip-right" data-tip={data.tooltip}>
<RoundButton
@ -32,6 +32,89 @@ function FilterButton (data: {
</div>;
}
export function SideDownloadFilters (data: {
id: string,
filters?: DownloadsLookupFilter;
setLocalFilter: (filter: DownloadsLookupFilter) => void,
localFilter: DownloadsLookupFilter,
filterValues: DownloadsLookupFilterValues | undefined;
})
{
const { ref, focusKey } = useFocusable({ focusKey: data.id });
const globalDialog = useContext(GlobalDialogContext);
const orderByDialog = (focusKey: string) => globalDialog.openContext({
content: <ContextList options={data.filterValues?.orderBy
.map(o => ({
content: o,
selected: data.localFilter.orderBy === o,
id: `sort-by-${o}`,
type: 'primary',
action (ctx)
{
data.setLocalFilter({ ...data.localFilter, orderBy: o });
ctx.close();
},
}))} />,
preferredChildFocusKey: `sort-by-${data.localFilter.orderBy}`
}, focusKey);
const orderDirectionDialog = (focusKey: string) => globalDialog.openContext({
content: <ContextList options={
[{ label: 'asc', icon: <ArrowDown /> }, { label: 'desc', icon: <ArrowUp /> }]
.map(o => ({
content: o.label,
selected: data.localFilter.sortDirection === o.label,
icon: o.icon,
id: `sort-direction-${o.label}`,
type: 'primary',
action (ctx)
{
data.setLocalFilter({ ...data.localFilter, sortDirection: o.label as any });
ctx.close();
},
}))
} />,
preferredChildFocusKey: `sort-direction-${data.localFilter.orderBy}`
}, focusKey);
const sourceFilterDialog = (focusKey: string) => globalDialog.openContext({
content: <ContextList options={data.filterValues?.source
.map<DialogEntry>(o => ({
content: o,
icon: sourceIconMap[o],
selected: data.localFilter.source === o,
id: `source-filter-${o}`,
type: 'primary',
action (ctx)
{
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, source: undefined });
else data.setLocalFilter({ ...data.localFilter, source: o });
ctx.close();
},
}))} />,
preferredChildFocusKey: `source-filter-${data.localFilter.source}`
}, focusKey);
return <div className='flex flex-col gap-2' ref={ref}>
<FocusContext value={focusKey} >
<FilterButton tooltip='Sorting' id='filter-order-by' dialog={orderByDialog} isActive={!!data.localFilter.orderBy} icon={<SortDesc />} />
<FilterButton tooltip='Sorting Direction' id='filter-order-direction' dialog={orderDirectionDialog} isActive={!!data.localFilter.sortDirection} icon={<ArrowUpDown />} />
{!data.filters?.source &&
<FilterButton tooltip='Source' id='filter-source' dialog={sourceFilterDialog} isActive={!!data.localFilter.source} icon={<Store />} />
}
{Object.values(data.localFilter).some(v => v !== undefined) &&
<>
<div className="divider m-0"></div>
<RoundButton id={'filter-clear'} onAction={() => data.setLocalFilter({})} className='p-3 drop-shadow-md!' > <FunnelX /> </RoundButton>
</>
}
</FocusContext>
</div>;
}
export default function SideFilters (data: {
id: string,
filters?: GameListFilterType;
@ -42,30 +125,11 @@ export default function SideFilters (data: {
{
const { ref, focusKey } = useFocusable({ focusKey: data.id });
const globalDialog = useContext(GlobalDialogContext);
const orderByDialog = useContextDialog('order-by-dialog', {
content: <ContextList options={([
{ stat: "name", icon: <ArrowDownAz /> },
{ stat: "activity", icon: <ClockArrowDown /> },
{ stat: "added", icon: <CalendarArrowDown /> },
{ stat: "release", icon: <Rocket /> },
] satisfies { stat: GameListFilterType['orderBy'], icon?: any; }[])
.map(o => ({
content: o.stat,
icon: o.icon,
selected: data.localFilter.orderBy === o.stat,
id: `sort-by-${o.stat}`,
type: 'primary',
action (ctx)
const openSourceDialog = (focusKey: string) =>
{
data.setLocalFilter({ ...data.localFilter, orderBy: o.stat });
ctx.close();
},
}))} />,
preferredChildFocusKey: `sort-by-${data.localFilter.orderBy}`
});
const sourceFilterDialog = useContextDialog('source-filter-dialog', {
globalDialog.openContext({
content: <ContextList options={["romm"]
.map<DialogEntry>(o => ({
content: o,
@ -91,11 +155,13 @@ export default function SideFilters (data: {
else data.setLocalFilter({ ...data.localFilter, localOnly: true });
ctx.close();
},
})} />,
preferredChildFocusKey: `source-filter-${data.localFilter.source}`
});
})} />, preferredChildFocusKey: `source-filter-${data.localFilter.source}`
}, focusKey);
};
const genreFilterDialog = useContextDialog('genre-filter-dialog', {
const openGenreDialog = (focusKey: string) =>
{
globalDialog.openContext({
content: <ContextList options={data.filterValues?.genres.map(g => ({
content: g,
selected: data.localFilter.genres?.includes(g),
@ -108,9 +174,36 @@ export default function SideFilters (data: {
ctx.close();
},
}))} />
});
}, focusKey);
};
const ageRatingFilterDialog = useContextDialog('age-rating-filter-dialog', {
const openSortingDialog = (focusKey: string) =>
{
globalDialog.openContext({
content: <ContextList options={([
{ stat: "name", icon: <ArrowDownAz /> },
{ stat: "activity", icon: <ClockArrowDown /> },
{ stat: "added", icon: <CalendarArrowDown /> },
{ stat: "release", icon: <Rocket /> },
] satisfies { stat: GameListFilterType['orderBy'], icon?: any; }[])
.map(o => ({
content: o.stat,
icon: o.icon,
selected: data.localFilter.orderBy === o.stat,
id: `sort-by-${o.stat}`,
type: 'primary',
action (ctx)
{
data.setLocalFilter({ ...data.localFilter, orderBy: o.stat });
ctx.close();
},
}))} />, preferredChildFocusKey: `sort-by-${data.localFilter.orderBy}`
}, focusKey);
};
const openAgeRatingDialog = (focusKey: string) =>
{
globalDialog.openContext({
content: <ContextList options={data.filterValues?.age_ratings.map(a => ({
content: a,
selected: data.localFilter.age_ratings?.includes(a),
@ -123,15 +216,16 @@ export default function SideFilters (data: {
ctx.close();
},
}))} />
});
}, focusKey);
};
return <div className='flex flex-col gap-2' ref={ref}>
<FocusContext value={focusKey} >
<FilterButton tooltip='Sorting' id='filter-order-by' dialog={orderByDialog} isActive={!!data.localFilter.orderBy} icon={<SortDesc />} />
<FilterButton tooltip='Age Rating' id='filter-age-ratings' dialog={ageRatingFilterDialog} isActive={!!data.localFilter.age_ratings && data.localFilter.age_ratings.length > 0} icon={<User />} />
<FilterButton tooltip='Genre' id='filter-genre' dialog={genreFilterDialog} isActive={!!data.localFilter.genres && data.localFilter.genres.length > 0} icon={<Drama />} />
<FilterButton tooltip='Sorting' id='filter-order-by' dialog={openSortingDialog} isActive={!!data.localFilter.orderBy} icon={<SortDesc />} />
<FilterButton tooltip='Age Rating' id='filter-age-ratings' dialog={openAgeRatingDialog} isActive={!!data.localFilter.age_ratings && data.localFilter.age_ratings.length > 0} icon={<User />} />
<FilterButton tooltip='Genre' id='filter-genre' dialog={openGenreDialog} isActive={!!data.localFilter.genres && data.localFilter.genres.length > 0} icon={<Drama />} />
{!data.filters?.source &&
<FilterButton tooltip='Source' id='filter-source' dialog={sourceFilterDialog} isActive={!!data.localFilter.source || data.localFilter.localOnly !== undefined} icon={<Store />} />
<FilterButton tooltip='Source' id='filter-source' dialog={openSourceDialog} isActive={!!data.localFilter.source || data.localFilter.localOnly !== undefined} icon={<Store />} />
}
{Object.values(data.localFilter).some(v => v !== undefined) &&
<>
@ -139,10 +233,6 @@ export default function SideFilters (data: {
<RoundButton id={'filter-clear'} onAction={() => data.setLocalFilter({})} className='p-3 drop-shadow-md!' > <FunnelX /> </RoundButton>
</>
}
{orderByDialog.dialog}
{sourceFilterDialog.dialog}
{genreFilterDialog.dialog}
{ageRatingFilterDialog.dialog}
</FocusContext>
</div>;
}

View file

@ -1,5 +1,5 @@
import { FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement } from "@/shared/types";
import { FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement } from "@simeonradivoev/gameflow-sdk/shared";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { Medal } from "lucide-react";

View file

@ -10,7 +10,7 @@ import ActionButton from "./ActionButton";
import { useLocalStorage } from "usehooks-ts";
import FocusTooltip from "../FocusTooltip";
import { useBlocker, useNavigate, useRouter } from "@tanstack/react-router";
import { FrontEndGameTypeDetailed } from "@/shared/types";
import { FrontEndGameTypeDetailed } from "@simeonradivoev/gameflow-sdk/shared";
function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractParams)
{
@ -30,7 +30,11 @@ function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractP
</ActionButton>;
}
export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; })
export default function ActionButtons (data: {
game?: FrontEndGameTypeDetailed,
source: string,
id: string;
})
{
const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots');
const navigate = useNavigate();

View file

@ -10,7 +10,7 @@ import prettyMilliseconds from 'pretty-ms';
import { useQuery } from "@tanstack/react-query";
import { validateSourceQuery } from "@/mainview/scripts/queries/romm";
import { sourceIconMap } from "../Constants";
import { FrontEndGameTypeDetailed } from "@/shared/types";
import { FrontEndGameTypeDetailed } from "@simeonradivoev/gameflow-sdk/shared";
export function DetailElement (data: { icon: JSX.Element; tooltip?: string | null, children?: any | any[]; })
{

View file

@ -6,7 +6,7 @@ import HeaderSearchField from "../HeaderSearchField";
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { scrollIntoViewHandler } from "@/mainview/scripts/utils";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
import { FrontEndId, GameLookup } from "@/shared/types";
import { FrontEndId, GameLookup } from "@simeonradivoev/gameflow-sdk/shared";
import { gameLookupQuery } from "@/mainview/scripts/queries/romm";
import { Button } from "../options/Button";
import { useNavigate } from "@tanstack/react-router";

View file

@ -1,22 +1,20 @@
import { rommApi } from "@/mainview/scripts/clientApi";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { JSX, useEffect, useRef, useState } from "react";
import { JSX, useContext, useEffect, useRef, useState } from "react";
import { getErrorMessage } from "react-error-boundary";
import toast from "react-hot-toast";
import { useLocalStorage } from "usehooks-ts";
import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
import { ContextList, DialogEntry } from "../ContextDialog";
import { Clock, Crosshair, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react";
import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview/scripts/queries/romm";
import ActionButton from "./ActionButton";
import { useRouter } from "@tanstack/react-router";
import { DownloadSourceType } from "@/shared/constants";
import { useNavigate, UseNavigateResult, useRouter } from "@tanstack/react-router";
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
import { CommandEntry, FrontEndGameTypeDetailed } from "@/shared/types";
import { CommandEntry, FrontEndGameTypeDetailed, DownloadSourceType } from "@simeonradivoev/gameflow-sdk/shared";
import { GlobalDialogContext } from "@/mainview/scripts/contexts";
export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; })
export function usePlayMutation (navigate: UseNavigateResult<string>)
{
const installMut = useMutation(installMutation(data.source, data.id));
const router = useRouter();
const playMut = useMutation({
...playMutation, onError (error)
{
@ -24,9 +22,36 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
},
onSuccess (data, { source, id }, onMutateResult, context)
{
router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id } });
navigate({ to: '/launcher/$source/$id', params: { source: source, id: id } });
},
});
return playMut;
}
export function playGame (source: string, id: string, cmd: CommandEntry, navigate: UseNavigateResult<string>, playMutation: (options: { source: string, id: string, command_id: string | number; }) => void)
{
if (cmd.emulator === 'EMULATORJS')
{
const params = new URLSearchParams(Array.isArray(cmd.command) ? cmd.command[0] : cmd.command);
navigate({ to: '/embedded/$source/$id', params: { source: source, id: id }, search: Object.fromEntries(params.entries()) });
} else
{
playMutation({ source: source, id: id, command_id: cmd.id });
}
}
export default function MainActions (data: {
game?: FrontEndGameTypeDetailed,
source: string,
id: string;
})
{
const installMut = useMutation(installMutation(data.source, data.id));
const router = useRouter();
const navigate = useNavigate();
const globalDialog = useContext(GlobalDialogContext);
const ws = useRef<{ send: (data: string) => void; }>(undefined);
const [progress, setProgress] = useState<number | undefined>(undefined);
const [status, setStatus] = useState<string | undefined>(undefined);
@ -43,7 +68,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
if (preferredCommand && c.id !== preferredCommand) return false;
return true;
});
const playMut = usePlayMutation(navigate);
useEffect(() =>
{
const sub = rommApi.api.romm.status({ source: data.source })({ id: data.id }).subscribe();
@ -100,32 +125,33 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
}
const showProgress = progress !== null && !!progressIcon;
useEffect(() =>
{
if (showProgress) return;
showInstallOptions(false);
}, [showProgress]);
const handlePlay = (cmd?: CommandEntry) =>
{
if (!cmd) return;
if (cmd.emulator === 'EMULATORJS')
{
const params = new URLSearchParams(Array.isArray(cmd.command) ? cmd.command[0] : cmd.command);
router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()) });
} else
{
playMut.mutate({ source: data.source, id: data.id, command_id: cmd.id });
}
};
let mainButton: any | undefined = undefined;
let showAllCommandsAction: ((focusKey: string) => void) | undefined;
let mainAction: () => void;
if (status === 'installed')
{
if (validCommands.length > 1) showAllCommandsAction = (focusKey) => showAllCommands(true, focusKey);
mainAction = () => handlePlay(validDefaultCommand);
if (validCommands.length > 1) showAllCommandsAction = (focusKey) => globalDialog.openContext({
content: <ContextList options={validCommands.map((c, i) =>
{
const commands: DialogEntry = {
id: String(c.id),
content: c.label ?? "",
type: 'primary',
selected: preferredCommand !== undefined ? preferredCommand === c.id : i === 0,
action (ctx)
{
setPreferredCommand(c.id);
playGame(data.source, data.id, c, navigate, playMut.mutate);
},
};
return commands;
})} />,
preferredChildFocusKey: String(preferredCommand)
}, focusKey);
mainAction = () => validDefaultCommand ? playGame(data.source, data.id, validDefaultCommand, navigate, playMut.mutate) : undefined;
mainButton = <div className="flex gap-2">
<ActionButton onAction={mainAction} tooltip={validDefaultCommand?.label ?? details}
key="primary"
@ -183,7 +209,18 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
case 'install':
if (installSources && installSources.length > 1)
{
showInstallSource(true, 'mainAction');
globalDialog.openContext({
content: <ContextList options={installSources?.map(s => ({
content: s.name,
action (ctx)
{
installMut.mutate({ downloadId: s.id });
ctx.close();
},
type: 'primary',
id: s.id
} satisfies DialogEntry)) ?? []} />
}, 'mainAction');
} else
{
installMut.mutate({});
@ -223,26 +260,10 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
return shortcuts;
}, [showAllCommandsAction, mainAction]);
const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', {
content: <ContextList options={validCommands.map((c, i) =>
{
const commands: DialogEntry = {
id: String(c.id),
content: c.label ?? "",
type: 'primary',
selected: preferredCommand !== undefined ? preferredCommand === c.id : i === 0,
action (ctx)
{
setPreferredCommand(c.id);
handlePlay(c);
},
};
return commands;
})} />,
preferredChildFocusKey: String(preferredCommand)
});
const { dialog: installOptionsDialog, setOpen: showInstallOptions } = useContextDialog('install-options-dialog', {
return <div className="flex gap-2">
{mainButton}
<div className="divider divider-horizontal m-0"></div>
{showProgress && <ActionButton onAction={() => globalDialog.openContext({
content: <ContextList options={[{
id: 'cancel',
content: "Cancel",
@ -253,25 +274,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
},
type: 'primary'
}]} />
});
const { dialog: installSourcesDialog, setOpen: showInstallSource } = useContextDialog('install-source-dialog', {
content: <ContextList options={installSources?.map(s => ({
content: s.name,
action (ctx)
{
installMut.mutate({ downloadId: s.id });
ctx.close();
},
type: 'primary',
id: s.id
} satisfies DialogEntry)) ?? []} />
});
return <div className="flex gap-2">
{mainButton}
<div className="divider divider-horizontal m-0"></div>
{showProgress && <ActionButton onAction={() => showInstallOptions(true, "progress")} key="progress" square tooltip={details} type="base" id="progress" >
}, "progress")} key="progress" square tooltip={details} type="base" id="progress" >
<div key={`install-${status}`} data-tooltip={details ?? status} className="flex flex-col gap-2 w-16 items-center text-2xl">
<div className="flex flex-row">
{progressIcon}
@ -279,8 +282,5 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
<progress className="progress progress-secondary w-full" value={progress} max="100"></progress>
</div>
</ActionButton>}
{installSourcesDialog}
{installOptionsDialog}
{allCommandDialog}
</div>;
}

View file

@ -12,7 +12,7 @@ import { oneShot } from "@/mainview/scripts/audio/audio";
export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error';
const styles = {
base: 'dark:bg-base-200 light:bg-base-300 text-base-content active:not-disabled:bg-base-300! active:not-disabled:text-base-content! active:not-disabled:ring-offset-base-content',
base: 'dark:bg-base-200 light:bg-base-100 text-base-content active:not-disabled:bg-base-300! active:not-disabled:text-base-content! active:not-disabled:ring-offset-base-content',
accent: "bg-accent text-accent-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:ring-offset-accent",
primary: "bg-primary text-primary-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-primary",
secondary: "bg-secondary text-secondary-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-secondary",
@ -22,6 +22,17 @@ const styles = {
error: "bg-error text-error-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-error",
};
const externalStyles = {
base: '',
accent: "focusable-accent",
primary: "focusable-primary",
secondary: "focusable-secondary",
info: "focusable-info",
success: "focusable-success",
warning: "focusable-warning",
error: "focusable-error",
};
export function Button (data: {
id: string,
children?: any,
@ -64,9 +75,9 @@ export function Button (data: {
className={twMerge("flex items-center justify-center px-4 py-2 disabled:bg-base-200/40 disabled:text-base-content/40 not-disabled:cursor-pointer rounded-3xl md:text-lg not-control-mouse:focused:drop-shadow-lg border border-base-content/5 not-control-mouse:focused:bg-base-content not-control-mouse:focused:text-base-100 control-mouse:hover:not-disabled:bg-base-content control-mouse:hover:not-disabled:text-base-100 active:not-disabled:transition-none active:not-disabled:ring-offset-4",
styles[data.style ?? 'base'],
focused ? data.focusClassName : undefined,
data.external ? `focusable focusable-hover ${externalStyles[data.style as keyof typeof externalStyles]}` : '',
classNames({
"btn-accent": focused,
"focusable focusable-primary focusable-hover": data.external
"btn-accent": focused
}, data.className))}
type={data.type ?? 'button'}
>

View file

@ -2,8 +2,7 @@ import { useState } from "react";
import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption";
import { useMutation, useQuery } from "@tanstack/react-query";
import { changeDownloadsMutation, getSettingQuery } from "@queries/settings";
import { SettingsType } from "@/shared/constants";
import { KeysWithValueAssignableTo } from "@/shared/types";
import { KeysWithValueAssignableTo, SettingsType } from "@simeonradivoev/gameflow-sdk/shared";
export default function DownloadDirectoryOption (data: PathSettingsOptionParams & { id: KeysWithValueAssignableTo<SettingsType, string>; })
{

View file

@ -1,5 +1,5 @@
import { JSX } from "react";
import { LocalSettingsSchema, LocalSettingsType } from "@shared/constants";
import { LocalSettingsSchema, LocalSettingsType } from '@simeonradivoev/gameflow-sdk/shared';
import { OptionSpace } from "./OptionSpace";
import { OptionInput } from "./OptionInput";
import { useLocalStorage } from "usehooks-ts";

View file

@ -1,5 +1,4 @@
import { HTMLInputTypeAttribute, JSX, useEffect, useState } from "react";
import { SettingsType } from "../../../shared/constants";
import { useMutation, useQuery } from "@tanstack/react-query";
import { OptionSpace } from "./OptionSpace";
import { OptionInput } from "./OptionInput";
@ -9,7 +8,7 @@ import { ContextDialog } from "../ContextDialog";
import FilePicker from "../FilePicker";
import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
import { getSettingQuery, setSettingMutation } from "@queries/settings";
import { KeysWithValueAssignableTo } from "@/shared/types";
import { KeysWithValueAssignableTo, SettingsType } from "@simeonradivoev/gameflow-sdk/shared";
export interface PathSettingsOptionParams
{

View file

@ -1,10 +1,9 @@
import { JSX, useCallback, useEffect, useState } from "react";
import { SettingsType } from "../../../shared/constants";
import { useMutation, useQuery } from "@tanstack/react-query";
import { OptionSpace } from "./OptionSpace";
import { getSettingQuery, setSettingMutation } from "@queries/settings";
import { OptionDropdown } from "./OptionDropdown";
import { KeysWithValueAssignableTo } from "@/shared/types";
import { KeysWithValueAssignableTo, SettingsType } from "@simeonradivoev/gameflow-sdk/shared";
export function SettingsDropdown (data: {
label: string;

View file

@ -1,10 +1,9 @@
import { HTMLInputTypeAttribute, JSX, useCallback, useEffect, useState } from "react";
import { SettingsType } from "../../../shared/constants";
import { useMutation, useQuery } from "@tanstack/react-query";
import { OptionSpace } from "./OptionSpace";
import { OptionInput } from "./OptionInput";
import { getSettingQuery, setSettingMutation } from "@queries/settings";
import { KeysWithValueAssignableTo } from "@/shared/types";
import { KeysWithValueAssignableTo, SettingsType } from "@simeonradivoev/gameflow-sdk/shared";
export function SettingsOption (data: {
label: string;

View file

@ -12,7 +12,7 @@ import { StoreEmulatorCard } from "./StoreEmulatorCard";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
import Carousel from "../Carousel";
import { useRouter } from "@tanstack/react-router";
import { FrontEndEmulator } from "@/shared/types";
import { FrontEndEmulator } from "@simeonradivoev/gameflow-sdk/shared";
function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant?: boolean; }) => void; })
{

View file

@ -10,7 +10,7 @@ import FrontEndGameCard from "../FrontEndGameCard";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
import Carousel from "../Carousel";
import { twMerge } from "tailwind-merge";
import { FrontEndGameType, FrontEndId } from "@/shared/types";
import { FrontEndGameType, FrontEndId } from "@simeonradivoev/gameflow-sdk/shared";
export function GamesSection (data: {
games?: FrontEndGameType[];

View file

@ -8,7 +8,7 @@ import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { RPC_URL } from "@/shared/constants";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
import { oneShot } from "@/mainview/scripts/audio/audio";
import { FrontEndEmulator } from "@/shared/types";
import { FrontEndEmulator } from "@simeonradivoev/gameflow-sdk/shared";
// ── Single missing-emulator card ───────────────────────────────────────────
interface MissingCardProps

View file

@ -6,11 +6,15 @@ import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { CircleFadingArrowUp, FileQuestion, IceCream2, Package, Store, WandSparkles } from "lucide-react";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
import { FlatpackIcon } from "@/mainview/scripts/brandIcons";
import { JSX } from "react";
import { JSX, useContext } from "react";
import { oneShot } from "@/mainview/scripts/audio/audio";
import { useQuery } from "@tanstack/react-query";
import { getUpdateInfoForEmulator } from "@/mainview/scripts/queries/store";
import { FrontEndEmulator } from "@/shared/types";
import { FrontEndEmulator } from "@simeonradivoev/gameflow-sdk/shared";
import { rommApi } from "@/mainview/scripts/clientApi";
import { useNavigate } from "@tanstack/react-router";
import { GlobalDialogContext } from "@/mainview/scripts/contexts";
import { ContextList, DialogEntry } from "../ContextDialog";
export const emulatorStatusIcons: Record<string, JSX.Element> = {
store: <Store />,
@ -28,6 +32,7 @@ export function StoreEmulatorCard (data: {
className?: string;
})
{
const navigate = useNavigate();
const handleSelect = () =>
{
data.onSelect?.(data.emulator.name, focusKey);
@ -45,7 +50,32 @@ export function StoreEmulatorCard (data: {
const { data: updateInfo } = useQuery(getUpdateInfoForEmulator(data.emulator.name));
useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]);
const globalDialogContext = useContext(GlobalDialogContext);
useShortcuts(focusKey, () => [{
button: GamePadButtonCode.A,
label: "Details",
action: handleSelect
}, {
button: GamePadButtonCode.Y,
label: "Launch Emulator",
action: e =>
{
const entries: DialogEntry[] = data.emulator.validSources.filter(s => s.exists).map(s => ({
content: `Launch: ${s.type}`,
type: 'primary',
icon: emulatorStatusIcons[s.type],
action (ctx)
{
if (!data.emulator) return;
rommApi.api.romm.game({ source: 'emulator' })({ id: data.emulator.name }).play.post({ command_id: s.type });
ctx.close();
navigate({ to: '/launcher/$source/$id', params: { source: 'emulator', id: data.emulator.name } });
}, id: `open-${s.type}`
} satisfies DialogEntry));
globalDialogContext.openContext({ content: <ContextList options={entries} /> }, focusKey);
}
}], [handleSelect]);
return (
<div

View file

@ -13,6 +13,7 @@ import { Route as GamesRouteImport } from './../routes/games'
import { Route as SettingsRouteRouteImport } from './../routes/settings/route'
import { Route as IndexRouteImport } from './../routes/index'
import { Route as SettingsUpdateRouteImport } from './../routes/settings/update'
import { Route as SettingsTasksRouteImport } from './../routes/settings/tasks'
import { Route as SettingsPluginsRouteImport } from './../routes/settings/plugins'
import { Route as SettingsInterfaceRouteImport } from './../routes/settings/interface'
import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emulators'
@ -22,16 +23,20 @@ import { Route as SettingsAboutRouteImport } from './../routes/settings/about'
import { Route as GameAddRouteImport } from './../routes/game/add'
import { Route as StoreTabRouteRouteImport } from './../routes/store/tab/route'
import { Route as StoreTabIndexRouteImport } from './../routes/store/tab/index'
import { Route as StoreTabPluginsRouteImport } from './../routes/store/tab/plugins'
import { Route as StoreTabGamesRouteImport } from './../routes/store/tab/games'
import { Route as StoreTabEmulatorsRouteImport } from './../routes/store/tab/emulators'
import { Route as StoreTabDownloadRouteImport } from './../routes/store/tab/download'
import { Route as SettingsPluginSourceRouteImport } from './../routes/settings/plugin.$source'
import { Route as PlatformSourceIdRouteImport } from './../routes/platform.$source.$id'
import { Route as LauncherSourceIdRouteImport } from './../routes/launcher.$source.$id'
import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id'
import { Route as EmbeddedSourceIdRouteImport } from './../routes/embedded.$source.$id'
import { Route as CollectionSourceIdRouteImport } from './../routes/collection.$source.$id'
import { Route as StoreDetailsPluginIdRouteImport } from './../routes/store/details.plugin.$id'
import { Route as StoreDetailsEmulatorIdRouteImport } from './../routes/store/details.emulator.$id'
import { Route as GameUpdateSourceIdRouteImport } from './../routes/game/update.$source.$id'
import { Route as StoreDetailsDownloadSourceIdRouteImport } from './../routes/store/details.download.$source.$id'
const GamesRoute = GamesRouteImport.update({
id: '/games',
@ -53,6 +58,11 @@ const SettingsUpdateRoute = SettingsUpdateRouteImport.update({
path: '/update',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsTasksRoute = SettingsTasksRouteImport.update({
id: '/tasks',
path: '/tasks',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsPluginsRoute = SettingsPluginsRouteImport.update({
id: '/plugins',
path: '/plugins',
@ -98,6 +108,11 @@ const StoreTabIndexRoute = StoreTabIndexRouteImport.update({
path: '/',
getParentRoute: () => StoreTabRouteRoute,
} as any)
const StoreTabPluginsRoute = StoreTabPluginsRouteImport.update({
id: '/plugins',
path: '/plugins',
getParentRoute: () => StoreTabRouteRoute,
} as any)
const StoreTabGamesRoute = StoreTabGamesRouteImport.update({
id: '/games',
path: '/games',
@ -108,6 +123,11 @@ const StoreTabEmulatorsRoute = StoreTabEmulatorsRouteImport.update({
path: '/emulators',
getParentRoute: () => StoreTabRouteRoute,
} as any)
const StoreTabDownloadRoute = StoreTabDownloadRouteImport.update({
id: '/download',
path: '/download',
getParentRoute: () => StoreTabRouteRoute,
} as any)
const SettingsPluginSourceRoute = SettingsPluginSourceRouteImport.update({
id: '/plugin/$source',
path: '/plugin/$source',
@ -138,6 +158,11 @@ const CollectionSourceIdRoute = CollectionSourceIdRouteImport.update({
path: '/collection/$source/$id',
getParentRoute: () => rootRouteImport,
} as any)
const StoreDetailsPluginIdRoute = StoreDetailsPluginIdRouteImport.update({
id: '/store/details/plugin/$id',
path: '/store/details/plugin/$id',
getParentRoute: () => rootRouteImport,
} as any)
const StoreDetailsEmulatorIdRoute = StoreDetailsEmulatorIdRouteImport.update({
id: '/store/details/emulator/$id',
path: '/store/details/emulator/$id',
@ -148,6 +173,12 @@ const GameUpdateSourceIdRoute = GameUpdateSourceIdRouteImport.update({
path: '/game/update/$source/$id',
getParentRoute: () => rootRouteImport,
} as any)
const StoreDetailsDownloadSourceIdRoute =
StoreDetailsDownloadSourceIdRouteImport.update({
id: '/store/details/download/$source/$id',
path: '/store/details/download/$source/$id',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@ -161,6 +192,7 @@ export interface FileRoutesByFullPath {
'/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/settings/plugins': typeof SettingsPluginsRoute
'/settings/tasks': typeof SettingsTasksRoute
'/settings/update': typeof SettingsUpdateRoute
'/collection/$source/$id': typeof CollectionSourceIdRoute
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
@ -168,11 +200,15 @@ export interface FileRoutesByFullPath {
'/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute
'/settings/plugin/$source': typeof SettingsPluginSourceRoute
'/store/tab/download': typeof StoreTabDownloadRoute
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
'/store/tab/games': typeof StoreTabGamesRoute
'/store/tab/plugins': typeof StoreTabPluginsRoute
'/store/tab/': typeof StoreTabIndexRoute
'/game/update/$source/$id': typeof GameUpdateSourceIdRoute
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
'/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute
'/store/details/download/$source/$id': typeof StoreDetailsDownloadSourceIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@ -185,6 +221,7 @@ export interface FileRoutesByTo {
'/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/settings/plugins': typeof SettingsPluginsRoute
'/settings/tasks': typeof SettingsTasksRoute
'/settings/update': typeof SettingsUpdateRoute
'/collection/$source/$id': typeof CollectionSourceIdRoute
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
@ -192,11 +229,15 @@ export interface FileRoutesByTo {
'/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute
'/settings/plugin/$source': typeof SettingsPluginSourceRoute
'/store/tab/download': typeof StoreTabDownloadRoute
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
'/store/tab/games': typeof StoreTabGamesRoute
'/store/tab/plugins': typeof StoreTabPluginsRoute
'/store/tab': typeof StoreTabIndexRoute
'/game/update/$source/$id': typeof GameUpdateSourceIdRoute
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
'/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute
'/store/details/download/$source/$id': typeof StoreDetailsDownloadSourceIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@ -211,6 +252,7 @@ export interface FileRoutesById {
'/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/settings/plugins': typeof SettingsPluginsRoute
'/settings/tasks': typeof SettingsTasksRoute
'/settings/update': typeof SettingsUpdateRoute
'/collection/$source/$id': typeof CollectionSourceIdRoute
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
@ -218,11 +260,15 @@ export interface FileRoutesById {
'/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute
'/settings/plugin/$source': typeof SettingsPluginSourceRoute
'/store/tab/download': typeof StoreTabDownloadRoute
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
'/store/tab/games': typeof StoreTabGamesRoute
'/store/tab/plugins': typeof StoreTabPluginsRoute
'/store/tab/': typeof StoreTabIndexRoute
'/game/update/$source/$id': typeof GameUpdateSourceIdRoute
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
'/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute
'/store/details/download/$source/$id': typeof StoreDetailsDownloadSourceIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@ -238,6 +284,7 @@ export interface FileRouteTypes {
| '/settings/emulators'
| '/settings/interface'
| '/settings/plugins'
| '/settings/tasks'
| '/settings/update'
| '/collection/$source/$id'
| '/embedded/$source/$id'
@ -245,11 +292,15 @@ export interface FileRouteTypes {
| '/launcher/$source/$id'
| '/platform/$source/$id'
| '/settings/plugin/$source'
| '/store/tab/download'
| '/store/tab/emulators'
| '/store/tab/games'
| '/store/tab/plugins'
| '/store/tab/'
| '/game/update/$source/$id'
| '/store/details/emulator/$id'
| '/store/details/plugin/$id'
| '/store/details/download/$source/$id'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@ -262,6 +313,7 @@ export interface FileRouteTypes {
| '/settings/emulators'
| '/settings/interface'
| '/settings/plugins'
| '/settings/tasks'
| '/settings/update'
| '/collection/$source/$id'
| '/embedded/$source/$id'
@ -269,11 +321,15 @@ export interface FileRouteTypes {
| '/launcher/$source/$id'
| '/platform/$source/$id'
| '/settings/plugin/$source'
| '/store/tab/download'
| '/store/tab/emulators'
| '/store/tab/games'
| '/store/tab/plugins'
| '/store/tab'
| '/game/update/$source/$id'
| '/store/details/emulator/$id'
| '/store/details/plugin/$id'
| '/store/details/download/$source/$id'
id:
| '__root__'
| '/'
@ -287,6 +343,7 @@ export interface FileRouteTypes {
| '/settings/emulators'
| '/settings/interface'
| '/settings/plugins'
| '/settings/tasks'
| '/settings/update'
| '/collection/$source/$id'
| '/embedded/$source/$id'
@ -294,11 +351,15 @@ export interface FileRouteTypes {
| '/launcher/$source/$id'
| '/platform/$source/$id'
| '/settings/plugin/$source'
| '/store/tab/download'
| '/store/tab/emulators'
| '/store/tab/games'
| '/store/tab/plugins'
| '/store/tab/'
| '/game/update/$source/$id'
| '/store/details/emulator/$id'
| '/store/details/plugin/$id'
| '/store/details/download/$source/$id'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@ -314,6 +375,8 @@ export interface RootRouteChildren {
PlatformSourceIdRoute: typeof PlatformSourceIdRoute
GameUpdateSourceIdRoute: typeof GameUpdateSourceIdRoute
StoreDetailsEmulatorIdRoute: typeof StoreDetailsEmulatorIdRoute
StoreDetailsPluginIdRoute: typeof StoreDetailsPluginIdRoute
StoreDetailsDownloadSourceIdRoute: typeof StoreDetailsDownloadSourceIdRoute
}
declare module '@tanstack/react-router' {
@ -346,6 +409,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsUpdateRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/tasks': {
id: '/settings/tasks'
path: '/tasks'
fullPath: '/settings/tasks'
preLoaderRoute: typeof SettingsTasksRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/plugins': {
id: '/settings/plugins'
path: '/plugins'
@ -409,6 +479,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StoreTabIndexRouteImport
parentRoute: typeof StoreTabRouteRoute
}
'/store/tab/plugins': {
id: '/store/tab/plugins'
path: '/plugins'
fullPath: '/store/tab/plugins'
preLoaderRoute: typeof StoreTabPluginsRouteImport
parentRoute: typeof StoreTabRouteRoute
}
'/store/tab/games': {
id: '/store/tab/games'
path: '/games'
@ -423,6 +500,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StoreTabEmulatorsRouteImport
parentRoute: typeof StoreTabRouteRoute
}
'/store/tab/download': {
id: '/store/tab/download'
path: '/download'
fullPath: '/store/tab/download'
preLoaderRoute: typeof StoreTabDownloadRouteImport
parentRoute: typeof StoreTabRouteRoute
}
'/settings/plugin/$source': {
id: '/settings/plugin/$source'
path: '/plugin/$source'
@ -465,6 +549,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CollectionSourceIdRouteImport
parentRoute: typeof rootRouteImport
}
'/store/details/plugin/$id': {
id: '/store/details/plugin/$id'
path: '/store/details/plugin/$id'
fullPath: '/store/details/plugin/$id'
preLoaderRoute: typeof StoreDetailsPluginIdRouteImport
parentRoute: typeof rootRouteImport
}
'/store/details/emulator/$id': {
id: '/store/details/emulator/$id'
path: '/store/details/emulator/$id'
@ -479,6 +570,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof GameUpdateSourceIdRouteImport
parentRoute: typeof rootRouteImport
}
'/store/details/download/$source/$id': {
id: '/store/details/download/$source/$id'
path: '/store/details/download/$source/$id'
fullPath: '/store/details/download/$source/$id'
preLoaderRoute: typeof StoreDetailsDownloadSourceIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
@ -489,6 +587,7 @@ interface SettingsRouteRouteChildren {
SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute
SettingsInterfaceRoute: typeof SettingsInterfaceRoute
SettingsPluginsRoute: typeof SettingsPluginsRoute
SettingsTasksRoute: typeof SettingsTasksRoute
SettingsUpdateRoute: typeof SettingsUpdateRoute
SettingsPluginSourceRoute: typeof SettingsPluginSourceRoute
}
@ -500,6 +599,7 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
SettingsEmulatorsRoute: SettingsEmulatorsRoute,
SettingsInterfaceRoute: SettingsInterfaceRoute,
SettingsPluginsRoute: SettingsPluginsRoute,
SettingsTasksRoute: SettingsTasksRoute,
SettingsUpdateRoute: SettingsUpdateRoute,
SettingsPluginSourceRoute: SettingsPluginSourceRoute,
}
@ -509,14 +609,18 @@ const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
)
interface StoreTabRouteRouteChildren {
StoreTabDownloadRoute: typeof StoreTabDownloadRoute
StoreTabEmulatorsRoute: typeof StoreTabEmulatorsRoute
StoreTabGamesRoute: typeof StoreTabGamesRoute
StoreTabPluginsRoute: typeof StoreTabPluginsRoute
StoreTabIndexRoute: typeof StoreTabIndexRoute
}
const StoreTabRouteRouteChildren: StoreTabRouteRouteChildren = {
StoreTabDownloadRoute: StoreTabDownloadRoute,
StoreTabEmulatorsRoute: StoreTabEmulatorsRoute,
StoreTabGamesRoute: StoreTabGamesRoute,
StoreTabPluginsRoute: StoreTabPluginsRoute,
StoreTabIndexRoute: StoreTabIndexRoute,
}
@ -537,6 +641,8 @@ const rootRouteChildren: RootRouteChildren = {
PlatformSourceIdRoute: PlatformSourceIdRoute,
GameUpdateSourceIdRoute: GameUpdateSourceIdRoute,
StoreDetailsEmulatorIdRoute: StoreDetailsEmulatorIdRoute,
StoreDetailsPluginIdRoute: StoreDetailsPluginIdRoute,
StoreDetailsDownloadSourceIdRoute: StoreDetailsDownloadSourceIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View file

@ -9,6 +9,7 @@
@theme {
--breakpoint-sm: 0px;
--breakpoint-md: 1024px;
--breakpoint-lg: 1280px;
--page-scroll-bg: transparent;
--animation-size: 1;

View file

@ -8,7 +8,7 @@ import
RouterProvider,
} from "@tanstack/react-router";
import { routeTree } from "./gen/routeTree.gen";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query";
import "./scripts/gamepads";
import "./scripts/windowEvents";
import "./scripts/spatialNavigation";
@ -16,6 +16,16 @@ import NotFound from "./components/NotFound";
import Error from "./components/Error";
import serviceWorker from './scripts/serviceWorker?worker&url';
import App from "./App";
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createStore, get, set, del } from "idb-keyval";
import
{
PersistedClient,
Persister,
} from '@tanstack/react-query-persist-client';
import pkg from '../../package.json';
const idbStore = createStore("tanstack-query", "cache");
if ('serviceWorker' in navigator)
{
@ -24,7 +34,31 @@ if ('serviceWorker' in navigator)
const hashHistory = createHashHistory({});
const queryClient = new QueryClient();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24 * 5, // 5 days
}
}
});
export function createIDBPersister (idbValidKey: IDBValidKey = 'reactQuery'): Persister
{
return {
persistClient: async (client: PersistedClient) =>
{
await set(idbValidKey, client, idbStore);
},
restoreClient: async () =>
{
return await get<PersistedClient>(idbValidKey, idbStore);
},
removeClient: async () =>
{
await del(idbValidKey, idbStore);
},
} satisfies Persister;
}
export interface RouterContext
{
@ -74,9 +108,9 @@ if (!rootElement.innerHTML)
root.render(
<StrictMode>
<App>
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister: createIDBPersister(), buster: pkg.version }}>
<RouterProvider router={Router} />
</QueryClientProvider>
</PersistQueryClientProvider>
</App>
</StrictMode>,
);

Some files were not shown because too many files have changed in this diff Show more