Compare commits

..

No commits in common. "master" and "v1.5.0" have entirely different histories.

155 changed files with 1915 additions and 4415 deletions

1
.gitignore vendored
View file

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

View file

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

View file

@ -1,13 +1,6 @@
# Changelog # Changelog
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. 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.
## [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) ## [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 @@
# Gameflow Deck # <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
A Cross-Platform open source Retro gaming frontend designed for handheld and controllers. 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. Focused on building a simple user experience and intuitive UI as a curated community driven experience.
@ -7,12 +7,6 @@ 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. > 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. > 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 ## Features
### Integrations ### Integrations

695
bun.lock

File diff suppressed because it is too large Load diff

View file

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

64
scripts/build-sdk.ts Normal file
View file

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

View file

@ -13,18 +13,3 @@ The package must expose a main script gameflow will import and validate. It must
For the plugin to show up in the UI for download. It must be published to NPM with the `gameflow-plugin` keyword. Gameflow uses bun to install plugins as packages from npmjs. For the plugin to show up in the UI for download. It must be published to NPM with the `gameflow-plugin` keyword. Gameflow uses bun to install plugins as packages from npmjs.
Follow publishing instruction check the [NPM Docs](https://docs.npmjs.com/packages-and-modules/contributing-packages-to-the-registry) Follow publishing instruction check the [NPM Docs](https://docs.npmjs.com/packages-and-modules/contributing-packages-to-the-registry)
## Dependencies
Peer dependencies will not be installed when the run adds the plugin package. They are provided by gameflow.
All peer dependencies can be marked as external as gameflow provides it. There is a helper build script that does all that for you, to run it use.
`bunx gameflow-build --entry=index.ts`
supported arguments are
`--entry` the entry of the app to build
`--outdir` Where to build. Default is 'dist'
`--minify` Minify the code. Default is 'false'
`--sourcemap` Include a source map. Default is 'none'
If you want to include dependencies that gameflow does not provide you have to bundle them in. Gameflow does not load dependencies for you.

9
scripts/sdk/package.json Normal file
View file

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

18
scripts/sdk/sdk.ts Normal file
View file

@ -0,0 +1,18 @@
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

@ -17,6 +17,26 @@
"outDir": "../../dist-sdk", "outDir": "../../dist-sdk",
"types": [ "types": [
"node" "node"
] ],
"paths": {
"@/*": [
"../../src/*"
],
"~/*": [
"../../*"
],
"@shared/*": [
"../../src/shared/*"
],
"@clients/*": [
"../../src/clients/*"
],
"@schema/*": [
"../../src/bun/api/schema/*"
],
"@queries/*": [
"../../src/mainview/scripts/queries/*"
]
}
} }
} }

View file

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

View file

@ -138,12 +138,6 @@ export async function checkLoginAndRefreshTwitch ()
export async function checkLoginAndRefreshRomm () export async function checkLoginAndRefreshRomm ()
{ {
//TODO: move to plugin logic
if (plugins.plugins['com.simeonradivoev.gameflow.romm'].config?.get('clientApiToken'))
{
return { hasLogin: true };
}
const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' }); const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' });
if (!access_token) if (!access_token)
{ {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,8 +4,7 @@ import { and, desc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm"
import z from "zod"; import z from "zod";
import * as schema from "@schema/app"; import * as schema from "@schema/app";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { SERVER_URL } from "@shared/constants"; import { GameListFilterSchema, SERVER_URL } from "@shared/constants";
import { CommandEntry, DownloadLookupEntry, DownloadsLookupFilterValues, GameListFilterSchema } from '@simeonradivoev/gameflow-sdk/shared';
import { InstallJob } from "../jobs/install-job"; import { InstallJob } from "../jobs/install-job";
import path from "node:path"; import path from "node:path";
import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
@ -23,7 +22,7 @@ import { LaunchGameJob } from "../jobs/launch-game-job";
import { cores } from "../emulatorjs/emulatorjs"; import { cores } from "../emulatorjs/emulatorjs";
import { findEmulatorPluginIntegration } from "../store/services/emulatorsService"; import { findEmulatorPluginIntegration } from "../store/services/emulatorsService";
import { ImportJob } from "../jobs/import-job"; import { ImportJob } from "../jobs/import-job";
import { EmulatorSourceEntryType, EmulatorSystem, FrontEndFilterLists, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailedEmulator, FrontEndGameTypeWithIds, FrontEndId, GameLookup } from "@simeonradivoev/gameflow-sdk/shared"; import { EmulatorSourceEntryType, EmulatorSystem, FrontEndFilterLists, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailedEmulator, FrontEndGameTypeWithIds, FrontEndId, GameLookup } from "@/shared/types";
// A custom jimp that supports webp // A custom jimp that supports webp
const Jimp = createJimp({ const Jimp = createJimp({
@ -454,18 +453,18 @@ export default new Elysia()
}, { }, {
params: z.object({ id: z.string(), source: z.string() }), params: z.object({ id: z.string(), source: z.string() }),
}) })
.post('/game/:source/:id/install', async ({ params: { id, source }, body }) => .post('/game/:source/:id/install', async ({ params: { id, source }, body: { downloadId } }) =>
{ {
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob)) if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
{ {
return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, body)); return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, { downloadId }));
} else } else
{ {
return status('Not Implemented'); return status('Not Implemented');
} }
}, { }, {
params: z.object({ id: z.string(), source: z.string() }), params: z.object({ id: z.string(), source: z.string() }),
body: z.object({ downloadId: z.string().optional() }).optional(), body: z.object({ downloadId: z.string().optional() }),
response: z.any() response: z.any()
}) })
.delete('/game/:source/:id/install', async ({ params: { id, source } }) => .delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
@ -512,25 +511,7 @@ export default new Elysia()
await plugins.hooks.games.gameLookup.promise(matches, { source, id }); await plugins.hooks.games.gameLookup.promise(matches, { source, id });
return Array.from(matches.values()).flatMap(m => m); return Array.from(matches.values()).flatMap(m => m);
}) })
.get('/game/:source/:id/commands', async ({ params: { id, source }, set }) => .post('/game/:source/:id/play', async ({ params: { id, source }, body, 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); const validCommands = await getValidLaunchCommandsForGame(source, id);
if (validCommands) if (validCommands)
@ -543,7 +524,7 @@ export default new Elysia()
{ {
try try
{ {
const validCommand = command_id ? validCommands.commands.find(c => c.id === command_id) : validCommands.commands[0]; const validCommand = body.command_id ? validCommands.commands.find(c => c.id === body.command_id) : validCommands.commands[0];
if (validCommand) if (validCommand)
{ {
// launch command waits for the game to exit, we don't want that. // launch command waits for the game to exit, we don't want that.
@ -694,10 +675,7 @@ export default new Elysia()
.post('/add/custom', async ({ body: { source, id, platformId, gamePath } }) => .post('/add/custom', async ({ body: { source, id, platformId, gamePath } }) =>
{ {
if (taskQueue.hasActiveOfType(ImportJob)) return status("Conflict", "Import Job Already Running"); if (taskQueue.hasActiveOfType(ImportJob)) return status("Conflict", "Import Job Already Running");
const data = await taskQueue.enqueue(ImportJob.query({ source, id }), new ImportJob(source, id, gamePath, platformId), { const data = await taskQueue.enqueue(ImportJob.id, new ImportJob(source, id, gamePath, platformId), true);
throwOnCancel: true
});
return { source: 'local', id: data.localId }; return { source: 'local', id: data.localId };
}, { }, {
body: z.object({ body: z.object({
@ -706,41 +684,4 @@ export default new Elysia()
gamePath: z.string(), gamePath: z.string(),
platformId: z.number() 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 { config, db, plugins } from "../app";
import * as schema from "@schema/app"; import * as schema from "@schema/app";
import { findPlatform } from "./services/utils"; import { findPlatform } from "./services/utils";
import { FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared"; import { FrontEndPlatformType } from "@/shared/types";
export default new Elysia() export default new Elysia()
.get('/platforms', async () => .get('/platforms', async () =>

View file

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

View file

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

View file

@ -8,7 +8,7 @@ import { RPC_URL } from "@shared/constants";
import { hashFile } from "@/bun/utils"; import { hashFile } from "@/bun/utils";
import { host } from "@/bun/utils/host"; import { host } from "@/bun/utils/host";
import * as emulatorSchema from "@schema/emulators"; import * as emulatorSchema from "@schema/emulators";
import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared"; import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata } from "@/shared/types";
export async function calculateSize (installPath: string | null) export async function calculateSize (installPath: string | null)
{ {
@ -467,40 +467,4 @@ export async function createLocalGame (info: {
}); });
return id; 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;
} }

12
src/bun/api/hooks/app.ts Normal file
View file

@ -0,0 +1,12 @@
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,6 +1,5 @@
import { DownloadFileEntry } from "@/shared/types";
import { AsyncSeriesHook } from "tapable"; import { AsyncSeriesHook } from "tapable";
import { DownloadFileEntry } from "../shared";
export default class AuthHooks export default class AuthHooks
{ {

View file

@ -1,11 +1,9 @@
import { EmulatorPostInstallContext } from "@/bun/types/types";
import { EmulatorPostInstallContextType } from "../index"; import { DownloadFileEntry, EmulatorSourceEntryType, EmulatorSystem } from "@/shared/types";
import { DownloadFileEntry, EmulatorSourceEntryType, EmulatorSystem } from "../shared";
import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable";
export default class EmulatorHooks export default class EmulatorHooks
{ {
/** Download emulator bios files */
fetchBiosDownload = new AsyncSeriesBailHook<[ctx: { fetchBiosDownload = new AsyncSeriesBailHook<[ctx: {
emulator: string; emulator: string;
systems: EmulatorSystem[]; systems: EmulatorSystem[];
@ -15,10 +13,8 @@ export default class EmulatorHooks
/** /**
* Triggered when emulator is downloaded or updated * Triggered when emulator is downloaded or updated
*/ */
emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContextType], { emulator: string; }>(['ctx']); emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContext], { emulator: string; }>(['ctx']);
/** Find locations of emulators on the system. Be it already installed ones or ones downloaded by the store. */
findEmulatorSource = new AsyncSeriesHook<[ctx: { emulator: string; sources: EmulatorSourceEntryType[]; }]>(['ctx']); findEmulatorSource = new AsyncSeriesHook<[ctx: { emulator: string; sources: EmulatorSourceEntryType[]; }]>(['ctx']);
/** Match emulators for a given system */
findEmulatorForSystem = new AsyncSeriesHook<[ctx: { system: string; emulators: string[]; }]>(['ctx']); findEmulatorForSystem = new AsyncSeriesHook<[ctx: { system: string; emulators: string[]; }]>(['ctx']);
constructor() constructor()
@ -28,7 +24,7 @@ export default class EmulatorHooks
{ {
return { return {
...tap, ...tap,
fn: async (ctx: EmulatorPostInstallContextType, ...rest: any[]) => fn: async (ctx: EmulatorPostInstallContext, ...rest: any[]) =>
{ {
if (ctx.emulator === tap.emulator) if (ctx.emulator === tap.emulator)
{ {

View file

@ -1,32 +1,30 @@
import { EmulatorPackageType, GameListFilterType } from '@/shared/constants';
import { EmulatorPackageType, GameListFilterType, CommandEntry, DownloadInfo, EmulatorSourceEntryType, EmulatorSupport, EmulatorSystem, FrontEndCollection, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeWithIds, FrontEndId, FrontEndPlatformType, GameLookup, SaveFileChange, SaveSlots, DownloadLookupEntry, DownloadLookupDetails, DownloadsLookupFilterValues, DownloadsLookupFilter } from '../shared'; import { CommandEntry, DownloadInfo, EmulatorSourceEntryType, EmulatorSupport, EmulatorSystem, FrontEndCollection, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeWithIds, FrontEndId, FrontEndPlatformType, GameLookup, SaveFileChange, SaveSlots } from '@/shared/types';
import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable'; import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, Hook, AsyncSeriesWaterfallHook } from 'tapable';
export default class GameHooks export default class GameHooks
{ {
/** Build commands the game can be launched with. */
buildLaunchCommands = new AsyncSeriesBailHook<[ctx: { buildLaunchCommands = new AsyncSeriesBailHook<[ctx: {
source: string | null; source: string | null;
sourceId: string | null; sourceId: string | null;
id: FrontEndId; id: FrontEndId;
systemSlug: string; systemSlug: string;
gamePath: string | null, gamePath: string | null,
/** The glob pattern for the main executable of the game */
mainGlob?: string | null, mainGlob?: string | null,
}], CommandEntry[] | Error | undefined>(['ctx']); }], CommandEntry[] | Error | undefined>(['ctx']);
/** override the launch command for an emulator /** override the launch command for an emulator
* @param ctx.autoValidCommands The auto generated command for example based on the ES-DE listing
* @param ctx.emulator The emulator ID if any
* @param ctx.game.source The source of the game
* @param ctx.game.sourceId The ID of the source. This could be for example the ROMM ID the game was
* @returns The argument list to be used when running the emulator. * @returns The argument list to be used when running the emulator.
* If no emulator bin in the command entry is found the actual command will be used as the bin. * If no emulator bin in the command entry is found the actual command will be used as the bin.
*/ */
emulatorLaunch = new AsyncSeriesBailHook<[ctx: { emulatorLaunch = new AsyncSeriesBailHook<[ctx: {
/** The auto generated command for example based on the ES-DE listing */
autoValidCommand: CommandEntry; autoValidCommand: CommandEntry;
/** Don't actually launch just see if it can be launched */
dryRun: boolean, dryRun: boolean,
game: { game: {
/** The source of the game */
source?: string; source?: string;
/** The ID of the source. This could be for example the ROMM ID the game was */
sourceId?: string; sourceId?: string;
id: FrontEndId; id: FrontEndId;
platformSlug?: string; platformSlug?: string;
@ -43,36 +41,34 @@ export default class GameHooks
}], EmulatorSupport | undefined, { emulator: string; }>(['ctx']); }], EmulatorSupport | undefined, { emulator: string; }>(['ctx']);
/** /**
* Fetches and returns a list of games converted to frontend. * Fetches and returns a list of games converted to frontend.
* @param ctx.localGameIds This is local game ids in the format '<source>@<sourceId>'
*/ */
fetchGames = new AsyncSeriesHook<[ctx: { fetchGames = new AsyncSeriesHook<[ctx: {
query: GameListFilterType; query: GameListFilterType;
games: FrontEndGameTypeWithIds[]; games: FrontEndGameTypeWithIds[];
}]>(['ctx']); }]>(['ctx']);
/** Return all filters the users can apply for a give source. */
fetchFilters = new AsyncSeriesHook<[ctx: { fetchFilters = new AsyncSeriesHook<[ctx: {
source?: string; source?: string;
filters: FrontEndFilterSets; filters: FrontEndFilterSets;
}]>(['ctx']); }]>(['ctx']);
/** Get game metadata */
fetchGame = new AsyncSeriesBailHook<[ctx: { fetchGame = new AsyncSeriesBailHook<[ctx: {
source: string; source: string;
localGame?: FrontEndGameTypeDetailed; localGame?: FrontEndGameTypeDetailed;
id: string; id: string;
}], FrontEndGameTypeDetailed | undefined>(['ctx']); }], FrontEndGameTypeDetailed | undefined>(['ctx']);
/** Search for a given game based on the igdb id or ra id. */
searchGame = new AsyncSeriesBailHook<[ctx: { searchGame = new AsyncSeriesBailHook<[ctx: {
source: string; source: string;
igdb_id?: number; igdb_id?: number;
ra_id?: number; ra_id?: number;
}], FrontEndGameTypeDetailed | undefined>(['ctx']); }], FrontEndGameTypeDetailed | undefined>(['ctx']);
/** Get download file URLs */ /** Get download file URLs
* @param ctx.checksum Check if file already exists using checksums
*/
fetchDownloads = new AsyncSeriesBailHook<[ctx: { fetchDownloads = new AsyncSeriesBailHook<[ctx: {
source: string; source: string;
id: string; id: string;
/** If there are multiple downloads, use the one with same ID */
downloadId?: string; downloadId?: string;
}], DownloadInfo[] | undefined>(['ctx']); }], DownloadInfo[] | undefined>(['ctx']);
/** Get the paths to rom files. This is mainly used for emulator js. */
fetchRomFiles = new AsyncSeriesBailHook<[ctx: { fetchRomFiles = new AsyncSeriesBailHook<[ctx: {
source: string; source: string;
id: string; id: string;
@ -90,7 +86,6 @@ export default class GameHooks
source: string; source: string;
id: string; id: string;
}], FrontEndPlatformType | undefined>(['ctx']); }], FrontEndPlatformType | undefined>(['ctx']);
/** Lookup a given platform with a given slug or id. This may or may not exist. */
platformLookup = new AsyncSeriesBailHook<[ctx: { platformLookup = new AsyncSeriesBailHook<[ctx: {
source?: string; source?: string;
id?: string; id?: string;
@ -101,23 +96,6 @@ export default class GameHooks
name?: string; name?: string;
family_name?: string; family_name?: string;
} | undefined>(['ctx']); } | undefined>(['ctx']);
/** Lookup downloads based on a search pattern.
* This is just downloads. Doesn't actually have to be a game.
* This is mainly used to manually add games from outside sources */
downloadsLookup = new AsyncSeriesWaterfallHook<[matches: Map<string, {
count: number;
items: DownloadLookupEntry[];
}>, ctx: {
page?: number;
rows?: number;
} & DownloadsLookupFilter]>(['matches', 'ctx']);
/** List all available filters */
downloadsLookupFilters = new AsyncSeriesHook<[ctx: {
filters: DownloadsLookupFilterValues;
}]>(['ctx']);
/** Look for the files for a download the user can pick from */
downloadLookup = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], DownloadLookupDetails | undefined>(['ctx']);
/** Look up game metadata based on a search */
gameLookup = new AsyncSeriesWaterfallHook<[matches: Map<string, GameLookup[]>, ctx: { gameLookup = new AsyncSeriesWaterfallHook<[matches: Map<string, GameLookup[]>, ctx: {
source?: string, source?: string,
id?: string; id?: string;
@ -126,7 +104,6 @@ export default class GameHooks
fetchPlatforms = new AsyncSeriesHook<[ctx: { fetchPlatforms = new AsyncSeriesHook<[ctx: {
platforms: FrontEndPlatformType[]; platforms: FrontEndPlatformType[];
}]>(['ctx']); }]>(['ctx']);
/** Called before the game is played. */
prePlay = new AsyncSeriesHook<[ctx: { prePlay = new AsyncSeriesHook<[ctx: {
source: string, source: string,
id: string; id: string;
@ -138,25 +115,20 @@ export default class GameHooks
}; };
}]>(["ctx"]); }]>(["ctx"]);
/** /**
* Called after the game process has finished. * @param changedSaveFiles Auto detected changed files. This is mainly used to see what changed during gameplay
* @param validChangedSaveFiles This will be final valid changes to be saved using save integrations like rclone
*/ */
postPlay = new AsyncSeriesHook<[ctx: { postPlay = new AsyncSeriesHook<[ctx: {
source: string, source: string,
id: string; id: string;
saveFolderSlots?: SaveSlots; saveFolderSlots?: SaveSlots;
/** Auto detected changed files. This is mainly used to see what changed during gameplay */
changedSaveFiles: { subPath: string, cwd: string; }[], changedSaveFiles: { subPath: string, cwd: string; }[],
/** This will be final valid changes to be saved using save integrations like rclone */
validChangedSaveFiles: Record<string, SaveFileChange>, validChangedSaveFiles: Record<string, SaveFileChange>,
/** The command that was used to launch the game */
command: CommandEntry; command: CommandEntry;
gameInfo: { gameInfo: {
platformSlug?: string; platformSlug?: string;
}; };
}]>(["ctx"]); }]>(["ctx"]);
/** Called after game install
* This includes game being downloaded and registered in the database.
*/
postInstall = new AsyncSeriesHook<[ctx: { postInstall = new AsyncSeriesHook<[ctx: {
source: string, source: string,
id: string; id: string;

View file

@ -1,4 +1,5 @@
import { FrontEndEmulator, FrontEndEmulatorDetailed, FrontEndGameTypeDetailed, EmulatorDownloadInfoType } from "../shared"; import { EmulatorDownloadInfoType } from "@/shared/constants";
import { FrontEndEmulator, FrontEndEmulatorDetailed, FrontEndGameTypeDetailed } from "@/shared/types";
import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable";
export default class StoreHooks export default class StoreHooks

View file

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

View file

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

View file

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

View file

@ -1,12 +1,17 @@
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; import { IJob, JobContext } from "../task-queue";
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { config, events, plugins } from "../app"; import { config, events, plugins } from "../app";
import { simulateProgress } from "@/bun/utils"; import { simulateProgress } from "@/bun/utils";
import { Downloader } from "@/bun/utils/downloader";
import Seven from 'node-7z';
import z from "zod"; import z from "zod";
import { checkFiles, createLocalGame, downloadGame } from "../games/services/utils"; import { checkFiles, createLocalGame } from "../games/services/utils";
import { ensureDir } from "fs-extra"; import { ensureDir, move } from "fs-extra";
import { DownloadInfo, DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared"; import { path7za } from "7zip-bin";
import StreamZip from 'node-stream-zip';
import { which } from "bun";
import { DownloadInfo } from "@/shared/types";
interface JobConfig interface JobConfig
{ {
@ -17,7 +22,7 @@ interface JobConfig
export type InstallJobStates = 'download' | 'extract'; export type InstallJobStates = 'download' | 'extract';
export class InstallJob implements IJob<DownloadJobData, InstallJobStates> export class InstallJob implements IJob<never, InstallJobStates>
{ {
static id = "install-job" as const; static id = "install-job" as const;
static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`; static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`;
@ -29,9 +34,6 @@ export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
public localGameId?: number; public localGameId?: number;
public group = InstallJob.id; public group = InstallJob.id;
public localPath?: string; public localPath?: string;
data: DownloadJobData = {
name: "Install Game"
};
constructor(id: string, source: string, config?: JobConfig) constructor(id: string, source: string, config?: JobConfig)
{ {
@ -40,7 +42,7 @@ export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
this.source = source; this.source = source;
} }
public async start (cx: JobContext<InstallJob, DownloadJobData, InstallJobStates>) public async start (cx: JobContext<InstallJob, never, InstallJobStates>)
{ {
cx.setProgress(0, 'download'); cx.setProgress(0, 'download');
await fs.mkdir(config.get('downloadPath'), { recursive: true }); await fs.mkdir(config.get('downloadPath'), { recursive: true });
@ -56,31 +58,131 @@ export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
if (!info) throw new Error(`Could not find downloader for source ${this.source}`); if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
this.data.name = info.name;
this.data.preview_url = info.coverUrl;
const files = await checkFiles(info.files, !!info.extract_path); const files = await checkFiles(info.files, !!info.extract_path);
if (this.config?.dryDownload !== true && files.some(f => !f.exists || !f.matches)) if (this.config?.dryDownload !== true && files.some(f => !f.exists || !f.matches))
{ {
const downloadedFiles = await downloadGame({ const headers: Record<string, string> = {};
downloads: files.filter(f => !f.exists || !f.matches), if (info.auth)
extract_path: info.extract_path, headers['Authorization'] = info.auth;
path_fs: info.path_fs, const downloader = new Downloader(`game-${this.source}-${this.gameId}`,
abortSignal: cx.abortSignal, files.filter(f => !f.exists || !f.matches),
auth: info.auth, config.get('downloadPath'),
id: `game-${this.source}-${this.gameId}`,
setProgress: (process, state, info) =>
{ {
cx.setProgress(process, state); signal: cx.abortSignal,
this.data.downloaded = info.downloaded; headers,
this.data.speed = info.speed; onProgress (stats)
this.data.total = info.total; {
}, cx.setProgress(stats.progress, 'download');
}); },
});
if (downloadedFiles) 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
{
finalFiles.push(...downloadedFiles); finalFiles.push(...downloadedFiles);
}
} }
if (this.config?.dryDownload === true && info.extract_path) if (this.config?.dryDownload === true && info.extract_path)
@ -91,7 +193,7 @@ export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
const coverResponse = await fetch(info.coverUrl); const coverResponse = await fetch(info.coverUrl);
const cover = Buffer.from(await coverResponse.arrayBuffer()); const cover = Buffer.from(await coverResponse.arrayBuffer());
cx.abortSignal.throwIfAborted(); if (cx.abortSignal.aborted) return;
this.localGameId = await createLocalGame({ this.localGameId = await createLocalGame({
cover, cover,

View file

@ -3,24 +3,22 @@ import z, { _ZodType } from "zod";
import { taskQueue } from "../app"; import { taskQueue } from "../app";
import { LoginJob } from "./login-job"; import { LoginJob } from "./login-job";
import TwitchLoginJob from "./twitch-login-job"; import TwitchLoginJob from "./twitch-login-job";
import EnsureStore from "./ensure-store"; import UpdateStoreJob from "./update-store";
import { EmulatorDownloadJob } from "./emulator-download-job"; import { EmulatorDownloadJob } from "./emulator-download-job";
import { getErrorMessage } from "@/bun/utils"; import { getErrorMessage } from "@/bun/utils";
import { BaseEvent, IJob } from "@simeonradivoev/gameflow-sdk/task-queue"; import { IJob } from "../task-queue";
import { LaunchGameJob } from "./launch-game-job"; import { LaunchGameJob } from "./launch-game-job";
import { BiosDownloadJob } from "./bios-download-job"; import { BiosDownloadJob } from "./bios-download-job";
import { InstallJob } from "./install-job"; import { InstallJob } from "./install-job";
import ReloadPluginsJob from "./reload-plugins-job"; import ReloadPluginsJob from "./reload-plugins-job";
import { FrontEndJob } from "@simeonradivoev/gameflow-sdk/shared";
function registerJob< function registerJob<
const Path extends string, const Path extends string,
Schema, const Schema extends z.ZodTypeAny,
const Query extends z.ZodTypeAny,
const States extends string, const States extends string,
> (_job: { T extends IJob<z.infer<Schema>, States>
id: Path; > (_job: { id: Path; dataSchema: Schema; query?: (q: any) => string; } & (new (...args: any[]) => T))
query?: (q: any) => string;
} & (new (...args: any[]) => IJob<Schema, States>))
{ {
return new Elysia().ws(_job.id, { return new Elysia().ws(_job.id, {
body: z.discriminatedUnion('type', [ body: z.discriminatedUnion('type', [
@ -32,9 +30,9 @@ function registerJob<
type: z.literal(['data', 'started', 'progress']), type: z.literal(['data', 'started', 'progress']),
state: z.string().optional(), state: z.string().optional(),
progress: z.number(), progress: z.number(),
data: z.custom<Schema>() data: _job.dataSchema
}), }),
z.object({ type: z.literal(['completed', 'ended']), data: z.custom<Schema>() }), z.object({ type: z.literal(['completed', 'ended']), data: _job.dataSchema }),
z.object({ type: z.literal('waiting') }), z.object({ type: z.literal('waiting') }),
z.object({ type: z.literal('error'), error: z.string() }) z.object({ type: z.literal('error'), error: z.string() })
]), ]),
@ -44,7 +42,7 @@ function registerJob<
const job = taskQueue.findJob(jobId, _job); const job = taskQueue.findJob(jobId, _job);
if (job) if (job)
{ {
ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() as Schema }); ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
} else } else
{ {
ws.send({ type: 'waiting' }); ws.send({ type: 'waiting' });
@ -104,87 +102,10 @@ function registerJob<
} }
export const jobs = new Elysia({ prefix: '/api/jobs' }) 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(LaunchGameJob))
.use(registerJob(LoginJob)) .use(registerJob(LoginJob))
.use(registerJob(TwitchLoginJob)) .use(registerJob(TwitchLoginJob))
.use(registerJob(EnsureStore)) .use(registerJob(UpdateStoreJob))
.use(registerJob(BiosDownloadJob)) .use(registerJob(BiosDownloadJob))
.use(registerJob(InstallJob)) .use(registerJob(InstallJob))
.use(registerJob(ReloadPluginsJob)) .use(registerJob(ReloadPluginsJob))

View file

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

View file

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

View file

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

View file

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

View file

@ -1,30 +0,0 @@
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 "@simeonradivoev/gameflow-sdk"; import { IJob, JobContext } from "../task-queue";
import secrets from "../secrets"; import secrets from "../secrets";
import open from "open"; import open from "open";
import z from "zod"; import z from "zod";

View file

@ -0,0 +1,67 @@
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 '@simeonradivoev/gameflow-sdk/shared'; import { FrontendNotification } from '@/shared/types';
import { events } from './app'; import { events } from './app';
export default function buildNotificationsStream () export default function buildNotificationsStream ()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,12 @@
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema";
import desc from './package.json'; 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 { 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 { config, events } from "@/bun/api/app";
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { hashFile, isArchive, isSteamDeckGameMode } from "@/bun/utils"; import { hashFile, isSteamDeckGameMode } from "@/bun/utils";
import { CACHE_KEYS, getOrCached } from "@/bun/api/cache"; import { CACHE_KEYS, getOrCached } from "@/bun/api/cache";
import secrets from "@/bun/api/secrets"; import secrets from "@/bun/api/secrets";
import { getAuthToken } from "@/clients/romm/core/auth.gen"; 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 { validateGameSource } from "@/bun/api/games/services/statusService";
import z from "zod"; import z from "zod";
import { checkLoginAndRefreshRomm } from "@/bun/api/auth"; import { checkLoginAndRefreshRomm } from "@/bun/api/auth";
import { DownloadFileEntry, DownloadInfo, FrontEndCollection, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, FrontEndGameTypeWithIds, FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared"; import { DownloadFileEntry, DownloadInfo, FrontEndCollection, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, FrontEndGameTypeWithIds, FrontEndPlatformType } from "@/shared/types";
import Conf from "conf"; import Conf from "conf";
const SettingsSchema = z.object({ const SettingsSchema = z.object({
@ -44,7 +44,7 @@ export default class RommIntegration implements PluginType<SettingsType>
async getAccessToken (config: Conf<SettingsType>) async getAccessToken (config: Conf<SettingsType>)
{ {
if (process.env.ROMM_CLIENT_TOKEN) return process.env.ROMM_CLIENT_TOKEN; if (process.env.ROMM_CLIENT_TOKEN) return process.env.ROMM_CLIENT_TOKEN;
const client_token = config.get('clientApiToken'); const client_token = await config.get('clientApiToken');
if (client_token) return client_token; if (client_token) return client_token;
return (await secrets.get({ service: 'gameflow', name: 'romm_access_token' })) ?? undefined; return (await secrets.get({ service: 'gameflow', name: 'romm_access_token' })) ?? undefined;
} }
@ -254,7 +254,8 @@ export default class RommIntegration implements PluginType<SettingsType>
let path_fs = path.join(rom.fs_path, rom.fs_name); let path_fs = path.join(rom.fs_path, rom.fs_name);
if (files.length === 1) if (files.length === 1)
{ {
if (isArchive(files[0].file_name)) const name = files[0].file_name.toLocaleLowerCase();
if (name.endsWith('.zip') || name.endsWith('.7z') || name.endsWith('.rar'))
{ {
extract_path = '.'; extract_path = '.';
path_fs = path.join(rom.fs_path, rom.slug ?? rom.fs_name_no_ext); 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", "name": "com.simeonradivoev.gameflow.store",
"displayName": "Gameflow Store Integration", "displayName": "Gameflow Store",
"version": "0.0.1", "version": "0.0.1",
"description": "The internal gameflow store integration. This is the logic of the store that uses the data only store package", "description": "The internal gameflow store",
"main": "./store.ts", "main": "./store.ts",
"category": "sources", "category": "sources",
"canDisable": false, "canDisable": false,

View file

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

View file

@ -1,26 +1,20 @@
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema";
import desc from './package.json'; import desc from './package.json';
import path, { } from 'node:path'; import path, { } from 'node:path';
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService";
import { Glob, pathToFileURL, which } from "bun"; import { Glob, pathToFileURL } from "bun";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import * as emulatorSchema from '@schema/emulators'; import * as emulatorSchema from '@schema/emulators';
import { config, emulatorsDb, taskQueue } from "@/bun/api/app"; import { config, emulatorsDb, taskQueue } from "@/bun/api/app";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { getSourceGameDetailed } from "@/bun/api/games/services/utils"; import { getSourceGameDetailed } from "@/bun/api/games/services/utils";
import EnsureStore from "@/bun/api/jobs/ensure-store"; import UpdateStoreJob from "@/bun/api/jobs/update-store";
import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService";
import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services"; import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services";
import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@simeonradivoev/gameflow-sdk/shared"; import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@/shared/types";
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 StoreIntegration implements PluginType export default class RommIntegration implements PluginType
{ {
eventsNames = [{ id: 'updateStore', title: "Update Store", description: "Update the Store Manifest", action: "Update" }]; eventsNames = [{ id: 'updateStore', title: "Update Store", description: "Update the Store Manifest", action: "Update" }];
@ -29,7 +23,7 @@ export default class StoreIntegration implements PluginType
switch (e) switch (e)
{ {
case 'updateStore': case 'updateStore':
await taskQueue.enqueue(EnsureStore.id, new EnsureStore()); await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
return { reload: true }; return { reload: true };
} }
} }
@ -38,7 +32,7 @@ export default class StoreIntegration implements PluginType
{ {
console.log("Store Directory is ", getStoreFolder()); console.log("Store Directory is ", getStoreFolder());
ctx.setProgress(0, "Updating Store"); ctx.setProgress(0, "Updating Store");
await taskQueue.enqueue(EnsureStore.id, new EnsureStore()); await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
} }
async load (ctx: PluginLoadingContextType) async load (ctx: PluginLoadingContextType)
@ -157,8 +151,7 @@ export default class StoreIntegration implements PluginType
if (!validDownload || !validDownload.bin) return; if (!validDownload || !validDownload.bin) return;
const glob = new Glob(validDownload.bin); const glob = new Glob(validDownload.bin);
const files = await Array.fromAsync(glob.scan({ cwd: emulatorPath })); const files = await Array.fromAsync(glob.scan({ cwd: emulatorPath }));
// es-de also searches for store executables so there might be duplicates, check first. if (files.length > 0)
if (files.length > 0 && !sources.find(s => s.type === 'store'))
{ {
sources.push({ binPath: path.join(emulatorPath, files[0]), exists: true, rootPath: emulatorPath, type: 'store' }); sources.push({ binPath: path.join(emulatorPath, files[0]), exists: true, rootPath: emulatorPath, type: 'store' });
} }
@ -301,7 +294,7 @@ export default class StoreIntegration implements PluginType
const info: DownloadInfo = { const info: DownloadInfo = {
id: validDownload.id, id: validDownload.id,
coverUrl: game.covers?.[0] ? isUrl(game.covers[0]) ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "", coverUrl: game.covers?.[0] ? game.covers[0].startsWith('http') ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "",
screenshotUrls: game.screenshots ?? [], screenshotUrls: game.screenshots ?? [],
files: [{ files: [{
url: new URL(validDownload.url), url: new URL(validDownload.url),
@ -331,129 +324,5 @@ export default class StoreIntegration implements PluginType
return info; 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,13 +1,10 @@
import { GameflowHooks } from "@simeonradivoev/gameflow-sdk"; import GameflowHooks from "../hooks/app";
import { PluginDescriptionType, PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import { PluginDescriptionType, PluginLoadingContextType, PluginType } from "../../types/types.schema";
import { config, events, taskQueue } from "../app"; import { config } from "../app";
import Conf from "conf"; import Conf from "conf";
import projectPackage from '~/package.json'; import projectPackage from '~/package.json';
import z from "zod"; import z from "zod";
import { PluginSourceType, PluginUpdateCheck } from "@simeonradivoev/gameflow-sdk/shared"; import { PluginSourceType } from "@/shared/types";
import { getUpdates } from "./services";
import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json';
import { semver } from "bun";
export const pluginZodRegistry = z.registry<{ export const pluginZodRegistry = z.registry<{
requiresRestart?: boolean; requiresRestart?: boolean;
@ -24,19 +21,9 @@ export class PluginManager
description: PluginDescriptionType, description: PluginDescriptionType,
source: PluginSourceType; source: PluginSourceType;
config?: Conf; 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) register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType)
{ {
try try
@ -81,33 +68,16 @@ export class PluginManager
}; };
} }
checkValidity (plugin: PluginDescriptionType) private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; })
{
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 | null)
{ {
const plugin = this.plugins[name]; const plugin = this.plugins[name];
if (plugin) if (plugin)
{ {
plugin.update = update && !semver.satisfies(plugin.description.version, update) ? { current: plugin.description.version, new: update } : undefined;
const ctx: PluginLoadingContextType = { const ctx: PluginLoadingContextType = {
hooks: this.hooks, hooks: this.hooks,
setProgress: reloadCtx.setProgress.bind(reloadCtx), setProgress: reloadCtx.setProgress.bind(reloadCtx),
config: plugin.config as any, config: plugin.config as any,
zodRegistry: pluginZodRegistry, zodRegistry: pluginZodRegistry
app: {
config,
events,
taskQueue
}
}; };
if (plugin.loaded) if (plugin.loaded)
@ -118,14 +88,7 @@ export class PluginManager
try try
{ {
plugin.incompatible = !this.checkValidity(plugin.description); if (plugin.enabled || plugin.description.canDisable === false)
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); console.log("Loading Plugin", plugin.description.name);
await plugin.plugin.load(ctx); await plugin.plugin.load(ctx);
@ -143,13 +106,10 @@ export class PluginManager
async reloadAll (ctx: { setProgress: (progress: number, state: string) => void; }) async reloadAll (ctx: { setProgress: (progress: number, state: string) => void; })
{ {
this.hooks = new GameflowHooks(); this.hooks = new GameflowHooks();
const outdated = await getUpdates();
for await (const id of Object.keys(this.plugins)) for await (const id of Object.keys(this.plugins))
{ {
ctx.setProgress(0, `Loading ${id}`); ctx.setProgress(0, `Loading ${id}`);
await this.reload(id, ctx, outdated.find(i => i.package === id)?.update); await this.reload(id, ctx);
} }
} }

View file

@ -3,9 +3,7 @@ import { plugins, taskQueue } from "../app";
import z from "zod"; import z from "zod";
import { toggleElementInConfig } from "@/bun/utils"; import { toggleElementInConfig } from "@/bun/utils";
import ReloadPluginsJob from "../jobs/reload-plugins-job"; import ReloadPluginsJob from "../jobs/reload-plugins-job";
import { FrontendPlugin } from "@simeonradivoev/gameflow-sdk/shared"; import { FrontendPlugin } from "@/shared/types";
import { canDisable, canUninstall } from "./services";
import PluginOperationJob from "../jobs/plugin-operation-job";
export default new Elysia({ prefix: '/plugins' }) export default new Elysia({ prefix: '/plugins' })
.get('/', async () => .get('/', async () =>
@ -19,27 +17,25 @@ export default new Elysia({ prefix: '/plugins' })
description: p.description.description, description: p.description.description,
source: p.source, source: p.source,
version: p.description.version, version: p.description.version,
canDisable: canDisable(p.description), canDisable: p.description.canDisable ?? true,
icon: p.description.icon, icon: p.description.icon,
category: p.description.category, 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; return plugin;
}); });
}) })
.get('/:id', async ({ params: { id } }) => .get('/:id', async ({ params: { id } }) =>
{ {
const plugin = plugins.plugins[decodeURIComponent(id)]; const plugin = plugins.plugins[id];
return { ...plugin.description, update: plugin.update }; return plugin.description;
}) })
.post('/:id', async ({ params: { id }, body: { enabled } }) => .post('/:id', async ({ params: { id }, body: { enabled } }) =>
{ {
const plugin = plugins.plugins[decodeURIComponent(id)]; const plugin = plugins.plugins[id];
if (plugin) if (plugin)
{ {
if (!canDisable(plugin.description)) if (plugin.description.canDisable === false)
{ {
return status("Forbidden"); return status("Forbidden");
} }
@ -52,26 +48,4 @@ export default new Elysia({ prefix: '/plugins' })
} }
}, { }, {
body: z.object({ enabled: z.boolean() }) 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,74 +11,12 @@ import igdb from './builtin/sources/com.simeonradivoev.gameflow.igdb/package.jso
import store from './builtin/sources/com.simeonradivoev.gameflow.store/package.json'; import store from './builtin/sources/com.simeonradivoev.gameflow.store/package.json';
import es from './builtin/launchers/com.simeonradivoev.gameflow.es/package.json'; import es from './builtin/launchers/com.simeonradivoev.gameflow.es/package.json';
import rclone from './builtin/other/com.simeonradivoev.gameflow.rclone/package.json'; import rclone from './builtin/other/com.simeonradivoev.gameflow.rclone/package.json';
import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@simeonradivoev/gameflow-sdk"; import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/types.schema";
import path from 'node:path'; import path from 'node:path';
import { getStoreRootFolder } from "../store/services/gamesService"; 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>; }; 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) export default async function register (pluginManager: PluginManager)
{ {
const plugins: PluginEntry[] = [ const plugins: PluginEntry[] = [
@ -95,59 +33,53 @@ export default async function register (pluginManager: PluginManager)
{ ...rclone, load: () => import('./builtin/other/com.simeonradivoev.gameflow.rclone/rclone') }, { ...rclone, load: () => import('./builtin/other/com.simeonradivoev.gameflow.rclone/rclone') },
]; ];
await Promise.all(plugins.map(p => registerPlugin(p, 'builtin', pluginManager))); const storePackageFile = path.join(getStoreRootFolder(), 'package.json');
const storePackage = await Bun.file(storePackageFile).json();
if (IsPluginAllowed('@simeonradivoev/gameflow-store')) if (storePackage.dependencies)
{ {
const storePackageFilePath = path.join(getStoreRootFolder(), 'package.json'); const storePlugins = await Promise.all(Object.keys(storePackage.dependencies).map(async p =>
if (!await Bun.file(storePackageFilePath).exists())
{ {
console.log("Store is missing. Updating it."); const pluginPath = path.join(getStoreRootFolder(), 'node_modules', p);
await taskQueue.enqueue(EnsureStore.id, new EnsureStore()); const pluginPackageFile = Bun.file(path.join(pluginPath, 'package.json'));
console.log("Store Updated"); if (await pluginPackageFile.exists())
}
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); const pluginPackage = await PluginDescriptionSchema.safeParseAsync(await pluginPackageFile.json());
})); if (pluginPackage.success)
console.log("Checking for outdated packages");
const outdated = await getUpdates();
const validPlugins = storePlugins.filter(p => !!p);
if (outdated)
{
for (let i = 0; i < validPlugins.length; i++)
{ {
const plugin = validPlugins[i]; const mainPath = path.join(pluginPath, pluginPackage.data.main);
const newVersion = outdated.find(i => i.package === plugin.name); if (await Bun.file(mainPath).exists())
if (newVersion)
{ {
console.log("Plugin", plugin.name, "has update", plugin.version, "=>", newVersion.update); const entry: PluginEntry = { ...pluginPackage.data, load: () => import(mainPath) };
return entry;
if (plugin.autoUpdate || plugin.name === '@simeonradivoev/gameflow-store')
{
console.log("Auto Updating Plugin", plugin.name);
let response = await runBunPackageCommand(["add", `${plugin.name}@${newVersion?.update}`, "--registry", PluginRegistry, '--omit', 'peer']);
console.log(response);
// Update plugin package
const newPlugin = await getPlugin(plugin.name, pluginManager);
if (newPlugin)
validPlugins[i] = newPlugin;
}
} }
} }
} }
}));
await Promise.all(validPlugins.map(p => registerPlugin(p, 'store', pluginManager))); plugins.push(...storePlugins.filter(p => !!p));
}
} else
{
console.log('Skipping Store Packages');
} }
await Promise.all(plugins.filter(p =>
{
if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(p.name))
{
return false;
}
if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(p.name))
{
return false;
}
return true;
}).map(async (pluginPackage) =>
{
const file = await pluginPackage.load();
if (file.default && typeof file.default === 'function')
{
const pluginInstance = new file.default();
await PluginSchema.parseAsync(pluginInstance);
const description = await PluginDescriptionSchema.parseAsync(pluginPackage);
pluginManager.register(pluginInstance, description, 'builtin');
}
}));
} }

View file

@ -1,64 +0,0 @@
import path from 'node:path';
import os from 'node:os';
import { getStoreRootFolder } from '../store/services/gamesService';
import { PluginDescriptionType } from '@simeonradivoev/gameflow-sdk';
import { existsSync } from 'node:fs';
import { checkOutdated } from './update-check';
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 results = await checkOutdated(getStoreRootFolder());
return results;
}
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,169 +0,0 @@
import { semver } from "bun";
import { readFile } from "fs/promises";
import { join } from "path";
import { getOrCached } from "../cache";
import { PluginRegistry } from "@/shared/constants";
import sdkPkg from '@/packages/gameflow-sdk/package.json';
interface UpdateInfo
{
package: string,
current: string,
update: string | null,
latest: string,
sdkConstrained: boolean,
sdkRange: string,
note: string | null;
}
function parseBunOutdated (cwd: string)
{
const proc = Bun.spawnSync([process.execPath, "outdated"], {
stderr: "inherit", env: {
BUN_BE_BUN: "1",
NO_COLOR: "1",
}, cwd: cwd
});
const output = proc.stdout.toString();
const lines = output.split("\n").filter(Boolean);
const headerIndex = lines.findIndex(
(l) => l.includes("Package") && l.includes("Current")
);
if (headerIndex === -1) return [];
return lines
.slice(headerIndex + 1)
.filter((line) => !/^[-─╌| ]+$/.test(line))
.map((line) =>
{
const [, pkg, current, , latest] = line.split("|").map((c) => c.trim());
return pkg ? { package: pkg, current, latest } : null;
})
.filter(p => p !== null);
}
async function getInstalledVersion (cwd: string, pkg: string)
{
try
{
const raw = await readFile(join(cwd, "node_modules", pkg, "package.json"), "utf8");
return JSON.parse(raw).version ?? null;
} catch
{
return null;
}
}
async function fetchAllVersions (pkg: string)
{
const res = await fetch(`${PluginRegistry}/${pkg}`);
if (!res.ok) return [];
const data = await res.json();
return Object.keys(data.versions ?? {});
}
async function fetchPeerDeps (pkg: string, version: string)
{
const peerDependencies = await getOrCached(`npm-${pkg}-${version}`, async () =>
{
const res = await fetch(`${PluginRegistry}/${pkg}/${version}`);
if (!res.ok)
{
throw new Error(`Error while fetching peer deps for ${pkg} ${version} ${res.status} ${res.statusText}`);
}
const data = await res.json();
return data.peerDependencies ?? {};
}, {
//5 days
expireMs: 1000 * 60 * 60 * 24 * 5
});
return peerDependencies;
}
async function findBestVersion (pkg: string, allVersions: string[], sdkVersion: string)
{
// Sort descending so we find the highest compatible version first
const sorted = [...allVersions].sort((a, b) => semver.order(b, a));
for (const version of sorted)
{
const peers = await fetchPeerDeps(pkg, version);
const sdkRange = peers[sdkPkg.name];
if (!sdkRange)
{
// No peer dep on SDK — compatible by default
return { version, sdkRange: null };
}
if (semver.satisfies(sdkVersion, sdkRange))
{
return { version, sdkRange };
}
}
return null;
}
export async function checkOutdated (cwd: string)
{
const outdated = parseBunOutdated(cwd);
if (outdated.length === 0)
{
return [];
}
const sdkVersion = await getInstalledVersion(cwd, sdkPkg.name);
if (!sdkVersion)
{
console.error(`Could not find installed version of ${sdkPkg.name} in node_modules.`);
process.exit(1);
}
const results = await Promise.all(
outdated.map(async ({ package: pkg, current, latest }) =>
{
const allVersions = await fetchAllVersions(pkg);
// Check if the outright latest is already SDK compatible
const latestPeers = await fetchPeerDeps(pkg, latest);
const latestSdkRange = latestPeers[sdkPkg.name];
const latestCompatible =
!latestSdkRange || semver.satisfies(sdkVersion, latestSdkRange);
if (latestCompatible)
{
return {
package: pkg,
current,
update: latest,
latest,
sdkConstrained: false,
sdkRange: latestSdkRange ?? null,
note: null
} satisfies UpdateInfo as UpdateInfo;
}
const best = await findBestVersion(pkg, allVersions, sdkVersion);
return {
package: pkg,
current,
update: best?.version ?? null,
latest,
sdkConstrained: true,
sdkRange: best?.sdkRange ?? null,
note: best
? `Latest (${latest}) requires incompatible SDK range; best compatible: ${best.version}`
: `No version of ${pkg} is compatible with ${sdkPkg.name}@${sdkVersion}`,
} satisfies UpdateInfo as UpdateInfo;
})
);
return results;
}

View file

@ -1,5 +1,4 @@
import { LocalGameMetadata } from "@/shared/types";
import { LocalGameMetadata } from "@simeonradivoev/gameflow-sdk/shared";
import { sql, relations } from "drizzle-orm"; import { sql, relations } from "drizzle-orm";
import { integer, text, sqliteTable, blob } from "drizzle-orm/sqlite-core"; 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 { SERVER_URL } from '@/shared/constants';
import { host } from '@/bun/utils/host'; import { host } from '@/bun/utils/host';
import { findEmulatorPluginIntegration } from '../store/services/emulatorsService'; import { findEmulatorPluginIntegration } from '../store/services/emulatorsService';
import { EmulatorSourceEntryType, FrontEndEmulator } from '@simeonradivoev/gameflow-sdk/shared'; import { EmulatorSourceEntryType, FrontEndEmulator } from '@/shared/types';
/** /**
* Get emulators based on local games. Only the ones we probably need. * Get emulators based on local games. Only the ones we probably need.

View file

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

View file

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

View file

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

View file

@ -3,6 +3,7 @@ import Elysia, { status } from "elysia";
import { config, db, plugins, taskQueue } from "../app"; import { config, db, plugins, taskQueue } from "../app";
import path from "node:path"; import path from "node:path";
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { EmulatorDownloadInfoSchema } from "@/shared/constants";
import * as appSchema from '@schema/app'; import * as appSchema from '@schema/app';
import z from "zod"; import z from "zod";
import { convertLocalToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; import { convertLocalToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
@ -12,17 +13,7 @@ import { getStoreFolder } from "./services/gamesService";
import { EmulatorDownloadJob } from "../jobs/emulator-download-job"; import { EmulatorDownloadJob } from "../jobs/emulator-download-job";
import { BiosDownloadJob } from "../jobs/bios-download-job"; import { BiosDownloadJob } from "../jobs/bios-download-job";
import { findEmulatorPluginIntegration, getEmulatorPath } from "./services/emulatorsService"; import { findEmulatorPluginIntegration, getEmulatorPath } from "./services/emulatorsService";
import { EmulatorSourceEntryType, FrontEndEmulator, FrontEndGameTypeDetailed, PluginBunDetailsSchema, PluginEntrySchema, EmulatorDownloadInfoSchema } from "@simeonradivoev/gameflow-sdk/shared"; import { EmulatorSourceEntryType, FrontEndEmulator, FrontEndGameTypeDetailed } from "@/shared/types";
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' }) export const store = new Elysia({ prefix: '/api/store' })
.get('/emulators', async ({ query }) => .get('/emulators', async ({ query }) =>
@ -118,49 +109,6 @@ export const store = new Elysia({ prefix: '/api/store' })
gameCount 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 }) => .get('/media/*', async ({ params }) =>
{ {
return Bun.file(path.join(getStoreFolder(), params["*"])); return Bun.file(path.join(getStoreFolder(), params["*"]));
@ -188,16 +136,16 @@ export const store = new Elysia({ prefix: '/api/store' })
emulator.integrations = integrations; emulator.integrations = integrations;
return emulator; return emulator;
}, { params: z.object({ id: z.string() }) }) }, { params: z.object({ id: z.string() }) })
.post('/install/emulator/:id/:source', async ({ params: { source, id }, body }) => .post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) =>
{ {
if (taskQueue.hasActiveOfType(EmulatorDownloadJob)) if (taskQueue.hasActiveOfType(EmulatorDownloadJob))
{ {
return status("Conflict", "Installation already running"); return status("Conflict", "Installation already running");
} }
const job = new EmulatorDownloadJob(id, source, body); const job = new EmulatorDownloadJob(id, source, { isUpdate });
return taskQueue.enqueue(EmulatorDownloadJob.id, job); return taskQueue.enqueue(EmulatorDownloadJob.id, job);
}, { }, {
body: z.object({ isUpdate: z.boolean().optional() }).optional() body: z.object({ isUpdate: z.boolean().optional() })
}) })
.delete('/emulator/:id', async ({ params: { id } }) => .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 fs from 'node:fs/promises';
import buildNotificationsStream from "./notifications"; import buildNotificationsStream from "./notifications";
import path, { dirname } from "node:path"; import path, { dirname } from "node:path";
import { SystemInfoSchema, DirSchema, DownloadsDrive } from '@simeonradivoev/gameflow-sdk/shared'; import { DirSchema, SystemInfoSchema } from "@/shared/constants";
import { getDevices, getDevicesCurated } from "./drives"; import { getDevices, getDevicesCurated } from "./drives";
import getFolderSize from "get-folder-size"; import getFolderSize from "get-folder-size";
import si from 'systeminformation'; import si from 'systeminformation';
@ -16,6 +16,7 @@ import ReloadPluginsJob from "./jobs/reload-plugins-job";
import { semver } from "bun"; import { semver } from "bun";
import { getOrCachedGithubRelease } from "./cache"; import { getOrCachedGithubRelease } from "./cache";
import SelfUpdateJob from "./jobs/self-update-job"; import SelfUpdateJob from "./jobs/self-update-job";
import { DownloadsDrive } from "@/shared/types";
async function checkUpdate (force?: boolean) async function checkUpdate (force?: boolean)
{ {
@ -86,7 +87,6 @@ export const system = new Elysia({ prefix: '/api/system' })
z.object({ type: z.literal('info'), data: SystemInfoSchema }), z.object({ type: z.literal('info'), data: SystemInfoSchema }),
z.object({ type: z.literal('focus') }), z.object({ type: z.literal('focus') }),
z.object({ type: z.literal('loading'), progress: z.number(), state: z.string().optional() }), z.object({ type: z.literal('loading'), progress: z.number(), state: z.string().optional() }),
z.object({ type: z.literal('activeTask'), progress: z.number().nullable() }),
z.object({ type: z.literal('loaded') }), z.object({ type: z.literal('loaded') }),
]), ]),
async open (ws) async open (ws)
@ -95,8 +95,6 @@ export const system = new Elysia({ prefix: '/api/system' })
if (existingLoading) ws.send({ type: 'loading', progress: existingLoading.progress, state: existingLoading.state }); if (existingLoading) ws.send({ type: 'loading', progress: existingLoading.progress, state: existingLoading.state });
else ws.send({ type: 'loaded' }); else ws.send({ type: 'loaded' });
ws.send({ type: 'activeTask', progress: taskQueue.getActiveJobs()[0]?.progress });
const startInfo = async () => const startInfo = async () =>
{ {
const battery = await si.battery(); const battery = await si.battery();
@ -119,8 +117,6 @@ export const system = new Elysia({ prefix: '/api/system' })
dispose.push(taskQueue.on('progress', e => dispose.push(taskQueue.on('progress', e =>
{ {
ws.send({ type: 'activeTask', progress: e.progress });
if (e.id === ReloadPluginsJob.id) if (e.id === ReloadPluginsJob.id)
{ {
ws.send({ type: "loading", progress: e.progress, state: e.state }); ws.send({ type: "loading", progress: e.progress, state: e.state });
@ -132,8 +128,6 @@ export const system = new Elysia({ prefix: '/api/system' })
})); }));
dispose.push(taskQueue.on('started', e => dispose.push(taskQueue.on('started', e =>
{ {
ws.send({ type: 'activeTask', progress: 0 });
if (e.id === ReloadPluginsJob.id) if (e.id === ReloadPluginsJob.id)
ws.send({ type: "loading", progress: e.job.progress, state: e.job.state }); ws.send({ type: "loading", progress: e.job.progress, state: e.job.state });
else if (e.id === SelfUpdateJob.id) else if (e.id === SelfUpdateJob.id)
@ -141,7 +135,6 @@ export const system = new Elysia({ prefix: '/api/system' })
})); }));
dispose.push(taskQueue.on('ended', e => dispose.push(taskQueue.on('ended', e =>
{ {
ws.send({ type: 'activeTask', progress: null });
if (e.id !== ReloadPluginsJob.id && e.id !== SelfUpdateJob.id) return; if (e.id !== ReloadPluginsJob.id && e.id !== SelfUpdateJob.id) return;
ws.send({ type: "loaded" }); ws.send({ type: "loaded" });
})); }));

View file

@ -1,7 +1,6 @@
import { JobStatus } from '@/shared/types';
import EventEmitter from 'node:events'; import EventEmitter from 'node:events';
import z from 'zod'; import z from 'zod';
import { JobStatus } from './shared';
export class TaskQueue export class TaskQueue
{ {
@ -18,24 +17,14 @@ export class TaskQueue
}); });
} }
public enqueue<T> (id: string, job: T, options?: { throwOnCancel?: boolean; }): T extends IJob<infer TData, infer TState extends string> public enqueue<T> (id: string, job: T, throwOnError?: boolean): T extends IJob<infer TData, infer TState extends string>
? Promise<TData> ? Promise<TData>
: never : never
{ {
this.disposeSafeguard(); this.disposeSafeguard();
if (!this.queue || !this.events) throw new Error("Queue disposed"); if (!this.queue || !this.events) throw new Error("Queue disposed");
if (this.activeQueue.some(j => j.id === id)) throw new Error(`Job with ID ${id} already active`); const context = new JobContext<any, any, any>(id, this.events, job);
if (this.queue.some(j => j.id === id)) throw new Error(`Job with ${id} already queued`);
const context = new JobContext<any, any, any>(id, this.events, job, options);
this.queue.push(context as any); this.queue.push(context as any);
context.abortSignal.addEventListener('abort', () =>
{
const queueIndex = this.queue?.findIndex(c => c === context);
if (queueIndex !== undefined && queueIndex >= 0)
{
this.queue?.splice(queueIndex, 1);
}
});
this.events?.emit('queued', { id: context.id, job: context }); this.events?.emit('queued', { id: context.id, job: context });
this.processQueue(); this.processQueue();
return context.promise.promise as any; return context.promise.promise as any;
@ -45,24 +34,7 @@ export class TaskQueue
{ {
if (!this.queue) return Promise.resolve(); if (!this.queue) return Promise.resolve();
let activeGroupsSet = new Set(this.activeQueue.filter(j => j.job.group).map(j => j.job.group)); const next = this.queue.filter(j => !j.job.group || !this.activeQueue.some(a => a.job.group === j.job.group)).map((job, i) => ({ i, job }));
const next = this.queue.filter(j =>
{
if (j.job.group)
{
// Only take one task per group to be active
if (!activeGroupsSet.has(j.job.group))
{
activeGroupsSet.add(j.job.group);
return true;
}
} else
{
return true;
}
return false;
}).map((job, i) => ({ i, job }));
next.reverse().forEach(({ i }) => this.queue!.splice(i, 1)); next.reverse().forEach(({ i }) => this.queue!.splice(i, 1));
@ -91,11 +63,6 @@ export class TaskQueue
return this.activeQueue.length > 0; return this.activeQueue.length > 0;
} }
public hasQueued ()
{
return this.queue && this.queue.length > 0;
}
public hasActiveOfType (type: any) public hasActiveOfType (type: any)
{ {
for (const entry of this.activeQueue) for (const entry of this.activeQueue)
@ -114,38 +81,6 @@ export class TaskQueue
return job?.promise.promise ?? Promise.resolve(); return job?.promise.promise ?? Promise.resolve();
} }
public waitForAll ()
{
return new Promise((resolve) =>
{
if (!this.hasActive())
{
resolve(true);
return;
}
const handleEnded = () =>
{
if (!this.hasActive() && !this.hasQueued())
{
resolve(true);
this.events?.removeListener('ended', handleEnded);
this.events?.removeListener('abort', handleEnded);
}
};
this.events?.on('ended', handleEnded);
this.events?.on('abort', handleEnded);
});
}
public cancelJob (id: string)
{
const job = this.queue?.find(j => j.id === id)
?? this.activeQueue?.find(j => j.id === id);
job?.abort('cancel');
}
public findJob<T> ( public findJob<T> (
id: string, id: string,
type: new (...args: any[]) => T type: new (...args: any[]) => T
@ -163,16 +98,6 @@ export class TaskQueue
return undefined as any; return undefined as any;
} }
public getActiveJobs ()
{
return this.activeQueue;
}
public getQueuedJobs ()
{
return this.queue;
}
public on<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void public on<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void
{ {
this.events?.on(event, listener); this.events?.on(event, listener);
@ -244,7 +169,6 @@ export interface CompletedEvent extends BaseEvent
export interface IJob<TData, TState extends string> export interface IJob<TData, TState extends string>
{ {
/** What group does the job belong to. Grouped jobs can only have 1 active job per group */
group?: string; group?: string;
start (context: JobContext<IJob<TData, TState>, TData, TState>): Promise<any>; start (context: JobContext<IJob<TData, TState>, TData, TState>): Promise<any>;
exposeData?(): TData; exposeData?(): TData;
@ -285,14 +209,12 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
private events: EventEmitter<EventsList>; private events: EventEmitter<EventsList>;
private abortController: AbortController; private abortController: AbortController;
private m_promise: PromiseWithResolvers<TData | undefined>; private m_promise: PromiseWithResolvers<TData | undefined>;
private throwOnCancel: boolean;
private readonly m_job: T; private readonly m_job: T;
constructor(id: string, events: EventEmitter<EventsList>, job: T, options?: { throwOnCancel?: boolean; }) constructor(id: string, events: EventEmitter<EventsList>, job: T)
{ {
this.m_id = id; this.m_id = id;
this.m_job = job; this.m_job = job;
this.throwOnCancel = options?.throwOnCancel ?? false;
this.abortController = new AbortController(); this.abortController = new AbortController();
this.abortController.signal.addEventListener('abort', () => this.abortController.signal.addEventListener('abort', () =>
{ {
@ -324,13 +246,7 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
{ {
if (error.target instanceof AbortSignal) if (error.target instanceof AbortSignal)
{ {
if (this.throwOnCancel) this.m_promise.resolve(undefined);
{
this.m_promise.reject(this.abortSignal.reason);
} else
{
this.m_promise.resolve(undefined);
}
} else } else
{ {
console.error(error); console.error(error);

View file

@ -1,20 +1,7 @@
import z from "zod"; import z from "zod";
import { GameflowHooks } from "./hooks/app"; import GameflowHooks from "../api/hooks/app";
import { EmulatorDownloadInfoSchema, EmulatorPackageSchema, FrontendNotification, SettingsType } from "./shared";
import { $ZodRegistry } from "zod/v4/core";
import Conf from "conf"; import Conf from "conf";
import { EventEmitter } from 'node:events'; import { $ZodRegistry } from "zod/v4/core";
import { TaskQueue } from "./task-queue";
export * from "./hooks/app";
export * from "./task-queue";
export interface AppEventMap
{
exitapp: [];
notification: [FrontendNotification];
focus: [];
}
export const PluginContextSchema = z.object({ export const PluginContextSchema = z.object({
hooks: z.instanceof(GameflowHooks) hooks: z.instanceof(GameflowHooks)
@ -23,26 +10,19 @@ export const PluginContextSchema = z.object({
export const PluginLoadingContextSchema = z.object({ export const PluginLoadingContextSchema = z.object({
setProgress: z.function().input([z.number(), z.string()]).output(z.void()), setProgress: z.function().input([z.number(), z.string()]).output(z.void()),
config: z.instanceof(Conf).describe("Per plugin config. It will use the settings schema defined in the plugin class"), config: z.instanceof(Conf).describe("Per plugin config. It will use the settings schema defined in the plugin class"),
zodRegistry: z.instanceof($ZodRegistry).describe("Used by the settings to register metadata for the UI"), zodRegistry: z.instanceof($ZodRegistry).describe("Used by the settings to register metadata for the UI")
app: z.object({
config: z.instanceof(Conf<SettingsType>),
events: z.instanceof(EventEmitter<AppEventMap>),
taskQueue: z.instanceof(TaskQueue)
})
}).extend(PluginContextSchema.shape); }).extend(PluginContextSchema.shape);
export const PluginDescriptionSchema = z.object({ export const PluginDescriptionSchema = z.object({
name: z.string(), name: z.string(),
displayName: z.string().optional(), displayName: z.string(),
version: z.string(), version: z.string(),
description: z.string().optional(), description: z.string(),
icon: z.url().optional().describe("Can be an external URL to an image or a data url"), icon: z.url().optional().describe("Can be an external URL to an image or a data url"),
keywords: z.array(z.string()).optional(), keywords: z.array(z.string()).optional(),
peerDependencies: z.record(z.string(), z.string()).optional(),
category: z.string().default("other"), category: z.string().default("other"),
main: z.string().describe("The main entry. It must export a default class implementing PluginType"), main: z.string().describe("The main entry. It must export a default class implementing PluginType"),
canDisable: z.boolean().default(true).optional().describe("Can the plugin be disabled or enabled by the user"), canDisable: z.boolean().default(true).optional().describe("Can the plugin be disabled or enabled by the user")
autoUpdate: z.boolean().optional().describe("Should the plugin auto update to latest version")
}); });
export const PluginSchema = z.object({ export const PluginSchema = z.object({
@ -62,6 +42,16 @@ export const PluginSchema = z.object({
}).or(z.record(z.string(), z.any()))).optional() }).or(z.record(z.string(), z.any()))).optional()
}); });
export type PluginType<T extends Record<string, any> = Record<string, any>> = Omit<z.infer<typeof PluginSchema>, "load" | 'settingsMigrations'> & {
load: (ctx: PluginLoadingContextType<T>) => Promise<void>;
settingsMigrations?: Record<string, (conf: Conf<T>) => void>;
};
export type PluginContextType = z.infer<typeof PluginContextSchema>;
export type PluginLoadingContextType<TSettings extends Record<string, any> = Record<string, any>> = z.infer<typeof PluginLoadingContextSchema> & {
config: Conf<TSettings>;
};
export type PluginDescriptionType = z.infer<typeof PluginDescriptionSchema>;
export const ActiveGameSchema = z.object({ export const ActiveGameSchema = z.object({
process: z.any().optional(), process: z.any().optional(),
gameId: z.object({ id: z.string(), source: z.string() }), gameId: z.object({ id: z.string(), source: z.string() }),
@ -70,24 +60,4 @@ export const ActiveGameSchema = z.object({
name: z.string(), name: z.string(),
command: z.object({ command: z.string().or(z.string().array()), startDir: z.string().optional() }) command: z.object({ command: z.string().or(z.string().array()), startDir: z.string().optional() })
}); });
export type ActiveGameType = z.infer<typeof ActiveGameSchema>;
export const EmulatorPostInstallContextSchema = z.object({
emulator: z.string(),
emulatorPackage: EmulatorPackageSchema.optional(),
path: z.string(),
update: z.boolean(),
info: EmulatorDownloadInfoSchema,
});
export type ActiveGameType = z.infer<typeof ActiveGameSchema>;
export type PluginDescriptionType = z.infer<typeof PluginDescriptionSchema>;
export type PluginContextType = z.infer<typeof PluginContextSchema>;
export type PluginLoadingContextType<TSettings extends Record<string, any> = Record<string, any>> = z.infer<typeof PluginLoadingContextSchema> & {
config: Conf<TSettings>;
};
export type PluginType<T extends Record<string, any> = Record<string, any>> = Omit<z.infer<typeof PluginSchema>, "load" | 'settingsMigrations'> & {
load: (ctx: PluginLoadingContextType<T>) => Promise<void>;
settingsMigrations?: Record<string, (conf: Conf<T>) => void>;
};
export type EmulatorPostInstallContextType = z.infer<typeof EmulatorPostInstallContextSchema>;

18
src/bun/types/types.ts Normal file
View file

@ -0,0 +1,18 @@
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,11 +1,10 @@
import { $, sleep } from 'bun'; import { $, sleep } from 'bun';
import path from 'node:path'; import path from 'node:path';
import { SettingsType, KeysWithValueAssignableTo } from '@simeonradivoev/gameflow-sdk/shared'; import { SettingsType } from '@/shared/constants';
import { config } from './api/app'; import { config } from './api/app';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import packageDef from '~/package.json'; 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) export function checkRunning (pid: number)
{ {
@ -180,24 +179,4 @@ export async function moveAllFiles (srcDir: string, destDir: string)
export function getAppVersion () export function getAppVersion ()
{ {
return process.env.VERSION_OVERRIDE ?? packageDef.version; 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,7 +5,12 @@ import fs from 'node:fs/promises';
import { createWriteStream } from "node:fs"; import { createWriteStream } from "node:fs";
import { config, jar } from "../api/app"; import { config, jar } from "../api/app";
import { moveAllFiles } from "../utils"; import { moveAllFiles } from "../utils";
import { DownloadFileEntry, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared"; import { DownloadFileEntry } from "@/shared/types";
export interface ProgressStats
{
progress: number;
}
interface TmpDownloadMetadata interface TmpDownloadMetadata
{ {
@ -27,7 +32,6 @@ export class Downloader
id: string; id: string;
tmpPath: string; tmpPath: string;
tmpPathMeta: string; tmpPathMeta: string;
downloadSpeed: number = 0;
/** /**
* *
@ -159,7 +163,10 @@ export class Downloader
}); });
const totalBytes = totalSize || Number(res.headers.get("content-length")) || 0; const totalBytes = totalSize || Number(res.headers.get("content-length")) || 0;
bytesReceived += start; if (totalSize <= 0)
bytesReceived = 0;
else
bytesReceived += start;
const reader = res.body!.getReader(); const reader = res.body!.getReader();
@ -174,11 +181,10 @@ export class Downloader
if (totalBytes > 0 && this.onProgress) if (totalBytes > 0 && this.onProgress)
{ {
const percent = (bytesReceived / totalBytes) * 100; const percent = (bytesReceived / totalBytes) * 100;
const timeDelta = Date.now() - lastUpdate;
if (timeDelta > 100) if (Date.now() - lastUpdate > 100)
{ {
this.downloadSpeed = this.downloadSpeed * 0.8 + Math.round(value.length / (timeDelta / 1000)) * 0.2; this.onProgress({ progress: percent });
this.onProgress({ progress: percent, downloaded: bytesReceived, total: totalBytes, speed: this.downloadSpeed });
lastUpdate = Date.now(); lastUpdate = Date.now();
} }
} }
@ -188,7 +194,7 @@ export class Downloader
if (this.signal.reason === 'cancel') if (this.signal.reason === 'cancel')
{ {
console.log("Canceling Download and cleaning up files"); console.log("Canceling Download and cleaning up files");
await fs.rm(this.tmpPath, { recursive: true, maxRetries: 3, retryDelay: 3 }); await fs.rm(this.tmpPath, { recursive: true });
await fs.rm(this.tmpPathMeta); await fs.rm(this.tmpPathMeta);
return; return;
} }

View file

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

View file

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

View file

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

View file

@ -64,7 +64,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
className={ className={
twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}> twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}>
<FocusContext value={focusKey}> <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 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", <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",
data.className, data.className,
colors[data.type], colors[data.type],
"in-focused:bg-base-content in-focused:text-base-100")}> "in-focused:bg-base-content in-focused:text-base-100")}>
@ -166,7 +166,7 @@ export function ContextDialog (data: {
}] : [], [data.open]); }] : [], [data.open]);
return <dialog ref={ref} open={data.open} closedby="any" className={ return <dialog ref={ref} open={data.open} closedby="any" className={
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", 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",
classNames({ "opacity-0": !data.open }), data.backdropClassName) classNames({ "opacity-0": !data.open }), data.backdropClassName)
} }
onClick={handleClose}> onClick={handleClose}>
@ -174,7 +174,7 @@ export function ContextDialog (data: {
<ContextDialogContext value={{ id: data.id, close: handleClose }} > <ContextDialogContext value={{ id: data.id, close: handleClose }} >
<div <div
className={twMerge( 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 not-mobile:drop-shadow-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",
data.open ? "animate-scale-delayed" : "opacity-0", data.open ? "animate-scale-delayed" : "opacity-0",
data.className) data.className)
} }

View file

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

View file

@ -116,7 +116,7 @@ export function FilterUI (data: {
style={{ viewTransitionName: `filter-${data.id}` }} style={{ viewTransitionName: `filter-${data.id}` }}
> >
<FocusContext.Provider value={focusKey}> <FocusContext.Provider value={focusKey}>
<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)}> <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)}>
{!!data.rootFocusKey && (data.showShortcuts ?? true) && <li className=" flex px-4 items-center justify-center rounded-full"> {!!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" /> <SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_l1_outline" />
</li>} </li>}

View file

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

View file

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

View file

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

View file

@ -1,28 +0,0 @@
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,11 +29,10 @@ import { twMerge } from "tailwind-merge";
import { TwitchIcon } from "../scripts/brandIcons"; import { TwitchIcon } from "../scripts/brandIcons";
import { rommLoggedInQuery } from "../scripts/queries/romm"; import { rommLoggedInQuery } from "../scripts/queries/romm";
import { twitchLoginVerificationQuery } from "../scripts/queries/settings"; import { twitchLoginVerificationQuery } from "../scripts/queries/settings";
import { AppContext, SystemInfoContext } from "../scripts/contexts"; import { SystemInfoContext } from "../scripts/contexts";
import { useNavigate, useRouter } from "@tanstack/react-router"; import { useNavigate, useRouter } from "@tanstack/react-router";
import { oneShot } from "../scripts/audio/audio"; import { oneShot } from "../scripts/audio/audio";
import { hasUpdateQuery } from "../scripts/queries/system"; import { hasUpdateQuery } from "../scripts/queries/system";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
function HeaderAvatar (data: { function HeaderAvatar (data: {
id: string; id: string;
@ -74,7 +73,6 @@ export interface HeaderButton
external?: boolean; external?: boolean;
action?: () => void; action?: () => void;
className?: string; className?: string;
shortcutLabel?: string;
} }
export interface HeaderAccount export interface HeaderAccount
@ -113,22 +111,14 @@ function NotificationStatus ()
function ClockStatus () function ClockStatus ()
{ {
const navigate = useNavigate(); const ref = useRef<HTMLSpanElement>(null);
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(() => useEffect(() =>
{ {
function update () function update ()
{ {
if (refClock.current) if (ref.current)
{ {
refClock.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); ref.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} }
} }
@ -152,16 +142,7 @@ function ClockStatus ()
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, []); }, []);
useShortcuts(focusKey, () => [{ 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>;
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 () function BluetoothStatus ()
@ -307,7 +288,6 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
{data.buttonElements} {data.buttonElements}
{data.buttons?.map(b => <RoundButton {data.buttons?.map(b => <RoundButton
key={b.id} key={b.id}
shortcutLabel={b.shortcutLabel}
className={twMerge("header-icon sm:size-10 md:size-14", b.className)} className={twMerge("header-icon sm:size-10 md:size-14", b.className)}
id={b.id} id={b.id}
external={b.external} external={b.external}
@ -347,19 +327,7 @@ export function HeaderUI (data: HeaderUIParams)
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
<HeaderAccounts key={"header-accounts"} accounts={data.accounts} /> <HeaderAccounts key={"header-accounts"} accounts={data.accounts} />
{data.title} {data.title}
<HeaderStatusBar <HeaderStatusBar key={"header-status-bar"} buttonElements={data.buttonElements} buttons={[...data.buttons ?? [], { icon: <Settings />, id: "header-settings-btn", action: goToSettings, external: true }]} />
key={"header-status-bar"}
buttonElements={data.buttonElements}
buttons={[
...data.buttons ?? [],
{
icon: <Settings />,
id: "header-settings-btn",
action: goToSettings,
external: true,
shortcutLabel: "Settings"
}
]} />
</FocusContext> </FocusContext>
</header > </header >

View file

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

View file

@ -1,7 +1,6 @@
import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { FOCUS_KEYS } from "../scripts/types"; import { FOCUS_KEYS } from "../scripts/types";
import { useIntersectionObserver } from "usehooks-ts"; 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) 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 { RPC_URL } from "@/shared/constants";
import { FrontendNotification } from "@simeonradivoev/gameflow-sdk/shared"; import { FrontendNotification } from "@/shared/types";
import { Clock, CloudUpload, Save } from "lucide-react"; import { Clock, CloudUpload, Save } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import toast, { ToastOptions } from "react-hot-toast"; import toast, { ToastOptions } from "react-hot-toast";

View file

@ -9,11 +9,10 @@ export function RoundButton (data: {
external?: boolean; external?: boolean;
style?: ButtonStyle; style?: ButtonStyle;
cssStyle?: CSSProperties; cssStyle?: CSSProperties;
shortcutLabel?: string;
} & InteractParams & FocusParams) } & InteractParams & FocusParams)
{ {
return ( return (
<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}> <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}>
{data.children} {data.children}
</Button> </Button>

View file

@ -8,7 +8,6 @@ import Carousel from "./Carousel";
import { ContextDialog } from "./ContextDialog"; import { ContextDialog } from "./ContextDialog";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { isUrl } from "@/shared/utils";
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams) function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams)
{ {
@ -22,9 +21,8 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n
scrollIntoNearestParent(ref.current, { behavior: details.instant ? 'instant' : 'smooth' }); scrollIntoNearestParent(ref.current, { behavior: details.instant ? 'instant' : 'smooth' });
} }
}); 4096; }); 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"> 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={url} loading="lazy" decoding="async" /> <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" />
<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 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>; </div>;
} }
@ -61,9 +59,8 @@ function Preview (data: { id: string; screenshots?: string[]; preview: number; s
} }
} }
], [data.preview, focusKey, data.screenshots?.length ?? 0]); ], [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={url} loading="lazy" />; 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" />;
} }
export default function Screenshots (data: { screenshots?: string[]; className?: string; } & FocusParams) 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 { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { useMatchRoute, useNavigate, useRouter } from "@tanstack/react-router"; import { useMatchRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { DoorOpen, Gamepad2, Home, Puzzle, RefreshCcw, Settings, Store } from "lucide-react"; import { DoorOpen, Gamepad2, Puzzle, RefreshCcw, Settings, Store } from "lucide-react";
import { systemApi } from "../scripts/clientApi"; import { systemApi } from "../scripts/clientApi";
import { FOCUS_KEYS } from "../scripts/types"; import { FOCUS_KEYS } from "../scripts/types";
@ -15,7 +15,7 @@ export default function SelectMenu (data: { rootFocusKey: string; })
const options: DialogEntry[] = [ const options: DialogEntry[] = [
{ {
content: "Home", content: "Home",
icon: <Home />, icon: <Gamepad2 />,
action (ctx) action (ctx)
{ {
setOpen(false); setOpen(false);

View file

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

View file

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

View file

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

View file

@ -10,7 +10,7 @@ import prettyMilliseconds from 'pretty-ms';
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { validateSourceQuery } from "@/mainview/scripts/queries/romm"; import { validateSourceQuery } from "@/mainview/scripts/queries/romm";
import { sourceIconMap } from "../Constants"; import { sourceIconMap } from "../Constants";
import { FrontEndGameTypeDetailed } from "@simeonradivoev/gameflow-sdk/shared"; import { FrontEndGameTypeDetailed } from "@/shared/types";
export function DetailElement (data: { icon: JSX.Element; tooltip?: string | null, children?: any | any[]; }) 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 { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { scrollIntoViewHandler } from "@/mainview/scripts/utils"; import { scrollIntoViewHandler } from "@/mainview/scripts/utils";
import { FOCUS_KEYS } from "@/mainview/scripts/types"; import { FOCUS_KEYS } from "@/mainview/scripts/types";
import { FrontEndId, GameLookup } from "@simeonradivoev/gameflow-sdk/shared"; import { FrontEndId, GameLookup } from "@/shared/types";
import { gameLookupQuery } from "@/mainview/scripts/queries/romm"; import { gameLookupQuery } from "@/mainview/scripts/queries/romm";
import { Button } from "../options/Button"; import { Button } from "../options/Button";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";

View file

@ -1,20 +1,22 @@
import { rommApi } from "@/mainview/scripts/clientApi"; import { rommApi } from "@/mainview/scripts/clientApi";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { JSX, useContext, useEffect, useRef, useState } from "react"; import { JSX, useEffect, useRef, useState } from "react";
import { getErrorMessage } from "react-error-boundary"; import { getErrorMessage } from "react-error-boundary";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useLocalStorage } from "usehooks-ts"; import { useLocalStorage } from "usehooks-ts";
import { ContextList, DialogEntry } from "../ContextDialog"; import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
import { Clock, Crosshair, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react"; import { Clock, Crosshair, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react";
import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview/scripts/queries/romm"; import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview/scripts/queries/romm";
import ActionButton from "./ActionButton"; import ActionButton from "./ActionButton";
import { useNavigate, UseNavigateResult, useRouter } from "@tanstack/react-router"; import { useRouter } from "@tanstack/react-router";
import { DownloadSourceType } from "@/shared/constants";
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
import { CommandEntry, FrontEndGameTypeDetailed, DownloadSourceType } from "@simeonradivoev/gameflow-sdk/shared"; import { CommandEntry, FrontEndGameTypeDetailed } from "@/shared/types";
import { GlobalDialogContext } from "@/mainview/scripts/contexts";
export function usePlayMutation (navigate: UseNavigateResult<string>) export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; })
{ {
const installMut = useMutation(installMutation(data.source, data.id));
const router = useRouter();
const playMut = useMutation({ const playMut = useMutation({
...playMutation, onError (error) ...playMutation, onError (error)
{ {
@ -22,36 +24,9 @@ export function usePlayMutation (navigate: UseNavigateResult<string>)
}, },
onSuccess (data, { source, id }, onMutateResult, context) onSuccess (data, { source, id }, onMutateResult, context)
{ {
navigate({ to: '/launcher/$source/$id', params: { source: source, id: id } }); router.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 ws = useRef<{ send: (data: string) => void; }>(undefined);
const [progress, setProgress] = useState<number | undefined>(undefined); const [progress, setProgress] = useState<number | undefined>(undefined);
const [status, setStatus] = useState<string | undefined>(undefined); const [status, setStatus] = useState<string | undefined>(undefined);
@ -68,7 +43,7 @@ export default function MainActions (data: {
if (preferredCommand && c.id !== preferredCommand) return false; if (preferredCommand && c.id !== preferredCommand) return false;
return true; return true;
}); });
const playMut = usePlayMutation(navigate);
useEffect(() => useEffect(() =>
{ {
const sub = rommApi.api.romm.status({ source: data.source })({ id: data.id }).subscribe(); const sub = rommApi.api.romm.status({ source: data.source })({ id: data.id }).subscribe();
@ -125,33 +100,32 @@ export default function MainActions (data: {
} }
const showProgress = progress !== null && !!progressIcon; 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 mainButton: any | undefined = undefined;
let showAllCommandsAction: ((focusKey: string) => void) | undefined; let showAllCommandsAction: ((focusKey: string) => void) | undefined;
let mainAction: () => void; let mainAction: () => void;
if (status === 'installed') if (status === 'installed')
{ {
if (validCommands.length > 1) showAllCommandsAction = (focusKey) => globalDialog.openContext({ if (validCommands.length > 1) showAllCommandsAction = (focusKey) => showAllCommands(true, focusKey);
content: <ContextList options={validCommands.map((c, i) => mainAction = () => handlePlay(validDefaultCommand);
{
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"> mainButton = <div className="flex gap-2">
<ActionButton onAction={mainAction} tooltip={validDefaultCommand?.label ?? details} <ActionButton onAction={mainAction} tooltip={validDefaultCommand?.label ?? details}
key="primary" key="primary"
@ -209,18 +183,7 @@ export default function MainActions (data: {
case 'install': case 'install':
if (installSources && installSources.length > 1) if (installSources && installSources.length > 1)
{ {
globalDialog.openContext({ showInstallSource(true, 'mainAction');
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 } else
{ {
installMut.mutate({}); installMut.mutate({});
@ -260,21 +223,55 @@ export default function MainActions (data: {
return shortcuts; return shortcuts;
}, [showAllCommandsAction, mainAction]); }, [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', {
content: <ContextList options={[{
id: 'cancel',
content: "Cancel",
action (ctx)
{
ws.current?.send('cancel');
ctx.close();
},
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"> return <div className="flex gap-2">
{mainButton} {mainButton}
<div className="divider divider-horizontal m-0"></div> <div className="divider divider-horizontal m-0"></div>
{showProgress && <ActionButton onAction={() => globalDialog.openContext({ {showProgress && <ActionButton onAction={() => showInstallOptions(true, "progress")} key="progress" square tooltip={details} type="base" id="progress" >
content: <ContextList options={[{
id: 'cancel',
content: "Cancel",
action (ctx)
{
ws.current?.send('cancel');
ctx.close();
},
type: 'primary'
}]} />
}, "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 key={`install-${status}`} data-tooltip={details ?? status} className="flex flex-col gap-2 w-16 items-center text-2xl">
<div className="flex flex-row"> <div className="flex flex-row">
{progressIcon} {progressIcon}
@ -282,5 +279,8 @@ export default function MainActions (data: {
<progress className="progress progress-secondary w-full" value={progress} max="100"></progress> <progress className="progress progress-secondary w-full" value={progress} max="100"></progress>
</div> </div>
</ActionButton>} </ActionButton>}
{installSourcesDialog}
{installOptionsDialog}
{allCommandDialog}
</div>; </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'; export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error';
const styles = { const styles = {
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', 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',
accent: "bg-accent text-accent-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:ring-offset-accent", 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", 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", 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,17 +22,6 @@ 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", 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: { export function Button (data: {
id: string, id: string,
children?: any, children?: any,
@ -75,9 +64,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", 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'], styles[data.style ?? 'base'],
focused ? data.focusClassName : undefined, focused ? data.focusClassName : undefined,
data.external ? `focusable focusable-hover ${externalStyles[data.style as keyof typeof externalStyles]}` : '',
classNames({ classNames({
"btn-accent": focused "btn-accent": focused,
"focusable focusable-primary focusable-hover": data.external
}, data.className))} }, data.className))}
type={data.type ?? 'button'} type={data.type ?? 'button'}
> >

View file

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

View file

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

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