From 489124a4a332a7606fb4b8b82f76929c7909a192 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 15 Mar 2026 16:38:01 +0200 Subject: [PATCH 01/65] fix: Browser not getting closed on manual exit --- src/bun/browser.ts | 19 +++++++++++++------ src/bun/utils/browser-spawner.ts | 15 ++++----------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/bun/browser.ts b/src/bun/browser.ts index 14d3f27..1f1acbb 100644 --- a/src/bun/browser.ts +++ b/src/bun/browser.ts @@ -41,9 +41,20 @@ async function runWebview (events: EventEmitter, params: BrowserParams) return new Promise((resolve, reject) => { + + const handleExit = () => + { + resolve(true); + console.log("Terminating Webview Worker"); + webviewWorker.terminate(); + }; + webviewWorker.addEventListener('error', e => { console.error(e.message); + events.removeListener('exitapp', handleExit); + // error doesn't termiate the worker, make sure it's unalived + webviewWorker.terminate(); reject(e.error); }); @@ -56,12 +67,7 @@ async function runWebview (events: EventEmitter, params: BrowserParams) } }); - events.on('exitapp', () => - { - resolve(true); - console.log("Terminating Webview Worker"); - webviewWorker.terminate(); - }); + events.on('exitapp', handleExit); }); } @@ -94,6 +100,7 @@ async function runBrowser (events: EventEmitter, params: BrowserParams) { events.on('exitapp', () => { + console.log("Killing Browser"); killBrowser(browser); resolve(true); }); diff --git a/src/bun/utils/browser-spawner.ts b/src/bun/utils/browser-spawner.ts index 005bf05..0ab7419 100644 --- a/src/bun/utils/browser-spawner.ts +++ b/src/bun/utils/browser-spawner.ts @@ -172,17 +172,10 @@ export async function killBrowser (browser: Subprocess) { if (os.platform() === 'linux') { - // kill chrome by your unique identifier - await $`pkill -KILL -P ${browser.pid}`.quiet().nothrow(); + // we have to force kill the demon spawn for some reason, doesn't respond to SIGTERM + await $`pkill -SIGKILL -P ${browser.pid}`.nothrow(); } else { - browser?.kill(15); + browser?.kill('SIGTERM'); } -} - -// --- Test Run --- -// spawnBrowser({ -// browser: "chrome", -// args: ["--window-size=1024,640", "--force-device-scale-factor=1.25"], -// detached: true -// }); \ No newline at end of file +} \ No newline at end of file From 258ce63bc3cb24c6fb273fd98a1323ae7fde439d Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 15 Mar 2026 16:40:28 +0200 Subject: [PATCH 02/65] fix: Wrong webview library path for appimage building --- scripts/build-appimage.ts | 1 + scripts/package-bun.ts | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/build-appimage.ts b/scripts/build-appimage.ts index 447c32d..70100fe 100644 --- a/scripts/build-appimage.ts +++ b/scripts/build-appimage.ts @@ -23,6 +23,7 @@ const APPDIR = path.resolve(TMP_FOLDER, `${APP_ID}.AppDir`); console.log(`>>> Building AppImage for ${APP_NAME} (${APP_ID})...`); await ensureDir(path.join(APPDIR, `usr`, 'bin')); +await ensureDir(path.join(APPDIR, `usr`, 'lib')); await ensureDir("build"); // Copy app dir diff --git a/scripts/package-bun.ts b/scripts/package-bun.ts index 917c0c5..f22dd96 100644 --- a/scripts/package-bun.ts +++ b/scripts/package-bun.ts @@ -27,8 +27,9 @@ if (process.platform === 'linux' && system.arch === 'arm64') if (process.platform === 'darwin') webviewLib = "libwebview-arm64.dylib"; +let webviewLibPath = '.'; if (process.env.APPIMAGE === "true") - webviewLib = `./usr/lib/${webviewLib}`; + webviewLibPath = `./usr/lib`; await Bun.build({ entrypoints: ["./src/bun/index.ts", `./src/bun/webview/${system.platform}.ts`], @@ -38,7 +39,7 @@ await Bun.build({ root: './src/bun', define: { "process.env.IS_BINARY": "true", - "process.env.WEBVIEW_PATH": `./${webviewLib}`, + "process.env.WEBVIEW_PATH": `${webviewLibPath}/${webviewLib}`, }, minify: process.env.NODE_ENV !== 'development', sourcemap: process.env.NODE_ENV === 'development' ? 'inline' : "linked", From fe80b074d2e5c6c0b9bd9a667f3378455fb5d97a Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 15 Mar 2026 16:41:22 +0200 Subject: [PATCH 03/65] fix: Emulators not launching --- src/bun/api/games/services/launchGameService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index bdd0fd2..0bf8535 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -215,7 +215,7 @@ export async function getValidLaunchCommands (data: { } emulator = emulatorName; - return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec.path)) : undefined]]; + return [[value, exec ? exec.path : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec.path)) : undefined]]; } const key = value[0].substring(1, value.length - 1); From f33c928633a06d1f99e1125a984059b9ade3a369 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 15 Mar 2026 16:42:11 +0200 Subject: [PATCH 04/65] fix: Added control for opening emulator js menu on steam deck controller --- src/mainview/scripts/shortcuts.ts | 32 ++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/mainview/scripts/shortcuts.ts b/src/mainview/scripts/shortcuts.ts index 1de3845..0440a3c 100644 --- a/src/mainview/scripts/shortcuts.ts +++ b/src/mainview/scripts/shortcuts.ts @@ -32,6 +32,7 @@ export interface Shortcut { label?: string; button: GamePadButtonCode; + heldTime?: number; action?: (e: GamepadButtonEvent) => void; } @@ -55,7 +56,7 @@ import.meta.hot?.dispose(() => shortcutMap.clear()); export function useShortcutContext () { - const [array, setArray] = useState(); + const [array, setArray] = useState<({ key: string; } & Shortcut)[] | undefined>(); useEffect(() => { @@ -65,7 +66,7 @@ export function useShortcutContext () const focusKey = getCurrentFocusKey(); const newArray = GetFocusedTree(focusKey) .filter(f => shortcutMap.has(f)) - .flatMap(f => shortcutMap.get(f)!) + .flatMap(f => shortcutMap.get(f)!.map(s => ({ key: f, ...s }))) .filter(s => { const empty = !conflictSet.has(s.button); @@ -79,6 +80,8 @@ export function useShortcutContext () }; const shortcuts = new Map(array?.reverse().map(s => [s.button, s]) ?? []); + const holdChecks = new Map(); + const handleGamepadButtonDown = (e: Event) => { const event = e as GamepadButtonEvent; @@ -90,7 +93,21 @@ export function useShortcutContext () if (shortcuts.has(event.button)) { - shortcuts.get(event.button)?.action?.(event); + const shortcut = shortcuts.get(event.button); + if (shortcut) + { + if (shortcut.heldTime && shortcut.heldTime > 0) + { + holdChecks.set(event.button, setTimeout(() => + { + shortcut.action?.(event); + }, shortcut.heldTime)); + } else + { + shortcut.action?.(event); + } + + } } else if (event.button === GamePadButtonCode.A) { @@ -120,6 +137,14 @@ export function useShortcutContext () { dispatchFocusedEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true })); } + + if (shortcuts.has(event.button)) + { + if (holdChecks.has(event.button)) + { + clearInterval(holdChecks.get(event.button)); + } + } }; function compareShortcut (a: Shortcut, b: Shortcut) @@ -157,6 +182,7 @@ export function useShortcutContext () window.removeEventListener('gamepadbuttonup', handleGamepadButtonUp); window.removeEventListener('shortcutsChanged', handleShortcutRebuild); window.removeEventListener('keydown', handleKeyPress); + holdChecks.forEach(c => clearInterval(c)); }; }, [array]); From df20979afa00bd578922a6a516b28845a4b5cab3 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 15 Mar 2026 16:43:07 +0200 Subject: [PATCH 05/65] fix: Fixed cross platform errors and emulatorjs not opening on linux --- src/bun/api/clients.ts | 2 ++ src/bun/api/games/games.ts | 7 ++++-- src/bun/api/games/platforms.ts | 2 ++ src/bun/server.ts | 18 +++++++-------- src/mainview/routes/embedded.$source.$id.tsx | 23 ++++++++++++++------ vite.config.ts | 5 +++++ 6 files changed, 39 insertions(+), 18 deletions(-) diff --git a/src/bun/api/clients.ts b/src/bun/api/clients.ts index b444eb6..ef4741c 100644 --- a/src/bun/api/clients.ts +++ b/src/bun/api/clients.ts @@ -9,6 +9,8 @@ export default new Elysia({ prefix: "/api/romm" }) .use([games, platforms, auth]) .all("/*", async ({ request, params, set }) => { + set.headers["cross-origin-resource-policy"] = 'cross-origin'; + if (!config.has('rommAddress') && !config.get('rommAddress')) { return new Response("Romm Address Not Found", { status: 404 }); diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index d9fe0ce..ef1d8c9 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -74,21 +74,24 @@ export default new Elysia() params: z.object({ id: z.coerce.number() }), query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) }) - .get('/image/:source/*', async ({ params: { source, "*": path }, query }) => + .get('/image/:source/*', async ({ params: { source, "*": path }, query, set }) => { if (source === 'romm') { + set.headers["cross-origin-resource-policy"] = 'cross-origin'; const rommAdress = config.get('rommAddress'); return processImage(`${rommAdress}/${path}`, query); } return status('Not Found'); }, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional(), noBlur: z.coerce.boolean().optional() }) }) - .get('/image', async ({ query }) => + .get('/image', async ({ query, set }) => { + set.headers["cross-origin-resource-policy"] = 'cross-origin'; return processImage(query.url, query); }, { query: z.object({ url: z.url(), blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) }) .get('/screenshot/:id', async ({ params: { id }, query, set }) => { + set.headers["cross-origin-resource-policy"] = 'cross-origin'; const screenshot = await db.query.screenshots.findFirst({ where: eq(schema.screenshots.id, id), columns: { content: true, type: true } }); if (screenshot) { diff --git a/src/bun/api/games/platforms.ts b/src/bun/api/games/platforms.ts index d8a1e9c..73e0347 100644 --- a/src/bun/api/games/platforms.ts +++ b/src/bun/api/games/platforms.ts @@ -121,6 +121,8 @@ export default new Elysia() return status("Not Implemented"); }, { params: z.object({ source: z.string(), id: z.coerce.number() }) }).get('/platform/local/:id/cover', async ({ params: { id }, set }) => { + set.headers["cross-origin-resource-policy"] = 'cross-origin'; + const coverBlob = await db.query.platforms.findFirst({ columns: { cover: true, cover_type: true diff --git a/src/bun/server.ts b/src/bun/server.ts index 58fd64a..c33dc51 100644 --- a/src/bun/server.ts +++ b/src/bun/server.ts @@ -12,25 +12,25 @@ export function RunBunServer () console.log("Launching Server on port ", SERVER_PORT); return new Elysia() .use(cors()) + .headers({ + 'cross-origin-embedder-policy': 'credentialless', + 'cross-origin-opener-policy': 'same-origin', + 'cross-origin-resource-policy': 'cross-origin' + }) .get("/", ({ set }) => { - set.headers['cross-origin-opener-policy'] = 'same-origin'; - set.headers['cross-origin-embedder-policy'] = 'require-corp'; - return file("./dist/index.html"); + return Bun.file(appPath("./dist/index.html")); }) .get('/emulatorjs', ({ set }) => { - set.headers['cross-origin-opener-policy'] = 'same-origin'; - set.headers['cross-origin-embedder-policy'] = 'require-corp'; - set.headers['cross-origin-resource-policy'] = 'cross-origin'; - return file('./dist/emulatorjs/index.html'); + return Bun.file(appPath('./dist/emulatorjs/index.html')); }) .use(staticPlugin({ indexHTML: false, - assets: "dist", + assets: appPath("./dist"), prefix: "/", alwaysStatic: true - })).listen({ port: SERVER_PORT, hostname: host }, console.log); + })).listen({ port: SERVER_PORT, hostname: host, development: true }, console.log); /*return Bun.serve({ port: SERVER_PORT, hostname: host, diff --git a/src/mainview/routes/embedded.$source.$id.tsx b/src/mainview/routes/embedded.$source.$id.tsx index 8f09d49..b461846 100644 --- a/src/mainview/routes/embedded.$source.$id.tsx +++ b/src/mainview/routes/embedded.$source.$id.tsx @@ -118,9 +118,7 @@ function Frame (data: { ref: RefObject; }) data.ref.current = r; }} allow='fullscreen; cross-origin-isolated' - className='absolute w-full h-full transition-[padding]' src={ - __PUBLIC__ ? `${SERVER_URL(__HOST__)}/emulatorjs/?${params}` : `${EMULATORJS_URL(__HOST__)}/?${params}` - }>; + className='absolute w-full h-full transition-[padding]' src={`${SERVER_URL(__HOST__)}/emulatorjs/?${params}`}>; } function RouteComponent () @@ -147,12 +145,23 @@ function RouteComponent () } }); - useShortcuts(focusKey, () => [{ - button: GamePadButtonCode.Steam, action: () => + useShortcuts(focusKey, () => [ { - setOverlayOpen(!overlayOpen); + button: GamePadButtonCode.Steam, + action: () => + { + setOverlayOpen(!overlayOpen); + } + }, + { + button: GamePadButtonCode.Select, + heldTime: 1000, + action: () => + { + setOverlayOpen(!overlayOpen); + } } - }], [overlayOpen, setOverlayOpen]); + ], [overlayOpen, setOverlayOpen]); const setPaused = (paused: boolean) => { diff --git a/vite.config.ts b/vite.config.ts index 2dd2825..7ab665c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -49,6 +49,7 @@ export default defineConfig(({ command }) => minify: production, sourcemap: production ? false : 'inline', rollupOptions: { + preserveEntrySignatures: 'strict', input: { main: 'src/mainview/index.html', login: 'src/mainview/auth/qr/index.html', @@ -58,6 +59,10 @@ export default defineConfig(({ command }) => manualChunks: (id ) => { + if (id.includes('@emulatorjs')) + { + return 'emulatorjs'; + } if (id .includes ('node_modules')) From 8125c8695cc84358afdfb2657cc6a3638ae68d69 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 15 Mar 2026 16:45:00 +0200 Subject: [PATCH 06/65] fix: minor UI issues --- src/mainview/components/Header.tsx | 2 +- src/mainview/components/RoundButton.tsx | 4 +- src/mainview/components/options/Button.tsx | 3 + src/mainview/gen/static-icon-assets.gen.ts | 2 +- src/mainview/routes/game/$source.$id.tsx | 66 ++++++++++++---------- 5 files changed, 44 insertions(+), 33 deletions(-) diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index 272c968..a9affda 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -290,7 +290,7 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement className="header-icon sm:size-10 md:size-16" id={b.id} external={b.external} - style={{ viewTransitionName: `header-button-${b.id}` }} + cssStyle={{ viewTransitionName: `header-button-${b.id}` }} onAction={b.action} >{b.icon})} diff --git a/src/mainview/components/RoundButton.tsx b/src/mainview/components/RoundButton.tsx index 3f1c315..9a66e44 100644 --- a/src/mainview/components/RoundButton.tsx +++ b/src/mainview/components/RoundButton.tsx @@ -8,11 +8,11 @@ export function RoundButton (data: { className?: string; external?: boolean; style?: ButtonStyle; + cssStyle?: CSSProperties; } & InteractParams & FocusParams) { - return ( - diff --git a/src/mainview/components/options/Button.tsx b/src/mainview/components/options/Button.tsx index 55461d0..8a1fd57 100644 --- a/src/mainview/components/options/Button.tsx +++ b/src/mainview/components/options/Button.tsx @@ -6,6 +6,7 @@ import } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; +import { CSSProperties } from "react"; export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'; @@ -29,6 +30,7 @@ export function Button (data: { style?: ButtonStyle, shortcutLabel?: string; focusClassName?: string; + cssStyle?: CSSProperties; } & InteractParams & FocusParams) { const { ref, focused, focusKey } = useFocusable({ @@ -47,6 +49,7 @@ export function Button (data: { ref={ref} onClick={e => data.onAction?.(e.nativeEvent)} disabled={data.disabled} + style={data.cssStyle} className={twMerge("flex items-center justify-center px-4 py-2 disabled:bg-base-200/40 disabled:text-base-content/40 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:bg-base-content control-mouse:hover:text-base-100 active:transition-none active:ring-offset-4", styles[data.style ?? 'base'], focused ? data.focusClassName : undefined, diff --git a/src/mainview/gen/static-icon-assets.gen.ts b/src/mainview/gen/static-icon-assets.gen.ts index 1d1a4aa..cb3fe1b 100644 --- a/src/mainview/gen/static-icon-assets.gen.ts +++ b/src/mainview/gen/static-icon-assets.gen.ts @@ -464,7 +464,7 @@ const assets = new Set([ ]); // Store basePath resolved from Vite config -const BASE_PATH = "/"; +const BASE_PATH = "./"; /** diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index b3b6407..1621de8 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -64,6 +64,42 @@ function Error (data: ErrorComponentProps) ; } +function MainDetailsPending () +{ + + const { ref } = useFocusable({ focusKey: "main-details" }); + + return
+
+
+
+
+
+
+ } > + } >
+ + } > + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
; +} + function GameDetailsUIPending () { const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" }); @@ -83,35 +119,7 @@ function GameDetailsUIPending ()
-
-
-
-
-
-
-
- } > - } >
- - } > - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
Screenshots
From c86e8cd1976260e76823754d678b4f1caee2b393 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 15 Mar 2026 16:49:22 +0200 Subject: [PATCH 07/65] chore(release): 1.2.1 --- CHANGELOG.md | 12 ++++++++++++ package.json | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 266ed42..a0eeb31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ 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.2.1](https://github.com/simeonradivoev/gameflow-deck/compare/v1.2.0...v1.2.1) (2026-03-15) + + +### Bug Fixes + +* Added control for opening emulator js menu on steam deck controller ([f33c928](https://github.com/simeonradivoev/gameflow-deck/commit/f33c928633a06d1f99e1125a984059b9ade3a369)) +* Browser not getting closed on manual exit ([489124a](https://github.com/simeonradivoev/gameflow-deck/commit/489124a4a332a7606fb4b8b82f76929c7909a192)) +* Emulators not launching ([fe80b07](https://github.com/simeonradivoev/gameflow-deck/commit/fe80b074d2e5c6c0b9bd9a667f3378455fb5d97a)) +* Fixed cross platform errors and emulatorjs not opening on linux ([df20979](https://github.com/simeonradivoev/gameflow-deck/commit/df20979afa00bd578922a6a516b28845a4b5cab3)) +* minor UI issues ([8125c86](https://github.com/simeonradivoev/gameflow-deck/commit/8125c8695cc84358afdfb2657cc6a3638ae68d69)) +* Wrong webview library path for appimage building ([258ce63](https://github.com/simeonradivoev/gameflow-deck/commit/258ce63bc3cb24c6fb273fd98a1323ae7fde439d)) + ## [1.2.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.1.0...v1.2.0) (2026-03-14) diff --git a/package.json b/package.json index d07f8d3..ef6acb1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "com.simeonradivoev.gameflow-deck", "displayName": "Gameflow", - "version": "1.2.0", + "version": "1.2.1", "description": "Game Launcher", "icon": "./src/mainview/assets/icon.svg", "main": "./src/bun/index.ts", From acadfe04adceb4e559449ed81524e02af96274a5 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 15 Mar 2026 17:23:45 +0200 Subject: [PATCH 08/65] doc: Updated readme --- README.md | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e400611..49ffed7 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,34 @@ # Gameflow Deck -A Cross-Platform Retro gaming frontend designed for handheld and controllers. -Focused on building a simple user experience and intuitive UI. +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. > [!WARNING] -> This app is actively in development, it doesn't have most of its critical features implemented yet. +> This app is actively in development, it doesn't have most of its major features implemented yet. > It will have an opinionated design and will be used as an experiment in discovering a good UX. ## Features -- **Cross Platform**: Can run on multiple platforms. Built with web technologies and bun backend. -- **[Romm](https://github.com/rommapp/romm) Support**: Has integration with romm. -- **Lightweight**: It uses the existing system browser to launch the front end, so no need to include a whole web browser. +### Integrations +- **[ROMM](https://github.com/rommapp/romm)** - download, sync and update roms and platforms. +- **[Emulator JS](https://github.com/EmulatorJS/EmulatorJS)** - play your games with emulator js right within the app. Uses RetroArch cores. + +### Store +- **Emulators** - (WIP) Download and install emulators and automatically configure them +- **Free Curated Games** - Download free curreted games and homebrew roms without ever leaving the app + +### Others +- **Cross Platform** - Can run on multiple platforms. Built with web technologies and bun backend. +- **Steam Deck Support** - Extensively tested with the steam deck. It can use flatpak installed browsers. +- **Lightweight** - It uses the existing system browser to launch the front end, so no need to include a whole web browser. - On Windows it first uses webview2 then your browser - On linux it uses WebKitGTK or a browser even from flatpak - Not tested on Mac yet -- **Steam Deck Support**: Extensively tested with the steam deck. It can use flatpak installed browsers. - - Automatic Keyboard prompts -- **Great for Controllers**: The UI is inspired by the switch and works great with joysticks and dpads. -- **Automatic Download** Downloads roms from ROMM automatically -- **Automatic Emulator Discovery** Using the configs of the excellent ES-DE to discover installed emulators and launch games. +- **Great for Controllers** - The UI is inspired by the switch and works great with joysticks and dpads. +- **Automatic Downloads** - Downloads roms from ROMM automatically +- **Automatic Emulator Discovery** - Using the configs of the excellent ES-DE to discover installed emulators and launch games. - Easy fallback configuration with built in file browser. -- **Responsive Layout** Optimized mainly for the steam deck with responsive layout support and dynamic switching of inputs. +- **Responsive Layout** - Optimized mainly for the steam deck with responsive layout support and dynamic switching of inputs. ## Screenshots @@ -33,9 +40,10 @@ Focused on building a simple user experience and intuitive UI. ## Goals -I want to build an open and free platform where you can play and discover new hidden gems from the past. -I plan to add a free store where you can download all your needed emulators, the goal is to not have to leave the UI for anything. -I really want to add matrix chat support in the app for engaging with your favorite community. Having access to so many nodejs libraries would make it quite straight forward. +- I want to build an open and free platform where you can play and discover new hidden gems from the past. +- I plan to add a free store where you can download all your needed emulators, the goal is to not have to leave the UI for anything. +- I really want to add matrix chat support in the app for engaging with your favorite community. Having access to so many nodejs libraries would make it quite straight forward. +- I'm sick of closed source and private store fronts, and want a way to share community currated free experiences. I'm also sick of the profit driven nature of games and promotions. ## Development @@ -77,3 +85,4 @@ I really want to add matrix chat support in the app for engaging with your favor - [Tanstack](https://tanstack.com/) router and query for navigation and data - [elysia](https://elysiajs.com/) for the APIs - [webview](https://github.com/webview/webview) for launching existing system webviews instead of full browser if possible. +- [emulatorjs](https://emulatorjs.org/) for playing lots of roms inside the app without having to deal with external emulators From 364bc9d0bec0c2e4263672d86960a3670dc3603d Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 15 Mar 2026 17:41:56 +0200 Subject: [PATCH 09/65] doc: updated screenshots --- .github/screenshots/3nhuKCK6E3.jpg | 3 +++ .github/screenshots/4MtAe7Wkev.png | 3 +++ .github/screenshots/7s0842oAC9.png | 3 --- .github/screenshots/8jipsHiLST.png | 3 --- .github/screenshots/CpBLzTNM6N.png | 3 +++ .github/screenshots/FHMzJjGOs6.png | 3 --- .github/screenshots/GL7SkQbHIY.png | 3 +++ .github/screenshots/J5BHVZBh7k.png | 3 --- .github/screenshots/Pkazk0RufB.png | 3 +++ .github/screenshots/xNj7scPEDQ.png | 3 +++ .github/screenshots/yObFD2LySH.jpg | 3 +++ .github/screenshots/zl8Dj4xnEw.png | 3 +++ README.md | 16 ++++++++++------ src/mainview/gen/static-icon-assets.gen.ts | 2 +- src/mainview/routes/settings/directories.tsx | 2 +- 15 files changed, 36 insertions(+), 20 deletions(-) create mode 100644 .github/screenshots/3nhuKCK6E3.jpg create mode 100644 .github/screenshots/4MtAe7Wkev.png delete mode 100644 .github/screenshots/7s0842oAC9.png delete mode 100644 .github/screenshots/8jipsHiLST.png create mode 100644 .github/screenshots/CpBLzTNM6N.png delete mode 100644 .github/screenshots/FHMzJjGOs6.png create mode 100644 .github/screenshots/GL7SkQbHIY.png delete mode 100644 .github/screenshots/J5BHVZBh7k.png create mode 100644 .github/screenshots/Pkazk0RufB.png create mode 100644 .github/screenshots/xNj7scPEDQ.png create mode 100644 .github/screenshots/yObFD2LySH.jpg create mode 100644 .github/screenshots/zl8Dj4xnEw.png diff --git a/.github/screenshots/3nhuKCK6E3.jpg b/.github/screenshots/3nhuKCK6E3.jpg new file mode 100644 index 0000000..7f70d9c --- /dev/null +++ b/.github/screenshots/3nhuKCK6E3.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a33280e455034d34c0b2dfa111cff8fe179f97c2a39f7cd0c99b71b1957eda4f +size 1070602 diff --git a/.github/screenshots/4MtAe7Wkev.png b/.github/screenshots/4MtAe7Wkev.png new file mode 100644 index 0000000..d352b3d --- /dev/null +++ b/.github/screenshots/4MtAe7Wkev.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:770f13947f6cc6e349b68db524b6219798688c8143718fe159205eeca0983410 +size 586736 diff --git a/.github/screenshots/7s0842oAC9.png b/.github/screenshots/7s0842oAC9.png deleted file mode 100644 index 4c55f38..0000000 --- a/.github/screenshots/7s0842oAC9.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:df932e6c24b3d70cb7ad7d7ec3ea240341e4be80cee328eee41d35c36e1937c6 -size 1556410 diff --git a/.github/screenshots/8jipsHiLST.png b/.github/screenshots/8jipsHiLST.png deleted file mode 100644 index 7abc387..0000000 --- a/.github/screenshots/8jipsHiLST.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1e2db97ea3b385ac01fb6ec4ac89197dfd4fc6c727639c9493336ddd23be60c7 -size 1003321 diff --git a/.github/screenshots/CpBLzTNM6N.png b/.github/screenshots/CpBLzTNM6N.png new file mode 100644 index 0000000..e661830 --- /dev/null +++ b/.github/screenshots/CpBLzTNM6N.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9864e3e0982f73f59ed32365eec96ff84ec23906bd73fbc23ef8742235c9da4e +size 1734885 diff --git a/.github/screenshots/FHMzJjGOs6.png b/.github/screenshots/FHMzJjGOs6.png deleted file mode 100644 index d9dbbf8..0000000 --- a/.github/screenshots/FHMzJjGOs6.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:96eef8c330ed6739ed2a595fa2170c8f5b5cb7a9cec9b4ad58af51a63eb22b4f -size 1325789 diff --git a/.github/screenshots/GL7SkQbHIY.png b/.github/screenshots/GL7SkQbHIY.png new file mode 100644 index 0000000..2cdbe12 --- /dev/null +++ b/.github/screenshots/GL7SkQbHIY.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf2d692f8ccf3a1c5f9addf26052a34a74c332474fbd8c5bbc7923208407a748 +size 86214 diff --git a/.github/screenshots/J5BHVZBh7k.png b/.github/screenshots/J5BHVZBh7k.png deleted file mode 100644 index dc1bf97..0000000 --- a/.github/screenshots/J5BHVZBh7k.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c92749fe4122c719f09a9397345f1a06df4aedee8f50855601be3ec25e85ad3c -size 50175 diff --git a/.github/screenshots/Pkazk0RufB.png b/.github/screenshots/Pkazk0RufB.png new file mode 100644 index 0000000..ceced8a --- /dev/null +++ b/.github/screenshots/Pkazk0RufB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2dd9859c9495af93872534a913a78597d235f8fb723fe685aa1aeab9283e028b +size 1986843 diff --git a/.github/screenshots/xNj7scPEDQ.png b/.github/screenshots/xNj7scPEDQ.png new file mode 100644 index 0000000..d50d6aa --- /dev/null +++ b/.github/screenshots/xNj7scPEDQ.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a234b8d4624ccfd677c698c1e33eb7c0b757dc13f1403fd8bc6d37ed9e6ff02 +size 1673960 diff --git a/.github/screenshots/yObFD2LySH.jpg b/.github/screenshots/yObFD2LySH.jpg new file mode 100644 index 0000000..00d761f --- /dev/null +++ b/.github/screenshots/yObFD2LySH.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:46e473f90661400fec49d87a972a3324cd4fb18f5b8c670aa5b606462f98fbfe +size 1194459 diff --git a/.github/screenshots/zl8Dj4xnEw.png b/.github/screenshots/zl8Dj4xnEw.png new file mode 100644 index 0000000..8888187 --- /dev/null +++ b/.github/screenshots/zl8Dj4xnEw.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b68cd855b9e63d3219efe889a129843187639f6fc9c8a27638c983c5740ac9a1 +size 501858 diff --git a/README.md b/README.md index 49ffed7..b3578f1 100644 --- a/README.md +++ b/README.md @@ -10,21 +10,24 @@ Focused on building a simple user experience and intuitive UI as a curated commu ## Features ### Integrations + - **[ROMM](https://github.com/rommapp/romm)** - download, sync and update roms and platforms. - **[Emulator JS](https://github.com/EmulatorJS/EmulatorJS)** - play your games with emulator js right within the app. Uses RetroArch cores. ### Store + - **Emulators** - (WIP) Download and install emulators and automatically configure them - **Free Curated Games** - Download free curreted games and homebrew roms without ever leaving the app ### Others + - **Cross Platform** - Can run on multiple platforms. Built with web technologies and bun backend. - **Steam Deck Support** - Extensively tested with the steam deck. It can use flatpak installed browsers. - **Lightweight** - It uses the existing system browser to launch the front end, so no need to include a whole web browser. - On Windows it first uses webview2 then your browser - On linux it uses WebKitGTK or a browser even from flatpak - Not tested on Mac yet -- **Great for Controllers** - The UI is inspired by the switch and works great with joysticks and dpads. +- **Great for Controllers** - The UI is inspired by the switch and works great with joysticks and dpads. - **Automatic Downloads** - Downloads roms from ROMM automatically - **Automatic Emulator Discovery** - Using the configs of the excellent ES-DE to discover installed emulators and launch games. - Easy fallback configuration with built in file browser. @@ -32,11 +35,12 @@ Focused on building a simple user experience and intuitive UI as a curated commu ## Screenshots - - - - - + + + + + + ## Goals diff --git a/src/mainview/gen/static-icon-assets.gen.ts b/src/mainview/gen/static-icon-assets.gen.ts index cb3fe1b..1d1a4aa 100644 --- a/src/mainview/gen/static-icon-assets.gen.ts +++ b/src/mainview/gen/static-icon-assets.gen.ts @@ -464,7 +464,7 @@ const assets = new Set([ ]); // Store basePath resolved from Vite config -const BASE_PATH = "./"; +const BASE_PATH = "/"; /** diff --git a/src/mainview/routes/settings/directories.tsx b/src/mainview/routes/settings/directories.tsx index d0edb9c..986c638 100644 --- a/src/mainview/routes/settings/directories.tsx +++ b/src/mainview/routes/settings/directories.tsx @@ -101,7 +101,7 @@ function RouteComponent () -
+
{drives?.configPath}
From cf6fff6facb3866b097661499bb372cf8ade39b0 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Tue, 17 Mar 2026 12:57:11 +0200 Subject: [PATCH 10/65] refactor: moved queries to their own file --- .../com.simeonradivoev.gameflow-deck.json | 2 +- .vscode/settings.json | 2 + package.json | 2 +- scripts/build-appimage.ts | 2 +- src/bun/api/auth.ts | 2 +- src/bun/api/clients.ts | 2 +- src/bun/api/games/games.ts | 2 +- .../api/games/services/launchGameService.ts | 2 +- src/bun/api/games/services/statusService.ts | 2 +- src/bun/api/games/services/utils.ts | 11 +- src/bun/api/jobs/install-job.ts | 2 +- src/bun/api/jobs/jobs.ts | 4 +- src/bun/api/settings/services.ts | 126 ++++------------ src/bun/api/store/store.ts | 3 +- src/bun/server.ts | 8 +- src/bun/utils/browser-params.ts | 1 - src/bun/utils/browser-spawner.ts | 5 - .../components/AnimatedBackground.tsx | 6 +- src/mainview/components/CardElement.tsx | 4 +- src/mainview/components/CardList.tsx | 6 +- src/mainview/components/Carousel.tsx | 72 ++++++++++ src/mainview/components/CollectionList.tsx | 12 +- src/mainview/components/CollectionsDetail.tsx | 24 +++- src/mainview/components/ContextDialog.tsx | 2 +- src/mainview/components/Error.tsx | 10 +- src/mainview/components/FilePicker.tsx | 17 +-- src/mainview/components/Filters.tsx | 7 +- src/mainview/components/FocusDots.tsx | 75 ++++++++-- src/mainview/components/GameList.tsx | 34 ++--- src/mainview/components/Header.tsx | 15 +- src/mainview/components/LoadMoreButton.tsx | 35 +++++ src/mainview/components/PlatformsList.tsx | 2 + src/mainview/components/RoundButton.tsx | 4 +- src/mainview/components/Screenshots.tsx | 109 ++++++++++++-- src/mainview/components/options/Button.tsx | 2 +- .../options/DownloadDirectoryOption.tsx | 4 +- .../components/options/OptionDropdown.tsx | 11 +- .../components/options/OptionInput.tsx | 2 +- .../components/options/OptionSpace.tsx | 2 +- .../components/options/PathSettingsOption.tsx | 39 ++--- .../components/options/SettingsOption.tsx | 29 +--- .../components/store/EmulatorsSection.tsx | 13 +- .../components/store/GamesSection.tsx | 17 +-- .../components/store/StatsSection.tsx | 12 +- src/mainview/gen/routeTree.gen.ts | 21 +++ src/mainview/gen/static-icon-assets.gen.ts | 2 +- src/mainview/index.html | 9 +- src/mainview/index.tsx | 7 +- src/mainview/manifest.json | 17 +++ src/mainview/public/256x256.png | 3 + src/mainview/{assets => public}/favicon.ico | 0 src/mainview/{assets => public}/icon.svg | 0 src/mainview/query-options.ts | 7 +- src/mainview/routes/collection.$id.tsx | 5 +- src/mainview/routes/embedded.$source.$id.tsx | 13 +- src/mainview/routes/game/$source.$id.tsx | 15 +- src/mainview/routes/games.tsx | 21 +++ src/mainview/routes/index.tsx | 61 ++++---- src/mainview/routes/launcher.$source.$id.tsx | 6 +- src/mainview/routes/platform.$source.$id.tsx | 18 +-- src/mainview/routes/settings/about.tsx | 5 +- src/mainview/routes/settings/accounts.tsx | 96 +++---------- src/mainview/routes/settings/directories.tsx | 14 +- src/mainview/routes/settings/emulators.tsx | 72 ++-------- src/mainview/routes/settings/interface.tsx | 2 +- src/mainview/routes/settings/route.tsx | 2 +- .../routes/store/details.emulator.$id.tsx | 14 +- src/mainview/routes/store/tab/emulators.tsx | 32 +---- src/mainview/routes/store/tab/games.tsx | 98 ++----------- src/mainview/routes/store/tab/index.tsx | 100 +++++-------- src/mainview/scripts/gamepads.ts | 20 ++- src/mainview/scripts/queries.ts | 117 ++------------- src/mainview/scripts/queries/romm.ts | 79 +++++++++++ src/mainview/scripts/queries/settings.ts | 134 ++++++++++++++++++ src/mainview/scripts/queries/store.ts | 58 ++++++++ src/mainview/scripts/queries/system.ts | 51 +++++++ src/mainview/scripts/serviceWorker.ts | 60 ++++++++ src/mainview/scripts/shortcuts.ts | 17 ++- src/mainview/scripts/utils.ts | 7 +- src/shared/constants.ts | 2 + src/tests/game-launching.test.ts | 2 +- tsconfig.json | 1 - vite.config.ts | 28 ++-- 83 files changed, 1107 insertions(+), 852 deletions(-) create mode 100644 src/mainview/components/Carousel.tsx create mode 100644 src/mainview/components/LoadMoreButton.tsx create mode 100644 src/mainview/manifest.json create mode 100644 src/mainview/public/256x256.png rename src/mainview/{assets => public}/favicon.ico (100%) rename src/mainview/{assets => public}/icon.svg (100%) create mode 100644 src/mainview/routes/games.tsx create mode 100644 src/mainview/scripts/queries/romm.ts create mode 100644 src/mainview/scripts/queries/settings.ts create mode 100644 src/mainview/scripts/queries/store.ts create mode 100644 src/mainview/scripts/queries/system.ts create mode 100644 src/mainview/scripts/serviceWorker.ts diff --git a/.config/flatpak/com.simeonradivoev.gameflow-deck.json b/.config/flatpak/com.simeonradivoev.gameflow-deck.json index dbaabd8..ccdc833 100644 --- a/.config/flatpak/com.simeonradivoev.gameflow-deck.json +++ b/.config/flatpak/com.simeonradivoev.gameflow-deck.json @@ -47,7 +47,7 @@ }, { "type": "file", - "path": "../src/mainview/assets/256x256.png" + "path": "../src/mainview/public/256x256.png" }, { "type": "script", diff --git a/.vscode/settings.json b/.vscode/settings.json index 8c7e283..2c6da05 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -30,9 +30,11 @@ "cSpell.words": [ "elysia", "elysiajs", + "emulatorjs", "gameflow", "hackolade", "keytar", + "mainview", "norigin", "noriginmedia", "romm" diff --git a/package.json b/package.json index ef6acb1..21b34f8 100644 --- a/package.json +++ b/package.json @@ -115,4 +115,4 @@ "vite-static-assets-plugin": "^1.2.2", "vite-tsconfig-paths": "^6.1.1" } -} +} \ No newline at end of file diff --git a/scripts/build-appimage.ts b/scripts/build-appimage.ts index 70100fe..496945a 100644 --- a/scripts/build-appimage.ts +++ b/scripts/build-appimage.ts @@ -11,7 +11,7 @@ import { rmdir } from "node:fs"; // ───────────────────────────────────────────── const APP_DIR = process.env.BUILD_DIR ?? `./build/${process.platform}`; const BINARY_NAME = pkg.bin; -const ICON = "./src/mainview/assets/256x256.png"; +const ICON = "./src/mainview/public/256x256.png"; const DESKTOP = "./flatpak/com.simeonradivoev.gameflow-deck.desktop"; const TMP_FOLDER = "."; // ───────────────────────────────────────────── diff --git a/src/bun/api/auth.ts b/src/bun/api/auth.ts index 501275d..c05749d 100644 --- a/src/bun/api/auth.ts +++ b/src/bun/api/auth.ts @@ -1,4 +1,4 @@ -import Elysia, { sse, status } from "elysia"; +import Elysia, { status } from "elysia"; import { config, events, jar, taskQueue } from "./app"; import z from "zod"; import { client } from "@clients/romm/client.gen"; diff --git a/src/bun/api/clients.ts b/src/bun/api/clients.ts index ef4741c..6a7c17f 100644 --- a/src/bun/api/clients.ts +++ b/src/bun/api/clients.ts @@ -7,7 +7,7 @@ import auth from "./auth"; export default new Elysia({ prefix: "/api/romm" }) .use([games, platforms, auth]) - .all("/*", async ({ request, params, set }) => + .all("/*", async ({ request, set }) => { set.headers["cross-origin-resource-policy"] = 'cross-origin'; diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index ef1d8c9..152207b 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -401,7 +401,7 @@ export default new Elysia() const res = await fetch(`https://cdn.emulatorjs.org/latest/data/cores/${params['*']}`); return res; }) - .get('/emulatorjs/data/*', async ({ params }) => + .get('/emulatorjs/data/*', async () => { return status("Not Found"); }); \ No newline at end of file diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index 0bf8535..d554dc3 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -181,7 +181,7 @@ export async function getValidLaunchCommands (data: { '%FILENAME%': $.escape(path.basename(validFiles[0])) }; - cmd = cmd.replace(/\%INJECT\%=(?[\w\%.\/\\]+)/g, (subscring, injectFile: string) => + cmd = cmd.replace(/\%INJECT\%=(?[\w\%.\/\\]+)/g, (_, injectFile: string) => { try { diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index e022fb8..2acde4c 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -230,7 +230,7 @@ export default async function buildStatusResponse (source: string, id: string) dispose.forEach(f => f()); }; }, - cancel (reason) + cancel () { cleanup?.(); cleanup = undefined; diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index 66e4944..c2a1e8b 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -1,7 +1,7 @@ import getFolderSize from "get-folder-size"; import fs from "node:fs/promises"; import path from "node:path"; -import { config, db, emulatorsDb } from "../../app"; +import { config, emulatorsDb } from "../../app"; import { and, eq } from "drizzle-orm"; import * as schema from "@schema/app"; import { FrontEndGameType, FrontEndGameTypeDetailed, StoreGameType } from "@shared/constants"; @@ -103,15 +103,6 @@ export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSel export async function convertStoreToFrontend (system: string, id: string, storeGame: StoreGameType): Promise { - let size: number | null = null; - try - { - const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); - size = Number(fileResponse.headers.get('content-length')); - } catch (error) - { - console.error(error); - } const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.system, system), eq(emulatorSchema.systemMappings.source, 'romm')) }); diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 89b1912..710d0e4 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -165,7 +165,7 @@ export class InstallJob implements IJob let bytesReceived = 0; const progressStream = new Transform({ - transform (chunk, encoding, callback) + transform (chunk, _, callback) { bytesReceived += chunk.length; if (totalBytes > 0) diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index b4680a6..317211b 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -5,7 +5,7 @@ import { LoginJob } from "./login-job"; import TwitchLoginJob from "./twitch-login-job"; import UpdateStoreJob from "./update-store"; -function registerJob (job: T, path: Path, dataSchema: TS) +function registerJob (_job: T, path: Path, dataSchema: TS) { return new Elysia().ws(path, { body: z.discriminatedUnion('type', [ @@ -64,7 +64,7 @@ function registerJob d()); }, - message (ws, message) + message (_, message) { if (message.type === 'cancel') { diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index cce32de..1f7b2d1 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -1,11 +1,12 @@ import * as appSchema from '@schema/app'; -import { findExec, findExecByName } from "../games/services/launchGameService"; +import { findExecByName } from "../games/services/launchGameService"; import * as emulatorSchema from "@schema/emulators"; import { eq, inArray } from 'drizzle-orm'; import { customEmulators, db, emulatorsDb } from '../app'; import fs from 'node:fs/promises'; import { cores } from '../emulatorjs/emulatorjs'; +import { FrontEndEmulator } from '@/shared/constants'; /** * Get emulators based on local games. Only the ones we probably need. @@ -77,117 +78,40 @@ export async function getRelevantEmulators () systems.forEach(s => platformViability.set(s, true)); } - return { - emulator: emulator, - path: execPath, + const em: FrontEndEmulator & { isCritical: boolean; path?: { path: string, type: string; }; } = { + name: emulator, exists: exists, + logo: platform ? `/api/romm/platform/local/${platform}/cover` : '', + systems: systems.map(s => platformLookup.get(s)).filter(s => !!s).map(e => ({ icon: `/api/romm/image/romm/assets/platforms/${e.es_slug}.svg`, name: e.platform_name ?? 'Unknown', id: e.es_slug ?? '' })), + gameCount: 0, + description: '', + homepage: '', + type: 'emulator', + os: [process.platform as any], isCritical: false, - path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null, - systems: systems.map(s => platformLookup.get(s)).filter(s => !!s) + path: execPath, }; + + return em; })); finalEmulators.push({ - emulator: 'emulatorjs', + name: 'emulatorjs', exists: true, path: { path: 'localhost', type: 'js' }, - path_cover: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, - isCritical: false, - systems: [] + logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, + systems: [], + gameCount: 0, + type: 'emulator', + description: '', + homepage: '', + os: [process.platform as any], + isCritical: false }); return finalEmulators.map(e => { - e.isCritical = !e.systems.filter(s => s?.es_slug).some(s => !!platformViability.get(s?.es_slug!)); + e.isCritical = !e.systems.filter(s => s?.id).some(s => !!platformViability.get(s?.id!)); return e; }); -} - -/** - * Only emulators we strictly need based on local games. Emulator JS is included as bundled. - * If there is even single emulator for a system don't include emulators for that system. - */ -/*export async function getMissingEmulators () -{ - const localGames = await db.query.games.findMany({ - columns: { - platform_id: true, - slug: true - }, - with: { - platform: { - columns: { - name: true, - es_slug: true - } - }, - } - }); - - const platformLookup = new Map(localGames.map(g => [g.platform.es_slug, g])); - const platformViability = new Map(localGames.map(g => [g.platform.es_slug, false])); - - // all commands based on the local games - const commands = await emulatorsDb.query.commands.findMany({ - columns: { command: true }, - where: inArray(emulatorSchema.commands.system, Array.from(new Set(localGames.filter(g => g.platform.es_slug).map(s => s.platform.es_slug!)))), - with: { system: { columns: { name: true } } } - }); - - // get all emulators in said commands - const emulators = commands - .flatMap(command => - { - const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/); - if (!matches) - { - return undefined; - } - - return matches?.map(m => ({ emulator: m, system: command.system?.name })); - } - ).filter(c => !!c); - - const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator); - const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) => - { - let execPath: { path: string; type: string, } | undefined; - if (customEmulators.has(emulator)) - { - execPath = { path: customEmulators.get(emulator), type: 'custom' }; - } else - { - execPath = await findExecByName(emulator); - } - - let platform: number | null | undefined = null; - if (system_slug.length <= 1) - { - platform = platformLookup.get(system_slug[0].system)?.platform_id; - } - - // check if automatic or custom path found existing binary. - // This might not be the actual emulator but I don't care. - const exists = !!execPath && await fs.exists(execPath.path); - const systems = Array.from(new Set(system_slug.map(s => s.system))); - if (exists) - { - systems.forEach(s => platformViability.set(s, true)); - } - - return { - emulator: emulator, - path: execPath, - exists: exists, - isCritical: false, - path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null, - systems: systems.map(s => platformLookup.get(s)).filter(s => !!s) - }; - })); - - return finalEmulators.map(e => - { - e.isCritical = !e.systems.filter(s => s?.es_slug).some(s => !!platformViability.get(s?.es_slug!)); - return e; - }); -}*/ \ No newline at end of file +} \ No newline at end of file diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 3720c96..24dfba4 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -194,7 +194,8 @@ export const store = new Elysia({ prefix: '/api/store' }) source: execPath?.type, location: execPath?.path }, - screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`) + screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`), + gameCount: 0 }; return emulator; diff --git a/src/bun/server.ts b/src/bun/server.ts index c33dc51..86e5fac 100644 --- a/src/bun/server.ts +++ b/src/bun/server.ts @@ -1,9 +1,7 @@ import { SERVER_PORT } from "@shared/constants"; -import path from 'node:path'; -import appInfo from '~/package.json'; import { host } from "./utils/host"; import { appPath } from "./utils"; -import Elysia, { file } from "elysia"; +import Elysia from "elysia"; import cors from "@elysiajs/cors"; import staticPlugin from "@elysiajs/static"; @@ -17,11 +15,11 @@ export function RunBunServer () 'cross-origin-opener-policy': 'same-origin', 'cross-origin-resource-policy': 'cross-origin' }) - .get("/", ({ set }) => + .get("/", () => { return Bun.file(appPath("./dist/index.html")); }) - .get('/emulatorjs', ({ set }) => + .get('/emulatorjs', () => { return Bun.file(appPath('./dist/emulatorjs/index.html')); }) diff --git a/src/bun/utils/browser-params.ts b/src/bun/utils/browser-params.ts index 05efdf9..3ae3236 100644 --- a/src/bun/utils/browser-params.ts +++ b/src/bun/utils/browser-params.ts @@ -41,7 +41,6 @@ export async function BuildParams (data: BrowserParams) args.push(`--app=${SERVER_URL(host)}`); args.push(`--app-id=gameflow`); - args.push(`--force-app-mode`); args.push('--no-default-browser-check'); args.push('--new-instance'); args.push('--no-first-run'); diff --git a/src/bun/utils/browser-spawner.ts b/src/bun/utils/browser-spawner.ts index 0ab7419..5481580 100644 --- a/src/bun/utils/browser-spawner.ts +++ b/src/bun/utils/browser-spawner.ts @@ -27,11 +27,6 @@ interface SpawnBrowserOptions ipc?: (message: string) => void; } -interface SpawnLastInfo -{ - PID: number; -} - /** * Spawns a browser process with proper handling for different installation types. * diff --git a/src/mainview/components/AnimatedBackground.tsx b/src/mainview/components/AnimatedBackground.tsx index 6b936fd..8aa67ab 100644 --- a/src/mainview/components/AnimatedBackground.tsx +++ b/src/mainview/components/AnimatedBackground.tsx @@ -1,9 +1,9 @@ -import classNames from 'classnames'; + import { CSSProperties, JSX, Ref, useEffect, useRef, useState } from 'react'; import { twMerge } from 'tailwind-merge'; import { useSessionStorage } from 'usehooks-ts'; -import { mobileCheck, useLocalSetting } from '../scripts/utils'; +import { useLocalSetting } from '../scripts/utils'; import { AnimatedBackgroundContext } from '../scripts/contexts'; export function AnimatedBackground (data: { @@ -88,8 +88,6 @@ export function AnimatedBackground (data: { }, [finalBackgroundUrl]); - const isMobile = mobileCheck(); - function handleSetBackground (url: string) { diff --git a/src/mainview/components/CardElement.tsx b/src/mainview/components/CardElement.tsx index f098c5f..8865d7a 100644 --- a/src/mainview/components/CardElement.tsx +++ b/src/mainview/components/CardElement.tsx @@ -39,11 +39,11 @@ export default function CardElement (data: GameCardParams & InteractParams) { const { ref, focused, focusSelf } = useFocusable({ focusKey: data.focusKey, - onFocus: (l, p, detals) => data.onFocus?.(data.id, ref.current as any, detals), + onFocus: (l, p, details) => data.onFocus?.(data.id, ref.current as any, details), onEnterPress: () => data.onAction?.(), onBlur: () => data.onBlur?.(data.id) }); - const { isMouse, isPointer } = useActiveControl(); + const { isPointer } = useActiveControl(); return (
  • void; onGameFocus?: GameCardFocusHandler; className?: string; + finalElement?: JSX.Element; + saveChildFocus?: 'session' | 'local'; }) { const { ref, focusKey } = useFocusable({ @@ -72,7 +74,7 @@ export function CardList (data: { title="Games" id={`card-list-${data.id}`} ref={ref} - save-child-focus="session" + save-child-focus={data.saveChildFocus} className={twMerge("items-center justify-center-safe h-full", data.grid ? "grid h-fit sm:gap-2 md:gap-5 auto-rows-min grid-cols-[repeat(auto-fill,var(--game-card-width))]" : 'landscape:grid landscape:grid-flow-col landscape:auto-cols-min auto-rows-[1fr] sm:gap-2 md:gap-4 portrait:grid portrait:auto-rows-min portrait:grid-cols-[repeat(auto-fill,var(--game-card-width))] *:portrait:aspect-8/10 *:landscape:aspect-8/12 sm:landscape:max-h-84 md:max-h-128!', @@ -83,10 +85,10 @@ export function CardList (data: { e.preventDefault(); e.stopPropagation(); }} - style={{ scrollbarWidth: "none" }} > {data.games.map(BuildCard)} + {data.finalElement} ); diff --git a/src/mainview/components/Carousel.tsx b/src/mainview/components/Carousel.tsx new file mode 100644 index 0000000..9b55993 --- /dev/null +++ b/src/mainview/components/Carousel.tsx @@ -0,0 +1,72 @@ +import { twMerge } from "tailwind-merge"; +import { RoundButton } from "./RoundButton"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { CSSProperties, Ref, useEffect, useRef, useState } from "react"; +import useActiveControl from "../scripts/gamepads"; + +export default function Carousel (data: { + className?: string; + rootClassName?: string; + controlsClassName?: string; + children?: any; + scrollRef?: Ref; + scrollHandler?: (direction: number, element: HTMLDivElement) => void; + isScrollable?: boolean; + style?: CSSProperties; +}) +{ + const [scrollable, setScrollable] = useState(false); + const localRef = useRef(null); + const handleScroll = (dir: number) => + { + if (!localRef.current) return; + if (data.scrollHandler) + { + data.scrollHandler(dir, localRef.current); + return; + } + localRef.current.scrollBy({ behavior: 'smooth', left: localRef.current.clientWidth / 2 * dir }); + }; + const { isMouse } = useActiveControl(); + + useEffect(() => + { + const el = localRef.current; + if (!el) return; + + setScrollable(el.scrollWidth > el.clientWidth); + const observer = new ResizeObserver(() => + { + setScrollable(el.scrollWidth > el.clientWidth); + }); + + observer.observe(el); + return () => observer.disconnect(); + }, [localRef.current, localRef.current?.clientWidth, localRef.current?.scrollWidth]); + + return
    +
    + { + if (data.scrollRef instanceof Function) + { + data.scrollRef(r); + } else if (data.scrollRef) + { + data.scrollRef.current = r; + } + localRef.current = r; + + }} className={twMerge(data.className)}> + {data.children} +
    + {((scrollable || data.isScrollable) && isMouse) && <> +
    + handleScroll(-1)} id="move-left" className="p-2 border-base-content/40"> +
    +
    + handleScroll(1)} id="move-left" className="p-2 border-base-content/40"> +
    + } + +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index 67a1716..77722cf 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -1,11 +1,11 @@ -import { getCollectionsApiCollectionsGetOptions } from "@/clients/romm/@tanstack/react-query.gen"; -import { DefaultRommStaleTime, RPC_URL } from "@/shared/constants"; +import { RPC_URL } from "@/shared/constants"; import { useSuspenseQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { CardList, GameMetaExtra } from "./CardList"; import { SaveSource } from "../scripts/spatialNavigation"; import { GameCardFocusHandler } from "./CardElement"; import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; +import queries from "../scripts/queries"; export default function CollectionList (data: { id: string, @@ -13,14 +13,11 @@ export default function CollectionList (data: { className?: string; onFocus?: GameCardFocusHandler; onSelect?: (id: string) => void; + saveChildFocus?: 'session' | 'local'; }) { const navigate = useNavigate(); - const { data: collections } = useSuspenseQuery({ - ...getCollectionsApiCollectionsGetOptions(), - refetchOnWindowFocus: false, - staleTime: DefaultRommStaleTime - }); + const { data: collections } = useSuspenseQuery(queries.romm.getCollectionsQuery()); const handleDefaultSelect = (id: string) => { @@ -33,6 +30,7 @@ export default function CollectionList (data: { type="collection" id={data.id} className={data.className} + saveChildFocus={data.saveChildFocus} games={collections.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at)) .map((g) => ({ id: String(g.id), diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index fa0d97a..dc55be1 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -1,25 +1,25 @@ import { AnimatedBackground } from './AnimatedBackground'; -import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { HeaderUI } from './Header'; import { GameList } from './GameList'; import { Search, Settings2 } from 'lucide-react'; -import { JSX, Suspense } from 'react'; +import { JSX, Suspense, useEffect } from 'react'; import Shortcuts from './Shortcuts'; import { AutoFocus } from './AutoFocus'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; -import { Router } from '..'; -import { PopNavigateSource, PopSource } from '../scripts/spatialNavigation'; +import { PopNavigateSource } from '../scripts/spatialNavigation'; import { GameListFilterType } from '@/shared/constants'; import { GameCardFocusHandler } from './CardElement'; export interface CollectionsDetailParams { id?: string; - setBackground: (url: string) => void; + setBackground?: (url: string) => void; filters?: GameListFilterType; headerTitle?: JSX.Element; title?: JSX.Element; footer?: JSX.Element; + focus?: string; } export function CollectionsDetail (data: CollectionsDetailParams) @@ -37,10 +37,21 @@ export function CollectionsDetail (data: CollectionsDetailParams) { if (!(details.nativeEvent instanceof MouseEvent)) { - node.scrollIntoView({ block: 'center', behavior: 'smooth' }); + node.scrollIntoView({ block: 'center', behavior: details.instant ? 'instant' : 'smooth' }); } }; + useEffect(() => + { + if (data.focus) + setFocus(data.focus, { instant: true }); + }, [data.focus]); + + useEffect(() => + { + return () => setFocus(''); + }, []); + return ( @@ -53,7 +64,6 @@ export function CollectionsDetail (data: CollectionsDetailParams) diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index ff429c6..ad4f5f2 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -24,7 +24,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class data.onFocus?.(); }; const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined; - const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({ + const { ref, focusSelf, focusKey } = useFocusable({ focusKey: `${context.id}-list-option-${data.id}`, onEnterPress: data.shortcuts ? undefined : handleAction, onFocus: handleFocus, diff --git a/src/mainview/components/Error.tsx b/src/mainview/components/Error.tsx index fe824db..645d086 100644 --- a/src/mainview/components/Error.tsx +++ b/src/mainview/components/Error.tsx @@ -6,7 +6,6 @@ import Shortcuts from "./Shortcuts"; import { Button } from "./options/Button"; import { useEffect } from "react"; import { ErrorComponentProps } from "@tanstack/react-router"; -import { mobileCheck } from "../scripts/utils"; export default function Error (data: ErrorComponentProps) { @@ -19,12 +18,15 @@ export default function Error (data: ErrorComponentProps) return
    -

    +

    {data.error.message}

    -

    {window.location.href}

    - +

    {window.location.href}

    + + {import.meta.env.DEV &&
    {data.error.stack}
    } + +
    diff --git a/src/mainview/components/FilePicker.tsx b/src/mainview/components/FilePicker.tsx index 2c369a6..4444641 100644 --- a/src/mainview/components/FilePicker.tsx +++ b/src/mainview/components/FilePicker.tsx @@ -2,7 +2,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { ContextList, DialogEntry } from "./ContextDialog"; import { systemApi } from "../scripts/clientApi"; import { useContext, useRef, useState } from "react"; -import path from "pathe"; +import path, { dirname } from "pathe"; import { Check, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { DirType } from "@/shared/constants"; @@ -12,7 +12,7 @@ import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts" import SvgIcon from "./SvgIcon"; import { Button } from "./options/Button"; import toast from "react-hot-toast"; -import { drivesQuery, filesQuery } from "../scripts/queries"; +import queries from "../scripts/queries"; import { FilePickerContext } from "../scripts/contexts"; import useActiveControl from "../scripts/gamepads"; @@ -113,12 +113,7 @@ function NewFolderOption (data: { id: string, dirname: string; }) const { refetchFiles } = useContext(FilePickerContext); const [name, setName] = useState(); const createMutation = useMutation({ - mutationKey: ['create', 'folder', data.id], mutationFn: async () => - { - if (!name) return; - const { error } = await systemApi.api.system.dirs.put({ name, dirname: data.dirname }); - if (error) throw error.value; - }, + ...queries.system.createFolderMutation(data.id), onError: (e) => toast.error(e.message ?? 'Error Creating New Folder'), onSuccess: (d, v, r, cx) => { @@ -128,7 +123,7 @@ function NewFolderOption (data: { id: string, dirname: string; }) }); return
    - +
    ; } @@ -233,8 +228,8 @@ export default function FilePicker (data: { { const [currentPath, setCurrentPath] = useState(data.startingPath); - const { data: files, refetch: refetchFiles, isLoading: filesLoading } = useQuery(filesQuery(currentPath, data.id)); - const { data: drives, isLoading: drivesLoading } = useQuery(drivesQuery); + const { data: files, refetch: refetchFiles, isLoading: filesLoading } = useQuery(queries.system.filesQuery(currentPath, data.id)); + const { data: drives, isLoading: drivesLoading } = useQuery(queries.system.drivesQuery); const fullPath = files ? path.join(files.parentPath, files.name) : ''; const activeDrive = drives?.filter(d => !!d.mountPoint).sort((a, b) => b.mountPoint!.length - a.mountPoint!.length).filter(d => fullPath.startsWith(d.mountPoint!))[0]; diff --git a/src/mainview/components/Filters.tsx b/src/mainview/components/Filters.tsx index 73796e6..3c7a2b6 100644 --- a/src/mainview/components/Filters.tsx +++ b/src/mainview/components/Filters.tsx @@ -11,14 +11,13 @@ function FilterCat ( id: string; children?: any; active: boolean; - onFocus: () => void; hasFocusedPeer: boolean; - } & FilterOption, + } & FilterOption & FocusParams, ) { - const { ref, focusSelf, focused } = useFocusable({ + const { ref, focusSelf } = useFocusable({ focusKey: data.id, - onFocus: data.onFocus, + onFocus: (l, p, details) => data.onFocus?.(data.id, ref.current, details), onEnterPress: data.onAction }); diff --git a/src/mainview/components/FocusDots.tsx b/src/mainview/components/FocusDots.tsx index d42d945..807bdbe 100644 --- a/src/mainview/components/FocusDots.tsx +++ b/src/mainview/components/FocusDots.tsx @@ -2,20 +2,75 @@ import { setFocus } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; import { twMerge } from "tailwind-merge"; import { useGlobalFocus } from "../scripts/spatialNavigation"; +import { JSX, RefObject, useMemo, useState } from "react"; +import { useEventListener } from "usehooks-ts"; + +function ScrollDot (data: { index: number; parent: RefObject, peers: HTMLElement[]; }) +{ + const [focused, setFocused] = useState(false); + + useEventListener('scrollend', () => + { + if (!data.parent.current) return; + const center = data.parent.current.scrollLeft + data.parent.current.clientWidth / 2; + + // find child closest to center + const closest = data.peers.reduce((closest, child) => + { + const childCenter = child.offsetLeft + child.offsetWidth / 2; + const closestCenter = closest.offsetLeft + closest.offsetWidth / 2; + return Math.abs(childCenter - center) < Math.abs(closestCenter - center) + ? child + : closest; + }); + + setFocused(closest === data.peers[data.index]); + + }, data.parent as any); + + return ; +} export default function FocusDots (data: { - elements: string[]; - + elements?: string[] | undefined; + scrollElement?: RefObject; }) { - const focusedKey = useGlobalFocus(); - return
    {data.elements.map((em, i) => + const focusedKey = useGlobalFocus(); + let elements = useMemo(() => { - const focused = em === focusedKey; - return ; - })}
    ; + if (data.elements) + { + return data.elements.map((em, i) => + { + const focused = em === focusedKey; + return ; + }); + } else if (data.scrollElement?.current) + { + const childrenArray = Array.from(data.scrollElement.current.children); + + return childrenArray.map((c, i) => + { + return ; + }); + } else + { + return []; + } + }, [data.elements, data.scrollElement?.current]); + + return
    +
    {elements}
    +
    ; } \ No newline at end of file diff --git a/src/mainview/components/GameList.tsx b/src/mainview/components/GameList.tsx index f1eb533..cbf125f 100644 --- a/src/mainview/components/GameList.tsx +++ b/src/mainview/components/GameList.tsx @@ -1,13 +1,14 @@ import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { GameMetaExtra, CardList } from "./CardList"; -import { FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants"; +import { FrontEndGameType, FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants"; import { useNavigate } from "@tanstack/react-router"; import { SaveSource } from "../scripts/spatialNavigation"; -import { rommApi } from "../scripts/clientApi"; import { HardDrive } from "lucide-react"; -import { JSX } from "react"; +import { JSX, useContext } from "react"; import { GameCardFocusHandler } from "./CardElement"; import { useLocalSetting } from "../scripts/utils"; +import { AnimatedBackgroundContext } from "../scripts/contexts"; +import queries from "../scripts/queries"; export interface GameListParams { @@ -18,19 +19,16 @@ export interface GameListParams onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void; onFocus?: GameCardFocusHandler; className?: string; + finalElement?: JSX.Element; + saveChildFocus?: "session" | "local"; } export function GameList (data: GameListParams) { - const games = useSuspenseQuery({ - queryKey: ['games', data.filters ?? 'all'], - queryFn: () => rommApi.api.romm.games.get({ - query: data.filters - }).then(d => d.data) - }); + const games = useSuspenseQuery(queries.romm.allGamesQuery(data.filters)); const navigator = useNavigate(); - const queryClient = useQueryClient(); const blur = useLocalSetting('backgroundBlur'); + const backgroundContext = useContext(AnimatedBackgroundContext); const handleFocus = (id: FrontEndId, source: string | null, sourceId: string | null) => { @@ -39,11 +37,11 @@ export function GameList (data: GameListParams) { try { - const screenshotUrl = new URL(`${RPC_URL(__HOST__)}${game.paths_screenshots[new Date().getMinutes() % game.paths_screenshots.length]}`); + const screenshotUrl = game.paths_screenshots && game.paths_screenshots.length > 0 ? new URL(`${RPC_URL(__HOST__)}${game.paths_screenshots[new Date().getMinutes() % game.paths_screenshots.length]}`) : undefined; const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_cover}`); - const previewUrl = blur ? coverUrl : screenshotUrl; + const previewUrl = blur ? coverUrl : (screenshotUrl ?? coverUrl); previewUrl.searchParams.delete('ts'); - data.setBackground?.(previewUrl.href); + data.setBackground?.(previewUrl.href) ?? backgroundContext.setBackground(previewUrl.href); } catch { @@ -51,10 +49,10 @@ export function GameList (data: GameListParams) } }; - function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null) + function handleDefaultSelect (g: FrontEndGameType) { - SaveSource('details'); - navigator({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } }); + SaveSource('details', { search: { focus: g.slug ?? `game-${g.id}` } }); + navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source }, viewTransition: { types: ['zoom-in'] } }); }; return ( @@ -65,6 +63,8 @@ export function GameList (data: GameListParams) grid={data.grid} className={data.className} onGameFocus={data.onFocus} + finalElement={data.finalElement} + saveChildFocus={data.saveChildFocus} games={games.data?.games .map( (g) => @@ -92,7 +92,7 @@ export function GameList (data: GameListParams) ), previewUrl: previewUrl.href, badges: badges, - onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g.id, g.source, g.source_id), + onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g), onFocus: () => handleFocus(g.id, g.source, g.source_id) } satisfies GameMetaExtra; }, diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index a9affda..9ec4386 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -25,7 +25,7 @@ import { useQuery } from "@tanstack/react-query"; import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@clients/romm/@tanstack/react-query.gen"; import { RPC_URL } from "../../shared/constants"; import { JSX, useEffect, useRef } from "react"; -import { SaveSource, useFocusableDynamic } from "../scripts/spatialNavigation"; +import { SaveSource } from "../scripts/spatialNavigation"; import { systemApi } from "../scripts/clientApi"; import { Router } from ".."; @@ -228,25 +228,12 @@ function BatteryStatus () export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) { - const rommOnline = useQuery({ - ...statsApiStatsGetOptions(), - refetchInterval: 30000, - retry: false, - }); const user = useQuery({ ...getCurrentUserApiUsersMeGetOptions(), refetchOnWindowFocus: false, retry: 1 }); - let indicator = "status-neutral"; - if (user.isError) - { - indicator = "status-error"; - } else if (!user.isPending && rommOnline.isSuccess) - { - indicator = "status-success"; - } const accounts: HeaderAccount[] = [{ id: 'romm', previewUrl: [ `${RPC_URL(__HOST__)}/api/romm/assets/logos/romm_logo_xbox_one_square.svg`, diff --git a/src/mainview/components/LoadMoreButton.tsx b/src/mainview/components/LoadMoreButton.tsx new file mode 100644 index 0000000..8747bfb --- /dev/null +++ b/src/mainview/components/LoadMoreButton.tsx @@ -0,0 +1,35 @@ +import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { FOCUS_KEYS } from "../scripts/types"; +import { useIntersectionObserver } from "usehooks-ts"; + +export default function LoadMoreButton (data: { isFetching: boolean; lastId?: string; } & FocusParams & InteractParams) +{ + const handleAction = (e?: Event) => + { + data.onAction?.(e); + if (data.lastId && focused) + setFocus(FOCUS_KEYS.GAME_CARD(data.lastId)); + }; + + const { ref, focusKey, focused } = useFocusable({ + focusKey: 'load-more-btn', + onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details), + onEnterPress: handleAction + }); + + const { ref: intersct } = useIntersectionObserver({ + onChange: (isIntersecting, entry) => + { + if (isIntersecting) + { + handleAction(); + } + } + }); + + return
    + { + ref.current = r; + intersct(r); + }} className='flex bg-base-100 game-card focusable focusable-accent focusable-hover text-2xl justify-center items-center cursor-pointer' onClick={e => handleAction(e.nativeEvent)} id='load-more-btn'>{data.isFetching ? : "Load More"}
    ; +} \ No newline at end of file diff --git a/src/mainview/components/PlatformsList.tsx b/src/mainview/components/PlatformsList.tsx index a4503fb..78d3229 100644 --- a/src/mainview/components/PlatformsList.tsx +++ b/src/mainview/components/PlatformsList.tsx @@ -17,6 +17,7 @@ export function PlatformsList (data: { onFocus?: GameCardFocusHandler; grid?: boolean; onSelect?: (source: string, id: string) => void; + saveChildFocus?: "session" | "local"; }) { const isMobile = mobileCheck(); @@ -85,6 +86,7 @@ export function PlatformsList (data: { return ( + diff --git a/src/mainview/components/Screenshots.tsx b/src/mainview/components/Screenshots.tsx index 3a760e8..f29e142 100644 --- a/src/mainview/components/Screenshots.tsx +++ b/src/mainview/components/Screenshots.tsx @@ -1,16 +1,19 @@ import { RPC_URL } from "@/shared/constants"; import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import FocusDots from "./FocusDots"; import { scrollIntoNearestParent, useDragScroll } from "../scripts/utils"; import { Fullscreen } from "lucide-react"; +import Carousel from "./Carousel"; +import { ContextDialog } from "./ContextDialog"; +import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts"; -function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; }) +function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams) { const imageRef = useRef(null); - const { ref, focused, focusSelf } = useFocusable({ + const { ref, focusSelf } = useFocusable({ focusKey: `screenshot-${data.index}`, - onEnterPress: () => (ref.current as HTMLElement).requestFullscreen(), + onEnterPress: () => data.onAction?.(), onFocus: (e, p, details) => { data.setFocused?.(data.index); @@ -19,31 +22,109 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n }); 4096; return
    focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" /> -
    imageRef.current?.requestFullscreen()}>
    +
    data.onAction?.(e.nativeEvent)}>
    ; } export default function Screenshots (data: { screenshots: string[]; } & FocusParams) { - const scrollRef = useRef(null); - const { ref, focusKey } = useFocusable({ + const [preview, setPreview] = useState(undefined); + const scrollRef = useRef(null); + const { ref, focusKey, focused, hasFocusedChild } = useFocusable({ focusKey: 'screenshot-list', + trackChildren: true, onFocus: (e, p, details) => { data.onFocus?.(focusKey, ref.current, details); } }); + + useEffect(() => + { + if ((focused || hasFocusedChild) && scrollRef.current) + { + const closest = findClosestElementToCenter(scrollRef.current); + const closestIndex = Array.from(scrollRef.current.children).indexOf(closest); + setFocus(`screenshot-${closestIndex}`); + } + }, [focused, hasFocusedChild, scrollRef.current]); + + const findClosestElementToCenter = (element: HTMLDivElement) => + { + const center = element.scrollLeft + element.clientWidth / 2; + + const children = Array.from(element.children) as HTMLElement[]; + + // find child closest to center + return children.reduce((closest, child) => + { + const childCenter = child.offsetLeft + child.offsetWidth / 2; + const closestCenter = closest.offsetLeft + closest.offsetWidth / 2; + return Math.abs(childCenter - center) < Math.abs(closestCenter - center) + ? child + : closest; + }); + }; + + useEffect(() => + { + if (preview !== undefined && scrollRef.current) + { + Array.from(scrollRef.current.children)[preview].scrollIntoView({ inline: 'center', behavior: 'instant' }); + } + + }, [preview]); + + const handleScroll = (dir: number, element: HTMLDivElement) => + { + const current = findClosestElementToCenter(element); + + const next = (dir > 0 ? current.nextElementSibling : current.previousElementSibling) as HTMLElement | null; + if (!next) return; + + // scroll so next element is centered + element.scrollTo({ + left: next.offsetLeft - element.clientWidth / 2 + next.offsetWidth / 2, + behavior: "smooth" + }); + }; + + useShortcuts(`screenshots-context-dialog`, () => [ + { + button: GamePadButtonCode.Left, + label: "Left", + action: () => + { + if (preview === undefined) return; + setPreview((data.screenshots.length + preview - 1) % data.screenshots.length); + } + }, + { + button: GamePadButtonCode.Right, + label: "Right", + action: () => + { + if (preview === undefined) return; + setPreview((preview + 1) % data.screenshots.length); + } + } + ], [preview, focusKey]); + useDragScroll(scrollRef); return
    -
    - {data.screenshots.map((s, i) => )} -
    - `screenshot-${i}`)} /> + + {data.screenshots.map((s, i) => setPreview(i)} />)} + +
    + {preview !== undefined && + { + setFocus(`screenshot-${preview}`); + setPreview(undefined); + }} open={true}> + + }
    ; } \ No newline at end of file diff --git a/src/mainview/components/options/Button.tsx b/src/mainview/components/options/Button.tsx index 8a1fd57..ce3c44c 100644 --- a/src/mainview/components/options/Button.tsx +++ b/src/mainview/components/options/Button.tsx @@ -5,7 +5,7 @@ import useFocusable, } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; -import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; +import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import { CSSProperties } from "react"; export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'; diff --git a/src/mainview/components/options/DownloadDirectoryOption.tsx b/src/mainview/components/options/DownloadDirectoryOption.tsx index 760ddd0..44c24d7 100644 --- a/src/mainview/components/options/DownloadDirectoryOption.tsx +++ b/src/mainview/components/options/DownloadDirectoryOption.tsx @@ -1,14 +1,14 @@ import { useState } from "react"; import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption"; import { useMutation } from "@tanstack/react-query"; -import { changeDownloadsMutation } from "@/mainview/scripts/queries"; +import queries from "@/mainview/scripts/queries"; export default function DownloadDirectoryOption (data: PathSettingsOptionParams) { const [localValue, setLocalValue] = useState(); const [dirty, setDirty] = useState(false); const setSettingMutation = useMutation({ - ...changeDownloadsMutation, + ...queries.settings.changeDownloadsMutation, onSuccess: (d, v, r, cx) => { setDirty(r !== localValue); diff --git a/src/mainview/components/options/OptionDropdown.tsx b/src/mainview/components/options/OptionDropdown.tsx index 093c664..3045e74 100644 --- a/src/mainview/components/options/OptionDropdown.tsx +++ b/src/mainview/components/options/OptionDropdown.tsx @@ -1,6 +1,5 @@ -import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef, useState } from "react"; +import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useState } from "react"; import { twMerge } from "tailwind-merge"; -import { useOptionContext } from "./OptionSpace"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { ContextDialog, ContextList, DialogEntry } from "../ContextDialog"; import { ChevronDown } from "lucide-react"; @@ -25,15 +24,9 @@ export function OptionDropdown (data: { setOpen(true); }; const handleClose = () => setOpen(false); - const { ref, focused, focusKey } = useFocusable({ + const { ref } = useFocusable({ focusKey: data.name, onEnterPress: handlePress }); - const inputRef = useRef(null); - const option = useOptionContext({ - onOptionEnterPress: handlePress, - }); - - const valueIndex = data.value ? data.values?.indexOf(data.value) : -1; return ( <> diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index d3de509..bd903c6 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -28,7 +28,7 @@ export function OptionInput (data: { inputRef.current?.focus(); } }; - const { ref, focused } = useFocusable({ + const { ref } = useFocusable({ focusKey: data.name, onEnterPress: handlePress }); const inputRef = useRef(null); diff --git a/src/mainview/components/options/OptionSpace.tsx b/src/mainview/components/options/OptionSpace.tsx index ef21162..41f8620 100644 --- a/src/mainview/components/options/OptionSpace.tsx +++ b/src/mainview/components/options/OptionSpace.tsx @@ -41,7 +41,7 @@ export function OptionSpace (data: { }) { const eventTarget = useMemo(() => new EventTarget(), []); - const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({ + const { ref, focused, focusSelf, focusKey } = useFocusable({ focusKey: data.id, focusable: data.focusable !== false, trackChildren: true, diff --git a/src/mainview/components/options/PathSettingsOption.tsx b/src/mainview/components/options/PathSettingsOption.tsx index 054202a..a0c18c3 100644 --- a/src/mainview/components/options/PathSettingsOption.tsx +++ b/src/mainview/components/options/PathSettingsOption.tsx @@ -1,14 +1,14 @@ -import { HTMLInputTypeAttribute, JSX, useCallback, useState } from "react"; +import { HTMLInputTypeAttribute, JSX, useEffect, useState } from "react"; import { SettingsType } from "../../../shared/constants"; import { useMutation, useQuery } from "@tanstack/react-query"; import { OptionSpace } from "./OptionSpace"; import { OptionInput } from "./OptionInput"; -import { settingsApi } from "../../scripts/clientApi"; import { Button } from "./Button"; import { FileSearchCorner, FolderSearch, Pen, Save } from "lucide-react"; import { ContextDialog } from "../ContextDialog"; import FilePicker from "../FilePicker"; import { setFocus } from "@noriginmedia/norigin-spatial-navigation"; +import queries from "@/mainview/scripts/queries"; type KeysWithValueAssignableTo = { [K in keyof T]: Exclude extends Value ? K : never; @@ -32,14 +32,8 @@ export function PathSettingsOption (data: PathSettingsOptionParams) { const [localValue, setLocalValue] = useState(); const [dirty, setDirty] = useState(false); - const setSettingMutation = useMutation({ - mutationKey: ["setting", data.id], - mutationFn: async (value: any) => - { - const response = await settingsApi.api.settings({ id: data.id! }).post({ value }); - if (response.error) throw response.error; - return response.data; - }, + const setMutation = useMutation({ + ...queries.settings.setSettingMutation(data.id), onSuccess: (d, v, r, cx) => { setDirty(r !== localValue); @@ -51,7 +45,7 @@ export function PathSettingsOption (data: PathSettingsOptionParams) label={data.label} id={data.id} type={data.type} - save={setSettingMutation.mutate} + save={setMutation.mutate} localValue={localValue} allowNewFolderCreation={data.allowNewFolderCreation} setLocalValue={(v) => @@ -69,22 +63,17 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & { }) { const [isBrowsing, setIsBrowsing] = useState(false); - const { data: defaultValue } = useQuery({ - enabled: !!data.id, - queryKey: ["setting", data.id], - queryFn: async () => - { - const { data: value, error } = await settingsApi.api.settings({ id: data.id! }).get(); - if (error) throw error; - if (!data.isDirty) - { - data.setLocalValue(String(value.value)); - } - return value.value; - }, - }); + const { data: defaultValue } = useQuery(queries.settings.getSettingQuery(data.id)); const changed = defaultValue !== data.localValue; + useEffect(() => + { + if (!data.isDirty) + { + data.setLocalValue(String(defaultValue)); + } + }, [data.isDirty, defaultValue]); + const handleSelectPath = (path: string) => { data.setLocalValue(path); diff --git a/src/mainview/components/options/SettingsOption.tsx b/src/mainview/components/options/SettingsOption.tsx index 5022514..7f42948 100644 --- a/src/mainview/components/options/SettingsOption.tsx +++ b/src/mainview/components/options/SettingsOption.tsx @@ -3,7 +3,7 @@ import { SettingsType } from "../../../shared/constants"; import { useMutation, useQuery } from "@tanstack/react-query"; import { OptionSpace } from "./OptionSpace"; import { OptionInput } from "./OptionInput"; -import { settingsApi } from "../../scripts/clientApi"; +import queries from "@/mainview/scripts/queries"; type KeysWithValueAssignableTo = { [K in keyof T]: Exclude extends Value ? K : never; @@ -20,36 +20,15 @@ export function SettingsOption (data: { { const [dirty, setDirty] = useState(false); const [localValue, setLocalValue] = useState(); - useQuery({ - enabled: !!data.id, - queryKey: ["setting", data.id], - queryFn: async () => - { - const { data: value, error } = await settingsApi.api.settings({ id: data.id! }).get(); - if (error) throw error; - if (!dirty) - { - setLocalValue(String(value.value)); - } - return value.value; - }, - }); - const setSettingMutation = useMutation({ - mutationKey: ["setting", data.id], - mutationFn: async (value: any) => - { - const response = await settingsApi.api.settings({ id: data.id! }).post({ value }); - if (response.error) throw response.error; - return response.data; - } - }); + useQuery(queries.settings.getSettingQuery(data.id)); + const setMutation = useMutation(queries.settings.setSettingMutation(data.id)); const handleSave = useCallback(() => { if (dirty) { setDirty(false); - setSettingMutation.mutate(localValue); + setMutation.mutate(localValue); } }, [dirty, setDirty, localValue]); diff --git a/src/mainview/components/store/EmulatorsSection.tsx b/src/mainview/components/store/EmulatorsSection.tsx index 2f1d463..8284137 100644 --- a/src/mainview/components/store/EmulatorsSection.tsx +++ b/src/mainview/components/store/EmulatorsSection.tsx @@ -12,6 +12,7 @@ import { Router } from "@/mainview"; import { StoreEmulatorCard } from "./StoreEmulatorCard"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import { FrontEndEmulator } from "@/shared/constants"; +import Carousel from "../Carousel"; function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant: boolean; }) => void; }) { @@ -34,7 +35,7 @@ function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (detail export function EmulatorsSection (data: { id: string; - emulators: FrontEndEmulator[]; + emulators?: FrontEndEmulator[]; onSelect?: (id: string, focusKey: string) => void; header?: any; } & FocusParams) @@ -60,17 +61,19 @@ export function EmulatorsSection (data: { }
    -
    + + {data.emulators?.map((em) => ( data.onSelect?.(em.name, focusKey)} onFocus={({ node, details }) => { scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' }); }} /> - ))} + )) ?? Array.from({ length: 8 }).map((_, i) =>
    )} Router.navigate({ to: '/store/tab/emulators' })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} /> -
    +
    + - {!!data.emulators && FOCUS_KEYS.EMULATOR_CARD(e.name))} />} + FOCUS_KEYS.EMULATOR_CARD(e.name))} /> ); } \ No newline at end of file diff --git a/src/mainview/components/store/GamesSection.tsx b/src/mainview/components/store/GamesSection.tsx index 4322cb3..2d7a170 100644 --- a/src/mainview/components/store/GamesSection.tsx +++ b/src/mainview/components/store/GamesSection.tsx @@ -4,15 +4,16 @@ import useFocusable, FocusContext, } from "@noriginmedia/norigin-spatial-navigation"; -import { Gamepad2 } from "lucide-react"; +import { Gamepad2, Star } from "lucide-react"; import { useDragScroll } from "@/mainview/scripts/utils"; import FocusDots from "../FocusDots"; import { FrontEndGameType, FrontEndId } from "@/shared/constants"; import FrontEndGameCard from "../FrontEndGameCard"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; +import Carousel from "../Carousel"; export function GamesSection ({ games, onSelect, onFocus }: { - games: FrontEndGameType[]; + games?: FrontEndGameType[]; onSelect?: (id: FrontEndId, focusKey: string) => void; } & FocusParams) { @@ -33,17 +34,17 @@ export function GamesSection ({ games, onSelect, onFocus }: {

    Featured Games

    -
    Curated picks
    +
    Creator Picks
    -
    - {games.map((g, i) => + {games?.map((g, i) => onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id.id))} - index={i} />)} -
    + index={i} />) ?? Array.from({ length: 8 }).map((_, i) =>
    )} + - FOCUS_KEYS.GAME_CARD(e.id.id))} /> + FOCUS_KEYS.GAME_CARD(e.id.id)) ?? []} /> ); } \ No newline at end of file diff --git a/src/mainview/components/store/StatsSection.tsx b/src/mainview/components/store/StatsSection.tsx index 4c40491..f3925a7 100644 --- a/src/mainview/components/store/StatsSection.tsx +++ b/src/mainview/components/store/StatsSection.tsx @@ -1,4 +1,5 @@ -import { storeApi } from "@/mainview/scripts/clientApi"; + +import queries from "@/mainview/scripts/queries"; import { useQuery } from "@tanstack/react-query"; import { Joystick, LibraryBig, Save, TriangleAlert } from "lucide-react"; @@ -14,14 +15,7 @@ export function StatsSection ({ }: StatsSectionProps) { - const { data: stats } = useQuery({ - queryKey: ['store', 'stats'], queryFn: async () => - { - const { data, error } = await storeApi.api.store.stats.get(); - if (error) throw error; - return data; - } - }); + const { data: stats } = useQuery(queries.store.storeGetStatsQuery); return (
    diff --git a/src/mainview/gen/routeTree.gen.ts b/src/mainview/gen/routeTree.gen.ts index edf83bc..38b4102 100644 --- a/src/mainview/gen/routeTree.gen.ts +++ b/src/mainview/gen/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './../routes/__root' +import { Route as GamesRouteImport } from './../routes/games' import { Route as SettingsRouteRouteImport } from './../routes/settings/route' import { Route as IndexRouteImport } from './../routes/index' import { Route as SettingsInterfaceRouteImport } from './../routes/settings/interface' @@ -27,6 +28,11 @@ import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id' import { Route as EmbeddedSourceIdRouteImport } from './../routes/embedded.$source.$id' import { Route as StoreDetailsEmulatorIdRouteImport } from './../routes/store/details.emulator.$id' +const GamesRoute = GamesRouteImport.update({ + id: '/games', + path: '/games', + getParentRoute: () => rootRouteImport, +} as any) const SettingsRouteRoute = SettingsRouteRouteImport.update({ id: '/settings', path: '/settings', @@ -116,6 +122,7 @@ const StoreDetailsEmulatorIdRoute = StoreDetailsEmulatorIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/settings': typeof SettingsRouteRouteWithChildren + '/games': typeof GamesRoute '/store/tab': typeof StoreTabRouteRouteWithChildren '/collection/$id': typeof CollectionIdRoute '/settings/about': typeof SettingsAboutRoute @@ -135,6 +142,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/settings': typeof SettingsRouteRouteWithChildren + '/games': typeof GamesRoute '/collection/$id': typeof CollectionIdRoute '/settings/about': typeof SettingsAboutRoute '/settings/accounts': typeof SettingsAccountsRoute @@ -154,6 +162,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/settings': typeof SettingsRouteRouteWithChildren + '/games': typeof GamesRoute '/store/tab': typeof StoreTabRouteRouteWithChildren '/collection/$id': typeof CollectionIdRoute '/settings/about': typeof SettingsAboutRoute @@ -175,6 +184,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/settings' + | '/games' | '/store/tab' | '/collection/$id' | '/settings/about' @@ -194,6 +204,7 @@ export interface FileRouteTypes { to: | '/' | '/settings' + | '/games' | '/collection/$id' | '/settings/about' | '/settings/accounts' @@ -212,6 +223,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/settings' + | '/games' | '/store/tab' | '/collection/$id' | '/settings/about' @@ -232,6 +244,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute SettingsRouteRoute: typeof SettingsRouteRouteWithChildren + GamesRoute: typeof GamesRoute StoreTabRouteRoute: typeof StoreTabRouteRouteWithChildren CollectionIdRoute: typeof CollectionIdRoute EmbeddedSourceIdRoute: typeof EmbeddedSourceIdRoute @@ -243,6 +256,13 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/games': { + id: '/games' + path: '/games' + fullPath: '/games' + preLoaderRoute: typeof GamesRouteImport + parentRoute: typeof rootRouteImport + } '/settings': { id: '/settings' path: '/settings' @@ -404,6 +424,7 @@ const StoreTabRouteRouteWithChildren = StoreTabRouteRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, SettingsRouteRoute: SettingsRouteRouteWithChildren, + GamesRoute: GamesRoute, StoreTabRouteRoute: StoreTabRouteRouteWithChildren, CollectionIdRoute: CollectionIdRoute, EmbeddedSourceIdRoute: EmbeddedSourceIdRoute, diff --git a/src/mainview/gen/static-icon-assets.gen.ts b/src/mainview/gen/static-icon-assets.gen.ts index 1d1a4aa..cb3fe1b 100644 --- a/src/mainview/gen/static-icon-assets.gen.ts +++ b/src/mainview/gen/static-icon-assets.gen.ts @@ -464,7 +464,7 @@ const assets = new Set([ ]); // Store basePath resolved from Vite config -const BASE_PATH = "/"; +const BASE_PATH = "./"; /** diff --git a/src/mainview/index.html b/src/mainview/index.html index 6332b4d..d468edc 100644 --- a/src/mainview/index.html +++ b/src/mainview/index.html @@ -3,7 +3,14 @@ - + + + + + + + + { - const data = await ctx.context.queryClient.fetchQuery(gameQuery(ctx.params.source, ctx.params.id)); + const data = await ctx.context.queryClient.fetchQuery(queries.romm.gameQuery(ctx.params.source, ctx.params.id)); return { data }; }, validateSearch: zodValidator(z.record(z.string(), z.string().optional().nullable())) diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 1621de8..675388f 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -1,6 +1,6 @@ import { createFileRoute, ErrorComponentProps } from "@tanstack/react-router"; import { CommandEntry, FrontEndGameTypeDetailed, GameInstallProgress, GameStatusType, RPC_URL } from "@shared/constants"; -import { twJoin, twMerge } from "tailwind-merge"; +import { twMerge } from "tailwind-merge"; import { JSX, RefObject, useEffect, useRef, useState } from "react"; import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; @@ -11,20 +11,20 @@ import { PopSource, SaveSource, useFocusEventListener } from "../../scripts/spat import { AnimatedBackground } from "../../components/AnimatedBackground"; import { rommApi } from "../../scripts/clientApi"; import toast from "react-hot-toast"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Router } from "../.."; import { ContextDialog, ContextList, DialogEntry } from "../../components/ContextDialog"; import Shortcuts from "../../components/Shortcuts"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; -import { gameQuery } from "@/mainview/scripts/queries"; +import queries from "@/mainview/scripts/queries"; import Screenshots from "@/mainview/components/Screenshots"; -import { delay, useSticky, useStickyDataAttr } from "@/mainview/scripts/utils"; +import { useStickyDataAttr } from "@/mainview/scripts/utils"; import useActiveControl from "@/mainview/scripts/gamepads"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => { - const data = await context.queryClient.fetchQuery(gameQuery(params.source, params.id)); + const data = await context.queryClient.fetchQuery(queries.romm.gameQuery(params.source, params.id)); return { data }; }, component: GameDetailsUI, @@ -402,8 +402,7 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; }) const { ref, focusKey } = useFocusable({ focusKey: 'actions', onBlur: () => setHoverText(undefined) }); const [open, setOpen] = useState(false); const deleteMutation = useMutation({ - mutationKey: ['delete', data.game.id], - mutationFn: () => rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).delete(), + ...queries.romm.deleteGameMutation, onSuccess: () => { location.reload(); @@ -493,7 +492,7 @@ function ActionButton (data: { disabled?: boolean; }) { - const { ref, focused } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true }); + const { ref } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true }); const styles = { primary: "bg-primary text-primary-content focused:bg-base-content focused:text-base-300 focusable focusable-primary", base: " text-base-content border-dashed border-base-content/20 border-2 focused:bg-base-content focused:text-base-300 focusable focusable-primary", diff --git a/src/mainview/routes/games.tsx b/src/mainview/routes/games.tsx new file mode 100644 index 0000000..b9ae196 --- /dev/null +++ b/src/mainview/routes/games.tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { CollectionsDetail } from '../components/CollectionsDetail'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; + +export const Route = createFileRoute('/games')({ + component: RouteComponent, + validateSearch: zodValidator(z.object({ focus: z.string().optional() })) +}); + +function RouteComponent () +{ + const { focus } = Route.useSearch(); + + return ( +
    + +
    + ); +} \ No newline at end of file diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 421429d..0db1bd5 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -1,4 +1,4 @@ -import { JSX, Suspense, useContext, useState } from "react"; +import { JSX, Suspense, useContext, useEffect, useState } from "react"; import { Gamepad2, @@ -21,7 +21,6 @@ import { FocusContext, FocusDetails, - getCurrentFocusKey, useFocusable, } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; @@ -38,7 +37,6 @@ import { ErrorBoundary, useErrorBoundary } from "react-error-boundary"; import { twMerge } from "tailwind-merge"; import Shortcuts from "../components/Shortcuts"; import { PlatformsList } from "../components/PlatformsList"; -import { systemApi } from "../scripts/clientApi"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts"; import z from "zod"; import { Router } from ".."; @@ -47,6 +45,8 @@ import { zodValidator } from '@tanstack/zod-adapter'; import { mobileCheck, useDragScroll } from "../scripts/utils"; import { AnimatedBackgroundContext } from "../scripts/contexts"; import { FrontEndId } from "@/shared/constants"; +import Carousel from "../components/Carousel"; +import queries from "../scripts/queries"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -90,6 +90,16 @@ function HomeListError (data: { focused: boolean; })
  • ; } +function ShowAllGamesCard () +{ + const handleNavigate = () => + { + Router.navigate({ to: '/games', viewTransition: { types: ['zoom-in'] } }); + }; + const { ref } = useFocusable({ focusKey: 'all-games-btn', onEnterPress: handleNavigate }); + return
    All Games
    ; +} + function HomeList (data: { selectedFilter: string; }) @@ -104,8 +114,8 @@ function HomeList (data: { const handleNodeFocus = (id: string, node: HTMLElement, details: FocusDetails) => { - const isMounseEvent = details.nativeEvent instanceof MouseEvent; - if (!isMounseEvent) + const isMouseEvent = details.nativeEvent instanceof MouseEvent; + if (!isMouseEvent) { node?.scrollIntoView({ inline: 'center', block: 'center', behavior: initFocus ? 'smooth' : 'instant' }); } @@ -136,19 +146,29 @@ function HomeList (data: { { case 'consoles': activeList = <> - + ; break; case 'collections': activeList = <> - + ; break; default: activeList = <> - + } + /> ; break; @@ -182,7 +202,7 @@ function HomeList (data: { return ( -
    @@ -193,17 +213,16 @@ function HomeList (data: {
    -
    +
    ); } -function MainMenu (data: {}) +function MainMenu () { - const { ref, focusKey, hasFocusedChild } = useFocusable({ + const { ref, focusKey } = useFocusable({ focusKey: `main-menu`, trackChildren: true, - onBlur: (layout, props, details) => { }, }); const navigate = useNavigate(); return ( @@ -214,7 +233,7 @@ function MainMenu (data: {}) > navigate({ to: "/" })} + action={() => navigate({ to: "/games", viewTransition: { types: ['zoom-in'] } })} icon={} label="Home" type="secondary" @@ -248,7 +267,7 @@ function CircleIcon (data: { icon?: JSX.Element; }) { - const { ref, focused, focusKey } = useFocusable({ + const { ref, focusKey } = useFocusable({ focusKey: `navigation-icon-${data.label}`, onEnterPress: data.action, }); @@ -275,15 +294,9 @@ export default function ConsoleHomeUI () { const { filter } = Route.useSearch(); - const closeMutation = useMutation({ - mutationKey: ['close'], mutationFn: async () => - { - const { error } = await systemApi.api.system.exit.post(); - if (error) throw error; - } - }); + const close = useMutation(queries.system.closeMutation); - const { ref, focusKey, focusSelf } = useFocusable({ + const { ref, focusKey } = useFocusable({ forceFocus: true, autoRestoreFocus: false, saveLastFocusedChild: false, @@ -319,7 +332,7 @@ export default function ConsoleHomeUI () const headerButtons = []; if (mobileCheck()) headerButtons.push({ id: "fullscreen", icon: , action: handleFullscreen }); - headerButtons.push({ id: "search", icon: }, { id: "power-button", icon: , external: true, action: () => closeMutation.mutate() }); + headerButtons.push({ id: "search", icon: }, { id: "power-button", icon: , external: true, action: () => close.mutate() }); return ( diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index c9fc871..6f987fa 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -4,11 +4,11 @@ import { GameInstallProgress, RPC_URL } from '@/shared/constants'; import DotsLoading from '../components/backgrounds/dots'; import { Router } from '..'; import { useEffect } from 'react'; -import { rommApi } from '../scripts/clientApi'; import { useQuery } from '@tanstack/react-query'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import Shortcuts from '../components/Shortcuts'; +import queries from '../scripts/queries'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, @@ -23,7 +23,7 @@ function RouteComponent () const { source, id } = Route.useParams(); const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` }); - const { data } = useQuery({ queryKey: ['romm', 'game'], queryFn: () => rommApi.api.romm.game({ source })({ id }).get() }); + const { data } = useQuery(queries.romm.gameQuery(source, id)); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); const { shortcuts } = useShortcutContext(); @@ -58,7 +58,7 @@ function RouteComponent () return
    -

    Launching {data?.data?.name} ...

    +

    Launching {data?.name} ...

    diff --git a/src/mainview/routes/platform.$source.$id.tsx b/src/mainview/routes/platform.$source.$id.tsx index 17b7efa..0ae0c5a 100644 --- a/src/mainview/routes/platform.$source.$id.tsx +++ b/src/mainview/routes/platform.$source.$id.tsx @@ -1,10 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; import { CollectionsDetail } from "../components/CollectionsDetail"; import { useQuery } from "@tanstack/react-query"; -import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants"; -import { useContext } from "react"; -import { rommApi } from "../scripts/clientApi"; -import { AnimatedBackgroundContext } from "../scripts/contexts"; +import { RPC_URL } from "../../shared/constants"; +import queries from "../scripts/queries"; export const Route = createFileRoute("/platform/$source/$id")({ component: RouteComponent @@ -24,22 +22,12 @@ function PlatformTitle (data: { pathCover: string | null, platformName?: string; function RouteComponent () { const { source, id } = Route.useParams(); - const { data: platform } = useQuery({ - queryKey: ['platform', source, id], queryFn: async () => - { - const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get(); - if (error) throw error; - return data; - }, staleTime: DefaultRommStaleTime - }); - - const animatedBgContext = useContext(AnimatedBackgroundContext); + const { data: platform } = useQuery(queries.romm.platformQuery(source, id)); return (
    {!!platform && } - setBackground={animatedBgContext.setBackground} filters={{ platform_id: Number(id), platform_slug: platform.slug, platform_source: source }} />}
    diff --git a/src/mainview/routes/settings/about.tsx b/src/mainview/routes/settings/about.tsx index 7fe9b9f..fd0fede 100644 --- a/src/mainview/routes/settings/about.tsx +++ b/src/mainview/routes/settings/about.tsx @@ -1,4 +1,5 @@ -import { systemApi } from '@/mainview/scripts/clientApi'; + +import queries from '@/mainview/scripts/queries'; import { useQuery } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import prettyBytes from 'pretty-bytes'; @@ -9,7 +10,7 @@ export const Route = createFileRoute('/settings/about')({ function RouteComponent () { - const { data: systemInfo } = useQuery({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() }); + const { data: systemInfo } = useQuery(queries.system.systemInfoQuery); return diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index 39df364..ffd6026 100644 --- a/src/mainview/routes/settings/accounts.tsx +++ b/src/mainview/routes/settings/accounts.tsx @@ -13,23 +13,17 @@ import useEffect, useRef, } from "react"; -import { RPC_URL } from "@shared/constants"; -import -{ - getCurrentUserApiUsersMeGetOptions, - statsApiStatsGetOptions, -} from "@clients/romm/@tanstack/react-query.gen"; +import { RommLoginDataSchema, RPC_URL } from "@shared/constants"; import toast from "react-hot-toast"; -import z from "zod"; import { OptionSpace } from "../../components/options/OptionSpace"; import { useSettingsForm, useSettingsFormContext } from "../../components/options/SettingsAppForm"; -import { rommApi, settingsApi } from "../../scripts/clientApi"; import { Button } from "../../components/options/Button"; import { ContextDialog } from "@/mainview/components/ContextDialog"; import QRCode from "react-qr-code"; import { useJobStatus } from "@/mainview/scripts/utils"; import { useInterval } from "usehooks-ts"; import { TwitchIcon } from "@/mainview/scripts/brandIcons"; +import queries from "@/mainview/scripts/queries"; export const Route = createFileRoute("/settings/accounts")({ component: RouteComponent, @@ -56,44 +50,16 @@ function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: ; } -function TwitchLogin (data: {}) +function TwitchLogin () { - - const loginStatus = useQuery({ - queryKey: ['twitch', 'login', 'status'], - retry (failureCount, error) - { - if (error.status === 404) - { - return false; - } - return failureCount < 3; - }, - queryFn: async () => - { - const { data, error, status } = await rommApi.api.romm.login.twitch.get(); - if (error) throw { ...error, status }; - return data; - } - }); + const loginStatus = useQuery(queries.settings.twitchLoginVerificationQuery); const loginMutation = useMutation({ - mutationKey: ['twitch', 'login'], - mutationFn: (openInBrowser: boolean) => - { - return rommApi.api.romm.login.twitch.post({ openInBrowser }); - }, + ...queries.settings.twitchLoginMutation, onSuccess: () => loginStatus.refetch() }); - const logoutMutation = useMutation({ - mutationKey: ['twitch', 'logout'], - mutationFn: () => - { - return rommApi.api.romm.logout.twitch.post(); - }, - onSuccess: () => loginStatus.refetch() - }); + const logoutMutation = useMutation({ ...queries.settings.twitchLogoutMutation, onSuccess: () => loginStatus.refetch() }); const { data: loginData, wsRef } = useJobStatus('twitch-login-job', { onEnded: () => loginStatus.refetch() }); @@ -118,22 +84,13 @@ function TwitchLogin (data: {}) function LoginControls (data: { hasPassword: boolean; }) { - const user = useQuery({ - ...getCurrentUserApiUsersMeGetOptions(), - queryKey: ['romm', 'auth', "login"], - refetchOnWindowFocus: false, - retry: 0 - }); - - const loginMutation = useMutation({ - mutationKey: ['login', 'qr', 'cancel'], - mutationFn: () => rommApi.api.romm.login.romm.post() - }); - const { data: statusValue, error: loginError, wsRef } = useJobStatus('login-job'); + const user = useQuery(queries.romm.rommUserQuery()); + const loginMutation = useMutation(queries.romm.rommQrLoginMutation); + const { data: statusValue, wsRef } = useJobStatus('login-job'); const context = useSettingsFormContext({}); const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0; const logoutMutation = useMutation({ - mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post(), + ...queries.romm.rommLogoutMutation, onSuccess: async (d, v, r, c) => { user.refetch(); @@ -171,8 +128,6 @@ function LoginControls (data: { hasPassword: boolean; }) ; } -const dataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() }); - function RouteComponent () { const { focus } = Route.useSearch(); @@ -181,9 +136,9 @@ function RouteComponent () preferredChildFocusKey: focus }); - const { data: hasPassword } = useQuery({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => rommApi.api.romm.login.get().then(d => d.data?.hasPassword as boolean) }); - const { data: hostname } = useQuery({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) }); - const { data: username } = useQuery({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) }); + const { data: hasPassword } = useQuery(queries.romm.rommHasPasswordQuery); + const { data: hostname } = useQuery(queries.romm.rommHostnameQuery); + const { data: username } = useQuery(queries.romm.rommUsernameQuery); const loginForm = useSettingsForm({ defaultValues: { @@ -201,15 +156,11 @@ function RouteComponent () loginForm.reset(); }, validators: { - onChange: dataSchema + onChange: RommLoginDataSchema } }); - const rommOnline = useQuery({ - ...statsApiStatsGetOptions(), - refetchInterval: 30000, - retry: false, - }); + const rommOnline = useQuery(queries.romm.rommGetOptionsQuery()); useEffect(() => { @@ -219,22 +170,7 @@ function RouteComponent () } }, [focus]); - const loginMutation = useMutation({ - mutationKey: ["romm", "login"], - mutationFn: async (data: z.infer) => - { - const { error } = await rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname }); - if (error) throw error; - }, - onSuccess: (d, v, r, c) => - { - c.client.invalidateQueries({ queryKey: ['romm', 'auth'] }); - }, - onError: (e) => - { - console.error(e); - }, - }); + const loginMutation = useMutation(queries.romm.rommLoginMutation); let indicator = ""; if (rommOnline.isError) diff --git a/src/mainview/routes/settings/directories.tsx b/src/mainview/routes/settings/directories.tsx index 986c638..37e8984 100644 --- a/src/mainview/routes/settings/directories.tsx +++ b/src/mainview/routes/settings/directories.tsx @@ -2,7 +2,7 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-naviga import { Block, createFileRoute } from '@tanstack/react-router'; import DownloadDirectoryOption from '@/mainview/components/options/DownloadDirectoryOption'; import { useIsMutating, useMutation, useQuery } from '@tanstack/react-query'; -import { changeDownloadsMutation, downloadDrivesQuery } from '@/mainview/scripts/queries'; +import queries from '@/mainview/scripts/queries'; import { DownloadsDrive } from '@/shared/constants'; import prettyBytes from 'pretty-bytes'; import classNames from 'classnames'; @@ -24,11 +24,11 @@ function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; r focusKey: data.drive.device, onFocus: () => (ref.current as HTMLElement)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) }); - const isMoving = useIsMutating(changeDownloadsMutation); + const isMoving = useIsMutating(queries.settings.changeDownloadsMutation); const usedWithoutDownlods = data.drive.used - (data.drive.isCurrentlyUsed ? data.downloadsSize : 0); const usedPercent = usedWithoutDownlods / data.drive.size; const usedPercentRaw = data.drive.used / data.drive.size; - const changeDownloads = useMutation({ ...changeDownloadsMutation, onSuccess: data.refetchDrives }); data.drive.unusableReason; + const changeDownloads = useMutation({ ...queries.settings.changeDownloadsMutation, onSuccess: data.refetchDrives }); data.drive.unusableReason; const shortcuts: Shortcut[] = []; const valid = !data.drive.unusableReason && isMoving <= 0; const handleAction = () => changeDownloads.mutate(data.drive.mountPoint); @@ -74,16 +74,16 @@ function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; r function RouteComponent () { const { focus } = Route.useSearch(); - const { ref, focusKey, focusSelf } = useFocusable({ + const { ref, focusKey } = useFocusable({ focusKey: "directories", preferredChildFocusKey: focus }); - const isMoving = useIsMutating(changeDownloadsMutation); - const { data: drives, refetch } = useQuery({ ...downloadDrivesQuery, refetchInterval: isMoving > 0 ? 1000 : undefined }); + const isMoving = useIsMutating(queries.settings.changeDownloadsMutation); + const { data: drives, refetch } = useQuery({ ...queries.system.downloadDrivesQuery, refetchInterval: isMoving > 0 ? 1000 : undefined }); return - isMoving} withResolver={false} /> + isMoving > 0} withResolver={false} />
      Downloads ({drives?.downloadsSize ? prettyBytes(drives?.downloadsSize) : }) diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index b8c6d46..3fa94a8 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -2,7 +2,6 @@ import { createFileRoute } from '@tanstack/react-router'; import { OptionSpace } from '../../components/options/OptionSpace'; import { OptionInput } from '../../components/options/OptionInput'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { settingsApi } from '../../scripts/clientApi'; import { useCallback, useState } from 'react'; import { Button } from '../../components/options/Button'; import { Check, ChevronDown, FolderSearch, SearchAlert, Trash, TriangleAlert } from 'lucide-react'; @@ -15,7 +14,7 @@ import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spat import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; import FilePicker from '@/mainview/components/FilePicker'; import { dirname } from 'pathe'; -import { autoEmulatorsQuery } from '@/mainview/scripts/queries'; +import queries from '@/mainview/scripts/queries'; export const Route = createFileRoute('/settings/emulators')({ component: RouteComponent, @@ -33,7 +32,7 @@ function EmulatorsPending () function EmulatorListCat (data: { selected: string, set: (c: string) => void; }) { - const { ref, focused, focusKey } = useFocusable({ focusKey: 'categories' }); + const { ref, focusKey } = useFocusable({ focusKey: 'categories' }); return
        {[..."ABCDEFGHIJKLMNOPQRSTVWXYZ"].map(c => @@ -99,40 +98,13 @@ function EmulatorPath (data: { id: string; }) const [isSearching, setIsSearching] = useState(false); const [dirty, setDirty] = useState(false); const [localValue, setLocalValue] = useState(); - const { data: remoteValue } = useQuery({ - enabled: !!data.id, - queryKey: ["emulator", data.id], - queryFn: async () => - { - const { data: value, error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).get(); - if (error) throw error; - return value; - }, - }); - const setSettingMutation = useMutation({ - mutationKey: ["emulator", data.id, 'set'], - mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: data.id }).put({ value }), - onSuccess: (d, v, r, ctx) => - { - ctx.client.invalidateQueries({ queryKey: ["emulator", data.id] }); - ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] }); - setLocalValue(v); - setDirty(false); - } - }); - const deleteMutation = useMutation({ - mutationKey: ["emulator", data.id, 'delete'], - mutationFn: async () => - { - const { error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).delete(); - if (error) throw error; - }, - onSuccess: (d, v, r, ctx) => - { - ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] }); - ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] }); - } - }); + const { data: remoteValue } = useQuery(queries.settings.customEmulatorRemoveValueQuery(data.id)); + const setSettingMutation = useMutation(queries.settings.setCustomEmulatorMutation(data.id, (v) => + { + setLocalValue(v); + setDirty(false); + })); + const deleteMutation = useMutation(queries.settings.customEmulatorDeleteMutation(data.id)); const handleSave = useCallback(() => { @@ -251,11 +223,11 @@ function EmulatorBadge (data: { function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) => void; }) { - const { data: autoEmulators } = useQuery(autoEmulatorsQuery); + const { data: autoEmulators } = useQuery(queries.settings.autoEmulatorsQuery); const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators && autoEmulators.length > 0 }); return
        - {autoEmulators?.map(e => )} + {autoEmulators?.map(e => )}
        ; } @@ -263,30 +235,14 @@ function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) function RouteComponent () { const { focus } = Route.useSearch(); - const { ref, focusKey, focusSelf } = useFocusable({ + const { ref, focusKey } = useFocusable({ focusKey: "emulators-setting", preferredChildFocusKey: focus }); - const { data: customEmulators } = useQuery({ - queryKey: ['custom-emulators'], queryFn: async () => - { - const { data, error } = await settingsApi.api.settings.emulators.custom.get(); - if (error) throw error; - return data; - } - }); + const { data: customEmulators } = useQuery(queries.settings.customEmulatorsQuery); - const addOverrideMutation = useMutation({ - mutationKey: ['emulator', 'custom', 'add'], - mutationFn: async (id: string) => - { - const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' }); - if (error) throw error; - return data; - }, - onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] }) - }); + const addOverrideMutation = useMutation(queries.settings.customEmulatorAddMutation); return
          diff --git a/src/mainview/routes/settings/interface.tsx b/src/mainview/routes/settings/interface.tsx index a322b25..c1c94f9 100644 --- a/src/mainview/routes/settings/interface.tsx +++ b/src/mainview/routes/settings/interface.tsx @@ -9,7 +9,7 @@ export const Route = createFileRoute('/settings/interface')({ function RouteComponent () { const { focus } = Route.useSearch(); - const { ref, focusKey, focusSelf } = useFocusable({ + const { ref, focusKey } = useFocusable({ focusKey: "interface-settings", preferredChildFocusKey: focus }); diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index 3d00a05..8fb69a0 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -55,7 +55,7 @@ function MenuItem (data: { const { to, search } = PopSource('settings'); navigate({ to: data.return ? to ?? data.route : data.route, viewTransition: data.viewTransition, search: data.return ? search : undefined }); }; - const { ref, focusSelf, focused } = useFocusable({ + const { ref, focusSelf } = useFocusable({ focusKey: `menu-item-${data.route}`, forceFocus: !!acitve, onFocus: () => diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 77e74a6..302c799 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -12,7 +12,7 @@ import Shortcuts from "@/mainview/components/Shortcuts"; import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; import { PopSource } from "@/mainview/scripts/spatialNavigation"; import { systemApi } from "@/mainview/scripts/clientApi"; -import { storeEmulatorDetailsQuery, storeEmulatorsRecommendedQuery } from "@/mainview/scripts/queries"; +import queries from "@/mainview/scripts/queries"; import { Button } from "@/mainview/components/options/Button"; import { ChevronDown, Download, Info, Settings } from "lucide-react"; import { ContextDialog, ContextList, DialogEntry } from "@/mainview/components/ContextDialog"; @@ -27,7 +27,7 @@ export const Route = createFileRoute('/store/details/emulator/$id')({ component: RouteComponent, async loader (ctx) { - const emulator = await ctx.context.queryClient.fetchQuery(storeEmulatorDetailsQuery(ctx.params.id)); + const emulator = await ctx.context.queryClient.fetchQuery(queries.store.storeEmulatorDetailsQuery(ctx.params.id)); return { emulator }; } }); @@ -107,7 +107,7 @@ export function RouteComponent () }); const { emulator } = Route.useLoaderData(); - const { data: recommended } = useQuery(storeEmulatorsRecommendedQuery); + const { data: recommended } = useQuery(queries.store.storeEmulatorsRecommendedQuery); useShortcuts(focusKey, () => [{ label: "Return", @@ -180,13 +180,7 @@ export function RouteComponent () setFocus("title-area"); Router.navigate({ to: '/store/details/emulator/$id', params: { id }, viewTransition: { types: ['zoom-in'] } }); }} - emulators={recommended.map(em => ({ - name: em.name, - id: em.name, - installed: em.exists, - logo: em.logo, - systems: em.systems - } satisfies ShopFrontEndEmulator))} />} + emulators={recommended} />}
      diff --git a/src/mainview/routes/store/tab/emulators.tsx b/src/mainview/routes/store/tab/emulators.tsx index cbd00c1..ce30db4 100644 --- a/src/mainview/routes/store/tab/emulators.tsx +++ b/src/mainview/routes/store/tab/emulators.tsx @@ -1,5 +1,5 @@ -import { storeEmulatorsQuery } from '@/mainview/scripts/queries'; + import { createFileRoute, useSearch } from '@tanstack/react-router'; import { Joystick } from 'lucide-react'; import { useContext, useEffect } from 'react'; @@ -7,33 +7,13 @@ import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/no import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard'; import { StoreContext } from '@/mainview/scripts/contexts'; import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; +import { useQuery } from '@tanstack/react-query'; +import queries from '@/mainview/scripts/queries'; export const Route = createFileRoute('/store/tab/emulators')({ component: RouteComponent, - pendingComponent: PendingComponent, - async loader ({ context }) - { - const emulators = await context.queryClient.fetchQuery(storeEmulatorsQuery); - return { emulators }; - }, }); -function PendingComponent () -{ - return
      -
      - -

      - Emulators -

      -
      - {/* Cards */} -
      - {[1, 2, 3, 4, 5, 6].map(i =>
      )} -
      -
      ; -} - function RouteComponent () { const { focus } = useSearch({ from: '/store/tab' }); @@ -42,7 +22,7 @@ function RouteComponent () preferredChildFocusKey: focus }); const storeContext = useContext(StoreContext); - const { emulators } = Route.useLoaderData(); + const { data: emulators } = useQuery(queries.store.storeEmulatorsQuery); useEffect(() => { @@ -64,7 +44,7 @@ function RouteComponent ()
      {/* Cards */}
      - {emulators && emulators.map((data) => ( + {emulators?.map((data) => ( { node.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'center' }); }} onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)} /> - ))} + )) ?? Array.from({ length: 10 }).map((_, i) =>
      )}
      diff --git a/src/mainview/routes/store/tab/games.tsx b/src/mainview/routes/store/tab/games.tsx index 167d6fc..ddea718 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -1,95 +1,23 @@ -import { StoreGameCard } from '@/mainview/components/store/GamesSection'; -import { FocusContext, getCurrentFocusKey, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { createFileRoute, useSearch } from '@tanstack/react-router'; -import { Gamepad, Gamepad2, HardDrive, Save } from 'lucide-react'; -import { JSX, useContext, useEffect, useRef, useState } from 'react'; +import { Gamepad2 } from 'lucide-react'; +import { useEffect } from 'react'; import { useInfiniteQuery } from '@tanstack/react-query'; -import { StoreContext } from '@/mainview/scripts/contexts'; -import { basename, dirname, extname } from 'pathe'; -import { rommApi } from '@/mainview/scripts/clientApi'; -import { FrontEndGameType, RPC_URL } from '@/shared/constants'; -import CardElement from '@/mainview/components/CardElement'; -import { FOCUS_KEYS } from '@/mainview/scripts/types'; import FrontEndGameCard from '@/mainview/components/FrontEndGameCard'; import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; -import { useIntersectionObserver } from 'usehooks-ts'; - -const staleTime = 24 * 60 * 60 * 1000; +import LoadMoreButton from '@/mainview/components/LoadMoreButton'; +import queries from '@/mainview/scripts/queries'; export const Route = createFileRoute('/store/tab/games')({ - component: RouteComponent, - async loader (ctx) - { - - /*const gamesManifest = await ctx.context.queryClient.fetchQuery({ - queryKey: ['store-games-manifest'], queryFn: async () => - { - const store = await fetch('https://api.github.com/repos/dragoonDorise/EmuDeck/git/trees/50261b66d69c1758efa28c6d7c54e45259a0c9c5?recursive=true').then(r => r.json()); - - return store.tree.filter((e: any) => - { - if (e.type === 'blob' && e.path !== "featured.json") - { - return true; - } - return false; - }) as []; - }, staleTime - }); - - return { gamesManifest };*/ - }, + component: RouteComponent }); -function LoadMoreButton (data: { isFetching: boolean; lastId?: string; } & FocusParams & InteractParams) -{ - const handleAction = (e?: Event) => - { - data.onAction?.(e); - if (data.lastId && focused) - setFocus(FOCUS_KEYS.GAME_CARD(data.lastId)); - }; - - const { ref, focusKey, focused } = useFocusable({ - focusKey: 'load-more-btn', - onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details), - onEnterPress: handleAction - }); - - const { ref: intersct } = useIntersectionObserver({ - onChange: (isIntersecting, entry) => - { - if (isIntersecting) - { - handleAction(); - } - } - }); - - return
      - { - ref.current = r; - intersct(r); - }} className='flex bg-base-100 game-card focusable focusable-accent focusable-hover text-2xl justify-center items-center cursor-pointer' onClick={handleAction} id='load-more-btn'>{data.isFetching ? : "Load More"}
      ; -} - function RouteComponent () { const { focus } = useSearch({ from: '/store/tab' }); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus }); - const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery<{ data: FrontEndGameType[], nextPage: number; }>({ - initialPageParam: 0, - queryKey: ['store-games'], - getNextPageParam: (lastPage, pages) => lastPage.nextPage, - queryFn: async (data) => - { - const pageParam = data.pageParam as number; - const { data: games, error } = await rommApi.api.romm.games.get({ query: { source: 'store', offset: pageParam * 10, limit: 10 } }); - if (error) throw error; - return { data: games.games, nextPage: pageParam + 1 }; - } - }); + const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(queries.store.storeGamesInfiniteQuery); useEffect(() => { @@ -115,17 +43,21 @@ function RouteComponent () Games
      -
      +
      {data?.pages.flatMap((page) => ( page.data.map((g, i) => )) - )} + ) ?? Array.from({ length: 20 }).map((_, i) =>
      +
      +
      +
      +
      )} { - if (isFetchingNextPage) + if (isFetchingNextPage || isFetching) return; fetchNextPage(); }} /> diff --git a/src/mainview/routes/store/tab/index.tsx b/src/mainview/routes/store/tab/index.tsx index 89d00aa..94df52e 100644 --- a/src/mainview/routes/store/tab/index.tsx +++ b/src/mainview/routes/store/tab/index.tsx @@ -1,11 +1,11 @@ -import { createFileRoute, ErrorComponentProps, useSearch } from '@tanstack/react-router'; +import { createFileRoute, useSearch } from '@tanstack/react-router'; import { useFocusable, FocusContext, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; import { MissingEmulatorsSection } from "../../../components/store/MissingEmulatorsSection"; import { EmulatorsSection } from "../../../components/store/EmulatorsSection"; import { GamesSection } from "../../../components/store/GamesSection"; import { StatsSection } from "../../../components/store/StatsSection"; import { FrontEndGameTypeDetailed, RPC_URL } from '@/shared/constants'; -import { autoEmulatorsQuery, storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@/mainview/scripts/queries'; +import queries from '@/mainview/scripts/queries'; import { useContext, useEffect, useRef, useState } from 'react'; import { scrollIntoViewHandler } from '@/mainview/scripts/utils'; import { StoreContext } from '@/mainview/scripts/contexts'; @@ -13,66 +13,34 @@ import { useInterval } from 'usehooks-ts'; import { Button } from '@/mainview/components/options/Button'; import { HardDrive, Search } from 'lucide-react'; import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; +import { useQuery } from '@tanstack/react-query'; export const Route = createFileRoute('/store/tab/')({ - component: RouteComponent, - pendingComponent: LoadingSkeleton, - errorComponent: ErrorComponent, - loader: async ({ context }) => - { - const autoEmulators = await context.queryClient.fetchQuery(autoEmulatorsQuery); - const crutialEmulators = autoEmulators?.filter(e => !e.exists && e.isCritical); - const featuredGames = await await context.queryClient.fetchQuery(storeFeaturedGamesQuery); - const recommendedEmulators = await context.queryClient.fetchQuery(storeEmulatorsRecommendedQuery); - return { crutialEmulators, recommendedEmulators, featuredGames }; - } + component: RouteComponent }); -function ErrorComponent (data: ErrorComponentProps) -{ - return
      -
      - Failed to load store data. -

      {data.error.message}

      -
      -
      ; -} -// ── Loading skeleton ─────────────────────────────────────────────────────── -function LoadingSkeleton () +function Main (data: { games?: FrontEndGameTypeDetailed[]; }) { - return ( -
      - {/* Missing section */} -
      - {[1, 2, 3].map((i) =>
      )} -
      - {/* Emulators */} -
      - {[1, 2, 3, 4, 5, 6].map((i) =>
      )} -
      - {/* Games */} -
      - {[1, 2, 3, 4].map((i) =>
      )} -
      -
      - ); -} - -function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; }) -{ - const [selectedGame, setSelectedGame] = useState(new Date().getSeconds() % data.games.length); + const [selectedGame, setSelectedGame] = useState(0); const [nextSwitch, setNextSwitch] = useState(new Date().getTime() + 10000); const progressRef = useRef(null); const { ref, focusKey } = useFocusable({ focusKey: 'main-featured-area' }); - const game = data.games[selectedGame]; + const game = data.games ? data.games[selectedGame] : undefined; useInterval(() => { - setSelectedGame(current => (current + 1) % data.games.length); + if (!data.games) return; + setSelectedGame(current => (current + 1) % data.games!.length); setNextSwitch(new Date().getTime() + 10000); }, 10000); + useEffect(() => + { + if (!data.games) return; + setSelectedGame(new Date().getSeconds() % data.games.length); + }, [data.games]); + useInterval(() => { var time = (nextSwitch - new Date().getTime()) / 10000; @@ -81,18 +49,18 @@ function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; }) }, 10); const storeContext = useContext(StoreContext); - const previewUrl = new URL(`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`); - previewUrl.searchParams.set('blur', '16'); + const previewUrl = data.games ? new URL(`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`) : undefined; + previewUrl?.searchParams.set('blur', '16'); return
      -
      + {game ?
      { e.currentTarget.classList.toggle('opacity-0', false); @@ -101,11 +69,11 @@ function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; }) />
      -
      +
      -
      +
      - + {!!data.games && }

      {game.name}

      @@ -117,21 +85,19 @@ function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; })
      - - {data.children} -
      +
      :
      }
      - {data.games.map((g, i) => + {data.games?.map((g, i) =>
      - +
      {g.name}
      {i === selectedGame && } -
      )} +
      ) ?? Array.from({ length: 3 }).map((_, i) =>
      )}
      ; @@ -140,7 +106,9 @@ function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; }) export function RouteComponent () { const { focus } = useSearch({ from: '/store/tab' }); - const { crutialEmulators, recommendedEmulators, featuredGames } = Route.useLoaderData(); + const { data: crucialEmulators, isSuccess } = useQuery({ ...queries.settings.autoEmulatorsQuery, select: (data) => data.filter(e => !e.exists && e.isCritical) }); + const { data: featuredGames } = useQuery(queries.store.storeFeaturedGamesQuery); + const { data: recommendedEmulators } = useQuery(queries.store.storeEmulatorsRecommendedQuery); const { focusKey, ref, focusSelf } = useFocusable({ focusKey: 'main-area', preferredChildFocusKey: focus ?? "recommended-emulators" }); const storeContext = useContext(StoreContext); @@ -152,15 +120,15 @@ export function RouteComponent () focusSelf({ instant: true }); } - }, [focus]); + }, [focus, isSuccess]); return (
      - {!!featuredGames &&
      } - {crutialEmulators.length > 0 && } + {!!crucialEmulators && crucialEmulators?.length > 0 && storeContext.showDetails('emulator', 'store', id, focus)} - emulators={crutialEmulators} />} + emulators={crucialEmulators} />}
      diff --git a/src/mainview/scripts/gamepads.ts b/src/mainview/scripts/gamepads.ts index 5ca8ff1..fed3f3d 100644 --- a/src/mainview/scripts/gamepads.ts +++ b/src/mainview/scripts/gamepads.ts @@ -6,9 +6,18 @@ import { mobileCheck } from "./utils"; let loopStarted = false; let isTouching = false; type ActiveControlType = 'keyboard' | 'gamepad' | 'mouse' | 'touch' | undefined; -let activeControls: ActiveControlType = mobileCheck() ? 'touch' : 'mouse'; +let activeControls: ActiveControlType = sessionStorage.getItem('active-controls') as any; +if (!activeControls) +{ + if (mobileCheck()) + { + activeControls = 'touch'; + } else + { + activeControls = 'mouse'; + } +} let mouseUpdateTimeout: any | undefined = undefined; -let touchStopTimeout: any | undefined = undefined; const handleLoop = () => { @@ -109,6 +118,13 @@ function focusControl (control: typeof activeControls) if (activeControls != control) { activeControls = control; + if (control) + { + sessionStorage.setItem('active-controls', control); + } else + { + sessionStorage.removeItem('active-controls'); + } window.dispatchEvent(new CustomEvent('activecontrolschange', { detail: control })); if (control !== 'mouse') { diff --git a/src/mainview/scripts/queries.ts b/src/mainview/scripts/queries.ts index 9f07adc..f45ce51 100644 --- a/src/mainview/scripts/queries.ts +++ b/src/mainview/scripts/queries.ts @@ -1,108 +1,11 @@ -import { keepPreviousData, mutationOptions, queryOptions } from "@tanstack/react-query"; -import { rommApi, settingsApi, storeApi, systemApi } from "./clientApi"; -import toast from "react-hot-toast"; -import { getErrorMessage } from "react-error-boundary"; +import system from "./queries/system"; +import settings from "./queries/settings"; +import romm from "./queries/romm"; +import store from "./queries/store"; -export const drivesQuery = queryOptions({ - queryKey: ['drives'], - queryFn: async () => - { - const { data, error } = await systemApi.api.system.drives.get(); - if (error) throw error; - return data; - } -}); - -export const downloadDrivesQuery = queryOptions({ - queryKey: ['drives', 'download'], - queryFn: async () => - { - const { data, error } = await systemApi.api.system.drives.download.get(); - if (error) throw error; - return data; - } -}); - -export const filesQuery = (currentPath: string | undefined, id: string) => queryOptions({ - queryKey: ['files', currentPath ?? '', id], - queryFn: async () => - { - const { data, error } = await systemApi.api.system.dirs.get({ query: { path: currentPath } }); - if (error) throw error; - return data; - }, - placeholderData: keepPreviousData -}); - -export const changeDownloadsMutation = mutationOptions({ - mutationKey: ["setting", "downloads"], - mutationFn: async (value: any) => - { - const response = await toast.promise(settingsApi.api.settings.path.download.put({ manualPath: value }).then(d => - { - if (d.error) throw d.error; - return d.data; - }), { - success: e => `Download Moved to ${e}`, - loading: "Moving Download", - error: e => getErrorMessage(e) ?? "Error Moving Download" - }); - - return response; - } -}); - -export const gameQuery = (source: string, id: string) => queryOptions({ - queryKey: ['game', source, id], - queryFn: async () => - { - const { data, error } = await rommApi.api.romm.game({ source })({ id }).get(); - if (error) throw error; - return data; - }, -}); - -export const autoEmulatorsQuery = queryOptions({ - queryKey: ['auto-emulators'], queryFn: async () => - { - const { data, error } = await settingsApi.api.settings.emulators.automatic.get(); - if (error) throw error; - return data; - } -}); - -export const storeEmulatorsQuery = queryOptions({ - queryKey: ['store-emulators'], queryFn: async () => - { - const { data, error } = await storeApi.api.store.emulators.get(); - if (error) throw error; - return data; - } -}); - -export const storeFeaturedGamesQuery = queryOptions({ - queryKey: ['store-emulators', 'recommended'], queryFn: async () => - { - const { data, error } = await storeApi.api.store.games.featured.get(); - if (error) throw error; - return data; - } -}); - -export const storeEmulatorsRecommendedQuery = queryOptions({ - queryKey: ['store-emulators', 'recommended'], queryFn: async () => - { - const { data, error } = await storeApi.api.store.emulators.get({ query: { limit: 6, missing: true, orderBy: 'importance' } }); - if (error) throw error; - return data; - } -}); - -export const storeEmulatorDetailsQuery = (id: string) => queryOptions({ - queryKey: ['store-emulator', id], queryFn: async () => - { - const { data, error } = await storeApi.api.store.details.emulator({ id }).get(); - if (error) throw error; - return data; - } -}); \ No newline at end of file +export default { + system, + settings, + romm, + store +}; \ No newline at end of file diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts new file mode 100644 index 0000000..406fb03 --- /dev/null +++ b/src/mainview/scripts/queries/romm.ts @@ -0,0 +1,79 @@ +import { DefaultRommStaleTime, FrontEndId, GameListFilterType, RommLoginDataSchema, RPC_URL } from "@/shared/constants"; +import { rommApi, settingsApi } from "../clientApi"; +import { mutationOptions, queryOptions } from "@tanstack/react-query"; +import z from "zod"; +import { getCollectionApiCollectionsIdGetOptions, getCollectionsApiCollectionsGetOptions, getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen"; + +export default { + allGamesQuery: (filter?: GameListFilterType) => queryOptions({ + queryKey: ['games', filter ?? 'all'], + queryFn: async () => + { + const { data, error } = await rommApi.api.romm.games.get({ query: filter }); + if (error) throw error; + return data; + } + }), + gameQuery: (source: string, id: string) => queryOptions({ + queryKey: ['game', source, id], + queryFn: async () => + { + const { data, error } = await rommApi.api.romm.game({ source })({ id }).get(); + if (error) throw error; + return data; + }, + }), + rommLogoutMutation: mutationOptions({ mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post() }), + rommQrLoginMutation: mutationOptions({ + mutationKey: ['login', 'qr', 'cancel'], + mutationFn: () => rommApi.api.romm.login.romm.post() + }), + rommLoginMutation: mutationOptions({ + mutationKey: ["romm", "login"], + mutationFn: async (data: z.infer) => + { + const { error } = await rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname }); + if (error) throw error; + }, + onSuccess: (d, v, r, c) => + { + c.client.invalidateQueries({ queryKey: ['romm', 'auth'] }); + }, + onError: (e) => + { + console.error(e); + }, + }), + rommUserQuery: () => queryOptions({ + ...getCurrentUserApiUsersMeGetOptions(), + queryKey: ['romm', 'auth', "login"], + refetchOnWindowFocus: false, + retry: 0 + }), + rommGetOptionsQuery: () => queryOptions({ + ...statsApiStatsGetOptions(), + refetchInterval: 30000, + retry: false, + }), + rommHasPasswordQuery: queryOptions({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => rommApi.api.romm.login.get().then(d => d.data?.hasPassword as boolean) }), + rommHostnameQuery: queryOptions({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) }), + rommUsernameQuery: queryOptions({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) }), + deleteGameMutation: (id: FrontEndId) => mutationOptions({ + mutationKey: ['delete', id], + mutationFn: () => rommApi.api.romm.game({ source: id.source })({ id: id.id }).delete() + }), + getCollectionsQuery: () => queryOptions({ + ...getCollectionsApiCollectionsGetOptions(), + refetchOnWindowFocus: false, + staleTime: DefaultRommStaleTime + }), + getCollectionQuery: (id: number) => queryOptions({ ...getCollectionApiCollectionsIdGetOptions({ path: { id } }) }), + platformQuery: (source: string, id: string) => queryOptions({ + queryKey: ['platform', source, id], queryFn: async () => + { + const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get(); + if (error) throw error; + return data; + }, staleTime: DefaultRommStaleTime + }) +}; \ No newline at end of file diff --git a/src/mainview/scripts/queries/settings.ts b/src/mainview/scripts/queries/settings.ts new file mode 100644 index 0000000..7fa9c6a --- /dev/null +++ b/src/mainview/scripts/queries/settings.ts @@ -0,0 +1,134 @@ +import { mutationOptions, queryOptions } from "@tanstack/react-query"; +import { getErrorMessage } from "react-error-boundary"; +import toast from "react-hot-toast"; +import { rommApi, settingsApi } from "../clientApi"; + +export default { + changeDownloadsMutation: mutationOptions({ + mutationKey: ["setting", "downloads"], + mutationFn: async (value: any) => + { + const response = await toast.promise(settingsApi.api.settings.path.download.put({ manualPath: value }).then(d => + { + if (d.error) throw d.error; + return d.data; + }), { + success: e => `Download Moved to ${e}`, + loading: "Moving Download", + error: e => getErrorMessage(e) ?? "Error Moving Download" + }); + + return response; + } + }), + autoEmulatorsQuery: queryOptions({ + queryKey: ['auto-emulators'], queryFn: async () => + { + const { data, error } = await settingsApi.api.settings.emulators.automatic.get(); + if (error) throw error; + return data; + } + }), + twitchLogoutMutation: mutationOptions({ + mutationKey: ['twitch', 'logout'], + mutationFn: () => + { + return rommApi.api.romm.logout.twitch.post(); + } + }), + twitchLoginMutation: mutationOptions({ + mutationKey: ['twitch', 'login'], + mutationFn: (openInBrowser: boolean) => + { + return rommApi.api.romm.login.twitch.post({ openInBrowser }); + } + }), + twitchLoginVerificationQuery: queryOptions({ + queryKey: ['twitch', 'login', 'status'], + retry (failureCount, error) + { + if ((error as any).status === 404) + { + return false; + } + return failureCount < 3; + }, + queryFn: async () => + { + const { data, error, status } = await rommApi.api.romm.login.twitch.get(); + if (error) throw { ...error, status }; + return data; + } + }), + customEmulatorsQuery: queryOptions({ + queryKey: ['custom-emulators'], queryFn: async () => + { + const { data, error } = await settingsApi.api.settings.emulators.custom.get(); + if (error) throw error; + return data; + } + }), + customEmulatorAddMutation: mutationOptions({ + mutationKey: ['emulator', 'custom', 'add'], + mutationFn: async (id: string) => + { + const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' }); + if (error) throw error; + return data; + }, + onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] }) + }), + customEmulatorDeleteMutation: (id: string) => mutationOptions({ + mutationKey: ["emulator", id, 'delete'], + mutationFn: async () => + { + const { error } = await settingsApi.api.settings.emulators.custom({ id: id }).delete(); + if (error) throw error; + }, + onSuccess: (d, v, r, ctx) => + { + ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] }); + ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] }); + } + }), + setCustomEmulatorMutation: (id: string, onSuccess?: (value: string) => void) => mutationOptions({ + mutationKey: ["emulator", id, 'set'], + mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: id }).put({ value }), + onSuccess: (d, v, r, ctx) => + { + ctx.client.invalidateQueries({ queryKey: ["emulator", id] }); + ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] }); + onSuccess?.(v); + } + }), + customEmulatorRemoveValueQuery: (id?: string) => queryOptions({ + enabled: !!id, + queryKey: ["emulator", id], + queryFn: async () => + { + const { data: value, error } = await settingsApi.api.settings.emulators.custom({ id: id! }).get(); + if (error) throw error; + return value; + }, + }), + setSettingMutation: (id?: string) => mutationOptions({ + mutationKey: ["setting", id], + mutationFn: async (value: any) => + { + const response = await settingsApi.api.settings({ id: id! }).post({ value }); + if (response.error) throw response.error; + return response.data; + } + }), + getSettingQuery: (id: string | undefined) => queryOptions({ + enabled: !!id, + queryKey: ["setting", id], + queryFn: async () => + { + const { data: value, error } = await settingsApi.api.settings({ id: id! }).get(); + if (error) throw error; + + return value.value; + }, + }) +}; \ No newline at end of file diff --git a/src/mainview/scripts/queries/store.ts b/src/mainview/scripts/queries/store.ts new file mode 100644 index 0000000..8adde23 --- /dev/null +++ b/src/mainview/scripts/queries/store.ts @@ -0,0 +1,58 @@ +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; +import { rommApi, storeApi } from "../clientApi"; +import { FrontEndGameType } from "@/shared/constants"; + +export default { + storeEmulatorsQuery: queryOptions({ + queryKey: ['store-emulators'], queryFn: async () => + { + const { data, error } = await storeApi.api.store.emulators.get(); + if (error) throw error; + return data; + } + }), + storeFeaturedGamesQuery: queryOptions({ + queryKey: ['store-emulators', 'featured'], queryFn: async () => + { + const { data, error } = await storeApi.api.store.games.featured.get(); + if (error) throw error; + return data; + } + }), + storeEmulatorsRecommendedQuery: queryOptions({ + queryKey: ['store-emulators', 'recommended'], queryFn: async () => + { + const { data, error } = await storeApi.api.store.emulators.get({ query: { limit: 6, missing: true, orderBy: 'importance' } }); + if (error) throw error; + return data; + } + }), + storeEmulatorDetailsQuery: (id: string) => queryOptions({ + queryKey: ['store-emulator', id], queryFn: async () => + { + const { data, error } = await storeApi.api.store.details.emulator({ id }).get(); + if (error) throw error; + return data; + } + }), + storeGamesInfiniteQuery: infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({ + initialPageParam: 0, + queryKey: ['store-games'], + getNextPageParam: (lastPage, pages) => lastPage.nextPage, + queryFn: async (data) => + { + const pageParam = data.pageParam as number; + const { data: games, error } = await rommApi.api.romm.games.get({ query: { source: 'store', offset: pageParam * 10, limit: 10 } }); + if (error) throw error; + return { data: games.games, nextPage: pageParam + 1 }; + } + }), + storeGetStatsQuery: queryOptions({ + queryKey: ['store', 'stats'], queryFn: async () => + { + const { data, error } = await storeApi.api.store.stats.get(); + if (error) throw error; + return data; + } + }) +}; \ No newline at end of file diff --git a/src/mainview/scripts/queries/system.ts b/src/mainview/scripts/queries/system.ts new file mode 100644 index 0000000..8ac3224 --- /dev/null +++ b/src/mainview/scripts/queries/system.ts @@ -0,0 +1,51 @@ +import { keepPreviousData, mutationOptions, queryOptions } from "@tanstack/react-query"; +import { systemApi } from "../clientApi"; + +export default { + drivesQuery: queryOptions({ + queryKey: ['drives'], + queryFn: async () => + { + const { data, error } = await systemApi.api.system.drives.get(); + if (error) throw error; + return data; + } + }), + downloadDrivesQuery: queryOptions({ + queryKey: ['drives', 'download'], + queryFn: async () => + { + const { data, error } = await systemApi.api.system.drives.download.get(); + if (error) throw error; + return data; + } + }), + filesQuery: (currentPath: string | undefined, id: string) => queryOptions({ + queryKey: ['files', currentPath ?? '', id], + queryFn: async () => + { + const { data, error } = await systemApi.api.system.dirs.get({ query: { path: currentPath } }); + if (error) throw error; + return data; + }, + placeholderData: keepPreviousData + }), + systemInfoQuery: queryOptions({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() }), + createFolderMutation: (id: string) => mutationOptions({ + + mutationKey: ['create', 'folder', id], + mutationFn: async ({ name, dirname }: { name: string | undefined, dirname: string; }) => + { + if (!name) return; + const { error } = await systemApi.api.system.dirs.put({ name, dirname: dirname }); + if (error) throw error.value; + }, + }), + closeMutation: mutationOptions({ + mutationKey: ['close'], mutationFn: async () => + { + const { error } = await systemApi.api.system.exit.post(); + if (error) throw error; + } + }) +}; \ No newline at end of file diff --git a/src/mainview/scripts/serviceWorker.ts b/src/mainview/scripts/serviceWorker.ts new file mode 100644 index 0000000..0a3e391 --- /dev/null +++ b/src/mainview/scripts/serviceWorker.ts @@ -0,0 +1,60 @@ +/// +declare const self: ServiceWorkerGlobalScope; + +const SHELL = 'shell-v1'; + +async function cacheWithoutVary (cache: Cache, url: string) +{ + const response = await fetch(url); + const cleaned = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: (() => + { + const h = new Headers(response.headers); + h.delete('Vary'); + return h; + })() + }); + await cache.put(url, cleaned); +} + +self.addEventListener('install', (event: ExtendableEvent) => +{ + event.waitUntil( + caches.open(SHELL).then(cache => cacheWithoutVary(cache, '/')) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event: ExtendableEvent) => +{ + // Clean up old caches when you bump SHELL version + event.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== SHELL).map(k => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event: FetchEvent) => +{ + if (event.request.mode !== 'navigate') return; + + event.respondWith( + fetch(event.request) + .then(response => + { + const vary = response.headers.get('Vary'); + if (!vary?.includes('*')) + { + caches.open(SHELL).then(cache => cache.put(event.request, response.clone())); + } + return response; + }) + .catch(() => + caches.match('/').then(cached => cached ?? Response.error()) + ) + ); +}); \ No newline at end of file diff --git a/src/mainview/scripts/shortcuts.ts b/src/mainview/scripts/shortcuts.ts index 0440a3c..5defdf7 100644 --- a/src/mainview/scripts/shortcuts.ts +++ b/src/mainview/scripts/shortcuts.ts @@ -3,7 +3,7 @@ import { GamepadButtonEvent } from "./gamepads"; import { dispatchFocusedEvent, GetFocusedTree } from "./spatialNavigation"; import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; -const shortcutMap = new Map(); +const shortcutMap = new Map Shortcut[])[]>(); const conflictSet = new Set(); let hadEnterDown = false; @@ -66,7 +66,8 @@ export function useShortcutContext () const focusKey = getCurrentFocusKey(); const newArray = GetFocusedTree(focusKey) .filter(f => shortcutMap.has(f)) - .flatMap(f => shortcutMap.get(f)!.map(s => ({ key: f, ...s }))) + .flatMap(f => shortcutMap.get(f)!.map(s => ({ key: f, handler: s }))) + .flatMap(kvp => kvp.handler().map(s => ({ key: kvp.key, ...s }))) .filter(s => { const empty = !conflictSet.has(s.button); @@ -193,12 +194,20 @@ export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps { useEffect(() => { - shortcutMap.set(focusKey, build()); + const array = shortcutMap.get(focusKey) ?? []; + array.push(build); + shortcutMap.set(focusKey, array); markDirtyThrottled(); return () => { - shortcutMap.delete(focusKey); + const array = shortcutMap.get(focusKey); + if (array) + { + const index = array.indexOf(build); + array?.splice(index, 1); + } + markDirtyThrottled(); }; }, [...deps, focusKey]); diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index 7ed4168..05f6d28 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -1,11 +1,8 @@ import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants"; import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; -import { Ref, RefObject, useEffect, useRef, useState } from "react"; +import { RefObject, useEffect, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; import { jobsApi } from "./clientApi"; -import { EdenWS } from "@elysiajs/eden/treaty"; -import { InputSchema } from "elysia/types"; -import { Treaty } from "@elysiajs/eden"; import { JobsAPIType } from "@/bun/api/rpc"; export function selfFocusSmart (shouldFocus: boolean, focusSelf: () => void) @@ -67,7 +64,7 @@ export function useScrollSave (data: ScrollSaveParams) export function mobileCheck () { let check = false; - (function (a) { if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) check = true; })(navigator.userAgent || navigator.vendor || window.opera); + (function (a) { if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) check = true; })(navigator.userAgent || navigator.vendor || (window as any).opera); return check; }; diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 504ca15..094f467 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -49,6 +49,8 @@ export const GameListFilterSchema = z.object({ source: z.string().optional(), }); +export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() }); + export type GameListFilterType = z.infer; export const DirSchema = z.object({ name: z.string(), parentPath: z.string(), isDirectory: z.boolean() }); diff --git a/src/tests/game-launching.test.ts b/src/tests/game-launching.test.ts index b8295e8..7618096 100644 --- a/src/tests/game-launching.test.ts +++ b/src/tests/game-launching.test.ts @@ -1,4 +1,4 @@ -import { expect, test, mock } from 'bun:test'; +import { expect, test } from 'bun:test'; test("uses custom emulator", async () => { diff --git a/tsconfig.json b/tsconfig.json index fe0da14..c39bbcd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,6 @@ "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, - "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "paths": { "@/*": [ diff --git a/vite.config.ts b/vite.config.ts index 7ab665c..e87e1c7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig, Plugin } from "vite"; +import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from '@tailwindcss/vite'; import { tanstackRouter } from '@tanstack/router-plugin/vite'; @@ -8,6 +8,7 @@ import staticAssetsPlugin from 'vite-static-assets-plugin'; import os from 'node:os'; import tsconfigPaths from 'vite-tsconfig-paths'; import { host } from "./src/bun/utils/host"; +import { VitePWA } from 'vite-plugin-pwa'; export default defineConfig(({ command }) => { @@ -59,21 +60,22 @@ export default defineConfig(({ command }) => manualChunks: (id ) => { - if (id.includes('@emulatorjs')) - { - return 'emulatorjs'; - } - if (id - .includes - ('node_modules')) - { - return 'vendor'; - } + if (id.includes('@emulatorjs')) + return 'emulatorjs'; + if (id.includes('clients/romm')) + return 'clients'; + if (id.includes('node_modules/lucide-react')) + return 'lucide'; + if (id.includes('node_modules/zod')) + return 'zod'; + if (id.includes('node_modules/@tanstack')) + return 'tanstack'; + console.log(id); + if (id.includes('node_modules')) + return 'vendor'; if (id.endsWith('SvgIcon.tsx')) - { return 'icons'; - } return null; }, From 3750e9ed8fc1c0919aade9e45a0189838f12b16d Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 22 Mar 2026 01:11:21 +0200 Subject: [PATCH 11/65] feat: Implemented emulator installation feat: Updated romm API version feat: Updated es-de rules feat: Added tabs to game details refactor: returned to global query definitions to help with typescript performance --- bun.lock | 5 + package.json | 7 +- scripts/dev.ts | 7 +- scripts/generate-es-de-mapping.ts | 6 +- scripts/romm/openapi.json | 2 +- src/bun/api/app.ts | 3 + src/bun/api/cache.ts | 11 + src/bun/api/games/games.ts | 469 ++++++--- src/bun/api/games/platforms.ts | 26 +- .../api/games/services/launchGameService.ts | 275 ++++-- src/bun/api/games/services/statusService.ts | 147 +-- src/bun/api/games/services/utils.ts | 133 ++- src/bun/api/jobs/emulator-download-job.ts | 105 ++ src/bun/api/jobs/install-job.ts | 280 +++--- src/bun/api/jobs/jobs.ts | 52 +- src/bun/api/jobs/login-job.ts | 6 +- src/bun/api/jobs/twitch-login-job.ts | 6 +- src/bun/api/jobs/update-store.ts | 10 +- src/bun/api/schema/emulators.ts | 1 + src/bun/api/settings/services.ts | 41 +- .../api/store/services/emulatorsService.ts | 31 + src/bun/api/store/services/gamesService.ts | 70 +- src/bun/api/store/store.ts | 167 ++-- src/bun/api/system.ts | 2 +- src/bun/api/task-queue.ts | 89 +- src/bun/index.ts | 6 +- src/bun/server.ts | 38 +- src/bun/types/types.d.ts | 2 +- src/bun/utils.ts | 44 +- src/bun/utils/downloader.ts | 222 +++++ src/clients/romm/@tanstack/react-query.gen.ts | 522 +++++++++- src/clients/romm/index.ts | 4 +- src/clients/romm/sdk.gen.ts | 321 ++++++- src/clients/romm/types.gen.ts | 900 +++++++++++++++++- src/mainview/components/CardElement.tsx | 5 +- src/mainview/components/CollectionList.tsx | 11 +- src/mainview/components/CollectionsDetail.tsx | 4 +- src/mainview/components/ContextDialog.tsx | 58 +- src/mainview/components/FilePicker.tsx | 8 +- src/mainview/components/Filters.tsx | 66 +- src/mainview/components/FocusDots.tsx | 3 +- src/mainview/components/FrontEndGameCard.tsx | 34 +- src/mainview/components/GameList.tsx | 11 +- src/mainview/components/Header.tsx | 32 +- src/mainview/components/LoadMoreButton.tsx | 3 +- src/mainview/components/Notifications.tsx | 9 +- src/mainview/components/PlatformsList.tsx | 4 +- src/mainview/components/Screenshots.tsx | 78 +- src/mainview/components/StatList.tsx | 50 + src/mainview/components/game/Achievements.tsx | 35 + .../options/DownloadDirectoryOption.tsx | 4 +- .../components/options/PathSettingsOption.tsx | 6 +- .../components/options/SettingsOption.tsx | 6 +- .../components/store/EmulatorsSection.tsx | 2 +- .../components/store/GamesSection.tsx | 44 +- .../components/store/StatsSection.tsx | 5 +- .../components/store/StoreEmulatorCard.tsx | 26 +- src/mainview/emulatorjs/emulator.ts | 1 + src/mainview/gen/static-icon-assets.gen.ts | 2 +- src/mainview/index.css | 23 + src/mainview/index.tsx | 37 +- src/mainview/routes/collection.$id.tsx | 4 +- src/mainview/routes/embedded.$source.$id.tsx | 6 +- src/mainview/routes/game/$source.$id.tsx | 464 ++++++--- src/mainview/routes/index.tsx | 47 +- src/mainview/routes/launcher.$source.$id.tsx | 6 +- src/mainview/routes/platform.$source.$id.tsx | 4 +- src/mainview/routes/settings/about.tsx | 5 +- src/mainview/routes/settings/accounts.tsx | 25 +- src/mainview/routes/settings/directories.tsx | 6 +- src/mainview/routes/settings/emulators.tsx | 16 +- src/mainview/routes/settings/route.tsx | 34 +- .../routes/store/details.emulator.$id.tsx | 299 ++++-- src/mainview/routes/store/tab/emulators.tsx | 4 +- src/mainview/routes/store/tab/games.tsx | 6 +- src/mainview/routes/store/tab/index.tsx | 32 +- src/mainview/routes/store/tab/route.tsx | 77 +- src/mainview/scripts/brandIcons.tsx | 4 +- src/mainview/scripts/contexts.ts | 4 + src/mainview/scripts/queries.ts | 11 - src/mainview/scripts/queries/romm.ts | 186 ++-- src/mainview/scripts/queries/settings.ts | 248 +++-- src/mainview/scripts/queries/store.ts | 126 +-- src/mainview/scripts/queries/system.ts | 92 +- src/mainview/scripts/spatialNavigation.ts | 42 +- src/mainview/scripts/types.ts | 4 +- src/mainview/scripts/utils.ts | 42 +- src/mainview/types.d.ts | 1 + src/shared/constants.ts | 76 +- tsconfig.json | 3 + vendors/es-de/emulators.darwin.x64.sqlite | Bin 176128 -> 180224 bytes vendors/es-de/emulators.haiku.x64.sqlite | Bin 135168 -> 135168 bytes vendors/es-de/emulators.linux.arm.sqlite | Bin 180224 -> 184320 bytes vendors/es-de/emulators.linux.x64.sqlite | Bin 217088 -> 221184 bytes vendors/es-de/emulators.win32.x64.sqlite | Bin 208896 -> 212992 bytes .../es-de/systems/android/es_find_rules.xml | 25 + vendors/es-de/systems/android/es_systems.xml | 19 +- vendors/es-de/systems/linux/es_systems.xml | 4 +- vendors/es-de/systems/linuxarm/es_systems.xml | 2 +- vendors/es-de/systems/macos/es_systems.xml | 5 +- vendors/es-de/systems/unix/es_systems.xml | 4 +- vendors/es-de/systems/windows/es_systems.xml | 6 +- vite.config.ts | 4 +- 103 files changed, 4888 insertions(+), 1632 deletions(-) create mode 100644 src/bun/api/jobs/emulator-download-job.ts create mode 100644 src/bun/api/store/services/emulatorsService.ts create mode 100644 src/bun/utils/downloader.ts create mode 100644 src/mainview/components/StatList.tsx create mode 100644 src/mainview/components/game/Achievements.tsx delete mode 100644 src/mainview/scripts/queries.ts diff --git a/bun.lock b/bun.lock index 9bf51a2..e4c0aff 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "electrobun-hello-world", "dependencies": { + "7zip-min": "^3.0.1", "@auth/core": "^0.34.3", "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", @@ -83,6 +84,10 @@ }, }, "packages": { + "7zip-bin": ["7zip-bin@5.1.1", "", {}, "sha512-sAP4LldeWNz0lNzmTird3uWfFDWWTeg6V/MsmyyLR9X1idwKBWIgt/ZvinqQldJm3LecKEs1emkbquO6PCiLVQ=="], + + "7zip-min": ["7zip-min@3.0.1", "", { "dependencies": { "7zip-bin": "5.1.1" } }, "sha512-WB4VCA/KSKzxhj+BAp8fI3ZYMMAftclkXlUTckuiDacsqyubQxxG3lGcpBcgzWWuJqnfQncEq1xrJpPLSxqsxw=="], + "@ap0nia/eden": ["@ap0nia/eden@1.0.0-next.22", "", { "peerDependencies": { "elysia": "^1.3.1" } }, "sha512-9iH09koK29Yuem80fz8nCt9iHVcJqxUo2QHAr4psI02PhvL70n6aWVo/hlHyYXwOSsSgRQlLl1vPmiulFOUFoA=="], "@ap0nia/eden-tanstack-query": ["@ap0nia/eden-tanstack-query@1.0.0-next.22", "", {}, "sha512-eSQ98G4TYzrAdsfRekrvqIrTqrAUFy+YpibZ5fj5KL6/R6FcrS2U2F51iML98baXT4MTpOJARY9p+7x0hiA8Qw=="], diff --git a/package.json b/package.json index 21b34f8..a0070eb 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "packageManager": "bun@1.3.9", "type": "module", "scripts": { - "dev": "NODE_ENV=development bun run build:vite && 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'", - "build:vite": "vite build", + "build:vite": "bun run --bun vite build", "build:prod:vite": "NODE_ENV=production bun run build:vite", "build:dev:vite": "NODE_ENV=development bun run build:vite", "build": "bun run build:vite && bun run ./scripts/package-bun.ts", @@ -24,7 +24,7 @@ "build:linux": "TARGET=bun-linux-x64 bun run build", "openapi-ts": "bun run ./scripts/romm/openapi-ts.ts", "run:build-action": "act workflow_dispatch --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .forgejo/workflows/build.yml", - "hmr": "vite --port 5173", + "hmr": "bun run --bun vite --port 5173", "drizzle:generate": "bunx drizzle-kit generate", "test": "bun test", "mappings:generate": "bun run drizzle-kit generate --dialect=sqlite --schema=./src/bun/api/schema/emulators.ts --out=./scripts/drizzle/es-de && bun run ./scripts/generate-es-de-mapping.ts", @@ -40,6 +40,7 @@ "package:Windows": "bun run build:prod" }, "dependencies": { + "7zip-min": "^3.0.1", "@auth/core": "^0.34.3", "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", diff --git a/scripts/dev.ts b/scripts/dev.ts index 96ac9f2..436c615 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -1,4 +1,3 @@ -// watcher.ts - run this instead of --watch import EventEmitter from "events"; import browser from '../src/bun/browser'; import { tmpdir } from "os"; @@ -13,9 +12,9 @@ let retries = 0; function spawnServer () { - return Bun.spawn(["bun", "run", '--watch', "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], { + return Bun.spawn(["bun", '--watch', '--install=fallback', '--smol', "run", "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], { env: { - ...Bun.env, + ...process.env, HEADLESS: "true", }, stdout: "inherit", @@ -50,7 +49,7 @@ function spawnBrowser () try { - return browser(events, Bun.env.FORCE_BROWSER === "true", { configPath: path.join(tmpdir(), 'gameflow') }); + return browser(events, process.env.FORCE_BROWSER === "true", { configPath: path.join(tmpdir(), 'gameflow') }); } catch (error) { console.error(error); diff --git a/scripts/generate-es-de-mapping.ts b/scripts/generate-es-de-mapping.ts index 3c0aa10..115c19f 100644 --- a/scripts/generate-es-de-mapping.ts +++ b/scripts/generate-es-de-mapping.ts @@ -6,8 +6,6 @@ import { Database } from "bun:sqlite"; import * as schema from '../src/bun/api/schema/emulators'; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { drizzle } from "drizzle-orm/bun-sqlite"; -import path from 'node:path'; -import { ensureDir } from 'fs-extra'; /** get all latest supported romm platforms */ const rommPlatforms = await getSupportedPlatformsEndpointApiPlatformsSupportedGet({ baseUrl: "https://demo.romm.app" }); @@ -57,6 +55,7 @@ await Promise.all(platforms.map(async ([platform, arch]) => const emulators = $r('ruleList emulator').toArray().map(s => { const $emulator = $r(s); + const comment = $emulator.contents().toArray().find((node) => node.type === 'comment'); const $systempath = $emulator.find('rule[type=systempath] entry'); const $staticpath = $emulator.find('rule[type=staticpath] entry'); const $corepath = $emulator.find('rule[type=corepath] entry'); @@ -66,12 +65,14 @@ await Promise.all(platforms.map(async ([platform, arch]) => const emulatorName = $emulator.attr('name'); const emulator: typeof schema.emulators.$inferInsert = { name: emulatorName!, + fullname: comment?.data.trim(), systempath: $systempath.toArray().map(p => $r(p).text()), staticpath: $staticpath.toArray().map(p => $r(p).text()), corepath: $corepath.toArray().map(p => $r(p).text()), androidpackage: $androidpackage.toArray().map(p => $r(p).text()), winregistrypath: $winregistrypath.toArray().map(p => $r(p).text()), }; + return emulator; }); @@ -143,6 +144,7 @@ await Promise.all(platforms.map(async ([platform, arch]) => commands, mappings }; + return system; })); diff --git a/scripts/romm/openapi.json b/scripts/romm/openapi.json index 86d3921..4214bca 100644 --- a/scripts/romm/openapi.json +++ b/scripts/romm/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"RomM API","version":"4.6.1"},"paths":{"/api/heartbeat":{"get":{"tags":["system"],"summary":"Heartbeat","description":"Endpoint to set the CSRF token in cache and return all the basic RomM config\n\nReturns:\n HeartbeatReturn: TypedDict structure with all the defined values in the HeartbeatReturn class.","operationId":"heartbeat_api_heartbeat_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HeartbeatResponse"}}}}}}},"/api/heartbeat/metadata/{source}":{"get":{"tags":["system"],"summary":"Metadata Heartbeat","description":"Endpoint to return the heartbeat of the metadata sources","operationId":"metadata_heartbeat_api_heartbeat_metadata__source__get","parameters":[{"name":"source","in":"path","required":true,"schema":{"type":"string","title":"Source"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"boolean","title":"Response Metadata Heartbeat Api Heartbeat Metadata Source Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/setup/library":{"get":{"tags":["system"],"summary":"Get Setup Library Info","description":"Get library structure information for setup wizard.\n\nOnly accessible during initial setup (no admin users) or with authentication.\n\nReturns:\n - detected_structure: \"struct_a\" (roms/{platform}), \"struct_b\" ({platform}/roms), or None\n - existing_platforms: list of objects with fs_slug and rom_count\n - supported_platforms: list of all supported platforms with metadata","operationId":"get_setup_library_info_api_setup_library_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]},{"HTTPBasic":[]}]}},"/api/setup/platforms":{"post":{"tags":["system"],"summary":"Create Setup Platforms","description":"Create platform folders during setup wizard.\n\nOnly accessible during initial setup (no admin users) or with authentication.\n\nArgs:\n platform_slugs: List of platform fs_slugs to create\n\nReturns:\n - success: bool\n - created_count: number of platforms created\n - message: success or error message","operationId":"create_setup_platforms_api_setup_platforms_post","requestBody":{"content":{"application/json":{"schema":{"items":{"type":"string"},"type":"array","title":"Platform Slugs"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]},{"HTTPBasic":[]}]}},"/api/login":{"post":{"tags":["auth"],"summary":"Login","description":"Session login endpoint\n\nArgs:\n request (Request): Fastapi Request object\n credentials: Defaults to Depends(HTTPBasic()).\n\nRaises:\n CredentialsException: Invalid credentials\n UserDisabledException: Auth is disabled","operationId":"login_api_login_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"HTTPBasic":[]}]}},"/api/logout":{"post":{"tags":["auth"],"summary":"Logout","description":"Session logout endpoint\n\nArgs:\n request (Request): Fastapi Request object","operationId":"logout_api_logout_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/token":{"post":{"tags":["auth"],"summary":"Token","description":"OAuth2 token endpoint\n\nArgs:\n form_data (Annotated[OAuth2RequestForm, Depends): Form Data with OAuth2 info\n\nRaises:\n HTTPException: Missing refresh token\n HTTPException: Invalid refresh token\n HTTPException: Missing username or password\n HTTPException: Invalid username or password\n HTTPException: Client credentials are not yet supported\n HTTPException: Invalid or unsupported grant type\n HTTPException: Insufficient scope\n\nReturns:\n TokenResponse: TypedDict with the new generated token info","operationId":"token_api_token_post","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Body_token_api_token_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/login/openid":{"get":{"tags":["auth"],"summary":"Login Via Openid","description":"OIDC login endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nRaises:\n OIDCDisabledException: OAuth is disabled\n OIDCNotConfiguredException: OAuth not configured\n\nReturns:\n RedirectResponse: Redirect to OIDC provider","operationId":"login_via_openid_api_login_openid_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/oauth/openid":{"get":{"tags":["auth"],"summary":"Auth Openid","description":"OIDC callback endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nRaises:\n OIDCDisabledException: OAuth is disabled\n OIDCNotConfiguredException: OAuth not configured\n AuthCredentialsException: Invalid credentials\n UserDisabledException: Auth is disabled\n\nReturns:\n RedirectResponse: Redirect to home page","operationId":"auth_openid_api_oauth_openid_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/forgot-password":{"post":{"tags":["auth"],"summary":"Request Password Reset","description":"Request a password reset link for the user.\n\nArgs:\n username (str): Username of the user requesting the reset\nReturns:\n None: Returns 200 OK status","operationId":"request_password_reset_api_forgot_password_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_request_password_reset_api_forgot_password_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/reset-password":{"post":{"tags":["auth"],"summary":"Reset Password","description":"Reset password using the token.\n\nArgs:\n token (str): Reset token from the URL\n new_password (str): New user password\n\nReturns:\n None: Returns 200 OK status","operationId":"reset_password_api_reset_password_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_reset_password_api_reset_password_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/users":{"get":{"tags":["users"],"summary":"Get Users","description":"Get all users endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n list[UserSchema]: All users stored in the RomM's database","operationId":"get_users_api_users_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/UserSchema"},"type":"array","title":"Response Get Users Api Users Get"}}}}},"security":[{"OAuth2PasswordBearer":["users.read"]},{"HTTPBasic":[]}]},"post":{"tags":["users"],"summary":"Add User","description":"Create user endpoint\n\nArgs:\n request (Request): Fastapi Requests object\n username (str): User username\n password (str): User password\n email (str): User email\n role (str): RomM Role object represented as string\n\nReturns:\n UserSchema: Newly created user","operationId":"add_user_api_users_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_add_user_api_users_post"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]},{"HTTPBasic":[]}]}},"/api/users/invite-link":{"post":{"tags":["users"],"summary":"Create Invite Link","description":"Create an invite link for a user.\n\nArgs:\n request (Request): FastAPI Request object\n role (str): The role of the user\n\nReturns:\n InviteLinkSchema: Invite link","operationId":"create_invite_link_api_users_invite_link_post","security":[{"OAuth2PasswordBearer":[]},{"HTTPBasic":[]}],"parameters":[{"name":"role","in":"query","required":true,"schema":{"type":"string","title":"Role"}}],"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteLinkSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/users/register":{"post":{"tags":["users"],"summary":"Create User From Invite","description":"Create user endpoint with invite link\n\nArgs:\n username (str): User username\n email (str): User email\n password (str): User password\n token (str): Invite link token\n\nReturns:\n UserSchema: Newly created user","operationId":"create_user_from_invite_api_users_register_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_create_user_from_invite_api_users_register_post"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/users/me":{"get":{"tags":["users"],"summary":"Get Current User","description":"Get current user endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n UserSchema | None: Current user","operationId":"get_current_user_api_users_me_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/UserSchema"},{"type":"null"}],"title":"Response Get Current User Api Users Me Get"}}}}},"security":[{"OAuth2PasswordBearer":["me.read"]},{"HTTPBasic":[]}]}},"/api/users/{id}":{"get":{"tags":["users"],"summary":"Get User","description":"Get user endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n UserSchem: User stored in the RomM's database","operationId":"get_user_api_users__id__get","security":[{"OAuth2PasswordBearer":["users.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["users"],"summary":"Update User","description":"Update user endpoint\n\nArgs:\n request (Request): Fastapi Requests object\n user_id (int): User internal id\n form_data (Annotated[UserUpdateForm, Depends): Form Data with user updated info\n\nRaises:\n HTTPException: User is not found in database\n HTTPException: Username already in use by another user\n\nReturns:\n UserSchema: Updated user info","operationId":"update_user_api_users__id__put","security":[{"OAuth2PasswordBearer":["me.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/UserForm"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["users"],"summary":"Delete User","description":"Delete a user by ID.\n\nRaises:\n HTTPException: User is not found in database\n HTTPException: User deleting itself\n HTTPException: User is the last admin user","operationId":"delete_user_api_users__id__delete","security":[{"OAuth2PasswordBearer":["users.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"User internal id.","title":"Id"},"description":"User internal id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/users/{id}/ra/refresh":{"post":{"tags":["users"],"summary":"Refresh RetroAchievements","description":"Refresh RetroAchievements progression data for a user.","operationId":"refresh_retro_achievements_api_users__id__ra_refresh_post","security":[{"OAuth2PasswordBearer":["me.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"User internal id.","title":"Id"},"description":"User internal id."}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_refresh_retro_achievements_api_users__id__ra_refresh_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/platforms":{"post":{"tags":["platforms"],"summary":"Add Platform","description":"Create a platform.","operationId":"add_platform_api_platforms_post","security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_add_platform_api_platforms_post"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["platforms"],"summary":"Get Platforms","description":"Retrieve platforms.","operationId":"get_platforms_api_platforms_get","security":[{"OAuth2PasswordBearer":["platforms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"updated_after","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter platforms updated after this datetime (ISO 8601 format with timezone information).","title":"Updated After"},"description":"Filter platforms updated after this datetime (ISO 8601 format with timezone information)."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PlatformSchema"},"title":"Response Get Platforms Api Platforms Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/platforms/supported":{"get":{"tags":["platforms"],"summary":"Get Supported Platforms Endpoint","description":"Retrieve the list of supported platforms.","operationId":"get_supported_platforms_endpoint_api_platforms_supported_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/PlatformSchema"},"type":"array","title":"Response Get Supported Platforms Endpoint Api Platforms Supported Get"}}}}},"security":[{"OAuth2PasswordBearer":["platforms.read"]},{"HTTPBasic":[]}]}},"/api/platforms/{id}":{"get":{"tags":["platforms"],"summary":"Get Platform","description":"Retrieve a platform by ID.","operationId":"get_platform_api_platforms__id__get","security":[{"OAuth2PasswordBearer":["platforms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Platform id.","title":"Id"},"description":"Platform id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformSchema"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["platforms"],"summary":"Update Platform","description":"Update a platform.","operationId":"update_platform_api_platforms__id__put","security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Platform id.","title":"Id"},"description":"Platform id."}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_update_platform_api_platforms__id__put"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformSchema"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["platforms"],"summary":"Delete Platform","description":"Delete a platform by ID.","operationId":"delete_platform_api_platforms__id__delete","security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Platform id.","title":"Id"},"description":"Platform id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms":{"post":{"tags":["roms"],"summary":"Add Rom","description":"Upload a single rom.","operationId":"add_rom_api_roms_post","security":[{"OAuth2PasswordBearer":["roms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"x-upload-platform","in":"header","required":true,"schema":{"type":"integer","minimum":1,"description":"Platform internal id.","title":"X-Upload-Platform"},"description":"Platform internal id."},{"name":"x-upload-filename","in":"header","required":true,"schema":{"type":"string","description":"The name of the file being uploaded.","title":"X-Upload-Filename"},"description":"The name of the file being uploaded."}],"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["roms"],"summary":"Get Roms","description":"Retrieve roms.","operationId":"get_roms_api_roms_get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"with_char_index","in":"query","required":false,"schema":{"type":"boolean","description":"Whether to get the char index.","default":true,"title":"With Char Index"},"description":"Whether to get the char index."},{"name":"with_filter_values","in":"query","required":false,"schema":{"type":"boolean","description":"Whether to return filter values.","default":true,"title":"With Filter Values"},"description":"Whether to return filter values."},{"name":"search_term","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Search term to filter roms.","title":"Search Term"},"description":"Search term to filter roms."},{"name":"platform_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"integer"}},{"type":"null"}],"description":"Platform internal ids. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Platform Ids"},"description":"Platform internal ids. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"collection_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","minimum":1},{"type":"null"}],"description":"Collection internal id.","title":"Collection Id"},"description":"Collection internal id."},{"name":"virtual_collection_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Virtual collection internal id.","title":"Virtual Collection Id"},"description":"Virtual collection internal id."},{"name":"smart_collection_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","minimum":1},{"type":"null"}],"description":"Smart collection internal id.","title":"Smart Collection Id"},"description":"Smart collection internal id."},{"name":"matched","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Whether the rom matched at least one metadata source.","title":"Matched"},"description":"Whether the rom matched at least one metadata source."},{"name":"favorite","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Whether the rom is marked as favorite.","title":"Favorite"},"description":"Whether the rom is marked as favorite."},{"name":"duplicate","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Whether the rom is marked as duplicate.","title":"Duplicate"},"description":"Whether the rom is marked as duplicate."},{"name":"last_played","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Whether the rom has a last played value for the current user.","title":"Last Played"},"description":"Whether the rom has a last played value for the current user."},{"name":"playable","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Whether the rom is playable from the browser.","title":"Playable"},"description":"Whether the rom is playable from the browser."},{"name":"missing","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Whether the rom is missing from the filesystem.","title":"Missing"},"description":"Whether the rom is missing from the filesystem."},{"name":"has_ra","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Whether the rom has RetroAchievements data.","title":"Has Ra"},"description":"Whether the rom has RetroAchievements data."},{"name":"verified","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Whether the rom is verified by Hasheous.","title":"Verified"},"description":"Whether the rom is verified by Hasheous."},{"name":"group_by_meta_id","in":"query","required":false,"schema":{"type":"boolean","description":"Whether to group roms by metadata ID (IGDB / Moby / ScreenScraper / RetroAchievements / LaunchBox).","default":false,"title":"Group By Meta Id"},"description":"Whether to group roms by metadata ID (IGDB / Moby / ScreenScraper / RetroAchievements / LaunchBox)."},{"name":"genres","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Associated genre. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Genres"},"description":"Associated genre. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"franchises","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Associated franchise. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Franchises"},"description":"Associated franchise. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"collections","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Associated collection. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Collections"},"description":"Associated collection. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"companies","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Associated company. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Companies"},"description":"Associated company. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"age_ratings","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Associated age rating. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Age Ratings"},"description":"Associated age rating. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"statuses","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Game status, set by the current user. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Statuses"},"description":"Game status, set by the current user. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"regions","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Associated region tag. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Regions"},"description":"Associated region tag. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"languages","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Associated language tag. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Languages"},"description":"Associated language tag. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"player_counts","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Associated player count. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Player Counts"},"description":"Associated player count. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"genres_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for genres filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Genres Logic"},"description":"Logic operator for genres filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"franchises_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for franchises filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Franchises Logic"},"description":"Logic operator for franchises filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"collections_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for collections filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Collections Logic"},"description":"Logic operator for collections filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"companies_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for companies filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Companies Logic"},"description":"Logic operator for companies filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"age_ratings_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for age ratings filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Age Ratings Logic"},"description":"Logic operator for age ratings filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"regions_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for regions filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Regions Logic"},"description":"Logic operator for regions filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"languages_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for languages filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Languages Logic"},"description":"Logic operator for languages filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"statuses_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for statuses filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Statuses Logic"},"description":"Logic operator for statuses filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"player_counts_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for player counts filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Player Counts Logic"},"description":"Logic operator for player counts filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"order_by","in":"query","required":false,"schema":{"type":"string","description":"Field to order results by.","default":"name","title":"Order By"},"description":"Field to order results by."},{"name":"order_dir","in":"query","required":false,"schema":{"type":"string","description":"Order direction, either 'asc' or 'desc'.","default":"asc","title":"Order Dir"},"description":"Order direction, either 'asc' or 'desc'."},{"name":"updated_after","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter roms updated after this datetime (ISO 8601 format with timezone information).","title":"Updated After"},"description":"Filter roms updated after this datetime (ISO 8601 format with timezone information)."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":10000,"minimum":1,"description":"Page size limit","default":50,"title":"Limit"},"description":"Page size limit"},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Page offset","default":0,"title":"Offset"},"description":"Page offset"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomLimitOffsetPage_SimpleRomSchema_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/download":{"get":{"tags":["roms"],"summary":"Download Roms","description":"Download a list of roms as a zip file.","operationId":"download_roms_api_roms_download_get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"rom_ids","in":"query","required":true,"schema":{"type":"string","description":"Comma-separated list of ROM IDs to download as a zip file.","title":"Rom Ids"},"description":"Comma-separated list of ROM IDs to download as a zip file."},{"name":"filename","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Name for the zip file (optional).","title":"Filename"},"description":"Name for the zip file (optional)."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/by-metadata-provider":{"get":{"tags":["roms"],"summary":"Get Rom By Metadata Provider","description":"Retrieve a rom by metadata ID.","operationId":"get_rom_by_metadata_provider_api_roms_by_metadata_provider_get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"igdb_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"IGDB ID to search by","title":"Igdb Id"},"description":"IGDB ID to search by"},{"name":"moby_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"MobyGames ID to search by","title":"Moby Id"},"description":"MobyGames ID to search by"},{"name":"ss_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"ScreenScraper ID to search by","title":"Ss Id"},"description":"ScreenScraper ID to search by"},{"name":"ra_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"RetroAchievements ID to search by","title":"Ra Id"},"description":"RetroAchievements ID to search by"},{"name":"launchbox_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"LaunchBox ID to search by","title":"Launchbox Id"},"description":"LaunchBox ID to search by"},{"name":"hasheous_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Hasheous ID to search by","title":"Hasheous Id"},"description":"Hasheous ID to search by"},{"name":"tgdb_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"TGDB ID to search by","title":"Tgdb Id"},"description":"TGDB ID to search by"},{"name":"flashpoint_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Flashpoint ID to search by","title":"Flashpoint Id"},"description":"Flashpoint ID to search by"},{"name":"hltb_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"HLTB ID to search by","title":"Hltb Id"},"description":"HLTB ID to search by"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DetailedRomSchema"}}}},"404":{"description":"Not Found"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/by-hash":{"get":{"tags":["roms"],"summary":"Get Rom By Hash","operationId":"get_rom_by_hash_api_roms_by_hash_get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"crc_hash","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"CRC hash value","title":"Crc Hash"},"description":"CRC hash value"},{"name":"md5_hash","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"MD5 hash value","title":"Md5 Hash"},"description":"MD5 hash value"},{"name":"sha1_hash","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"SHA1 hash value","title":"Sha1 Hash"},"description":"SHA1 hash value"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DetailedRomSchema"}}}},"404":{"description":"Not Found"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/filters":{"get":{"tags":["roms"],"summary":"Get Rom Filters","operationId":"get_rom_filters_api_roms_filters_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RomFiltersDict"}}}}},"security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}]}},"/api/roms/{id}":{"get":{"tags":["roms"],"summary":"Get Rom","description":"Retrieve a rom by ID.","operationId":"get_rom_api_roms__id__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DetailedRomSchema"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["roms"],"summary":"Update Rom","description":"Update a rom.","operationId":"update_rom_api_roms__id__put","security":[{"OAuth2PasswordBearer":["roms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."},{"name":"remove_cover","in":"query","required":false,"schema":{"type":"boolean","description":"Whether to remove the cover image for this rom.","default":false,"title":"Remove Cover"},"description":"Whether to remove the cover image for this rom."},{"name":"unmatch_metadata","in":"query","required":false,"schema":{"type":"boolean","description":"Whether to remove the metadata matches for this game.","default":false,"title":"Unmatch Metadata"},"description":"Whether to remove the metadata matches for this game."}],"requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_update_rom_api_roms__id__put"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DetailedRomSchema"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/{id}/content/{file_name}":{"head":{"tags":["roms"],"summary":"Head Rom Content","description":"Retrieve head information for a rom file download.","operationId":"head_rom_content_api_roms__id__content__file_name__head","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."},{"name":"file_name","in":"path","required":true,"schema":{"type":"string","description":"File name to download","title":"File Name"},"description":"File name to download"},{"name":"file_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Comma-separated list of file ids to download for multi-part roms.","title":"File Ids"},"description":"Comma-separated list of file ids to download for multi-part roms."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["roms"],"summary":"Get Rom Content","description":"Download a rom.\n\nThis endpoint serves the content of the requested rom, as:\n- A single file for single file roms.\n- A zipped file for multi-part roms, including a .m3u file if applicable.","operationId":"get_rom_content_api_roms__id__content__file_name__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."},{"name":"file_name","in":"path","required":true,"schema":{"type":"string","description":"Zip file output name","title":"File Name"},"description":"Zip file output name"},{"name":"file_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Comma-separated list of file ids to download for multi-part roms.","title":"File Ids"},"description":"Comma-separated list of file ids to download for multi-part roms."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/{id}/manuals":{"post":{"tags":["roms"],"summary":"Add Rom Manuals","description":"Upload manuals for a rom.","operationId":"add_rom_manuals_api_roms__id__manuals_post","security":[{"OAuth2PasswordBearer":["roms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."},{"name":"x-upload-filename","in":"header","required":true,"schema":{"type":"string","description":"The name of the file being uploaded.","title":"X-Upload-Filename"},"description":"The name of the file being uploaded."}],"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["roms"],"summary":"Delete Rom Manuals","description":"Delete manuals for a rom.","operationId":"delete_rom_manuals_api_roms__id__manuals_delete","security":[{"OAuth2PasswordBearer":["roms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/delete":{"post":{"tags":["roms"],"summary":"Delete Roms","description":"Delete roms.","operationId":"delete_roms_api_roms_delete_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_delete_roms_api_roms_delete_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkOperationResponse"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":["roms.write"]},{"HTTPBasic":[]}]}},"/api/roms/{id}/props":{"put":{"tags":["roms"],"summary":"Update Rom User","description":"Update rom data associated to the current user.","operationId":"update_rom_user_api_roms__id__props_put","security":[{"OAuth2PasswordBearer":["roms.user.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_update_rom_user_api_roms__id__props_put"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RomUserSchema"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/files/{id}":{"get":{"tags":["roms"],"summary":"Get Romfile","description":"Retrieve a rom file by ID.","operationId":"get_romfile_api_roms_files__id__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom file internal id.","title":"Id"},"description":"Rom file internal id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RomFileSchema"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/romsfiles/{id}/content/{file_name}":{"get":{"tags":["roms"],"summary":"Get Romfile Content","description":"Download a rom file.","operationId":"get_romfile_content_api_romsfiles__id__content__file_name__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom file internal id.","title":"Id"},"description":"Rom file internal id."},{"name":"file_name","in":"path","required":true,"schema":{"type":"string","description":"File name to download","title":"File Name"},"description":"File name to download"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/{id}/notes":{"get":{"tags":["roms"],"summary":"Get Rom Notes","description":"Get all notes for a ROM.","operationId":"get_rom_notes_api_roms__id__notes_get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."},{"name":"public_only","in":"query","required":false,"schema":{"type":"boolean","description":"Only return public notes","default":false,"title":"Public Only"},"description":"Only return public notes"},{"name":"search","in":"query","required":false,"schema":{"type":"string","description":"Search notes by title or content","title":"Search"},"description":"Search notes by title or content"},{"name":"tags","in":"query","required":false,"schema":{"type":"array","items":{"type":"string"},"description":"Filter by tags","title":"Tags"},"description":"Filter by tags"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserNoteSchema"},"title":"Response Get Rom Notes Api Roms Id Notes Get"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["roms"],"summary":"Create Rom Note","description":"Create a new note for a ROM.","operationId":"create_rom_note_api_roms__id__notes_post","security":[{"OAuth2PasswordBearer":["roms.user.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Note Data"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserNoteSchema"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/{id}/notes/{note_id}":{"put":{"tags":["roms"],"summary":"Update Rom Note","description":"Update a ROM note.","operationId":"update_rom_note_api_roms__id__notes__note_id__put","security":[{"OAuth2PasswordBearer":["roms.user.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."},{"name":"note_id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Note id.","title":"Note Id"},"description":"Note id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Note Data"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserNoteSchema"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["roms"],"summary":"Delete Rom Note","description":"Delete a ROM note.","operationId":"delete_rom_note_api_roms__id__notes__note_id__delete","security":[{"OAuth2PasswordBearer":["roms.user.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."},{"name":"note_id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Note id.","title":"Note Id"},"description":"Note id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Rom Note Api Roms Id Notes Note Id Delete"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/search/roms":{"get":{"tags":["search"],"summary":"Search Rom","description":"Search for rom in metadata providers\n\nArgs:\n request (Request): FastAPI request\n rom_id (int): Rom ID\n source (str): Source of the rom\n search_term (str, optional): Search term. Defaults to None.\n search_by (str, optional): Search by name or ID. Defaults to \"name\".\n search_extended (bool, optional): Search extended info. Defaults to False.\n\nReturns:\n list[SearchRomSchema]: List of matched roms","operationId":"search_rom_api_search_roms_get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"rom_id","in":"query","required":true,"schema":{"type":"integer","title":"Rom Id"}},{"name":"search_term","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Search Term"}},{"name":"search_by","in":"query","required":false,"schema":{"type":"string","default":"name","title":"Search By"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchRomSchema"},"title":"Response Search Rom Api Search Roms Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/search/cover":{"get":{"tags":["search"],"summary":"Search Cover","operationId":"search_cover_api_search_cover_get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"search_term","in":"query","required":false,"schema":{"type":"string","default":"","title":"Search Term"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchCoverSchema"},"title":"Response Search Cover Api Search Cover Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/saves":{"post":{"tags":["saves"],"summary":"Add Save","operationId":"add_save_api_saves_post","security":[{"OAuth2PasswordBearer":["assets.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"rom_id","in":"query","required":true,"schema":{"type":"integer","title":"Rom Id"}},{"name":"emulator","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Emulator"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SaveSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["saves"],"summary":"Get Saves","operationId":"get_saves_api_saves_get","security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"rom_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Rom Id"}},{"name":"platform_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Platform Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SaveSchema"},"title":"Response Get Saves Api Saves Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/saves/{id}":{"get":{"tags":["saves"],"summary":"Get Save","operationId":"get_save_api_saves__id__get","security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SaveSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["saves"],"summary":"Update Save","operationId":"update_save_api_saves__id__put","security":[{"OAuth2PasswordBearer":["assets.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SaveSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/saves/delete":{"post":{"tags":["saves"],"summary":"Delete Saves","description":"Delete saves.","operationId":"delete_saves_api_saves_delete_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_delete_saves_api_saves_delete_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"type":"integer"},"type":"array","title":"Response Delete Saves Api Saves Delete Post"}}}},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":["assets.write"]},{"HTTPBasic":[]}]}},"/api/states":{"post":{"tags":["states"],"summary":"Add State","operationId":"add_state_api_states_post","security":[{"OAuth2PasswordBearer":["assets.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"rom_id","in":"query","required":true,"schema":{"type":"integer","title":"Rom Id"}},{"name":"emulator","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Emulator"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StateSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["states"],"summary":"Get States","operationId":"get_states_api_states_get","security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"rom_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Rom Id"}},{"name":"platform_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Platform Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/StateSchema"},"title":"Response Get States Api States Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/states/{id}":{"get":{"tags":["states"],"summary":"Get State","operationId":"get_state_api_states__id__get","security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StateSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["states"],"summary":"Update State","operationId":"update_state_api_states__id__put","security":[{"OAuth2PasswordBearer":["assets.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StateSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/states/delete":{"post":{"tags":["states"],"summary":"Delete States","description":"Delete states.","operationId":"delete_states_api_states_delete_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_delete_states_api_states_delete_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"type":"integer"},"type":"array","title":"Response Delete States Api States Delete Post"}}}},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":["assets.write"]},{"HTTPBasic":[]}]}},"/api/tasks":{"get":{"tags":["tasks"],"summary":"List Tasks","description":"List all available tasks grouped by task type.\n\nArgs:\n request (Request): FastAPI Request object\nReturns:\n GroupedTasksDict: Dictionary with tasks grouped by their type (scheduled, manual, watcher)","operationId":"list_tasks_api_tasks_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"items":{"$ref":"#/components/schemas/TaskInfo"},"type":"array"},"type":"object","title":"Response List Tasks Api Tasks Get"}}}}},"security":[{"OAuth2PasswordBearer":["tasks.run"]},{"HTTPBasic":[]}]}},"/api/tasks/status":{"get":{"tags":["tasks"],"summary":"Get Tasks Status","description":"Get all active, queued, completed, and failed tasks.\n\nArgs:\n request (Request): FastAPI Request object\nReturns:\n list[TaskStatusResponse]: List of all tasks with their current status","operationId":"get_tasks_status_api_tasks_status_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"anyOf":[{"$ref":"#/components/schemas/ScanTaskStatusResponse"},{"$ref":"#/components/schemas/ConversionTaskStatusResponse"},{"$ref":"#/components/schemas/UpdateTaskStatusResponse"},{"$ref":"#/components/schemas/CleanupTaskStatusResponse"},{"$ref":"#/components/schemas/WatcherTaskStatusResponse"},{"$ref":"#/components/schemas/GenericTaskStatusResponse"}]},"type":"array","title":"Response Get Tasks Status Api Tasks Status Get"}}}}},"security":[{"OAuth2PasswordBearer":["tasks.run"]},{"HTTPBasic":[]}]}},"/api/tasks/{task_id}":{"get":{"tags":["tasks"],"summary":"Get Task By Id","description":"Get the status of a task by its job ID.\n\nArgs:\n request (Request): FastAPI Request object\n task_id (str): Job ID of the task to retrieve status for\nReturns:\n TaskStatusResponse: Task status information","operationId":"get_task_by_id_api_tasks__task_id__get","security":[{"OAuth2PasswordBearer":["tasks.run"]},{"HTTPBasic":[]}],"parameters":[{"name":"task_id","in":"path","required":true,"schema":{"type":"string","title":"Task Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ScanTaskStatusResponse"},{"$ref":"#/components/schemas/ConversionTaskStatusResponse"},{"$ref":"#/components/schemas/UpdateTaskStatusResponse"},{"$ref":"#/components/schemas/CleanupTaskStatusResponse"},{"$ref":"#/components/schemas/WatcherTaskStatusResponse"},{"$ref":"#/components/schemas/GenericTaskStatusResponse"}],"title":"Response Get Task By Id Api Tasks Task Id Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/tasks/run":{"post":{"tags":["tasks"],"summary":"Run All Tasks","description":"Run all runnable tasks endpoint\n\nArgs:\n request (Request): FastAPI Request object\nReturns:\n TaskExecutionResponse: Task execution response with details","operationId":"run_all_tasks_api_tasks_run_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/TaskExecutionResponse"},"type":"array","title":"Response Run All Tasks Api Tasks Run Post"}}}}},"security":[{"OAuth2PasswordBearer":["tasks.run"]},{"HTTPBasic":[]}]}},"/api/tasks/run/{task_name}":{"post":{"tags":["tasks"],"summary":"Run Single Task","description":"Run a single task endpoint.\n\nArgs:\n request (Request): FastAPI Request object\n task_name (str): Name of the task to run\nReturns:\n TaskExecutionResponse: Task execution response with details","operationId":"run_single_task_api_tasks_run__task_name__post","security":[{"OAuth2PasswordBearer":["tasks.run"]},{"HTTPBasic":[]}],"parameters":[{"name":"task_name","in":"path","required":true,"schema":{"type":"string","title":"Task Name"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskExecutionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/feeds/webrcade":{"get":{"tags":["feeds"],"summary":"Platforms Webrcade Feed","description":"Get webrcade feed endpoint\nhttps://docs.webrcade.com/feeds/format/\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n WebrcadeFeedSchema: Webrcade feed object schema","operationId":"platforms_webrcade_feed_api_feeds_webrcade_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebrcadeFeedSchema"}}}}},"security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}]}},"/api/feeds/tinfoil":{"get":{"tags":["feeds"],"summary":"Tinfoil Index Feed","description":"Get tinfoil custom index feed endpoint\nhttps://blawar.github.io/tinfoil/custom_index/\n\nArgs:\n request (Request): Fastapi Request object\n slug (str, optional): Platform slug. Defaults to \"switch\".\n\nReturns:\n TinfoilFeedSchema: Tinfoil feed object schema","operationId":"tinfoil_index_feed_api_feeds_tinfoil_get","security":[{"OAuth2PasswordBearer":[]},{"HTTPBasic":[]}],"parameters":[{"name":"slug","in":"query","required":false,"schema":{"type":"string","default":"switch","title":"Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TinfoilFeedSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/feeds/pkgi/ps3/{content_type}":{"get":{"tags":["feeds"],"summary":"Pkgi Ps3 Feed","description":"Get PKGi PS3 feed endpoint\nhttps://github.com/bucanero/pkgi-ps3\n\nArgs:\n request (Request): Fastapi Request object\n content_type (str): Content type (game, dlc, demo, update, patch, mod, translation, prototype)\n\nReturns:\n Response: txt file with PKGi PS3 database format","operationId":"pkgi_ps3_feed_api_feeds_pkgi_ps3__content_type__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"content_type","in":"path","required":true,"schema":{"type":"string","description":"Content type","title":"Content Type"},"description":"Content type"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/feeds/pkgi/psvita/{content_type}":{"get":{"tags":["feeds"],"summary":"Pkgi Psvita Feed","description":"Get PKGi PS Vita feed endpoint\nhttps://github.com/mmozeiko/pkgi\n\nArgs:\n request (Request): Fastapi Request object\n content_type (str): Content type (game, dlc, demo, update, patch, mod, translation, prototype)\n\nReturns:\n Response: txt file with PKGi PS Vita database format","operationId":"pkgi_psvita_feed_api_feeds_pkgi_psvita__content_type__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"content_type","in":"path","required":true,"schema":{"type":"string","description":"Content type","title":"Content Type"},"description":"Content type"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/feeds/pkgi/psp/{content_type}":{"get":{"tags":["feeds"],"summary":"Pkgi Psp Feed","description":"Get PKGi PSP feed endpoint\nhttps://github.com/bucanero/pkgi-psp\n\nArgs:\n request (Request): Fastapi Request object\n content_type (str): Content type (game, dlc, demo, update, patch, mod, translation, prototype)\n\nReturns:\n Response: txt file with PKGi PSP database format","operationId":"pkgi_psp_feed_api_feeds_pkgi_psp__content_type__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"content_type","in":"path","required":true,"schema":{"type":"string","description":"Content type","title":"Content Type"},"description":"Content type"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/feeds/fpkgi/{platform_slug}":{"get":{"tags":["feeds"],"summary":"Fpkgi Feed","description":"https://github.com/ItsJokerZz/FPKGi\n\nArgs:\n request (Request): Fastapi Request object\n platform_slug (str): Platform slug (ps4, ps5)\n\nReturns:\n Response: JSON file in FPKGi format","operationId":"fpkgi_feed_api_feeds_fpkgi__platform_slug__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"platform_slug","in":"path","required":true,"schema":{"type":"string","title":"Platform Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/feeds/kekatsu/{platform_slug}":{"get":{"tags":["feeds"],"summary":"Kekatsu Ds Feed","description":"Get Kekatsu DS feed endpoint\nhttps://github.com/cavv-dev/Kekatsu-DS\n\nArgs:\n request (Request): Fastapi Request object\n platform_slug (str): Platform slug (nds, nintendo-ds, ds, gba, etc.)\n\nReturns:\n Response: Text file with Kekatsu DS database format","operationId":"kekatsu_ds_feed_api_feeds_kekatsu__platform_slug__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"platform_slug","in":"path","required":true,"schema":{"type":"string","title":"Platform Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/config":{"get":{"tags":["config"],"summary":"Get Config","description":"Get config endpoint\n\nReturns:\n ConfigResponse: RomM's configuration","operationId":"get_config_api_config_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfigResponse"}}}}}}},"/api/config/system/platforms":{"post":{"tags":["config"],"summary":"Add Platform Binding","description":"Add platform binding to the configuration","operationId":"add_platform_binding_api_config_system_platforms_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}]}},"/api/config/system/platforms/{fs_slug}":{"delete":{"tags":["config"],"summary":"Delete Platform Binding","description":"Delete platform binding from the configuration","operationId":"delete_platform_binding_api_config_system_platforms__fs_slug__delete","security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"fs_slug","in":"path","required":true,"schema":{"type":"string","title":"Fs Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/config/system/versions":{"post":{"tags":["config"],"summary":"Add Platform Version","description":"Add platform version to the configuration","operationId":"add_platform_version_api_config_system_versions_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}]}},"/api/config/system/versions/{fs_slug}":{"delete":{"tags":["config"],"summary":"Delete Platform Version","description":"Delete platform version from the configuration","operationId":"delete_platform_version_api_config_system_versions__fs_slug__delete","security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"fs_slug","in":"path","required":true,"schema":{"type":"string","title":"Fs Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/config/exclude":{"post":{"tags":["config"],"summary":"Add Exclusion","description":"Add platform exclusion to the configuration","operationId":"add_exclusion_api_config_exclude_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}]}},"/api/config/exclude/{exclusion_type}/{exclusion_value}":{"delete":{"tags":["config"],"summary":"Delete Exclusion","description":"Delete platform binding from the configuration","operationId":"delete_exclusion_api_config_exclude__exclusion_type___exclusion_value__delete","security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"exclusion_type","in":"path","required":true,"schema":{"type":"string","title":"Exclusion Type"}},{"name":"exclusion_value","in":"path","required":true,"schema":{"type":"string","title":"Exclusion Value"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/stats":{"get":{"tags":["stats"],"summary":"Stats","description":"Endpoint to return the current RomM stats\n\nReturns:\n dict: Dictionary with all the stats","operationId":"stats_api_stats_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsReturn"}}}}}}},"/api/raw/assets/{path}":{"head":{"tags":["raw"],"summary":"Head Raw Asset","operationId":"head_raw_asset_api_raw_assets__path__head","security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"path","in":"path","required":true,"schema":{"type":"string","title":"Path"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["raw"],"summary":"Get Raw Asset","description":"Download a single asset file\n\nArgs:\n request (Request): Fastapi Request object\n path (str): Relative path to the asset file\n\nReturns:\n FileResponse: Returns a single asset file\n\nRaises:\n HTTPException: 404 if asset not found or access denied","operationId":"get_raw_asset_api_raw_assets__path__get","security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"path","in":"path","required":true,"schema":{"type":"string","title":"Path"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/screenshots":{"post":{"tags":["screenshots"],"summary":"Add Screenshot","operationId":"add_screenshot_api_screenshots_post","security":[{"OAuth2PasswordBearer":["assets.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"rom_id","in":"query","required":true,"schema":{"type":"integer","title":"Rom Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScreenshotSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/firmware":{"post":{"tags":["firmware"],"summary":"Add Firmware","description":"Upload firmware files endpoint\n\nArgs:\n request (Request): Fastapi Request object\n platform_slug (str): Slug of the platform where to upload the files\n files (list[UploadFile], optional): List of files to upload\n\nRaises:\n HTTPException\n\nReturns:\n AddFirmwareResponse: Standard message response","operationId":"add_firmware_api_firmware_post","security":[{"OAuth2PasswordBearer":["firmware.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"platform_id","in":"query","required":true,"schema":{"type":"integer","title":"Platform Id"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_add_firmware_api_firmware_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddFirmwareResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["firmware"],"summary":"Get Platform Firmware","description":"Get firmware endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n list[FirmwareSchema]: Firmware stored in the database","operationId":"get_platform_firmware_api_firmware_get","security":[{"OAuth2PasswordBearer":["firmware.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"platform_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Platform Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FirmwareSchema"},"title":"Response Get Platform Firmware Api Firmware Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/firmware/{id}":{"get":{"tags":["firmware"],"summary":"Get Firmware","description":"Get firmware endpoint\n\nArgs:\n request (Request): Fastapi Request object\n id (int): Firmware internal id\n\nReturns:\n FirmwareSchema: Firmware stored in the database","operationId":"get_firmware_api_firmware__id__get","security":[{"OAuth2PasswordBearer":["firmware.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FirmwareSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/firmware/{id}/content/{file_name}":{"head":{"tags":["firmware"],"summary":"Head Firmware Content","description":"Head firmware content endpoint\n\nArgs:\n request (Request): Fastapi Request object\n id (int): Rom internal id\n file_name (str): Required due to a bug in emulatorjs\n\nReturns:\n FileResponse: Returns the response with headers","operationId":"head_firmware_content_api_firmware__id__content__file_name__head","security":[{"OAuth2PasswordBearer":["firmware.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}},{"name":"file_name","in":"path","required":true,"schema":{"type":"string","title":"File Name"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["firmware"],"summary":"Get Firmware Content","description":"Download firmware endpoint\n\nArgs:\n request (Request): Fastapi Request object\n id (int): Rom internal id\n file_name (str): Required due to a bug in emulatorjs\n\nReturns:\n FileResponse: Returns the firmware file","operationId":"get_firmware_content_api_firmware__id__content__file_name__get","security":[{"OAuth2PasswordBearer":["firmware.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}},{"name":"file_name","in":"path","required":true,"schema":{"type":"string","title":"File Name"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/firmware/delete":{"post":{"tags":["firmware"],"summary":"Delete Firmware","description":"Delete firmware.","operationId":"delete_firmware_api_firmware_delete_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_delete_firmware_api_firmware_delete_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkOperationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":["firmware.write"]},{"HTTPBasic":[]}]}},"/api/collections":{"post":{"tags":["collections"],"summary":"Add Collection","description":"Create collection endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n CollectionSchema: Just created collection","operationId":"add_collection_api_collections_post","security":[{"OAuth2PasswordBearer":["collections.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"is_public","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Public"}},{"name":"is_favorite","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Favorite"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_add_collection_api_collections_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectionSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["collections"],"summary":"Get Collections","description":"Get collections endpoint\n\nArgs:\n request (Request): Fastapi Request object\n updated_after: Filter collections updated after this datetime\n\nReturns:\n list[CollectionSchema]: List of collections","operationId":"get_collections_api_collections_get","security":[{"OAuth2PasswordBearer":["collections.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"updated_after","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter collections updated after this datetime (ISO 8601 format with timezone information).","title":"Updated After"},"description":"Filter collections updated after this datetime (ISO 8601 format with timezone information)."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CollectionSchema"},"title":"Response Get Collections Api Collections Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/collections/smart":{"post":{"tags":["collections"],"summary":"Add Smart Collection","description":"Create smart collection endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n SmartCollectionSchema: Just created smart collection","operationId":"add_smart_collection_api_collections_smart_post","security":[{"OAuth2PasswordBearer":["collections.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"is_public","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Public"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SmartCollectionSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["collections"],"summary":"Get Smart Collections","description":"Get smart collections endpoint\n\nArgs:\n request (Request): Fastapi Request object\n updated_after: Filter smart collections updated after this datetime\n\nReturns:\n list[SmartCollectionSchema]: List of smart collections","operationId":"get_smart_collections_api_collections_smart_get","security":[{"OAuth2PasswordBearer":["collections.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"updated_after","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter smart collections updated after this datetime (ISO 8601 format with timezone information).","title":"Updated After"},"description":"Filter smart collections updated after this datetime (ISO 8601 format with timezone information)."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SmartCollectionSchema"},"title":"Response Get Smart Collections Api Collections Smart Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/collections/virtual":{"get":{"tags":["collections"],"summary":"Get Virtual Collections","description":"Get virtual collections endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n list[VirtualCollectionSchema]: List of virtual collections","operationId":"get_virtual_collections_api_collections_virtual_get","security":[{"OAuth2PasswordBearer":["collections.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"type","in":"query","required":true,"schema":{"type":"string","title":"Type"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/VirtualCollectionSchema"},"title":"Response Get Virtual Collections Api Collections Virtual Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/collections/{id}":{"get":{"tags":["collections"],"summary":"Get Collection","description":"Get collections endpoint\n\nArgs:\n request (Request): Fastapi Request object\n id (int, optional): Collection id. Defaults to None.\n\nReturns:\n CollectionSchema: Collection","operationId":"get_collection_api_collections__id__get","security":[{"OAuth2PasswordBearer":["collections.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectionSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["collections"],"summary":"Update Collection","description":"Update collection endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n CollectionSchema: Updated collection","operationId":"update_collection_api_collections__id__put","security":[{"OAuth2PasswordBearer":["collections.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}},{"name":"remove_cover","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Remove Cover"}},{"name":"is_public","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Public"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_update_collection_api_collections__id__put"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectionSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["collections"],"summary":"Delete Collection","description":"Delete a collection by ID.","operationId":"delete_collection_api_collections__id__delete","security":[{"OAuth2PasswordBearer":["collections.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Collection internal id.","title":"Id"},"description":"Collection internal id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/collections/virtual/{id}":{"get":{"tags":["collections"],"summary":"Get Virtual Collection","description":"Get virtual collections endpoint\n\nArgs:\n request (Request): Fastapi Request object\n id (str): Virtual collection id\n\nReturns:\n VirtualCollectionSchema: Virtual collection","operationId":"get_virtual_collection_api_collections_virtual__id__get","security":[{"OAuth2PasswordBearer":["collections.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VirtualCollectionSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/collections/smart/{id}":{"get":{"tags":["collections"],"summary":"Get Smart Collection","description":"Get smart collection endpoint\n\nArgs:\n request (Request): Fastapi Request object\n id (int): Smart collection id\n\nReturns:\n SmartCollectionSchema: Smart collection","operationId":"get_smart_collection_api_collections_smart__id__get","security":[{"OAuth2PasswordBearer":["collections.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SmartCollectionSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["collections"],"summary":"Update Smart Collection","description":"Update smart collection endpoint\n\nArgs:\n request (Request): Fastapi Request object\n id (int): Smart collection id\n\nReturns:\n SmartCollectionSchema: Updated smart collection","operationId":"update_smart_collection_api_collections_smart__id__put","security":[{"OAuth2PasswordBearer":["collections.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}},{"name":"is_public","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Public"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SmartCollectionSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["collections"],"summary":"Delete Smart Collection","description":"Delete a smart collection by ID.","operationId":"delete_smart_collection_api_collections_smart__id__delete","security":[{"OAuth2PasswordBearer":["collections.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Smart collection internal id.","title":"Id"},"description":"Smart collection internal id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/gamelist/export":{"post":{"tags":["gamelist"],"summary":"Export Gamelist","description":"Export platforms/ROMs to gamelist.xml format and write to platform directories","operationId":"export_gamelist_api_gamelist_export_post","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"platform_ids","in":"query","required":true,"schema":{"type":"array","items":{"type":"integer"},"description":"List of platform IDs to export","title":"Platform Ids"},"description":"List of platform IDs to export"},{"name":"local_export","in":"query","required":false,"schema":{"type":"boolean","description":"Use local paths instead of URLs","default":false,"title":"Local Export"},"description":"Use local paths instead of URLs"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/netplay/list":{"get":{"tags":["netplay"],"summary":"Get Rooms","operationId":"get_rooms_api_netplay_list_get","security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"game_id","in":"query","required":true,"schema":{"type":"string","title":"Game Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/RoomsResponse"},"title":"Response Get Rooms Api Netplay List Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"AddFirmwareResponse":{"properties":{"uploaded":{"type":"integer","title":"Uploaded"},"firmware":{"items":{"$ref":"#/components/schemas/FirmwareSchema"},"type":"array","title":"Firmware"}},"type":"object","required":["uploaded","firmware"],"title":"AddFirmwareResponse"},"Body_add_collection_api_collections_post":{"properties":{"artwork":{"anyOf":[{"type":"string","format":"binary"},{"type":"null"}],"title":"Artwork"}},"type":"object","title":"Body_add_collection_api_collections_post"},"Body_add_firmware_api_firmware_post":{"properties":{"files":{"items":{"type":"string","format":"binary"},"type":"array","title":"Files"}},"type":"object","required":["files"],"title":"Body_add_firmware_api_firmware_post"},"Body_add_platform_api_platforms_post":{"properties":{"fs_slug":{"type":"string","title":"Fs Slug","description":"Platform slug."}},"type":"object","required":["fs_slug"],"title":"Body_add_platform_api_platforms_post"},"Body_add_user_api_users_post":{"properties":{"username":{"type":"string","title":"Username"},"email":{"type":"string","title":"Email"},"password":{"type":"string","title":"Password"},"role":{"type":"string","title":"Role"}},"type":"object","required":["username","email","password","role"],"title":"Body_add_user_api_users_post"},"Body_create_user_from_invite_api_users_register_post":{"properties":{"username":{"type":"string","title":"Username"},"email":{"type":"string","title":"Email"},"password":{"type":"string","title":"Password"},"token":{"type":"string","title":"Token"}},"type":"object","required":["username","email","password","token"],"title":"Body_create_user_from_invite_api_users_register_post"},"Body_delete_firmware_api_firmware_delete_post":{"properties":{"firmware":{"items":{"type":"integer"},"type":"array","title":"Firmware","description":"List of firmware ids to delete from database."},"delete_from_fs":{"items":{"type":"integer"},"type":"array","title":"Delete From Fs","description":"List of firmware ids to delete from filesystem."}},"type":"object","required":["firmware"],"title":"Body_delete_firmware_api_firmware_delete_post"},"Body_delete_roms_api_roms_delete_post":{"properties":{"roms":{"items":{"type":"integer"},"type":"array","title":"Roms","description":"List of rom ids to delete from database."},"delete_from_fs":{"items":{"type":"integer"},"type":"array","title":"Delete From Fs","description":"List of rom ids to delete from filesystem."}},"type":"object","required":["roms"],"title":"Body_delete_roms_api_roms_delete_post"},"Body_delete_saves_api_saves_delete_post":{"properties":{"saves":{"items":{"type":"integer"},"type":"array","title":"Saves","description":"List of save ids to delete from database."}},"type":"object","required":["saves"],"title":"Body_delete_saves_api_saves_delete_post"},"Body_delete_states_api_states_delete_post":{"properties":{"states":{"items":{"type":"integer"},"type":"array","title":"States","description":"List of states ids to delete from database."}},"type":"object","required":["states"],"title":"Body_delete_states_api_states_delete_post"},"Body_refresh_retro_achievements_api_users__id__ra_refresh_post":{"properties":{"incremental":{"type":"boolean","title":"Incremental","description":"Whether to only retrieve RetroAchievements progression incrementally.","default":false}},"type":"object","title":"Body_refresh_retro_achievements_api_users__id__ra_refresh_post"},"Body_request_password_reset_api_forgot_password_post":{"properties":{"username":{"type":"string","title":"Username"}},"type":"object","required":["username"],"title":"Body_request_password_reset_api_forgot_password_post"},"Body_reset_password_api_reset_password_post":{"properties":{"token":{"type":"string","title":"Token"},"new_password":{"type":"string","title":"New Password"}},"type":"object","required":["token","new_password"],"title":"Body_reset_password_api_reset_password_post"},"Body_token_api_token_post":{"properties":{"grant_type":{"type":"string","title":"Grant Type","default":"password"},"scope":{"type":"string","title":"Scope","default":""},"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"password":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Password"},"client_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Id"},"client_secret":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Secret"},"refresh_token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Refresh Token"}},"type":"object","title":"Body_token_api_token_post"},"Body_update_collection_api_collections__id__put":{"properties":{"artwork":{"anyOf":[{"type":"string","format":"binary"},{"type":"null"}],"title":"Artwork"}},"type":"object","title":"Body_update_collection_api_collections__id__put"},"Body_update_platform_api_platforms__id__put":{"properties":{"aspect_ratio":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Aspect Ratio","description":"Cover aspect ratio."},"custom_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custom Name","description":"Custom platform name."}},"type":"object","title":"Body_update_platform_api_platforms__id__put"},"Body_update_rom_api_roms__id__put":{"properties":{"artwork":{"anyOf":[{"type":"string","format":"binary"},{"type":"null"}],"title":"Artwork","description":"Custom artwork to set as cover."}},"type":"object","title":"Body_update_rom_api_roms__id__put"},"Body_update_rom_user_api_roms__id__props_put":{"properties":{"update_last_played":{"type":"boolean","title":"Update Last Played","description":"Whether to update the last played date.","default":false},"remove_last_played":{"type":"boolean","title":"Remove Last Played","description":"Whether to remove the last played date.","default":false}},"type":"object","title":"Body_update_rom_user_api_roms__id__props_put"},"BulkOperationResponse":{"properties":{"successful_items":{"type":"integer","title":"Successful Items"},"failed_items":{"type":"integer","title":"Failed Items"},"errors":{"items":{"type":"string"},"type":"array","title":"Errors"}},"type":"object","required":["successful_items","failed_items","errors"],"title":"BulkOperationResponse"},"CleanupStats":{"properties":{"platforms_in_db":{"type":"integer","title":"Platforms In Db"},"roms_in_db":{"type":"integer","title":"Roms In Db"},"platforms_in_fs":{"type":"integer","title":"Platforms In Fs"},"roms_in_fs":{"type":"integer","title":"Roms In Fs"},"removed_fs_platforms":{"type":"integer","title":"Removed Fs Platforms"},"removed_fs_roms":{"type":"integer","title":"Removed Fs Roms"}},"type":"object","required":["platforms_in_db","roms_in_db","platforms_in_fs","roms_in_fs","removed_fs_platforms","removed_fs_roms"],"title":"CleanupStats"},"CleanupTaskMeta":{"properties":{"cleanup_stats":{"anyOf":[{"$ref":"#/components/schemas/CleanupStats"},{"type":"null"}]}},"type":"object","required":["cleanup_stats"],"title":"CleanupTaskMeta"},"CleanupTaskStatusResponse":{"properties":{"task_name":{"type":"string","title":"Task Name"},"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"enqueued_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Enqueued At"},"started_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Started At"},"ended_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ended At"},"task_type":{"type":"string","const":"cleanup","title":"Task Type"},"meta":{"$ref":"#/components/schemas/CleanupTaskMeta"}},"type":"object","required":["task_name","task_id","status","created_at","enqueued_at","started_at","ended_at","task_type","meta"],"title":"CleanupTaskStatusResponse"},"CollectionSchema":{"properties":{"name":{"type":"string","title":"Name"},"description":{"type":"string","title":"Description"},"rom_ids":{"items":{"type":"integer"},"type":"array","uniqueItems":true,"title":"Rom Ids"},"rom_count":{"type":"integer","title":"Rom Count"},"path_cover_small":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Small"},"path_cover_large":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Large"},"path_covers_small":{"items":{"type":"string"},"type":"array","title":"Path Covers Small"},"path_covers_large":{"items":{"type":"string"},"type":"array","title":"Path Covers Large"},"is_public":{"type":"boolean","title":"Is Public","default":false},"is_favorite":{"type":"boolean","title":"Is Favorite","default":false},"is_virtual":{"type":"boolean","title":"Is Virtual","default":false},"is_smart":{"type":"boolean","title":"Is Smart","default":false},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"id":{"type":"integer","title":"Id"},"url_cover":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url Cover"},"user_id":{"type":"integer","title":"User Id"},"user__username":{"type":"string","title":"User Username"}},"type":"object","required":["name","description","rom_ids","rom_count","path_cover_small","path_cover_large","path_covers_small","path_covers_large","created_at","updated_at","id","url_cover","user_id","user__username"],"title":"CollectionSchema"},"ConfigResponse":{"properties":{"CONFIG_FILE_MOUNTED":{"type":"boolean","title":"Config File Mounted"},"CONFIG_FILE_WRITABLE":{"type":"boolean","title":"Config File Writable"},"EXCLUDED_PLATFORMS":{"items":{"type":"string"},"type":"array","title":"Excluded Platforms"},"EXCLUDED_SINGLE_EXT":{"items":{"type":"string"},"type":"array","title":"Excluded Single Ext"},"EXCLUDED_SINGLE_FILES":{"items":{"type":"string"},"type":"array","title":"Excluded Single Files"},"EXCLUDED_MULTI_FILES":{"items":{"type":"string"},"type":"array","title":"Excluded Multi Files"},"EXCLUDED_MULTI_PARTS_EXT":{"items":{"type":"string"},"type":"array","title":"Excluded Multi Parts Ext"},"EXCLUDED_MULTI_PARTS_FILES":{"items":{"type":"string"},"type":"array","title":"Excluded Multi Parts Files"},"PLATFORMS_BINDING":{"additionalProperties":{"type":"string"},"type":"object","title":"Platforms Binding"},"PLATFORMS_VERSIONS":{"additionalProperties":{"type":"string"},"type":"object","title":"Platforms Versions"},"SKIP_HASH_CALCULATION":{"type":"boolean","title":"Skip Hash Calculation"},"EJS_DEBUG":{"type":"boolean","title":"Ejs Debug"},"EJS_CACHE_LIMIT":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ejs Cache Limit"},"EJS_DISABLE_AUTO_UNLOAD":{"type":"boolean","title":"Ejs Disable Auto Unload"},"EJS_DISABLE_BATCH_BOOTUP":{"type":"boolean","title":"Ejs Disable Batch Bootup"},"EJS_NETPLAY_ENABLED":{"type":"boolean","title":"Ejs Netplay Enabled"},"EJS_NETPLAY_ICE_SERVERS":{"items":{"$ref":"#/components/schemas/NetplayICEServer"},"type":"array","title":"Ejs Netplay Ice Servers"},"EJS_SETTINGS":{"additionalProperties":{"additionalProperties":{"type":"string"},"type":"object"},"type":"object","title":"Ejs Settings"},"EJS_CONTROLS":{"additionalProperties":{"$ref":"#/components/schemas/EjsControls"},"type":"object","title":"Ejs Controls"},"SCAN_METADATA_PRIORITY":{"items":{"type":"string"},"type":"array","title":"Scan Metadata Priority"},"SCAN_ARTWORK_PRIORITY":{"items":{"type":"string"},"type":"array","title":"Scan Artwork Priority"},"SCAN_REGION_PRIORITY":{"items":{"type":"string"},"type":"array","title":"Scan Region Priority"},"SCAN_LANGUAGE_PRIORITY":{"items":{"type":"string"},"type":"array","title":"Scan Language Priority"},"SCAN_MEDIA":{"items":{"type":"string"},"type":"array","title":"Scan Media"}},"type":"object","required":["CONFIG_FILE_MOUNTED","CONFIG_FILE_WRITABLE","EXCLUDED_PLATFORMS","EXCLUDED_SINGLE_EXT","EXCLUDED_SINGLE_FILES","EXCLUDED_MULTI_FILES","EXCLUDED_MULTI_PARTS_EXT","EXCLUDED_MULTI_PARTS_FILES","PLATFORMS_BINDING","PLATFORMS_VERSIONS","SKIP_HASH_CALCULATION","EJS_DEBUG","EJS_CACHE_LIMIT","EJS_DISABLE_AUTO_UNLOAD","EJS_DISABLE_BATCH_BOOTUP","EJS_NETPLAY_ENABLED","EJS_NETPLAY_ICE_SERVERS","EJS_SETTINGS","EJS_CONTROLS","SCAN_METADATA_PRIORITY","SCAN_ARTWORK_PRIORITY","SCAN_REGION_PRIORITY","SCAN_LANGUAGE_PRIORITY","SCAN_MEDIA"],"title":"ConfigResponse"},"ConversionStats":{"properties":{"processed":{"type":"integer","title":"Processed"},"errors":{"type":"integer","title":"Errors"},"total":{"type":"integer","title":"Total"}},"type":"object","required":["processed","errors","total"],"title":"ConversionStats"},"ConversionTaskMeta":{"properties":{"conversion_stats":{"anyOf":[{"$ref":"#/components/schemas/ConversionStats"},{"type":"null"}]}},"type":"object","required":["conversion_stats"],"title":"ConversionTaskMeta"},"ConversionTaskStatusResponse":{"properties":{"task_name":{"type":"string","title":"Task Name"},"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"enqueued_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Enqueued At"},"started_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Started At"},"ended_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ended At"},"task_type":{"type":"string","const":"conversion","title":"Task Type"},"meta":{"$ref":"#/components/schemas/ConversionTaskMeta"}},"type":"object","required":["task_name","task_id","status","created_at","enqueued_at","started_at","ended_at","task_type","meta"],"title":"ConversionTaskStatusResponse"},"CustomLimitOffsetPage_SimpleRomSchema_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/SimpleRomSchema"},"type":"array","title":"Items"},"total":{"type":"integer","minimum":0.0,"title":"Total"},"limit":{"type":"integer","minimum":1.0,"title":"Limit"},"offset":{"type":"integer","minimum":0.0,"title":"Offset"},"char_index":{"additionalProperties":{"type":"integer"},"type":"object","title":"Char Index"},"rom_id_index":{"items":{"type":"integer"},"type":"array","title":"Rom Id Index"},"filter_values":{"$ref":"#/components/schemas/RomFiltersDict"}},"type":"object","required":["items","total","limit","offset","char_index","rom_id_index","filter_values"],"title":"CustomLimitOffsetPage[SimpleRomSchema]"},"DetailedRomSchema":{"properties":{"id":{"type":"integer","title":"Id"},"igdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Igdb Id"},"sgdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Sgdb Id"},"moby_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Moby Id"},"ss_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ss Id"},"ra_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ra Id"},"launchbox_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Launchbox Id"},"hasheous_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Hasheous Id"},"tgdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Tgdb Id"},"flashpoint_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flashpoint Id"},"hltb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Hltb Id"},"gamelist_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Gamelist Id"},"platform_id":{"type":"integer","title":"Platform Id"},"platform_slug":{"type":"string","title":"Platform Slug"},"platform_fs_slug":{"type":"string","title":"Platform Fs Slug"},"platform_custom_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Platform Custom Name"},"platform_display_name":{"type":"string","title":"Platform Display Name"},"fs_name":{"type":"string","title":"Fs Name"},"fs_name_no_tags":{"type":"string","title":"Fs Name No Tags"},"fs_name_no_ext":{"type":"string","title":"Fs Name No Ext"},"fs_extension":{"type":"string","title":"Fs Extension"},"fs_path":{"type":"string","title":"Fs Path"},"fs_size_bytes":{"type":"integer","title":"Fs Size Bytes"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary"},"alternative_names":{"items":{"type":"string"},"type":"array","title":"Alternative Names"},"youtube_video_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Youtube Video Id"},"metadatum":{"$ref":"#/components/schemas/RomMetadataSchema"},"igdb_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomIGDBMetadata"},{"type":"null"}]},"moby_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomMobyMetadata"},{"type":"null"}]},"ss_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomSSMetadata"},{"type":"null"}]},"launchbox_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomLaunchboxMetadata"},{"type":"null"}]},"hasheous_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomHasheousMetadata"},{"type":"null"}]},"flashpoint_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomFlashpointMetadata"},{"type":"null"}]},"hltb_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomHLTBMetadata"},{"type":"null"}]},"gamelist_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomGamelistMetadata"},{"type":"null"}]},"manual_metadata":{"anyOf":[{"$ref":"#/components/schemas/ManualMetadata"},{"type":"null"}]},"path_cover_small":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Small"},"path_cover_large":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Large"},"url_cover":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url Cover"},"has_manual":{"type":"boolean","title":"Has Manual"},"path_manual":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Manual"},"url_manual":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url Manual"},"is_identifying":{"type":"boolean","title":"Is Identifying","default":false},"is_unidentified":{"type":"boolean","title":"Is Unidentified"},"is_identified":{"type":"boolean","title":"Is Identified"},"revision":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Revision"},"regions":{"items":{"type":"string"},"type":"array","title":"Regions"},"languages":{"items":{"type":"string"},"type":"array","title":"Languages"},"tags":{"items":{"type":"string"},"type":"array","title":"Tags"},"crc_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Crc Hash"},"md5_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Md5 Hash"},"sha1_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sha1 Hash"},"has_simple_single_file":{"type":"boolean","title":"Has Simple Single File"},"has_nested_single_file":{"type":"boolean","title":"Has Nested Single File"},"has_multiple_files":{"type":"boolean","title":"Has Multiple Files"},"files":{"items":{"$ref":"#/components/schemas/RomFileSchema"},"type":"array","title":"Files"},"full_path":{"type":"string","title":"Full Path"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"missing_from_fs":{"type":"boolean","title":"Missing From Fs"},"has_notes":{"type":"boolean","title":"Has Notes"},"siblings":{"items":{"$ref":"#/components/schemas/SiblingRomSchema"},"type":"array","title":"Siblings"},"rom_user":{"$ref":"#/components/schemas/RomUserSchema"},"merged_screenshots":{"items":{"type":"string"},"type":"array","title":"Merged Screenshots"},"merged_ra_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomRAMetadata"},{"type":"null"}]},"user_saves":{"items":{"$ref":"#/components/schemas/SaveSchema"},"type":"array","title":"User Saves"},"user_states":{"items":{"$ref":"#/components/schemas/StateSchema"},"type":"array","title":"User States"},"user_screenshots":{"items":{"$ref":"#/components/schemas/ScreenshotSchema"},"type":"array","title":"User Screenshots"},"user_collections":{"items":{"$ref":"#/components/schemas/UserCollectionSchema"},"type":"array","title":"User Collections"},"all_user_notes":{"items":{"$ref":"#/components/schemas/UserNoteSchema"},"type":"array","title":"All User Notes"}},"type":"object","required":["id","igdb_id","sgdb_id","moby_id","ss_id","ra_id","launchbox_id","hasheous_id","tgdb_id","flashpoint_id","hltb_id","gamelist_id","platform_id","platform_slug","platform_fs_slug","platform_custom_name","platform_display_name","fs_name","fs_name_no_tags","fs_name_no_ext","fs_extension","fs_path","fs_size_bytes","name","slug","summary","alternative_names","youtube_video_id","metadatum","igdb_metadata","moby_metadata","ss_metadata","launchbox_metadata","hasheous_metadata","flashpoint_metadata","hltb_metadata","gamelist_metadata","manual_metadata","path_cover_small","path_cover_large","url_cover","has_manual","path_manual","url_manual","is_unidentified","is_identified","revision","regions","languages","tags","crc_hash","md5_hash","sha1_hash","has_simple_single_file","has_nested_single_file","has_multiple_files","files","full_path","created_at","updated_at","missing_from_fs","has_notes","siblings","rom_user","merged_screenshots","merged_ra_metadata","user_saves","user_states","user_screenshots","user_collections","all_user_notes"],"title":"DetailedRomSchema"},"EarnedAchievement":{"properties":{"id":{"type":"string","title":"Id"},"date":{"type":"string","title":"Date"},"date_hardcore":{"type":"string","title":"Date Hardcore"}},"type":"object","required":["id","date"],"title":"EarnedAchievement"},"EjsControls":{"properties":{"_0":{"additionalProperties":{"$ref":"#/components/schemas/EjsControlsButton"},"type":"object","title":"0"},"_1":{"additionalProperties":{"$ref":"#/components/schemas/EjsControlsButton"},"type":"object","title":"1"},"_2":{"additionalProperties":{"$ref":"#/components/schemas/EjsControlsButton"},"type":"object","title":"2"},"_3":{"additionalProperties":{"$ref":"#/components/schemas/EjsControlsButton"},"type":"object","title":"3"}},"type":"object","required":["_0","_1","_2","_3"],"title":"EjsControls"},"EjsControlsButton":{"properties":{"value":{"type":"string","title":"Value"},"value2":{"type":"string","title":"Value2"}},"type":"object","title":"EjsControlsButton"},"EmulationDict":{"properties":{"DISABLE_EMULATOR_JS":{"type":"boolean","title":"Disable Emulator Js"},"DISABLE_RUFFLE_RS":{"type":"boolean","title":"Disable Ruffle Rs"}},"type":"object","required":["DISABLE_EMULATOR_JS","DISABLE_RUFFLE_RS"],"title":"EmulationDict"},"FilesystemDict":{"properties":{"FS_PLATFORMS":{"items":{"type":"string"},"type":"array","title":"Fs Platforms"}},"type":"object","required":["FS_PLATFORMS"],"title":"FilesystemDict"},"FirmwareSchema":{"properties":{"id":{"type":"integer","title":"Id"},"file_name":{"type":"string","title":"File Name"},"file_name_no_tags":{"type":"string","title":"File Name No Tags"},"file_name_no_ext":{"type":"string","title":"File Name No Ext"},"file_extension":{"type":"string","title":"File Extension"},"file_path":{"type":"string","title":"File Path"},"file_size_bytes":{"type":"integer","title":"File Size Bytes"},"full_path":{"type":"string","title":"Full Path"},"is_verified":{"type":"boolean","title":"Is Verified"},"crc_hash":{"type":"string","title":"Crc Hash"},"md5_hash":{"type":"string","title":"Md5 Hash"},"sha1_hash":{"type":"string","title":"Sha1 Hash"},"missing_from_fs":{"type":"boolean","title":"Missing From Fs"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","file_name","file_name_no_tags","file_name_no_ext","file_extension","file_path","file_size_bytes","full_path","is_verified","crc_hash","md5_hash","sha1_hash","missing_from_fs","created_at","updated_at"],"title":"FirmwareSchema"},"FrontendDict":{"properties":{"UPLOAD_TIMEOUT":{"type":"integer","title":"Upload Timeout"},"DISABLE_USERPASS_LOGIN":{"type":"boolean","title":"Disable Userpass Login"},"YOUTUBE_BASE_URL":{"type":"string","title":"Youtube Base Url"}},"type":"object","required":["UPLOAD_TIMEOUT","DISABLE_USERPASS_LOGIN","YOUTUBE_BASE_URL"],"title":"FrontendDict"},"GenericTaskMeta":{"properties":{},"type":"object","title":"GenericTaskMeta"},"GenericTaskStatusResponse":{"properties":{"task_name":{"type":"string","title":"Task Name"},"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"enqueued_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Enqueued At"},"started_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Started At"},"ended_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ended At"},"task_type":{"type":"string","const":"generic","title":"Task Type"},"meta":{"$ref":"#/components/schemas/GenericTaskMeta"}},"type":"object","required":["task_name","task_id","status","created_at","enqueued_at","started_at","ended_at","task_type","meta"],"title":"GenericTaskStatusResponse"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HeartbeatResponse":{"properties":{"SYSTEM":{"$ref":"#/components/schemas/SystemDict"},"METADATA_SOURCES":{"$ref":"#/components/schemas/MetadataSourcesDict"},"FILESYSTEM":{"$ref":"#/components/schemas/FilesystemDict"},"EMULATION":{"$ref":"#/components/schemas/EmulationDict"},"FRONTEND":{"$ref":"#/components/schemas/FrontendDict"},"OIDC":{"$ref":"#/components/schemas/OIDCDict"},"TASKS":{"$ref":"#/components/schemas/TasksDict"}},"type":"object","required":["SYSTEM","METADATA_SOURCES","FILESYSTEM","EMULATION","FRONTEND","OIDC","TASKS"],"title":"HeartbeatResponse"},"IGDBAgeRating":{"properties":{"rating":{"type":"string","title":"Rating"},"category":{"type":"string","title":"Category"},"rating_cover_url":{"type":"string","title":"Rating Cover Url"}},"type":"object","required":["rating","category","rating_cover_url"],"title":"IGDBAgeRating"},"IGDBMetadataMultiplayerMode":{"properties":{"campaigncoop":{"type":"boolean","title":"Campaigncoop"},"dropin":{"type":"boolean","title":"Dropin"},"lancoop":{"type":"boolean","title":"Lancoop"},"offlinecoop":{"type":"boolean","title":"Offlinecoop"},"offlinecoopmax":{"type":"integer","title":"Offlinecoopmax"},"offlinemax":{"type":"integer","title":"Offlinemax"},"onlinecoop":{"type":"integer","title":"Onlinecoop"},"onlinecoopmax":{"type":"integer","title":"Onlinecoopmax"},"onlinemax":{"type":"integer","title":"Onlinemax"},"splitscreen":{"type":"boolean","title":"Splitscreen"},"splitscreenonline":{"type":"boolean","title":"Splitscreenonline"},"platform":{"$ref":"#/components/schemas/IGDBMetadataPlatform"}},"type":"object","required":["campaigncoop","dropin","lancoop","offlinecoop","offlinecoopmax","offlinemax","onlinecoop","onlinecoopmax","onlinemax","splitscreen","splitscreenonline","platform"],"title":"IGDBMetadataMultiplayerMode"},"IGDBMetadataPlatform":{"properties":{"igdb_id":{"type":"integer","title":"Igdb Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["igdb_id","name"],"title":"IGDBMetadataPlatform"},"IGDBRelatedGame":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"slug":{"type":"string","title":"Slug"},"type":{"type":"string","title":"Type"},"cover_url":{"type":"string","title":"Cover Url"}},"type":"object","required":["id","name","slug","type","cover_url"],"title":"IGDBRelatedGame"},"InviteLinkSchema":{"properties":{"token":{"type":"string","title":"Token"}},"type":"object","required":["token"],"title":"InviteLinkSchema"},"JobStatus":{"type":"string","enum":["queued","finished","failed","started","deferred","scheduled","stopped","canceled"],"title":"JobStatus","description":"The Status of Job within its lifecycle at any given time."},"LaunchboxImage":{"properties":{"url":{"type":"string","title":"Url"},"type":{"type":"string","title":"Type"},"region":{"type":"string","title":"Region"}},"type":"object","required":["url"],"title":"LaunchboxImage"},"ManualMetadata":{"properties":{"genres":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Genres"},"franchises":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Franchises"},"companies":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Companies"},"game_modes":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Game Modes"},"age_ratings":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Age Ratings"},"first_release_date":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"First Release Date"},"youtube_video_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Youtube Video Id"}},"type":"object","title":"ManualMetadata"},"MetadataSourcesDict":{"properties":{"ANY_SOURCE_ENABLED":{"type":"boolean","title":"Any Source Enabled"},"IGDB_API_ENABLED":{"type":"boolean","title":"Igdb Api Enabled"},"SS_API_ENABLED":{"type":"boolean","title":"Ss Api Enabled"},"MOBY_API_ENABLED":{"type":"boolean","title":"Moby Api Enabled"},"STEAMGRIDDB_API_ENABLED":{"type":"boolean","title":"Steamgriddb Api Enabled"},"RA_API_ENABLED":{"type":"boolean","title":"Ra Api Enabled"},"LAUNCHBOX_API_ENABLED":{"type":"boolean","title":"Launchbox Api Enabled"},"HASHEOUS_API_ENABLED":{"type":"boolean","title":"Hasheous Api Enabled"},"PLAYMATCH_API_ENABLED":{"type":"boolean","title":"Playmatch Api Enabled"},"TGDB_API_ENABLED":{"type":"boolean","title":"Tgdb Api Enabled"},"FLASHPOINT_API_ENABLED":{"type":"boolean","title":"Flashpoint Api Enabled"},"HLTB_API_ENABLED":{"type":"boolean","title":"Hltb Api Enabled"}},"type":"object","required":["ANY_SOURCE_ENABLED","IGDB_API_ENABLED","SS_API_ENABLED","MOBY_API_ENABLED","STEAMGRIDDB_API_ENABLED","RA_API_ENABLED","LAUNCHBOX_API_ENABLED","HASHEOUS_API_ENABLED","PLAYMATCH_API_ENABLED","TGDB_API_ENABLED","FLASHPOINT_API_ENABLED","HLTB_API_ENABLED"],"title":"MetadataSourcesDict"},"MobyMetadataPlatform":{"properties":{"moby_id":{"type":"integer","title":"Moby Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["moby_id","name"],"title":"MobyMetadataPlatform"},"NetplayICEServer":{"properties":{"urls":{"type":"string","title":"Urls"},"username":{"type":"string","title":"Username"},"credential":{"type":"string","title":"Credential"}},"type":"object","required":["urls"],"title":"NetplayICEServer"},"OIDCDict":{"properties":{"ENABLED":{"type":"boolean","title":"Enabled"},"PROVIDER":{"type":"string","title":"Provider"}},"type":"object","required":["ENABLED","PROVIDER"],"title":"OIDCDict"},"PlatformSchema":{"properties":{"id":{"type":"integer","title":"Id"},"slug":{"type":"string","title":"Slug"},"fs_slug":{"type":"string","title":"Fs Slug"},"rom_count":{"type":"integer","title":"Rom Count"},"name":{"type":"string","title":"Name"},"igdb_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Igdb Slug"},"moby_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Moby Slug"},"hltb_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Hltb Slug"},"custom_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custom Name"},"igdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Igdb Id"},"sgdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Sgdb Id"},"moby_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Moby Id"},"launchbox_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Launchbox Id"},"ss_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ss Id"},"ra_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ra Id"},"hasheous_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Hasheous Id"},"tgdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Tgdb Id"},"flashpoint_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Flashpoint Id"},"category":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Category"},"generation":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Generation"},"family_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Family Name"},"family_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Family Slug"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"url_logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url Logo"},"firmware":{"items":{"$ref":"#/components/schemas/FirmwareSchema"},"type":"array","title":"Firmware"},"aspect_ratio":{"type":"string","title":"Aspect Ratio","default":"2 / 3"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"fs_size_bytes":{"type":"integer","title":"Fs Size Bytes"},"is_unidentified":{"type":"boolean","title":"Is Unidentified"},"is_identified":{"type":"boolean","title":"Is Identified"},"missing_from_fs":{"type":"boolean","title":"Missing From Fs"},"display_name":{"type":"string","title":"Display Name","readOnly":true}},"type":"object","required":["id","slug","fs_slug","rom_count","name","igdb_slug","moby_slug","hltb_slug","created_at","updated_at","fs_size_bytes","is_unidentified","is_identified","missing_from_fs","display_name"],"title":"PlatformSchema"},"RAGameRomAchievement":{"properties":{"ra_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ra Id"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"points":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Points"},"num_awarded":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Num Awarded"},"num_awarded_hardcore":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Num Awarded Hardcore"},"badge_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Badge Id"},"badge_url_lock":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Badge Url Lock"},"badge_path_lock":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Badge Path Lock"},"badge_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Badge Url"},"badge_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Badge Path"},"display_order":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Display Order"},"type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Type"}},"type":"object","required":["ra_id","title","description","points","num_awarded","num_awarded_hardcore","badge_id","badge_url_lock","badge_path_lock","badge_url","badge_path","display_order","type"],"title":"RAGameRomAchievement"},"RAProgression":{"properties":{"total":{"type":"integer","title":"Total"},"results":{"items":{"$ref":"#/components/schemas/RAUserGameProgression"},"type":"array","title":"Results"}},"type":"object","title":"RAProgression"},"RAUserGameProgression":{"properties":{"rom_ra_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Rom Ra Id"},"max_possible":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Possible"},"num_awarded":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Num Awarded"},"num_awarded_hardcore":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Num Awarded Hardcore"},"most_recent_awarded_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Most Recent Awarded Date"},"earned_achievements":{"items":{"$ref":"#/components/schemas/EarnedAchievement"},"type":"array","title":"Earned Achievements"}},"type":"object","required":["rom_ra_id","max_possible","num_awarded","num_awarded_hardcore","earned_achievements"],"title":"RAUserGameProgression"},"Role":{"type":"string","enum":["viewer","editor","admin"],"title":"Role"},"RomFileCategory":{"type":"string","enum":["game","dlc","hack","manual","patch","update","mod","demo","translation","prototype","cheat"],"title":"RomFileCategory"},"RomFileSchema":{"properties":{"id":{"type":"integer","title":"Id"},"rom_id":{"type":"integer","title":"Rom Id"},"file_name":{"type":"string","title":"File Name"},"file_path":{"type":"string","title":"File Path"},"file_size_bytes":{"type":"integer","title":"File Size Bytes"},"full_path":{"type":"string","title":"Full Path"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"last_modified":{"type":"string","format":"date-time","title":"Last Modified"},"crc_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Crc Hash"},"md5_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Md5 Hash"},"sha1_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sha1 Hash"},"category":{"anyOf":[{"$ref":"#/components/schemas/RomFileCategory"},{"type":"null"}]}},"type":"object","required":["id","rom_id","file_name","file_path","file_size_bytes","full_path","created_at","updated_at","last_modified","crc_hash","md5_hash","sha1_hash","category"],"title":"RomFileSchema"},"RomFiltersDict":{"properties":{"genres":{"items":{"type":"string"},"type":"array","title":"Genres"},"franchises":{"items":{"type":"string"},"type":"array","title":"Franchises"},"collections":{"items":{"type":"string"},"type":"array","title":"Collections"},"companies":{"items":{"type":"string"},"type":"array","title":"Companies"},"game_modes":{"items":{"type":"string"},"type":"array","title":"Game Modes"},"age_ratings":{"items":{"type":"string"},"type":"array","title":"Age Ratings"},"player_counts":{"items":{"type":"string"},"type":"array","title":"Player Counts"},"regions":{"items":{"type":"string"},"type":"array","title":"Regions"},"languages":{"items":{"type":"string"},"type":"array","title":"Languages"},"platforms":{"items":{"type":"integer"},"type":"array","title":"Platforms"}},"type":"object","required":["genres","franchises","collections","companies","game_modes","age_ratings","player_counts","regions","languages","platforms"],"title":"RomFiltersDict"},"RomFlashpointMetadata":{"properties":{"franchises":{"items":{"type":"string"},"type":"array","title":"Franchises"},"companies":{"items":{"type":"string"},"type":"array","title":"Companies"},"source":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source"},"genres":{"items":{"type":"string"},"type":"array","title":"Genres"},"first_release_date":{"type":"string","title":"First Release Date"},"game_modes":{"items":{"type":"string"},"type":"array","title":"Game Modes"},"status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language"},"notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Notes"}},"type":"object","title":"RomFlashpointMetadata"},"RomGamelistMetadata":{"properties":{"box2d_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box2D Url"},"box2d_back_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box2D Back Url"},"box3d_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box3D Url"},"fanart_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Fanart Url"},"image_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Image Url"},"manual_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Manual Url"},"marquee_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Marquee Url"},"miximage_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Miximage Url"},"physical_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Physical Url"},"screenshot_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Screenshot Url"},"thumbnail_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Thumbnail Url"},"title_screen_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title Screen Url"},"video_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Video Url"},"rating":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Rating"},"first_release_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"First Release Date"},"companies":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Companies"},"franchises":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Franchises"},"genres":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Genres"},"player_count":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Player Count"},"md5_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Md5 Hash"},"box3d_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box3D Path"},"miximage_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Miximage Path"},"physical_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Physical Path"},"marquee_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Marquee Path"},"video_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Video Path"}},"type":"object","title":"RomGamelistMetadata"},"RomHLTBMetadata":{"properties":{"main_story":{"type":"integer","title":"Main Story"},"main_story_count":{"type":"integer","title":"Main Story Count"},"main_plus_extra":{"type":"integer","title":"Main Plus Extra"},"main_plus_extra_count":{"type":"integer","title":"Main Plus Extra Count"},"completionist":{"type":"integer","title":"Completionist"},"completionist_count":{"type":"integer","title":"Completionist Count"},"all_styles":{"type":"integer","title":"All Styles"},"all_styles_count":{"type":"integer","title":"All Styles Count"},"release_year":{"type":"integer","title":"Release Year"},"review_score":{"type":"integer","title":"Review Score"},"review_count":{"type":"integer","title":"Review Count"},"popularity":{"type":"integer","title":"Popularity"},"completions":{"type":"integer","title":"Completions"}},"type":"object","title":"RomHLTBMetadata"},"RomHasheousMetadata":{"properties":{"tosec_match":{"type":"boolean","title":"Tosec Match"},"mame_arcade_match":{"type":"boolean","title":"Mame Arcade Match"},"mame_mess_match":{"type":"boolean","title":"Mame Mess Match"},"nointro_match":{"type":"boolean","title":"Nointro Match"},"redump_match":{"type":"boolean","title":"Redump Match"},"whdload_match":{"type":"boolean","title":"Whdload Match"},"ra_match":{"type":"boolean","title":"Ra Match"},"fbneo_match":{"type":"boolean","title":"Fbneo Match"},"puredos_match":{"type":"boolean","title":"Puredos Match"}},"type":"object","title":"RomHasheousMetadata"},"RomIGDBMetadata":{"properties":{"total_rating":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Total Rating"},"aggregated_rating":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Aggregated Rating"},"first_release_date":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"First Release Date"},"youtube_video_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Youtube Video Id"},"genres":{"items":{"type":"string"},"type":"array","title":"Genres"},"franchises":{"items":{"type":"string"},"type":"array","title":"Franchises"},"alternative_names":{"items":{"type":"string"},"type":"array","title":"Alternative Names"},"collections":{"items":{"type":"string"},"type":"array","title":"Collections"},"companies":{"items":{"type":"string"},"type":"array","title":"Companies"},"game_modes":{"items":{"type":"string"},"type":"array","title":"Game Modes"},"age_ratings":{"items":{"$ref":"#/components/schemas/IGDBAgeRating"},"type":"array","title":"Age Ratings"},"platforms":{"items":{"$ref":"#/components/schemas/IGDBMetadataPlatform"},"type":"array","title":"Platforms"},"multiplayer_modes":{"items":{"$ref":"#/components/schemas/IGDBMetadataMultiplayerMode"},"type":"array","title":"Multiplayer Modes"},"player_count":{"type":"string","title":"Player Count"},"expansions":{"items":{"$ref":"#/components/schemas/IGDBRelatedGame"},"type":"array","title":"Expansions"},"dlcs":{"items":{"$ref":"#/components/schemas/IGDBRelatedGame"},"type":"array","title":"Dlcs"},"remasters":{"items":{"$ref":"#/components/schemas/IGDBRelatedGame"},"type":"array","title":"Remasters"},"remakes":{"items":{"$ref":"#/components/schemas/IGDBRelatedGame"},"type":"array","title":"Remakes"},"expanded_games":{"items":{"$ref":"#/components/schemas/IGDBRelatedGame"},"type":"array","title":"Expanded Games"},"ports":{"items":{"$ref":"#/components/schemas/IGDBRelatedGame"},"type":"array","title":"Ports"},"similar_games":{"items":{"$ref":"#/components/schemas/IGDBRelatedGame"},"type":"array","title":"Similar Games"}},"type":"object","title":"RomIGDBMetadata"},"RomLaunchboxMetadata":{"properties":{"first_release_date":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"First Release Date"},"max_players":{"type":"integer","title":"Max Players"},"release_type":{"type":"string","title":"Release Type"},"cooperative":{"type":"boolean","title":"Cooperative"},"youtube_video_id":{"type":"string","title":"Youtube Video Id"},"community_rating":{"type":"number","title":"Community Rating"},"community_rating_count":{"type":"integer","title":"Community Rating Count"},"wikipedia_url":{"type":"string","title":"Wikipedia Url"},"esrb":{"type":"string","title":"Esrb"},"genres":{"items":{"type":"string"},"type":"array","title":"Genres"},"companies":{"items":{"type":"string"},"type":"array","title":"Companies"},"images":{"items":{"$ref":"#/components/schemas/LaunchboxImage"},"type":"array","title":"Images"}},"type":"object","title":"RomLaunchboxMetadata"},"RomMetadataSchema":{"properties":{"rom_id":{"type":"integer","title":"Rom Id"},"genres":{"items":{"type":"string"},"type":"array","title":"Genres"},"franchises":{"items":{"type":"string"},"type":"array","title":"Franchises"},"collections":{"items":{"type":"string"},"type":"array","title":"Collections"},"companies":{"items":{"type":"string"},"type":"array","title":"Companies"},"game_modes":{"items":{"type":"string"},"type":"array","title":"Game Modes"},"age_ratings":{"items":{"type":"string"},"type":"array","title":"Age Ratings"},"player_count":{"type":"string","title":"Player Count"},"first_release_date":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"First Release Date"},"average_rating":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Average Rating"}},"type":"object","required":["rom_id","genres","franchises","collections","companies","game_modes","age_ratings","player_count","first_release_date","average_rating"],"title":"RomMetadataSchema"},"RomMobyMetadata":{"properties":{"moby_score":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Moby Score"},"genres":{"items":{"type":"string"},"type":"array","title":"Genres"},"alternate_titles":{"items":{"type":"string"},"type":"array","title":"Alternate Titles"},"platforms":{"items":{"$ref":"#/components/schemas/MobyMetadataPlatform"},"type":"array","title":"Platforms"}},"type":"object","title":"RomMobyMetadata"},"RomRAMetadata":{"properties":{"first_release_date":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"First Release Date"},"genres":{"items":{"type":"string"},"type":"array","title":"Genres"},"companies":{"items":{"type":"string"},"type":"array","title":"Companies"},"achievements":{"items":{"$ref":"#/components/schemas/RAGameRomAchievement"},"type":"array","title":"Achievements"}},"type":"object","title":"RomRAMetadata"},"RomSSMetadata":{"properties":{"bezel_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bezel Url"},"box2d_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box2D Url"},"box2d_side_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box2D Side Url"},"box2d_back_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box2D Back Url"},"box3d_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box3D Url"},"fanart_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Fanart Url"},"fullbox_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Fullbox Url"},"logo_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo Url"},"manual_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Manual Url"},"marquee_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Marquee Url"},"miximage_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Miximage Url"},"physical_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Physical Url"},"screenshot_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Screenshot Url"},"steamgrid_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Steamgrid Url"},"title_screen_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title Screen Url"},"video_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Video Url"},"video_normalized_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Video Normalized Url"},"bezel_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bezel Path"},"box2d_back_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box2D Back Path"},"box3d_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box3D Path"},"fanart_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Fanart Path"},"miximage_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Miximage Path"},"physical_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Physical Path"},"marquee_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Marquee Path"},"logo_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo Path"},"video_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Video Path"},"ss_score":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ss Score"},"first_release_date":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"First Release Date"},"alternative_names":{"items":{"type":"string"},"type":"array","title":"Alternative Names"},"companies":{"items":{"type":"string"},"type":"array","title":"Companies"},"franchises":{"items":{"type":"string"},"type":"array","title":"Franchises"},"game_modes":{"items":{"type":"string"},"type":"array","title":"Game Modes"},"genres":{"items":{"type":"string"},"type":"array","title":"Genres"},"player_count":{"type":"string","title":"Player Count"}},"type":"object","title":"RomSSMetadata"},"RomUserSchema":{"properties":{"id":{"type":"integer","title":"Id"},"user_id":{"type":"integer","title":"User Id"},"rom_id":{"type":"integer","title":"Rom Id"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"last_played":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Played"},"is_main_sibling":{"type":"boolean","title":"Is Main Sibling"},"backlogged":{"type":"boolean","title":"Backlogged"},"now_playing":{"type":"boolean","title":"Now Playing"},"hidden":{"type":"boolean","title":"Hidden"},"rating":{"type":"integer","title":"Rating"},"difficulty":{"type":"integer","title":"Difficulty"},"completion":{"type":"integer","title":"Completion"},"status":{"anyOf":[{"$ref":"#/components/schemas/RomUserStatus"},{"type":"null"}]},"user__username":{"type":"string","title":"User Username"}},"type":"object","required":["id","user_id","rom_id","created_at","updated_at","last_played","is_main_sibling","backlogged","now_playing","hidden","rating","difficulty","completion","status","user__username"],"title":"RomUserSchema"},"RomUserStatus":{"type":"string","enum":["incomplete","finished","completed_100","retired","never_playing"],"title":"RomUserStatus"},"RoomsResponse":{"properties":{"room_name":{"type":"string","title":"Room Name"},"current":{"type":"integer","title":"Current"},"max":{"type":"integer","title":"Max"},"player_name":{"type":"string","title":"Player Name"},"hasPassword":{"type":"boolean","title":"Haspassword"}},"type":"object","required":["room_name","current","max","player_name","hasPassword"],"title":"RoomsResponse"},"SGDBResource":{"properties":{"thumb":{"type":"string","title":"Thumb"},"url":{"type":"string","title":"Url"},"type":{"type":"string","title":"Type"}},"type":"object","required":["thumb","url","type"],"title":"SGDBResource"},"SaveSchema":{"properties":{"id":{"type":"integer","title":"Id"},"rom_id":{"type":"integer","title":"Rom Id"},"user_id":{"type":"integer","title":"User Id"},"file_name":{"type":"string","title":"File Name"},"file_name_no_tags":{"type":"string","title":"File Name No Tags"},"file_name_no_ext":{"type":"string","title":"File Name No Ext"},"file_extension":{"type":"string","title":"File Extension"},"file_path":{"type":"string","title":"File Path"},"file_size_bytes":{"type":"integer","title":"File Size Bytes"},"full_path":{"type":"string","title":"Full Path"},"download_path":{"type":"string","title":"Download Path"},"missing_from_fs":{"type":"boolean","title":"Missing From Fs"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"emulator":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Emulator"},"screenshot":{"anyOf":[{"$ref":"#/components/schemas/ScreenshotSchema"},{"type":"null"}]}},"type":"object","required":["id","rom_id","user_id","file_name","file_name_no_tags","file_name_no_ext","file_extension","file_path","file_size_bytes","full_path","download_path","missing_from_fs","created_at","updated_at","emulator","screenshot"],"title":"SaveSchema"},"ScanStats":{"properties":{"total_platforms":{"type":"integer","title":"Total Platforms"},"total_roms":{"type":"integer","title":"Total Roms"},"scanned_platforms":{"type":"integer","title":"Scanned Platforms"},"new_platforms":{"type":"integer","title":"New Platforms"},"identified_platforms":{"type":"integer","title":"Identified Platforms"},"scanned_roms":{"type":"integer","title":"Scanned Roms"},"new_roms":{"type":"integer","title":"New Roms"},"identified_roms":{"type":"integer","title":"Identified Roms"},"scanned_firmware":{"type":"integer","title":"Scanned Firmware"},"new_firmware":{"type":"integer","title":"New Firmware"}},"type":"object","required":["total_platforms","total_roms","scanned_platforms","new_platforms","identified_platforms","scanned_roms","new_roms","identified_roms","scanned_firmware","new_firmware"],"title":"ScanStats"},"ScanTaskMeta":{"properties":{"scan_stats":{"anyOf":[{"$ref":"#/components/schemas/ScanStats"},{"type":"null"}]}},"type":"object","required":["scan_stats"],"title":"ScanTaskMeta"},"ScanTaskStatusResponse":{"properties":{"task_name":{"type":"string","title":"Task Name"},"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"enqueued_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Enqueued At"},"started_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Started At"},"ended_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ended At"},"task_type":{"type":"string","const":"scan","title":"Task Type"},"meta":{"$ref":"#/components/schemas/ScanTaskMeta"}},"type":"object","required":["task_name","task_id","status","created_at","enqueued_at","started_at","ended_at","task_type","meta"],"title":"ScanTaskStatusResponse"},"ScreenshotSchema":{"properties":{"id":{"type":"integer","title":"Id"},"rom_id":{"type":"integer","title":"Rom Id"},"user_id":{"type":"integer","title":"User Id"},"file_name":{"type":"string","title":"File Name"},"file_name_no_tags":{"type":"string","title":"File Name No Tags"},"file_name_no_ext":{"type":"string","title":"File Name No Ext"},"file_extension":{"type":"string","title":"File Extension"},"file_path":{"type":"string","title":"File Path"},"file_size_bytes":{"type":"integer","title":"File Size Bytes"},"full_path":{"type":"string","title":"Full Path"},"download_path":{"type":"string","title":"Download Path"},"missing_from_fs":{"type":"boolean","title":"Missing From Fs"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","rom_id","user_id","file_name","file_name_no_tags","file_name_no_ext","file_extension","file_path","file_size_bytes","full_path","download_path","missing_from_fs","created_at","updated_at"],"title":"ScreenshotSchema"},"SearchCoverSchema":{"properties":{"name":{"type":"string","title":"Name"},"resources":{"items":{"$ref":"#/components/schemas/SGDBResource"},"type":"array","title":"Resources"}},"type":"object","required":["name","resources"],"title":"SearchCoverSchema"},"SearchRomSchema":{"properties":{"id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Id"},"igdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Igdb Id"},"moby_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Moby Id"},"ss_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ss Id"},"sgdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Sgdb Id"},"flashpoint_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flashpoint Id"},"launchbox_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Launchbox Id"},"platform_id":{"type":"integer","title":"Platform Id"},"name":{"type":"string","title":"Name"},"slug":{"type":"string","title":"Slug","default":""},"summary":{"type":"string","title":"Summary","default":""},"igdb_url_cover":{"type":"string","title":"Igdb Url Cover","default":""},"moby_url_cover":{"type":"string","title":"Moby Url Cover","default":""},"ss_url_cover":{"type":"string","title":"Ss Url Cover","default":""},"sgdb_url_cover":{"type":"string","title":"Sgdb Url Cover","default":""},"flashpoint_url_cover":{"type":"string","title":"Flashpoint Url Cover","default":""},"launchbox_url_cover":{"type":"string","title":"Launchbox Url Cover","default":""},"is_unidentified":{"type":"boolean","title":"Is Unidentified"},"is_identified":{"type":"boolean","title":"Is Identified"}},"type":"object","required":["platform_id","name","is_unidentified","is_identified"],"title":"SearchRomSchema"},"SiblingRomSchema":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"fs_name_no_tags":{"type":"string","title":"Fs Name No Tags"},"fs_name_no_ext":{"type":"string","title":"Fs Name No Ext"},"sort_comparator":{"type":"string","title":"Sort Comparator","readOnly":true}},"type":"object","required":["id","name","fs_name_no_tags","fs_name_no_ext","sort_comparator"],"title":"SiblingRomSchema"},"SimpleRomSchema":{"properties":{"id":{"type":"integer","title":"Id"},"igdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Igdb Id"},"sgdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Sgdb Id"},"moby_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Moby Id"},"ss_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ss Id"},"ra_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ra Id"},"launchbox_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Launchbox Id"},"hasheous_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Hasheous Id"},"tgdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Tgdb Id"},"flashpoint_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flashpoint Id"},"hltb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Hltb Id"},"gamelist_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Gamelist Id"},"platform_id":{"type":"integer","title":"Platform Id"},"platform_slug":{"type":"string","title":"Platform Slug"},"platform_fs_slug":{"type":"string","title":"Platform Fs Slug"},"platform_custom_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Platform Custom Name"},"platform_display_name":{"type":"string","title":"Platform Display Name"},"fs_name":{"type":"string","title":"Fs Name"},"fs_name_no_tags":{"type":"string","title":"Fs Name No Tags"},"fs_name_no_ext":{"type":"string","title":"Fs Name No Ext"},"fs_extension":{"type":"string","title":"Fs Extension"},"fs_path":{"type":"string","title":"Fs Path"},"fs_size_bytes":{"type":"integer","title":"Fs Size Bytes"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary"},"alternative_names":{"items":{"type":"string"},"type":"array","title":"Alternative Names"},"youtube_video_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Youtube Video Id"},"metadatum":{"$ref":"#/components/schemas/RomMetadataSchema"},"igdb_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomIGDBMetadata"},{"type":"null"}]},"moby_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomMobyMetadata"},{"type":"null"}]},"ss_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomSSMetadata"},{"type":"null"}]},"launchbox_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomLaunchboxMetadata"},{"type":"null"}]},"hasheous_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomHasheousMetadata"},{"type":"null"}]},"flashpoint_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomFlashpointMetadata"},{"type":"null"}]},"hltb_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomHLTBMetadata"},{"type":"null"}]},"gamelist_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomGamelistMetadata"},{"type":"null"}]},"manual_metadata":{"anyOf":[{"$ref":"#/components/schemas/ManualMetadata"},{"type":"null"}]},"path_cover_small":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Small"},"path_cover_large":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Large"},"url_cover":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url Cover"},"has_manual":{"type":"boolean","title":"Has Manual"},"path_manual":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Manual"},"url_manual":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url Manual"},"is_identifying":{"type":"boolean","title":"Is Identifying","default":false},"is_unidentified":{"type":"boolean","title":"Is Unidentified"},"is_identified":{"type":"boolean","title":"Is Identified"},"revision":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Revision"},"regions":{"items":{"type":"string"},"type":"array","title":"Regions"},"languages":{"items":{"type":"string"},"type":"array","title":"Languages"},"tags":{"items":{"type":"string"},"type":"array","title":"Tags"},"crc_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Crc Hash"},"md5_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Md5 Hash"},"sha1_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sha1 Hash"},"has_simple_single_file":{"type":"boolean","title":"Has Simple Single File"},"has_nested_single_file":{"type":"boolean","title":"Has Nested Single File"},"has_multiple_files":{"type":"boolean","title":"Has Multiple Files"},"files":{"items":{"$ref":"#/components/schemas/RomFileSchema"},"type":"array","title":"Files"},"full_path":{"type":"string","title":"Full Path"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"missing_from_fs":{"type":"boolean","title":"Missing From Fs"},"has_notes":{"type":"boolean","title":"Has Notes"},"siblings":{"items":{"$ref":"#/components/schemas/SiblingRomSchema"},"type":"array","title":"Siblings"},"rom_user":{"$ref":"#/components/schemas/RomUserSchema"},"merged_screenshots":{"items":{"type":"string"},"type":"array","title":"Merged Screenshots"},"merged_ra_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomRAMetadata"},{"type":"null"}]}},"type":"object","required":["id","igdb_id","sgdb_id","moby_id","ss_id","ra_id","launchbox_id","hasheous_id","tgdb_id","flashpoint_id","hltb_id","gamelist_id","platform_id","platform_slug","platform_fs_slug","platform_custom_name","platform_display_name","fs_name","fs_name_no_tags","fs_name_no_ext","fs_extension","fs_path","fs_size_bytes","name","slug","summary","alternative_names","youtube_video_id","metadatum","igdb_metadata","moby_metadata","ss_metadata","launchbox_metadata","hasheous_metadata","flashpoint_metadata","hltb_metadata","gamelist_metadata","manual_metadata","path_cover_small","path_cover_large","url_cover","has_manual","path_manual","url_manual","is_unidentified","is_identified","revision","regions","languages","tags","crc_hash","md5_hash","sha1_hash","has_simple_single_file","has_nested_single_file","has_multiple_files","files","full_path","created_at","updated_at","missing_from_fs","has_notes","siblings","rom_user","merged_screenshots","merged_ra_metadata"],"title":"SimpleRomSchema"},"SmartCollectionSchema":{"properties":{"name":{"type":"string","title":"Name"},"description":{"type":"string","title":"Description","default":""},"rom_ids":{"items":{"type":"integer"},"type":"array","uniqueItems":true,"title":"Rom Ids"},"rom_count":{"type":"integer","title":"Rom Count"},"path_cover_small":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Small"},"path_cover_large":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Large"},"path_covers_small":{"items":{"type":"string"},"type":"array","title":"Path Covers Small"},"path_covers_large":{"items":{"type":"string"},"type":"array","title":"Path Covers Large"},"is_public":{"type":"boolean","title":"Is Public","default":false},"is_favorite":{"type":"boolean","title":"Is Favorite","default":false},"is_virtual":{"type":"boolean","title":"Is Virtual","default":false},"is_smart":{"type":"boolean","title":"Is Smart","default":true},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"id":{"type":"integer","title":"Id"},"filter_criteria":{"additionalProperties":true,"type":"object","title":"Filter Criteria"},"filter_summary":{"type":"string","title":"Filter Summary"},"user_id":{"type":"integer","title":"User Id"},"user__username":{"type":"string","title":"User Username"}},"type":"object","required":["name","rom_ids","rom_count","path_cover_small","path_cover_large","path_covers_small","path_covers_large","created_at","updated_at","id","filter_criteria","filter_summary","user_id","user__username"],"title":"SmartCollectionSchema"},"StateSchema":{"properties":{"id":{"type":"integer","title":"Id"},"rom_id":{"type":"integer","title":"Rom Id"},"user_id":{"type":"integer","title":"User Id"},"file_name":{"type":"string","title":"File Name"},"file_name_no_tags":{"type":"string","title":"File Name No Tags"},"file_name_no_ext":{"type":"string","title":"File Name No Ext"},"file_extension":{"type":"string","title":"File Extension"},"file_path":{"type":"string","title":"File Path"},"file_size_bytes":{"type":"integer","title":"File Size Bytes"},"full_path":{"type":"string","title":"Full Path"},"download_path":{"type":"string","title":"Download Path"},"missing_from_fs":{"type":"boolean","title":"Missing From Fs"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"emulator":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Emulator"},"screenshot":{"anyOf":[{"$ref":"#/components/schemas/ScreenshotSchema"},{"type":"null"}]}},"type":"object","required":["id","rom_id","user_id","file_name","file_name_no_tags","file_name_no_ext","file_extension","file_path","file_size_bytes","full_path","download_path","missing_from_fs","created_at","updated_at","emulator","screenshot"],"title":"StateSchema"},"StatsReturn":{"properties":{"PLATFORMS":{"type":"integer","title":"Platforms"},"ROMS":{"type":"integer","title":"Roms"},"SAVES":{"type":"integer","title":"Saves"},"STATES":{"type":"integer","title":"States"},"SCREENSHOTS":{"type":"integer","title":"Screenshots"},"TOTAL_FILESIZE_BYTES":{"type":"integer","title":"Total Filesize Bytes"}},"type":"object","required":["PLATFORMS","ROMS","SAVES","STATES","SCREENSHOTS","TOTAL_FILESIZE_BYTES"],"title":"StatsReturn"},"SystemDict":{"properties":{"VERSION":{"type":"string","title":"Version"},"SHOW_SETUP_WIZARD":{"type":"boolean","title":"Show Setup Wizard"}},"type":"object","required":["VERSION","SHOW_SETUP_WIZARD"],"title":"SystemDict"},"TaskExecutionResponse":{"properties":{"task_name":{"type":"string","title":"Task Name"},"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"enqueued_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Enqueued At"}},"type":"object","required":["task_name","task_id","status","created_at","enqueued_at"],"title":"TaskExecutionResponse"},"TaskInfo":{"properties":{"name":{"type":"string","title":"Name"},"type":{"$ref":"#/components/schemas/TaskType"},"manual_run":{"type":"boolean","title":"Manual Run"},"title":{"type":"string","title":"Title"},"description":{"type":"string","title":"Description"},"enabled":{"type":"boolean","title":"Enabled"},"cron_string":{"type":"string","title":"Cron String"}},"type":"object","required":["name","type","manual_run","title","description","enabled","cron_string"],"title":"TaskInfo"},"TaskType":{"type":"string","enum":["scan","conversion","cleanup","update","watcher","generic"],"title":"TaskType","description":"Enumeration of task types for categorization and UI display."},"TasksDict":{"properties":{"ENABLE_SCHEDULED_RESCAN":{"type":"boolean","title":"Enable Scheduled Rescan"},"SCHEDULED_RESCAN_CRON":{"type":"string","title":"Scheduled Rescan Cron"},"ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB":{"type":"boolean","title":"Enable Scheduled Update Switch Titledb"},"SCHEDULED_UPDATE_SWITCH_TITLEDB_CRON":{"type":"string","title":"Scheduled Update Switch Titledb Cron"},"ENABLE_SCHEDULED_UPDATE_LAUNCHBOX_METADATA":{"type":"boolean","title":"Enable Scheduled Update Launchbox Metadata"},"SCHEDULED_UPDATE_LAUNCHBOX_METADATA_CRON":{"type":"string","title":"Scheduled Update Launchbox Metadata Cron"},"ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP":{"type":"boolean","title":"Enable Scheduled Convert Images To Webp"},"SCHEDULED_CONVERT_IMAGES_TO_WEBP_CRON":{"type":"string","title":"Scheduled Convert Images To Webp Cron"}},"type":"object","required":["ENABLE_SCHEDULED_RESCAN","SCHEDULED_RESCAN_CRON","ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB","SCHEDULED_UPDATE_SWITCH_TITLEDB_CRON","ENABLE_SCHEDULED_UPDATE_LAUNCHBOX_METADATA","SCHEDULED_UPDATE_LAUNCHBOX_METADATA_CRON","ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP","SCHEDULED_CONVERT_IMAGES_TO_WEBP_CRON"],"title":"TasksDict"},"TinfoilFeedFileSchema":{"properties":{"url":{"type":"string","title":"Url"},"size":{"type":"integer","title":"Size"}},"type":"object","required":["url","size"],"title":"TinfoilFeedFileSchema"},"TinfoilFeedSchema":{"properties":{"files":{"items":{"$ref":"#/components/schemas/TinfoilFeedFileSchema"},"type":"array","title":"Files"},"directories":{"items":{"type":"string"},"type":"array","title":"Directories"},"titledb":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Titledb"},"success":{"type":"string","title":"Success"},"error":{"type":"string","title":"Error"}},"type":"object","required":["files","directories"],"title":"TinfoilFeedSchema"},"TokenResponse":{"properties":{"access_token":{"type":"string","title":"Access Token"},"refresh_token":{"type":"string","title":"Refresh Token"},"token_type":{"type":"string","title":"Token Type"},"expires":{"type":"integer","title":"Expires"}},"type":"object","required":["access_token","token_type","expires"],"title":"TokenResponse"},"UpdateStats":{"properties":{"processed":{"type":"integer","title":"Processed"},"total":{"type":"integer","title":"Total"}},"type":"object","required":["processed","total"],"title":"UpdateStats"},"UpdateTaskMeta":{"properties":{"update_stats":{"anyOf":[{"$ref":"#/components/schemas/UpdateStats"},{"type":"null"}]}},"type":"object","required":["update_stats"],"title":"UpdateTaskMeta"},"UpdateTaskStatusResponse":{"properties":{"task_name":{"type":"string","title":"Task Name"},"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"enqueued_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Enqueued At"},"started_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Started At"},"ended_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ended At"},"task_type":{"type":"string","const":"update","title":"Task Type"},"meta":{"$ref":"#/components/schemas/UpdateTaskMeta"}},"type":"object","required":["task_name","task_id","status","created_at","enqueued_at","started_at","ended_at","task_type","meta"],"title":"UpdateTaskStatusResponse"},"UserCollectionSchema":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["id","name"],"title":"UserCollectionSchema"},"UserForm":{"properties":{"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"password":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Password"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"role":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Role"},"enabled":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Enabled"},"ra_username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ra Username"},"avatar":{"anyOf":[{"type":"string","format":"binary"},{"type":"null"}],"title":"Avatar"},"ui_settings":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ui Settings"}},"type":"object","title":"UserForm"},"UserNoteSchema":{"properties":{"id":{"type":"integer","title":"Id"},"title":{"type":"string","title":"Title"},"content":{"type":"string","title":"Content"},"is_public":{"type":"boolean","title":"Is Public"},"tags":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Tags"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"user_id":{"type":"integer","title":"User Id"},"username":{"type":"string","title":"Username"}},"type":"object","required":["id","title","content","is_public","created_at","updated_at","user_id","username"],"title":"UserNoteSchema"},"UserSchema":{"properties":{"id":{"type":"integer","title":"Id"},"username":{"type":"string","title":"Username"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"enabled":{"type":"boolean","title":"Enabled"},"role":{"$ref":"#/components/schemas/Role"},"oauth_scopes":{"items":{"type":"string"},"type":"array","title":"Oauth Scopes"},"avatar_path":{"type":"string","title":"Avatar Path"},"last_login":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Login"},"last_active":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Active"},"ra_username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ra Username"},"ra_progression":{"anyOf":[{"$ref":"#/components/schemas/RAProgression"},{"type":"null"}]},"ui_settings":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Ui Settings"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","username","email","enabled","role","oauth_scopes","avatar_path","last_login","last_active","created_at","updated_at"],"title":"UserSchema"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"VirtualCollectionSchema":{"properties":{"name":{"type":"string","title":"Name"},"description":{"type":"string","title":"Description"},"rom_ids":{"items":{"type":"integer"},"type":"array","uniqueItems":true,"title":"Rom Ids"},"rom_count":{"type":"integer","title":"Rom Count"},"path_cover_small":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Small"},"path_cover_large":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Large"},"path_covers_small":{"items":{"type":"string"},"type":"array","title":"Path Covers Small"},"path_covers_large":{"items":{"type":"string"},"type":"array","title":"Path Covers Large"},"is_public":{"type":"boolean","title":"Is Public","default":false},"is_favorite":{"type":"boolean","title":"Is Favorite","default":false},"is_virtual":{"type":"boolean","title":"Is Virtual","default":true},"is_smart":{"type":"boolean","title":"Is Smart","default":false},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"id":{"type":"string","title":"Id"},"type":{"type":"string","title":"Type"}},"type":"object","required":["name","description","rom_ids","rom_count","path_cover_small","path_cover_large","path_covers_small","path_covers_large","created_at","updated_at","id","type"],"title":"VirtualCollectionSchema"},"WatcherTaskMeta":{"properties":{},"type":"object","title":"WatcherTaskMeta"},"WatcherTaskStatusResponse":{"properties":{"task_name":{"type":"string","title":"Task Name"},"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"enqueued_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Enqueued At"},"started_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Started At"},"ended_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ended At"},"task_type":{"type":"string","const":"watcher","title":"Task Type"},"meta":{"$ref":"#/components/schemas/WatcherTaskMeta"}},"type":"object","required":["task_name","task_id","status","created_at","enqueued_at","started_at","ended_at","task_type","meta"],"title":"WatcherTaskStatusResponse"},"WebrcadeFeedCategorySchema":{"properties":{"title":{"type":"string","title":"Title"},"longTitle":{"type":"string","title":"Longtitle"},"background":{"type":"string","title":"Background"},"thumbnail":{"type":"string","title":"Thumbnail"},"description":{"type":"string","title":"Description"},"items":{"items":{"$ref":"#/components/schemas/WebrcadeFeedItemSchema"},"type":"array","title":"Items"}},"type":"object","required":["title","items"],"title":"WebrcadeFeedCategorySchema"},"WebrcadeFeedItemPropsSchema":{"properties":{"rom":{"type":"string","title":"Rom"}},"type":"object","required":["rom"],"title":"WebrcadeFeedItemPropsSchema"},"WebrcadeFeedItemSchema":{"properties":{"title":{"type":"string","title":"Title"},"longTitle":{"type":"string","title":"Longtitle"},"description":{"type":"string","title":"Description"},"type":{"type":"string","title":"Type"},"thumbnail":{"type":"string","title":"Thumbnail"},"background":{"type":"string","title":"Background"},"props":{"$ref":"#/components/schemas/WebrcadeFeedItemPropsSchema"}},"type":"object","required":["title","type","props"],"title":"WebrcadeFeedItemSchema"},"WebrcadeFeedSchema":{"properties":{"title":{"type":"string","title":"Title"},"longTitle":{"type":"string","title":"Longtitle"},"description":{"type":"string","title":"Description"},"thumbnail":{"type":"string","title":"Thumbnail"},"background":{"type":"string","title":"Background"},"categories":{"items":{"$ref":"#/components/schemas/WebrcadeFeedCategorySchema"},"type":"array","title":"Categories"}},"type":"object","required":["title","categories"],"title":"WebrcadeFeedSchema"}},"securitySchemes":{"OAuth2PasswordBearer":{"type":"oauth2","flows":{"password":{"scopes":{"me.read":"View your profile","roms.read":"View ROMs","platforms.read":"View platforms","assets.read":"View assets","firmware.read":"View firmware","roms.user.read":"View user-rom properties","collections.read":"View collections","me.write":"Modify your profile","assets.write":"Modify assets","roms.user.write":"Modify user-rom properties","collections.write":"Modify collections","roms.write":"Modify ROMs","platforms.write":"Modify platforms","firmware.write":"Modify firmware","users.read":"View users","users.write":"Modify users","tasks.run":"Run tasks"},"tokenUrl":"/token"}}},"HTTPBasic":{"type":"http","scheme":"basic"}}}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"RomM API","version":"4.7.0"},"paths":{"/api/heartbeat":{"get":{"tags":["system"],"summary":"Heartbeat","description":"Endpoint to set the CSRF token in cache and return all the basic RomM config\n\nReturns:\n HeartbeatReturn: TypedDict structure with all the defined values in the HeartbeatReturn class.","operationId":"heartbeat_api_heartbeat_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HeartbeatResponse"}}}}}}},"/api/heartbeat/metadata/{source}":{"get":{"tags":["system"],"summary":"Metadata Heartbeat","description":"Endpoint to return the heartbeat of the metadata sources","operationId":"metadata_heartbeat_api_heartbeat_metadata__source__get","parameters":[{"name":"source","in":"path","required":true,"schema":{"type":"string","title":"Source"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"boolean","title":"Response Metadata Heartbeat Api Heartbeat Metadata Source Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/setup/library":{"get":{"tags":["system"],"summary":"Get Setup Library Info","description":"Get library structure information for setup wizard.\n\nOnly accessible during initial setup (no admin users) or with authentication.\n\nReturns:\n - detected_structure: \"struct_a\" (roms/{platform}), \"struct_b\" ({platform}/roms), or None\n - existing_platforms: list of objects with fs_slug and rom_count\n - supported_platforms: list of all supported platforms with metadata","operationId":"get_setup_library_info_api_setup_library_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]},{"HTTPBasic":[]}]}},"/api/setup/platforms":{"post":{"tags":["system"],"summary":"Create Setup Platforms","description":"Create platform folders during setup wizard.\n\nOnly accessible during initial setup (no admin users) or with authentication.\n\nArgs:\n platform_slugs: List of platform fs_slugs to create\n\nReturns:\n - success: bool\n - created_count: number of platforms created\n - message: success or error message","operationId":"create_setup_platforms_api_setup_platforms_post","requestBody":{"content":{"application/json":{"schema":{"items":{"type":"string"},"type":"array","title":"Platform Slugs"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]},{"HTTPBasic":[]}]}},"/api/login":{"post":{"tags":["auth"],"summary":"Login","description":"Session login endpoint\n\nArgs:\n request (Request): Fastapi Request object\n credentials: Defaults to Depends(HTTPBasic()).\n\nRaises:\n CredentialsException: Invalid credentials\n UserDisabledException: Auth is disabled","operationId":"login_api_login_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"HTTPBasic":[]}]}},"/api/logout":{"post":{"tags":["auth"],"summary":"Logout","description":"Session logout endpoint\n\nArgs:\n request (Request): Fastapi Request object","operationId":"logout_api_logout_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/token":{"post":{"tags":["auth"],"summary":"Token","description":"OAuth2 token endpoint\n\nArgs:\n form_data (Annotated[OAuth2RequestForm, Depends): Form Data with OAuth2 info\n\nRaises:\n HTTPException: Missing refresh token\n HTTPException: Invalid refresh token\n HTTPException: Missing username or password\n HTTPException: Invalid username or password\n HTTPException: Client credentials are not yet supported\n HTTPException: Invalid or unsupported grant type\n HTTPException: Insufficient scope\n\nReturns:\n TokenResponse: TypedDict with the new generated token info","operationId":"token_api_token_post","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Body_token_api_token_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/login/openid":{"get":{"tags":["auth"],"summary":"Login Via Openid","description":"OIDC login endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nRaises:\n OIDCDisabledException: OAuth is disabled\n OIDCNotConfiguredException: OAuth not configured\n\nReturns:\n RedirectResponse: Redirect to OIDC provider","operationId":"login_via_openid_api_login_openid_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/oauth/openid":{"get":{"tags":["auth"],"summary":"Auth Openid","description":"OIDC callback endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nRaises:\n OIDCDisabledException: OAuth is disabled\n OIDCNotConfiguredException: OAuth not configured\n AuthCredentialsException: Invalid credentials\n UserDisabledException: Auth is disabled\n\nReturns:\n RedirectResponse: Redirect to home page","operationId":"auth_openid_api_oauth_openid_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/forgot-password":{"post":{"tags":["auth"],"summary":"Request Password Reset","description":"Request a password reset link for the user.\n\nArgs:\n username (str): Username of the user requesting the reset\nReturns:\n None: Returns 200 OK status","operationId":"request_password_reset_api_forgot_password_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_request_password_reset_api_forgot_password_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/reset-password":{"post":{"tags":["auth"],"summary":"Reset Password","description":"Reset password using the token.\n\nArgs:\n token (str): Reset token from the URL\n new_password (str): New user password\n\nReturns:\n None: Returns 200 OK status","operationId":"reset_password_api_reset_password_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_reset_password_api_reset_password_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/users":{"get":{"tags":["users"],"summary":"Get Users","description":"Get all users endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n list[UserSchema]: All users stored in the RomM's database","operationId":"get_users_api_users_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/UserSchema"},"type":"array","title":"Response Get Users Api Users Get"}}}}},"security":[{"OAuth2PasswordBearer":["users.read"]},{"HTTPBasic":[]}]},"post":{"tags":["users"],"summary":"Add User","description":"Create user endpoint\n\nArgs:\n request (Request): Fastapi Requests object\n username (str): User username\n password (str): User password\n email (str): User email\n role (str): RomM Role object represented as string\n\nReturns:\n UserSchema: Newly created user","operationId":"add_user_api_users_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_add_user_api_users_post"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]},{"HTTPBasic":[]}]}},"/api/users/invite-link":{"post":{"tags":["users"],"summary":"Create Invite Link","description":"Create an invite link for a user.\n\nArgs:\n request (Request): FastAPI Request object\n role (str): The role of the user\n\nReturns:\n InviteLinkSchema: Invite link","operationId":"create_invite_link_api_users_invite_link_post","security":[{"OAuth2PasswordBearer":[]},{"HTTPBasic":[]}],"parameters":[{"name":"role","in":"query","required":true,"schema":{"type":"string","title":"Role"}}],"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteLinkSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/users/register":{"post":{"tags":["users"],"summary":"Create User From Invite","description":"Create user endpoint with invite link\n\nArgs:\n username (str): User username\n email (str): User email\n password (str): User password\n token (str): Invite link token\n\nReturns:\n UserSchema: Newly created user","operationId":"create_user_from_invite_api_users_register_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_create_user_from_invite_api_users_register_post"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/users/identifiers":{"get":{"tags":["users"],"summary":"Get User Identifiers","description":"Get all user identifiers endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n list[int]: All user ids stored in the RomM's database","operationId":"get_user_identifiers_api_users_identifiers_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"type":"integer"},"type":"array","title":"Response Get User Identifiers Api Users Identifiers Get"}}}}},"security":[{"OAuth2PasswordBearer":["users.read"]},{"HTTPBasic":[]}]}},"/api/users/me":{"get":{"tags":["users"],"summary":"Get Current User","description":"Get current user endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n UserSchema | None: Current user","operationId":"get_current_user_api_users_me_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/UserSchema"},{"type":"null"}],"title":"Response Get Current User Api Users Me Get"}}}}},"security":[{"OAuth2PasswordBearer":["me.read"]},{"HTTPBasic":[]}]}},"/api/users/{id}":{"get":{"tags":["users"],"summary":"Get User","description":"Get user endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n UserSchem: User stored in the RomM's database","operationId":"get_user_api_users__id__get","security":[{"OAuth2PasswordBearer":["users.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["users"],"summary":"Update User","description":"Update user endpoint\n\nArgs:\n request (Request): Fastapi Requests object\n user_id (int): User internal id\n form_data (Annotated[UserUpdateForm, Depends): Form Data with user updated info\n\nRaises:\n HTTPException: User is not found in database\n HTTPException: Username already in use by another user\n\nReturns:\n UserSchema: Updated user info","operationId":"update_user_api_users__id__put","security":[{"OAuth2PasswordBearer":["me.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/UserForm"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["users"],"summary":"Delete User","description":"Delete a user by ID.\n\nRaises:\n HTTPException: User is not found in database\n HTTPException: User deleting itself\n HTTPException: User is the last admin user","operationId":"delete_user_api_users__id__delete","security":[{"OAuth2PasswordBearer":["users.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"User internal id.","title":"Id"},"description":"User internal id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/users/{id}/ra/refresh":{"post":{"tags":["users"],"summary":"Refresh RetroAchievements","description":"Refresh RetroAchievements progression data for a user.","operationId":"refresh_retro_achievements_api_users__id__ra_refresh_post","security":[{"OAuth2PasswordBearer":["me.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"User internal id.","title":"Id"},"description":"User internal id."}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_refresh_retro_achievements_api_users__id__ra_refresh_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/devices":{"get":{"tags":["devices"],"summary":"Get Devices","operationId":"get_devices_api_devices_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/DeviceSchema"},"type":"array","title":"Response Get Devices Api Devices Get"}}}}},"security":[{"OAuth2PasswordBearer":["devices.read"]},{"HTTPBasic":[]}]},"post":{"tags":["devices"],"summary":"Register Device","operationId":"register_device_api_devices_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceCreatePayload"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceCreateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":["devices.write"]},{"HTTPBasic":[]}]}},"/api/devices/{device_id}":{"get":{"tags":["devices"],"summary":"Get Device","operationId":"get_device_api_devices__device_id__get","security":[{"OAuth2PasswordBearer":["devices.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"device_id","in":"path","required":true,"schema":{"type":"string","title":"Device Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["devices"],"summary":"Update Device","operationId":"update_device_api_devices__device_id__put","security":[{"OAuth2PasswordBearer":["devices.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"device_id","in":"path","required":true,"schema":{"type":"string","title":"Device Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceUpdatePayload"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["devices"],"summary":"Delete Device","operationId":"delete_device_api_devices__device_id__delete","security":[{"OAuth2PasswordBearer":["devices.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"device_id","in":"path","required":true,"schema":{"type":"string","title":"Device Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/platforms":{"post":{"tags":["platforms"],"summary":"Add Platform","description":"Create a platform.","operationId":"add_platform_api_platforms_post","security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_add_platform_api_platforms_post"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["platforms"],"summary":"Get Platforms","description":"Retrieve platforms.","operationId":"get_platforms_api_platforms_get","security":[{"OAuth2PasswordBearer":["platforms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"updated_after","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter platforms updated after this datetime (ISO 8601 format with timezone information).","title":"Updated After"},"description":"Filter platforms updated after this datetime (ISO 8601 format with timezone information)."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PlatformSchema"},"title":"Response Get Platforms Api Platforms Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/platforms/identifiers":{"get":{"tags":["platforms"],"summary":"Get Platform Identifiers","description":"Retrieve platform identifiers.","operationId":"get_platform_identifiers_api_platforms_identifiers_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"type":"integer"},"type":"array","title":"Response Get Platform Identifiers Api Platforms Identifiers Get"}}}}},"security":[{"OAuth2PasswordBearer":["platforms.read"]},{"HTTPBasic":[]}]}},"/api/platforms/supported":{"get":{"tags":["platforms"],"summary":"Get Supported Platforms Endpoint","description":"Retrieve the list of supported platforms.","operationId":"get_supported_platforms_endpoint_api_platforms_supported_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/PlatformSchema"},"type":"array","title":"Response Get Supported Platforms Endpoint Api Platforms Supported Get"}}}}},"security":[{"OAuth2PasswordBearer":["platforms.read"]},{"HTTPBasic":[]}]}},"/api/platforms/{id}":{"get":{"tags":["platforms"],"summary":"Get Platform","description":"Retrieve a platform by ID.","operationId":"get_platform_api_platforms__id__get","security":[{"OAuth2PasswordBearer":["platforms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Platform id.","title":"Id"},"description":"Platform id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformSchema"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["platforms"],"summary":"Update Platform","description":"Update a platform.","operationId":"update_platform_api_platforms__id__put","security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Platform id.","title":"Id"},"description":"Platform id."}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_update_platform_api_platforms__id__put"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformSchema"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["platforms"],"summary":"Delete Platform","description":"Delete a platform by ID.","operationId":"delete_platform_api_platforms__id__delete","security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Platform id.","title":"Id"},"description":"Platform id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms":{"post":{"tags":["roms"],"summary":"Add Rom","description":"Upload a single rom.","operationId":"add_rom_api_roms_post","security":[{"OAuth2PasswordBearer":["roms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"x-upload-platform","in":"header","required":true,"schema":{"type":"integer","minimum":1,"description":"Platform internal id.","title":"X-Upload-Platform"},"description":"Platform internal id."},{"name":"x-upload-filename","in":"header","required":true,"schema":{"type":"string","description":"The name of the file being uploaded.","title":"X-Upload-Filename"},"description":"The name of the file being uploaded."}],"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["roms"],"summary":"Get Roms","description":"Retrieve roms.","operationId":"get_roms_api_roms_get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"with_char_index","in":"query","required":false,"schema":{"type":"boolean","description":"Whether to get the char index.","default":true,"title":"With Char Index"},"description":"Whether to get the char index."},{"name":"with_filter_values","in":"query","required":false,"schema":{"type":"boolean","description":"Whether to return filter values.","default":true,"title":"With Filter Values"},"description":"Whether to return filter values."},{"name":"search_term","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Search term to filter roms.","title":"Search Term"},"description":"Search term to filter roms."},{"name":"platform_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"integer"}},{"type":"null"}],"description":"Platform internal ids. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Platform Ids"},"description":"Platform internal ids. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"collection_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","minimum":1},{"type":"null"}],"description":"Collection internal id.","title":"Collection Id"},"description":"Collection internal id."},{"name":"virtual_collection_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Virtual collection internal id.","title":"Virtual Collection Id"},"description":"Virtual collection internal id."},{"name":"smart_collection_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","minimum":1},{"type":"null"}],"description":"Smart collection internal id.","title":"Smart Collection Id"},"description":"Smart collection internal id."},{"name":"matched","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Whether the rom matched at least one metadata source.","title":"Matched"},"description":"Whether the rom matched at least one metadata source."},{"name":"favorite","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Whether the rom is marked as favorite.","title":"Favorite"},"description":"Whether the rom is marked as favorite."},{"name":"duplicate","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Whether the rom is marked as duplicate.","title":"Duplicate"},"description":"Whether the rom is marked as duplicate."},{"name":"last_played","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Whether the rom has a last played value for the current user.","title":"Last Played"},"description":"Whether the rom has a last played value for the current user."},{"name":"playable","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Whether the rom is playable from the browser.","title":"Playable"},"description":"Whether the rom is playable from the browser."},{"name":"missing","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Whether the rom is missing from the filesystem.","title":"Missing"},"description":"Whether the rom is missing from the filesystem."},{"name":"has_ra","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Whether the rom has RetroAchievements data.","title":"Has Ra"},"description":"Whether the rom has RetroAchievements data."},{"name":"verified","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Whether the rom is verified by Hasheous.","title":"Verified"},"description":"Whether the rom is verified by Hasheous."},{"name":"group_by_meta_id","in":"query","required":false,"schema":{"type":"boolean","description":"Whether to group roms by metadata ID (IGDB / Moby / ScreenScraper / RetroAchievements / LaunchBox).","default":false,"title":"Group By Meta Id"},"description":"Whether to group roms by metadata ID (IGDB / Moby / ScreenScraper / RetroAchievements / LaunchBox)."},{"name":"genres","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Associated genre. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Genres"},"description":"Associated genre. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"franchises","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Associated franchise. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Franchises"},"description":"Associated franchise. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"collections","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Associated collection. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Collections"},"description":"Associated collection. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"companies","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Associated company. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Companies"},"description":"Associated company. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"age_ratings","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Associated age rating. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Age Ratings"},"description":"Associated age rating. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"statuses","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Game status, set by the current user. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Statuses"},"description":"Game status, set by the current user. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"regions","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Associated region tag. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Regions"},"description":"Associated region tag. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"languages","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Associated language tag. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Languages"},"description":"Associated language tag. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"player_counts","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"Associated player count. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned.","title":"Player Counts"},"description":"Associated player count. Multiple values are allowed by repeating the parameter, and results that match any of the values will be returned."},{"name":"genres_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for genres filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Genres Logic"},"description":"Logic operator for genres filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"franchises_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for franchises filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Franchises Logic"},"description":"Logic operator for franchises filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"collections_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for collections filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Collections Logic"},"description":"Logic operator for collections filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"companies_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for companies filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Companies Logic"},"description":"Logic operator for companies filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"age_ratings_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for age ratings filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Age Ratings Logic"},"description":"Logic operator for age ratings filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"regions_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for regions filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Regions Logic"},"description":"Logic operator for regions filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"languages_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for languages filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Languages Logic"},"description":"Logic operator for languages filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"statuses_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for statuses filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Statuses Logic"},"description":"Logic operator for statuses filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"player_counts_logic","in":"query","required":false,"schema":{"type":"string","description":"Logic operator for player counts filter: 'any' (OR), 'all' (AND) or 'none' (NOT).","default":"any","title":"Player Counts Logic"},"description":"Logic operator for player counts filter: 'any' (OR), 'all' (AND) or 'none' (NOT)."},{"name":"order_by","in":"query","required":false,"schema":{"type":"string","description":"Field to order results by.","default":"name","title":"Order By"},"description":"Field to order results by."},{"name":"order_dir","in":"query","required":false,"schema":{"type":"string","description":"Order direction, either 'asc' or 'desc'.","default":"asc","title":"Order Dir"},"description":"Order direction, either 'asc' or 'desc'."},{"name":"updated_after","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter roms updated after this datetime (ISO 8601 format with timezone information).","title":"Updated After"},"description":"Filter roms updated after this datetime (ISO 8601 format with timezone information)."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":10000,"minimum":1,"description":"Page size limit","default":50,"title":"Limit"},"description":"Page size limit"},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Page offset","default":0,"title":"Offset"},"description":"Page offset"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomLimitOffsetPage_SimpleRomSchema_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/identifiers":{"get":{"tags":["roms"],"summary":"Get Rom Identifiers","description":"Retrieve rom identifiers.","operationId":"get_rom_identifiers_api_roms_identifiers_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"type":"integer"},"type":"array","title":"Response Get Rom Identifiers Api Roms Identifiers Get"}}}}},"security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}]}},"/api/roms/download":{"get":{"tags":["roms"],"summary":"Download Roms","description":"Download a list of roms as a zip file.","operationId":"download_roms_api_roms_download_get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"rom_ids","in":"query","required":true,"schema":{"type":"string","description":"Comma-separated list of ROM IDs to download as a zip file.","title":"Rom Ids"},"description":"Comma-separated list of ROM IDs to download as a zip file."},{"name":"filename","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Name for the zip file (optional).","title":"Filename"},"description":"Name for the zip file (optional)."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/by-metadata-provider":{"get":{"tags":["roms"],"summary":"Get Rom By Metadata Provider","description":"Retrieve a rom by metadata ID.","operationId":"get_rom_by_metadata_provider_api_roms_by_metadata_provider_get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"igdb_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"IGDB ID to search by","title":"Igdb Id"},"description":"IGDB ID to search by"},{"name":"moby_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"MobyGames ID to search by","title":"Moby Id"},"description":"MobyGames ID to search by"},{"name":"ss_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"ScreenScraper ID to search by","title":"Ss Id"},"description":"ScreenScraper ID to search by"},{"name":"ra_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"RetroAchievements ID to search by","title":"Ra Id"},"description":"RetroAchievements ID to search by"},{"name":"launchbox_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"LaunchBox ID to search by","title":"Launchbox Id"},"description":"LaunchBox ID to search by"},{"name":"hasheous_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Hasheous ID to search by","title":"Hasheous Id"},"description":"Hasheous ID to search by"},{"name":"tgdb_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"TGDB ID to search by","title":"Tgdb Id"},"description":"TGDB ID to search by"},{"name":"flashpoint_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Flashpoint ID to search by","title":"Flashpoint Id"},"description":"Flashpoint ID to search by"},{"name":"hltb_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"HLTB ID to search by","title":"Hltb Id"},"description":"HLTB ID to search by"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DetailedRomSchema"}}}},"404":{"description":"Not Found"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/by-hash":{"get":{"tags":["roms"],"summary":"Get Rom By Hash","operationId":"get_rom_by_hash_api_roms_by_hash_get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"crc_hash","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"CRC hash value","title":"Crc Hash"},"description":"CRC hash value"},{"name":"md5_hash","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"MD5 hash value","title":"Md5 Hash"},"description":"MD5 hash value"},{"name":"sha1_hash","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"SHA1 hash value","title":"Sha1 Hash"},"description":"SHA1 hash value"},{"name":"ra_hash","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"RetroAchievements hash value","title":"Ra Hash"},"description":"RetroAchievements hash value"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DetailedRomSchema"}}}},"404":{"description":"Not Found"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/filters":{"get":{"tags":["roms"],"summary":"Get Rom Filters","operationId":"get_rom_filters_api_roms_filters_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RomFiltersDict"}}}}},"security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}]}},"/api/roms/{id}":{"get":{"tags":["roms"],"summary":"Get Rom","description":"Retrieve a rom by ID.","operationId":"get_rom_api_roms__id__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DetailedRomSchema"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["roms"],"summary":"Update Rom","description":"Update a rom.","operationId":"update_rom_api_roms__id__put","security":[{"OAuth2PasswordBearer":["roms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."},{"name":"remove_cover","in":"query","required":false,"schema":{"type":"boolean","description":"Whether to remove the cover image for this rom.","default":false,"title":"Remove Cover"},"description":"Whether to remove the cover image for this rom."},{"name":"unmatch_metadata","in":"query","required":false,"schema":{"type":"boolean","description":"Whether to remove the metadata matches for this game.","default":false,"title":"Unmatch Metadata"},"description":"Whether to remove the metadata matches for this game."}],"requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_update_rom_api_roms__id__put"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DetailedRomSchema"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/{id}/content/{file_name}":{"head":{"tags":["roms"],"summary":"Head Rom Content","description":"Retrieve head information for a rom file download.","operationId":"head_rom_content_api_roms__id__content__file_name__head","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."},{"name":"file_name","in":"path","required":true,"schema":{"type":"string","description":"File name to download","title":"File Name"},"description":"File name to download"},{"name":"file_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Comma-separated list of file ids to download for multi-part roms.","title":"File Ids"},"description":"Comma-separated list of file ids to download for multi-part roms."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["roms"],"summary":"Get Rom Content","description":"Download a rom.\n\nThis endpoint serves the content of the requested rom, as:\n- A single file for single file roms.\n- A zipped file for multi-part roms, including a .m3u file if applicable.","operationId":"get_rom_content_api_roms__id__content__file_name__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."},{"name":"file_name","in":"path","required":true,"schema":{"type":"string","description":"Zip file output name","title":"File Name"},"description":"Zip file output name"},{"name":"file_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Comma-separated list of file ids to download for multi-part roms.","title":"File Ids"},"description":"Comma-separated list of file ids to download for multi-part roms."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/{id}/manuals":{"post":{"tags":["roms"],"summary":"Add Rom Manuals","description":"Upload manuals for a rom.","operationId":"add_rom_manuals_api_roms__id__manuals_post","security":[{"OAuth2PasswordBearer":["roms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."},{"name":"x-upload-filename","in":"header","required":true,"schema":{"type":"string","description":"The name of the file being uploaded.","title":"X-Upload-Filename"},"description":"The name of the file being uploaded."}],"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["roms"],"summary":"Delete Rom Manuals","description":"Delete manuals for a rom.","operationId":"delete_rom_manuals_api_roms__id__manuals_delete","security":[{"OAuth2PasswordBearer":["roms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/delete":{"post":{"tags":["roms"],"summary":"Delete Roms","description":"Delete roms.","operationId":"delete_roms_api_roms_delete_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_delete_roms_api_roms_delete_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkOperationResponse"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":["roms.write"]},{"HTTPBasic":[]}]}},"/api/roms/{id}/props":{"put":{"tags":["roms"],"summary":"Update Rom User","description":"Update rom data associated to the current user.","operationId":"update_rom_user_api_roms__id__props_put","security":[{"OAuth2PasswordBearer":["roms.user.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_update_rom_user_api_roms__id__props_put"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RomUserSchema"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/files/{id}":{"get":{"tags":["roms"],"summary":"Get Romfile","description":"Retrieve a rom file by ID.","operationId":"get_romfile_api_roms_files__id__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom file internal id.","title":"Id"},"description":"Rom file internal id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RomFileSchema"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/romsfiles/{id}/content/{file_name}":{"get":{"tags":["roms"],"summary":"Get Romfile Content","description":"Download a rom file.","operationId":"get_romfile_content_api_romsfiles__id__content__file_name__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom file internal id.","title":"Id"},"description":"Rom file internal id."},{"name":"file_name","in":"path","required":true,"schema":{"type":"string","description":"File name to download","title":"File Name"},"description":"File name to download"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/{id}/notes":{"get":{"tags":["roms"],"summary":"Get Rom Notes","description":"Get all notes for a ROM.","operationId":"get_rom_notes_api_roms__id__notes_get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."},{"name":"public_only","in":"query","required":false,"schema":{"type":"boolean","description":"Only return public notes","default":false,"title":"Public Only"},"description":"Only return public notes"},{"name":"search","in":"query","required":false,"schema":{"type":"string","description":"Search notes by title or content","title":"Search"},"description":"Search notes by title or content"},{"name":"tags","in":"query","required":false,"schema":{"type":"array","items":{"type":"string"},"description":"Filter by tags","title":"Tags"},"description":"Filter by tags"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserNoteSchema"},"title":"Response Get Rom Notes Api Roms Id Notes Get"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["roms"],"summary":"Create Rom Note","description":"Create a new note for a ROM.","operationId":"create_rom_note_api_roms__id__notes_post","security":[{"OAuth2PasswordBearer":["roms.user.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Note Data"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserNoteSchema"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/{id}/notes/identifiers":{"get":{"tags":["roms"],"summary":"Get Rom Note Identifiers","description":"Get all note identifiers for a ROM.","operationId":"get_rom_note_identifiers_api_roms__id__notes_identifiers_get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"type":"integer"},"title":"Response Get Rom Note Identifiers Api Roms Id Notes Identifiers Get"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/roms/{id}/notes/{note_id}":{"put":{"tags":["roms"],"summary":"Update Rom Note","description":"Update a ROM note.","operationId":"update_rom_note_api_roms__id__notes__note_id__put","security":[{"OAuth2PasswordBearer":["roms.user.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."},{"name":"note_id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Note id.","title":"Note Id"},"description":"Note id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Note Data"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserNoteSchema"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["roms"],"summary":"Delete Rom Note","description":"Delete a ROM note.","operationId":"delete_rom_note_api_roms__id__notes__note_id__delete","security":[{"OAuth2PasswordBearer":["roms.user.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Rom internal id.","title":"Id"},"description":"Rom internal id."},{"name":"note_id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Note id.","title":"Note Id"},"description":"Note id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Rom Note Api Roms Id Notes Note Id Delete"}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/search/roms":{"get":{"tags":["search"],"summary":"Search Rom","description":"Search for rom in metadata providers\n\nArgs:\n request (Request): FastAPI request\n rom_id (int): Rom ID\n source (str): Source of the rom\n search_term (str, optional): Search term. Defaults to None.\n search_by (str, optional): Search by name or ID. Defaults to \"name\".\n search_extended (bool, optional): Search extended info. Defaults to False.\n\nReturns:\n list[SearchRomSchema]: List of matched roms","operationId":"search_rom_api_search_roms_get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"rom_id","in":"query","required":true,"schema":{"type":"integer","title":"Rom Id"}},{"name":"search_term","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Search Term"}},{"name":"search_by","in":"query","required":false,"schema":{"type":"string","default":"name","title":"Search By"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchRomSchema"},"title":"Response Search Rom Api Search Roms Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/search/cover":{"get":{"tags":["search"],"summary":"Search Cover","operationId":"search_cover_api_search_cover_get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"search_term","in":"query","required":false,"schema":{"type":"string","default":"","title":"Search Term"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchCoverSchema"},"title":"Response Search Cover Api Search Cover Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/saves":{"post":{"tags":["saves"],"summary":"Add Save","description":"Upload a save file for a ROM.","operationId":"add_save_api_saves_post","security":[{"OAuth2PasswordBearer":["assets.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"rom_id","in":"query","required":true,"schema":{"type":"integer","title":"Rom Id"}},{"name":"emulator","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Emulator"}},{"name":"slot","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slot"}},{"name":"device_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Device Id"}},{"name":"overwrite","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Overwrite"}},{"name":"autocleanup","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Autocleanup"}},{"name":"autocleanup_limit","in":"query","required":false,"schema":{"type":"integer","default":10,"title":"Autocleanup Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SaveSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["saves"],"summary":"Get Saves","description":"Retrieve saves for the current user.","operationId":"get_saves_api_saves_get","security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"rom_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Rom Id"}},{"name":"platform_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Platform Id"}},{"name":"device_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Device Id"}},{"name":"slot","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slot"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SaveSchema"},"title":"Response Get Saves Api Saves Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/saves/identifiers":{"get":{"tags":["saves"],"summary":"Get Save Identifiers","description":"Retrieve save identifiers.","operationId":"get_save_identifiers_api_saves_identifiers_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"type":"integer"},"type":"array","title":"Response Get Save Identifiers Api Saves Identifiers Get"}}}}},"security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}]}},"/api/saves/summary":{"get":{"tags":["saves"],"summary":"Get Saves Summary","description":"Retrieve saves summary grouped by slot.","operationId":"get_saves_summary_api_saves_summary_get","security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"rom_id","in":"query","required":true,"schema":{"type":"integer","title":"Rom Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SaveSummarySchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/saves/{id}":{"get":{"tags":["saves"],"summary":"Get Save","description":"Retrieve a save by ID.","operationId":"get_save_api_saves__id__get","security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}},{"name":"device_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Device Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SaveSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["saves"],"summary":"Update Save","description":"Update a save file.","operationId":"update_save_api_saves__id__put","security":[{"OAuth2PasswordBearer":["assets.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SaveSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/saves/{id}/content":{"get":{"tags":["saves"],"summary":"Download Save","description":"Download a save file.","operationId":"download_save_api_saves__id__content_get","security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}},{"name":"device_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Device Id"}},{"name":"optimistic","in":"query","required":false,"schema":{"type":"boolean","default":true,"title":"Optimistic"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/saves/{id}/downloaded":{"post":{"tags":["saves"],"summary":"Confirm Download","description":"Confirm a save was downloaded successfully.","operationId":"confirm_download_api_saves__id__downloaded_post","security":[{"OAuth2PasswordBearer":["devices.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_confirm_download_api_saves__id__downloaded_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SaveSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/saves/delete":{"post":{"tags":["saves"],"summary":"Delete Saves","description":"Delete saves.","operationId":"delete_saves_api_saves_delete_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_delete_saves_api_saves_delete_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"type":"integer"},"type":"array","title":"Response Delete Saves Api Saves Delete Post"}}}},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":["assets.write"]},{"HTTPBasic":[]}]}},"/api/saves/{id}/track":{"post":{"tags":["saves"],"summary":"Track Save","description":"Re-enable sync tracking for a save on a device.","operationId":"track_save_api_saves__id__track_post","security":[{"OAuth2PasswordBearer":["devices.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_track_save_api_saves__id__track_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SaveSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/saves/{id}/untrack":{"post":{"tags":["saves"],"summary":"Untrack Save","description":"Disable sync tracking for a save on a device.","operationId":"untrack_save_api_saves__id__untrack_post","security":[{"OAuth2PasswordBearer":["devices.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_untrack_save_api_saves__id__untrack_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SaveSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/states":{"post":{"tags":["states"],"summary":"Add State","operationId":"add_state_api_states_post","security":[{"OAuth2PasswordBearer":["assets.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"rom_id","in":"query","required":true,"schema":{"type":"integer","title":"Rom Id"}},{"name":"emulator","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Emulator"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StateSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["states"],"summary":"Get States","operationId":"get_states_api_states_get","security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"rom_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Rom Id"}},{"name":"platform_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Platform Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/StateSchema"},"title":"Response Get States Api States Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/states/identifiers":{"get":{"tags":["states"],"summary":"Get State Identifiers","description":"Get state identifiers endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n list[int]: List of state IDs","operationId":"get_state_identifiers_api_states_identifiers_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"type":"integer"},"type":"array","title":"Response Get State Identifiers Api States Identifiers Get"}}}}},"security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}]}},"/api/states/{id}":{"get":{"tags":["states"],"summary":"Get State","operationId":"get_state_api_states__id__get","security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StateSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["states"],"summary":"Update State","operationId":"update_state_api_states__id__put","security":[{"OAuth2PasswordBearer":["assets.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StateSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/states/delete":{"post":{"tags":["states"],"summary":"Delete States","description":"Delete states.","operationId":"delete_states_api_states_delete_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_delete_states_api_states_delete_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"type":"integer"},"type":"array","title":"Response Delete States Api States Delete Post"}}}},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":["assets.write"]},{"HTTPBasic":[]}]}},"/api/tasks":{"get":{"tags":["tasks"],"summary":"List Tasks","description":"List all available tasks grouped by task type.\n\nArgs:\n request (Request): FastAPI Request object\nReturns:\n GroupedTasksDict: Dictionary with tasks grouped by their type (scheduled, manual, watcher)","operationId":"list_tasks_api_tasks_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"items":{"$ref":"#/components/schemas/TaskInfo"},"type":"array"},"type":"object","title":"Response List Tasks Api Tasks Get"}}}}},"security":[{"OAuth2PasswordBearer":["tasks.run"]},{"HTTPBasic":[]}]}},"/api/tasks/status":{"get":{"tags":["tasks"],"summary":"Get Tasks Status","description":"Get all active, queued, completed, and failed tasks.\n\nArgs:\n request (Request): FastAPI Request object\nReturns:\n list[TaskStatusResponse]: List of all tasks with their current status","operationId":"get_tasks_status_api_tasks_status_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"anyOf":[{"$ref":"#/components/schemas/ScanTaskStatusResponse"},{"$ref":"#/components/schemas/ConversionTaskStatusResponse"},{"$ref":"#/components/schemas/UpdateTaskStatusResponse"},{"$ref":"#/components/schemas/CleanupTaskStatusResponse"},{"$ref":"#/components/schemas/WatcherTaskStatusResponse"},{"$ref":"#/components/schemas/GenericTaskStatusResponse"}]},"type":"array","title":"Response Get Tasks Status Api Tasks Status Get"}}}}},"security":[{"OAuth2PasswordBearer":["tasks.run"]},{"HTTPBasic":[]}]}},"/api/tasks/{task_id}":{"get":{"tags":["tasks"],"summary":"Get Task By Id","description":"Get the status of a task by its job ID.\n\nArgs:\n request (Request): FastAPI Request object\n task_id (str): Job ID of the task to retrieve status for\nReturns:\n TaskStatusResponse: Task status information","operationId":"get_task_by_id_api_tasks__task_id__get","security":[{"OAuth2PasswordBearer":["tasks.run"]},{"HTTPBasic":[]}],"parameters":[{"name":"task_id","in":"path","required":true,"schema":{"type":"string","title":"Task Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ScanTaskStatusResponse"},{"$ref":"#/components/schemas/ConversionTaskStatusResponse"},{"$ref":"#/components/schemas/UpdateTaskStatusResponse"},{"$ref":"#/components/schemas/CleanupTaskStatusResponse"},{"$ref":"#/components/schemas/WatcherTaskStatusResponse"},{"$ref":"#/components/schemas/GenericTaskStatusResponse"}],"title":"Response Get Task By Id Api Tasks Task Id Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/tasks/run":{"post":{"tags":["tasks"],"summary":"Run All Tasks","description":"Run all runnable tasks endpoint\n\nArgs:\n request (Request): FastAPI Request object\nReturns:\n TaskExecutionResponse: Task execution response with details","operationId":"run_all_tasks_api_tasks_run_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/TaskExecutionResponse"},"type":"array","title":"Response Run All Tasks Api Tasks Run Post"}}}}},"security":[{"OAuth2PasswordBearer":["tasks.run"]},{"HTTPBasic":[]}]}},"/api/tasks/run/{task_name}":{"post":{"tags":["tasks"],"summary":"Run Single Task","description":"Run a single task endpoint.\n\nArgs:\n request (Request): FastAPI Request object\n task_name (str): Name of the task to run\nReturns:\n TaskExecutionResponse: Task execution response with details","operationId":"run_single_task_api_tasks_run__task_name__post","security":[{"OAuth2PasswordBearer":["tasks.run"]},{"HTTPBasic":[]}],"parameters":[{"name":"task_name","in":"path","required":true,"schema":{"type":"string","title":"Task Name"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskExecutionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/feeds/webrcade":{"get":{"tags":["feeds"],"summary":"Platforms Webrcade Feed","description":"Get webrcade feed endpoint\nhttps://docs.webrcade.com/feeds/format/\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n WebrcadeFeedSchema: Webrcade feed object schema","operationId":"platforms_webrcade_feed_api_feeds_webrcade_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebrcadeFeedSchema"}}}}},"security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}]}},"/api/feeds/tinfoil":{"get":{"tags":["feeds"],"summary":"Tinfoil Index Feed","description":"Get tinfoil custom index feed endpoint\nhttps://blawar.github.io/tinfoil/custom_index/\n\nArgs:\n request (Request): Fastapi Request object\n slug (str, optional): Platform slug. Defaults to \"switch\".\n\nReturns:\n TinfoilFeedSchema: Tinfoil feed object schema","operationId":"tinfoil_index_feed_api_feeds_tinfoil_get","security":[{"OAuth2PasswordBearer":[]},{"HTTPBasic":[]}],"parameters":[{"name":"slug","in":"query","required":false,"schema":{"type":"string","default":"switch","title":"Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TinfoilFeedSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/feeds/pkgi/ps3/{content_type}":{"get":{"tags":["feeds"],"summary":"Pkgi Ps3 Feed","description":"Get PKGi PS3 feed endpoint\nhttps://github.com/bucanero/pkgi-ps3\n\nArgs:\n request (Request): Fastapi Request object\n content_type (str): Content type (game, dlc, demo, update, patch, mod, translation, prototype)\n\nReturns:\n Response: txt file with PKGi PS3 database format","operationId":"pkgi_ps3_feed_api_feeds_pkgi_ps3__content_type__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"content_type","in":"path","required":true,"schema":{"type":"string","description":"Content type","title":"Content Type"},"description":"Content type"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/feeds/pkgi/psvita/{content_type}":{"get":{"tags":["feeds"],"summary":"Pkgi Psvita Feed","description":"Get PKGi PS Vita feed endpoint\nhttps://github.com/mmozeiko/pkgi\n\nArgs:\n request (Request): Fastapi Request object\n content_type (str): Content type (game, dlc, demo, update, patch, mod, translation, prototype)\n\nReturns:\n Response: txt file with PKGi PS Vita database format","operationId":"pkgi_psvita_feed_api_feeds_pkgi_psvita__content_type__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"content_type","in":"path","required":true,"schema":{"type":"string","description":"Content type","title":"Content Type"},"description":"Content type"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/feeds/pkgi/psp/{content_type}":{"get":{"tags":["feeds"],"summary":"Pkgi Psp Feed","description":"Get PKGi PSP feed endpoint\nhttps://github.com/bucanero/pkgi-psp\n\nArgs:\n request (Request): Fastapi Request object\n content_type (str): Content type (game, dlc, demo, update, patch, mod, translation, prototype)\n\nReturns:\n Response: txt file with PKGi PSP database format","operationId":"pkgi_psp_feed_api_feeds_pkgi_psp__content_type__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"content_type","in":"path","required":true,"schema":{"type":"string","description":"Content type","title":"Content Type"},"description":"Content type"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/feeds/fpkgi/{platform_slug}":{"get":{"tags":["feeds"],"summary":"Fpkgi Feed","description":"https://github.com/ItsJokerZz/FPKGi\n\nArgs:\n request (Request): Fastapi Request object\n platform_slug (str): Platform slug (ps4, ps5)\n\nReturns:\n Response: JSON file in FPKGi format","operationId":"fpkgi_feed_api_feeds_fpkgi__platform_slug__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"platform_slug","in":"path","required":true,"schema":{"type":"string","title":"Platform Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/feeds/kekatsu/{platform_slug}":{"get":{"tags":["feeds"],"summary":"Kekatsu Ds Feed","description":"Get Kekatsu DS feed endpoint\nhttps://github.com/cavv-dev/Kekatsu-DS\n\nArgs:\n request (Request): Fastapi Request object\n platform_slug (str): Platform slug (nds, nintendo-ds, ds, gba, etc.)\n\nReturns:\n Response: Text file with Kekatsu DS database format","operationId":"kekatsu_ds_feed_api_feeds_kekatsu__platform_slug__get","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"platform_slug","in":"path","required":true,"schema":{"type":"string","title":"Platform Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/feeds/pkgj/psp/games":{"get":{"tags":["feeds"],"summary":"Pkgj Psp Games Feed","operationId":"pkgj_psp_games_feed_api_feeds_pkgj_psp_games_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}]}},"/api/feeds/pkgj/psp/dlc":{"get":{"tags":["feeds"],"summary":"Pkgj Psp Dlcs Feed","operationId":"pkgj_psp_dlcs_feed_api_feeds_pkgj_psp_dlc_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}]}},"/api/feeds/pkgj/psvita/games":{"get":{"tags":["feeds"],"summary":"Pkgj Psv Games Feed","operationId":"pkgj_psv_games_feed_api_feeds_pkgj_psvita_games_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}]}},"/api/feeds/pkgj/psvita/dlc":{"get":{"tags":["feeds"],"summary":"Pkgj Psv Dlcs Feed","operationId":"pkgj_psv_dlcs_feed_api_feeds_pkgj_psvita_dlc_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}]}},"/api/feeds/pkgj/psx/games":{"get":{"tags":["feeds"],"summary":"Pkgj Psx Games Feed","operationId":"pkgj_psx_games_feed_api_feeds_pkgj_psx_games_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}]}},"/api/config":{"get":{"tags":["config"],"summary":"Get Config","description":"Get config endpoint\n\nReturns:\n ConfigResponse: RomM's configuration","operationId":"get_config_api_config_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfigResponse"}}}}}}},"/api/config/system/platforms":{"post":{"tags":["config"],"summary":"Add Platform Binding","description":"Add platform binding to the configuration","operationId":"add_platform_binding_api_config_system_platforms_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}]}},"/api/config/system/platforms/{fs_slug}":{"delete":{"tags":["config"],"summary":"Delete Platform Binding","description":"Delete platform binding from the configuration","operationId":"delete_platform_binding_api_config_system_platforms__fs_slug__delete","security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"fs_slug","in":"path","required":true,"schema":{"type":"string","title":"Fs Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/config/system/versions":{"post":{"tags":["config"],"summary":"Add Platform Version","description":"Add platform version to the configuration","operationId":"add_platform_version_api_config_system_versions_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}]}},"/api/config/system/versions/{fs_slug}":{"delete":{"tags":["config"],"summary":"Delete Platform Version","description":"Delete platform version from the configuration","operationId":"delete_platform_version_api_config_system_versions__fs_slug__delete","security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"fs_slug","in":"path","required":true,"schema":{"type":"string","title":"Fs Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/config/exclude":{"post":{"tags":["config"],"summary":"Add Exclusion","description":"Add platform exclusion to the configuration","operationId":"add_exclusion_api_config_exclude_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}]}},"/api/config/exclude/{exclusion_type}/{exclusion_value}":{"delete":{"tags":["config"],"summary":"Delete Exclusion","description":"Delete platform binding from the configuration","operationId":"delete_exclusion_api_config_exclude__exclusion_type___exclusion_value__delete","security":[{"OAuth2PasswordBearer":["platforms.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"exclusion_type","in":"path","required":true,"schema":{"type":"string","title":"Exclusion Type"}},{"name":"exclusion_value","in":"path","required":true,"schema":{"type":"string","title":"Exclusion Value"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/stats":{"get":{"tags":["stats"],"summary":"Stats","description":"Endpoint to return the current RomM stats\n\nReturns:\n dict: Dictionary with all the stats","operationId":"stats_api_stats_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsReturn"}}}}}}},"/api/raw/assets/{path}":{"head":{"tags":["raw"],"summary":"Head Raw Asset","operationId":"head_raw_asset_api_raw_assets__path__head","security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"path","in":"path","required":true,"schema":{"type":"string","title":"Path"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["raw"],"summary":"Get Raw Asset","description":"Download a single asset file\n\nArgs:\n request (Request): Fastapi Request object\n path (str): Relative path to the asset file\n\nReturns:\n FileResponse: Returns a single asset file\n\nRaises:\n HTTPException: 404 if asset not found or access denied","operationId":"get_raw_asset_api_raw_assets__path__get","security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"path","in":"path","required":true,"schema":{"type":"string","title":"Path"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/screenshots":{"post":{"tags":["screenshots"],"summary":"Add Screenshot","operationId":"add_screenshot_api_screenshots_post","security":[{"OAuth2PasswordBearer":["assets.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"rom_id","in":"query","required":true,"schema":{"type":"integer","title":"Rom Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScreenshotSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/firmware":{"post":{"tags":["firmware"],"summary":"Add Firmware","description":"Upload firmware files endpoint\n\nArgs:\n request (Request): Fastapi Request object\n platform_slug (str): Slug of the platform where to upload the files\n files (list[UploadFile], optional): List of files to upload\n\nRaises:\n HTTPException\n\nReturns:\n AddFirmwareResponse: Standard message response","operationId":"add_firmware_api_firmware_post","security":[{"OAuth2PasswordBearer":["firmware.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"platform_id","in":"query","required":true,"schema":{"type":"integer","title":"Platform Id"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_add_firmware_api_firmware_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddFirmwareResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["firmware"],"summary":"Get Platform Firmware","description":"Get firmware endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n list[FirmwareSchema]: Firmware stored in the database","operationId":"get_platform_firmware_api_firmware_get","security":[{"OAuth2PasswordBearer":["firmware.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"platform_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Platform Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FirmwareSchema"},"title":"Response Get Platform Firmware Api Firmware Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/firmware/identifiers":{"get":{"tags":["firmware"],"summary":"Get Firmware Identifiers","description":"Get firmware identifiers endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n list[int]: List of firmware IDs","operationId":"get_firmware_identifiers_api_firmware_identifiers_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"type":"integer"},"type":"array","title":"Response Get Firmware Identifiers Api Firmware Identifiers Get"}}}}},"security":[{"OAuth2PasswordBearer":["firmware.read"]},{"HTTPBasic":[]}]}},"/api/firmware/{id}":{"get":{"tags":["firmware"],"summary":"Get Firmware","description":"Get firmware endpoint\n\nArgs:\n request (Request): Fastapi Request object\n id (int): Firmware internal id\n\nReturns:\n FirmwareSchema: Firmware stored in the database","operationId":"get_firmware_api_firmware__id__get","security":[{"OAuth2PasswordBearer":["firmware.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FirmwareSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/firmware/{id}/content/{file_name}":{"head":{"tags":["firmware"],"summary":"Head Firmware Content","description":"Head firmware content endpoint\n\nArgs:\n request (Request): Fastapi Request object\n id (int): Rom internal id\n file_name (str): Required due to a bug in emulatorjs\n\nReturns:\n FileResponse: Returns the response with headers","operationId":"head_firmware_content_api_firmware__id__content__file_name__head","security":[{"OAuth2PasswordBearer":["firmware.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}},{"name":"file_name","in":"path","required":true,"schema":{"type":"string","title":"File Name"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["firmware"],"summary":"Get Firmware Content","description":"Download firmware endpoint\n\nArgs:\n request (Request): Fastapi Request object\n id (int): Rom internal id\n file_name (str): Required due to a bug in emulatorjs\n\nReturns:\n FileResponse: Returns the firmware file","operationId":"get_firmware_content_api_firmware__id__content__file_name__get","security":[{"OAuth2PasswordBearer":["firmware.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}},{"name":"file_name","in":"path","required":true,"schema":{"type":"string","title":"File Name"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/firmware/delete":{"post":{"tags":["firmware"],"summary":"Delete Firmware","description":"Delete firmware.","operationId":"delete_firmware_api_firmware_delete_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_delete_firmware_api_firmware_delete_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkOperationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":["firmware.write"]},{"HTTPBasic":[]}]}},"/api/collections":{"post":{"tags":["collections"],"summary":"Add Collection","description":"Create collection endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n CollectionSchema: Just created collection","operationId":"add_collection_api_collections_post","security":[{"OAuth2PasswordBearer":["collections.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"is_public","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Public"}},{"name":"is_favorite","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Favorite"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_add_collection_api_collections_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectionSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["collections"],"summary":"Get Collections","description":"Get collections endpoint\n\nArgs:\n request (Request): Fastapi Request object\n updated_after: Filter collections updated after this datetime\n\nReturns:\n list[CollectionSchema]: List of collections","operationId":"get_collections_api_collections_get","security":[{"OAuth2PasswordBearer":["collections.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"updated_after","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter collections updated after this datetime (ISO 8601 format with timezone information).","title":"Updated After"},"description":"Filter collections updated after this datetime (ISO 8601 format with timezone information)."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CollectionSchema"},"title":"Response Get Collections Api Collections Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/collections/smart":{"post":{"tags":["collections"],"summary":"Add Smart Collection","description":"Create smart collection endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n SmartCollectionSchema: Just created smart collection","operationId":"add_smart_collection_api_collections_smart_post","security":[{"OAuth2PasswordBearer":["collections.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"is_public","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Public"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SmartCollectionSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["collections"],"summary":"Get Smart Collections","description":"Get smart collections endpoint\n\nArgs:\n request (Request): Fastapi Request object\n updated_after: Filter smart collections updated after this datetime\n\nReturns:\n list[SmartCollectionSchema]: List of smart collections","operationId":"get_smart_collections_api_collections_smart_get","security":[{"OAuth2PasswordBearer":["collections.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"updated_after","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter smart collections updated after this datetime (ISO 8601 format with timezone information).","title":"Updated After"},"description":"Filter smart collections updated after this datetime (ISO 8601 format with timezone information)."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SmartCollectionSchema"},"title":"Response Get Smart Collections Api Collections Smart Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/collections/identifiers":{"get":{"tags":["collections"],"summary":"Get Collection Identifiers","description":"Get collections identifiers endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n list[int]: List of collection IDs","operationId":"get_collection_identifiers_api_collections_identifiers_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"type":"integer"},"type":"array","title":"Response Get Collection Identifiers Api Collections Identifiers Get"}}}}},"security":[{"OAuth2PasswordBearer":["collections.read"]},{"HTTPBasic":[]}]}},"/api/collections/virtual":{"get":{"tags":["collections"],"summary":"Get Virtual Collections","description":"Get virtual collections endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n list[VirtualCollectionSchema]: List of virtual collections","operationId":"get_virtual_collections_api_collections_virtual_get","security":[{"OAuth2PasswordBearer":["collections.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"type","in":"query","required":true,"schema":{"type":"string","title":"Type"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/VirtualCollectionSchema"},"title":"Response Get Virtual Collections Api Collections Virtual Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/collections/virtual/identifiers":{"get":{"tags":["collections"],"summary":"Get Virtual Collection Identifiers","description":"Get virtual collections identifiers endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n list[str]: List of generated virtual collection IDs","operationId":"get_virtual_collection_identifiers_api_collections_virtual_identifiers_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"type":"string"},"type":"array","title":"Response Get Virtual Collection Identifiers Api Collections Virtual Identifiers Get"}}}}},"security":[{"OAuth2PasswordBearer":["collections.read"]},{"HTTPBasic":[]}]}},"/api/collections/smart/identifiers":{"get":{"tags":["collections"],"summary":"Get Smart Collection Identifiers","description":"Get smart collections identifiers endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n list[int]: List of smart collection IDs","operationId":"get_smart_collection_identifiers_api_collections_smart_identifiers_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"type":"integer"},"type":"array","title":"Response Get Smart Collection Identifiers Api Collections Smart Identifiers Get"}}}}},"security":[{"OAuth2PasswordBearer":["collections.read"]},{"HTTPBasic":[]}]}},"/api/collections/{id}":{"get":{"tags":["collections"],"summary":"Get Collection","description":"Get collections endpoint\n\nArgs:\n request (Request): Fastapi Request object\n id (int, optional): Collection id. Defaults to None.\n\nReturns:\n CollectionSchema: Collection","operationId":"get_collection_api_collections__id__get","security":[{"OAuth2PasswordBearer":["collections.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectionSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["collections"],"summary":"Update Collection","description":"Update collection endpoint\n\nArgs:\n request (Request): Fastapi Request object\n\nReturns:\n CollectionSchema: Updated collection","operationId":"update_collection_api_collections__id__put","security":[{"OAuth2PasswordBearer":["collections.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}},{"name":"remove_cover","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Remove Cover"}},{"name":"is_public","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Public"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_update_collection_api_collections__id__put"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectionSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["collections"],"summary":"Delete Collection","description":"Delete a collection by ID.","operationId":"delete_collection_api_collections__id__delete","security":[{"OAuth2PasswordBearer":["collections.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Collection internal id.","title":"Id"},"description":"Collection internal id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/collections/virtual/{id}":{"get":{"tags":["collections"],"summary":"Get Virtual Collection","description":"Get virtual collections endpoint\n\nArgs:\n request (Request): Fastapi Request object\n id (str): Virtual collection id\n\nReturns:\n VirtualCollectionSchema: Virtual collection","operationId":"get_virtual_collection_api_collections_virtual__id__get","security":[{"OAuth2PasswordBearer":["collections.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VirtualCollectionSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/collections/smart/{id}":{"get":{"tags":["collections"],"summary":"Get Smart Collection","description":"Get smart collection endpoint\n\nArgs:\n request (Request): Fastapi Request object\n id (int): Smart collection id\n\nReturns:\n SmartCollectionSchema: Smart collection","operationId":"get_smart_collection_api_collections_smart__id__get","security":[{"OAuth2PasswordBearer":["collections.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SmartCollectionSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["collections"],"summary":"Update Smart Collection","description":"Update smart collection endpoint\n\nArgs:\n request (Request): Fastapi Request object\n id (int): Smart collection id\n\nReturns:\n SmartCollectionSchema: Updated smart collection","operationId":"update_smart_collection_api_collections_smart__id__put","security":[{"OAuth2PasswordBearer":["collections.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}},{"name":"is_public","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Public"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SmartCollectionSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["collections"],"summary":"Delete Smart Collection","description":"Delete a smart collection by ID.","operationId":"delete_smart_collection_api_collections_smart__id__delete","security":[{"OAuth2PasswordBearer":["collections.write"]},{"HTTPBasic":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1,"description":"Smart collection internal id.","title":"Id"},"description":"Smart collection internal id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/gamelist/export":{"post":{"tags":["gamelist"],"summary":"Export Gamelist","description":"Export platforms/ROMs to gamelist.xml format and write to platform directories","operationId":"export_gamelist_api_gamelist_export_post","security":[{"OAuth2PasswordBearer":["roms.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"platform_ids","in":"query","required":true,"schema":{"type":"array","items":{"type":"integer"},"description":"List of platform IDs to export","title":"Platform Ids"},"description":"List of platform IDs to export"},{"name":"local_export","in":"query","required":false,"schema":{"type":"boolean","description":"Use local paths instead of URLs","default":false,"title":"Local Export"},"description":"Use local paths instead of URLs"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/netplay/list":{"get":{"tags":["netplay"],"summary":"Get Rooms","operationId":"get_rooms_api_netplay_list_get","security":[{"OAuth2PasswordBearer":["assets.read"]},{"HTTPBasic":[]}],"parameters":[{"name":"game_id","in":"query","required":true,"schema":{"type":"string","title":"Game Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/RoomsResponse"},"title":"Response Get Rooms Api Netplay List Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"AddFirmwareResponse":{"properties":{"uploaded":{"type":"integer","title":"Uploaded"},"firmware":{"items":{"$ref":"#/components/schemas/FirmwareSchema"},"type":"array","title":"Firmware"}},"type":"object","required":["uploaded","firmware"],"title":"AddFirmwareResponse"},"Body_add_collection_api_collections_post":{"properties":{"artwork":{"anyOf":[{"type":"string","format":"binary"},{"type":"null"}],"title":"Artwork"}},"type":"object","title":"Body_add_collection_api_collections_post"},"Body_add_firmware_api_firmware_post":{"properties":{"files":{"items":{"type":"string","format":"binary"},"type":"array","title":"Files"}},"type":"object","required":["files"],"title":"Body_add_firmware_api_firmware_post"},"Body_add_platform_api_platforms_post":{"properties":{"fs_slug":{"type":"string","title":"Fs Slug","description":"Platform slug."}},"type":"object","required":["fs_slug"],"title":"Body_add_platform_api_platforms_post"},"Body_add_user_api_users_post":{"properties":{"username":{"type":"string","title":"Username"},"email":{"type":"string","title":"Email"},"password":{"type":"string","title":"Password"},"role":{"type":"string","title":"Role"}},"type":"object","required":["username","email","password","role"],"title":"Body_add_user_api_users_post"},"Body_confirm_download_api_saves__id__downloaded_post":{"properties":{"device_id":{"type":"string","title":"Device Id"}},"type":"object","required":["device_id"],"title":"Body_confirm_download_api_saves__id__downloaded_post"},"Body_create_user_from_invite_api_users_register_post":{"properties":{"username":{"type":"string","title":"Username"},"email":{"type":"string","title":"Email"},"password":{"type":"string","title":"Password"},"token":{"type":"string","title":"Token"}},"type":"object","required":["username","email","password","token"],"title":"Body_create_user_from_invite_api_users_register_post"},"Body_delete_firmware_api_firmware_delete_post":{"properties":{"firmware":{"items":{"type":"integer"},"type":"array","title":"Firmware","description":"List of firmware ids to delete from database."},"delete_from_fs":{"items":{"type":"integer"},"type":"array","title":"Delete From Fs","description":"List of firmware ids to delete from filesystem."}},"type":"object","required":["firmware"],"title":"Body_delete_firmware_api_firmware_delete_post"},"Body_delete_roms_api_roms_delete_post":{"properties":{"roms":{"items":{"type":"integer"},"type":"array","title":"Roms","description":"List of rom ids to delete from database."},"delete_from_fs":{"items":{"type":"integer"},"type":"array","title":"Delete From Fs","description":"List of rom ids to delete from filesystem."}},"type":"object","required":["roms"],"title":"Body_delete_roms_api_roms_delete_post"},"Body_delete_saves_api_saves_delete_post":{"properties":{"saves":{"items":{"type":"integer"},"type":"array","title":"Saves","description":"List of save ids to delete from database."}},"type":"object","required":["saves"],"title":"Body_delete_saves_api_saves_delete_post"},"Body_delete_states_api_states_delete_post":{"properties":{"states":{"items":{"type":"integer"},"type":"array","title":"States","description":"List of states ids to delete from database."}},"type":"object","required":["states"],"title":"Body_delete_states_api_states_delete_post"},"Body_refresh_retro_achievements_api_users__id__ra_refresh_post":{"properties":{"incremental":{"type":"boolean","title":"Incremental","description":"Whether to only retrieve RetroAchievements progression incrementally.","default":false}},"type":"object","title":"Body_refresh_retro_achievements_api_users__id__ra_refresh_post"},"Body_request_password_reset_api_forgot_password_post":{"properties":{"username":{"type":"string","title":"Username"}},"type":"object","required":["username"],"title":"Body_request_password_reset_api_forgot_password_post"},"Body_reset_password_api_reset_password_post":{"properties":{"token":{"type":"string","title":"Token"},"new_password":{"type":"string","title":"New Password"}},"type":"object","required":["token","new_password"],"title":"Body_reset_password_api_reset_password_post"},"Body_token_api_token_post":{"properties":{"grant_type":{"type":"string","title":"Grant Type","default":"password"},"scope":{"type":"string","title":"Scope","default":""},"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"password":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Password"},"client_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Id"},"client_secret":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Secret"},"refresh_token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Refresh Token"}},"type":"object","title":"Body_token_api_token_post"},"Body_track_save_api_saves__id__track_post":{"properties":{"device_id":{"type":"string","title":"Device Id"}},"type":"object","required":["device_id"],"title":"Body_track_save_api_saves__id__track_post"},"Body_untrack_save_api_saves__id__untrack_post":{"properties":{"device_id":{"type":"string","title":"Device Id"}},"type":"object","required":["device_id"],"title":"Body_untrack_save_api_saves__id__untrack_post"},"Body_update_collection_api_collections__id__put":{"properties":{"artwork":{"anyOf":[{"type":"string","format":"binary"},{"type":"null"}],"title":"Artwork"}},"type":"object","title":"Body_update_collection_api_collections__id__put"},"Body_update_platform_api_platforms__id__put":{"properties":{"aspect_ratio":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Aspect Ratio","description":"Cover aspect ratio."},"custom_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custom Name","description":"Custom platform name."}},"type":"object","title":"Body_update_platform_api_platforms__id__put"},"Body_update_rom_api_roms__id__put":{"properties":{"artwork":{"anyOf":[{"type":"string","format":"binary"},{"type":"null"}],"title":"Artwork","description":"Custom artwork to set as cover."}},"type":"object","title":"Body_update_rom_api_roms__id__put"},"Body_update_rom_user_api_roms__id__props_put":{"properties":{"update_last_played":{"type":"boolean","title":"Update Last Played","description":"Whether to update the last played date.","default":false},"remove_last_played":{"type":"boolean","title":"Remove Last Played","description":"Whether to remove the last played date.","default":false}},"type":"object","title":"Body_update_rom_user_api_roms__id__props_put"},"BulkOperationResponse":{"properties":{"successful_items":{"type":"integer","title":"Successful Items"},"failed_items":{"type":"integer","title":"Failed Items"},"errors":{"items":{"type":"string"},"type":"array","title":"Errors"}},"type":"object","required":["successful_items","failed_items","errors"],"title":"BulkOperationResponse"},"CleanupStats":{"properties":{"platforms_in_db":{"type":"integer","title":"Platforms In Db"},"roms_in_db":{"type":"integer","title":"Roms In Db"},"platforms_in_fs":{"type":"integer","title":"Platforms In Fs"},"roms_in_fs":{"type":"integer","title":"Roms In Fs"},"removed_fs_platforms":{"type":"integer","title":"Removed Fs Platforms"},"removed_fs_roms":{"type":"integer","title":"Removed Fs Roms"}},"type":"object","required":["platforms_in_db","roms_in_db","platforms_in_fs","roms_in_fs","removed_fs_platforms","removed_fs_roms"],"title":"CleanupStats"},"CleanupTaskMeta":{"properties":{"cleanup_stats":{"anyOf":[{"$ref":"#/components/schemas/CleanupStats"},{"type":"null"}]}},"type":"object","required":["cleanup_stats"],"title":"CleanupTaskMeta"},"CleanupTaskStatusResponse":{"properties":{"task_name":{"type":"string","title":"Task Name"},"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"enqueued_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Enqueued At"},"started_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Started At"},"ended_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ended At"},"task_type":{"type":"string","const":"cleanup","title":"Task Type"},"meta":{"$ref":"#/components/schemas/CleanupTaskMeta"}},"type":"object","required":["task_name","task_id","status","created_at","enqueued_at","started_at","ended_at","task_type","meta"],"title":"CleanupTaskStatusResponse"},"CollectionSchema":{"properties":{"name":{"type":"string","title":"Name"},"description":{"type":"string","title":"Description"},"rom_ids":{"items":{"type":"integer"},"type":"array","uniqueItems":true,"title":"Rom Ids"},"rom_count":{"type":"integer","title":"Rom Count"},"path_cover_small":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Small"},"path_cover_large":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Large"},"path_covers_small":{"items":{"type":"string"},"type":"array","title":"Path Covers Small"},"path_covers_large":{"items":{"type":"string"},"type":"array","title":"Path Covers Large"},"is_public":{"type":"boolean","title":"Is Public","default":false},"is_favorite":{"type":"boolean","title":"Is Favorite","default":false},"is_virtual":{"type":"boolean","title":"Is Virtual","default":false},"is_smart":{"type":"boolean","title":"Is Smart","default":false},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"id":{"type":"integer","title":"Id"},"url_cover":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url Cover"},"user_id":{"type":"integer","title":"User Id"},"owner_username":{"type":"string","title":"Owner Username"}},"type":"object","required":["name","description","rom_ids","rom_count","path_cover_small","path_cover_large","path_covers_small","path_covers_large","created_at","updated_at","id","url_cover","user_id","owner_username"],"title":"CollectionSchema"},"ConfigResponse":{"properties":{"CONFIG_FILE_MOUNTED":{"type":"boolean","title":"Config File Mounted"},"CONFIG_FILE_WRITABLE":{"type":"boolean","title":"Config File Writable"},"EXCLUDED_PLATFORMS":{"items":{"type":"string"},"type":"array","title":"Excluded Platforms"},"EXCLUDED_SINGLE_EXT":{"items":{"type":"string"},"type":"array","title":"Excluded Single Ext"},"EXCLUDED_SINGLE_FILES":{"items":{"type":"string"},"type":"array","title":"Excluded Single Files"},"EXCLUDED_MULTI_FILES":{"items":{"type":"string"},"type":"array","title":"Excluded Multi Files"},"EXCLUDED_MULTI_PARTS_EXT":{"items":{"type":"string"},"type":"array","title":"Excluded Multi Parts Ext"},"EXCLUDED_MULTI_PARTS_FILES":{"items":{"type":"string"},"type":"array","title":"Excluded Multi Parts Files"},"PLATFORMS_BINDING":{"additionalProperties":{"type":"string"},"type":"object","title":"Platforms Binding"},"PLATFORMS_VERSIONS":{"additionalProperties":{"type":"string"},"type":"object","title":"Platforms Versions"},"SKIP_HASH_CALCULATION":{"type":"boolean","title":"Skip Hash Calculation"},"EJS_DEBUG":{"type":"boolean","title":"Ejs Debug"},"EJS_CACHE_LIMIT":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ejs Cache Limit"},"EJS_DISABLE_AUTO_UNLOAD":{"type":"boolean","title":"Ejs Disable Auto Unload"},"EJS_DISABLE_BATCH_BOOTUP":{"type":"boolean","title":"Ejs Disable Batch Bootup"},"EJS_NETPLAY_ENABLED":{"type":"boolean","title":"Ejs Netplay Enabled"},"EJS_NETPLAY_ICE_SERVERS":{"items":{"$ref":"#/components/schemas/NetplayICEServer"},"type":"array","title":"Ejs Netplay Ice Servers"},"EJS_SETTINGS":{"additionalProperties":{"additionalProperties":{"type":"string"},"type":"object"},"type":"object","title":"Ejs Settings"},"EJS_CONTROLS":{"additionalProperties":{"$ref":"#/components/schemas/EjsControls"},"type":"object","title":"Ejs Controls"},"SCAN_METADATA_PRIORITY":{"items":{"type":"string"},"type":"array","title":"Scan Metadata Priority"},"SCAN_ARTWORK_PRIORITY":{"items":{"type":"string"},"type":"array","title":"Scan Artwork Priority"},"SCAN_REGION_PRIORITY":{"items":{"type":"string"},"type":"array","title":"Scan Region Priority"},"SCAN_LANGUAGE_PRIORITY":{"items":{"type":"string"},"type":"array","title":"Scan Language Priority"},"SCAN_MEDIA":{"items":{"type":"string"},"type":"array","title":"Scan Media"}},"type":"object","required":["CONFIG_FILE_MOUNTED","CONFIG_FILE_WRITABLE","EXCLUDED_PLATFORMS","EXCLUDED_SINGLE_EXT","EXCLUDED_SINGLE_FILES","EXCLUDED_MULTI_FILES","EXCLUDED_MULTI_PARTS_EXT","EXCLUDED_MULTI_PARTS_FILES","PLATFORMS_BINDING","PLATFORMS_VERSIONS","SKIP_HASH_CALCULATION","EJS_DEBUG","EJS_CACHE_LIMIT","EJS_DISABLE_AUTO_UNLOAD","EJS_DISABLE_BATCH_BOOTUP","EJS_NETPLAY_ENABLED","EJS_NETPLAY_ICE_SERVERS","EJS_SETTINGS","EJS_CONTROLS","SCAN_METADATA_PRIORITY","SCAN_ARTWORK_PRIORITY","SCAN_REGION_PRIORITY","SCAN_LANGUAGE_PRIORITY","SCAN_MEDIA"],"title":"ConfigResponse"},"ConversionStats":{"properties":{"processed":{"type":"integer","title":"Processed"},"errors":{"type":"integer","title":"Errors"},"total":{"type":"integer","title":"Total"}},"type":"object","required":["processed","errors","total"],"title":"ConversionStats"},"ConversionTaskMeta":{"properties":{"conversion_stats":{"anyOf":[{"$ref":"#/components/schemas/ConversionStats"},{"type":"null"}]}},"type":"object","required":["conversion_stats"],"title":"ConversionTaskMeta"},"ConversionTaskStatusResponse":{"properties":{"task_name":{"type":"string","title":"Task Name"},"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"enqueued_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Enqueued At"},"started_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Started At"},"ended_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ended At"},"task_type":{"type":"string","const":"conversion","title":"Task Type"},"meta":{"$ref":"#/components/schemas/ConversionTaskMeta"}},"type":"object","required":["task_name","task_id","status","created_at","enqueued_at","started_at","ended_at","task_type","meta"],"title":"ConversionTaskStatusResponse"},"CustomLimitOffsetPage_SimpleRomSchema_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/SimpleRomSchema"},"type":"array","title":"Items"},"total":{"type":"integer","minimum":0.0,"title":"Total"},"limit":{"type":"integer","minimum":1.0,"title":"Limit"},"offset":{"type":"integer","minimum":0.0,"title":"Offset"},"char_index":{"additionalProperties":{"type":"integer"},"type":"object","title":"Char Index"},"rom_id_index":{"items":{"type":"integer"},"type":"array","title":"Rom Id Index"},"filter_values":{"$ref":"#/components/schemas/RomFiltersDict"}},"type":"object","required":["items","total","limit","offset","char_index","rom_id_index","filter_values"],"title":"CustomLimitOffsetPage[SimpleRomSchema]"},"DetailedRomSchema":{"properties":{"id":{"type":"integer","title":"Id"},"igdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Igdb Id"},"sgdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Sgdb Id"},"moby_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Moby Id"},"ss_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ss Id"},"ra_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ra Id"},"launchbox_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Launchbox Id"},"hasheous_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Hasheous Id"},"tgdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Tgdb Id"},"flashpoint_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flashpoint Id"},"hltb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Hltb Id"},"gamelist_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Gamelist Id"},"platform_id":{"type":"integer","title":"Platform Id"},"platform_slug":{"type":"string","title":"Platform Slug"},"platform_fs_slug":{"type":"string","title":"Platform Fs Slug"},"platform_custom_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Platform Custom Name"},"platform_display_name":{"type":"string","title":"Platform Display Name"},"fs_name":{"type":"string","title":"Fs Name"},"fs_name_no_tags":{"type":"string","title":"Fs Name No Tags"},"fs_name_no_ext":{"type":"string","title":"Fs Name No Ext"},"fs_extension":{"type":"string","title":"Fs Extension"},"fs_path":{"type":"string","title":"Fs Path"},"fs_size_bytes":{"type":"integer","title":"Fs Size Bytes"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary"},"alternative_names":{"items":{"type":"string"},"type":"array","title":"Alternative Names"},"youtube_video_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Youtube Video Id"},"metadatum":{"$ref":"#/components/schemas/RomMetadataSchema"},"igdb_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomIGDBMetadata"},{"type":"null"}]},"moby_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomMobyMetadata"},{"type":"null"}]},"ss_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomSSMetadata"},{"type":"null"}]},"launchbox_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomLaunchboxMetadata"},{"type":"null"}]},"hasheous_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomHasheousMetadata"},{"type":"null"}]},"flashpoint_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomFlashpointMetadata"},{"type":"null"}]},"hltb_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomHLTBMetadata"},{"type":"null"}]},"gamelist_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomGamelistMetadata"},{"type":"null"}]},"manual_metadata":{"anyOf":[{"$ref":"#/components/schemas/ManualMetadata"},{"type":"null"}]},"path_cover_small":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Small"},"path_cover_large":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Large"},"url_cover":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url Cover"},"has_manual":{"type":"boolean","title":"Has Manual"},"path_manual":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Manual"},"url_manual":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url Manual"},"is_identifying":{"type":"boolean","title":"Is Identifying","default":false},"is_unidentified":{"type":"boolean","title":"Is Unidentified"},"is_identified":{"type":"boolean","title":"Is Identified"},"revision":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Revision"},"regions":{"items":{"type":"string"},"type":"array","title":"Regions"},"languages":{"items":{"type":"string"},"type":"array","title":"Languages"},"tags":{"items":{"type":"string"},"type":"array","title":"Tags"},"crc_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Crc Hash"},"md5_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Md5 Hash"},"sha1_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sha1 Hash"},"ra_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ra Hash"},"has_simple_single_file":{"type":"boolean","title":"Has Simple Single File"},"has_nested_single_file":{"type":"boolean","title":"Has Nested Single File"},"has_multiple_files":{"type":"boolean","title":"Has Multiple Files"},"files":{"items":{"$ref":"#/components/schemas/RomFileSchema"},"type":"array","title":"Files"},"full_path":{"type":"string","title":"Full Path"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"missing_from_fs":{"type":"boolean","title":"Missing From Fs"},"has_notes":{"type":"boolean","title":"Has Notes"},"siblings":{"items":{"$ref":"#/components/schemas/SiblingRomSchema"},"type":"array","title":"Siblings"},"rom_user":{"$ref":"#/components/schemas/RomUserSchema"},"merged_screenshots":{"items":{"type":"string"},"type":"array","title":"Merged Screenshots"},"merged_ra_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomRAMetadata"},{"type":"null"}]},"user_saves":{"items":{"$ref":"#/components/schemas/SaveSchema"},"type":"array","title":"User Saves"},"user_states":{"items":{"$ref":"#/components/schemas/StateSchema"},"type":"array","title":"User States"},"user_screenshots":{"items":{"$ref":"#/components/schemas/ScreenshotSchema"},"type":"array","title":"User Screenshots"},"user_collections":{"items":{"$ref":"#/components/schemas/UserCollectionSchema"},"type":"array","title":"User Collections"},"all_user_notes":{"items":{"$ref":"#/components/schemas/UserNoteSchema"},"type":"array","title":"All User Notes"}},"type":"object","required":["id","igdb_id","sgdb_id","moby_id","ss_id","ra_id","launchbox_id","hasheous_id","tgdb_id","flashpoint_id","hltb_id","gamelist_id","platform_id","platform_slug","platform_fs_slug","platform_custom_name","platform_display_name","fs_name","fs_name_no_tags","fs_name_no_ext","fs_extension","fs_path","fs_size_bytes","name","slug","summary","alternative_names","youtube_video_id","metadatum","igdb_metadata","moby_metadata","ss_metadata","launchbox_metadata","hasheous_metadata","flashpoint_metadata","hltb_metadata","gamelist_metadata","manual_metadata","path_cover_small","path_cover_large","url_cover","has_manual","path_manual","url_manual","is_unidentified","is_identified","revision","regions","languages","tags","crc_hash","md5_hash","sha1_hash","ra_hash","has_simple_single_file","has_nested_single_file","has_multiple_files","files","full_path","created_at","updated_at","missing_from_fs","has_notes","siblings","rom_user","merged_screenshots","merged_ra_metadata","user_saves","user_states","user_screenshots","user_collections","all_user_notes"],"title":"DetailedRomSchema"},"DeviceCreatePayload":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"platform":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Platform"},"client":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client"},"client_version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Version"},"ip_address":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ip Address"},"mac_address":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Mac Address"},"hostname":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Hostname"},"allow_existing":{"type":"boolean","title":"Allow Existing","default":true},"allow_duplicate":{"type":"boolean","title":"Allow Duplicate","default":false},"reset_syncs":{"type":"boolean","title":"Reset Syncs","default":false}},"type":"object","title":"DeviceCreatePayload"},"DeviceCreateResponse":{"properties":{"device_id":{"type":"string","title":"Device Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["device_id","name","created_at"],"title":"DeviceCreateResponse"},"DeviceSchema":{"properties":{"id":{"type":"string","title":"Id"},"user_id":{"type":"integer","title":"User Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"platform":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Platform"},"client":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client"},"client_version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Version"},"ip_address":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ip Address"},"mac_address":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Mac Address"},"hostname":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Hostname"},"sync_mode":{"$ref":"#/components/schemas/SyncMode"},"sync_enabled":{"type":"boolean","title":"Sync Enabled"},"last_seen":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Seen"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","user_id","name","platform","client","client_version","ip_address","mac_address","hostname","sync_mode","sync_enabled","last_seen","created_at","updated_at"],"title":"DeviceSchema"},"DeviceSyncSchema":{"properties":{"device_id":{"type":"string","title":"Device Id"},"device_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Device Name"},"last_synced_at":{"type":"string","format":"date-time","title":"Last Synced At"},"is_untracked":{"type":"boolean","title":"Is Untracked"},"is_current":{"type":"boolean","title":"Is Current"}},"type":"object","required":["device_id","device_name","last_synced_at","is_untracked","is_current"],"title":"DeviceSyncSchema"},"DeviceUpdatePayload":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"platform":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Platform"},"client":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client"},"client_version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Version"},"ip_address":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ip Address"},"mac_address":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Mac Address"},"hostname":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Hostname"},"sync_enabled":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Sync Enabled"}},"type":"object","title":"DeviceUpdatePayload"},"EarnedAchievement":{"properties":{"id":{"type":"string","title":"Id"},"date":{"type":"string","title":"Date"},"date_hardcore":{"type":"string","title":"Date Hardcore"}},"type":"object","required":["id","date"],"title":"EarnedAchievement"},"EjsControls":{"properties":{"_0":{"additionalProperties":{"$ref":"#/components/schemas/EjsControlsButton"},"type":"object","title":"0"},"_1":{"additionalProperties":{"$ref":"#/components/schemas/EjsControlsButton"},"type":"object","title":"1"},"_2":{"additionalProperties":{"$ref":"#/components/schemas/EjsControlsButton"},"type":"object","title":"2"},"_3":{"additionalProperties":{"$ref":"#/components/schemas/EjsControlsButton"},"type":"object","title":"3"}},"type":"object","required":["_0","_1","_2","_3"],"title":"EjsControls"},"EjsControlsButton":{"properties":{"value":{"type":"string","title":"Value"},"value2":{"type":"string","title":"Value2"}},"type":"object","title":"EjsControlsButton"},"EmulationDict":{"properties":{"DISABLE_EMULATOR_JS":{"type":"boolean","title":"Disable Emulator Js"},"DISABLE_RUFFLE_RS":{"type":"boolean","title":"Disable Ruffle Rs"}},"type":"object","required":["DISABLE_EMULATOR_JS","DISABLE_RUFFLE_RS"],"title":"EmulationDict"},"FilesystemDict":{"properties":{"FS_PLATFORMS":{"items":{"type":"string"},"type":"array","title":"Fs Platforms"}},"type":"object","required":["FS_PLATFORMS"],"title":"FilesystemDict"},"FirmwareSchema":{"properties":{"id":{"type":"integer","title":"Id"},"file_name":{"type":"string","title":"File Name"},"file_name_no_tags":{"type":"string","title":"File Name No Tags"},"file_name_no_ext":{"type":"string","title":"File Name No Ext"},"file_extension":{"type":"string","title":"File Extension"},"file_path":{"type":"string","title":"File Path"},"file_size_bytes":{"type":"integer","title":"File Size Bytes"},"full_path":{"type":"string","title":"Full Path"},"is_verified":{"type":"boolean","title":"Is Verified"},"crc_hash":{"type":"string","title":"Crc Hash"},"md5_hash":{"type":"string","title":"Md5 Hash"},"sha1_hash":{"type":"string","title":"Sha1 Hash"},"missing_from_fs":{"type":"boolean","title":"Missing From Fs"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","file_name","file_name_no_tags","file_name_no_ext","file_extension","file_path","file_size_bytes","full_path","is_verified","crc_hash","md5_hash","sha1_hash","missing_from_fs","created_at","updated_at"],"title":"FirmwareSchema"},"FrontendDict":{"properties":{"UPLOAD_TIMEOUT":{"type":"integer","title":"Upload Timeout"},"DISABLE_USERPASS_LOGIN":{"type":"boolean","title":"Disable Userpass Login"},"YOUTUBE_BASE_URL":{"type":"string","title":"Youtube Base Url"}},"type":"object","required":["UPLOAD_TIMEOUT","DISABLE_USERPASS_LOGIN","YOUTUBE_BASE_URL"],"title":"FrontendDict"},"GenericTaskMeta":{"properties":{},"type":"object","title":"GenericTaskMeta"},"GenericTaskStatusResponse":{"properties":{"task_name":{"type":"string","title":"Task Name"},"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"enqueued_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Enqueued At"},"started_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Started At"},"ended_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ended At"},"task_type":{"type":"string","const":"generic","title":"Task Type"},"meta":{"$ref":"#/components/schemas/GenericTaskMeta"}},"type":"object","required":["task_name","task_id","status","created_at","enqueued_at","started_at","ended_at","task_type","meta"],"title":"GenericTaskStatusResponse"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HeartbeatResponse":{"properties":{"SYSTEM":{"$ref":"#/components/schemas/SystemDict"},"METADATA_SOURCES":{"$ref":"#/components/schemas/MetadataSourcesDict"},"FILESYSTEM":{"$ref":"#/components/schemas/FilesystemDict"},"EMULATION":{"$ref":"#/components/schemas/EmulationDict"},"FRONTEND":{"$ref":"#/components/schemas/FrontendDict"},"OIDC":{"$ref":"#/components/schemas/OIDCDict"},"TASKS":{"$ref":"#/components/schemas/TasksDict"}},"type":"object","required":["SYSTEM","METADATA_SOURCES","FILESYSTEM","EMULATION","FRONTEND","OIDC","TASKS"],"title":"HeartbeatResponse"},"IGDBAgeRating":{"properties":{"rating":{"type":"string","title":"Rating"},"category":{"type":"string","title":"Category"},"rating_cover_url":{"type":"string","title":"Rating Cover Url"}},"type":"object","required":["rating","category","rating_cover_url"],"title":"IGDBAgeRating"},"IGDBMetadataMultiplayerMode":{"properties":{"campaigncoop":{"type":"boolean","title":"Campaigncoop"},"dropin":{"type":"boolean","title":"Dropin"},"lancoop":{"type":"boolean","title":"Lancoop"},"offlinecoop":{"type":"boolean","title":"Offlinecoop"},"offlinecoopmax":{"type":"integer","title":"Offlinecoopmax"},"offlinemax":{"type":"integer","title":"Offlinemax"},"onlinecoop":{"type":"integer","title":"Onlinecoop"},"onlinecoopmax":{"type":"integer","title":"Onlinecoopmax"},"onlinemax":{"type":"integer","title":"Onlinemax"},"splitscreen":{"type":"boolean","title":"Splitscreen"},"splitscreenonline":{"type":"boolean","title":"Splitscreenonline"},"platform":{"$ref":"#/components/schemas/IGDBMetadataPlatform"}},"type":"object","required":["campaigncoop","dropin","lancoop","offlinecoop","offlinecoopmax","offlinemax","onlinecoop","onlinecoopmax","onlinemax","splitscreen","splitscreenonline","platform"],"title":"IGDBMetadataMultiplayerMode"},"IGDBMetadataPlatform":{"properties":{"igdb_id":{"type":"integer","title":"Igdb Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["igdb_id","name"],"title":"IGDBMetadataPlatform"},"IGDBRelatedGame":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"slug":{"type":"string","title":"Slug"},"type":{"type":"string","title":"Type"},"cover_url":{"type":"string","title":"Cover Url"}},"type":"object","required":["id","name","slug","type","cover_url"],"title":"IGDBRelatedGame"},"InviteLinkSchema":{"properties":{"token":{"type":"string","title":"Token"}},"type":"object","required":["token"],"title":"InviteLinkSchema"},"JobStatus":{"type":"string","enum":["queued","finished","failed","started","deferred","scheduled","stopped","canceled"],"title":"JobStatus","description":"The Status of Job within its lifecycle at any given time."},"LaunchboxImage":{"properties":{"url":{"type":"string","title":"Url"},"type":{"type":"string","title":"Type"},"region":{"type":"string","title":"Region"}},"type":"object","required":["url"],"title":"LaunchboxImage"},"ManualMetadata":{"properties":{"genres":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Genres"},"franchises":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Franchises"},"companies":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Companies"},"game_modes":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Game Modes"},"age_ratings":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Age Ratings"},"first_release_date":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"First Release Date"},"youtube_video_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Youtube Video Id"}},"type":"object","title":"ManualMetadata"},"MetadataSourcesDict":{"properties":{"ANY_SOURCE_ENABLED":{"type":"boolean","title":"Any Source Enabled"},"IGDB_API_ENABLED":{"type":"boolean","title":"Igdb Api Enabled"},"SS_API_ENABLED":{"type":"boolean","title":"Ss Api Enabled"},"MOBY_API_ENABLED":{"type":"boolean","title":"Moby Api Enabled"},"STEAMGRIDDB_API_ENABLED":{"type":"boolean","title":"Steamgriddb Api Enabled"},"RA_API_ENABLED":{"type":"boolean","title":"Ra Api Enabled"},"LAUNCHBOX_API_ENABLED":{"type":"boolean","title":"Launchbox Api Enabled"},"HASHEOUS_API_ENABLED":{"type":"boolean","title":"Hasheous Api Enabled"},"PLAYMATCH_API_ENABLED":{"type":"boolean","title":"Playmatch Api Enabled"},"TGDB_API_ENABLED":{"type":"boolean","title":"Tgdb Api Enabled"},"FLASHPOINT_API_ENABLED":{"type":"boolean","title":"Flashpoint Api Enabled"},"HLTB_API_ENABLED":{"type":"boolean","title":"Hltb Api Enabled"}},"type":"object","required":["ANY_SOURCE_ENABLED","IGDB_API_ENABLED","SS_API_ENABLED","MOBY_API_ENABLED","STEAMGRIDDB_API_ENABLED","RA_API_ENABLED","LAUNCHBOX_API_ENABLED","HASHEOUS_API_ENABLED","PLAYMATCH_API_ENABLED","TGDB_API_ENABLED","FLASHPOINT_API_ENABLED","HLTB_API_ENABLED"],"title":"MetadataSourcesDict"},"MobyMetadataPlatform":{"properties":{"moby_id":{"type":"integer","title":"Moby Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["moby_id","name"],"title":"MobyMetadataPlatform"},"NetplayICEServer":{"properties":{"urls":{"type":"string","title":"Urls"},"username":{"type":"string","title":"Username"},"credential":{"type":"string","title":"Credential"}},"type":"object","required":["urls"],"title":"NetplayICEServer"},"OIDCDict":{"properties":{"ENABLED":{"type":"boolean","title":"Enabled"},"AUTOLOGIN":{"type":"boolean","title":"Autologin"},"PROVIDER":{"type":"string","title":"Provider"}},"type":"object","required":["ENABLED","AUTOLOGIN","PROVIDER"],"title":"OIDCDict"},"PlatformSchema":{"properties":{"id":{"type":"integer","title":"Id"},"slug":{"type":"string","title":"Slug"},"fs_slug":{"type":"string","title":"Fs Slug"},"rom_count":{"type":"integer","title":"Rom Count"},"name":{"type":"string","title":"Name"},"igdb_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Igdb Slug"},"moby_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Moby Slug"},"hltb_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Hltb Slug"},"custom_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custom Name"},"igdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Igdb Id"},"sgdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Sgdb Id"},"moby_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Moby Id"},"launchbox_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Launchbox Id"},"ss_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ss Id"},"ra_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ra Id"},"hasheous_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Hasheous Id"},"tgdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Tgdb Id"},"flashpoint_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Flashpoint Id"},"category":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Category"},"generation":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Generation"},"family_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Family Name"},"family_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Family Slug"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"url_logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url Logo"},"firmware":{"items":{"$ref":"#/components/schemas/FirmwareSchema"},"type":"array","title":"Firmware"},"aspect_ratio":{"type":"string","title":"Aspect Ratio","default":"2 / 3"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"fs_size_bytes":{"type":"integer","title":"Fs Size Bytes"},"is_unidentified":{"type":"boolean","title":"Is Unidentified"},"is_identified":{"type":"boolean","title":"Is Identified"},"missing_from_fs":{"type":"boolean","title":"Missing From Fs"},"display_name":{"type":"string","title":"Display Name","readOnly":true}},"type":"object","required":["id","slug","fs_slug","rom_count","name","igdb_slug","moby_slug","hltb_slug","created_at","updated_at","fs_size_bytes","is_unidentified","is_identified","missing_from_fs","display_name"],"title":"PlatformSchema"},"RAGameRomAchievement":{"properties":{"ra_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ra Id"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"points":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Points"},"num_awarded":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Num Awarded"},"num_awarded_hardcore":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Num Awarded Hardcore"},"badge_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Badge Id"},"badge_url_lock":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Badge Url Lock"},"badge_path_lock":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Badge Path Lock"},"badge_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Badge Url"},"badge_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Badge Path"},"display_order":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Display Order"},"type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Type"}},"type":"object","required":["ra_id","title","description","points","num_awarded","num_awarded_hardcore","badge_id","badge_url_lock","badge_path_lock","badge_url","badge_path","display_order","type"],"title":"RAGameRomAchievement"},"RAProgression":{"properties":{"total":{"type":"integer","title":"Total"},"results":{"items":{"$ref":"#/components/schemas/RAUserGameProgression"},"type":"array","title":"Results"}},"type":"object","title":"RAProgression"},"RAUserGameProgression":{"properties":{"rom_ra_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Rom Ra Id"},"max_possible":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Possible"},"num_awarded":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Num Awarded"},"num_awarded_hardcore":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Num Awarded Hardcore"},"most_recent_awarded_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Most Recent Awarded Date"},"earned_achievements":{"items":{"$ref":"#/components/schemas/EarnedAchievement"},"type":"array","title":"Earned Achievements"}},"type":"object","required":["rom_ra_id","max_possible","num_awarded","num_awarded_hardcore","earned_achievements"],"title":"RAUserGameProgression"},"Role":{"type":"string","enum":["viewer","editor","admin"],"title":"Role"},"RomFileCategory":{"type":"string","enum":["game","dlc","hack","manual","patch","update","mod","demo","translation","prototype","cheat"],"title":"RomFileCategory"},"RomFileSchema":{"properties":{"id":{"type":"integer","title":"Id"},"rom_id":{"type":"integer","title":"Rom Id"},"file_name":{"type":"string","title":"File Name"},"file_path":{"type":"string","title":"File Path"},"file_size_bytes":{"type":"integer","title":"File Size Bytes"},"full_path":{"type":"string","title":"Full Path"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"last_modified":{"type":"string","format":"date-time","title":"Last Modified"},"crc_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Crc Hash"},"md5_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Md5 Hash"},"sha1_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sha1 Hash"},"ra_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ra Hash"},"category":{"anyOf":[{"$ref":"#/components/schemas/RomFileCategory"},{"type":"null"}]}},"type":"object","required":["id","rom_id","file_name","file_path","file_size_bytes","full_path","created_at","updated_at","last_modified","crc_hash","md5_hash","sha1_hash","ra_hash","category"],"title":"RomFileSchema"},"RomFiltersDict":{"properties":{"genres":{"items":{"type":"string"},"type":"array","title":"Genres"},"franchises":{"items":{"type":"string"},"type":"array","title":"Franchises"},"collections":{"items":{"type":"string"},"type":"array","title":"Collections"},"companies":{"items":{"type":"string"},"type":"array","title":"Companies"},"game_modes":{"items":{"type":"string"},"type":"array","title":"Game Modes"},"age_ratings":{"items":{"type":"string"},"type":"array","title":"Age Ratings"},"player_counts":{"items":{"type":"string"},"type":"array","title":"Player Counts"},"regions":{"items":{"type":"string"},"type":"array","title":"Regions"},"languages":{"items":{"type":"string"},"type":"array","title":"Languages"},"platforms":{"items":{"type":"integer"},"type":"array","title":"Platforms"}},"type":"object","required":["genres","franchises","collections","companies","game_modes","age_ratings","player_counts","regions","languages","platforms"],"title":"RomFiltersDict"},"RomFlashpointMetadata":{"properties":{"franchises":{"items":{"type":"string"},"type":"array","title":"Franchises"},"companies":{"items":{"type":"string"},"type":"array","title":"Companies"},"source":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source"},"genres":{"items":{"type":"string"},"type":"array","title":"Genres"},"first_release_date":{"type":"string","title":"First Release Date"},"game_modes":{"items":{"type":"string"},"type":"array","title":"Game Modes"},"status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language"},"notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Notes"}},"type":"object","title":"RomFlashpointMetadata"},"RomGamelistMetadata":{"properties":{"box2d_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box2D Url"},"box2d_back_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box2D Back Url"},"box3d_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box3D Url"},"fanart_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Fanart Url"},"image_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Image Url"},"manual_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Manual Url"},"marquee_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Marquee Url"},"miximage_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Miximage Url"},"physical_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Physical Url"},"screenshot_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Screenshot Url"},"thumbnail_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Thumbnail Url"},"title_screen_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title Screen Url"},"video_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Video Url"},"rating":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Rating"},"first_release_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"First Release Date"},"companies":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Companies"},"franchises":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Franchises"},"genres":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Genres"},"player_count":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Player Count"},"md5_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Md5 Hash"},"box3d_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box3D Path"},"miximage_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Miximage Path"},"physical_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Physical Path"},"marquee_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Marquee Path"},"video_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Video Path"}},"type":"object","title":"RomGamelistMetadata"},"RomHLTBMetadata":{"properties":{"main_story":{"type":"integer","title":"Main Story"},"main_story_count":{"type":"integer","title":"Main Story Count"},"main_plus_extra":{"type":"integer","title":"Main Plus Extra"},"main_plus_extra_count":{"type":"integer","title":"Main Plus Extra Count"},"completionist":{"type":"integer","title":"Completionist"},"completionist_count":{"type":"integer","title":"Completionist Count"},"all_styles":{"type":"integer","title":"All Styles"},"all_styles_count":{"type":"integer","title":"All Styles Count"},"release_year":{"type":"integer","title":"Release Year"},"review_score":{"type":"integer","title":"Review Score"},"review_count":{"type":"integer","title":"Review Count"},"popularity":{"type":"integer","title":"Popularity"},"completions":{"type":"integer","title":"Completions"}},"type":"object","title":"RomHLTBMetadata"},"RomHasheousMetadata":{"properties":{"tosec_match":{"type":"boolean","title":"Tosec Match"},"mame_arcade_match":{"type":"boolean","title":"Mame Arcade Match"},"mame_mess_match":{"type":"boolean","title":"Mame Mess Match"},"nointro_match":{"type":"boolean","title":"Nointro Match"},"redump_match":{"type":"boolean","title":"Redump Match"},"whdload_match":{"type":"boolean","title":"Whdload Match"},"ra_match":{"type":"boolean","title":"Ra Match"},"fbneo_match":{"type":"boolean","title":"Fbneo Match"},"puredos_match":{"type":"boolean","title":"Puredos Match"}},"type":"object","title":"RomHasheousMetadata"},"RomIGDBMetadata":{"properties":{"total_rating":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Total Rating"},"aggregated_rating":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Aggregated Rating"},"first_release_date":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"First Release Date"},"youtube_video_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Youtube Video Id"},"genres":{"items":{"type":"string"},"type":"array","title":"Genres"},"franchises":{"items":{"type":"string"},"type":"array","title":"Franchises"},"alternative_names":{"items":{"type":"string"},"type":"array","title":"Alternative Names"},"collections":{"items":{"type":"string"},"type":"array","title":"Collections"},"companies":{"items":{"type":"string"},"type":"array","title":"Companies"},"game_modes":{"items":{"type":"string"},"type":"array","title":"Game Modes"},"age_ratings":{"items":{"$ref":"#/components/schemas/IGDBAgeRating"},"type":"array","title":"Age Ratings"},"platforms":{"items":{"$ref":"#/components/schemas/IGDBMetadataPlatform"},"type":"array","title":"Platforms"},"multiplayer_modes":{"items":{"$ref":"#/components/schemas/IGDBMetadataMultiplayerMode"},"type":"array","title":"Multiplayer Modes"},"player_count":{"type":"string","title":"Player Count"},"expansions":{"items":{"$ref":"#/components/schemas/IGDBRelatedGame"},"type":"array","title":"Expansions"},"dlcs":{"items":{"$ref":"#/components/schemas/IGDBRelatedGame"},"type":"array","title":"Dlcs"},"remasters":{"items":{"$ref":"#/components/schemas/IGDBRelatedGame"},"type":"array","title":"Remasters"},"remakes":{"items":{"$ref":"#/components/schemas/IGDBRelatedGame"},"type":"array","title":"Remakes"},"expanded_games":{"items":{"$ref":"#/components/schemas/IGDBRelatedGame"},"type":"array","title":"Expanded Games"},"ports":{"items":{"$ref":"#/components/schemas/IGDBRelatedGame"},"type":"array","title":"Ports"},"similar_games":{"items":{"$ref":"#/components/schemas/IGDBRelatedGame"},"type":"array","title":"Similar Games"}},"type":"object","title":"RomIGDBMetadata"},"RomLaunchboxMetadata":{"properties":{"first_release_date":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"First Release Date"},"max_players":{"type":"integer","title":"Max Players"},"release_type":{"type":"string","title":"Release Type"},"cooperative":{"type":"boolean","title":"Cooperative"},"youtube_video_id":{"type":"string","title":"Youtube Video Id"},"community_rating":{"type":"number","title":"Community Rating"},"community_rating_count":{"type":"integer","title":"Community Rating Count"},"wikipedia_url":{"type":"string","title":"Wikipedia Url"},"esrb":{"type":"string","title":"Esrb"},"genres":{"items":{"type":"string"},"type":"array","title":"Genres"},"companies":{"items":{"type":"string"},"type":"array","title":"Companies"},"images":{"items":{"$ref":"#/components/schemas/LaunchboxImage"},"type":"array","title":"Images"}},"type":"object","title":"RomLaunchboxMetadata"},"RomMetadataSchema":{"properties":{"rom_id":{"type":"integer","title":"Rom Id"},"genres":{"items":{"type":"string"},"type":"array","title":"Genres"},"franchises":{"items":{"type":"string"},"type":"array","title":"Franchises"},"collections":{"items":{"type":"string"},"type":"array","title":"Collections"},"companies":{"items":{"type":"string"},"type":"array","title":"Companies"},"game_modes":{"items":{"type":"string"},"type":"array","title":"Game Modes"},"age_ratings":{"items":{"type":"string"},"type":"array","title":"Age Ratings"},"player_count":{"type":"string","title":"Player Count"},"first_release_date":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"First Release Date"},"average_rating":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Average Rating"}},"type":"object","required":["rom_id","genres","franchises","collections","companies","game_modes","age_ratings","player_count","first_release_date","average_rating"],"title":"RomMetadataSchema"},"RomMobyMetadata":{"properties":{"moby_score":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Moby Score"},"genres":{"items":{"type":"string"},"type":"array","title":"Genres"},"alternate_titles":{"items":{"type":"string"},"type":"array","title":"Alternate Titles"},"platforms":{"items":{"$ref":"#/components/schemas/MobyMetadataPlatform"},"type":"array","title":"Platforms"}},"type":"object","title":"RomMobyMetadata"},"RomRAMetadata":{"properties":{"first_release_date":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"First Release Date"},"genres":{"items":{"type":"string"},"type":"array","title":"Genres"},"companies":{"items":{"type":"string"},"type":"array","title":"Companies"},"achievements":{"items":{"$ref":"#/components/schemas/RAGameRomAchievement"},"type":"array","title":"Achievements"}},"type":"object","title":"RomRAMetadata"},"RomSSMetadata":{"properties":{"bezel_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bezel Url"},"box2d_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box2D Url"},"box2d_side_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box2D Side Url"},"box2d_back_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box2D Back Url"},"box3d_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box3D Url"},"fanart_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Fanart Url"},"fullbox_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Fullbox Url"},"logo_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo Url"},"manual_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Manual Url"},"marquee_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Marquee Url"},"miximage_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Miximage Url"},"physical_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Physical Url"},"screenshot_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Screenshot Url"},"steamgrid_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Steamgrid Url"},"title_screen_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title Screen Url"},"video_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Video Url"},"video_normalized_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Video Normalized Url"},"bezel_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bezel Path"},"box2d_back_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box2D Back Path"},"box3d_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Box3D Path"},"fanart_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Fanart Path"},"miximage_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Miximage Path"},"physical_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Physical Path"},"marquee_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Marquee Path"},"logo_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo Path"},"video_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Video Path"},"ss_score":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ss Score"},"first_release_date":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"First Release Date"},"alternative_names":{"items":{"type":"string"},"type":"array","title":"Alternative Names"},"companies":{"items":{"type":"string"},"type":"array","title":"Companies"},"franchises":{"items":{"type":"string"},"type":"array","title":"Franchises"},"game_modes":{"items":{"type":"string"},"type":"array","title":"Game Modes"},"genres":{"items":{"type":"string"},"type":"array","title":"Genres"},"player_count":{"type":"string","title":"Player Count"}},"type":"object","title":"RomSSMetadata"},"RomUserSchema":{"properties":{"id":{"type":"integer","title":"Id"},"user_id":{"type":"integer","title":"User Id"},"rom_id":{"type":"integer","title":"Rom Id"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"last_played":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Played"},"is_main_sibling":{"type":"boolean","title":"Is Main Sibling"},"backlogged":{"type":"boolean","title":"Backlogged"},"now_playing":{"type":"boolean","title":"Now Playing"},"hidden":{"type":"boolean","title":"Hidden"},"rating":{"type":"integer","title":"Rating"},"difficulty":{"type":"integer","title":"Difficulty"},"completion":{"type":"integer","title":"Completion"},"status":{"anyOf":[{"$ref":"#/components/schemas/RomUserStatus"},{"type":"null"}]}},"type":"object","required":["id","user_id","rom_id","created_at","updated_at","last_played","is_main_sibling","backlogged","now_playing","hidden","rating","difficulty","completion","status"],"title":"RomUserSchema"},"RomUserStatus":{"type":"string","enum":["incomplete","finished","completed_100","retired","never_playing"],"title":"RomUserStatus"},"RoomsResponse":{"properties":{"room_name":{"type":"string","title":"Room Name"},"current":{"type":"integer","title":"Current"},"max":{"type":"integer","title":"Max"},"player_name":{"type":"string","title":"Player Name"},"hasPassword":{"type":"boolean","title":"Haspassword"}},"type":"object","required":["room_name","current","max","player_name","hasPassword"],"title":"RoomsResponse"},"SGDBResource":{"properties":{"thumb":{"type":"string","title":"Thumb"},"url":{"type":"string","title":"Url"},"type":{"type":"string","title":"Type"}},"type":"object","required":["thumb","url","type"],"title":"SGDBResource"},"SaveSchema":{"properties":{"id":{"type":"integer","title":"Id"},"rom_id":{"type":"integer","title":"Rom Id"},"user_id":{"type":"integer","title":"User Id"},"file_name":{"type":"string","title":"File Name"},"file_name_no_tags":{"type":"string","title":"File Name No Tags"},"file_name_no_ext":{"type":"string","title":"File Name No Ext"},"file_extension":{"type":"string","title":"File Extension"},"file_path":{"type":"string","title":"File Path"},"file_size_bytes":{"type":"integer","title":"File Size Bytes"},"full_path":{"type":"string","title":"Full Path"},"download_path":{"type":"string","title":"Download Path"},"missing_from_fs":{"type":"boolean","title":"Missing From Fs"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"emulator":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Emulator"},"slot":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slot"},"content_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content Hash"},"screenshot":{"anyOf":[{"$ref":"#/components/schemas/ScreenshotSchema"},{"type":"null"}]},"device_syncs":{"items":{"$ref":"#/components/schemas/DeviceSyncSchema"},"type":"array","title":"Device Syncs","default":[]}},"type":"object","required":["id","rom_id","user_id","file_name","file_name_no_tags","file_name_no_ext","file_extension","file_path","file_size_bytes","full_path","download_path","missing_from_fs","created_at","updated_at","emulator","screenshot"],"title":"SaveSchema"},"SaveSummarySchema":{"properties":{"total_count":{"type":"integer","title":"Total Count"},"slots":{"items":{"$ref":"#/components/schemas/SlotSummarySchema"},"type":"array","title":"Slots"}},"type":"object","required":["total_count","slots"],"title":"SaveSummarySchema"},"ScanStats":{"properties":{"total_platforms":{"type":"integer","title":"Total Platforms"},"total_roms":{"type":"integer","title":"Total Roms"},"scanned_platforms":{"type":"integer","title":"Scanned Platforms"},"new_platforms":{"type":"integer","title":"New Platforms"},"identified_platforms":{"type":"integer","title":"Identified Platforms"},"scanned_roms":{"type":"integer","title":"Scanned Roms"},"new_roms":{"type":"integer","title":"New Roms"},"identified_roms":{"type":"integer","title":"Identified Roms"},"scanned_firmware":{"type":"integer","title":"Scanned Firmware"},"new_firmware":{"type":"integer","title":"New Firmware"}},"type":"object","required":["total_platforms","total_roms","scanned_platforms","new_platforms","identified_platforms","scanned_roms","new_roms","identified_roms","scanned_firmware","new_firmware"],"title":"ScanStats"},"ScanTaskMeta":{"properties":{"scan_stats":{"anyOf":[{"$ref":"#/components/schemas/ScanStats"},{"type":"null"}]}},"type":"object","required":["scan_stats"],"title":"ScanTaskMeta"},"ScanTaskStatusResponse":{"properties":{"task_name":{"type":"string","title":"Task Name"},"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"enqueued_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Enqueued At"},"started_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Started At"},"ended_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ended At"},"task_type":{"type":"string","const":"scan","title":"Task Type"},"meta":{"$ref":"#/components/schemas/ScanTaskMeta"}},"type":"object","required":["task_name","task_id","status","created_at","enqueued_at","started_at","ended_at","task_type","meta"],"title":"ScanTaskStatusResponse"},"ScreenshotSchema":{"properties":{"id":{"type":"integer","title":"Id"},"rom_id":{"type":"integer","title":"Rom Id"},"user_id":{"type":"integer","title":"User Id"},"file_name":{"type":"string","title":"File Name"},"file_name_no_tags":{"type":"string","title":"File Name No Tags"},"file_name_no_ext":{"type":"string","title":"File Name No Ext"},"file_extension":{"type":"string","title":"File Extension"},"file_path":{"type":"string","title":"File Path"},"file_size_bytes":{"type":"integer","title":"File Size Bytes"},"full_path":{"type":"string","title":"Full Path"},"download_path":{"type":"string","title":"Download Path"},"missing_from_fs":{"type":"boolean","title":"Missing From Fs"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","rom_id","user_id","file_name","file_name_no_tags","file_name_no_ext","file_extension","file_path","file_size_bytes","full_path","download_path","missing_from_fs","created_at","updated_at"],"title":"ScreenshotSchema"},"SearchCoverSchema":{"properties":{"name":{"type":"string","title":"Name"},"resources":{"items":{"$ref":"#/components/schemas/SGDBResource"},"type":"array","title":"Resources"}},"type":"object","required":["name","resources"],"title":"SearchCoverSchema"},"SearchRomSchema":{"properties":{"id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Id"},"igdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Igdb Id"},"moby_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Moby Id"},"ss_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ss Id"},"sgdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Sgdb Id"},"flashpoint_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flashpoint Id"},"launchbox_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Launchbox Id"},"platform_id":{"type":"integer","title":"Platform Id"},"name":{"type":"string","title":"Name"},"slug":{"type":"string","title":"Slug","default":""},"summary":{"type":"string","title":"Summary","default":""},"igdb_url_cover":{"type":"string","title":"Igdb Url Cover","default":""},"moby_url_cover":{"type":"string","title":"Moby Url Cover","default":""},"ss_url_cover":{"type":"string","title":"Ss Url Cover","default":""},"sgdb_url_cover":{"type":"string","title":"Sgdb Url Cover","default":""},"flashpoint_url_cover":{"type":"string","title":"Flashpoint Url Cover","default":""},"launchbox_url_cover":{"type":"string","title":"Launchbox Url Cover","default":""},"is_unidentified":{"type":"boolean","title":"Is Unidentified"},"is_identified":{"type":"boolean","title":"Is Identified"}},"type":"object","required":["platform_id","name","is_unidentified","is_identified"],"title":"SearchRomSchema"},"SiblingRomSchema":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"fs_name_no_tags":{"type":"string","title":"Fs Name No Tags"},"fs_name_no_ext":{"type":"string","title":"Fs Name No Ext"},"sort_comparator":{"type":"string","title":"Sort Comparator","readOnly":true}},"type":"object","required":["id","name","fs_name_no_tags","fs_name_no_ext","sort_comparator"],"title":"SiblingRomSchema"},"SimpleRomSchema":{"properties":{"id":{"type":"integer","title":"Id"},"igdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Igdb Id"},"sgdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Sgdb Id"},"moby_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Moby Id"},"ss_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ss Id"},"ra_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ra Id"},"launchbox_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Launchbox Id"},"hasheous_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Hasheous Id"},"tgdb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Tgdb Id"},"flashpoint_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flashpoint Id"},"hltb_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Hltb Id"},"gamelist_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Gamelist Id"},"platform_id":{"type":"integer","title":"Platform Id"},"platform_slug":{"type":"string","title":"Platform Slug"},"platform_fs_slug":{"type":"string","title":"Platform Fs Slug"},"platform_custom_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Platform Custom Name"},"platform_display_name":{"type":"string","title":"Platform Display Name"},"fs_name":{"type":"string","title":"Fs Name"},"fs_name_no_tags":{"type":"string","title":"Fs Name No Tags"},"fs_name_no_ext":{"type":"string","title":"Fs Name No Ext"},"fs_extension":{"type":"string","title":"Fs Extension"},"fs_path":{"type":"string","title":"Fs Path"},"fs_size_bytes":{"type":"integer","title":"Fs Size Bytes"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary"},"alternative_names":{"items":{"type":"string"},"type":"array","title":"Alternative Names"},"youtube_video_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Youtube Video Id"},"metadatum":{"$ref":"#/components/schemas/RomMetadataSchema"},"igdb_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomIGDBMetadata"},{"type":"null"}]},"moby_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomMobyMetadata"},{"type":"null"}]},"ss_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomSSMetadata"},{"type":"null"}]},"launchbox_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomLaunchboxMetadata"},{"type":"null"}]},"hasheous_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomHasheousMetadata"},{"type":"null"}]},"flashpoint_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomFlashpointMetadata"},{"type":"null"}]},"hltb_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomHLTBMetadata"},{"type":"null"}]},"gamelist_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomGamelistMetadata"},{"type":"null"}]},"manual_metadata":{"anyOf":[{"$ref":"#/components/schemas/ManualMetadata"},{"type":"null"}]},"path_cover_small":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Small"},"path_cover_large":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Large"},"url_cover":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url Cover"},"has_manual":{"type":"boolean","title":"Has Manual"},"path_manual":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Manual"},"url_manual":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url Manual"},"is_identifying":{"type":"boolean","title":"Is Identifying","default":false},"is_unidentified":{"type":"boolean","title":"Is Unidentified"},"is_identified":{"type":"boolean","title":"Is Identified"},"revision":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Revision"},"regions":{"items":{"type":"string"},"type":"array","title":"Regions"},"languages":{"items":{"type":"string"},"type":"array","title":"Languages"},"tags":{"items":{"type":"string"},"type":"array","title":"Tags"},"crc_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Crc Hash"},"md5_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Md5 Hash"},"sha1_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sha1 Hash"},"ra_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ra Hash"},"has_simple_single_file":{"type":"boolean","title":"Has Simple Single File"},"has_nested_single_file":{"type":"boolean","title":"Has Nested Single File"},"has_multiple_files":{"type":"boolean","title":"Has Multiple Files"},"files":{"items":{"$ref":"#/components/schemas/RomFileSchema"},"type":"array","title":"Files"},"full_path":{"type":"string","title":"Full Path"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"missing_from_fs":{"type":"boolean","title":"Missing From Fs"},"has_notes":{"type":"boolean","title":"Has Notes"},"siblings":{"items":{"$ref":"#/components/schemas/SiblingRomSchema"},"type":"array","title":"Siblings"},"rom_user":{"$ref":"#/components/schemas/RomUserSchema"},"merged_screenshots":{"items":{"type":"string"},"type":"array","title":"Merged Screenshots"},"merged_ra_metadata":{"anyOf":[{"$ref":"#/components/schemas/RomRAMetadata"},{"type":"null"}]}},"type":"object","required":["id","igdb_id","sgdb_id","moby_id","ss_id","ra_id","launchbox_id","hasheous_id","tgdb_id","flashpoint_id","hltb_id","gamelist_id","platform_id","platform_slug","platform_fs_slug","platform_custom_name","platform_display_name","fs_name","fs_name_no_tags","fs_name_no_ext","fs_extension","fs_path","fs_size_bytes","name","slug","summary","alternative_names","youtube_video_id","metadatum","igdb_metadata","moby_metadata","ss_metadata","launchbox_metadata","hasheous_metadata","flashpoint_metadata","hltb_metadata","gamelist_metadata","manual_metadata","path_cover_small","path_cover_large","url_cover","has_manual","path_manual","url_manual","is_unidentified","is_identified","revision","regions","languages","tags","crc_hash","md5_hash","sha1_hash","ra_hash","has_simple_single_file","has_nested_single_file","has_multiple_files","files","full_path","created_at","updated_at","missing_from_fs","has_notes","siblings","rom_user","merged_screenshots","merged_ra_metadata"],"title":"SimpleRomSchema"},"SlotSummarySchema":{"properties":{"slot":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slot"},"count":{"type":"integer","title":"Count"},"latest":{"$ref":"#/components/schemas/SaveSchema"}},"type":"object","required":["slot","count","latest"],"title":"SlotSummarySchema"},"SmartCollectionSchema":{"properties":{"name":{"type":"string","title":"Name"},"description":{"type":"string","title":"Description","default":""},"rom_ids":{"items":{"type":"integer"},"type":"array","uniqueItems":true,"title":"Rom Ids"},"rom_count":{"type":"integer","title":"Rom Count"},"path_cover_small":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Small"},"path_cover_large":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Large"},"path_covers_small":{"items":{"type":"string"},"type":"array","title":"Path Covers Small"},"path_covers_large":{"items":{"type":"string"},"type":"array","title":"Path Covers Large"},"is_public":{"type":"boolean","title":"Is Public","default":false},"is_favorite":{"type":"boolean","title":"Is Favorite","default":false},"is_virtual":{"type":"boolean","title":"Is Virtual","default":false},"is_smart":{"type":"boolean","title":"Is Smart","default":true},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"id":{"type":"integer","title":"Id"},"filter_criteria":{"additionalProperties":true,"type":"object","title":"Filter Criteria"},"filter_summary":{"type":"string","title":"Filter Summary"},"user_id":{"type":"integer","title":"User Id"},"owner_username":{"type":"string","title":"Owner Username"}},"type":"object","required":["name","rom_ids","rom_count","path_cover_small","path_cover_large","path_covers_small","path_covers_large","created_at","updated_at","id","filter_criteria","filter_summary","user_id","owner_username"],"title":"SmartCollectionSchema"},"StateSchema":{"properties":{"id":{"type":"integer","title":"Id"},"rom_id":{"type":"integer","title":"Rom Id"},"user_id":{"type":"integer","title":"User Id"},"file_name":{"type":"string","title":"File Name"},"file_name_no_tags":{"type":"string","title":"File Name No Tags"},"file_name_no_ext":{"type":"string","title":"File Name No Ext"},"file_extension":{"type":"string","title":"File Extension"},"file_path":{"type":"string","title":"File Path"},"file_size_bytes":{"type":"integer","title":"File Size Bytes"},"full_path":{"type":"string","title":"Full Path"},"download_path":{"type":"string","title":"Download Path"},"missing_from_fs":{"type":"boolean","title":"Missing From Fs"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"emulator":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Emulator"},"screenshot":{"anyOf":[{"$ref":"#/components/schemas/ScreenshotSchema"},{"type":"null"}]}},"type":"object","required":["id","rom_id","user_id","file_name","file_name_no_tags","file_name_no_ext","file_extension","file_path","file_size_bytes","full_path","download_path","missing_from_fs","created_at","updated_at","emulator","screenshot"],"title":"StateSchema"},"StatsReturn":{"properties":{"PLATFORMS":{"type":"integer","title":"Platforms"},"ROMS":{"type":"integer","title":"Roms"},"SAVES":{"type":"integer","title":"Saves"},"STATES":{"type":"integer","title":"States"},"SCREENSHOTS":{"type":"integer","title":"Screenshots"},"TOTAL_FILESIZE_BYTES":{"type":"integer","title":"Total Filesize Bytes"}},"type":"object","required":["PLATFORMS","ROMS","SAVES","STATES","SCREENSHOTS","TOTAL_FILESIZE_BYTES"],"title":"StatsReturn"},"SyncMode":{"type":"string","enum":["api","file_transfer","push_pull"],"title":"SyncMode"},"SystemDict":{"properties":{"VERSION":{"type":"string","title":"Version"},"SHOW_SETUP_WIZARD":{"type":"boolean","title":"Show Setup Wizard"}},"type":"object","required":["VERSION","SHOW_SETUP_WIZARD"],"title":"SystemDict"},"TaskExecutionResponse":{"properties":{"task_name":{"type":"string","title":"Task Name"},"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"enqueued_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Enqueued At"}},"type":"object","required":["task_name","task_id","status","created_at","enqueued_at"],"title":"TaskExecutionResponse"},"TaskInfo":{"properties":{"name":{"type":"string","title":"Name"},"type":{"$ref":"#/components/schemas/TaskType"},"manual_run":{"type":"boolean","title":"Manual Run"},"title":{"type":"string","title":"Title"},"description":{"type":"string","title":"Description"},"enabled":{"type":"boolean","title":"Enabled"},"cron_string":{"type":"string","title":"Cron String"}},"type":"object","required":["name","type","manual_run","title","description","enabled","cron_string"],"title":"TaskInfo"},"TaskType":{"type":"string","enum":["scan","conversion","cleanup","update","watcher","generic"],"title":"TaskType","description":"Enumeration of task types for categorization and UI display."},"TasksDict":{"properties":{"ENABLE_SCHEDULED_RESCAN":{"type":"boolean","title":"Enable Scheduled Rescan"},"SCHEDULED_RESCAN_CRON":{"type":"string","title":"Scheduled Rescan Cron"},"ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB":{"type":"boolean","title":"Enable Scheduled Update Switch Titledb"},"SCHEDULED_UPDATE_SWITCH_TITLEDB_CRON":{"type":"string","title":"Scheduled Update Switch Titledb Cron"},"ENABLE_SCHEDULED_UPDATE_LAUNCHBOX_METADATA":{"type":"boolean","title":"Enable Scheduled Update Launchbox Metadata"},"SCHEDULED_UPDATE_LAUNCHBOX_METADATA_CRON":{"type":"string","title":"Scheduled Update Launchbox Metadata Cron"},"ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP":{"type":"boolean","title":"Enable Scheduled Convert Images To Webp"},"SCHEDULED_CONVERT_IMAGES_TO_WEBP_CRON":{"type":"string","title":"Scheduled Convert Images To Webp Cron"}},"type":"object","required":["ENABLE_SCHEDULED_RESCAN","SCHEDULED_RESCAN_CRON","ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB","SCHEDULED_UPDATE_SWITCH_TITLEDB_CRON","ENABLE_SCHEDULED_UPDATE_LAUNCHBOX_METADATA","SCHEDULED_UPDATE_LAUNCHBOX_METADATA_CRON","ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP","SCHEDULED_CONVERT_IMAGES_TO_WEBP_CRON"],"title":"TasksDict"},"TinfoilFeedFileSchema":{"properties":{"url":{"type":"string","title":"Url"},"size":{"type":"integer","title":"Size"}},"type":"object","required":["url","size"],"title":"TinfoilFeedFileSchema"},"TinfoilFeedSchema":{"properties":{"files":{"items":{"$ref":"#/components/schemas/TinfoilFeedFileSchema"},"type":"array","title":"Files"},"directories":{"items":{"type":"string"},"type":"array","title":"Directories"},"titledb":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Titledb"},"success":{"type":"string","title":"Success"},"error":{"type":"string","title":"Error"}},"type":"object","required":["files","directories"],"title":"TinfoilFeedSchema"},"TokenResponse":{"properties":{"access_token":{"type":"string","title":"Access Token"},"refresh_token":{"type":"string","title":"Refresh Token"},"token_type":{"type":"string","title":"Token Type"},"expires":{"type":"integer","title":"Expires"}},"type":"object","required":["access_token","token_type","expires"],"title":"TokenResponse"},"UpdateStats":{"properties":{"processed":{"type":"integer","title":"Processed"},"total":{"type":"integer","title":"Total"}},"type":"object","required":["processed","total"],"title":"UpdateStats"},"UpdateTaskMeta":{"properties":{"update_stats":{"anyOf":[{"$ref":"#/components/schemas/UpdateStats"},{"type":"null"}]}},"type":"object","required":["update_stats"],"title":"UpdateTaskMeta"},"UpdateTaskStatusResponse":{"properties":{"task_name":{"type":"string","title":"Task Name"},"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"enqueued_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Enqueued At"},"started_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Started At"},"ended_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ended At"},"task_type":{"type":"string","const":"update","title":"Task Type"},"meta":{"$ref":"#/components/schemas/UpdateTaskMeta"}},"type":"object","required":["task_name","task_id","status","created_at","enqueued_at","started_at","ended_at","task_type","meta"],"title":"UpdateTaskStatusResponse"},"UserCollectionSchema":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["id","name"],"title":"UserCollectionSchema"},"UserForm":{"properties":{"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"password":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Password"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"role":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Role"},"enabled":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Enabled"},"ra_username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ra Username"},"avatar":{"anyOf":[{"type":"string","format":"binary"},{"type":"null"}],"title":"Avatar"},"ui_settings":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ui Settings"}},"type":"object","title":"UserForm"},"UserNoteSchema":{"properties":{"id":{"type":"integer","title":"Id"},"title":{"type":"string","title":"Title"},"content":{"type":"string","title":"Content"},"is_public":{"type":"boolean","title":"Is Public"},"tags":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Tags"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"user_id":{"type":"integer","title":"User Id"},"username":{"type":"string","title":"Username"}},"type":"object","required":["id","title","content","is_public","created_at","updated_at","user_id","username"],"title":"UserNoteSchema"},"UserSchema":{"properties":{"id":{"type":"integer","title":"Id"},"username":{"type":"string","title":"Username"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"enabled":{"type":"boolean","title":"Enabled"},"role":{"$ref":"#/components/schemas/Role"},"oauth_scopes":{"items":{"type":"string"},"type":"array","title":"Oauth Scopes"},"avatar_path":{"type":"string","title":"Avatar Path"},"last_login":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Login"},"last_active":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Active"},"ra_username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ra Username"},"ra_progression":{"anyOf":[{"$ref":"#/components/schemas/RAProgression"},{"type":"null"}]},"ui_settings":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Ui Settings"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","username","email","enabled","role","oauth_scopes","avatar_path","last_login","last_active","created_at","updated_at"],"title":"UserSchema"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"VirtualCollectionSchema":{"properties":{"name":{"type":"string","title":"Name"},"description":{"type":"string","title":"Description"},"rom_ids":{"items":{"type":"integer"},"type":"array","uniqueItems":true,"title":"Rom Ids"},"rom_count":{"type":"integer","title":"Rom Count"},"path_cover_small":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Small"},"path_cover_large":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path Cover Large"},"path_covers_small":{"items":{"type":"string"},"type":"array","title":"Path Covers Small"},"path_covers_large":{"items":{"type":"string"},"type":"array","title":"Path Covers Large"},"is_public":{"type":"boolean","title":"Is Public","default":false},"is_favorite":{"type":"boolean","title":"Is Favorite","default":false},"is_virtual":{"type":"boolean","title":"Is Virtual","default":true},"is_smart":{"type":"boolean","title":"Is Smart","default":false},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"id":{"type":"string","title":"Id"},"type":{"type":"string","title":"Type"}},"type":"object","required":["name","description","rom_ids","rom_count","path_cover_small","path_cover_large","path_covers_small","path_covers_large","created_at","updated_at","id","type"],"title":"VirtualCollectionSchema"},"WatcherTaskMeta":{"properties":{},"type":"object","title":"WatcherTaskMeta"},"WatcherTaskStatusResponse":{"properties":{"task_name":{"type":"string","title":"Task Name"},"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"enqueued_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Enqueued At"},"started_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Started At"},"ended_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ended At"},"task_type":{"type":"string","const":"watcher","title":"Task Type"},"meta":{"$ref":"#/components/schemas/WatcherTaskMeta"}},"type":"object","required":["task_name","task_id","status","created_at","enqueued_at","started_at","ended_at","task_type","meta"],"title":"WatcherTaskStatusResponse"},"WebrcadeFeedCategorySchema":{"properties":{"title":{"type":"string","title":"Title"},"longTitle":{"type":"string","title":"Longtitle"},"background":{"type":"string","title":"Background"},"thumbnail":{"type":"string","title":"Thumbnail"},"description":{"type":"string","title":"Description"},"items":{"items":{"$ref":"#/components/schemas/WebrcadeFeedItemSchema"},"type":"array","title":"Items"}},"type":"object","required":["title","items"],"title":"WebrcadeFeedCategorySchema"},"WebrcadeFeedItemPropsSchema":{"properties":{"rom":{"type":"string","title":"Rom"}},"type":"object","required":["rom"],"title":"WebrcadeFeedItemPropsSchema"},"WebrcadeFeedItemSchema":{"properties":{"title":{"type":"string","title":"Title"},"longTitle":{"type":"string","title":"Longtitle"},"description":{"type":"string","title":"Description"},"type":{"type":"string","title":"Type"},"thumbnail":{"type":"string","title":"Thumbnail"},"background":{"type":"string","title":"Background"},"props":{"$ref":"#/components/schemas/WebrcadeFeedItemPropsSchema"}},"type":"object","required":["title","type","props"],"title":"WebrcadeFeedItemSchema"},"WebrcadeFeedSchema":{"properties":{"title":{"type":"string","title":"Title"},"longTitle":{"type":"string","title":"Longtitle"},"description":{"type":"string","title":"Description"},"thumbnail":{"type":"string","title":"Thumbnail"},"background":{"type":"string","title":"Background"},"categories":{"items":{"$ref":"#/components/schemas/WebrcadeFeedCategorySchema"},"type":"array","title":"Categories"}},"type":"object","required":["title","categories"],"title":"WebrcadeFeedSchema"}},"securitySchemes":{"OAuth2PasswordBearer":{"type":"oauth2","flows":{"password":{"scopes":{"me.read":"View your profile","roms.read":"View ROMs","platforms.read":"View platforms","assets.read":"View assets","devices.read":"View devices","firmware.read":"View firmware","roms.user.read":"View user-rom properties","collections.read":"View collections","me.write":"Modify your profile","assets.write":"Modify assets","devices.write":"Modify devices","roms.user.write":"Modify user-rom properties","collections.write":"Modify collections","roms.write":"Modify ROMs","platforms.write":"Modify platforms","firmware.write":"Modify firmware","users.read":"View users","users.write":"Modify users","tasks.run":"Run tasks"},"tokenUrl":"/token"}}},"HTTPBasic":{"type":"http","scheme":"basic"}}}} \ No newline at end of file diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index ac4e780..b7b4050 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -22,6 +22,7 @@ import { appPath, getErrorMessage } from "../utils"; import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; import { ensureDir } from "fs-extra"; import UpdateStoreJob from "./jobs/update-store"; +import { getStoreFolder } from "./store/services/gamesService"; export const config = new Conf({ projectName: projectPackage.name, @@ -47,6 +48,8 @@ export const customEmulators = new Conf>({ console.log("Config Path Located At: ", config.path); console.log("Custom Emulator Paths Located At: ", customEmulators.path); console.log("App Directory is ", process.env.APPDIR); +console.log("Store Directory is ", getStoreFolder()); + const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json')); console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath); export const jar = new CookieJar(fileCookieStore); diff --git a/src/bun/api/cache.ts b/src/bun/api/cache.ts index c8c4a8f..941ba7a 100644 --- a/src/bun/api/cache.ts +++ b/src/bun/api/cache.ts @@ -1,6 +1,7 @@ import { eq } from "drizzle-orm"; import { cache } from "./app"; import cacheSchema from "@schema/cache"; +import { GithubReleaseSchema } from "@/shared/constants"; export const CACHE_KEYS = { ROM_PLATFORMS: 'rom-platforms', @@ -31,4 +32,14 @@ export async function getOrCached (key: string, getter: () => Promise, opt .run(); return data; +} + +export async function getOrCachedGithubRelease (path: string) +{ + return getOrCached(`github-release-${path}`, async () => + { + const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, { method: "GET" }); + if (!response.ok) throw new Error(response.statusText); + return GithubReleaseSchema.parseAsync(await response.json()); + }); } \ No newline at end of file diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 152207b..7f67457 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -1,22 +1,27 @@ import Elysia, { status } from "elysia"; -import { activeGame, config, db, events, taskQueue } from "../app"; -import { and, eq, getTableColumns, sql } from "drizzle-orm"; -import z from "zod"; +import { activeGame, config, db, emulatorsDb, events, taskQueue } from "../app"; +import { and, eq, getTableColumns, inArray, not, or, sql } from "drizzle-orm"; +import z, { number } from "zod"; import * as schema from "@schema/app"; import fs from "node:fs/promises"; -import { FrontEndGameType, FrontEndGameTypeDetailed, GameListFilterSchema } from "@shared/constants"; -import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm"; +import { FrontEndEmulator, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedEmulator, GameListFilterSchema, SERVER_URL } from "@shared/constants"; +import { getCurrentUserApiUsersMeGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm"; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; -import { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameMatch } from "./services/utils"; +import { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameDetailed, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService"; import { errorToResponse } from "elysia/adapter/bun/handler"; -import { launchCommand } from "./services/launchGameService"; -import { getErrorMessage } from "@/bun/utils"; +import { getEmulatorsForSystem, launchCommand } from "./services/launchGameService"; +import { getErrorMessage, SeededRandom, shuffleInPlace } from "@/bun/utils"; import { defaultFormats, defaultPlugins } from 'jimp'; import { createJimp } from "@jimp/core"; import webp from "@jimp/wasm-webp"; -import { extractStoreGameSourceId, getStoreGame, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService"; +import * as emulatorSchema from '@schema/emulators'; +import { buildStoreFrontendEmulatorSystems, extractStoreGameSourceId, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGame, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService"; +import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService"; +import { use } from "react"; +import { CACHE_KEYS, getOrCached } from "../cache"; +import { host } from "@/bun/utils/host"; // A custom jimp that supports webp const Jimp = createJimp({ @@ -123,22 +128,52 @@ export default new Elysia() }) .get('/games', async ({ query, set }) => { - const where: any[] = []; - if (query.platform_slug) - { - where.push(eq(schema.platforms.slug, query.platform_slug)); - } - - if (query.source) - { - where.push(eq(schema.games.source, query.source)); - } - const games: FrontEndGameType[] = []; - let localGamesSet: Set | undefined; - if (!query.collection_id) + if (query.source === 'store') { + const shuffledGames = await getShuffledStoreGames(); + set.headers['x-max-items'] = shuffledGames.length; + const storeGames = await Promise.all(shuffledGames + .slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length)) + .map(async (e) => + { + const system = path.dirname(e.path); + const id = path.basename(e.path, path.extname(e.path)); + + const localGame = await db.select({ + ...getTableColumns(schema.games), + platform: schema.platforms, + screenshotIds: sql`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]), + }) + .from(schema.games) + .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) + .leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)) + .groupBy(schema.games.id) + .where(and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`))); + + if (localGame.length > 0) return convertLocalToFrontend(localGame[0]); + + const storeGame = await getStoreGameFromPath(e.path); + + return convertStoreToFrontend(system, id, storeGame); + })); + games.push(...storeGames.filter(g => g !== undefined)); + } else + { + const where: any[] = []; + let localGamesSet: Set | undefined; + + if (query.platform_slug) + { + where.push(eq(schema.platforms.slug, query.platform_slug)); + } + + if (query.source) + { + where.push(eq(schema.games.source, query.source)); + } + const localGames = await db.select({ ...getTableColumns(schema.games), platform: schema.platforms, @@ -153,52 +188,30 @@ export default new Elysia() .where(and(...where)); localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`)); - games.push(...localGames.map(g => + + if (!query.collection_id) { - return convertLocalToFrontend(g); - })); - } - - if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm')) - { - const rommGames = await getRomsApiRomsGet({ - query: { - platform_ids: query.platform_id ? [query.platform_id] : undefined, - collection_id: query.collection_id, - limit: query.limit, - offset: query.offset - }, throwOnError: true - }); - games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(`romm@${g.id}`)).map(g => - { - return convertRomToFrontend(g); - })); - } - - if (query.source === 'store') - { - const gamesManifest = await getStoreGameManifest(); - set.headers['x-max-items'] = gamesManifest.filter(g => g.type === 'blob').length; - - const storeGames = await Promise.all(gamesManifest - .slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), gamesManifest.length)) - .map(async (e) => + games.push(...localGames.map(g => { - const system = path.dirname(e.path); - const id = path.basename(e.path, path.extname(e.path)); - - const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) }); - - if (localGame) - { - return undefined; - } - - const storeGame = await getStoreGameFromPath(e.path); - - return convertStoreToFrontend(system, id, storeGame); + return convertLocalToFrontend(g); })); - games.push(...storeGames.filter(g => g !== undefined)); + } + + if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm')) + { + const rommGames = await getRomsApiRomsGet({ + query: { + platform_ids: query.platform_id ? [query.platform_id] : undefined, + collection_id: query.collection_id, + limit: query.limit, + offset: query.offset + }, throwOnError: true + }); + games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(`romm@${g.id}`)).map(g => + { + return convertRomToFrontend(g); + })); + } } return { games }; @@ -231,92 +244,59 @@ export default new Elysia() }) .get('/game/:source/:id', async ({ params: { source, id } }) => { - async function getLocalGameDetailed (match: any) + const sourceData = await getSourceGameDetailed(source, id); + + if (sourceData) { - const localGame = await db.query.games.findFirst({ - where: match, - with: { - screenshots: { columns: { id: true } }, - platform: { columns: { name: true, slug: true } } - } - }); - if (localGame) + if (sourceData.platform_slug) { - const exists = await checkInstalled(localGame.path_fs); - const fileSize = await calculateSize(localGame.path_fs); - const game: FrontEndGameTypeDetailed = { - path_cover: `/api/romm/game/local/${localGame.id}/cover`, - updated_at: localGame.created_at, - id: { id: String(localGame.id), source: 'local' }, - path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`, - fs_size_bytes: fileSize ?? null, - paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`), - local: true, - missing: !exists, - platform_display_name: localGame.platform?.name, - summary: localGame.summary, - source: localGame.source, - source_id: localGame.source_id, - path_fs: localGame.path_fs, - last_played: localGame.last_played, - slug: localGame.slug, - name: localGame.name, - platform_id: localGame.platform_id, - platform_slug: localGame.platform.slug - }; - return game; - } - - return undefined; - } - - if (source === 'local') - { - const localGame = await getLocalGameDetailed(eq(schema.games.id, Number(id))); - if (localGame) return localGame; - return status('Not Found'); - } - else - { - const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source)); - if (localGame) return localGame; - - if (source === 'romm') - { - const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } }); - if (rom.data) + const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) }); + if (systemMapping) { - const romGame = convertRomToFrontendDetailed(rom.data); - return romGame; + const emulatorNames = await getEmulatorsForSystem(systemMapping.system); + const emulators = await Promise.all(emulatorNames.map(n => getStoreEmulatorPackage(n).then(e => ({ name: n, data: e })))); + + sourceData.emulators = await Promise.all(emulators.map(async ({ name, data }) => + { + if (data) + { + const systems = await buildStoreFrontendEmulatorSystems(data); + return { ...await convertStoreEmulatorToFrontend(data, 0, systems), store_exists: true }; + } + else if (name === 'EMULATORJS') + { + return { + name: 'EMULATORJS', + validSource: { binPath: SERVER_URL(host), type: 'js', exists: true }, + logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, + systems: [], + gameCount: 0 + } satisfies FrontEndGameTypeDetailedEmulator; + } + else + { + return { + name: name, + logo: "", + systems: [], + gameCount: 0 + } satisfies FrontEndGameTypeDetailedEmulator; + } + + })); } - - return status("Not Found", rom.response); - } - else if (source === 'store') - { - const gameId = extractStoreGameSourceId(id); - const storeGame = await getStoreGame(gameId.system, gameId.id); - if (!storeGame) return status("Not Found"); - return convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame); } + return sourceData; + } else + { return status("Not Found"); } }, { params: z.object({ source: z.string(), id: z.string() }) }) - .get('/status/:source/:id', async ({ params: { source, id }, set }) => - { - set.headers["content-type"] = 'text/event-stream'; - set.headers["cache-control"] = 'no-cache'; - set.headers['connection'] = 'keep-alive'; - return buildStatusResponse(source, id); - }, { - response: z.any(), - params: z.object({ id: z.string(), source: z.string() }), - query: z.object({ isLocal: z.boolean().optional() }) - }) + .use(buildStatusResponse()) .delete('/game/:source/:id', async ({ params: { source, id } }) => { const deleted = await db.delete(schema.games).where(getLocalGameMatch(id, source)).returning({ path_fs: schema.games.path_fs }); @@ -332,11 +312,11 @@ export default new Elysia() }) .post('/game/:source/:id/install', async ({ params: { id, source } }) => { - if (!taskQueue.hasActive()) + if (!taskQueue.findJob(`install-rom-${source}-${id}`, InstallJob)) { if (source === 'romm' || source === 'store') { - taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id)); + taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id, { dryRun: true })); return status(200); } @@ -349,7 +329,20 @@ export default new Elysia() params: z.object({ id: z.string(), source: z.string() }), response: z.any() }) - .post('/game/:source/:id/play', async ({ params: { id, source }, query, set }) => + .delete('/game/:source/:id/install', async ({ params: { id, source } }) => + { + const job = taskQueue.findJob(`install-rom-${source}-${id}`, InstallJob); + if (job) + { + job.abort('cancel'); + return status('OK'); + } + return status('Not Found'); + }, { + params: z.object({ id: z.string(), source: z.string() }), + response: z.any() + }) + .post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) => { const validCommands = await getValidLaunchCommandsForGame(source, id); if (validCommands) @@ -362,11 +355,11 @@ export default new Elysia() { try { - const validCommand = query.command_id ? validCommands.commands.find(c => c.id === query.command_id) : validCommands.commands[0]; + const validCommand = body.command_id ? validCommands.commands.find(c => c.id === body.command_id) : validCommands.commands[0]; if (validCommand) { // launch command waits for the game to exit, we don't want that. - launchCommand(validCommand.command, source, id, validCommands.gameId); + launchCommand(validCommand, source, id, validCommands.gameId); return { type: 'application', command: null }; } else { @@ -382,7 +375,7 @@ export default new Elysia() } }, { params: z.object({ id: z.string(), source: z.string() }), - query: z.object({ command_id: z.number().or(z.string()).optional() }), + body: z.object({ command_id: z.number().or(z.string()).optional() }), response: z.object({ type: z.enum(['emulatorjs', 'application']), command: z.string().nullable() }) }) .post("/stop", async ({ }) => @@ -404,4 +397,190 @@ export default new Elysia() .get('/emulatorjs/data/*', async () => { return status("Not Found"); + }) + .get('/recommended/games/emulator/:id', async ({ params: { id } }) => + { + const emulator = await getStoreEmulatorPackage(id); + if (!emulator) return status("Not Found"); + const systems = await buildStoreFrontendEmulatorSystems(emulator); + const systemsIdSet = new Set(systems.map(s => s.id)); + const systemsRommSlugSet = new Set(systems.filter(s => s.romm_slug).map(s => s.romm_slug!)); + + const games: FrontEndGameType[] = []; + + let localGamesSet: Set | undefined; + + const localGames = await db.select({ + ...getTableColumns(schema.games), + platform: schema.platforms, + screenshotIds: sql`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]), + }) + .from(schema.games) + .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) + .leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)) + .groupBy(schema.games.id) + .where(inArray(schema.platforms.slug, systems.map(s => s.id))); + + localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`)); + games.push(...localGames.map(g => + { + return convertLocalToFrontend(g); + }).slice(0, 3)); + + const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e)); + + if (rommPlatforms) + { + const platformIds = rommPlatforms.filter(p => systemsRommSlugSet.has(p.slug)).map(s => s.id); + if (platformIds.length > 0) + { + const rommGames = await getRomsApiRomsGet({ + query: { + platform_ids: platformIds + } + }); + + let gamesPerSystem = Math.round(3 / systemsRommSlugSet.size); + + for (const slug of systemsRommSlugSet) + { + const systemRommGames = rommGames.data?.items.filter(g => !localGamesSet?.has(`romm@${g.id}`) && slug === g.platform_slug).map(g => + { + return convertRomToFrontend(g); + }).slice(0, gamesPerSystem) ?? []; + games.push(...systemRommGames); + } + } + } + + const gamesManifest = await getStoreGameManifest(); + const storeGames = await Promise.all(gamesManifest + .filter(g => systemsIdSet.has(path.dirname(g.path))) + .map(async (e) => + { + const system = path.dirname(e.path); + const id = path.basename(e.path, path.extname(e.path)); + + const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) }); + + if (localGame) + { + return undefined; + } + + const storeGame = await getStoreGameFromPath(e.path); + + return convertStoreToFrontend(system, id, storeGame); + })); + + games.push(...storeGames.filter(g => g !== undefined).slice(0, 3)); + + return games; + }) + .get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) => + { + const sourceData = await getSourceGameDetailed(source, id); + if (!sourceData) return status("Not Found"); + + const sourceCompaniesSet = new Set(sourceData.companies); + const sourceGenresSet = new Set(sourceData.genres); + + const esSystem = sourceData.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug)), columns: { system: true } }) : undefined; + + const games: (FrontEndGameType & { metadata?: any; })[] = []; + + const localGames = await db.select({ ...getTableColumns(schema.games), platform: schema.platforms }) + .from(schema.games) + .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) + .groupBy(schema.games.id); + + const localGamesSourceSet = new Set(localGames.filter(g => g.source).map(g => `${g.source}@${g.source_id}`)); + + games.push(...localGames.map(g => ({ ...convertLocalToFrontend(g), metadata: g.metadata }))); + + const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e)); + if (rommPlatforms) + { + const rommPlatform = rommPlatforms.find(p => p.slug === sourceData.platform_slug); + if (rommPlatform) + { + const rommGames = await getRomsApiRomsGet({ query: { genres: sourceData.genres, genres_logic: 'any' } }); + if (rommGames.data) + { + games.push(...rommGames.data.items.filter(g => !localGamesSourceSet.has(`romm@${g.id}`)).map(g => ({ ...convertRomToFrontend(g), metadata: g.metadatum }))); + } + } + } + + const shuffledGames = await getShuffledStoreGames(); + const storeGames = await Promise.all(shuffledGames + .filter(g => + { + const system = path.dirname(g.path); + const id = path.basename(g.path, path.extname(g.path)); + + if (localGamesSourceSet.has(`${system}@${id}`)) + return false; + + if (esSystem) + { + if (path.dirname(g.path) === esSystem.system) return true; + } + + return false; + }) + .map(async (e) => + { + const system = path.dirname(e.path); + const id = path.basename(e.path, path.extname(e.path)); + const storeGame = await getStoreGameFromPath(e.path); + return convertStoreToFrontend(system, id, storeGame); + })); + + if (storeGames) + { + games.push(...storeGames.slice(0, 3)); + } + + const random = new SeededRandom(Math.round(new Date().getTime() / 1000 / 60 / 60)); + + const rankedGames = games.filter(g => + { + if (sourceData.source && g.id.id === sourceData.source_id && g.id.source === sourceData.source) + { + return false; + } + + if (g.id.id === sourceData.id.id && g.id.source === sourceData.id.source) + { + return false; + } + + return true; + }).map(g => + { + let rank = random.next(); + + if (g.platform_slug === sourceData.platform_slug) + rank += 1; + + if (g.metadata) + { + if (g.metadata.companies instanceof Array && g.metadata.companies.some((c: string) => sourceCompaniesSet.has(c))) + { + rank += 1; + } + + if (g.metadata.genres instanceof Array && g.metadata.genres.some((g: string) => sourceGenresSet.has(g))) + { + rank += 1; + } + } + + return { rank: rank, game: g }; + }); + + rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank); + + return rankedGames.map(g => g.game).slice(0, 10); }); \ No newline at end of file diff --git a/src/bun/api/games/platforms.ts b/src/bun/api/games/platforms.ts index 73e0347..ee92a3f 100644 --- a/src/bun/api/games/platforms.ts +++ b/src/bun/api/games/platforms.ts @@ -1,7 +1,7 @@ import Elysia, { status } from "elysia"; import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm"; import z from "zod"; -import { count, eq, getTableColumns } from "drizzle-orm"; +import { and, count, eq, getTableColumns, not } from "drizzle-orm"; import { db } from "../app"; import { FrontEndPlatformType } from "@shared/constants"; import * as schema from "@schema/app"; @@ -25,17 +25,35 @@ export default new Elysia() { const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p => { - const game = await getRomsApiRomsGet({ query: { platform_ids: [p.id] } }); + const screenshots: string[] = []; + const rommGames = await getRomsApiRomsGet({ query: { platform_ids: [p.id], limit: 3 } }).then(d => d.data); + if (rommGames) + { + const rommScreenshots = rommGames.items.find(i => i.merged_screenshots.length > 0)?.merged_screenshots.map(s => `/api/romm/image/romm/${s}`); + if (rommScreenshots) + screenshots.push(...rommScreenshots); + } + + if (screenshots.length <= 0) + { + const localScreenshots = await db.select({ id: schema.screenshots.id }).from(schema.games).leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)).where(eq(schema.platforms.slug, p.slug)).leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)).limit(1); + + if (localScreenshots) + screenshots.push(...localScreenshots.map(s => `/api/romm/screenshot/${s.id}`)); + } + + const localGames = await db.select({ id: schema.games.id, source: schema.games.source, souceId: schema.games.source_id }).from(schema.games).leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)).where(and(eq(schema.platforms.slug, p.slug), not(eq(schema.games.source, 'romm')))).groupBy(schema.games.id); + const platform: FrontEndPlatformType = { slug: p.slug, name: p.display_name, family_name: p.family_name, path_cover: `/api/romm/image/romm/assets/platforms/${p.slug}.svg`, - game_count: p.rom_count, + game_count: p.rom_count + localGames.length, updated_at: new Date(p.updated_at), id: { source: 'romm', id: String(p.id) }, hasLocal: localPlatformSet.has(p.slug), - paths_screenshots: game.data?.items[0]?.merged_screenshots.map(s => `/api/romm/image/romm/${s}`) ?? [] + paths_screenshots: screenshots }; return platform; diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index d554dc3..2b7276d 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -5,16 +5,18 @@ import { existsSync, readFileSync } from 'node:fs'; import * as schema from '@schema/emulators'; import * as appSchema from "@schema/app"; import { eq } from 'drizzle-orm'; -import { activeGame, config, db, emulatorsDb, events, setActiveGame } from '../../app'; +import { activeGame, config, customEmulators, db, emulatorsDb, events, setActiveGame } from '../../app'; import os from 'node:os'; import { $ } from 'bun'; import { spawn } from 'node:child_process'; import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm'; -import { CommandEntry } from '@/shared/constants'; +import { CommandEntry, EmulatorSourceType } from '@/shared/constants'; +import { cores } from '../../emulatorjs/emulatorjs'; export const varRegex = /%([^%]+)%/g; +export const assignRegex = /(%\w+%)=(\S+) /g; -export async function launchCommand (validCommand: string, source: string, sourceId: string, id: number) +export async function launchCommand (validCommand: { command: string, startDir?: string; }, source: string, sourceId: string, id: number) { if (activeGame && activeGame.process?.killed === false) { @@ -31,8 +33,9 @@ export async function launchCommand (validCommand: string, source: string, sourc await new Promise((resolve, reject) => { - const game = spawn(validCommand, { - shell: true + const game = spawn(validCommand.command, { + shell: true, + cwd: validCommand.startDir }); game.stdout.on('data', data => console.log(data)); game.on('close', (code) => @@ -99,6 +102,54 @@ export async function launchCommand (validCommand: string, source: string, sourc }*/ } +/** + * Get the emulators related to the given system + * @param systemSlug the ES-DE slug for the system + */ +export async function getEmulatorsForSystem (systemSlug: string) +{ + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(schema.systems.name, systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${systemSlug}'`); + } + + const emulators = new Set(); + await Promise.all(system.commands.map(async (command, index) => + { + let cmd = command.command; + + const matches = Array.from(cmd.matchAll(varRegex)); + matches.forEach(([value]) => + { + if (value.startsWith("%EMULATOR_")) + { + const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); + emulators.add(emulatorName); + return; + } + }); + })); + + + + if (cores[systemSlug]) + { + emulators.add('EMULATORJS'); + } + + return Array.from(emulators); +} + +/** + * + * @param data Uses es-de system slug + * @returns + */ export async function getValidLaunchCommands (data: { systemSlug: string; gamePath: string; @@ -160,101 +211,151 @@ export async function getValidLaunchCommands (data: { } } - const formattedCommands = await Promise.all(system.commands.map(async (command, index) => + function escapeWindowsArg (arg: string): string { - const label = command.label; - let cmd = command.command; + return `"${arg + .replace(/(\\*)"/g, '$1$1\\"') // escape quotes + .replace(/(\\*)$/, '$1$1') // escape trailing backslashes + }"`; + } - let emulator: string | undefined = undefined; - let rom = validFiles[0]; - - if (cmd.includes('%ESCAPESPECIALS%')) - rom = rom.replace(/[&()^=;,]/g, ''); - - const staticVars: Record = { - '%ROM%': $.escape(rom), - '%ROMRAW%': validFiles[0], - '%ROMRAWWIN%': $.escape(validFiles[0].replace('/', '\\')), - '%ESPATH%': $.escape(path.dirname(Bun.main)), - '%ROMPATH%': $.escape(gamePath), - '%BASENAME%': $.escape(path.basename(validFiles[0], path.extname(validFiles[0]))), - '%FILENAME%': $.escape(path.basename(validFiles[0])) - }; - - cmd = cmd.replace(/\%INJECT\%=(?[\w\%.\/\\]+)/g, (_, injectFile: string) => + const formattedCommands = await Promise.all(system.commands + .filter(c => !c.command.includes(`%ENABLESHORTCUTS%`)) + .map(async (command, index) => { - try + const label = command.label; + let cmd = command.command; + + let emulator: string | undefined = undefined; + let rom = validFiles[0]; + + if (cmd.includes('%ESCAPESPECIALS%')) + rom = rom.replace(/[&()^=;,]/g, ''); + + + + const staticVars: Record = { + '%ROM%': escapeWindowsArg(rom), + '%ROMRAW%': validFiles[0], + '%ROMRAWWIN%': escapeWindowsArg(validFiles[0].replaceAll('/', '\\')), + '%ESPATH%': escapeWindowsArg(path.dirname(Bun.main)), + '%ROMPATH%': escapeWindowsArg(gamePath), + '%BASENAME%': escapeWindowsArg(path.basename(validFiles[0], path.extname(validFiles[0]))), + '%FILENAME%': escapeWindowsArg(path.basename(validFiles[0])), + '%ESCAPESPECIALS%': "", + '%HIDEWINDOW%': "" + }; + + cmd = cmd.replace(/\%INJECT\%=(?[\w\%.\/\\]+)/g, (_, injectFile: string) => { - const resolvedInjectFile = injectFile.replace(varRegex, (a) => + try { - return staticVars[a] ?? a; + const resolvedInjectFile = injectFile.replace(varRegex, (a) => + { + return staticVars[a] ?? a; + }); + if (existsSync(resolvedInjectFile)) + { + const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' }); + return rawContents.split('\n').map(v => v.replace('\r', '')).join(' '); + } + + return ''; + } catch (error) + { + return ''; + } + }); + + const matches = Array.from(cmd.matchAll(varRegex)); + const varList = await Promise.all(matches.map(async ([value]) => + { + if (value.startsWith("%EMULATOR_")) + { + const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); + let execs = await findExecsByName(emulatorName); + let validExec = execs.find(e => e.exists); + + emulator = emulatorName; + return [[value, validExec ? validExec.path : undefined], ['%EMUDIR%', validExec ? escapeWindowsArg(path.dirname(validExec.path)) : undefined]]; + } + + const key = value[0].substring(1, value.length - 1); + return [[value, process.env[key]]]; + })); + + const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars }; + let startDir: string | undefined = undefined; + + if ('%STARTDIR%' in vars) + { + delete vars['%STARTDIR%']; + + cmd = cmd.replace(assignRegex, (match, p1, p2) => + { + if (p1 === '%STARTDIR%') + { + startDir = varRegex.test(p2) ? staticVars[p2] : p2; + } + return ""; }); - if (existsSync(resolvedInjectFile)) - { - const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' }); - return rawContents.split('\n').map(v => v.replace('\r', '')).join(' '); - } - - return ''; - } catch (error) - { - return ''; - } - }); - - const matches = Array.from(cmd.matchAll(varRegex)); - const varList = await Promise.all(matches.map(async ([value]) => - { - if (value.startsWith("%EMULATOR_")) - { - const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); - let exec = await findExecByName(emulatorName); - if (data.customEmulatorConfig.has(emulatorName)) - { - exec = { path: data.customEmulatorConfig.get(emulatorName)!, type: 'custom' }; - } - - emulator = emulatorName; - return [[value, exec ? exec.path : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec.path)) : undefined]]; } - const key = value[0].substring(1, value.length - 1); - return [[value, process.env[key]]]; + // missing variable + const invalid = Object.entries(vars).find(c => c[1] === undefined); + + const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim(); + + return { + id: index, + label: label ?? undefined, + command: formattedCommand, + startDir, + valid: !invalid, emulator + } satisfies CommandEntry; })); - const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars }; - vars['%ESCAPESPECIALS%'] = ""; - vars['%HIDEWINDOW%'] = ''; - - // missing variable - const invalid = Object.entries(vars).find(c => c[1] === undefined); - - const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim(); - - return { - id: index, - label: label ?? undefined, - command: formattedCommand, - valid: !invalid, emulator - } satisfies CommandEntry; - })); - return formattedCommands.filter(c => !!c); } -export async function findExecByName (emulatorName: string) +export async function findExecsByName (emulatorName: string) { const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) }); if (!emulator) { throw new Error(`Could not find emulator ${emulatorName}`); } - return findExec(emulator); + return findExecs(emulatorName, emulator); } -export async function findExec (emulator: { winregistrypath: string[], systempath: string[], staticpath: string[]; }) +export function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): EmulatorSourceType | undefined { - if (os.platform() === 'win32') + const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id); + const storeExecName = emulator?.systempath.find(name => existsSync(path.join(storeEmulatorFolder, name))); + if (storeExecName) + { + return { binPath: path.join(storeEmulatorFolder, storeExecName), rootPath: storeEmulatorFolder, exists: true, type: "store" }; + } + + return undefined; +} + +export async function findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; }) +{ + const execs: EmulatorSourceType[] = []; + + if (customEmulators.has(id)) + { + execs.push({ binPath: customEmulators.get(id), type: 'custom', exists: await fs.exists(customEmulators.get(id)) }); + } + + if (emulator && emulator.systempath.length > 0) + { + const storePath = findStoreEmulatorExec(id, emulator); + if (storePath) execs.push(storePath); + } + + if (emulator && os.platform() === 'win32') { const regValues = emulator.winregistrypath; if (regValues.length > 0) @@ -264,32 +365,32 @@ export async function findExec (emulator: { winregistrypath: string[], systempat const registryValue = await readRegistryValue(node); if (registryValue) { - return { path: registryValue, type: 'registry' }; + execs.push({ binPath: registryValue, type: 'registry', exists: true }); } } } } - const systempaths = emulator.systempath; - if (systempaths.length > 0) + if (emulator && emulator.systempath.length > 0) { - const systemPath = await resolveSystemPath(systempaths); + const systemPath = await resolveSystemPath(emulator.systempath); if (systemPath) { - return { path: systemPath, type: 'system' }; + execs.push({ binPath: systemPath, type: 'system', exists: true }); } } - const staticPaths = emulator.staticpath; - if (staticPaths.length > 0) + if (emulator && emulator.staticpath.length > 0) { - const staticPath = await resolveStaticPath(staticPaths); + const staticPath = await resolveStaticPath(emulator.staticpath); if (staticPath) { - return { path: staticPath, type: 'static' }; + execs.push({ binPath: staticPath, type: 'static', exists: true }); } } + + return execs; } async function readRegistryValue (text: string) diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 2acde4c..8b56b51 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -11,6 +11,10 @@ import { ErrorLike } from "elysia/universal"; import { getStoreGameFromId } from "../../store/services/gamesService"; import { cores } from "../../emulatorjs/emulatorjs"; import { host } from "@/bun/utils/host"; +import Elysia from "elysia"; +import z from "zod"; +import data from "@emulators"; +import { InstallJob, InstallJobStates } from "../../jobs/install-job"; class CommandSearchError extends Error { @@ -54,8 +58,11 @@ export async function getValidLaunchCommandsForGame (source: string, id: string) { const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`; commands.push({ - id: 'emulatorjs', - label: "Emulator JS", command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`, valid: true, emulator: 'emulatorjs' + id: 'EMULATORJS', + label: "Emulator JS", + command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`, + valid: true, + emulator: 'EMULATORJS' }); } @@ -89,97 +96,93 @@ export async function getValidLaunchCommandsForGame (source: string, id: string) return undefined; } -export default async function buildStatusResponse (source: string, id: string) +export default function buildStatusResponse () { - let cleanup: (() => void) | undefined; - let closed = false; - return new Response(new ReadableStream({ - async start (controller) + return new Elysia().ws('/status/:source/:id', { + response: z.discriminatedUnion('status', [ + z.object({ status: z.literal('error'), error: z.unknown() }), + z.object({ status: z.literal('installed'), commands: z.array(z.any()), details: z.string().optional() }), + z.object({ status: z.literal(['refresh', 'queued']) }), + z.object({ status: z.literal('playing'), details: z.string() }), + z.object({ status: z.literal('install'), details: z.string() }), + z.object({ status: z.literal(['download', 'extract']), progress: z.number() }), + ]), + message (ws, data) { - const encoder = new TextEncoder(); - - function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh' | 'ping') + if (data === 'cancel') { - if (closed) return; - const evntString = event ? `event: ${event}\n` : ''; - controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`)); + const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob); + activeTask?.abort('cancel'); } - - await sendLatests(); - - // seems to help with issue of buffers not flushing, keeping the connection open forcefully - const keepAlive = setInterval(() => - { - if (closed) return clearInterval(keepAlive); - try - { - enqueue({}, 'ping'); - } catch - { - closed = true; - clearInterval(keepAlive); - } - }, 15000); - - const sourceId = `${source}-${id}`; + }, + async open (ws) + { + sendLatests(); async function sendLatests () { - if (closed) return; - const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), columns: { id: true } }); - const activeTask = taskQueue.findJob(`install-rom-${source}-${id}`); + if (ws.readyState > 1) return; + const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source), columns: { id: true } }); + const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob); if (activeTask) { - enqueue({ - progress: activeTask.progress, - status: activeTask.state as any - }); + if (activeTask.status === 'queued') + { + ws.send({ status: 'queued' }); + } else + { + ws.send({ status: activeTask.state as InstallJobStates, progress: activeTask.progress }); + } } else if (activeGame && activeGame.gameId === localGame?.id) { - enqueue({ status: 'playing' as GameStatusType, details: 'Playing' }); + ws.send({ status: 'playing', details: 'Playing' }); } else { - const validCommand = await getValidLaunchCommandsForGame(source, id); + const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id); if (validCommand) { if (validCommand instanceof Error) { - enqueue({ status: validCommand.name as GameStatusType, error: validCommand.message }); + ws.send({ status: 'error', error: validCommand.message }); } else { - enqueue({ status: 'installed', details: validCommand.commands[0].label, commands: validCommand.commands }); + ws.send({ + status: 'installed', + details: validCommand.commands[0].label, + commands: validCommand.commands + }); } } - else if (source === 'romm') + else if (ws.data.params.source === 'romm') { // TODO: Add Caching - const remoteGame = await getRomApiRomsIdGet({ path: { id: Number(id) } }); + const remoteGame = await getRomApiRomsIdGet({ path: { id: Number(ws.data.params.id) } }); const stats = await fs.statfs(config.get('downloadPath')); if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail) { - enqueue({ status: 'error', error: "Not Enough Free Space" }); + ws.send({ status: 'error', error: "Not Enough Free Space" }); } else { - enqueue({ status: 'install', details: 'Install' }); + ws.send({ status: 'install', details: 'Install' }); } - } else if (source === 'store') + } else if (ws.data.params.source === 'store') { - const storeGame = await getStoreGameFromId(id); + const storeGame = await getStoreGameFromId(ws.data.params.id); const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); const size = Number(fileResponse.headers.get('content-length')); const stats = await fs.statfs(config.get('downloadPath')); if (size > stats.bsize * stats.bavail) { - enqueue({ status: 'error', error: "Not Enough Free Space" }); + ws.send({ status: 'error', error: "Not Enough Free Space" }); } else { - enqueue({ status: 'install', details: 'Install' }); + ws.send({ status: 'install', details: 'Install' }); } } } @@ -190,50 +193,56 @@ export default async function buildStatusResponse (source: string, id: string) { if (data.error) { - enqueue({ + ws.send({ status: 'error', error: data.error - }, 'error'); + }); } await sendLatests(); }; events.on('activegameexit', handleActiveExit); dispose.push(() => events.off('activegameexit', handleActiveExit)); - dispose.push(taskQueue.on('progress', ({ id, progress, state }) => + dispose.push(taskQueue.on('progress', (data) => { - if (id.endsWith(sourceId)) + if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`) { - enqueue({ progress, status: state as any }); + + ws.send({ status: data.job.state as InstallJobStates, progress: data.progress }); } })); - dispose.push(taskQueue.on('completed', ({ id }) => + dispose.push(taskQueue.on('queued', (data) => { - if (id.endsWith(sourceId)) + if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`) { - enqueue({}, 'refresh'); + ws.send({ status: 'queued' }); } })); - dispose.push(taskQueue.on('error', ({ id, error }) => + dispose.push(taskQueue.on('completed', (data) => { - if (id.endsWith(sourceId)) + if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`) { - enqueue({ + ws.send({ status: 'refresh' }); + } + })); + dispose.push(taskQueue.on('error', (data) => + { + if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`) + { + ws.send({ status: 'error', - error: getErrorMessage(error) - }, 'error'); + error: getErrorMessage(data.error) + }); } })); - cleanup = () => + (ws.data as any).cleanup = () => { - closed = true; dispose.forEach(f => f()); }; }, - cancel () + close (ws, code, reason) { - cleanup?.(); - cleanup = undefined; + (ws.data as any).cleanup?.(); }, - })); + }); } \ No newline at end of file diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index c2a1e8b..5cc3a96 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -1,12 +1,14 @@ import getFolderSize from "get-folder-size"; import fs from "node:fs/promises"; import path from "node:path"; -import { config, emulatorsDb } from "../../app"; +import { config, db, emulatorsDb } from "../../app"; import { and, eq } from "drizzle-orm"; import * as schema from "@schema/app"; -import { FrontEndGameType, FrontEndGameTypeDetailed, StoreGameType } from "@shared/constants"; -import { DetailedRomSchema, SimpleRomSchema } from "@clients/romm"; +import { FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, StoreGameType } from "@shared/constants"; +import { DetailedRomSchema, getCurrentUserApiUsersMeGet, getRomApiRomsIdGet, SimpleRomSchema } from "@clients/romm"; import * as emulatorSchema from "@schema/emulators"; +import romm from "@/mainview/scripts/queries/romm"; +import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService"; export async function calculateSize (installPath: string | null) { @@ -127,7 +129,7 @@ export async function convertStoreToFrontend (system: string, id: string, storeG slug: null, name: storeGame.title, platform_id: null, - platform_slug: system, + platform_slug: rommSystem?.sourceSlug ?? system, paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [] }; @@ -157,21 +159,138 @@ export async function convertStoreToFrontendDetailed (system: string, id: string return detailed; } -export function convertRomToFrontendDetailed (rom: DetailedRomSchema) +export async function convertRomToFrontendDetailed (rom: DetailedRomSchema) { const detailed: FrontEndGameTypeDetailed = { ...convertRomToFrontend(rom), summary: rom.summary, fs_size_bytes: rom.fs_size_bytes, local: false, - missing: rom.missing_from_fs + missing: rom.missing_from_fs, + genres: rom.metadatum.genres, + companies: rom.metadatum.companies, + release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined }; + + const userData = await getCurrentUserApiUsersMeGet(); + const gameAchievements = userData.data?.ra_progression?.results?.find(p => p.rom_ra_id == rom.ra_id); + if (rom.merged_ra_metadata?.achievements) { + const earnedMap = new Map(gameAchievements?.earned_achievements.map(a => [a.id, { date: new Date(a.date), date_hardcore: a.date_hardcore ? new Date(a.date_hardcore) : undefined }])); detailed.achievements = { - unlocked: rom.merged_ra_metadata.achievements?.map(a => a.num_awarded).length, + unlocked: gameAchievements?.num_awarded ?? 0, + entires: rom.merged_ra_metadata.achievements.map(a => + { + const earned = a.badge_id ? earnedMap.get(a.badge_id) : undefined; + const ach: FrontEndGameTypeDetailedAchievement = { + id: a.badge_id ?? String(a.ra_id) ?? 'unknown', + title: a.title ?? "Unknown", + badge_url: (earned ? a.badge_url : a.badge_url_lock) ?? undefined, + date: earned?.date, + date_hardcode: earned?.date_hardcode, + description: a.description ?? undefined, + display_order: a.display_order ?? 0, + type: a.type ?? undefined + }; + + return ach; + }).sort((a, b) => a.display_order - b.display_order), total: rom.merged_ra_metadata.achievements.length }; } return detailed; +} + +export async function getLocalGameDetailed (match: any) +{ + const localGame = await db.query.games.findFirst({ + where: match, + with: { + screenshots: { columns: { id: true } }, + platform: { columns: { name: true, slug: true } } + } + }); + + if (localGame) + { + const exists = await checkInstalled(localGame.path_fs); + const fileSize = await calculateSize(localGame.path_fs); + const game: FrontEndGameTypeDetailed = { + path_cover: `/api/romm/game/local/${localGame.id}/cover`, + updated_at: localGame.created_at, + id: { id: String(localGame.id), source: 'local' }, + path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`, + fs_size_bytes: fileSize ?? null, + paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`), + local: true, + missing: !exists, + platform_display_name: localGame.platform?.name, + summary: localGame.summary, + source: localGame.source, + source_id: localGame.source_id, + path_fs: localGame.path_fs, + last_played: localGame.last_played, + slug: localGame.slug, + name: localGame.name, + platform_id: localGame.platform_id, + platform_slug: localGame.platform.slug + }; + return game; + } + + return undefined; +} + +export async function getSourceGameDetailed (source: string, id: string) +{ + if (source === 'local') + { + const localGame = await getLocalGameDetailed(eq(schema.games.id, Number(id))); + if (localGame) return localGame; + return undefined; + } + else + { + const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source)); + if (source === 'romm') + { + const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } }); + if (rom.data) + { + const romGame = await convertRomToFrontendDetailed(rom.data); + if (localGame) + { + return { + ...romGame, + ...localGame, + }; + } + return romGame; + } + else if (localGame) + { + return localGame; + } + + return undefined; + } + else if (source === 'store') + { + const gameId = extractStoreGameSourceId(id); + const storeGame = await getStoreGame(gameId.system, gameId.id); + if (!storeGame) return undefined; + const storeFrontendGame = await convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame); + if (localGame) + { + return { ...storeFrontendGame, ...localGame }; + } + return storeFrontendGame; + } else if (localGame) + { + return localGame; + } + + return undefined; + } } \ No newline at end of file diff --git a/src/bun/api/jobs/emulator-download-job.ts b/src/bun/api/jobs/emulator-download-job.ts new file mode 100644 index 0000000..1e4a673 --- /dev/null +++ b/src/bun/api/jobs/emulator-download-job.ts @@ -0,0 +1,105 @@ +import { EmulatorPackageType } from "@/shared/constants"; +import { getStoreEmulatorPackage } from "../store/services/gamesService"; +import { IJob, JobContext } from "../task-queue"; +import z from "zod"; +import { Glob } from "bun"; +import { config } from "../app"; +import path from 'node:path'; +import { getOrCachedGithubRelease } from "../cache"; +import _7z from '7zip-min'; +import fs from "node:fs/promises"; +import { Downloader } from "@/bun/utils/downloader"; +import { move } from "fs-extra"; + +type EmulatorDownloadStates = "download" | "extract"; + +export class EmulatorDownloadJob implements IJob, EmulatorDownloadStates> +{ + static id = "download-emulator" as const; + static dataSchema = z.object({ emulator: z.string() }); + emulator: string; + downloadSource: string; + emulatorPackage?: EmulatorPackageType; + + constructor(emulator: string, downloadSource: string) + { + this.emulator = emulator; + this.downloadSource = downloadSource; + } + + async start (context: JobContext, EmulatorDownloadStates>) + { + this.emulatorPackage = await getStoreEmulatorPackage(this.emulator); + if (!this.emulatorPackage) throw new Error("Emulator not found"); + if (!this.emulatorPackage.downloads) throw new Error("Emulator has no downloads"); + + const validDownloads = this.emulatorPackage.downloads[`${process.platform}:${process.arch}`]; + if (!validDownloads) throw new Error(`Now downloads in ${this.emulatorPackage.name} for platform ${process.platform}:${process.arch}`); + + const validDownload = validDownloads.find(d => d.type === this.downloadSource); + if (!validDownload || !validDownload.path) throw new Error(`Download type ${this.downloadSource} not found`); + + console.log("Trying To Download from ", `https://api.github.com/repos/${validDownload.path}/releases/latest`); + const latestRelease = await getOrCachedGithubRelease(validDownload.path); + const glob = new Glob(validDownload.pattern); + const validAsset = latestRelease.assets.find(a => glob.match(a.name)); + if (!validAsset) throw new Error("Could Not Find Valid Asset"); + const downloadUrl = validAsset.browser_download_url; + const emulatorsFolder = path.join(config.get('downloadPath'), "emulators", this.emulator); + + const isArchive = validAsset.content_type === 'application/x-7z-compressed' || validAsset.name.endsWith('.7z') || validAsset.content_type === 'application/zip' || validAsset.name.endsWith('.zip'); + + const isAppImage = validAsset.name.endsWith(".AppImage"); + + if (!isArchive && !isAppImage) + { + throw new Error("Invalid Download Type"); + } + + const tmpFolder = path.join(config.get("downloadPath"), ".tmp"); + const downloader = new Downloader(this.emulator, + [{ url: new URL(downloadUrl), file_name: path.basename(downloadUrl), file_path: this.emulator }], + tmpFolder, + { + onProgress (stats) + { + context.setProgress(stats.progress, 'download'); + }, + }); + + const destinationPaths = await downloader.start(); + if (destinationPaths) + { + if (isArchive) + { + if (await downloader.start() && destinationPaths[0]) + { + let destinationPath = destinationPaths[0]; + await _7z.unpack(destinationPath, emulatorsFolder); + await fs.rm(destinationPath, { recursive: true }); + + // check if 1 root folder we need to get rid of + const contents = await fs.readdir(emulatorsFolder); + if (contents.length === 1) + { + const stat = await fs.stat(path.join(emulatorsFolder, contents[0])); + if (stat.isDirectory()) + { + console.log("Found 1 root folder, using that instead"); + const tmpEmulatorsFolder = `${emulatorsFolder} (1)`; + await move(path.join(emulatorsFolder, contents[0]), tmpEmulatorsFolder, { overwrite: true }); + await move(tmpEmulatorsFolder, emulatorsFolder, { overwrite: true }); + } + } + } + } + } + } + + exposeData () + { + return { emulator: this.emulator }; + } + +} + diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 710d0e4..b095e8f 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -6,12 +6,14 @@ import * as schema from "@schema/app"; import * as emulatorSchema from "@schema/emulators"; import path from 'node:path'; import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet, PlatformSchema } from "@clients/romm"; -import { config, db, emulatorsDb, jar } from "../app"; -import unzip from 'unzip-stream'; -import { Readable, Transform } from "node:stream"; +import { config, db, emulatorsDb, events, jar } from "../app"; import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService"; import * as igdb from 'ts-igdb-client'; import secrets from "../secrets"; +import { hashFile } from "@/bun/utils"; +import { Downloader } from "@/bun/utils/downloader"; +import { sleep } from "bun"; +import _7z from '7zip-min'; interface JobConfig { @@ -19,13 +21,16 @@ interface JobConfig dryDownload?: boolean; } -export class InstallJob implements IJob +export type InstallJobStates = 'download' | 'extract'; + +export class InstallJob implements IJob { public gameId: string; public source: string; public sourceId: string; public config?: JobConfig; static id = "install-job" as const; + public group = InstallJob.id; constructor(id: string, source: string, sourceId: string, config?: JobConfig) { @@ -35,162 +40,124 @@ export class InstallJob implements IJob this.source = source; } - public async start (cx: JobContext) + public async start (cx: JobContext) { cx.setProgress(0, 'download'); fs.mkdir(config.get('downloadPath'), { recursive: true }); + const downloadPath = config.get('downloadPath'); + + let files: { + url: URL, + file_path: string; + file_name: string; + size?: number; + }[] = []; + let cookie: string = ''; + let screenshotUrls: string[]; + let coverUrl: string; + let rommPlatform: PlatformSchema | undefined; + let slug: string | null; + let path_fs: string | undefined; + let summary: string | null; + let name: string | null; + let last_played: Date | null; + let igdb_id: number | null; + let ra_id: number | null; + let source_id: string; + let system_slug: string; + let extract_path: string; + let metadata: any | undefined; + + switch (this.source) + { + case 'romm': + + const rom = (await getRomApiRomsIdGet({ path: { id: Number(this.gameId) }, throwOnError: true })).data; + rommPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data; + + const rommAddress = config.get('rommAddress'); + coverUrl = `${rommAddress}${rom.path_cover_large}`; + screenshotUrls = rom.merged_screenshots.map(s => `${config.get('rommAddress')}${s}`); + last_played = rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null; + igdb_id = rom.igdb_id; + ra_id = rom.ra_id; + summary = rom.summary; + name = rom.name; + path_fs = path.join(rom.fs_path, rom.fs_name); + source_id = String(rom.id); + slug = rom.slug; + system_slug = rommPlatform.slug; + extract_path = ''; + metadata = rom.metadatum; + + const rommFiles = await Promise.all(rom.files.map(async f => + { + const localPath = path.join(config.get('downloadPath'), f.full_path); + if (f.md5_hash && await fs.exists(localPath)) + { + const existingHash = await hashFile(localPath, 'sha1'); + if (existingHash === f.md5_hash) + { + console.log("File Already Present: ", f.full_path); + return undefined; + } + + console.warn("File ", f.full_path, 'with hash', existingHash, 'has different hash than', f.sha1_hash); + } + + return { + url: new URL(`${config.get('rommAddress')}/api/romsfiles/${f.id}/content/${f.file_name}`), + file_name: f.file_name, + file_path: path.join(config.get('downloadPath'), f.file_path), + size: f.file_size_bytes + }; + })); + + files.push(...rommFiles.filter(f => f !== undefined)); + cookie = await jar.getCookieString(config.get('rommAddress') ?? ''); + break; + case 'store': + const game = await getStoreGameFromId(this.gameId); + const gameId = extractStoreGameSourceId(this.gameId); + coverUrl = game.pictures.titlescreens[0]; + screenshotUrls = game.pictures.screenshots; + files.push({ url: new URL(game.file), file_path: `roms/${game.system}`, file_name: path.basename(decodeURI(game.file)) }); + slug = this.gameId; + source_id = this.gameId; + name = game.title; + summary = game.description; + system_slug = gameId.system; + extract_path = path.join('roms', gameId.system); + + break; + default: + throw new Error("Unsupported source"); + } + if (this.config?.dryRun !== true) { - const downloadPath = config.get('downloadPath'); - - let downloadUrl: URL; - let cookie: string = ''; - let screenshotUrls: string[]; - let coverUrl: string; - let rommPlatform: PlatformSchema | undefined; - let slug: string | null; - let path_fs: string | undefined; - let summary: string | null; - let name: string | null; - let last_played: Date | null; - let igdb_id: number | null; - let ra_id: number | null; - let source_id: string; - let system_slug: string; - let extract_path: string; - - switch (this.source) - { - case 'romm': - - const rom = (await getRomApiRomsIdGet({ path: { id: Number(this.gameId) }, throwOnError: true })).data; - rommPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data; - - const rommAddress = config.get('rommAddress'); - coverUrl = `${rommAddress}${rom.path_cover_large}`; - screenshotUrls = rom.merged_screenshots.map(s => `${config.get('rommAddress')}${s}`); - last_played = rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null; - igdb_id = rom.igdb_id; - ra_id = rom.ra_id; - summary = rom.summary; - name = rom.name; - path_fs = path.join(rom.fs_path, rom.fs_name); - source_id = String(rom.id); - slug = rom.slug; - system_slug = rommPlatform.slug; - extract_path = ''; - - downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`); - downloadUrl.searchParams.set('rom_ids', String(this.gameId)); - cookie = await jar.getCookieString(config.get('rommAddress') ?? ''); - break; - case 'store': - const game = await getStoreGameFromId(this.gameId); - const gameId = extractStoreGameSourceId(this.gameId); - coverUrl = game.pictures.titlescreens[0]; - screenshotUrls = game.pictures.screenshots; - downloadUrl = new URL(game.file); - slug = this.gameId; - source_id = this.gameId; - name = game.title; - summary = game.description; - system_slug = gameId.system; - extract_path = 'roms', gameId.system; - - break; - default: - throw new Error("Unsupported source"); - } - if (this.config?.dryDownload !== true) { - /* - // download files for rom - const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`); - downloadUrl.searchParams.set('rom_ids', String(this.id)); - const downloader = new DownloaderHelper(downloadUrl.href, downloadPath, { - headers: { - cookie: await jar.getCookieString(config.get('rommAddress') ?? '') - }, - fileName: `${this.id}.zip`, - // Romm doesn't support resume download - override: true - }); - - cx.abortSignal.addEventListener('abort', downloader.stop); - - downloader.on('progress.throttled', e => - { - cx.setProgress(e.progress, 'download'); - }); - - downloader.on('error', (e) => - { - cx.abort(e); - }); - const finishPromise = new Promise(resolve => - { - downloader.on("end", ({ filePath }) => resolve(filePath)); - }); - - await downloader.start().catch(err => console.error(err)); - const zipFilePath = await finishPromise; - - cx.setProgress(0, 'extract'); - - const zip = new StreamZip.async({ file: zipFilePath }); - const totalCount = await zip.entriesCount; - let extractCount = 0; - zip.on('extract', async (entry, file) => - { - console.log(`Extracted ${entry.name} to ${file}`); - cx.setProgress(extractCount / totalCount * 100, 'extract'); - extractCount++; - }); - await zip.extract(null, downloadPath); - await zip.close(); - - await fs.rm(zipFilePath);*/ - - cx.setProgress(0, 'download'); - - const res = await fetch(downloadUrl, { - headers: { - cookie: cookie - }, - }); - - const totalBytes = Number(res.headers.get("content-length")) || 0; - let bytesReceived = 0; - - const progressStream = new Transform({ - transform (chunk, _, callback) + const downloader = new Downloader(`game-${this.source}-${this.gameId}`, + files, + config.get('downloadPath'), { - bytesReceived += chunk.length; - if (totalBytes > 0) + signal: cx.abortSignal, + onProgress (stats) { - const percent = (bytesReceived / totalBytes) * 100; - cx.setProgress(percent, 'download'); - } - this.push(chunk); - callback(); - } - }); - - await new Promise((resolve, reject) => - { - const extract = unzip.Extract({ path: path.join(downloadPath, extract_path), }); - (extract as any).unzipStream.on('entry', (entry: any) => - { - if (!path_fs) - path_fs = path.join(extract_path, entry.path); + cx.setProgress(stats.progress, 'download'); + }, }); - Readable.fromWeb(res.body as any).pipe(progressStream) - .pipe(extract) - .on('close', resolve) - .on('error', reject); - }); + + const downloadedFiles = await downloader.start(); + if (extract_path && downloadedFiles) + { + for (const path of downloadedFiles) + { + await _7z.unpack(path, extract_path); + } + } } if (this.config?.dryDownload === true) @@ -198,8 +165,6 @@ export class InstallJob implements IJob await mkdir(path.join(downloadPath, extract_path), { recursive: true }); } - - const coverResponse = await fetch(coverUrl); const cover = Buffer.from(await coverResponse.arrayBuffer()); @@ -291,7 +256,8 @@ export class InstallJob implements IJob summary: summary, name, cover, - cover_type: coverResponse.headers.get('content-type') + cover_type: coverResponse.headers.get('content-type'), + metadata }; const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id }); @@ -327,7 +293,17 @@ export class InstallJob implements IJob } }); + } else + { + for (let i = 0; i < 10; i++) + { + cx.setProgress(i * 10, "download"); + if (cx.abortSignal.aborted) return; + await sleep(1000); + } } + + events.emit('notification', { message: `${name}: Installed`, type: 'success', duration: 8000 }); } } \ No newline at end of file diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index 317211b..2c4e3c2 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -1,13 +1,21 @@ import Elysia from "elysia"; -import z, { } from "zod"; +import z, { _ZodType, ZodAny, ZodObject, ZodTypeAny } from "zod"; import { taskQueue } from "../app"; import { LoginJob } from "./login-job"; import TwitchLoginJob from "./twitch-login-job"; import UpdateStoreJob from "./update-store"; +import { EmulatorDownloadJob } from "./emulator-download-job"; +import { getErrorMessage } from "@/bun/utils"; +import { IJob } from "../task-queue"; -function registerJob (_job: T, path: Path, dataSchema: TS) +function registerJob< + const Path extends string, + const Schema extends ZodTypeAny, + const States extends string, + T extends IJob, States> +> (_job: { id: Path; dataSchema: Schema; } & (new (...args: any[]) => T)) { - return new Elysia().ws(path, { + return new Elysia().ws(_job.id, { body: z.discriminatedUnion('type', [ z.object({ type: z.literal('cancel') }) ]), @@ -16,14 +24,14 @@ function registerJob { - if (id === path) + if (id === _job.id) { ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() }); } }), taskQueue.on('progress', ({ id, job }) => { - if (id === path) + if (id === _job.id) { ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() }); } }), - taskQueue.on('completed', ({ id }) => + taskQueue.on('completed', ({ id, job }) => { - if (id === path) + if (id === _job.id) { - ws.send({ type: 'completed' }); + ws.send({ type: 'completed', data: job.job.exposeData?.() }); + } + }), + taskQueue.on('ended', ({ id, job }) => + { + if (id === _job.id) + { + ws.send({ type: 'ended', data: job.job.exposeData?.() }); } }), taskQueue.on('error', ({ id, error }) => { - if (id === path) + if (id === _job.id) { - ws.send({ type: 'error', error: error }); + ws.send({ type: 'error', error: getErrorMessage(error) }); } }) ]; @@ -68,13 +83,14 @@ function registerJob, "base"> { endsAt: Date; startedAt: Date; @@ -25,7 +25,7 @@ export class LoginJob implements IJob exposeData = (): z.infer => ({ endsAt: this.endsAt, startedAt: this.startedAt, url: this.url }); - async start (context: JobContext): Promise + async start (context: JobContext, "base">): Promise { const loginServer = new Elysia({ serve: { hostname: localIp, port: LOGIN_PORT } }) .use(cors()) diff --git a/src/bun/api/jobs/twitch-login-job.ts b/src/bun/api/jobs/twitch-login-job.ts index 3d2a0c0..59b5fdb 100644 --- a/src/bun/api/jobs/twitch-login-job.ts +++ b/src/bun/api/jobs/twitch-login-job.ts @@ -16,7 +16,9 @@ interface TwitchDevice verification_uri: string; } -export default class TwitchLoginJob implements IJob +type States = "Retrieving Device" | "Waiting For Authentication"; + +export default class TwitchLoginJob implements IJob, States> { twitchScopes = "analytics:read:extensions analytics:read:games user:read:email"; device?: TwitchDevice; @@ -38,7 +40,7 @@ export default class TwitchLoginJob implements IJob user_code: this.device.user_code }) : undefined; - async start (context: JobContext): Promise + async start (context: JobContext, States>): Promise { context.setProgress(0, "Retrieving Device"); let res = await fetch("https://id.twitch.tv/oauth2/device", { diff --git a/src/bun/api/jobs/update-store.ts b/src/bun/api/jobs/update-store.ts index ce97c27..a842b1d 100644 --- a/src/bun/api/jobs/update-store.ts +++ b/src/bun/api/jobs/update-store.ts @@ -1,12 +1,14 @@ import { ensureDir } from "fs-extra"; import { IJob, JobContext } from "../task-queue"; -import { getStoreFolder } from "../store/store"; +import { getStoreFolder } from "../store/services/gamesService"; +import z from "zod"; -export default class UpdateStoreJob implements IJob +export default class UpdateStoreJob implements IJob { static id = "update-store" as const; static origin = "https://github.com/simeonradivoev/gameflow-store.git"; static branch = "master"; + static dataSchema = z.never(); async gitCommand (commands: string[], dir: string) { @@ -40,8 +42,10 @@ export default class UpdateStoreJob implements IJob return (await this.gitCommand(["status", "--porcelain"], dir)).length > 0; } - async start (context: JobContext) + async start (context: JobContext) { + if (process.env.CUSTOM_STORE_PATH) return; + const storeFolder = getStoreFolder(); await ensureDir(storeFolder); context.setProgress(10); diff --git a/src/bun/api/schema/emulators.ts b/src/bun/api/schema/emulators.ts index 0af0ff0..c70679d 100644 --- a/src/bun/api/schema/emulators.ts +++ b/src/bun/api/schema/emulators.ts @@ -3,6 +3,7 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const emulators = sqliteTable('emulators', { name: text().primaryKey().unique(), + fullname: text(), systempath: text({ mode: 'json' }).notNull().$type().default(sql`(json_array())`), staticpath: text({ mode: 'json' }).notNull().$type().default(sql`(json_array())`), corepath: text({ mode: 'json' }).notNull().$type().default(sql`(json_array())`), diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index 1f7b2d1..04efda2 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -1,12 +1,13 @@ import * as appSchema from '@schema/app'; -import { findExecByName } from "../games/services/launchGameService"; import * as emulatorSchema from "@schema/emulators"; import { eq, inArray } from 'drizzle-orm'; import { customEmulators, db, emulatorsDb } from '../app'; import fs from 'node:fs/promises'; import { cores } from '../emulatorjs/emulatorjs'; -import { FrontEndEmulator } from '@/shared/constants'; +import { FrontEndEmulator, SERVER_URL } from '@/shared/constants'; +import { findExecsByName } from '../games/services/launchGameService'; +import { host } from '@/bun/utils/host'; /** * Get emulators based on local games. Only the ones we probably need. @@ -53,14 +54,8 @@ export async function getRelevantEmulators () const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator); const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) => { - let execPath: { path: string; type: string, } | undefined; - if (customEmulators.has(emulator)) - { - execPath = { path: customEmulators.get(emulator), type: 'custom' }; - } else - { - execPath = await findExecByName(emulator); - } + const execPaths = await findExecsByName(emulator); + const validExecPath = execPaths.find(e => e.exists); let platform: number | null | undefined = null; const validSystemSlug = system_slug.find(s => s.system); @@ -68,45 +63,31 @@ export async function getRelevantEmulators () { platform = platformLookup.get(validSystemSlug.system)?.platform_id; } - - // check if automatic or custom path found existing binary. - // This might not be the actual emulator but I don't care. - const exists = !!execPath && await fs.exists(execPath.path); const systems = Array.from(new Set(system_slug.filter(s => s.system).map(s => s.system!))); - if (exists) + if (validExecPath) { systems.forEach(s => platformViability.set(s, true)); } - const em: FrontEndEmulator & { isCritical: boolean; path?: { path: string, type: string; }; } = { + const em: FrontEndEmulator & { isCritical: boolean; } = { name: emulator, - exists: exists, logo: platform ? `/api/romm/platform/local/${platform}/cover` : '', systems: systems.map(s => platformLookup.get(s)).filter(s => !!s).map(e => ({ icon: `/api/romm/image/romm/assets/platforms/${e.es_slug}.svg`, name: e.platform_name ?? 'Unknown', id: e.es_slug ?? '' })), gameCount: 0, - description: '', - homepage: '', - type: 'emulator', - os: [process.platform as any], isCritical: false, - path: execPath, + validSource: validExecPath }; return em; })); finalEmulators.push({ - name: 'emulatorjs', - exists: true, - path: { path: 'localhost', type: 'js' }, + name: 'EMULATORJS', + validSource: { binPath: `${SERVER_URL(host)}`, type: 'js', exists: true }, logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, systems: [], gameCount: 0, - type: 'emulator', - description: '', - homepage: '', - os: [process.platform as any], - isCritical: false + isCritical: false, }); return finalEmulators.map(e => diff --git a/src/bun/api/store/services/emulatorsService.ts b/src/bun/api/store/services/emulatorsService.ts new file mode 100644 index 0000000..d7595bf --- /dev/null +++ b/src/bun/api/store/services/emulatorsService.ts @@ -0,0 +1,31 @@ +import { EmulatorPackageType, EmulatorSourceType, FrontEndEmulator } from "@/shared/constants"; +import { emulatorsDb } from "../../app"; +import * as emulatorSchema from '@schema/emulators'; +import { findExecs } from "../../games/services/launchGameService"; +import { eq } from "drizzle-orm"; + +export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: { + id: string; + name: string; + icon: string; +}[]) +{ + let execPath: EmulatorSourceType | undefined; + const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) }); + + if (esEmulator) + { + const allExecs = await findExecs(emulator.name, esEmulator); + if (allExecs.length > 0) execPath = allExecs[0]; + } + + const em: FrontEndEmulator = { + name: emulator.name, + logo: emulator.logo, + systems, + gameCount, + validSource: execPath + }; + + return em; +} \ No newline at end of file diff --git a/src/bun/api/store/services/gamesService.ts b/src/bun/api/store/services/gamesService.ts index 4221e5a..3ebb355 100644 --- a/src/bun/api/store/services/gamesService.ts +++ b/src/bun/api/store/services/gamesService.ts @@ -1,5 +1,22 @@ -import { GithubManifestSchema, StoreGameSchema } from "@/shared/constants"; +import { EmulatorPackageSchema, EmulatorPackageType, GithubManifestSchema, StoreGameSchema } from "@/shared/constants"; import { CACHE_KEYS, getOrCached } from "../../cache"; +import { and, eq } from "drizzle-orm"; +import { config, emulatorsDb } from '../../app'; +import path from "node:path"; +import fs from 'node:fs/promises'; +import * as emulatorSchema from '@schema/emulators'; +import { shuffleInPlace } from "@/bun/utils"; + +export async function getShuffledStoreGames () +{ + return getOrCached('shuffled-store-games', async () => + { + const gamesManifest = await getStoreGameManifest(); + const allStoreGames = gamesManifest.filter(g => g.type === 'blob'); + shuffleInPlace(allStoreGames, Math.round(new Date().getTime() / 1000 / 60 / 60)); + return allStoreGames; + }, { expireMs: 1000 / 60 / 60 }); +} export async function getStoreGameManifest () { @@ -56,4 +73,55 @@ export async function getStoreGameFromPath (path: string) .then(e => e.json()) .then(g => StoreGameSchema.parseAsync(g))); return game; +} + +export function getStoreFolder () +{ + if (process.env.CUSTOM_STORE_PATH) return process.env.CUSTOM_STORE_PATH; + const downlodDir = config.get('downloadPath'); + return path.join(downlodDir, "store"); +} + +export async function getStoreEmulatorPackage (id: string) +{ + const emulatorPath = path.join(getStoreFolder(), "buckets", "emulators", `${id}.json`); + if (await fs.exists(emulatorPath)) + return EmulatorPackageSchema.parseAsync(JSON.parse(await fs.readFile(emulatorPath, 'utf-8'))); + return undefined; +} + +export async function getAllStoreEmulatorPackages () +{ + const emulatorsBucket = path.join(getStoreFolder(), "buckets", "emulators"); + const emulators = await fs.readdir(emulatorsBucket); + const emulatorsRawData = await Promise.all(emulators.map(e => fs.readFile(path.join(emulatorsBucket, e), 'utf-8'))); + + const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.safeParse(JSON.parse(d))).filter(e => + { + if (e.error) + { + console.error(e.error); + } + return e.data; + }).map(e => e.data!); + + return emulatesParsed; +} + +export async function buildStoreFrontendEmulatorSystems (emulator: EmulatorPackageType) +{ + const systems = await Promise.all(emulator.systems.map(async system => + { + const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ + where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system)) + }); + + const esSystem = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.emulators.name, system), columns: { fullname: true } }); + + let icon: string = `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`; + + return { id: system, romm_slug: rommSystem?.sourceSlug, name: esSystem?.fullname ?? system, icon: icon }; + })); + + return systems; } \ No newline at end of file diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 24dfba4..f7e96bc 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -1,61 +1,19 @@ -import Elysia from "elysia"; -import { config, customEmulators, db } from "../app"; +import Elysia, { status } from "elysia"; +import { config, db, taskQueue } from "../app"; import path from "node:path"; import fs from 'node:fs/promises'; -import { EmulatorPackageSchema, EmulatorPackageType, FrontEndEmulator, FrontEndEmulatorDetailed, StoreGameSchema } from "@/shared/constants"; -import { findExec } from "../games/services/launchGameService"; -import { emulatorsDb } from '../app'; -import { and, eq } from "drizzle-orm"; -import * as emulatorSchema from '@schema/emulators'; +import { FrontEndEmulatorDetailed, FrontEndEmulatorDetailedDownload, StoreGameSchema } from "@/shared/constants"; +import { findExecsByName } from "../games/services/launchGameService"; import * as appSchema from '@schema/app'; import z from "zod"; import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; import { getPlatformsApiPlatformsGet } from "@/clients/romm"; -import { CACHE_KEYS, getOrCached } from "../cache"; - -export function getStoreFolder () -{ - const downlodDir = config.get('downloadPath'); - return path.join(downlodDir, "store"); -} - -async function getAllStoreEmulatorPackages () -{ - const downlodDir = config.get('downloadPath'); - const emulatorsBucket = path.join(downlodDir, "store", "buckets", "emulators"); - const emulators = await fs.readdir(emulatorsBucket); - const emulatorsRawData = await Promise.all(emulators.map(e => fs.readFile(path.join(emulatorsBucket, e), 'utf-8'))); - - const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.safeParse(JSON.parse(d))).filter(e => - { - if (e.error) - { - console.error(e.error); - } - return e.data; - }).map(e => e.data!); - - return emulatesParsed; -} - -async function buildSystems (emulator: EmulatorPackageType) -{ - const systems = await Promise.all(emulator.systems.map(async system => - { - const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ - where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system)) - }); - - const esSystem = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.emulators.name, system), columns: { fullname: true } }); - - let icon: string = `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`; - - return { id: system, name: esSystem?.fullname ?? system, icon: icon }; - })); - - return systems; -} +import { CACHE_KEYS, getOrCached, getOrCachedGithubRelease } from "../cache"; +import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage } from "./services/gamesService"; +import { EmulatorDownloadJob } from "../jobs/emulator-download-job"; +import { Glob } from "bun"; +import { convertStoreEmulatorToFrontend } from "./services/emulatorsService"; export const store = new Elysia({ prefix: '/api/store' }) .get('/emulators', async ({ query }) => @@ -70,27 +28,10 @@ export const store = new Elysia({ prefix: '/api/store' }) .filter(e => e.os.includes(process.platform as any)) .map(async (emulator) => { - let execPath: { path: string; type: string; } | undefined; - const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) }); - - if (esEmulator) - { - if (customEmulators.has(emulator?.name)) - { - execPath = { path: customEmulators.get(emulator.name), type: 'custom' }; - } else - { - execPath = await findExec(esEmulator); - } - } - - const exists = !!execPath && await fs.exists(execPath.path); - const systems = await buildSystems(emulator); - + const systems = await buildStoreFrontendEmulatorSystems(emulator); const gameCounts = await Promise.all(systems.map(async (s) => { - const rommMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, s.id)) }); - const romPlatform = rommPlatforms?.find(p => p.slug === (rommMapping?.sourceSlug ?? s.id)); + const romPlatform = rommPlatforms?.find(p => p.slug === (s.romm_slug ?? s.id)); if (romPlatform) { return romPlatform.rom_count; @@ -101,13 +42,12 @@ export const store = new Elysia({ prefix: '/api/store' }) })); const gameCount = gameCounts.reduce((a, c) => a + c); - - return { ...emulator, exists, systems, gameCount } satisfies FrontEndEmulator; + return convertStoreEmulatorToFrontend(emulator, gameCount, systems); })); if (query.missing) { - frontEndEmulators = frontEndEmulators.filter(e => !e.exists); + frontEndEmulators = frontEndEmulators.filter(e => !e.validSource); } if (query.orderBy === 'importance') @@ -161,42 +101,65 @@ export const store = new Elysia({ prefix: '/api/store' }) return Bun.file(path.join(downlodDir, "store", "media", "screenshots", id, name)); }, { params: z.object({ id: z.string(), name: z.string() }) }) - .get('/details/emulator/:id', async ({ params: { id } }) => + .get('/emulator/:id', async ({ params: { id } }) => { const downlodDir = config.get('downloadPath'); - const emulatorPath = path.join(downlodDir, "store", "buckets", "emulators", `${id}.json`); + const emulatorPackage = await getStoreEmulatorPackage(id); + if (!emulatorPackage) return status("Not Found"); + + const systems = await buildStoreFrontendEmulatorSystems(emulatorPackage); + + const execPaths = await findExecsByName(emulatorPackage.name); + const emulatorScreenshotsPath = path.join(downlodDir, "store", "media", "screenshots", id); - const emulatorPackage = await EmulatorPackageSchema.parseAsync(JSON.parse(await fs.readFile(emulatorPath, 'utf-8'))); - - const systems = await buildSystems(emulatorPackage); - let execPath: { path: string; type: string; } | undefined; - const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulatorPackage.name) }); - - if (esEmulator) - { - if (customEmulators.has(emulatorPackage?.name)) - { - execPath = { path: customEmulators.get(emulatorPackage.name), type: 'custom' }; - } else - { - execPath = await findExec(esEmulator); - } - } - - const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : []; - const exists = !!execPath && await fs.exists(execPath.path); + const validExec = execPaths.find(p => p.exists); const emulator: FrontEndEmulatorDetailed = { - ...emulatorPackage, + name: emulatorPackage.name, + description: emulatorPackage.description, systems, - exists, - status: { - source: execPath?.type, - location: execPath?.path - }, + validSource: validExec, screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`), - gameCount: 0 + gameCount: 0, + homepage: emulatorPackage.homepage, + downloads: await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d => + { + if (d.type === 'github' && d.path) + { + const release = await getOrCachedGithubRelease(d.path); + const glob = new Glob(d.pattern); + const download: FrontEndEmulatorDetailedDownload = { + name: d.type, + type: release.assets.find(a => glob.match(a.name))?.content_type + }; + return download; + }; + + return { name: d.type, type: "Unknown" }; + }) ?? []), + logo: emulatorPackage.logo, + sources: execPaths }; return emulator; - }, { params: z.object({ id: z.string() }) }); \ No newline at end of file + }, { params: z.object({ id: z.string() }) }) + .post('/install/emulator/:id/:source', async ({ params: { source, id } }) => + { + if (taskQueue.hasActiveOfType(EmulatorDownloadJob)) + { + return status("Conflict", "Installation already running"); + } + const job = new EmulatorDownloadJob(id, source); + return taskQueue.enqueue(EmulatorDownloadJob.id, job); + }) + .delete('/emulator/:id', async ({ params: { id } }) => + { + + const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id); + if (await fs.exists(storeEmulatorFolder)) + { + fs.rm(storeEmulatorFolder, { recursive: true }); + return status("OK"); + } + return status("Not Found"); + }); \ No newline at end of file diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 31741a4..5c592b1 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -11,7 +11,7 @@ import { DirSchema, DownloadsDrive } from "@/shared/constants"; import { getDevices, getDevicesCurated } from "./drives"; import getFolderSize from "get-folder-size"; import si from 'systeminformation'; -import { getStoreFolder } from "./store/store"; +import { getStoreFolder } from "./store/services/gamesService"; export const system = new Elysia({ prefix: '/api/system' }) .post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) => diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index 002f326..51f4fd2 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -1,40 +1,44 @@ +import { JobStatus } from '@/shared/constants'; import EventEmitter from 'node:events'; +import z, { ZodTypeAny } from 'zod'; export class TaskQueue { - private activeQueue: { context: JobContext, promise?: Promise; }[] = []; - private queue?: { context: JobContext, promise?: Promise; }[] = []; + private activeQueue: { context: JobContext, promise?: Promise; }[] = []; + private queue?: { context: JobContext, promise?: Promise; }[] = []; private events?: EventEmitter = new EventEmitter(); - public enqueue (id: string, job: IJob): Promise + public enqueue> (id: string, job: T) { this.disposeSafeguard(); if (!this.queue || !this.events) throw new Error("Queue disposed"); const context = new JobContext(id, this.events, job); this.queue.push({ context }); + this.events?.emit('queued', { id: context.id, job: context }); return this.processQueue(); } - private processQueue (): Promise + private processQueue () { if (!this.queue) return Promise.resolve(); - const top = this.queue.pop(); - if (top) + + const next = this.queue.filter(j => !j.context.job.group || !this.activeQueue.some(a => a.context.job.group === j.context.job.group)).map((job, i) => ({ i, job })); + + next.reverse().forEach(({ i }) => this.queue!.splice(i, 1)); + + next.forEach(job => { - const promise = top.context.start(); - top.promise = promise; - const index = this.queue.length; - this.activeQueue.push(top); + const promise = job.job.context.start(); + job.job.promise = promise; + this.activeQueue.push(job.job); promise.finally(() => { + const index = this.activeQueue.indexOf(job.job); this.activeQueue.splice(index, 1); - setTimeout(this.processQueue); + setTimeout(() => this.processQueue(), 0); }); - return promise; - - } - return Promise.resolve(); + }); } private disposeSafeguard () @@ -65,10 +69,15 @@ export class TaskQueue return job?.promise ?? Promise.resolve(); } - public findJob (id: string): IPublicJob | undefined + + public findJob> (id: string, type: new (...args: any[]) => T): IPublicJob | undefined { const job = this.queue?.find(j => j.context.id === id) ?? this.activeQueue?.find(j => j.context.id === id); - return job?.context; + if (job?.context.job instanceof type) + { + return job?.context; + } + return undefined; } public on (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void @@ -99,12 +108,13 @@ export interface EventsList completed: [e: CompletedEvent]; error: [e: ErrorEvent]; ended: [e: BaseEvent]; + queued: [e: BaseEvent]; } interface BaseEvent { id: string; - job: IPublicJob; + job: IPublicJob; } interface ErrorEvent extends BaseEvent @@ -128,37 +138,50 @@ interface CompletedEvent extends BaseEvent } -export interface IJob +export interface IJob { - start (context: JobContext): Promise; - exposeData?(): any; + group?: string; + start (context: JobContext, TData, TState>): Promise; + exposeData?(): TData; } -export type JobStatus = 'completed' | 'error' | 'running' | 'waiting' | 'aborted'; - -export interface IPublicJob +export interface IPublicJob> { progress: number; state?: string; status: JobStatus; - job: IJob; + job: T; abort: (reason?: any) => void; } -export class JobContext implements IPublicJob +type JobClass = new (...args: any[]) => IJob; +type JobClassWithStatics = JobClass & { + id: string; + dataSchema?: any; +}; +export type JobContextFromClass = + JobContext< + InstanceType, + C extends { dataSchema: ZodTypeAny; } + ? z.infer + : never, + C['id'] + >; + +export class JobContext, TData, TState extends string> implements IPublicJob { private m_id: string; private m_progress: number = 0; - private m_state?: string; + private m_state?: TState; private running: boolean = false; private aborted: boolean = false; private completed: boolean = false; private error?: any; private events: EventEmitter; private abortController: AbortController; - private readonly m_job: IJob; + private readonly m_job: T; - constructor(id: string, events: EventEmitter, job: IJob) + constructor(id: string, events: EventEmitter, job: T) { this.m_id = id; this.m_job = job; @@ -202,7 +225,7 @@ export class JobContext implements IPublicJob if (this.error) return 'error'; if (this.aborted) return 'aborted'; if (this.running) return 'running'; - return 'waiting'; + return 'queued'; } public get id () { return this.m_id; } @@ -215,7 +238,11 @@ export class JobContext implements IPublicJob public get state () { return this.m_state; } - public setProgress (progress: number, state?: string) + /** + * @param progress The 0 to 100 progress + * @param state what type of progress is this. Is it really progress. I humanity even advancing. + */ + public setProgress (progress: number, state?: TState) { this.m_progress = progress; if (state) diff --git a/src/bun/index.ts b/src/bun/index.ts index 9ea71be..96b6f76 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -8,9 +8,9 @@ import { createInterface } from 'readline'; const api = RunAPIServer(); let bunServer: { stop: () => void; } | undefined; -if (!Bun.env.PUBLIC_ACCESS) +if (!process.env.PUBLIC_ACCESS) { - bunServer = RunBunServer(); + bunServer = await RunBunServer(); } async function cleanup () @@ -24,7 +24,7 @@ async function cleanup () process.exit(0); } -if (Bun.env.HEADLESS) +if (process.env.HEADLESS) { const rl = createInterface({ input: process.stdin }); diff --git a/src/bun/server.ts b/src/bun/server.ts index 86e5fac..d9ae6b0 100644 --- a/src/bun/server.ts +++ b/src/bun/server.ts @@ -8,7 +8,7 @@ import staticPlugin from "@elysiajs/static"; export function RunBunServer () { console.log("Launching Server on port ", SERVER_PORT); - return new Elysia() + const server = new Elysia() .use(cors()) .headers({ 'cross-origin-embedder-policy': 'credentialless', @@ -28,33 +28,11 @@ export function RunBunServer () assets: appPath("./dist"), prefix: "/", alwaysStatic: true - })).listen({ port: SERVER_PORT, hostname: host, development: true }, console.log); - /*return Bun.serve({ - port: SERVER_PORT, - hostname: host, - routes: { - "/": Bun.file(appPath("./dist/index.html")), - // Serve a file by lazily loading it into memory - "/favicon.ico": Bun.file(appPath("./dist/favicon.ico")), - "/emulatorjs/": Bun.file(appPath("./dist/emulatorjs/index.html")), - "/.well-known/appspecific/com.chrome.devtools.json": new Response( - JSON.stringify({ - name: appInfo.name, - version: appInfo.version, - debuggable: true, - }), - { - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-cache", - }, - } - ) - }, - fetch: async (req) => - { - const url = new URL(req.url); - return new Response(Bun.file(appPath(`./${path.join('dist', url.pathname)}`))); - }, - });*/ + })); + + return new Promise((resolve) => + { + server.onStart(() => resolve(server)) + .listen({ port: SERVER_PORT, hostname: host, development: true }, console.log); + }); } \ No newline at end of file diff --git a/src/bun/types/types.d.ts b/src/bun/types/types.d.ts index dd95180..4ba73c2 100644 --- a/src/bun/types/types.d.ts +++ b/src/bun/types/types.d.ts @@ -6,7 +6,7 @@ export type ActiveGame = { process?: ChildProcess; gameId: number; name: string; - command: string; + command: { command: string, startDir?: string; }; }; interface ObjectConstructor diff --git a/src/bun/utils.ts b/src/bun/utils.ts index 33a58cf..487719a 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -1,5 +1,7 @@ import { $ } from 'bun'; import path from 'node:path'; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; export function checkRunning (pid: number) { @@ -68,4 +70,44 @@ export async function openExternal (target: string) { return $`open ${target}`.throws(true); } -} \ No newline at end of file +} + +export function hashFile (path: string, algorithm: "sha1" | "md5"): Promise +{ + return new Promise((resolve, reject) => + { + const hash = createHash(algorithm); + const stream = createReadStream(path); + + stream.on("data", (data) => hash.update(data)); + stream.on("end", () => resolve(hash.digest("hex"))); + stream.on("error", reject); + }); +} + +export class SeededRandom +{ + seed: number; + + constructor(seed?: number) + { + this.seed = seed ?? new Date().getTime(); + } + + next () + { + var x = Math.sin(this.seed++) * 10000; + return x - Math.floor(x); + } +} + +export function shuffleInPlace (array: any[], startSeed?: number) +{ + const random = new SeededRandom(startSeed); + + for (let i = array.length - 1; i > 0; i--) + { + const j = Math.floor(random.next() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } +} diff --git a/src/bun/utils/downloader.ts b/src/bun/utils/downloader.ts new file mode 100644 index 0000000..92a4893 --- /dev/null +++ b/src/bun/utils/downloader.ts @@ -0,0 +1,222 @@ +import { ensureDir, move } from "fs-extra"; +import path from 'node:path'; +import fs from 'node:fs/promises'; + +import { createWriteStream } from "node:fs"; +import { config, jar } from "../api/app"; +import { file } from "bun"; + +export interface FileEntry +{ + url: URL; + file_path: string; + file_name: string; + size?: number; +} + +export interface ProgressStats +{ + progress: number; +} + +interface TmpDownloadMetadata +{ + files: FileEntry[]; +} + +export class Downloader +{ + files: FileEntry[]; + headers?: Record; + onProgress?: (stats: ProgressStats) => void; + signal?: AbortSignal; + activeFile?: FileEntry; + downloadPath: string; + id: string; + tmpPath: string; + tmpPathMeta: string; + + constructor( + id: string, + files: FileEntry[], + downloadPath: string, init?: { + headers?: Record, + onProgress?: (stats: ProgressStats) => void; + signal?: AbortSignal; + }) + { + this.files = files; + this.headers = init?.headers; + this.onProgress = init?.onProgress; + this.signal = init?.signal; + this.downloadPath = downloadPath; + this.id = id; + this.tmpPath = path.join(config.get('downloadPath'), 'downloads', this.id); + this.tmpPathMeta = path.join(config.get('downloadPath'), 'downloads', `${this.id}.json`); + } + + async updateTmpDownload () + { + const meta: TmpDownloadMetadata = { + files: this.files + }; + + await ensureDir(path.join(config.get('downloadPath'), 'downloads')); + await fs.writeFile(this.tmpPathMeta, JSON.stringify(meta)); + } + + async start () + { + const totalSize = this.files.reduce((accum, current) => accum += current.size ?? 0, 0); + let bytesReceived = 0; + + if (this.files.some(f => path.isAbsolute(f.file_path))) + { + throw new Error("Only Relative Paths Supported"); + } + + await this.updateTmpDownload(); + + for (let i = 0; i < this.files.length; i++) + { + const file = this.files[i]; + this.activeFile = file; + const cookie = await jar.getCookieString(file.url.href); + + await ensureDir(path.join(this.tmpPath, file.file_path)); + + const filePath = path.join(this.tmpPath, file.file_path, file.file_name); + let start = 0; + + // 1. Check existing file + if (await fs.exists(filePath)) + { + start = ((await fs.stat(filePath)).size); + } + + // 2. Request remaining bytes + let res = await fetch(file.url, { + headers: { + ...this.headers, + ...(start > 0 + ? { Range: `bytes=${start}-` } + : undefined), + cookie + } + }); + + const resSize = Number(res.headers.get("content-length") ?? 0); + + if (start > 0) + { + if (res.status === 206) + { + console.log("Resume supported, continuing download"); + } else if (res.status === 200) + { + console.log("Server ignored Range, restarting download from beginning"); + start = 0; + + // Must make a new request from the beginning + res = await fetch(file.url, { headers: { ...this.headers, cookie } }); + + if (!res.ok) + { + throw new Error(`HTTP error: ${res.status} ${res.statusText}`); + } + } else if (res.status === 416) + { + const localSize = (await fs.stat(filePath)).size; + if (resSize && localSize === resSize) + { + console.log("File already fully downloaded, skipping"); + break; + } else + { + console.log("Partial file corrupt or changed, redownloading"); + start = 0; + res = await fetch(file.url, { headers: { ...this.headers, cookie } }); // full download + + if (!res.ok) + { + throw new Error(`HTTP error: ${res.status} ${res.statusText}`); + } + } + } + else + { + throw new Error(`HTTP error: ${res.status} ${res.statusText}`); + } + } else + { + if (!res.ok) throw new Error(`HTTP error: ${res.status} ${res.statusText}`); + } + + // 3. Append or overwrite + const stream = createWriteStream(filePath, { + flags: start > 0 ? "a" : "w", + highWaterMark: 64 * 1024 + }); + + const totalBytes = totalSize || Number(res.headers.get("content-length")) || 0; + if (totalSize <= 0) + bytesReceived = 0; + else + bytesReceived += start; + + const reader = res.body!.getReader(); + + let lastUpdate = 0; + + while (true) + { + const { done, value } = await reader.read(); + if (done) break; + + bytesReceived += value.length; + if (totalBytes > 0 && this.onProgress) + { + const percent = (bytesReceived / totalBytes) * 100; + + if (Date.now() - lastUpdate > 100) + { + this.onProgress({ progress: percent }); + lastUpdate = Date.now(); + } + } + + if (this.signal?.aborted) + { + if (this.signal.reason === 'cancel') + { + console.log("Canceling Download and cleaning up files"); + await fs.rm(this.tmpPath, { recursive: true }); + await fs.rm(this.tmpPathMeta); + return; + } + + console.log("Aborting Download: ", this.signal.reason); + break; + } + + if (!stream.write(value)) + { + await new Promise((resolve) => stream.once("drain", () => resolve(true))); + } + } + + await new Promise((resolve, reject) => + { + stream.end(() => resolve(undefined)); + stream.on("error", reject); + }); + } + + await move(this.tmpPath, this.downloadPath, { overwrite: true }); + if (await fs.exists(this.tmpPath)) + await fs.rm(this.tmpPath, { recursive: true }); + await fs.rm(this.tmpPathMeta); + + return this.files.map(f => path.join(this.downloadPath, f.file_path, f.file_name)); + } +} \ No newline at end of file diff --git a/src/clients/romm/@tanstack/react-query.gen.ts b/src/clients/romm/@tanstack/react-query.gen.ts index ae5aaab..3fa5c1e 100644 --- a/src/clients/romm/@tanstack/react-query.gen.ts +++ b/src/clients/romm/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { addCollectionApiCollectionsPost, addExclusionApiConfigExcludePost, addFirmwareApiFirmwarePost, addPlatformApiPlatformsPost, addPlatformBindingApiConfigSystemPlatformsPost, addPlatformVersionApiConfigSystemVersionsPost, addRomApiRomsPost, addRomManualsApiRomsIdManualsPost, addSaveApiSavesPost, addScreenshotApiScreenshotsPost, addSmartCollectionApiCollectionsSmartPost, addStateApiStatesPost, addUserApiUsersPost, authOpenidApiOauthOpenidGet, createInviteLinkApiUsersInviteLinkPost, createRomNoteApiRomsIdNotesPost, createSetupPlatformsApiSetupPlatformsPost, createUserFromInviteApiUsersRegisterPost, deleteCollectionApiCollectionsIdDelete, deleteExclusionApiConfigExcludeExclusionTypeExclusionValueDelete, deleteFirmwareApiFirmwareDeletePost, deletePlatformApiPlatformsIdDelete, deletePlatformBindingApiConfigSystemPlatformsFsSlugDelete, deletePlatformVersionApiConfigSystemVersionsFsSlugDelete, deleteRomManualsApiRomsIdManualsDelete, deleteRomNoteApiRomsIdNotesNoteIdDelete, deleteRomsApiRomsDeletePost, deleteSavesApiSavesDeletePost, deleteSmartCollectionApiCollectionsSmartIdDelete, deleteStatesApiStatesDeletePost, deleteUserApiUsersIdDelete, downloadRomsApiRomsDownloadGet, exportGamelistApiGamelistExportPost, fpkgiFeedApiFeedsFpkgiPlatformSlugGet, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getConfigApiConfigGet, getCurrentUserApiUsersMeGet, getFirmwareApiFirmwareIdGet, getFirmwareContentApiFirmwareIdContentFileNameGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRawAssetApiRawAssetsPathGet, getRomApiRomsIdGet, getRomByHashApiRomsByHashGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomfileApiRomsFilesIdGet, getRomfileContentApiRomsfilesIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomNotesApiRomsIdNotesGet, getRomsApiRomsGet, getRoomsApiNetplayListGet, getSaveApiSavesIdGet, getSavesApiSavesGet, getSetupLibraryInfoApiSetupLibraryGet, getSmartCollectionApiCollectionsSmartIdGet, getSmartCollectionsApiCollectionsSmartGet, getStateApiStatesIdGet, getStatesApiStatesGet, getSupportedPlatformsEndpointApiPlatformsSupportedGet, getTaskByIdApiTasksTaskIdGet, getTasksStatusApiTasksStatusGet, getUserApiUsersIdGet, getUsersApiUsersGet, getVirtualCollectionApiCollectionsVirtualIdGet, getVirtualCollectionsApiCollectionsVirtualGet, heartbeatApiHeartbeatGet, kekatsuDsFeedApiFeedsKekatsuPlatformSlugGet, listTasksApiTasksGet, loginApiLoginPost, loginViaOpenidApiLoginOpenidGet, logoutApiLogoutPost, metadataHeartbeatApiHeartbeatMetadataSourceGet, type Options, pkgiPs3FeedApiFeedsPkgiPs3ContentTypeGet, pkgiPspFeedApiFeedsPkgiPspContentTypeGet, pkgiPsvitaFeedApiFeedsPkgiPsvitaContentTypeGet, platformsWebrcadeFeedApiFeedsWebrcadeGet, refreshRetroAchievementsApiUsersIdRaRefreshPost, requestPasswordResetApiForgotPasswordPost, resetPasswordApiResetPasswordPost, runAllTasksApiTasksRunPost, runSingleTaskApiTasksRunTaskNamePost, searchCoverApiSearchCoverGet, searchRomApiSearchRomsGet, statsApiStatsGet, tinfoilIndexFeedApiFeedsTinfoilGet, tokenApiTokenPost, updateCollectionApiCollectionsIdPut, updatePlatformApiPlatformsIdPut, updateRomApiRomsIdPut, updateRomNoteApiRomsIdNotesNoteIdPut, updateRomUserApiRomsIdPropsPut, updateSaveApiSavesIdPut, updateSmartCollectionApiCollectionsSmartIdPut, updateStateApiStatesIdPut, updateUserApiUsersIdPut } from '../sdk.gen'; -import type { AddCollectionApiCollectionsPostData, AddCollectionApiCollectionsPostError, AddCollectionApiCollectionsPostResponse, AddExclusionApiConfigExcludePostData, AddFirmwareApiFirmwarePostData, AddFirmwareApiFirmwarePostError, AddFirmwareApiFirmwarePostResponse, AddPlatformApiPlatformsPostData, AddPlatformApiPlatformsPostError, AddPlatformApiPlatformsPostResponse, AddPlatformBindingApiConfigSystemPlatformsPostData, AddPlatformVersionApiConfigSystemVersionsPostData, AddRomApiRomsPostData, AddRomApiRomsPostError, AddRomManualsApiRomsIdManualsPostData, AddRomManualsApiRomsIdManualsPostError, AddSaveApiSavesPostData, AddSaveApiSavesPostError, AddSaveApiSavesPostResponse, AddScreenshotApiScreenshotsPostData, AddScreenshotApiScreenshotsPostError, AddScreenshotApiScreenshotsPostResponse, AddSmartCollectionApiCollectionsSmartPostData, AddSmartCollectionApiCollectionsSmartPostError, AddSmartCollectionApiCollectionsSmartPostResponse, AddStateApiStatesPostData, AddStateApiStatesPostError, AddStateApiStatesPostResponse, AddUserApiUsersPostData, AddUserApiUsersPostError, AddUserApiUsersPostResponse, AuthOpenidApiOauthOpenidGetData, CreateInviteLinkApiUsersInviteLinkPostData, CreateInviteLinkApiUsersInviteLinkPostError, CreateInviteLinkApiUsersInviteLinkPostResponse, CreateRomNoteApiRomsIdNotesPostData, CreateRomNoteApiRomsIdNotesPostError, CreateRomNoteApiRomsIdNotesPostResponse, CreateSetupPlatformsApiSetupPlatformsPostData, CreateSetupPlatformsApiSetupPlatformsPostError, CreateUserFromInviteApiUsersRegisterPostData, CreateUserFromInviteApiUsersRegisterPostError, CreateUserFromInviteApiUsersRegisterPostResponse, DeleteCollectionApiCollectionsIdDeleteData, DeleteCollectionApiCollectionsIdDeleteError, DeleteExclusionApiConfigExcludeExclusionTypeExclusionValueDeleteData, DeleteExclusionApiConfigExcludeExclusionTypeExclusionValueDeleteError, DeleteFirmwareApiFirmwareDeletePostData, DeleteFirmwareApiFirmwareDeletePostError, DeleteFirmwareApiFirmwareDeletePostResponse, DeletePlatformApiPlatformsIdDeleteData, DeletePlatformApiPlatformsIdDeleteError, DeletePlatformBindingApiConfigSystemPlatformsFsSlugDeleteData, DeletePlatformBindingApiConfigSystemPlatformsFsSlugDeleteError, DeletePlatformVersionApiConfigSystemVersionsFsSlugDeleteData, DeletePlatformVersionApiConfigSystemVersionsFsSlugDeleteError, DeleteRomManualsApiRomsIdManualsDeleteData, DeleteRomManualsApiRomsIdManualsDeleteError, DeleteRomNoteApiRomsIdNotesNoteIdDeleteData, DeleteRomNoteApiRomsIdNotesNoteIdDeleteError, DeleteRomNoteApiRomsIdNotesNoteIdDeleteResponse, DeleteRomsApiRomsDeletePostData, DeleteRomsApiRomsDeletePostError, DeleteRomsApiRomsDeletePostResponse, DeleteSavesApiSavesDeletePostData, DeleteSavesApiSavesDeletePostError, DeleteSavesApiSavesDeletePostResponse, DeleteSmartCollectionApiCollectionsSmartIdDeleteData, DeleteSmartCollectionApiCollectionsSmartIdDeleteError, DeleteStatesApiStatesDeletePostData, DeleteStatesApiStatesDeletePostError, DeleteStatesApiStatesDeletePostResponse, DeleteUserApiUsersIdDeleteData, DeleteUserApiUsersIdDeleteError, DownloadRomsApiRomsDownloadGetData, DownloadRomsApiRomsDownloadGetError, ExportGamelistApiGamelistExportPostData, ExportGamelistApiGamelistExportPostError, FpkgiFeedApiFeedsFpkgiPlatformSlugGetData, FpkgiFeedApiFeedsFpkgiPlatformSlugGetError, GetCollectionApiCollectionsIdGetData, GetCollectionApiCollectionsIdGetError, GetCollectionApiCollectionsIdGetResponse, GetCollectionsApiCollectionsGetData, GetCollectionsApiCollectionsGetError, GetCollectionsApiCollectionsGetResponse, GetConfigApiConfigGetData, GetConfigApiConfigGetResponse, GetCurrentUserApiUsersMeGetData, GetCurrentUserApiUsersMeGetResponse, GetFirmwareApiFirmwareIdGetData, GetFirmwareApiFirmwareIdGetError, GetFirmwareApiFirmwareIdGetResponse, GetFirmwareContentApiFirmwareIdContentFileNameGetData, GetFirmwareContentApiFirmwareIdContentFileNameGetError, GetPlatformApiPlatformsIdGetData, GetPlatformApiPlatformsIdGetError, GetPlatformApiPlatformsIdGetResponse, GetPlatformFirmwareApiFirmwareGetData, GetPlatformFirmwareApiFirmwareGetError, GetPlatformFirmwareApiFirmwareGetResponse, GetPlatformsApiPlatformsGetData, GetPlatformsApiPlatformsGetError, GetPlatformsApiPlatformsGetResponse, GetRawAssetApiRawAssetsPathGetData, GetRawAssetApiRawAssetsPathGetError, GetRomApiRomsIdGetData, GetRomApiRomsIdGetError, GetRomApiRomsIdGetResponse, GetRomByHashApiRomsByHashGetData, GetRomByHashApiRomsByHashGetError, GetRomByHashApiRomsByHashGetResponse, GetRomByMetadataProviderApiRomsByMetadataProviderGetData, GetRomByMetadataProviderApiRomsByMetadataProviderGetError, GetRomByMetadataProviderApiRomsByMetadataProviderGetResponse, GetRomContentApiRomsIdContentFileNameGetData, GetRomContentApiRomsIdContentFileNameGetError, GetRomfileApiRomsFilesIdGetData, GetRomfileApiRomsFilesIdGetError, GetRomfileApiRomsFilesIdGetResponse, GetRomfileContentApiRomsfilesIdContentFileNameGetData, GetRomfileContentApiRomsfilesIdContentFileNameGetError, GetRomFiltersApiRomsFiltersGetData, GetRomFiltersApiRomsFiltersGetResponse, GetRomNotesApiRomsIdNotesGetData, GetRomNotesApiRomsIdNotesGetError, GetRomNotesApiRomsIdNotesGetResponse, GetRomsApiRomsGetData, GetRomsApiRomsGetError, GetRomsApiRomsGetResponse, GetRoomsApiNetplayListGetData, GetRoomsApiNetplayListGetError, GetRoomsApiNetplayListGetResponse, GetSaveApiSavesIdGetData, GetSaveApiSavesIdGetError, GetSaveApiSavesIdGetResponse, GetSavesApiSavesGetData, GetSavesApiSavesGetError, GetSavesApiSavesGetResponse, GetSetupLibraryInfoApiSetupLibraryGetData, GetSmartCollectionApiCollectionsSmartIdGetData, GetSmartCollectionApiCollectionsSmartIdGetError, GetSmartCollectionApiCollectionsSmartIdGetResponse, GetSmartCollectionsApiCollectionsSmartGetData, GetSmartCollectionsApiCollectionsSmartGetError, GetSmartCollectionsApiCollectionsSmartGetResponse, GetStateApiStatesIdGetData, GetStateApiStatesIdGetError, GetStateApiStatesIdGetResponse, GetStatesApiStatesGetData, GetStatesApiStatesGetError, GetStatesApiStatesGetResponse, GetSupportedPlatformsEndpointApiPlatformsSupportedGetData, GetSupportedPlatformsEndpointApiPlatformsSupportedGetResponse, GetTaskByIdApiTasksTaskIdGetData, GetTaskByIdApiTasksTaskIdGetError, GetTaskByIdApiTasksTaskIdGetResponse, GetTasksStatusApiTasksStatusGetData, GetTasksStatusApiTasksStatusGetResponse, GetUserApiUsersIdGetData, GetUserApiUsersIdGetError, GetUserApiUsersIdGetResponse, GetUsersApiUsersGetData, GetUsersApiUsersGetResponse, GetVirtualCollectionApiCollectionsVirtualIdGetData, GetVirtualCollectionApiCollectionsVirtualIdGetError, GetVirtualCollectionApiCollectionsVirtualIdGetResponse, GetVirtualCollectionsApiCollectionsVirtualGetData, GetVirtualCollectionsApiCollectionsVirtualGetError, GetVirtualCollectionsApiCollectionsVirtualGetResponse, HeartbeatApiHeartbeatGetData, HeartbeatApiHeartbeatGetResponse, KekatsuDsFeedApiFeedsKekatsuPlatformSlugGetData, KekatsuDsFeedApiFeedsKekatsuPlatformSlugGetError, ListTasksApiTasksGetData, ListTasksApiTasksGetResponse, LoginApiLoginPostData, LoginViaOpenidApiLoginOpenidGetData, LogoutApiLogoutPostData, MetadataHeartbeatApiHeartbeatMetadataSourceGetData, MetadataHeartbeatApiHeartbeatMetadataSourceGetError, MetadataHeartbeatApiHeartbeatMetadataSourceGetResponse, PkgiPs3FeedApiFeedsPkgiPs3ContentTypeGetData, PkgiPs3FeedApiFeedsPkgiPs3ContentTypeGetError, PkgiPspFeedApiFeedsPkgiPspContentTypeGetData, PkgiPspFeedApiFeedsPkgiPspContentTypeGetError, PkgiPsvitaFeedApiFeedsPkgiPsvitaContentTypeGetData, PkgiPsvitaFeedApiFeedsPkgiPsvitaContentTypeGetError, PlatformsWebrcadeFeedApiFeedsWebrcadeGetData, PlatformsWebrcadeFeedApiFeedsWebrcadeGetResponse, RefreshRetroAchievementsApiUsersIdRaRefreshPostData, RefreshRetroAchievementsApiUsersIdRaRefreshPostError, RequestPasswordResetApiForgotPasswordPostData, RequestPasswordResetApiForgotPasswordPostError, ResetPasswordApiResetPasswordPostData, ResetPasswordApiResetPasswordPostError, RunAllTasksApiTasksRunPostData, RunAllTasksApiTasksRunPostResponse, RunSingleTaskApiTasksRunTaskNamePostData, RunSingleTaskApiTasksRunTaskNamePostError, RunSingleTaskApiTasksRunTaskNamePostResponse, SearchCoverApiSearchCoverGetData, SearchCoverApiSearchCoverGetError, SearchCoverApiSearchCoverGetResponse, SearchRomApiSearchRomsGetData, SearchRomApiSearchRomsGetError, SearchRomApiSearchRomsGetResponse, StatsApiStatsGetData, StatsApiStatsGetResponse, TinfoilIndexFeedApiFeedsTinfoilGetData, TinfoilIndexFeedApiFeedsTinfoilGetError, TinfoilIndexFeedApiFeedsTinfoilGetResponse, TokenApiTokenPostData, TokenApiTokenPostError, TokenApiTokenPostResponse, UpdateCollectionApiCollectionsIdPutData, UpdateCollectionApiCollectionsIdPutError, UpdateCollectionApiCollectionsIdPutResponse, UpdatePlatformApiPlatformsIdPutData, UpdatePlatformApiPlatformsIdPutError, UpdatePlatformApiPlatformsIdPutResponse, UpdateRomApiRomsIdPutData, UpdateRomApiRomsIdPutError, UpdateRomApiRomsIdPutResponse, UpdateRomNoteApiRomsIdNotesNoteIdPutData, UpdateRomNoteApiRomsIdNotesNoteIdPutError, UpdateRomNoteApiRomsIdNotesNoteIdPutResponse, UpdateRomUserApiRomsIdPropsPutData, UpdateRomUserApiRomsIdPropsPutError, UpdateRomUserApiRomsIdPropsPutResponse, UpdateSaveApiSavesIdPutData, UpdateSaveApiSavesIdPutError, UpdateSaveApiSavesIdPutResponse, UpdateSmartCollectionApiCollectionsSmartIdPutData, UpdateSmartCollectionApiCollectionsSmartIdPutError, UpdateSmartCollectionApiCollectionsSmartIdPutResponse, UpdateStateApiStatesIdPutData, UpdateStateApiStatesIdPutError, UpdateStateApiStatesIdPutResponse, UpdateUserApiUsersIdPutData, UpdateUserApiUsersIdPutError, UpdateUserApiUsersIdPutResponse } from '../types.gen'; +import { addCollectionApiCollectionsPost, addExclusionApiConfigExcludePost, addFirmwareApiFirmwarePost, addPlatformApiPlatformsPost, addPlatformBindingApiConfigSystemPlatformsPost, addPlatformVersionApiConfigSystemVersionsPost, addRomApiRomsPost, addRomManualsApiRomsIdManualsPost, addSaveApiSavesPost, addScreenshotApiScreenshotsPost, addSmartCollectionApiCollectionsSmartPost, addStateApiStatesPost, addUserApiUsersPost, authOpenidApiOauthOpenidGet, confirmDownloadApiSavesIdDownloadedPost, createInviteLinkApiUsersInviteLinkPost, createRomNoteApiRomsIdNotesPost, createSetupPlatformsApiSetupPlatformsPost, createUserFromInviteApiUsersRegisterPost, deleteCollectionApiCollectionsIdDelete, deleteDeviceApiDevicesDeviceIdDelete, deleteExclusionApiConfigExcludeExclusionTypeExclusionValueDelete, deleteFirmwareApiFirmwareDeletePost, deletePlatformApiPlatformsIdDelete, deletePlatformBindingApiConfigSystemPlatformsFsSlugDelete, deletePlatformVersionApiConfigSystemVersionsFsSlugDelete, deleteRomManualsApiRomsIdManualsDelete, deleteRomNoteApiRomsIdNotesNoteIdDelete, deleteRomsApiRomsDeletePost, deleteSavesApiSavesDeletePost, deleteSmartCollectionApiCollectionsSmartIdDelete, deleteStatesApiStatesDeletePost, deleteUserApiUsersIdDelete, downloadRomsApiRomsDownloadGet, downloadSaveApiSavesIdContentGet, exportGamelistApiGamelistExportPost, fpkgiFeedApiFeedsFpkgiPlatformSlugGet, getCollectionApiCollectionsIdGet, getCollectionIdentifiersApiCollectionsIdentifiersGet, getCollectionsApiCollectionsGet, getConfigApiConfigGet, getCurrentUserApiUsersMeGet, getDeviceApiDevicesDeviceIdGet, getDevicesApiDevicesGet, getFirmwareApiFirmwareIdGet, getFirmwareContentApiFirmwareIdContentFileNameGet, getFirmwareIdentifiersApiFirmwareIdentifiersGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformIdentifiersApiPlatformsIdentifiersGet, getPlatformsApiPlatformsGet, getRawAssetApiRawAssetsPathGet, getRomApiRomsIdGet, getRomByHashApiRomsByHashGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomfileApiRomsFilesIdGet, getRomfileContentApiRomsfilesIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomIdentifiersApiRomsIdentifiersGet, getRomNoteIdentifiersApiRomsIdNotesIdentifiersGet, getRomNotesApiRomsIdNotesGet, getRomsApiRomsGet, getRoomsApiNetplayListGet, getSaveApiSavesIdGet, getSaveIdentifiersApiSavesIdentifiersGet, getSavesApiSavesGet, getSavesSummaryApiSavesSummaryGet, getSetupLibraryInfoApiSetupLibraryGet, getSmartCollectionApiCollectionsSmartIdGet, getSmartCollectionIdentifiersApiCollectionsSmartIdentifiersGet, getSmartCollectionsApiCollectionsSmartGet, getStateApiStatesIdGet, getStateIdentifiersApiStatesIdentifiersGet, getStatesApiStatesGet, getSupportedPlatformsEndpointApiPlatformsSupportedGet, getTaskByIdApiTasksTaskIdGet, getTasksStatusApiTasksStatusGet, getUserApiUsersIdGet, getUserIdentifiersApiUsersIdentifiersGet, getUsersApiUsersGet, getVirtualCollectionApiCollectionsVirtualIdGet, getVirtualCollectionIdentifiersApiCollectionsVirtualIdentifiersGet, getVirtualCollectionsApiCollectionsVirtualGet, heartbeatApiHeartbeatGet, kekatsuDsFeedApiFeedsKekatsuPlatformSlugGet, listTasksApiTasksGet, loginApiLoginPost, loginViaOpenidApiLoginOpenidGet, logoutApiLogoutPost, metadataHeartbeatApiHeartbeatMetadataSourceGet, type Options, pkgiPs3FeedApiFeedsPkgiPs3ContentTypeGet, pkgiPspFeedApiFeedsPkgiPspContentTypeGet, pkgiPsvitaFeedApiFeedsPkgiPsvitaContentTypeGet, pkgjPspDlcsFeedApiFeedsPkgjPspDlcGet, pkgjPspGamesFeedApiFeedsPkgjPspGamesGet, pkgjPsvDlcsFeedApiFeedsPkgjPsvitaDlcGet, pkgjPsvGamesFeedApiFeedsPkgjPsvitaGamesGet, pkgjPsxGamesFeedApiFeedsPkgjPsxGamesGet, platformsWebrcadeFeedApiFeedsWebrcadeGet, refreshRetroAchievementsApiUsersIdRaRefreshPost, registerDeviceApiDevicesPost, requestPasswordResetApiForgotPasswordPost, resetPasswordApiResetPasswordPost, runAllTasksApiTasksRunPost, runSingleTaskApiTasksRunTaskNamePost, searchCoverApiSearchCoverGet, searchRomApiSearchRomsGet, statsApiStatsGet, tinfoilIndexFeedApiFeedsTinfoilGet, tokenApiTokenPost, trackSaveApiSavesIdTrackPost, untrackSaveApiSavesIdUntrackPost, updateCollectionApiCollectionsIdPut, updateDeviceApiDevicesDeviceIdPut, updatePlatformApiPlatformsIdPut, updateRomApiRomsIdPut, updateRomNoteApiRomsIdNotesNoteIdPut, updateRomUserApiRomsIdPropsPut, updateSaveApiSavesIdPut, updateSmartCollectionApiCollectionsSmartIdPut, updateStateApiStatesIdPut, updateUserApiUsersIdPut } from '../sdk.gen'; +import type { AddCollectionApiCollectionsPostData, AddCollectionApiCollectionsPostError, AddCollectionApiCollectionsPostResponse, AddExclusionApiConfigExcludePostData, AddFirmwareApiFirmwarePostData, AddFirmwareApiFirmwarePostError, AddFirmwareApiFirmwarePostResponse, AddPlatformApiPlatformsPostData, AddPlatformApiPlatformsPostError, AddPlatformApiPlatformsPostResponse, AddPlatformBindingApiConfigSystemPlatformsPostData, AddPlatformVersionApiConfigSystemVersionsPostData, AddRomApiRomsPostData, AddRomApiRomsPostError, AddRomManualsApiRomsIdManualsPostData, AddRomManualsApiRomsIdManualsPostError, AddSaveApiSavesPostData, AddSaveApiSavesPostError, AddSaveApiSavesPostResponse, AddScreenshotApiScreenshotsPostData, AddScreenshotApiScreenshotsPostError, AddScreenshotApiScreenshotsPostResponse, AddSmartCollectionApiCollectionsSmartPostData, AddSmartCollectionApiCollectionsSmartPostError, AddSmartCollectionApiCollectionsSmartPostResponse, AddStateApiStatesPostData, AddStateApiStatesPostError, AddStateApiStatesPostResponse, AddUserApiUsersPostData, AddUserApiUsersPostError, AddUserApiUsersPostResponse, AuthOpenidApiOauthOpenidGetData, ConfirmDownloadApiSavesIdDownloadedPostData, ConfirmDownloadApiSavesIdDownloadedPostError, ConfirmDownloadApiSavesIdDownloadedPostResponse, CreateInviteLinkApiUsersInviteLinkPostData, CreateInviteLinkApiUsersInviteLinkPostError, CreateInviteLinkApiUsersInviteLinkPostResponse, CreateRomNoteApiRomsIdNotesPostData, CreateRomNoteApiRomsIdNotesPostError, CreateRomNoteApiRomsIdNotesPostResponse, CreateSetupPlatformsApiSetupPlatformsPostData, CreateSetupPlatformsApiSetupPlatformsPostError, CreateUserFromInviteApiUsersRegisterPostData, CreateUserFromInviteApiUsersRegisterPostError, CreateUserFromInviteApiUsersRegisterPostResponse, DeleteCollectionApiCollectionsIdDeleteData, DeleteCollectionApiCollectionsIdDeleteError, DeleteDeviceApiDevicesDeviceIdDeleteData, DeleteDeviceApiDevicesDeviceIdDeleteError, DeleteDeviceApiDevicesDeviceIdDeleteResponse, DeleteExclusionApiConfigExcludeExclusionTypeExclusionValueDeleteData, DeleteExclusionApiConfigExcludeExclusionTypeExclusionValueDeleteError, DeleteFirmwareApiFirmwareDeletePostData, DeleteFirmwareApiFirmwareDeletePostError, DeleteFirmwareApiFirmwareDeletePostResponse, DeletePlatformApiPlatformsIdDeleteData, DeletePlatformApiPlatformsIdDeleteError, DeletePlatformBindingApiConfigSystemPlatformsFsSlugDeleteData, DeletePlatformBindingApiConfigSystemPlatformsFsSlugDeleteError, DeletePlatformVersionApiConfigSystemVersionsFsSlugDeleteData, DeletePlatformVersionApiConfigSystemVersionsFsSlugDeleteError, DeleteRomManualsApiRomsIdManualsDeleteData, DeleteRomManualsApiRomsIdManualsDeleteError, DeleteRomNoteApiRomsIdNotesNoteIdDeleteData, DeleteRomNoteApiRomsIdNotesNoteIdDeleteError, DeleteRomNoteApiRomsIdNotesNoteIdDeleteResponse, DeleteRomsApiRomsDeletePostData, DeleteRomsApiRomsDeletePostError, DeleteRomsApiRomsDeletePostResponse, DeleteSavesApiSavesDeletePostData, DeleteSavesApiSavesDeletePostError, DeleteSavesApiSavesDeletePostResponse, DeleteSmartCollectionApiCollectionsSmartIdDeleteData, DeleteSmartCollectionApiCollectionsSmartIdDeleteError, DeleteStatesApiStatesDeletePostData, DeleteStatesApiStatesDeletePostError, DeleteStatesApiStatesDeletePostResponse, DeleteUserApiUsersIdDeleteData, DeleteUserApiUsersIdDeleteError, DownloadRomsApiRomsDownloadGetData, DownloadRomsApiRomsDownloadGetError, DownloadSaveApiSavesIdContentGetData, DownloadSaveApiSavesIdContentGetError, ExportGamelistApiGamelistExportPostData, ExportGamelistApiGamelistExportPostError, FpkgiFeedApiFeedsFpkgiPlatformSlugGetData, FpkgiFeedApiFeedsFpkgiPlatformSlugGetError, GetCollectionApiCollectionsIdGetData, GetCollectionApiCollectionsIdGetError, GetCollectionApiCollectionsIdGetResponse, GetCollectionIdentifiersApiCollectionsIdentifiersGetData, GetCollectionIdentifiersApiCollectionsIdentifiersGetResponse, GetCollectionsApiCollectionsGetData, GetCollectionsApiCollectionsGetError, GetCollectionsApiCollectionsGetResponse, GetConfigApiConfigGetData, GetConfigApiConfigGetResponse, GetCurrentUserApiUsersMeGetData, GetCurrentUserApiUsersMeGetResponse, GetDeviceApiDevicesDeviceIdGetData, GetDeviceApiDevicesDeviceIdGetError, GetDeviceApiDevicesDeviceIdGetResponse, GetDevicesApiDevicesGetData, GetDevicesApiDevicesGetResponse, GetFirmwareApiFirmwareIdGetData, GetFirmwareApiFirmwareIdGetError, GetFirmwareApiFirmwareIdGetResponse, GetFirmwareContentApiFirmwareIdContentFileNameGetData, GetFirmwareContentApiFirmwareIdContentFileNameGetError, GetFirmwareIdentifiersApiFirmwareIdentifiersGetData, GetFirmwareIdentifiersApiFirmwareIdentifiersGetResponse, GetPlatformApiPlatformsIdGetData, GetPlatformApiPlatformsIdGetError, GetPlatformApiPlatformsIdGetResponse, GetPlatformFirmwareApiFirmwareGetData, GetPlatformFirmwareApiFirmwareGetError, GetPlatformFirmwareApiFirmwareGetResponse, GetPlatformIdentifiersApiPlatformsIdentifiersGetData, GetPlatformIdentifiersApiPlatformsIdentifiersGetResponse, GetPlatformsApiPlatformsGetData, GetPlatformsApiPlatformsGetError, GetPlatformsApiPlatformsGetResponse, GetRawAssetApiRawAssetsPathGetData, GetRawAssetApiRawAssetsPathGetError, GetRomApiRomsIdGetData, GetRomApiRomsIdGetError, GetRomApiRomsIdGetResponse, GetRomByHashApiRomsByHashGetData, GetRomByHashApiRomsByHashGetError, GetRomByHashApiRomsByHashGetResponse, GetRomByMetadataProviderApiRomsByMetadataProviderGetData, GetRomByMetadataProviderApiRomsByMetadataProviderGetError, GetRomByMetadataProviderApiRomsByMetadataProviderGetResponse, GetRomContentApiRomsIdContentFileNameGetData, GetRomContentApiRomsIdContentFileNameGetError, GetRomfileApiRomsFilesIdGetData, GetRomfileApiRomsFilesIdGetError, GetRomfileApiRomsFilesIdGetResponse, GetRomfileContentApiRomsfilesIdContentFileNameGetData, GetRomfileContentApiRomsfilesIdContentFileNameGetError, GetRomFiltersApiRomsFiltersGetData, GetRomFiltersApiRomsFiltersGetResponse, GetRomIdentifiersApiRomsIdentifiersGetData, GetRomIdentifiersApiRomsIdentifiersGetResponse, GetRomNoteIdentifiersApiRomsIdNotesIdentifiersGetData, GetRomNoteIdentifiersApiRomsIdNotesIdentifiersGetError, GetRomNoteIdentifiersApiRomsIdNotesIdentifiersGetResponse, GetRomNotesApiRomsIdNotesGetData, GetRomNotesApiRomsIdNotesGetError, GetRomNotesApiRomsIdNotesGetResponse, GetRomsApiRomsGetData, GetRomsApiRomsGetError, GetRomsApiRomsGetResponse, GetRoomsApiNetplayListGetData, GetRoomsApiNetplayListGetError, GetRoomsApiNetplayListGetResponse, GetSaveApiSavesIdGetData, GetSaveApiSavesIdGetError, GetSaveApiSavesIdGetResponse, GetSaveIdentifiersApiSavesIdentifiersGetData, GetSaveIdentifiersApiSavesIdentifiersGetResponse, GetSavesApiSavesGetData, GetSavesApiSavesGetError, GetSavesApiSavesGetResponse, GetSavesSummaryApiSavesSummaryGetData, GetSavesSummaryApiSavesSummaryGetError, GetSavesSummaryApiSavesSummaryGetResponse, GetSetupLibraryInfoApiSetupLibraryGetData, GetSmartCollectionApiCollectionsSmartIdGetData, GetSmartCollectionApiCollectionsSmartIdGetError, GetSmartCollectionApiCollectionsSmartIdGetResponse, GetSmartCollectionIdentifiersApiCollectionsSmartIdentifiersGetData, GetSmartCollectionIdentifiersApiCollectionsSmartIdentifiersGetResponse, GetSmartCollectionsApiCollectionsSmartGetData, GetSmartCollectionsApiCollectionsSmartGetError, GetSmartCollectionsApiCollectionsSmartGetResponse, GetStateApiStatesIdGetData, GetStateApiStatesIdGetError, GetStateApiStatesIdGetResponse, GetStateIdentifiersApiStatesIdentifiersGetData, GetStateIdentifiersApiStatesIdentifiersGetResponse, GetStatesApiStatesGetData, GetStatesApiStatesGetError, GetStatesApiStatesGetResponse, GetSupportedPlatformsEndpointApiPlatformsSupportedGetData, GetSupportedPlatformsEndpointApiPlatformsSupportedGetResponse, GetTaskByIdApiTasksTaskIdGetData, GetTaskByIdApiTasksTaskIdGetError, GetTaskByIdApiTasksTaskIdGetResponse, GetTasksStatusApiTasksStatusGetData, GetTasksStatusApiTasksStatusGetResponse, GetUserApiUsersIdGetData, GetUserApiUsersIdGetError, GetUserApiUsersIdGetResponse, GetUserIdentifiersApiUsersIdentifiersGetData, GetUserIdentifiersApiUsersIdentifiersGetResponse, GetUsersApiUsersGetData, GetUsersApiUsersGetResponse, GetVirtualCollectionApiCollectionsVirtualIdGetData, GetVirtualCollectionApiCollectionsVirtualIdGetError, GetVirtualCollectionApiCollectionsVirtualIdGetResponse, GetVirtualCollectionIdentifiersApiCollectionsVirtualIdentifiersGetData, GetVirtualCollectionIdentifiersApiCollectionsVirtualIdentifiersGetResponse, GetVirtualCollectionsApiCollectionsVirtualGetData, GetVirtualCollectionsApiCollectionsVirtualGetError, GetVirtualCollectionsApiCollectionsVirtualGetResponse, HeartbeatApiHeartbeatGetData, HeartbeatApiHeartbeatGetResponse, KekatsuDsFeedApiFeedsKekatsuPlatformSlugGetData, KekatsuDsFeedApiFeedsKekatsuPlatformSlugGetError, ListTasksApiTasksGetData, ListTasksApiTasksGetResponse, LoginApiLoginPostData, LoginViaOpenidApiLoginOpenidGetData, LogoutApiLogoutPostData, MetadataHeartbeatApiHeartbeatMetadataSourceGetData, MetadataHeartbeatApiHeartbeatMetadataSourceGetError, MetadataHeartbeatApiHeartbeatMetadataSourceGetResponse, PkgiPs3FeedApiFeedsPkgiPs3ContentTypeGetData, PkgiPs3FeedApiFeedsPkgiPs3ContentTypeGetError, PkgiPspFeedApiFeedsPkgiPspContentTypeGetData, PkgiPspFeedApiFeedsPkgiPspContentTypeGetError, PkgiPsvitaFeedApiFeedsPkgiPsvitaContentTypeGetData, PkgiPsvitaFeedApiFeedsPkgiPsvitaContentTypeGetError, PkgjPspDlcsFeedApiFeedsPkgjPspDlcGetData, PkgjPspGamesFeedApiFeedsPkgjPspGamesGetData, PkgjPsvDlcsFeedApiFeedsPkgjPsvitaDlcGetData, PkgjPsvGamesFeedApiFeedsPkgjPsvitaGamesGetData, PkgjPsxGamesFeedApiFeedsPkgjPsxGamesGetData, PlatformsWebrcadeFeedApiFeedsWebrcadeGetData, PlatformsWebrcadeFeedApiFeedsWebrcadeGetResponse, RefreshRetroAchievementsApiUsersIdRaRefreshPostData, RefreshRetroAchievementsApiUsersIdRaRefreshPostError, RegisterDeviceApiDevicesPostData, RegisterDeviceApiDevicesPostError, RegisterDeviceApiDevicesPostResponse, RequestPasswordResetApiForgotPasswordPostData, RequestPasswordResetApiForgotPasswordPostError, ResetPasswordApiResetPasswordPostData, ResetPasswordApiResetPasswordPostError, RunAllTasksApiTasksRunPostData, RunAllTasksApiTasksRunPostResponse, RunSingleTaskApiTasksRunTaskNamePostData, RunSingleTaskApiTasksRunTaskNamePostError, RunSingleTaskApiTasksRunTaskNamePostResponse, SearchCoverApiSearchCoverGetData, SearchCoverApiSearchCoverGetError, SearchCoverApiSearchCoverGetResponse, SearchRomApiSearchRomsGetData, SearchRomApiSearchRomsGetError, SearchRomApiSearchRomsGetResponse, StatsApiStatsGetData, StatsApiStatsGetResponse, TinfoilIndexFeedApiFeedsTinfoilGetData, TinfoilIndexFeedApiFeedsTinfoilGetError, TinfoilIndexFeedApiFeedsTinfoilGetResponse, TokenApiTokenPostData, TokenApiTokenPostError, TokenApiTokenPostResponse, TrackSaveApiSavesIdTrackPostData, TrackSaveApiSavesIdTrackPostError, TrackSaveApiSavesIdTrackPostResponse, UntrackSaveApiSavesIdUntrackPostData, UntrackSaveApiSavesIdUntrackPostError, UntrackSaveApiSavesIdUntrackPostResponse, UpdateCollectionApiCollectionsIdPutData, UpdateCollectionApiCollectionsIdPutError, UpdateCollectionApiCollectionsIdPutResponse, UpdateDeviceApiDevicesDeviceIdPutData, UpdateDeviceApiDevicesDeviceIdPutError, UpdateDeviceApiDevicesDeviceIdPutResponse, UpdatePlatformApiPlatformsIdPutData, UpdatePlatformApiPlatformsIdPutError, UpdatePlatformApiPlatformsIdPutResponse, UpdateRomApiRomsIdPutData, UpdateRomApiRomsIdPutError, UpdateRomApiRomsIdPutResponse, UpdateRomNoteApiRomsIdNotesNoteIdPutData, UpdateRomNoteApiRomsIdNotesNoteIdPutError, UpdateRomNoteApiRomsIdNotesNoteIdPutResponse, UpdateRomUserApiRomsIdPropsPutData, UpdateRomUserApiRomsIdPropsPutError, UpdateRomUserApiRomsIdPropsPutResponse, UpdateSaveApiSavesIdPutData, UpdateSaveApiSavesIdPutError, UpdateSaveApiSavesIdPutResponse, UpdateSmartCollectionApiCollectionsSmartIdPutData, UpdateSmartCollectionApiCollectionsSmartIdPutError, UpdateSmartCollectionApiCollectionsSmartIdPutResponse, UpdateStateApiStatesIdPutData, UpdateStateApiStatesIdPutError, UpdateStateApiStatesIdPutResponse, UpdateUserApiUsersIdPutData, UpdateUserApiUsersIdPutError, UpdateUserApiUsersIdPutResponse } from '../types.gen'; export type QueryKey = [ Pick & { @@ -442,6 +442,32 @@ export const createUserFromInviteApiUsersRegisterPostMutation = (options?: Parti return mutationOptions; }; +export const getUserIdentifiersApiUsersIdentifiersGetQueryKey = (options?: Options) => createQueryKey('getUserIdentifiersApiUsersIdentifiersGet', options); + +/** + * Get User Identifiers + * + * Get all user identifiers endpoint + * + * Args: + * request (Request): Fastapi Request object + * + * Returns: + * list[int]: All user ids stored in the RomM's database + */ +export const getUserIdentifiersApiUsersIdentifiersGetOptions = (options?: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getUserIdentifiersApiUsersIdentifiersGet({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getUserIdentifiersApiUsersIdentifiersGetQueryKey(options) +}); + export const getCurrentUserApiUsersMeGetQueryKey = (options?: Options) => createQueryKey('getCurrentUserApiUsersMeGet', options); /** @@ -568,6 +594,93 @@ export const refreshRetroAchievementsApiUsersIdRaRefreshPostMutation = (options? return mutationOptions; }; +export const getDevicesApiDevicesGetQueryKey = (options?: Options) => createQueryKey('getDevicesApiDevicesGet', options); + +/** + * Get Devices + */ +export const getDevicesApiDevicesGetOptions = (options?: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getDevicesApiDevicesGet({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getDevicesApiDevicesGetQueryKey(options) +}); + +/** + * Register Device + */ +export const registerDeviceApiDevicesPostMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await registerDeviceApiDevicesPost({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +/** + * Delete Device + */ +export const deleteDeviceApiDevicesDeviceIdDeleteMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await deleteDeviceApiDevicesDeviceIdDelete({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const getDeviceApiDevicesDeviceIdGetQueryKey = (options: Options) => createQueryKey('getDeviceApiDevicesDeviceIdGet', options); + +/** + * Get Device + */ +export const getDeviceApiDevicesDeviceIdGetOptions = (options: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getDeviceApiDevicesDeviceIdGet({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getDeviceApiDevicesDeviceIdGetQueryKey(options) +}); + +/** + * Update Device + */ +export const updateDeviceApiDevicesDeviceIdPutMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await updateDeviceApiDevicesDeviceIdPut({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const getPlatformsApiPlatformsGetQueryKey = (options?: Options) => createQueryKey('getPlatformsApiPlatformsGet', options); /** @@ -607,6 +720,26 @@ export const addPlatformApiPlatformsPostMutation = (options?: Partial) => createQueryKey('getPlatformIdentifiersApiPlatformsIdentifiersGet', options); + +/** + * Get Platform Identifiers + * + * Retrieve platform identifiers. + */ +export const getPlatformIdentifiersApiPlatformsIdentifiersGetOptions = (options?: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getPlatformIdentifiersApiPlatformsIdentifiersGet({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getPlatformIdentifiersApiPlatformsIdentifiersGetQueryKey(options) +}); + export const getSupportedPlatformsEndpointApiPlatformsSupportedGetQueryKey = (options?: Options) => createQueryKey('getSupportedPlatformsEndpointApiPlatformsSupportedGet', options); /** @@ -782,6 +915,26 @@ export const addRomApiRomsPostMutation = (options?: Partial) => createQueryKey('getRomIdentifiersApiRomsIdentifiersGet', options); + +/** + * Get Rom Identifiers + * + * Retrieve rom identifiers. + */ +export const getRomIdentifiersApiRomsIdentifiersGetOptions = (options?: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getRomIdentifiersApiRomsIdentifiersGet({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getRomIdentifiersApiRomsIdentifiersGetQueryKey(options) +}); + export const downloadRomsApiRomsDownloadGetQueryKey = (options: Options) => createQueryKey('downloadRomsApiRomsDownloadGet', options); /** @@ -1076,6 +1229,26 @@ export const createRomNoteApiRomsIdNotesPostMutation = (options?: Partial
      ; } -function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; }) +function AchievementsInfo (data: InteractParams) { - if (!data.game.achievements) + const { data: game } = Route.useLoaderData(); + if (!game.achievements) { return false; } - return -
      -
      + return +
      +
      - {`${data.game.achievements.unlocked}/${data.game.achievements.total}`} + {`${game.achievements.unlocked}/${game.achievements.total}`}
      - +
      ; } -function MainActions (data: { game: FrontEndGameTypeDetailed; }) +function MainActions () { + const { data } = Route.useLoaderData(); const { source, id } = Route.useParams(); - const installMutation = useMutation({ - mutationKey: ['install'], - mutationFn: async () => + const installMut = useMutation(installMutation(source, id)); + const playMut = useMutation({ + ...playMutation, onError (error) { - const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).install.post(); - if (error) throw error; - } - }); - const playMutation = useMutation({ - mutationKey: ['play'], - mutationFn: async () => - { - const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).play.post(); - if (error) - { - if (error.value.message) - { - toast.error(error.value.message); - } - - throw error; - }; - } + toast.error(error.message); + }, }); + const ws = useRef<{ send: (data: string) => void; }>(undefined); const [progress, setProgress] = useState(undefined); - const [status, setStatus] = useState(undefined); + const [status, setStatus] = useState(undefined); const [error, setError] = useState(undefined); const [details, setDetails] = useState(undefined); const [commands, setCommands] = useState(undefined); + const [preferredCommand, setPreferredCommand] = useLocalStorage(`${data.source ?? data.id.source}-${data.source_id ?? data.id.id}-preferred-command`, undefined); const queryClient = useQueryClient(); + const validCommands = commands ? commands.filter(c => c.valid) : []; + const validDefaultCommand = commands?.find(c => + { + if (!c.valid) return false; + if (preferredCommand && c.id !== preferredCommand) return false; + return true; + }); useEffect(() => { - const es = new EventSource(`${RPC_URL(__HOST__)}/api/romm/status/${data.game.id.source}/${data.game.id.id}`); + const sub = rommApi.api.romm.status({ source: data.id.source })({ id: data.id.id }).subscribe(); + ws.current = sub.ws; - es.onmessage = ({ data }) => + sub.subscribe((e) => { - const stats = JSON.parse(data) as GameInstallProgress; - setProgress(stats.progress); - setStatus(stats.status); - setDetails(stats.details); - setCommands(stats.commands); - setError(stats.error); - }; + setStatus(e.data.status); + setProgress((e.data as any).progress); + setDetails((e.data as any).details); + setCommands((e.data as any).commands); - es.addEventListener('refresh', () => - { - queryClient.invalidateQueries({ queryKey: ['game', data.game.id] }); - Router.navigate({ to: '/game/$source/$id', params: { id, source } }); - }); - - es.addEventListener('error', (e) => - { - if ((e as any).data) + if (e.data.status === 'refresh') { - const stats = JSON.parse((e as any).data) as GameInstallProgress; - toast.error(stats.error); - setError(stats.error); + queryClient.invalidateQueries({ queryKey: ['game', data.id] }); + Router.navigate({ to: '/game/$source/$id', params: { id, source }, replace: true }); + } else if (e.data.status === 'error') + { + const errorMessage = getErrorMessage(e.data.error); + if (!errorMessage) return; + toast.error(errorMessage); + setError(errorMessage); } }); - es.onerror = (event) => + return () => { - const error = (event as any).data?.error; - if (error) - { - toast.error(error); - setError(error); - } + sub.close(); + ws.current = undefined; }; - - return () => es.close(); - }, [data.game.id]); + }, [data.id]); let progressIcon: JSX.Element | undefined = undefined; switch (status) @@ -319,29 +335,51 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; }) case 'download': progressIcon = ; break; + case 'queued': + progressIcon = ; + break; case 'extract': progressIcon = ; break; } - let mainButton: JSX.Element | undefined = undefined; + 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(cmd.command); + Router.navigate({ to: '/embedded/$source/$id', params: { source, id }, search: Object.fromEntries(params.entries()), replace: true }); + } else + { + playMut.mutate({ source: data.id.source, id: data.id.id, command_id: cmd.id }); + Router.navigate({ to: '/launcher/$source/$id', params: { source, id }, replace: true }); + } + }; + + let mainButton: any | undefined = undefined; if (status === 'installed') { - mainButton = - { - const firstValid = commands?.find(c => c.valid); - if (firstValid?.emulator === 'emulatorjs') - { - const params = new URLSearchParams(firstValid.command); - Router.navigate({ to: '/embedded/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id }, search: Object.fromEntries(params.entries()) }); - } else - { - playMutation.mutate(); - SaveSource('launch'); - Router.navigate({ to: '/launcher/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id } }); - } + mainButton =
      handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details} + key="primary" + type='primary' + id="mainAction" + > + - }} tooltip={details} key="primary" type='primary' id="mainAction">; + + + {validCommands.length > 1 && + showAllCommands(true, 'allActionsBtn')}> + + }
      ; } else if (error) { @@ -354,8 +392,7 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; }) { if (status === 'missing-emulator') { - SaveSource('settings'); - Router.navigate({ to: '/settings/directories', viewTransition: { types: ['zoom-in'] } }); + Router.navigate({ to: '/settings/directories' }); } }} id="mainAction"> @@ -366,12 +403,12 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; }) { mainButton = { if (status === 'install') { - installMutation.mutate(); + installMut.mutate(); } }} tooltip={details ?? status} @@ -381,10 +418,41 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; }) ; } + const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', { + content: + { + const commands: DialogEntry = { + id: String(c.id), + content: c.label ?? "", + type: 'primary', + action (ctx) + { + setPreferredCommand(c.id); + handlePlay(c); + }, + }; + return commands; + })} />, + preferredChildFocusKey: String(preferredCommand) + }); + + const { dialog: installOptionsDialog, setOpen: showInstallOptions } = useContextDialog('install-options-dialog', { + content: + }); + return
      {mainButton}
      - {progress !== null && !!progressIcon && + {showProgress && showInstallOptions(true, "progress")} key="progress" square tooltip={details} type="base" id="progress" >
      {progressIcon} @@ -392,26 +460,34 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
      } + {installOptionsDialog} + {allCommandDialog}
      ; } -function ActionButtons (data: { game: FrontEndGameTypeDetailed; }) +function ActionButtons (data: {}) { + const [, setDetailsSection] = useDetailsSection(); + const { data: game } = Route.useLoaderData(); const [hoverText, setHoverText] = useState(undefined); const [hoverTextType, setHoverTextType] = useState('accent'); const { ref, focusKey } = useFocusable({ focusKey: 'actions', onBlur: () => setHoverText(undefined) }); const [open, setOpen] = useState(false); const deleteMutation = useMutation({ - ...queries.romm.deleteGameMutation, + ...deleteGameMutation(game.id), onSuccess: () => { location.reload(); console.log("Deleted"); + }, + onError (error) + { + toast.error(getErrorMessage(error) ?? "Error While Deleting"); } }); const contextOptions: DialogEntry[] = []; - if (data.game.local) + if (game.local) { contextOptions.push({ id: 'delete', @@ -451,16 +527,20 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; }) return
      - - + + + { + setDetailsSection("achievements"); + if (game.achievements?.entires[0]) + { + setFocus(game.achievements.entires[0].id); + } + + }} /> setOpen(true)} type="base" id="settings" icon={} > - - { - setOpen(false); - setFocus("settings"); - }}> + {!!hoverText && !isPointer &&

      {hoverText}

      } @@ -496,7 +576,7 @@ function ActionButton (data: { const styles = { primary: "bg-primary text-primary-content focused:bg-base-content focused:text-base-300 focusable focusable-primary", base: " text-base-content border-dashed border-base-content/20 border-2 focused:bg-base-content focused:text-base-300 focusable focusable-primary", - accent: "bg-primary text-primary-content focusable focusable-primary focusable:bg-base-content focusable:text-base-300", + accent: "bg-accent text-accent-content focusable focusable-primary focusable:bg-base-content focusable:text-base-300", error: "bg-error text-error-content focused:bg-error focused:text-error-content", }; return ( @@ -516,45 +596,135 @@ function ActionButton (data: { ); } -export default function GameDetailsUI () +function Stats () { const { data } = Route.useLoaderData(); + const stats: StatEntry[] = []; + if (data.path_fs) + stats.push({ label: "Location", content: data.path_fs, icon: }); + if (data.companies) + stats.push({ label: "Companies", content: data.companies }); + if (data.genres) + stats.push({ label: 'Genres', content: data.genres }); + if (data.release_date) + stats.push({ label: "Release Date", content: data.release_date.toLocaleDateString(), icon: }); + if (data.emulators) + stats.push({ label: "Emulators", content: data.emulators.map(e => e.name) }); + return ; +} + +function Divider (data: { rootFocusKey: string; showShortcuts: boolean; }) +{ + const [details, setDetails] = useDetailsSection(); + const { data: game } = Route.useLoaderData(); + const { ref, focusKey } = useFocusable({ + focusKey: "details-divider", + onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'nearest', behavior: 'smooth' })(focusKey, ref.current, d), + }); + const detailFilter: Record = { + stats: { label: "Stats", selected: details === 'stats', icon: }, + screenshots: { label: "Screenshots", selected: details === 'screenshots', icon: }, + }; + if (game.achievements) + { + detailFilter.achievements = { label: "Achievements", selected: details === 'achievements', icon: }; + } + + return
      + + + +
      ; +} + +export default function GameDetailsUI () +{ + const [recommendedGamesVisible, setRecommendedGamesVisible] = useState(false); + const { data } = Route.useLoaderData(); + const { focus } = Route.useSearch(); + const [, setUpdate] = useState(0); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" }); const headerRef = useRef(null); const sentinelRef = useRef(null); const backgroundImage = data.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined; const mainAreaRef = useRef(null); + const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data.id.source, data.id.id), enabled: recommendedGamesVisible }); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); const { shortcuts } = useShortcutContext(); useEffect(() => { - focusSelf(); + if (focus) + { + setFocus(focus, { instant: true }); + } else + { + focusSelf(); + } + }, []); useStickyDataAttr(headerRef, sentinelRef, ref); + const recommendedEmulators = data.emulators?.filter(e => e.store_exists); + + const { ref: intersct } = useIntersectionObserver({ + onChange: (isIntersecting, entry) => + { + setRecommendedGamesVisible(isIntersecting); + } + }); return ( -
      - -
      -
      - -
      -
      -
      -
      -
      -
      Screenshots
      - {!!data && node.scrollIntoView({ behavior: 'smooth', block: 'center' })} />} -
      - -
      -
      - -
      + setUpdate(v => v + 1) + }} > +
      + +
      +
      + +
      +
      +
      +
      + +
      + {!!recommendedEmulators && recommendedEmulators.length > 0 &&
      +

      + Related Emulators +

      } + onFocus={scrollIntoViewHandler({ block: 'center' })} + onSelect={(id, focus) => + { + Router.navigate({ to: '/store/details/emulator/$id', params: { id } }); + }} + emulators={recommendedEmulators} />} +
      +
      +
      +
      +
      + +

      + Related Games +

      +
      + + { + Router.navigate({ to: '/game/$source/$id', params: { id: id.id, source: id.source } }); + }} onFocus={scrollIntoViewHandler({ block: 'center', inline: 'nearest' })} games={recommendedGames} /> +
      +
      + +
      +
      + +
      + ); } \ No newline at end of file diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 0db1bd5..9f2bf7b 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -29,7 +29,6 @@ import { HeaderAccounts, HeaderStatusBar } from "../components/Header"; import { FilterUI } from "../components/Filters"; import { AnimatedBackground } from "../components/AnimatedBackground"; import { GameList } from "../components/GameList"; -import { SaveSource } from "../scripts/spatialNavigation"; import LoadingCardList from "../components/LoadingCardList"; import { AutoFocus } from "../components/AutoFocus"; import SaveScroll from "../components/SaveScroll"; @@ -46,7 +45,7 @@ import { mobileCheck, useDragScroll } from "../scripts/utils"; import { AnimatedBackgroundContext } from "../scripts/contexts"; import { FrontEndId } from "@/shared/constants"; import Carousel from "../components/Carousel"; -import queries from "../scripts/queries"; +import { closeMutation } from "@queries/system"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -125,20 +124,17 @@ function HomeList (data: { function handleGameSelect (id: FrontEndId, source: string | null, sourceId: string | null) { - SaveSource('details', { search: { filter: data.selectedFilter } }); - Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } }); + Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); }; const handleCollectionSelect = (id: string) => { - SaveSource('game-list', { search: { filter: data.selectedFilter } }); - Router.navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } }); + Router.navigate({ to: `/collection/${id}` }); }; const handlePlatformSelect = (source: string, id: string) => { - SaveSource('game-list', { search: { filter: data.selectedFilter } }); - Router.navigate({ to: `/platform/${source}/${id}`, viewTransition: { types: ['zoom-in'] } }); + Router.navigate({ to: `/platform/${source}/${id}` }); }; let activeList: JSX.Element; @@ -224,7 +220,6 @@ function MainMenu () focusKey: `main-menu`, trackChildren: true, }); - const navigate = useNavigate(); return (
        navigate({ to: "/games", viewTransition: { types: ['zoom-in'] } })} + action={() => Router.navigate({ to: "/games" })} icon={} label="Home" type="secondary" /> } label="News" /> - } action={() => navigate({ to: "/store/tab", viewTransition: { types: ['zoom-in'] } })} label="Shop" /> + } action={() => Router.navigate({ to: "/store/tab" })} label="Shop" /> } label="Album" /> } @@ -248,8 +243,7 @@ function MainMenu () { - SaveSource('settings'); - navigate({ to: "/settings/accounts", viewTransition: { types: ['zoom-in'] } }); + Router.navigate({ to: '/settings/accounts' }); }} icon={} label="Settings" @@ -294,7 +288,7 @@ export default function ConsoleHomeUI () { const { filter } = Route.useSearch(); - const close = useMutation(queries.system.closeMutation); + const close = useMutation(closeMutation); const { ref, focusKey } = useFocusable({ forceFocus: true, @@ -304,29 +298,7 @@ export default function ConsoleHomeUI () preferredChildFocusKey: `home-list`, }); - const setFilter = (filter: string) => Router.navigate({ to: '/', search: { filter } }); - - useShortcuts(focusKey, () => [ - { - action: () => - { - const filterKeys = Object.keys(filters); - const filterIndex = Math.max(0, filterKeys.indexOf(filter)); - const selectedFilterIndex = Math.min(filterIndex + 1, filterKeys.length - 1); - Router.navigate({ to: '/', search: { filter: filterKeys[selectedFilterIndex] } }); - }, - button: GamePadButtonCode.R1 - }, - { - action: () => - { - const filterKeys = Object.keys(filters); - const filterIndex = Math.max(0, filterKeys.indexOf(filter)); - const selectedFilterIndex = Math.max(0, filterIndex - 1,); - Router.navigate({ to: '/', search: { filter: filterKeys[selectedFilterIndex] } }); - }, - button: GamePadButtonCode.L1 - }], [filter]); + const setFilter = (filter: string) => Router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true }); const { shortcuts } = useShortcutContext(); const headerButtons = []; @@ -342,6 +314,7 @@ export default function ConsoleHomeUI ()
      [key, { ...value, selected: key === filter }]))} diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index 6f987fa..b9bb2a5 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import Shortcuts from '../components/Shortcuts'; -import queries from '../scripts/queries'; +import { gameQuery } from '@queries/romm'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, @@ -18,12 +18,12 @@ function RouteComponent () { function HandleGoBack () { - Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id } }); + Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); } const { source, id } = Route.useParams(); const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` }); - const { data } = useQuery(queries.romm.gameQuery(source, id)); + const { data } = useQuery(gameQuery(source, id)); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); const { shortcuts } = useShortcutContext(); diff --git a/src/mainview/routes/platform.$source.$id.tsx b/src/mainview/routes/platform.$source.$id.tsx index 0ae0c5a..b67fce6 100644 --- a/src/mainview/routes/platform.$source.$id.tsx +++ b/src/mainview/routes/platform.$source.$id.tsx @@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { CollectionsDetail } from "../components/CollectionsDetail"; import { useQuery } from "@tanstack/react-query"; import { RPC_URL } from "../../shared/constants"; -import queries from "../scripts/queries"; +import { platformQuery } from "@queries/romm"; export const Route = createFileRoute("/platform/$source/$id")({ component: RouteComponent @@ -22,7 +22,7 @@ function PlatformTitle (data: { pathCover: string | null, platformName?: string; function RouteComponent () { const { source, id } = Route.useParams(); - const { data: platform } = useQuery(queries.romm.platformQuery(source, id)); + const { data: platform } = useQuery(platformQuery(source, id)); return (
      diff --git a/src/mainview/routes/settings/about.tsx b/src/mainview/routes/settings/about.tsx index fd0fede..d30b291 100644 --- a/src/mainview/routes/settings/about.tsx +++ b/src/mainview/routes/settings/about.tsx @@ -1,5 +1,6 @@ -import queries from '@/mainview/scripts/queries'; + +import { systemInfoQuery } from '@queries/system'; import { useQuery } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import prettyBytes from 'pretty-bytes'; @@ -10,7 +11,7 @@ export const Route = createFileRoute('/settings/about')({ function RouteComponent () { - const { data: systemInfo } = useQuery(queries.system.systemInfoQuery); + const { data: systemInfo } = useQuery(systemInfoQuery); return
    diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index ffd6026..65cf0a0 100644 --- a/src/mainview/routes/settings/accounts.tsx +++ b/src/mainview/routes/settings/accounts.tsx @@ -23,7 +23,8 @@ import QRCode from "react-qr-code"; import { useJobStatus } from "@/mainview/scripts/utils"; import { useInterval } from "usehooks-ts"; import { TwitchIcon } from "@/mainview/scripts/brandIcons"; -import queries from "@/mainview/scripts/queries"; +import { twitchLoginMutation, twitchLoginVerificationQuery, twitchLogoutMutation } from "@queries/settings"; +import { rommGetOptionsQuery, rommHasPasswordQuery, rommHostnameQuery, rommLoginMutation, rommLogoutMutation, rommQrLoginMutation, rommUsernameQuery, rommUserQuery } from "@queries/romm"; export const Route = createFileRoute("/settings/accounts")({ component: RouteComponent, @@ -52,14 +53,14 @@ function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: function TwitchLogin () { - const loginStatus = useQuery(queries.settings.twitchLoginVerificationQuery); + const loginStatus = useQuery(twitchLoginVerificationQuery); const loginMutation = useMutation({ - ...queries.settings.twitchLoginMutation, + ...twitchLoginMutation, onSuccess: () => loginStatus.refetch() }); - const logoutMutation = useMutation({ ...queries.settings.twitchLogoutMutation, onSuccess: () => loginStatus.refetch() }); + const logoutMutation = useMutation({ ...twitchLogoutMutation, onSuccess: () => loginStatus.refetch() }); const { data: loginData, wsRef } = useJobStatus('twitch-login-job', { onEnded: () => loginStatus.refetch() }); @@ -84,13 +85,13 @@ function TwitchLogin () function LoginControls (data: { hasPassword: boolean; }) { - const user = useQuery(queries.romm.rommUserQuery()); - const loginMutation = useMutation(queries.romm.rommQrLoginMutation); + const user = useQuery(rommUserQuery()); + const loginMutation = useMutation(rommQrLoginMutation); const { data: statusValue, wsRef } = useJobStatus('login-job'); const context = useSettingsFormContext({}); const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0; const logoutMutation = useMutation({ - ...queries.romm.rommLogoutMutation, + ...rommLogoutMutation, onSuccess: async (d, v, r, c) => { user.refetch(); @@ -136,9 +137,9 @@ function RouteComponent () preferredChildFocusKey: focus }); - const { data: hasPassword } = useQuery(queries.romm.rommHasPasswordQuery); - const { data: hostname } = useQuery(queries.romm.rommHostnameQuery); - const { data: username } = useQuery(queries.romm.rommUsernameQuery); + const { data: hasPassword } = useQuery(rommHasPasswordQuery); + const { data: hostname } = useQuery(rommHostnameQuery); + const { data: username } = useQuery(rommUsernameQuery); const loginForm = useSettingsForm({ defaultValues: { @@ -160,7 +161,7 @@ function RouteComponent () } }); - const rommOnline = useQuery(queries.romm.rommGetOptionsQuery()); + const rommOnline = useQuery(rommGetOptionsQuery()); useEffect(() => { @@ -170,7 +171,7 @@ function RouteComponent () } }, [focus]); - const loginMutation = useMutation(queries.romm.rommLoginMutation); + const loginMutation = useMutation(rommLoginMutation); let indicator = ""; if (rommOnline.isError) diff --git a/src/mainview/routes/settings/directories.tsx b/src/mainview/routes/settings/directories.tsx index 37e8984..a755625 100644 --- a/src/mainview/routes/settings/directories.tsx +++ b/src/mainview/routes/settings/directories.tsx @@ -2,7 +2,6 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-naviga import { Block, createFileRoute } from '@tanstack/react-router'; import DownloadDirectoryOption from '@/mainview/components/options/DownloadDirectoryOption'; import { useIsMutating, useMutation, useQuery } from '@tanstack/react-query'; -import queries from '@/mainview/scripts/queries'; import { DownloadsDrive } from '@/shared/constants'; import prettyBytes from 'pretty-bytes'; import classNames from 'classnames'; @@ -13,6 +12,7 @@ import { OptionSpace } from '@/mainview/components/options/OptionSpace'; import { Button } from '@/mainview/components/options/Button'; import { systemApi } from '@/mainview/scripts/clientApi'; import useActiveControl from '@/mainview/scripts/gamepads'; +import { changeDownloadsMutation } from '@queries/settings'; export const Route = createFileRoute('/settings/directories')({ component: RouteComponent, @@ -24,11 +24,11 @@ function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; r focusKey: data.drive.device, onFocus: () => (ref.current as HTMLElement)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) }); - const isMoving = useIsMutating(queries.settings.changeDownloadsMutation); + const isMoving = useIsMutating(changeDownloadsMutation); const usedWithoutDownlods = data.drive.used - (data.drive.isCurrentlyUsed ? data.downloadsSize : 0); const usedPercent = usedWithoutDownlods / data.drive.size; const usedPercentRaw = data.drive.used / data.drive.size; - const changeDownloads = useMutation({ ...queries.settings.changeDownloadsMutation, onSuccess: data.refetchDrives }); data.drive.unusableReason; + const changeDownloads = useMutation({ ...changeDownloadsMutation, onSuccess: data.refetchDrives }); data.drive.unusableReason; const shortcuts: Shortcut[] = []; const valid = !data.drive.unusableReason && isMoving <= 0; const handleAction = () => changeDownloads.mutate(data.drive.mountPoint); diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index 3fa94a8..44e4e76 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -14,7 +14,7 @@ import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spat import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; import FilePicker from '@/mainview/components/FilePicker'; import { dirname } from 'pathe'; -import queries from '@/mainview/scripts/queries'; +import { autoEmulatorsQuery, customEmulatorAddMutation, customEmulatorDeleteMutation, customEmulatorRemoveValueQuery, customEmulatorsQuery, setCustomEmulatorMutation } from '@queries/settings'; export const Route = createFileRoute('/settings/emulators')({ component: RouteComponent, @@ -98,13 +98,13 @@ function EmulatorPath (data: { id: string; }) const [isSearching, setIsSearching] = useState(false); const [dirty, setDirty] = useState(false); const [localValue, setLocalValue] = useState(); - const { data: remoteValue } = useQuery(queries.settings.customEmulatorRemoveValueQuery(data.id)); - const setSettingMutation = useMutation(queries.settings.setCustomEmulatorMutation(data.id, (v) => + const { data: remoteValue } = useQuery(customEmulatorRemoveValueQuery(data.id)); + const setSettingMutation = useMutation(setCustomEmulatorMutation(data.id, (v) => { setLocalValue(v); setDirty(false); })); - const deleteMutation = useMutation(queries.settings.customEmulatorDeleteMutation(data.id)); + const deleteMutation = useMutation(customEmulatorDeleteMutation(data.id)); const handleSave = useCallback(() => { @@ -223,11 +223,11 @@ function EmulatorBadge (data: { function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) => void; }) { - const { data: autoEmulators } = useQuery(queries.settings.autoEmulatorsQuery); + const { data: autoEmulators } = useQuery(autoEmulatorsQuery); const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators && autoEmulators.length > 0 }); return
    - {autoEmulators?.map(e => )} + {autoEmulators?.map(e => )}
    ; } @@ -240,9 +240,9 @@ function RouteComponent () preferredChildFocusKey: focus }); - const { data: customEmulators } = useQuery(queries.settings.customEmulatorsQuery); + const { data: customEmulators } = useQuery(customEmulatorsQuery); - const addOverrideMutation = useMutation(queries.settings.customEmulatorAddMutation); + const addOverrideMutation = useMutation(customEmulatorAddMutation); return
      diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index 8fb69a0..2fe2467 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -8,7 +8,6 @@ import Outlet, createFileRoute, useMatch, - useNavigate, } from "@tanstack/react-router"; import { ViewTransitionOptions } from "@tanstack/router-core"; import classNames from "classnames"; @@ -25,10 +24,10 @@ import { JSX, useEffect } from "react"; import { twMerge } from "tailwind-merge"; import z from "zod"; import { SettingsSchema } from "../../../shared/constants"; -import { PopSource } from "../../scripts/spatialNavigation"; import { Router } from "../.."; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; import Shortcuts from "@/mainview/components/Shortcuts"; +import { HandleGoBack } from "@/mainview/scripts/utils"; export const Route = createFileRoute("/settings")({ component: SettingsUI, @@ -48,21 +47,26 @@ function MenuItem (data: { label: string; }) { - const navigate = useNavigate(); const acitve = !!useMatch({ from: data.route as any, shouldThrow: false });; const handleNonFocusSelect = () => { - const { to, search } = PopSource('settings'); - navigate({ to: data.return ? to ?? data.route : data.route, viewTransition: data.viewTransition, search: data.return ? search : undefined }); + if (data.return) + { + HandleGoBack(); + } else if (!acitve) + { + Router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true }); + } + }; const { ref, focusSelf } = useFocusable({ focusKey: `menu-item-${data.route}`, forceFocus: !!acitve, onFocus: () => { - if (data.focusSelect) + if (data.focusSelect && !acitve) { - navigate({ to: data.route }); + Router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true }); } (ref.current as HTMLElement).scrollIntoView({ inline: 'center' }); }, @@ -104,12 +108,13 @@ function SettingsMenu (data: {}) const { ref, focusKey } = useFocusable({ focusable: true, focusKey: 'settings-menu', - preferredChildFocusKey: location.hash.replace("#", '') + preferredChildFocusKey: location.hash.replaceAll(/#|(\?.+)/g, '') }); return
        } />
      ; } -function HandleGoBack () -{ - - const { to, search } = PopSource('settings'); - if (to) - { - console.log("Found source ", to, " to go back to"); - } - Router.navigate({ to: to ?? "/", viewTransition: { types: ['zoom-out'] }, search }); - -} - export function SettingsUI () { const { ref, focusKey, focusSelf } = useFocusable({ diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 302c799..0276a76 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -1,174 +1,264 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import { useFocusable, FocusContext, - setFocus, } from "@noriginmedia/norigin-spatial-navigation"; import { createFileRoute } from "@tanstack/react-router"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; import { Router } from "@/mainview"; import Shortcuts from "@/mainview/components/Shortcuts"; import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; -import { PopSource } from "@/mainview/scripts/spatialNavigation"; import { systemApi } from "@/mainview/scripts/clientApi"; -import queries from "@/mainview/scripts/queries"; import { Button } from "@/mainview/components/options/Button"; -import { ChevronDown, Download, Info, Settings } from "lucide-react"; -import { ContextDialog, ContextList, DialogEntry } from "@/mainview/components/ContextDialog"; -import { FrontEndEmulator, RPC_URL } from "@/shared/constants"; +import { ChevronDown, Download, Gamepad2, Info, Settings, Trash2, TriangleAlert } from "lucide-react"; +import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog"; +import { FrontEndEmulatorDetailed, RPC_URL } from "@/shared/constants"; import Screenshots from "@/mainview/components/Screenshots"; -import { HeaderUI } from "@/mainview/components/Header"; -import { useQuery } from "@tanstack/react-query"; +import { StickyHeaderUI } from "@/mainview/components/Header"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection"; -import { scrollIntoViewHandler, useStickyDataAttr } from "@/mainview/scripts/utils"; +import { HandleGoBack, scrollIntoViewHandler, useJobStatus } from "@/mainview/scripts/utils"; +import toast from "react-hot-toast"; +import { getErrorMessage } from "react-error-boundary"; +import { emulatorStatusIcons } from "@/mainview/components/store/StoreEmulatorCard"; +import StatList, { StatEntry } from "@/mainview/components/StatList"; +import { GamesSection } from "@/mainview/components/store/GamesSection"; +import { installEmulatorMutation, storeEmulatorDeleteMutation, storeEmulatorDetailsQuery, storeEmulatorsRecommendedQuery } from "@queries/store"; +import { gamesRecommendedBasedOnEmulatorQuery } from "@queries/romm"; export const Route = createFileRoute('/store/details/emulator/$id')({ component: RouteComponent, async loader (ctx) { - const emulator = await ctx.context.queryClient.fetchQuery(queries.store.storeEmulatorDetailsQuery(ctx.params.id)); - return { emulator }; + ctx.context.queryClient.prefetchQuery(storeEmulatorDetailsQuery(ctx.params.id)); + ctx.context.queryClient.prefetchQuery(storeEmulatorsRecommendedQuery); + ctx.context.queryClient.prefetchQuery(gamesRecommendedBasedOnEmulatorQuery(ctx.params.id)); } }); -function HomePageLink (data: { homepage: string; }) +function HomePageLink (data: { homepage?: string; }) { const { ref } = useFocusable({ focusKey: 'homepage-link' }); - return systemApi.api.system.open.post({ url: data.homepage })}>{data.homepage}; + return + { + if (data.homepage) systemApi.api.system.open.post({ url: data.homepage }); + }}> + {data.homepage ??
      } + ; } -function TitleArea (data: { emulator: FrontEndEmulator; }) +function TitleArea (data: { + emulator?: FrontEndEmulatorDetailed; + onInstall: (source: string) => void; +}) { - const [installOpen, setInstallOpen] = useState(false); - const installOptions: DialogEntry[] = []; + const queryClient = useQueryClient(); + const deleteMutation = useMutation({ + ...storeEmulatorDeleteMutation, onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(variables)), + }); + const installProgressRef = useRef(null); + const { data: installJob, status: installStatus } = useJobStatus('download-emulator', { + onError (error) + { + console.log(error); + toast.error(getErrorMessage(error) ?? "Error During Download"); + }, + onProgress (process) + { + if (installProgressRef.current) + installProgressRef.current.value = process; + }, + onEnded (data) + { + console.log("Finished Install", data.emulator); + if (data.emulator) + queryClient.refetchQueries(storeEmulatorDetailsQuery(data.emulator)); + }, + }); + + const isInstalling = !!installJob; + + const options: DialogEntry[] = []; + if (data.emulator) + { + if (!isInstalling && !data.emulator?.validSource) + { + options.push(...data.emulator.downloads.map(d => + { + const entry: DialogEntry = { + content: `Install From: ${d.name} (${d.type})`, + type: 'primary', + id: d.name, + action: (ctx) => + { + data.onInstall(d.name); + ctx.close(); + } + }; + return entry; + })); + } else if (data.emulator.sources.find(s => s.type === 'store' && s.exists)) + { + options.push({ + content: "Delete", + type: 'error', + icon: , + action (ctx) + { + if (data.emulator) deleteMutation.mutate(data.emulator.name); + ctx.close(); + }, + id: "delete" + }); + } + } + const { ref, focusKey } = useFocusable({ focusKey: 'title-area', preferredChildFocusKey: "install-btn", onFocus: () => { (ref.current as HTMLElement).scrollIntoView({ behavior: "smooth", block: 'end' }); } }); - return
      + + let installButtonContent = <>; + if (!data.emulator) + { + installButtonContent = ; + } + else if (isInstalling) + { + installButtonContent = <>{installStatus}; + } else if (data.emulator.validSource) + { + installButtonContent = <> Options; + } else if (data.emulator.downloads.length > 0) + { + installButtonContent = <>Install; + } else + { + installButtonContent = <>Unsupported; + } + + const { dialog: installOptionsDialog, setOpen } = useContextDialog("install-context-menu", { + content: + }); + + const handleOptionsOpen = () => + { + if (isInstalling || !data.emulator || data.emulator.downloads.length <= 0) return false; + setOpen(true, 'install-btn'); + }; + + return
      - -
      -

      {data.emulator.name}

      -

      - {data.emulator.systems.map(({ id, name, icon }) => + {data.emulator ? :

      } +
      +

      {data.emulator?.name ??
      }

      +
      + {data.emulator?.systems.map(({ id, name, icon }) => { return
      {!!icon && }

      {name}

      ; - })} -

      + }) ?? <>
      } +
      - +
      - - - - { - setInstallOpen(false); - setFocus("install-btn"); - }}> - - - - - -
      ; +
      + +
      + {installOptionsDialog} + +
      ; } -function Description (data: { emulator: FrontEndEmulator; }) +function Description (data: { emulator?: FrontEndEmulatorDetailed; }) { return
      -

      {data.emulator.description}

      +

      {data.emulator?.description ??

      +
      +
      +
      +
      }

      ; } export function RouteComponent () { const { id } = Route.useParams(); - const headerRef = useRef(null); - const sentinelRef = useRef(null); + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: `GAME_DETAIL_${id}`, trackChildren: true, preferredChildFocusKey: 'title-area' }); - const { emulator } = Route.useLoaderData(); - const { data: recommended } = useQuery(queries.store.storeEmulatorsRecommendedQuery); + const { data: emulator, isPending: isEmulatorPending } = useQuery(storeEmulatorDetailsQuery(id)); + const { data: recommendedEmulators } = useQuery(storeEmulatorsRecommendedQuery); + const { data: recommendedGames } = useQuery(gamesRecommendedBasedOnEmulatorQuery(id)); useShortcuts(focusKey, () => [{ label: "Return", - action: () => - { - const { to, search } = PopSource('store-details'); - Router.navigate({ to: to ?? '/store/tab', viewTransition: { types: ['zoom-out'] }, search: search ?? { focus: id } }); - }, + action: HandleGoBack, button: GamePadButtonCode.B }]); + const installMutation = useMutation({ + ...installEmulatorMutation(id), onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)), + }); + useEffect(() => { focusSelf(); }, []); const { shortcuts } = useShortcutContext(); - useStickyDataAttr(headerRef, sentinelRef, ref); + + + const stats: StatEntry[] = []; + if (emulator) + { + if (emulator.keywords) + stats.push({ label: "Tags", content: emulator.keywords }); + stats.push({ label: "Systems", content: emulator.systems.map(s => s.name) }); + stats.push(...emulator.sources.flatMap(s => [{ label: "Source", content: s.type, icon: emulatorStatusIcons[s.type] }, { label: "Location", content: s.binPath }])); + } return ( - + -
      -
      -
      - + +
      +
      + + +
      +
      -
      - -
      -
      - +
      + {isEmulatorPending || (!!emulator && emulator?.screenshots.length > 0) && }
      -
      -
      -
      +
      Stats
      -
        - {!!emulator.keywords && -
      • -
        Tags:
        -
        {emulator.keywords?.map(k => {k})}
        -
      • - } - {!!emulator.status.source && -
      • -
        Source
        -
        {emulator.status.source}
        -
      • - } - {!!emulator.status.location && -
      • -
        Location
        -
        {emulator.status.location}
        -
      • - } -
      -
      - {recommended && + {recommendedEmulators &&
      +

      @@ -177,11 +267,26 @@ export function RouteComponent () onFocus={scrollIntoViewHandler({ block: 'center' })} onSelect={(id, focus) => { - setFocus("title-area"); - Router.navigate({ to: '/store/details/emulator/$id', params: { id }, viewTransition: { types: ['zoom-in'] } }); + Router.navigate({ + to: '/store/details/emulator/$id', params: { id } + }); }} - emulators={recommended} />} -

      + emulators={recommendedEmulators} /> +
      } + {recommendedGames && recommendedGames.length > 0 &&
      +
      +
      + +

      + Related Games +

      +
      + + { + Router.navigate({ + to: '/game/$source/$id', params: { id: id.id, source: id.source } + }); + }} games={recommendedGames} />
      }
      diff --git a/src/mainview/routes/store/tab/emulators.tsx b/src/mainview/routes/store/tab/emulators.tsx index ce30db4..645a0fe 100644 --- a/src/mainview/routes/store/tab/emulators.tsx +++ b/src/mainview/routes/store/tab/emulators.tsx @@ -8,7 +8,7 @@ import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard import { StoreContext } from '@/mainview/scripts/contexts'; import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import { useQuery } from '@tanstack/react-query'; -import queries from '@/mainview/scripts/queries'; +import { storeEmulatorsQuery } from '@queries/store'; export const Route = createFileRoute('/store/tab/emulators')({ component: RouteComponent, @@ -22,7 +22,7 @@ function RouteComponent () preferredChildFocusKey: focus }); const storeContext = useContext(StoreContext); - const { data: emulators } = useQuery(queries.store.storeEmulatorsQuery); + const { data: emulators } = useQuery(storeEmulatorsQuery); useEffect(() => { diff --git a/src/mainview/routes/store/tab/games.tsx b/src/mainview/routes/store/tab/games.tsx index ddea718..860c61e 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -6,7 +6,7 @@ import { useInfiniteQuery } from '@tanstack/react-query'; import FrontEndGameCard from '@/mainview/components/FrontEndGameCard'; import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import LoadMoreButton from '@/mainview/components/LoadMoreButton'; -import queries from '@/mainview/scripts/queries'; +import { storeGamesInfiniteQuery } from '@queries/store'; export const Route = createFileRoute('/store/tab/games')({ component: RouteComponent @@ -17,7 +17,7 @@ function RouteComponent () const { focus } = useSearch({ from: '/store/tab' }); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus }); - const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(queries.store.storeGamesInfiniteQuery); + const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery); useEffect(() => { @@ -52,7 +52,7 @@ function RouteComponent ()
      )} diff --git a/src/mainview/routes/store/tab/index.tsx b/src/mainview/routes/store/tab/index.tsx index 94df52e..0f3b79b 100644 --- a/src/mainview/routes/store/tab/index.tsx +++ b/src/mainview/routes/store/tab/index.tsx @@ -5,15 +5,16 @@ import { EmulatorsSection } from "../../../components/store/EmulatorsSection"; import { GamesSection } from "../../../components/store/GamesSection"; import { StatsSection } from "../../../components/store/StatsSection"; import { FrontEndGameTypeDetailed, RPC_URL } from '@/shared/constants'; -import queries from '@/mainview/scripts/queries'; import { useContext, useEffect, useRef, useState } from 'react'; import { scrollIntoViewHandler } from '@/mainview/scripts/utils'; import { StoreContext } from '@/mainview/scripts/contexts'; import { useInterval } from 'usehooks-ts'; import { Button } from '@/mainview/components/options/Button'; -import { HardDrive, Search } from 'lucide-react'; +import { Gamepad2, HardDrive, Search, Star } from 'lucide-react'; import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import { useQuery } from '@tanstack/react-query'; +import { autoEmulatorsQuery } from '@queries/settings'; +import { storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@queries/store'; export const Route = createFileRoute('/store/tab/')({ component: RouteComponent @@ -106,9 +107,9 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; }) export function RouteComponent () { const { focus } = useSearch({ from: '/store/tab' }); - const { data: crucialEmulators, isSuccess } = useQuery({ ...queries.settings.autoEmulatorsQuery, select: (data) => data.filter(e => !e.exists && e.isCritical) }); - const { data: featuredGames } = useQuery(queries.store.storeFeaturedGamesQuery); - const { data: recommendedEmulators } = useQuery(queries.store.storeEmulatorsRecommendedQuery); + const { data: crucialEmulators, isSuccess } = useQuery({ ...autoEmulatorsQuery, select: (data) => data.filter(e => !e.validSource && e.isCritical) }); + const { data: featuredGames } = useQuery(storeFeaturedGamesQuery); + const { data: recommendedEmulators } = useQuery(storeEmulatorsRecommendedQuery); const { focusKey, ref, focusSelf } = useFocusable({ focusKey: 'main-area', preferredChildFocusKey: focus ?? "recommended-emulators" }); const storeContext = useContext(StoreContext); @@ -137,11 +138,22 @@ export function RouteComponent () emulators={recommendedEmulators} />
      - storeContext.showDetails('game', id.source, id.id, focus)} - onFocus={scrollIntoViewHandler({ block: 'center' })} - games={featuredGames} - /> +
      +
      +
      + +

      + Featured Games +

      +
      Creator Picks
      +
      + storeContext.showDetails('game', id.source, id.id, focus)} + onFocus={scrollIntoViewHandler({ block: 'center' })} + games={featuredGames} + /> +
      + ; }) { const { ref, focusKey } = useFocusable({ focusKey: 'top-area', - preferredChildFocusKey: 'store-tabs', + preferredChildFocusKey: `store-tabs`, onFocus: () => { (ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'end' }); } }); + useShortcuts("STORE_ROOT", () => [{ + label: "Return", + action: () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-out'] } }), + button: GamePadButtonCode.B + }], []); + + const handleNavigate = (s: string) => + { + Router.navigate({ to: `/store/tab/${s === 'home' ? '' : s}`, viewTransition: { types: ['slide-up'] }, replace: true }); + }; + return
      - Router.navigate({ to: `/store/tab/${s === 'home' ? '' : s}` })} /> +
      ; } +function StoreOutlet () +{ + const { ref, focusKey } = useFocusable({ focusKey: "STORE_OUTLET" }); + return
      + + + +
      ; +} + function RouteComponent () { // Root spatial nav container const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "STORE_ROOT", - trackChildren: true, - preferredChildFocusKey: 'top-area' + preferredChildFocusKey: 'top-area', + forceFocus: true }); const headerRef = useRef(null); const sentinelRef = useRef(null); @@ -65,34 +87,6 @@ function RouteComponent () games: { label: "Games", selected: useIsSettings('games') } }; - useShortcuts(focusKey, () => [{ - label: "Return", - action: () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-out'] } }), - button: GamePadButtonCode.B - }, - { - action: () => - { - const filterKeys = Object.keys(filters); - const filterIndex = Math.max(0, filterKeys.findIndex(f => filters[f].selected)); - const selectedFilterIndex = Math.min(filterIndex + 1, filterKeys.length - 1); - const newFilter = filterKeys[selectedFilterIndex]; - Router.navigate({ to: `/store/tab/${newFilter === 'home' ? '' : newFilter}` }); - }, - button: GamePadButtonCode.R1 - }, - { - action: () => - { - const filterKeys = Object.keys(filters); - const filterIndex = Math.max(0, filterKeys.findIndex(f => filters[f as any].selected)); - const selectedFilterIndex = Math.max(0, filterIndex - 1,); - const newFilter = filterKeys[selectedFilterIndex]; - Router.navigate({ to: `/store/tab/${newFilter === 'home' ? '' : newFilter}` }); - }, - button: GamePadButtonCode.L1 - }], [filters]); - const { shortcuts } = useShortcutContext(); const { focus } = Route.useSearch(); @@ -102,31 +96,24 @@ function RouteComponent () { focusSelf(); } - }, []); const handleDetails = (type: string, source: string, id: string, focus: string) => { - if (type === 'emulator') { - SaveSource('store-details', { url: location.hash.replaceAll(/#|(\?.+)/g, ''), search: { focus } }); - Router.navigate({ to: '/store/details/emulator/$id', params: { id }, viewTransition: { types: ['zoom-in'] } }); + Router.navigate({ to: '/store/details/emulator/$id', params: { id } }); } else if (type === 'game') { - console.log(source, id); - SaveSource('details', { url: location.hash.replaceAll(/#|(\?.+)/g, ''), search: { focus } }); - Router.navigate({ to: '/game/$source/$id', params: { source: source, id: id }, viewTransition: { types: ['zoom-in'] } }); + Router.navigate({ to: '/game/$source/$id', params: { source: source, id: id } }); } }; - const match = Route.useMatch(); const goToSettings = () => { - SaveSource('settings', { url: match.pathname, search: { focus: "settings" } }); - Router.navigate({ to: '/settings', viewTransition: { types: ['zoom-in'] } }); + Router.navigate({ to: '/settings' }); }; const isMobile = mobileCheck(); @@ -141,7 +128,7 @@ function RouteComponent () , id: "settings", action: goToSettings, external: true }]} />
      - +
      diff --git a/src/mainview/scripts/brandIcons.tsx b/src/mainview/scripts/brandIcons.tsx index a013185..ef35534 100644 --- a/src/mainview/scripts/brandIcons.tsx +++ b/src/mainview/scripts/brandIcons.tsx @@ -1,4 +1,6 @@ export const TwitchIcon = Twitch -; \ No newline at end of file +; + +export const FlatpackIcon = Flathub; \ No newline at end of file diff --git a/src/mainview/scripts/contexts.ts b/src/mainview/scripts/contexts.ts index 41cf82d..27d5c4f 100644 --- a/src/mainview/scripts/contexts.ts +++ b/src/mainview/scripts/contexts.ts @@ -31,4 +31,8 @@ export const FilePickerContext = createContext<{ refetchFiles: () => void; drives: Drive[], activeDrive: Drive | undefined; +}>({} as any); + +export const GameDetailsContext = createContext<{ + update: () => void; }>({} as any); \ No newline at end of file diff --git a/src/mainview/scripts/queries.ts b/src/mainview/scripts/queries.ts deleted file mode 100644 index f45ce51..0000000 --- a/src/mainview/scripts/queries.ts +++ /dev/null @@ -1,11 +0,0 @@ -import system from "./queries/system"; -import settings from "./queries/settings"; -import romm from "./queries/romm"; -import store from "./queries/store"; - -export default { - system, - settings, - romm, - store -}; \ No newline at end of file diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts index 406fb03..79dbafa 100644 --- a/src/mainview/scripts/queries/romm.ts +++ b/src/mainview/scripts/queries/romm.ts @@ -4,76 +4,116 @@ import { mutationOptions, queryOptions } from "@tanstack/react-query"; import z from "zod"; import { getCollectionApiCollectionsIdGetOptions, getCollectionsApiCollectionsGetOptions, getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen"; -export default { - allGamesQuery: (filter?: GameListFilterType) => queryOptions({ - queryKey: ['games', filter ?? 'all'], - queryFn: async () => - { - const { data, error } = await rommApi.api.romm.games.get({ query: filter }); - if (error) throw error; - return data; - } - }), - gameQuery: (source: string, id: string) => queryOptions({ - queryKey: ['game', source, id], - queryFn: async () => - { - const { data, error } = await rommApi.api.romm.game({ source })({ id }).get(); - if (error) throw error; - return data; - }, - }), - rommLogoutMutation: mutationOptions({ mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post() }), - rommQrLoginMutation: mutationOptions({ - mutationKey: ['login', 'qr', 'cancel'], - mutationFn: () => rommApi.api.romm.login.romm.post() - }), - rommLoginMutation: mutationOptions({ - mutationKey: ["romm", "login"], - mutationFn: async (data: z.infer) => - { - const { error } = await rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname }); - if (error) throw error; - }, - onSuccess: (d, v, r, c) => - { - c.client.invalidateQueries({ queryKey: ['romm', 'auth'] }); - }, - onError: (e) => - { - console.error(e); - }, - }), - rommUserQuery: () => queryOptions({ - ...getCurrentUserApiUsersMeGetOptions(), - queryKey: ['romm', 'auth', "login"], - refetchOnWindowFocus: false, - retry: 0 - }), - rommGetOptionsQuery: () => queryOptions({ - ...statsApiStatsGetOptions(), - refetchInterval: 30000, - retry: false, - }), - rommHasPasswordQuery: queryOptions({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => rommApi.api.romm.login.get().then(d => d.data?.hasPassword as boolean) }), - rommHostnameQuery: queryOptions({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) }), - rommUsernameQuery: queryOptions({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) }), - deleteGameMutation: (id: FrontEndId) => mutationOptions({ - mutationKey: ['delete', id], - mutationFn: () => rommApi.api.romm.game({ source: id.source })({ id: id.id }).delete() - }), - getCollectionsQuery: () => queryOptions({ - ...getCollectionsApiCollectionsGetOptions(), - refetchOnWindowFocus: false, - staleTime: DefaultRommStaleTime - }), - getCollectionQuery: (id: number) => queryOptions({ ...getCollectionApiCollectionsIdGetOptions({ path: { id } }) }), - platformQuery: (source: string, id: string) => queryOptions({ - queryKey: ['platform', source, id], queryFn: async () => - { - const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get(); - if (error) throw error; - return data; - }, staleTime: DefaultRommStaleTime - }) -}; \ No newline at end of file +export const allGamesQuery = (filter?: GameListFilterType) => queryOptions({ + queryKey: ['games', filter ?? 'all'], + queryFn: async () => + { + const { data, error } = await rommApi.api.romm.games.get({ query: filter }); + if (error) throw error; + return data; + } +}); +export const gameQuery = (source: string, id: string) => queryOptions({ + queryKey: ['game', source, id], + queryFn: async () => + { + const { data, error } = await rommApi.api.romm.game({ source })({ id }).get(); + if (error) throw error; + return data; + }, +}); +export const rommLogoutMutation = mutationOptions({ mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post() }); +export const rommQrLoginMutation = mutationOptions({ + mutationKey: ['login', 'qr', 'cancel'], + mutationFn: () => rommApi.api.romm.login.romm.post() +}); +export const rommLoginMutation = mutationOptions({ + mutationKey: ["romm", "login"], + mutationFn: async (data: z.infer) => + { + const { error } = await rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname }); + if (error) throw error; + }, + onSuccess: (d, v, r, c) => + { + c.client.invalidateQueries({ queryKey: ['romm', 'auth'] }); + }, + onError: (e) => + { + console.error(e); + }, +}); +export const rommUserQuery = () => queryOptions({ + ...getCurrentUserApiUsersMeGetOptions(), + queryKey: ['romm', 'auth', "login"], + refetchOnWindowFocus: false, + retry: 0 +}); +export const rommGetOptionsQuery = () => queryOptions({ + ...statsApiStatsGetOptions(), + refetchInterval: 30000, + retry: false, +}); +export const rommHasPasswordQuery = queryOptions({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => rommApi.api.romm.login.get().then(d => d.data?.hasPassword as boolean) }); +export const rommHostnameQuery = queryOptions({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) }); +export const rommUsernameQuery = queryOptions({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) }); +export const deleteGameMutation = (id: FrontEndId) => mutationOptions({ + mutationKey: ['delete', id], + mutationFn: () => rommApi.api.romm.game({ source: id.source })({ id: id.id }).delete() +}); +export const getCollectionsQuery = () => queryOptions({ + ...getCollectionsApiCollectionsGetOptions(), + refetchOnWindowFocus: false, + staleTime: DefaultRommStaleTime +}); +export const getCollectionQuery = (id: number) => queryOptions({ ...getCollectionApiCollectionsIdGetOptions({ path: { id } }) }); +export const platformQuery = (source: string, id: string) => queryOptions({ + queryKey: ['platform', source, id], queryFn: async () => + { + const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get(); + if (error) throw error; + return data; + }, staleTime: DefaultRommStaleTime +}); +export const installMutation = (source: string, id: string) => mutationOptions({ + mutationKey: ['install', source, id], + mutationFn: async () => + { + const { error } = await rommApi.api.romm.game({ source })({ id }).install.post(); + if (error) throw error; + } +}); +export const cancelInstallMutation = (source: string, id: string) => mutationOptions({ + mutationKey: ['install', 'cancel', source, id], + mutationFn: async () => + { + const { error } = await rommApi.api.romm.game({ source })({ id }).install.delete(); + if (error) throw error; + } +}); +export const playMutation = mutationOptions({ + mutationKey: ['play'], + mutationFn: async (data: { source: string, id: string; command_id?: string | number; }) => + { + const { error } = await rommApi.api.romm.game({ source: data.source })({ id: data.id }).play.post({ command_id: data.command_id }); + if (error) + throw error; + } +}); +export const gamesRecommendedBasedOnEmulatorQuery = (id: string) => queryOptions({ + queryKey: ['games', 'recommended', 'emulator', id], queryFn: async () => + { + const { data, error } = await rommApi.api.romm.recommended.games.emulator({ id }).get(); + if (error) throw error; + return data; + } +}); +export const gamesRecommendedBasedOnGameQuery = (source: string, id: string) => queryOptions({ + queryKey: ['games', 'recommended', 'game', source, id], + queryFn: async () => + { + const { data, error } = await rommApi.api.romm.recommended.games.game({ source })({ id }).get(); + if (error) throw error; + return data; + } +}); \ No newline at end of file diff --git a/src/mainview/scripts/queries/settings.ts b/src/mainview/scripts/queries/settings.ts index 7fa9c6a..a5f9bf7 100644 --- a/src/mainview/scripts/queries/settings.ts +++ b/src/mainview/scripts/queries/settings.ts @@ -3,132 +3,130 @@ import { getErrorMessage } from "react-error-boundary"; import toast from "react-hot-toast"; import { rommApi, settingsApi } from "../clientApi"; -export default { - changeDownloadsMutation: mutationOptions({ - mutationKey: ["setting", "downloads"], - mutationFn: async (value: any) => +export const changeDownloadsMutation = mutationOptions({ + mutationKey: ["setting", "downloads"], + mutationFn: async (value: any) => + { + const response = await toast.promise(settingsApi.api.settings.path.download.put({ manualPath: value }).then(d => { - const response = await toast.promise(settingsApi.api.settings.path.download.put({ manualPath: value }).then(d => - { - if (d.error) throw d.error; - return d.data; - }), { - success: e => `Download Moved to ${e}`, - loading: "Moving Download", - error: e => getErrorMessage(e) ?? "Error Moving Download" - }); + if (d.error) throw d.error; + return d.data; + }), { + success: e => `Download Moved to ${e}`, + loading: "Moving Download", + error: e => getErrorMessage(e) ?? "Error Moving Download" + }); - return response; + return response; + } +}); +export const autoEmulatorsQuery = queryOptions({ + queryKey: ['auto-emulators'], queryFn: async () => + { + const { data, error } = await settingsApi.api.settings.emulators.automatic.get(); + if (error) throw error; + return data; + } +}); +export const twitchLogoutMutation = mutationOptions({ + mutationKey: ['twitch', 'logout'], + mutationFn: () => + { + return rommApi.api.romm.logout.twitch.post(); + } +}); +export const twitchLoginMutation = mutationOptions({ + mutationKey: ['twitch', 'login'], + mutationFn: (openInBrowser: boolean) => + { + return rommApi.api.romm.login.twitch.post({ openInBrowser }); + } +}); +export const twitchLoginVerificationQuery = queryOptions({ + queryKey: ['twitch', 'login', 'status'], + retry (failureCount, error) + { + if ((error as any).status === 404) + { + return false; } - }), - autoEmulatorsQuery: queryOptions({ - queryKey: ['auto-emulators'], queryFn: async () => - { - const { data, error } = await settingsApi.api.settings.emulators.automatic.get(); - if (error) throw error; - return data; - } - }), - twitchLogoutMutation: mutationOptions({ - mutationKey: ['twitch', 'logout'], - mutationFn: () => - { - return rommApi.api.romm.logout.twitch.post(); - } - }), - twitchLoginMutation: mutationOptions({ - mutationKey: ['twitch', 'login'], - mutationFn: (openInBrowser: boolean) => - { - return rommApi.api.romm.login.twitch.post({ openInBrowser }); - } - }), - twitchLoginVerificationQuery: queryOptions({ - queryKey: ['twitch', 'login', 'status'], - retry (failureCount, error) - { - if ((error as any).status === 404) - { - return false; - } - return failureCount < 3; - }, - queryFn: async () => - { - const { data, error, status } = await rommApi.api.romm.login.twitch.get(); - if (error) throw { ...error, status }; - return data; - } - }), - customEmulatorsQuery: queryOptions({ - queryKey: ['custom-emulators'], queryFn: async () => - { - const { data, error } = await settingsApi.api.settings.emulators.custom.get(); - if (error) throw error; - return data; - } - }), - customEmulatorAddMutation: mutationOptions({ - mutationKey: ['emulator', 'custom', 'add'], - mutationFn: async (id: string) => - { - const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' }); - if (error) throw error; - return data; - }, - onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] }) - }), - customEmulatorDeleteMutation: (id: string) => mutationOptions({ - mutationKey: ["emulator", id, 'delete'], - mutationFn: async () => - { - const { error } = await settingsApi.api.settings.emulators.custom({ id: id }).delete(); - if (error) throw error; - }, - onSuccess: (d, v, r, ctx) => - { - ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] }); - ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] }); - } - }), - setCustomEmulatorMutation: (id: string, onSuccess?: (value: string) => void) => mutationOptions({ - mutationKey: ["emulator", id, 'set'], - mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: id }).put({ value }), - onSuccess: (d, v, r, ctx) => - { - ctx.client.invalidateQueries({ queryKey: ["emulator", id] }); - ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] }); - onSuccess?.(v); - } - }), - customEmulatorRemoveValueQuery: (id?: string) => queryOptions({ - enabled: !!id, - queryKey: ["emulator", id], - queryFn: async () => - { - const { data: value, error } = await settingsApi.api.settings.emulators.custom({ id: id! }).get(); - if (error) throw error; - return value; - }, - }), - setSettingMutation: (id?: string) => mutationOptions({ - mutationKey: ["setting", id], - mutationFn: async (value: any) => - { - const response = await settingsApi.api.settings({ id: id! }).post({ value }); - if (response.error) throw response.error; - return response.data; - } - }), - getSettingQuery: (id: string | undefined) => queryOptions({ - enabled: !!id, - queryKey: ["setting", id], - queryFn: async () => - { - const { data: value, error } = await settingsApi.api.settings({ id: id! }).get(); - if (error) throw error; + return failureCount < 3; + }, + queryFn: async () => + { + const { data, error, status } = await rommApi.api.romm.login.twitch.get(); + if (error) throw { ...error, status }; + return data; + } +}); +export const customEmulatorsQuery = queryOptions({ + queryKey: ['custom-emulators'], queryFn: async () => + { + const { data, error } = await settingsApi.api.settings.emulators.custom.get(); + if (error) throw error; + return data; + } +}); +export const customEmulatorAddMutation = mutationOptions({ + mutationKey: ['emulator', 'custom', 'add'], + mutationFn: async (id: string) => + { + const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' }); + if (error) throw error; + return data; + }, + onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] }) +}); +export const customEmulatorDeleteMutation = (id: string) => mutationOptions({ + mutationKey: ["emulator", id, 'delete'], + mutationFn: async () => + { + const { error } = await settingsApi.api.settings.emulators.custom({ id: id }).delete(); + if (error) throw error; + }, + onSuccess: (d, v, r, ctx) => + { + ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] }); + ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] }); + } +}); +export const setCustomEmulatorMutation = (id: string, onSuccess?: (value: string) => void) => mutationOptions({ + mutationKey: ["emulator", id, 'set'], + mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: id }).put({ value }), + onSuccess: (d, v, r, ctx) => + { + ctx.client.invalidateQueries({ queryKey: ["emulator", id] }); + ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] }); + onSuccess?.(v); + } +}); +export const customEmulatorRemoveValueQuery = (id?: string) => queryOptions({ + enabled: !!id, + queryKey: ["emulator", id], + queryFn: async () => + { + const { data: value, error } = await settingsApi.api.settings.emulators.custom({ id: id! }).get(); + if (error) throw error; + return value; + }, +}); +export const setSettingMutation = (id?: string) => mutationOptions({ + mutationKey: ["setting", id], + mutationFn: async (value: any) => + { + const response = await settingsApi.api.settings({ id: id! }).post({ value }); + if (response.error) throw response.error; + return response.data; + } +}); +export const getSettingQuery = (id: string | undefined) => queryOptions({ + enabled: !!id, + queryKey: ["setting", id], + queryFn: async () => + { + const { data: value, error } = await settingsApi.api.settings({ id: id! }).get(); + if (error) throw error; - return value.value; - }, - }) -}; \ No newline at end of file + return value.value; + }, +}); \ No newline at end of file diff --git a/src/mainview/scripts/queries/store.ts b/src/mainview/scripts/queries/store.ts index 8adde23..53a9eaf 100644 --- a/src/mainview/scripts/queries/store.ts +++ b/src/mainview/scripts/queries/store.ts @@ -1,58 +1,74 @@ -import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; +import { infiniteQueryOptions, mutationOptions, queryOptions } from "@tanstack/react-query"; import { rommApi, storeApi } from "../clientApi"; import { FrontEndGameType } from "@/shared/constants"; -export default { - storeEmulatorsQuery: queryOptions({ - queryKey: ['store-emulators'], queryFn: async () => - { - const { data, error } = await storeApi.api.store.emulators.get(); - if (error) throw error; - return data; - } - }), - storeFeaturedGamesQuery: queryOptions({ - queryKey: ['store-emulators', 'featured'], queryFn: async () => - { - const { data, error } = await storeApi.api.store.games.featured.get(); - if (error) throw error; - return data; - } - }), - storeEmulatorsRecommendedQuery: queryOptions({ - queryKey: ['store-emulators', 'recommended'], queryFn: async () => - { - const { data, error } = await storeApi.api.store.emulators.get({ query: { limit: 6, missing: true, orderBy: 'importance' } }); - if (error) throw error; - return data; - } - }), - storeEmulatorDetailsQuery: (id: string) => queryOptions({ - queryKey: ['store-emulator', id], queryFn: async () => - { - const { data, error } = await storeApi.api.store.details.emulator({ id }).get(); - if (error) throw error; - return data; - } - }), - storeGamesInfiniteQuery: infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({ - initialPageParam: 0, - queryKey: ['store-games'], - getNextPageParam: (lastPage, pages) => lastPage.nextPage, - queryFn: async (data) => - { - const pageParam = data.pageParam as number; - const { data: games, error } = await rommApi.api.romm.games.get({ query: { source: 'store', offset: pageParam * 10, limit: 10 } }); - if (error) throw error; - return { data: games.games, nextPage: pageParam + 1 }; - } - }), - storeGetStatsQuery: queryOptions({ - queryKey: ['store', 'stats'], queryFn: async () => - { - const { data, error } = await storeApi.api.store.stats.get(); - if (error) throw error; - return data; - } - }) -}; \ No newline at end of file + +export const storeEmulatorsQuery = queryOptions({ + queryKey: ['store-emulators'], queryFn: async () => + { + const { data, error } = await storeApi.api.store.emulators.get(); + if (error) throw error; + return data; + } +}); +export const storeFeaturedGamesQuery = queryOptions({ + queryKey: ['store-emulators', 'featured'], queryFn: async () => + { + const { data, error } = await storeApi.api.store.games.featured.get(); + if (error) throw error; + return data; + } +}); +export const storeEmulatorsRecommendedQuery = queryOptions({ + queryKey: ['store-emulators', 'recommended'], queryFn: async () => + { + const { data, error } = await storeApi.api.store.emulators.get({ query: { limit: 6, missing: true, orderBy: 'importance' } }); + if (error) throw error; + return data; + } +}); +export const storeEmulatorDetailsQuery = (id: string) => queryOptions({ + queryKey: ['store-emulator', id], queryFn: async () => + { + const { data, error } = await storeApi.api.store.emulator({ id }).get(); + if (error) throw error; + return data; + } +}); +export const storeEmulatorDeleteMutation = mutationOptions({ + mutationKey: ['store-emulator', 'delete'], + mutationFn: async (id: string) => + { + const { error } = await storeApi.api.store.emulator({ id }).delete(); + if (error) throw error; + } +}); +export const storeGamesInfiniteQuery = infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({ + initialPageParam: 0, + queryKey: ['store-games'], + getNextPageParam: (lastPage, pages) => lastPage.nextPage, + queryFn: async (data) => + { + const pageParam = data.pageParam as number; + const { data: games, error } = await rommApi.api.romm.games.get({ query: { source: 'store', offset: pageParam * 10, limit: 10 } }); + if (error) throw error; + return { data: games.games, nextPage: pageParam + 1 }; + } +}); +export const storeGetStatsQuery = queryOptions({ + queryKey: ['store', 'stats'], queryFn: async () => + { + const { data, error } = await storeApi.api.store.stats.get(); + if (error) throw error; + return data; + } +}); +export const installEmulatorMutation = (id: string) => mutationOptions({ + mutationKey: ['install', 'emulator', id], + mutationFn: async (source: string) => + { + const { data, error } = await storeApi.api.store.install.emulator({ id })({ source }).post(); + if (error) throw error; + return data; + } +}); \ No newline at end of file diff --git a/src/mainview/scripts/queries/system.ts b/src/mainview/scripts/queries/system.ts index 8ac3224..853e3a9 100644 --- a/src/mainview/scripts/queries/system.ts +++ b/src/mainview/scripts/queries/system.ts @@ -1,51 +1,49 @@ import { keepPreviousData, mutationOptions, queryOptions } from "@tanstack/react-query"; import { systemApi } from "../clientApi"; -export default { - drivesQuery: queryOptions({ - queryKey: ['drives'], - queryFn: async () => - { - const { data, error } = await systemApi.api.system.drives.get(); - if (error) throw error; - return data; - } - }), - downloadDrivesQuery: queryOptions({ - queryKey: ['drives', 'download'], - queryFn: async () => - { - const { data, error } = await systemApi.api.system.drives.download.get(); - if (error) throw error; - return data; - } - }), - filesQuery: (currentPath: string | undefined, id: string) => queryOptions({ - queryKey: ['files', currentPath ?? '', id], - queryFn: async () => - { - const { data, error } = await systemApi.api.system.dirs.get({ query: { path: currentPath } }); - if (error) throw error; - return data; - }, - placeholderData: keepPreviousData - }), - systemInfoQuery: queryOptions({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() }), - createFolderMutation: (id: string) => mutationOptions({ +export const drivesQuery = queryOptions({ + queryKey: ['drives'], + queryFn: async () => + { + const { data, error } = await systemApi.api.system.drives.get(); + if (error) throw error; + return data; + } +}); +export const downloadDrivesQuery = queryOptions({ + queryKey: ['drives', 'download'], + queryFn: async () => + { + const { data, error } = await systemApi.api.system.drives.download.get(); + if (error) throw error; + return data; + } +}); +export const filesQuery = (currentPath: string | undefined, id: string) => queryOptions({ + queryKey: ['files', currentPath ?? '', id], + queryFn: async () => + { + const { data, error } = await systemApi.api.system.dirs.get({ query: { path: currentPath } }); + if (error) throw error; + return data; + }, + placeholderData: keepPreviousData +}); +export const systemInfoQuery = queryOptions({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() }); +export const createFolderMutation = (id: string) => mutationOptions({ - mutationKey: ['create', 'folder', id], - mutationFn: async ({ name, dirname }: { name: string | undefined, dirname: string; }) => - { - if (!name) return; - const { error } = await systemApi.api.system.dirs.put({ name, dirname: dirname }); - if (error) throw error.value; - }, - }), - closeMutation: mutationOptions({ - mutationKey: ['close'], mutationFn: async () => - { - const { error } = await systemApi.api.system.exit.post(); - if (error) throw error; - } - }) -}; \ No newline at end of file + mutationKey: ['create', 'folder', id], + mutationFn: async ({ name, dirname }: { name: string | undefined, dirname: string; }) => + { + if (!name) return; + const { error } = await systemApi.api.system.dirs.put({ name, dirname: dirname }); + if (error) throw error.value; + }, +}); +export const closeMutation = mutationOptions({ + mutationKey: ['close'], mutationFn: async () => + { + const { error } = await systemApi.api.system.exit.post(); + if (error) throw error; + } +}); \ No newline at end of file diff --git a/src/mainview/scripts/spatialNavigation.ts b/src/mainview/scripts/spatialNavigation.ts index e4a795c..9418647 100644 --- a/src/mainview/scripts/spatialNavigation.ts +++ b/src/mainview/scripts/spatialNavigation.ts @@ -9,8 +9,6 @@ import UseFocusableResult, } from "@noriginmedia/norigin-spatial-navigation"; import { RefObject, useEffect, useState } from "react"; -import { Router } from ".."; -import { RouteIds } from "@tanstack/react-router"; init({ shouldFocusDOMNode: false, @@ -22,43 +20,10 @@ let updateFocusable = SpatialNavigation.updateFocusable.bind(SpatialNavigation); let sortSiblingsByPriority = SpatialNavigation.sortSiblingsByPriority.bind(SpatialNavigation); let removeFocusable = SpatialNavigation.removeFocusable.bind(SpatialNavigation); let setFocus = SpatialNavigation.setFocus.bind(SpatialNavigation); +let setCurrentFocusedKey = SpatialNavigation.setCurrentFocusedKey.bind(SpatialNavigation); type SaveFocusType = "session" | "local"; -type HistorySourceType = "settings" | 'details' | 'launch' | 'game-list' | 'store-details'; -const historySourceMap = new Map; }>(); - -export function SaveSource (id: HistorySourceType, init?: { url?: string, search?: Record; }) -{ - let finalUrl = init?.url ?? location.hash.replaceAll(/#|(\?.+)/g, ''); - if (finalUrl) - { - historySourceMap.set(id, { to: finalUrl, search: init?.search }); - } -} - -export function HasSource (id: HistorySourceType) -{ - return historySourceMap.has(id); -} - -export function PopSource (id: HistorySourceType) -{ - if (!historySourceMap.has(id)) - { - return { to: undefined, search: undefined }; - } - const source = historySourceMap.get(id); - historySourceMap.delete(id); - return source ?? { to: undefined, search: undefined }; -} - -export function PopNavigateSource (id: HistorySourceType, fallback: RouteIds) -{ - const { to, search } = PopSource(id); - Router.navigate({ to: to ?? fallback, viewTransition: { types: ['zoom-out'] }, search }); -} - export function GetFocusedElement (focusKey: string) { return (SpatialNavigation as any).focusableComponents[focusKey]?.node as HTMLElement | undefined; @@ -128,6 +93,11 @@ SpatialNavigation.setFocus = (newFocusKey, focusDetails) => dispatchFocusedEvent(new CustomEvent('focuschanged', { bubbles: true, detail: focusDetails })); }; +SpatialNavigation.setCurrentFocusedKey = (newFocusKey, focusDetails) => +{ + setCurrentFocusedKey(newFocusKey, focusDetails); + window.dispatchEvent(new CustomEvent('focuschanged', { bubbles: true, detail: focusDetails })); +}; SpatialNavigation.updateFocusable = (key, data) => { diff --git a/src/mainview/scripts/types.ts b/src/mainview/scripts/types.ts index 33afeed..72fe518 100644 --- a/src/mainview/scripts/types.ts +++ b/src/mainview/scripts/types.ts @@ -1,3 +1,5 @@ +import { FrontEndId } from "@/shared/constants"; + export const FOCUS_KEYS = { NAV_CATEGORIES: "NAV_CATEGORIES", NAV_CATEGORY: (cat: string) => `NAV_CAT_${cat}`, @@ -6,6 +8,6 @@ export const FOCUS_KEYS = { EMULATOR_SECTION: (id: string) => `EMULATOR_SECTION_${id}`, EMULATOR_CARD: (id: string) => `EMULATOR_${id}`, GAME_SECTION: "GAME_SECTION", - GAME_CARD: (id: string) => `GAME_${id}`, + GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`, STATS_SECTION: "STATS_SECTION", } as const; \ No newline at end of file diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index 05f6d28..08c721d 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -1,9 +1,11 @@ import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants"; -import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; +import { doesFocusableExist, FocusableComponentLayout, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; import { RefObject, useEffect, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; import { jobsApi } from "./clientApi"; import { JobsAPIType } from "@/bun/api/rpc"; +import { Router } from ".."; +import data from "@emulators"; export function selfFocusSmart (shouldFocus: boolean, focusSelf: () => void) { @@ -224,10 +226,14 @@ export function useDragScroll (ref: RefObject) export function scrollIntoViewHandler (params?: ScrollIntoViewOptions) { - return (focusKey: string, node: HTMLElement, details: any) => node.scrollIntoView({ ...params, behavior: details.instant ? 'instant' : 'smooth' }); + return (focusKey: string, node: HTMLElement, details: any) => + { + if (details.nativeEvent instanceof PointerEvent) return; + node.scrollIntoView({ ...params, behavior: details.instant ? 'instant' : 'smooth' }); + }; } -export function useStickyDataAttr (ref: RefObject, sentinelRef: RefObject, scrollRef: RefObject) +export function useStickyDataAttr (ref: RefObject, sentinelRef: RefObject, scrollRef: RefObject, callback?: (stuck: boolean) => void) { useEffect(() => { @@ -239,6 +245,7 @@ export function useStickyDataAttr { el.toggleAttribute("data-stuck", !entry.isIntersecting); + callback?.(!entry.isIntersecting); }, { root: scrollRef.current ?? null, @@ -249,7 +256,7 @@ export function useStickyDataAttr observer.disconnect(); - }, [scrollRef.current]); + }, [scrollRef.current, callback]); } type ExtractField = @@ -261,18 +268,19 @@ type JobResponse = export function useJobStatus ( id: JOB, init?: { - onProgress?: (process: number) => void, - onEnded?: () => void; + onProgress?: (process: number, data: ExtractField, "data" | "started" | "progress", 'data'>) => void, + onEnded?: (data: ExtractField, "completed" | "ended", 'data'>) => void; + onError?: (error: string) => void; } ) { type Response = JobResponse; - type DataPayload = ExtractField; + type DataPayload = ExtractField; const ref = useRef>(null); const [data, setData] = useState(); const [status, setStatus] = useState(); - const [error, setError] = useState(); + const [error, setError] = useState(); useEffect(() => { @@ -287,9 +295,13 @@ export function useJobStatus; export type StoreGameType = z.infer; - -export interface FrontEndEmulator extends Omit +export interface EmulatorSourceType { + binPath: string; + rootPath?: string; + type: string; + exists: boolean; +} + +export interface FrontEndEmulator +{ + name: string; + logo: string; systems: { id: string, name: string, icon: string; }[]; gameCount: number; - exists: boolean; + validSource?: EmulatorSourceType; +} + +export interface FrontEndEmulatorDetailedDownload +{ + name: string; + type: string | undefined; } export interface FrontEndEmulatorDetailed extends FrontEndEmulator { + homepage: string; + description: string; + downloads: FrontEndEmulatorDetailedDownload[]; + keywords?: string[]; screenshots: string[]; - status: { - source?: string; - location?: string; - }; + sources: EmulatorSourceType[]; +} + +export interface FrontEndGameTypeDetailedAchievement +{ + id: string; + title: string; + description?: string; + date?: Date; + date_hardcode?: Date; + badge_url?: string; + display_order: number; + type?: string; +} + +export interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator +{ + } export interface FrontEndGameTypeDetailed extends FrontEndGameType @@ -157,9 +204,14 @@ export interface FrontEndGameTypeDetailed extends FrontEndGameType fs_size_bytes: number | null; missing: boolean; local: boolean; + genres?: string[]; + companies?: string[]; + release_date?: Date; + emulators?: FrontEndGameTypeDetailedEmulator[], achievements?: { unlocked: number; total: number; + entires: FrontEndGameTypeDetailedAchievement[]; }; }; @@ -195,6 +247,7 @@ export interface Notification title?: string; message: string; type: 'success' | 'error' | 'info'; + duration?: number; } export interface CommandEntry @@ -202,10 +255,15 @@ export interface CommandEntry id: string | number; label?: string; command: string; + startDir?: string; valid: boolean; emulator?: string; } + + +export type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted'; + export type SettingsType = z.infer; export type LocalSettingsType = z.infer; export interface GameInstallProgress @@ -229,4 +287,4 @@ export const GameflowPluginSchema = z.object({ launchGame: z.function({ input: [GameLaunchSchema] }) }); export interface GameflowPlugin extends z.infer { } -export type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing'; \ No newline at end of file +export type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing' | 'queued'; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index c39bbcd..49e40c5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,6 +34,9 @@ "@schema/*": [ "./src/bun/api/schema/*" ], + "@queries/*": [ + "./src/mainview/scripts/queries/*" + ] } }, "include": [ diff --git a/vendors/es-de/emulators.darwin.x64.sqlite b/vendors/es-de/emulators.darwin.x64.sqlite index 75fba2d3067d67ba606e6b495fd1ee412447fda4..b669d1970556cd03ae1c00c4bc1efc99fe9388f9 100644 GIT binary patch delta 15024 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 HoR$ACm$mu9 diff --git a/vendors/es-de/emulators.haiku.x64.sqlite b/vendors/es-de/emulators.haiku.x64.sqlite index 8814f19d1d2c4fcf25006edd8ee7644d21acdcf9..556e0a8d20418835ea733a17e65d6e5876bfce18 100644 GIT binary patch delta 6854 zcmeHMi+fbnwV!n!GiTljAtaOMBnTvskW41YOacS~Bq8KA$(T1G%qz*jWF}wO4DAwpQ)6RBEY}LhU}m*Jt_<-0!=SWS!Z2 zul+jvx7PZtot=lIork17i$I#nDM>cms?tdzm6K_SpP>XJMeEB;fJ+Y(ATx)!5(wKl~?84TvgY6rCmZ;y|Fl;qA#UDzXK9j$(U> zJy7T@@pyae1zvB7t;k^y_=@u#4o_hL`}52$=qYxVczb#Z^Lw1W5;mF?OCGiAV;VBd z|1kf}eBS)O=J(BK%>QG4(|pqKV?&d{uD@6J2VI-?AKE*#LFpZ7os_LPqj_BYTXmVZ zQ8*?qW1LcA(3Fc9?3=(yHO3;tRI>2&*F?Obyj{g2O@d6JPzAO74zU4OqGQs_H> ze-n~f`5s7O-z_wB@d<8$g{ED~f<#(%sTvaK)TILb9MO_T8vLGtzA!yq?4|ja;~<`{ zzPyXepG}v(WP>;weyJN~(NiyVK`hPr*)EeRFccXH z%vflfk^bqmb}-PY6K$ZUhfcV8BI)Q)3%}20Xz72KWP(I5lvue&4b5261Zq0Oq)1<0 z;sAkuv7`}r+PKsS9KC+2vs0x~S>Z!%8A)V zCS!CDsLU^#x0`cKzch`R5{*ZV-!s|_rwrQ-G5R0tJM>1~1BYW{WL>N$MoNxtjg5_M z?Wk~&2@9=Ewu?a@I#1C3iJ<$JVTK|Yez-YGuYf#W0sN^6)|-?eXKS=)g1|W z28W~8US=%TK)5#;4!BHG^4zLvjdQg{sczqJe}7Bew>7**$r9^k8r?Lf&SrNjKW>;rso;MiI`?Ap0$$l_K50=Dl#^`72 z9^IF_Xr}Mf6)paHuGMaKA3z4Tpw;xzX|H zP@w-C-MWB3?CA-Fmq=eOu87vvr)QQA`aJ$`ZMd$yuA*5Z#mCmL@fs_e-4)Zva#MZm z^syv-{bnkMqk;8m$zor|6l>@Y>mAvvBK`dle`GLVE$<_@!Ddc!nfT4<7t?;AUe;{W^C{}cYT zkxK;XS(ezMoaO=*BMA=ELDNp-5o3YjxS>S_NJjG*tmUdJ@r_Ja3l*3$7usMoj?9G){VFj*8Qe3t$v!={!M9RO zU>&Dx_KiV17cIy8uYw_1foby~3zp-WdEkaJJis2(G9kWvQ@^L5W)!d0w=5OoS-57! zz;v|Ux&#l-hiy=bsSBW+ODe&Vg^;|=DOgl_q2ZyyV01=QOm!$hGZYye3`E0YGn&1{ zxOXA=p$IQ7glnM?Cl|r9X1kcqLN((NfkA&T>Z6U9JuF)C3!v_A=r+yoj zyP*NL;>~V|z!v1MhD|Vz16PB`I3}bzd;`8K5f;xMfEDJ@sF<2ZvcImp%gs)rZv>~B zAqc~GshI(12)kNfk#0>?hO4(l9(6NN!y%ir^0vn+PElb(Z&Dgh;S
    + https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/3nhuKCK6E3.jpg + + + + https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/GL7SkQbHIY.png + + + + https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/xNj7scPEDQ.png + + + + https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/CpBLzTNM6N.png + + + +{{{RELEASES}}} + + + + {{APP_ID}}.desktop + gameflow + + \ No newline at end of file diff --git a/.config/appimage/com.simeonradivoev.gameflow-deck.desktop b/.config/appimage/com.simeonradivoev.gameflow-deck.desktop new file mode 100644 index 0000000..dddf26d --- /dev/null +++ b/.config/appimage/com.simeonradivoev.gameflow-deck.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +X-AppImage-Name={{APP_NAME}} +X-AppImage-Version={{VERSION}} +X-AppImage-Arch={{ARCH}} +Name={{APP_NAME}} +Comment={{DESCRIPTION}} +Exec=gameflow +Icon=gameflow +Type=Application +Categories=Game; \ No newline at end of file diff --git a/.config/flatpak/com.simeonradivoev.gameflow-deck.desktop b/.config/flatpak/com.simeonradivoev.gameflow-deck.desktop index bed0068..b5e2806 100644 --- a/.config/flatpak/com.simeonradivoev.gameflow-deck.desktop +++ b/.config/flatpak/com.simeonradivoev.gameflow-deck.desktop @@ -4,4 +4,4 @@ Comment=GameFlow Deck Exec=gameflow Icon=com.simeonradivoev.gameflow-deck Type=Application -Categories=Game; \ No newline at end of file +Categories=Games; \ No newline at end of file diff --git a/.config/flatpak/com.simeonradivoev.gameflow-deck.json b/.config/flatpak/com.simeonradivoev.gameflow-deck.json index ccdc833..ebe1efa 100644 --- a/.config/flatpak/com.simeonradivoev.gameflow-deck.json +++ b/.config/flatpak/com.simeonradivoev.gameflow-deck.json @@ -1,23 +1,36 @@ { "app-id": "com.simeonradivoev.gameflow-deck", - "runtime": "org.kde.Platform", - "runtime-version": "6.10", - "sdk": "org.kde.Sdk", + "runtime": "org.freedesktop.Platform", + "runtime-version": "25.08", + "sdk": "org.freedesktop.Sdk", "command": "/app/bin/gameflow", - "base": "io.qt.qtwebengine.BaseApp", - "base-version": "6.10", "finish-args": [ "--share=ipc", "--share=network", "--socket=pulseaudio", "--socket=wayland", + "--socket=inherit-wayland-socket", "--socket=x11", + "--socket=fallback-x11", + "--socket=session-bus", + "--socket=system-bus", "--device=all", "--filesystem=host", "--filesystem=home", + "--filesystem=~/.steam/steam:rw", + "--filesystem=~/.steam:rw", + "--filesystem=~/.var/app/com.valvesoftware.Steam:rw", + "--filesystem=/run/udev:ro", + "--filesystem=/run/media", + "--filesystem=xdg-documents", + "--filesystem=xdg-desktop", + "--filesystem=xdg-run/gamescope-0:rw", "--env=PKG_CONFIG_LIBDIR=/app/lib", "--env=FLATPAK_BUILD=true", - "--allow=devel" + "--allow=devel", + "--talk-name=org.freedesktop.portal.OpenURI", + "--talk-name=org.freedesktop.Flatpak", + "--talk-name=org.a11y.Bus" ], "modules": [ { @@ -29,7 +42,6 @@ "mkdir -p /app/lib", "install -Dm644 256x256.png /app/share/icons/hicolor/256x256/apps/com.simeonradivoev.gameflow-deck.png", "install -Dm644 com.simeonradivoev.gameflow-deck.desktop /app/share/applications/com.simeonradivoev.gameflow-deck.desktop", - "mv libvips-cpp.so.* /app/lib", "mv * /app/share/gameflow/", "mv /app/share/gameflow/gameflow /app/bin", "mv /app/share/gameflow/bun /app/bin", @@ -39,15 +51,15 @@ "sources": [ { "type": "dir", - "path": "../build/linux" + "path": "../../build/linux" }, { "type": "file", - "path": "../flatpak/com.simeonradivoev.gameflow-deck.desktop" + "path": "com.simeonradivoev.gameflow-deck.desktop" }, { "type": "file", - "path": "../src/mainview/public/256x256.png" + "path": "../../src/mainview/public/256x256.png" }, { "type": "script", @@ -72,23 +84,22 @@ "only-arches": [ "aarch64" ] - }, - { - "type": "file", - "path": "../node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.8.17.3", - "only-arches": [ - "x86_64" - ] } ] }, { - "name": "webview", - "buildsystem": "cmake-ninja", + "name": "NW.js", + "buildsystem": "simple", + "build-commands": [ + "mkdir -p /app/bin/nw", + "mv * /app/bin/nw", + "chmod +x /app/bin/nw/nw" + ], "sources": [ { - "type": "dir", - "path": "../flatpak/webview" + "type": "archive", + "url": "https://dl.nwjs.io/v0.110.1/nwjs-v0.110.1-linux-x64.tar.gz", + "sha256": "d9a9ed2255e9ee87c9dd1860d9c7a479cea5279dcd80d3e80e23b083d325554a" } ] } diff --git a/.config/flatpak/webview/CMakeLists.txt b/.config/flatpak/webview/CMakeLists.txt deleted file mode 100644 index 511252a..0000000 --- a/.config/flatpak/webview/CMakeLists.txt +++ /dev/null @@ -1,35 +0,0 @@ -cmake_minimum_required(VERSION 3.16) - -project(SimpleWebView LANGUAGES CXX) - -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) - -# Required for Qt WebEngine -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTORCC ON) -set(CMAKE_AUTOUIC ON) - -find_package(Qt6 REQUIRED COMPONENTS - Core - Widgets - WebEngineWidgets, - Gamepad -) - -add_executable(webview - main.cpp -) - -target_link_libraries(webview PRIVATE - Qt6::Core - Qt6::Widgets - Qt6::WebEngineWidgets, - Qt6::Gamepad -) - - -# Install binary into Flatpak prefix (/app) -install(TARGETS webview - RUNTIME DESTINATION bin -) \ No newline at end of file diff --git a/.config/flatpak/webview/main.cpp b/.config/flatpak/webview/main.cpp deleted file mode 100644 index d0982e7..0000000 --- a/.config/flatpak/webview/main.cpp +++ /dev/null @@ -1,14 +0,0 @@ -#include -#include -#include -#include - -int main(int argc, char *argv[]) -{ - QApplication app(argc, argv); - if (argc < 2) { return 1; } - QWebEngineView view; - view.setUrl(QUrl(argv[1])); - view.showFullScreen(); - return app.exec(); -} \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 84ec3e5..e6ff5ea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -83,7 +83,7 @@ jobs: with: type: "zip" directory: ${{ github.workspace }} - filename: "Gameflow-Windows.zip" + filename: "Gameflow-win32-x64.zip" path: "canary-build-Windows" - name: Publish Release @@ -96,4 +96,4 @@ jobs: omitBodyDuringUpdate: true replacesArtifacts: true token: ${{ secrets.GITHUB_TOKEN }} - artifacts: "${{ github.workspace }}/canary-build-*/*.AppImage,${{ github.workspace }}/Gameflow-Windows.zip" + artifacts: "${{ github.workspace }}/canary-build-*/*.AppImage,${{ github.workspace }}/Gameflow-*.zip" diff --git a/.gitignore b/.gitignore index a89abd1..f21beae 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ gameflow-deck.code-workspace .env.local src/tests/mock-roms/db.sqlite src/tests/mock-config -bin \ No newline at end of file +bin +.config/flatpak/repo \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 2c6da05..b63a222 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,17 +6,22 @@ "files.watcherExclude": { "**/*.gen.*": true, "src/mainview/gen/*": true, + "**/build": true, + "**/.config/flatpack/repo/**": true, + "**/.flatpak-builder/**": true, }, "search.exclude": { "**/*.gen.*": true, - ".flatpak-builder/**/*": true, + "**/.flatpak-builder": true, + "**/.config/flatpack/repo/**": true, + "**/build": true, "src/mainview/gen/*": true, }, "npm.scriptRunner": "bun", "npm.exclude": [ "**/.flatpak-builder/**/*", "**/build/flatpack/**", - "**/flatpack/repo/**", + "**/.config/flatpack/repo/**", ], "editor.formatOnSave": true, "[typescriptreact]": { diff --git a/package.json b/package.json index 79ef69a..fc31668 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "icon": "./src/mainview/assets/icon.svg", "main": "./src/bun/index.ts", "bin": "gameflow", + "license": "AGPL-3.0", "repository": { "type": "git", "url": "https://github.com/simeonradivoev/gameflow-deck" @@ -22,7 +23,6 @@ "build:dev:vite": "NODE_ENV=development bun run build:vite", "build": "bun run build:vite && bun run ./scripts/package-bun.ts", "build:prod": "NODE_ENV=production bun run build", - "build:prod:dynamic": "NODE_ENV=production NON_COMPILED=true bun run build", "build:linux": "TARGET=bun-linux-x64 bun run build", "openapi-ts": "bun run ./scripts/romm/openapi-ts.ts", "run:build-action": "act workflow_dispatch --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .forgejo/workflows/build.yml", @@ -33,7 +33,7 @@ "flatpak:generate-sources": "bun run ./scripts/generate-flatpak-sources.ts", "flatpak:override": "flatpak override org.flatpak.Builder --filesystem=host --device=all", "flatpak:restore": "flatpak override --reset --user org.flatpak.Builder", - "flatpak:build": "flatpak run org.flatpak.Builder build/flatpak flatpak/com.simeonradivoev.gameflow-deck.json --repo=.config/flatpak/repo --force-clean", + "flatpak:build": "FLATPAK_BUILD=true NODE_ENV=production NON_COMPILED=true bun run build && flatpak run org.flatpak.Builder ../gameflow-flatpak/build/flatpak .config/flatpak/com.simeonradivoev.gameflow-deck.json --repo=.config/flatpak/repo --state-dir=../gameflow-flatpak/state --force-clean", "flatpak:install": "bun run flatpak:build && flatpak --user install --reinstall \"$PWD/.config/flatpak/repo\" com.simeonradivoev.gameflow-deck", "build:prod:appimage": "bun run build:prod && bun run ./scripts/build-appimage.ts", "build:dev:appimage": "bun run build && bun run ./scripts/build-appimage.ts", @@ -41,6 +41,7 @@ "package:Linux": "bun run build:prod:appimage", "package:Windows": "bun run build:prod", "download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium", + "download:nwjs": "bun scripts/download-nw.ts", "build:audiosprites": "bun ./scripts/generate-audio-sprites.ts", "tsc": "tsc --noEmit" }, diff --git a/scripts/build-appimage.ts b/scripts/build-appimage.ts index 852df2e..c2f07f5 100644 --- a/scripts/build-appimage.ts +++ b/scripts/build-appimage.ts @@ -4,11 +4,11 @@ import fs from 'node:fs/promises'; import { appBuilderPath, } from 'app-builder-bin'; import path from 'node:path'; import { ensureDir } from "fs-extra"; +import mustache from "mustache"; const APP_DIR = process.env.BUILD_DIR ?? `./build/${process.platform}`; const BINARY_NAME = pkg.bin; const ICON = "./src/mainview/public/256x256.png"; -const DESKTOP = "./flatpak/com.simeonradivoev.gameflow-deck.desktop"; const TMP_FOLDER = "."; const APP_NAME = pkg.displayName ?? pkg.name; @@ -27,24 +27,45 @@ await fs.rename(path.join(APPDIR, `usr`, 'share', BINARY_NAME), path.join(APPDIR await fs.rename(path.join(APPDIR, `usr`, 'share', `libwebview-${process.arch}.so`), path.join(APPDIR, `usr`, 'lib', `libwebview-${process.arch}.so`)); await fs.rename(path.join(APPDIR, `usr`, 'share', `7za`), path.join(APPDIR, `usr`, 'bin', `7za`)); -await fs.writeFile(path.join(APPDIR, `${APP_ID}.desktop`), `[Desktop Entry] -Version=${pkg.version} -X-AppImage-Name=${APP_NAME} -X-AppImage-Version=${pkg.version} -X-AppImage-Arch=${process.arch} -Name=${APP_NAME} -Comment=${pkg.description} -Exec=${APP_ID}.AppImage -Icon=.DirIcon -Type=Application -Categories=Game; -`); +if (!await fs.exists('./bin/nw/nw')) +{ + await import('./download-nw'); +} -await Bun.write(path.join(APPDIR, "AppRun"), `#!/bin/bash -APPDIR="$(dirname "$(readlink -f "$0")")" -APPIMAGE=true -exec "$APPDIR/usr/bin/${BINARY_NAME}" "$@" -`); +await ensureDir(path.join(APPDIR, `usr`, 'lib', 'nw')); +await fs.cp('./bin/nw', path.join(APPDIR, `usr`, 'lib', 'nw'), { recursive: true }); +await fs.symlink(path.join(APPDIR, `usr`, 'lib', 'nw', 'nw'), path.join(APPDIR, `usr`, `bin`, 'nw')); + +const templateVars = { + APP_NAME, + VERSION: pkg.version, + ARCH: process.arch, + DESCRIPTION: pkg.description, + APP_ID, + BINARY_NAME, + LICENSE: pkg.license +}; + +const desktopFileTemplate = await fs.readFile('./.config/appimage/com.simeonradivoev.gameflow-deck.desktop', 'utf8'); + +const raw = await $`git tag --sort=-version:refname`.text().then(d => d.trim()); +const tags = raw.split('\n').filter(t => t.match(/^\d+\.\d+\.\d+$/)); +console.log("tags", tags); + +console.log(">>> Updating Release History..."); +const releases = await Promise.all(tags.map(async tag => +{ + const date = await $`git log -1 --format=%as ${tag}`.text().then(d => d.trim()); + const version = tag.replace(/^v/, ''); + return ` `; +})); + +const appStreamTemplate = await fs.readFile('./.config/appimage/com.simeonradivoev.gameflow-deck.appdata.xml', 'utf8'); +await ensureDir(path.join(APPDIR, 'usr', 'share', 'metainfo')); +await fs.writeFile(path.join(APPDIR, 'usr', 'share', 'metainfo', `${APP_ID}.appdata.xml`), mustache.render(appStreamTemplate, { ...templateVars, RELEASES: releases })); + +const appRunTemplate = await fs.readFile(`./.config/appimage/AppRun`, 'utf8'); +await Bun.write(path.join(APPDIR, "AppRun"), mustache.render(appRunTemplate, templateVars)); await $`chmod +x ${APPDIR}/AppRun`; console.log(">>> Building AppImage..."); @@ -52,7 +73,7 @@ const config = { productName: pkg.displayName, productFilename: pkg.name, executableName: BINARY_NAME, - desktopEntry: DESKTOP, + desktopEntry: mustache.render(desktopFileTemplate, templateVars), icons: [ { file: ICON, @@ -67,7 +88,7 @@ const config = { // Remove the build dir, mainly to help with CIs await fs.rm(APP_DIR, { recursive: true }); await ensureDir(APP_DIR); -const OUTPUT = path.resolve(APP_DIR, `${APP_NAME}.AppImage`); +const OUTPUT = path.resolve(APP_DIR, `${APP_NAME}-${process.platform}-${process.arch}.AppImage`); const STAGE = path.resolve(TMP_FOLDER, `${APP_ID}.stage`); await ensureDir(STAGE); @@ -86,8 +107,9 @@ const proc = Bun.spawn([ }); const code = await proc.exited; -await fs.rm(APPDIR, { recursive: true, force: true }); await fs.rm(STAGE, { recursive: true, force: true }); +await fs.rm(APPDIR, { recursive: true, force: true }); + if (code !== 0) process.exit(code); console.log(`\n Done!`); \ No newline at end of file diff --git a/scripts/dev.ts b/scripts/dev.ts index b8d3f6d..14e3f40 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -2,49 +2,44 @@ import EventEmitter from "events"; import browser from '../src/bun/browser'; import { tmpdir } from "os"; import path from "path"; -import { createInterface } from "readline"; -import { Readable } from "stream"; +import { watch } from "fs"; const events = new EventEmitter(); const abortController = new AbortController(); process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=9222"; process.env.NODE_ENV = "development"; -let retries = 0; - function spawnServer () { - const s = Bun.spawn(["bun", '--watch', '--install=fallback', "run", "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], { + const s = Bun.spawn(["bun", '--install=fallback', "run", "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], { env: { ...process.env, HEADLESS: "true", }, - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", + stdout: 'inherit', + stderr: 'inherit', + stdin: 'inherit', signal: abortController.signal, killSignal: 'SIGUSR1', + ipc (message, subprocess, handle) + { + if (message === 'focus') + { + events.emit('focus'); + } else if (message === 'exitapp') + { + events.emit('exitapp'); + } + }, onExit (subprocess, exitCode, signalCode) { - process.exit(); + if (exitCode !== 3) + { + console.log("Existing Dev With", exitCode); + process.exit(); + } } }); - const rl = createInterface({ input: Readable.fromWeb(s.stdout as any) }); - rl.on('line', e => - { - if (e === 'focus') - { - events.emit('focus'); - } else - { - console.log(e); - } - }); - const rle = createInterface({ input: Readable.fromWeb(s.stderr as any) }); - rle.on('line', e => - { - console.error(e); - }); return s; } @@ -53,9 +48,10 @@ function spawnBrowser () try { - return browser(events, process.env.FORCE_BROWSER === "true", { + return browser(events, { configPath: path.join(tmpdir(), 'gameflow'), - isSteamDeckGameMode: false + isSteamDeckGameMode: false, + forceBrowser: process.env.FORCE_BROWSER === "true" }); } catch (error) { @@ -63,13 +59,33 @@ function spawnBrowser () }; } -let server = spawnServer(); +async function restart () +{ + if (server) + { + server.kill("SIGUSR1"); + await server.exited; + server = undefined; + console.log("Server Restarted"); + } + + server = spawnServer(); + console.log("Server Restarted"); +} + +watch("./src/bun", { recursive: true }, (event, filename) => +{ + console.log(`[watcher] ${event}: ${filename} — restarting...`); + restart(); +}); + +let server: Bun.Subprocess | undefined = spawnServer(); if (!process.env.HEADLESS) { spawnBrowser()?.then(async e => { - console.log("Sending exit Signal to server"); - await server.stdin.write('shutdown\n'); - await server.stdin.flush(); + if (!server) return; + server.kill("SIGUSR1"); + await server.exited; }); } \ No newline at end of file diff --git a/scripts/download-nw.ts b/scripts/download-nw.ts new file mode 100644 index 0000000..7d318b6 --- /dev/null +++ b/scripts/download-nw.ts @@ -0,0 +1,54 @@ +import { ensureDir, remove } from "fs-extra"; +import StreamZip from "node-stream-zip"; +import { spawnSync } from "node:child_process"; +import fs from 'node:fs/promises'; + +const VERSION = "0.110.1"; + +const platformMap: Record = { + "win32": "win", + "darwin": "osx" +}; +const extMap: Record = { + "win32": "zip", + "linux": "tar.gz", + "darwin": "zip" +}; + +console.log("Removing old download"); +await remove('./bin/nw'); + +const downloadUrl = `https://dl.nwjs.io/v${VERSION}/nwjs-sdk-v${VERSION}-${platformMap[process.platform] ?? process.platform}-${process.arch}.${extMap[process.platform]}`; + +console.log("Starting NW download from", downloadUrl); +const response = await fetch(downloadUrl); +if (!response.ok) throw new Error(response.statusText); +const downlodPath = `./bin/nw.${extMap[process.platform]}`; +await ensureDir('./bin'); +await Bun.write(downlodPath, response); +console.log("Downloaded NW to", downlodPath); + +if (downlodPath.endsWith('.zip')) +{ + await extractZip(downlodPath, './bin'); +} +else if (downlodPath.endsWith(".tar.gz")) +{ + const result = spawnSync("tar", ["-xvf", downlodPath, "-C", './bin'], { stdio: "inherit" }); + if (result.status !== 0) console.error("tar extraction failed"); +} + +console.log('Renaming to nw'); +await fs.rename(`./bin/nwjs-sdk-v${VERSION}-${platformMap[process.platform] ?? process.platform}-${process.arch}`, './bin/nw'); +await fs.rm(downlodPath); + +async function extractZip (src: string, outDir: string) +{ + console.log(`Extracting zip -> ${outDir}`); + const zip = new StreamZip.async({ file: src }); + const entries = await zip.entries(); + const total = Object.keys(entries).length; + await zip.extract(null, outDir); + await zip.close(); + console.log(`Extracted ${total} files.`); +} \ No newline at end of file diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index f54671c..57aa61c 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -18,7 +18,6 @@ import EventEmitter from "node:events"; import { appPath } from "../utils"; import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; import { ensureDir } from "fs-extra"; -import { getStoreFolder } from "./store/services/gamesService"; import { PluginManager } from "./plugins/plugin-manager"; import registerPlugins from "./plugins/register-plugins"; import controls from './controls/controls'; @@ -43,6 +42,8 @@ export let events: EventEmitter; let controlsHandle: { cleanup: () => void; }; let api: { cleanup: () => Promise; }; let bunServer: { cleanup: () => Promise; } | undefined; +let cleannedUp = false; +let cleaningUp = false; export async function load () { @@ -56,6 +57,7 @@ export async function load () windowSize: { width: 1280, height: 800 } }), }); + customEmulators = new Conf>({ projectName: projectPackage.name, projectSuffix: 'bun', @@ -96,6 +98,9 @@ export async function load () export async function cleanup () { + if (cleaningUp) throw new Error("Already Cleaning Up"); + cleaningUp = true; + if (cleannedUp) throw new Error("Already Cleaned Up. Skipping"); console.log("Cleaning Up"); await bunServer?.cleanup(); await api.cleanup(); @@ -108,6 +113,7 @@ export async function cleanup () config._closeWatcher(); customEmulators._closeWatcher(); console.log("Finished Cleaning Up"); + cleannedUp = true; } export async function reloadDatabase () diff --git a/src/bun/api/cache.ts b/src/bun/api/cache.ts index ff84b4d..cdad6b1 100644 --- a/src/bun/api/cache.ts +++ b/src/bun/api/cache.ts @@ -3,6 +3,7 @@ import { cache } from "./app"; import cacheSchema from "@schema/cache"; import { GithubReleaseSchema } from "@/shared/constants"; import PQueue from "p-queue"; +import z from "zod"; export const CACHE_KEYS = { ROM_PLATFORMS: 'rom-platforms', @@ -12,17 +13,17 @@ export const CACHE_KEYS = { export const githubRequestQueue = new PQueue({ intervalCap: 10, interval: 1000 * 60 * 10, strict: true }); -export async function getOrCached (key: string, getter: () => Promise, options?: { expireMs?: number; }): Promise +export async function getOrCached (key: string, getter: (lastValue: T | undefined) => Promise, options?: { expireMs?: number; force?: boolean; }): Promise { const cached = await cache.query.item_cache.findFirst({ where: eq(cacheSchema.item_cache.key, key) }); const updated_at = new Date(); - if (cached && cached.expire_at > updated_at) + if (cached && cached.expire_at > updated_at && !options?.force) { return cached.data as T; } - const data = await getter(); + const data = await getter(cached?.data as T); if (data === undefined) return data; const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000); @@ -38,12 +39,15 @@ export async function getOrCached (key: string, getter: () => Promise, opt return data; } -export async function getOrCachedGithubRelease (path: string) +export async function getOrCachedGithubRelease (path: string, forceCheck?: boolean) { - return getOrCached(`github-release-${path}`, async () => githubRequestQueue.add(async () => + return getOrCached>(`github-release-${path}`, () => githubRequestQueue.add(async () => { - const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, { method: "GET" }); + const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, { + method: "GET" + }); if (!response.ok) throw new Error(response.statusText); - return GithubReleaseSchema.parseAsync(await response.json()); - }), { expireMs: 1000 * 60 * 60 }); + const release = await GithubReleaseSchema.parseAsync(await response.json()); + return release; + }), { expireMs: 1000 * 60 * 60, force: forceCheck }); } \ No newline at end of file diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 139d8af..54085a1 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -1,7 +1,7 @@ import z from "zod"; import { IJob, JobContext } from "../task-queue"; import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema"; -import { db, events, plugins } from "../app"; +import { config, db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; import { eq } from "drizzle-orm"; import { spawn } from 'node:child_process'; @@ -51,6 +51,7 @@ export class LaunchGameJob implements IJob { @@ -129,31 +130,41 @@ export class LaunchGameJob implements IJob - { - resolve(true); - }).catch(e => - { - console.error(e); - reject(e); - }); - game = bunGame; } else { + + let command = this.validCommand.command; + + if (process.env.FLATPAK_BUILD) command = `flatpak-spawn --host --directory=${config.get('downloadPath')} ${command}`; + // ES-DE commands require shell execution. Some emulators fail otherwise. - const spawnGame = spawn(this.validCommand.command, { + const spawnGame = spawn(command, { shell: this.validCommand.shell ?? true, cwd: this.validCommand.startDir, signal: context.abortSignal, @@ -178,7 +189,6 @@ export class LaunchGameJob implements IJob - { - resolve(true); - }).catch(e => - { - console.error(e); - reject(e); - }); - game = bunGame; } else diff --git a/src/bun/api/jobs/self-update-job.ts b/src/bun/api/jobs/self-update-job.ts new file mode 100644 index 0000000..38d57c0 --- /dev/null +++ b/src/bun/api/jobs/self-update-job.ts @@ -0,0 +1,118 @@ +import z from "zod"; +import { IJob, JobContext } from "../task-queue"; +import { cleanPromise, cleanup, events, plugins } from "../app"; +import fs from 'fs/promises'; +import { Downloader } from "@/bun/utils/downloader"; +import path from 'node:path'; +import os from "node:os"; +import winUpdateScript from '@/bun/utils/update-gameflow-windows.bat' with { type: "text" }; +import linuxUpdateScript from '@/bun/utils/update-gameflow-linux.sh' with { type: "text" }; +import mustache from "mustache"; +import pkg from '~/package.json'; +import { sleep } from "bun"; + +export default class SelfUpdateJob implements IJob +{ + static id = "self-update-job" as const; + static dataSchema = z.never(); + group = "self-update"; + + async downloadUpdate (url: URL, dest: string | undefined, filename: string, ctx: JobContext, never, string>) + { + const downloader = new Downloader('update', + [{ + url: url, + file_path: "", + file_name: filename + }], + dest, + { + onProgress (stats) + { + ctx.setProgress(stats.progress, "Downloading Update"); + }, + }); + return downloader.start(); + } + + async start (context: JobContext, never, string>) + { + context.setProgress(0, "Downloading Update"); + await sleep(1000); + const latest = await fetch('https://api.github.com/repos/simeonradivoev/gameflow-deck/releases/latest'); + if (latest.ok) + { + const data = await latest.json(); + let validAsset: any | undefined; + switch (process.platform) + { + case "win32": + validAsset = data.assets.find((e: any) => new Bun.Glob(`Gameflow-${process.platform}-${process.arch}.zip`).match(e.name)); + break; + case "linux": + validAsset = data.assets.find((e: any) => new Bun.Glob(`Gameflow-${process.platform}-${process.arch}.AppImage`).match(e.name)); + if (!validAsset) + { + validAsset = data.assets.find((e: any) => new Bun.Glob(`*.AppImage`).match(e.name)); + } + break; + default: + events.emit('notification', { message: "Unsupported Platfrom", title: 'Failed Update', type: "error" }); + return; + } + + if (!validAsset) + { + events.emit('notification', { message: "Could not find download", title: 'Failed Update', type: "error" }); + return; + } + + console.log("Found Download", validAsset.browser_download_url); + console.log("Starting Download"); + + switch (process.platform) + { + case "linux": + const appimage = process.env.APPIMAGE; + if (!appimage) + { + events.emit('notification', { + message: "Only AppImage supported", + title: 'Failed Update', + type: 'error' + }); + return; + } + const linuxDownloads = await this.downloadUpdate(new URL(validAsset.browser_download_url), undefined, path.basename(appimage), context); + if (!linuxDownloads) return; + const shPath = path.join(os.tmpdir(), "update-gameflow.sh"); + await Bun.write(shPath, mustache.render(linuxUpdateScript, { + tempFile: linuxDownloads[0], + appImagePath: appimage + })); + context.setProgress(0, "Restarting App To Update"); + events.emit('exitapp'); + Bun.spawn(["bash", shPath], { detached: true }); + process.exit(0); + case "win32": + const winDownloads = await this.downloadUpdate(new URL(validAsset.browser_download_url), undefined, "Gameflow-update.zip", context); + if (!winDownloads) return; + const batPath = path.join(os.tmpdir(), "update-gameflow.bat"); + await Bun.write(batPath, mustache.render(winUpdateScript, { + tempFile: winDownloads[0], + extractDir: path.dirname(process.execPath), + exePath: `${pkg.bin}.exe` + })); + context.setProgress(0, "Restarting App To Update"); + await cleanup(); + events.emit('exitapp'); + Bun.spawn(["cmd", "/c", batPath], { detached: true }); + process.exit(0); + } + + } else + { + events.emit('notification', { message: latest.statusText, title: 'Failed Update', type: "error" }); + } + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts index 7a0eadc..080af5f 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts @@ -14,6 +14,17 @@ export default class CEMUIntegration implements PluginType return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] }; }); + ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) => + { + if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return; + validChangedSaveFiles[this.emulator] = { + cwd: saveFolderSlots[this.emulator].cwd, + shared: true, + subPath: '*.{tga,xml,dat}', + isGlob: true + }; + }); + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => { const args: string[] = []; @@ -29,7 +40,7 @@ export default class CEMUIntegration implements PluginType args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`); } - return { args, savesPath: { cemu: { cwd: savesPath } } }; + return { args, savesPath: { [this.emulator]: { cwd: savesPath } } }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index de853f7..79a615b 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -68,21 +68,20 @@ export default class DOLPHINIntegration implements PluginType args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`); finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder; + return { args, savesPath: { [this.emulator]: { cwd: finalSavesPath } } }; } - return { args, savesPath: { dolphin: { cwd: finalSavesPath } } }; + return { args }; }); - ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderSlots, command, gameInfo }) => + ctx.hooks.games.postPlay.tap({ name: desc.name }, async ({ validChangedSaveFiles, saveFolderSlots, command }) => { - if (command.emulator === this.emulator && saveFolderSlots && command.metadata.romPath) - { - validChangedSaveFiles.dolphin = { - cwd: saveFolderSlots.dolphin.cwd, - subPath: await getSavePaths(command.metadata.romPath, saveFolderSlots.dolphin.cwd, command.metadata.emulatorDir), - shared: false - }; - } + if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return; + validChangedSaveFiles[this.emulator] = { + cwd: saveFolderSlots[this.emulator].cwd, + subPath: await getSavePaths(command.metadata.romPath, saveFolderSlots.dolphin.cwd, command.metadata.emulatorDir), + shared: false + }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index 605c1b6..db39248 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -31,6 +31,18 @@ export default class PCSX2Integration implements PluginType } }); + ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) => + { + if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return; + validChangedSaveFiles[this.emulator] = { + cwd: saveFolderSlots[this.emulator].cwd, + shared: true, + subPath: '*.ps2', + isGlob: true, + fixedSize: true + }; + }); + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => { const args: string[] = []; @@ -103,7 +115,7 @@ export default class PCSX2Integration implements PluginType await Bun.write(configPath, ini.stringify(configFile)); - return { args, savesPath: { pcsx2: { cwd: paths.MEMORY_CARDS_PATH } } }; + return { args, savesPath: { [this.emulator]: { cwd: paths.MEMORY_CARDS_PATH } } }; } return { args }; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index 5f5dbbb..a0c4a2d 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -10,6 +10,7 @@ import Mustache from "mustache"; import { ensureDir } from "fs-extra"; import { homedir } from "node:os"; import ini from 'ini'; +import fs from 'node:fs/promises'; export default class PPSSPPIntegration implements PluginType { @@ -19,10 +20,14 @@ export default class PPSSPPIntegration implements PluginType { ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => { - await Bun.write(path.join(ctx.path, "portable.txt"), ""); - if (process.platform === 'win32') + const stat = await fs.stat(ctx.path); + if (stat.isDirectory()) { - await Bun.write(path.join(ctx.path, "installed.txt"), path.join(config.get('downloadPath'), 'saves', this.emulator)); + await Bun.write(path.join(ctx.path, "portable.txt"), ""); + if (process.platform === 'win32') + { + await Bun.write(path.join(ctx.path, "installed.txt"), path.join(config.get('downloadPath'), 'saves', this.emulator)); + } } }); @@ -44,6 +49,17 @@ export default class PPSSPPIntegration implements PluginType } }); + ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) => + { + if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return; + validChangedSaveFiles[this.emulator] = { + cwd: saveFolderSlots[this.emulator].cwd, + shared: true, + subPath: '*.{SFO,sfo,PNG,png}', + isGlob: true + }; + }); + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => { const args: string[] = []; @@ -114,7 +130,14 @@ export default class PPSSPPIntegration implements PluginType await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); } - return { args, savesPath: { ppsspp: { cwd: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") } } }; + return { + args, + savesPath: { + [this.emulator]: { + cwd: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") + } + } + }; } return { args }; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts index 774b918..9d8670f 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts @@ -68,7 +68,7 @@ export default class XENIAIntegration implements PluginType if (ctx.autoValidCommand.metadata.romPath) { finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath); - return { args, savesPath: { xenia: { cwd: finalSavesPath } } }; + return { args, savesPath: { [this.emulator]: { cwd: finalSavesPath } } }; } return { args }; @@ -91,13 +91,12 @@ export default class XENIAIntegration implements PluginType ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, this.handleLaunch); ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulatorEdge }, this.handleLaunch); - ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) => + ctx.hooks.games.postPlay.tap({ name: desc.name }, async ({ validChangedSaveFiles, saveFolderSlots, command }) => { - if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath) - { - const files = await fs.readdir(saveFolderPath, { recursive: true }); - validChangedSaveFiles.gameflow = { cwd: saveFolderPath, subPath: files, shared: false }; - } + if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return; + const files = await fs.readdir(saveFolderSlots[this.emulator].cwd, { recursive: true }); + validChangedSaveFiles.xenia = { cwd: saveFolderSlots[this.emulator].cwd, subPath: files, shared: false }; + }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts index 45602c2..f5fdf9f 100644 --- a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts +++ b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts @@ -1,6 +1,6 @@ import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; -import { config, events } from "@/bun/api/app"; +import { config, db, events } from "@/bun/api/app"; import path, { dirname } from 'node:path'; import unzip from 'unzip-stream'; import { chmodSync, ensureDir } from "fs-extra"; @@ -10,6 +10,10 @@ import fs from 'node:fs/promises'; import { randomUUIDv7, sleep } from "bun"; import z from "zod"; import { createInterface } from "node:readline"; +import { getLocalGameMatch } from "@/bun/api/games/services/utils"; +import { getErrorMessage } from "@/bun/utils"; + +const DefaultLocalName = "Default_Local"; const SettingsSchema = z.object({ runWebGui: z.boolean() @@ -18,7 +22,7 @@ const SettingsSchema = z.object({ .meta({ title: "Run Web GUI" }), globalConfig: z.boolean().default(false).describe("Use the Global Config file if already setup"), webGuiPassword: z.string().optional().readonly().describe("Randomly Generated. Read Only. Username is gameflow"), - remoteName: z.string().default(""), + remoteName: z.string().default(DefaultLocalName), verboseLog: z.boolean() .default(false) .describe("Show detailed log of operation for debugging") @@ -116,8 +120,21 @@ export default class RcloneIntegration implements PluginType async refresh () { - const data = await this.request('/config/listremotes', {}); - z.globalRegistry.add(SettingsSchema.shape.remoteName, { examples: data.remotes, description: "The name of the remote to sync with" }); + try + { + const data = await this.request('/config/listremotes', {}); + z.globalRegistry.add(SettingsSchema.shape.remoteName, { + examples: [''].concat(...data.remotes), + description: "The name of the remote to sync with" + }); + } catch (error) + { + events.emit('notification', { message: getErrorMessage(error), type: 'error' }); + z.globalRegistry.add(SettingsSchema.shape.remoteName, { + examples: [''], + description: "The name of the remote to sync with" + }); + } } async startServer (ctx: PluginLoadingContextType) @@ -146,23 +163,29 @@ export default class RcloneIntegration implements PluginType const rl = createInterface({ input: Readable.fromWeb(this.server.stderr as any) }); rl.on('line', e => { - const data = JSON.parse(e); - - if (data.level === 'error') + try { - console.error(data.msg); - } else if (data.level === 'critical') - { - console.error(data.msg); - } + const data = JSON.parse(e); - else + if (data.level === 'error') + { + console.error(data.msg); + } else if (data.level === 'critical') + { + console.error(data.msg); + } + + else + { + console.log(e); + if (loginTokenUrlRegex.test(data.msg)) + { + this.loginUrl = (data.msg as string).match(loginTokenUrlRegex)?.find(e => e); + } + } + } catch (error) { console.log(e); - if (loginTokenUrlRegex.test(data.msg)) - { - this.loginUrl = (data.msg as string).match(loginTokenUrlRegex)?.find(e => e); - } } }); @@ -171,10 +194,16 @@ export default class RcloneIntegration implements PluginType { const handleResolve = (line: string) => { - const data = JSON.parse(line); - if (!loginTokenUrlRegex.test(data.msg)) return; - rl.off('line', handleResolve); - resolve(data); + try + { + const data = JSON.parse(line); + if (!loginTokenUrlRegex.test(data.msg)) return; + rl.off('line', handleResolve); + resolve(data); + } catch (error) + { + + } }; rl.on('line', handleResolve); setTimeout(() => { reject("Timeout"); }, 5000); @@ -206,100 +235,235 @@ export default class RcloneIntegration implements PluginType async cleanup () { - await this.request('/core/quit', {}).catch(e => + await new Promise((resolve) => { - this.server?.kill("SIGKILL"); + this.request('/core/quit', {}).catch(e => + { + this.server?.kill("SIGKILL"); + this.server = undefined; + }); + + setTimeout(() => + { + this.request('/core/quit', { exitCode: 9 }).then(e => + { + resolve(false); + this.server = undefined; + }).catch(e => + { + resolve(false); + this.server?.kill("SIGKILL"); + this.server = undefined; + }); + + + }, 5000); + + this.server?.exited.then(() => resolve(true)); }); - await this.server?.exited; } async load (ctx: PluginLoadingContextType) { await this.setup(ctx); - ctx.hooks.games.prePlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, setProgress, saveFolderSlots }) => + ctx.hooks.games.prePlay.tapPromise({ + name: desc.name, + stage: 10, + }, async ({ source, id, setProgress, saveFolderSlots, command }) => { - if (source !== 'store' || !this.rclonePath || !saveFolderSlots || !ctx.config.get('importSaves')) return; + if (!this.rclonePath || !saveFolderSlots || !ctx.config.get('importSaves')) return; + + const destination = source === 'store' ? [source, id] : command.emulator ? [command.emulator] : undefined; + if (!destination) return; + + const remoteName = ctx.config.get('remoteName'); for await (const [slot, { cwd }] of Object.entries(saveFolderSlots)) { - + let supportsMetadata = true; let src: string; - if (ctx.config.get('remoteName')) + + if (remoteName && remoteName !== DefaultLocalName) { - src = `${ctx.config.get('remoteName')}:gameflow/saves/${source}/${id}/${slot}`; + src = `${remoteName}:gameflow/saves/${destination.join('/')}/${slot}`; const exists = await this.request('/operations/stat', { - fs: `${ctx.config.get('remoteName')}:`, - remote: `gameflow/saves/${source}/${id}/${slot}` + fs: `${remoteName}:`, + remote: `gameflow/saves/${destination.join('/')}/${slot}` }).catch(e => undefined); if (!exists || !exists.item) return; - + const remote = await this.request('/operations/fsinfo', { + fs: `${remoteName}:` + }); + supportsMetadata = !remote.ReadMetadata; + if (supportsMetadata) + { + console.warn("Remote", remoteName, "does not support metadata"); + } } else { - src = path.join(config.get('downloadPath'), 'saves', source, id, slot); - if (!await fs.exists(path.join(config.get('downloadPath'), 'saves', source, id, slot))) return; + src = path.join(config.get('downloadPath'), 'saves', ...destination, slot); + if (!await fs.exists(path.join(config.get('downloadPath'), 'saves', ...destination, slot))) return; } - setProgress(0.5, "RClone: Syncing Saves"); - - const data = await this.request('/sync/copy', { + const job = await this.request('/sync/copy', { srcFs: src, dstFs: cwd, createEmptySrcDirs: true, + _async: true, _config: { - UseJSONLog: true, - LogLevel: "DEBUG", - HumanReadable: true, - Progress: true - } - }); - console.log(data); - } - - }); - - ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles }) => - { - if (source !== 'store' || !this.rclonePath || !ctx.config.get('exportSaves')) return; - console.log("Save Files", Object.values(validChangedSaveFiles).flatMap(c => Array.isArray(c.subPath) ? c.subPath : [c.subPath]).join(",")); - - await Promise.all(Object.entries(validChangedSaveFiles).map(async ([slot, change]) => - { - let dest: string; - if (ctx.config.get('remoteName')) - { - dest = `${ctx.config.get('remoteName')}:gameflow/saves/${source}/${id}/${slot}`; - } else - { - dest = path.join(config.get('downloadPath'), 'saves', source, id, slot); - } - - const data = await this.request('/sync/sync', { - srcFs: change.cwd, - dstFs: dest, - createEmptySrcDirs: true, - _config: { - UseJSONLog: true, - LogLevel: "DEBUG", - HumanReadable: true, - Progress: true - }, - _filter: { - IncludeRule: Array.isArray(change.subPath) ? change.subPath.map(s => - { - if (change.isGlob) return s; - else s.replaceAll('\\', '/'); - }) : change.isGlob ? change.subPath : change.subPath.replaceAll('\\', '/') + CheckFirst: true, + Metadata: true, + NoCheckDest: supportsMetadata } }).catch(e => { events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' }); return undefined; + });; + + await new Promise(async (resolve, reject) => + { + setProgress(0, "RClone: Syncing Saves"); + + const checkInterval = setInterval(async () => + { + const stat = await this.request('/job/status', { jobid: job.jobid }); + if (stat.finished) + { + clearInterval(checkInterval); + console.log(stat.output); + resolve(true); + + } else if (stat.error) + { + reject(stat.error); + } else + { + setProgress(stat.progress, "RClone: Syncing Saves"); + } + }, 500); + }); + } + + }); + + ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) => + { + if (!this.rclonePath || !ctx.config.get('exportSaves')) return; + const local = await db.query.games.findFirst({ where: getLocalGameMatch(id, source) }); + console.log("Save Files", Object.values(validChangedSaveFiles).flatMap(c => Array.isArray(c.subPath) ? c.subPath : [c.subPath]).join(",")); + + const destination = source === 'store' ? [source, id] : command.emulator ? [command.emulator] : undefined; + if (!destination) return; + + const remoteName = ctx.config.get('remoteName'); + + await Promise.all(Object.entries(validChangedSaveFiles).map(async ([slot, change]) => + { + let suportsMetadata = false; + let dest: string; + if (remoteName && remoteName !== DefaultLocalName) + { + dest = `${remoteName}:gameflow/saves/${destination.join('/')}/${slot}`; + const remote = await this.request('/operations/fsinfo', { + fs: `${remoteName}:` + }); + suportsMetadata = !remote.ReadMetadata; + if (suportsMetadata) + { + console.warn("Remote", remoteName, "does not support metadata"); + } + } else + { + dest = path.join(config.get('downloadPath'), 'saves', ...destination, slot); + } + + const filter = { + IncludeRule: Array.isArray(change.subPath) ? + change.subPath.map(s => + { + if (change.isGlob) return s; + else s.replaceAll('\\', '/'); + }) : + [change.isGlob ? change.subPath : change.subPath.replaceAll('\\', '/')] + }; + + let jobid: number | undefined = undefined; + + if (change.fixedSize) + { + await this.request('/sync/copy', { + srcFs: change.cwd, + dstFs: dest, + createEmptySrcDirs: true, + _async: true, + _config: { + NoCheckDest: true + }, + _filter: filter + }) + .then(job => jobid = job.jobid) + .catch(e => + { + events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' }); + return undefined; + }); + } else + { + await this.request('/sync/sync', { + srcFs: change.cwd, + dstFs: dest, + createEmptySrcDirs: true, + _async: true, + _config: { + CheckSum: true, + CheckFirst: true, + Metadata: true, + MetadataSet: { + igdb_id: local?.igdb_id ? String(local?.igdb_id) : undefined, + ra_id: local?.ra_id ? String(local?.ra_id) : undefined + } + }, + _filter: filter + }) + .then(job => jobid = job.jobid) + .catch(e => + { + events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' }); + return undefined; + }); + } + + if (!jobid) return; + await new Promise(async (resolve, reject) => + { + const checkInterval = setInterval(async () => + { + const stat = await this.request('/job/status', { jobid }); + if (stat.finished) + { + clearInterval(checkInterval); + console.log(stat.output); + resolve(true); + + } else if (stat.error) + { + reject(stat.error); + } else + { + + } + }, 500); }); - if (data) + const stats = await this.request('/core/stats', { + group: `job/${jobid}` + }); + + if (stats.transfers > 0) { events.emit('notification', { message: "RClone: Save Synced", type: 'success', icon: 'save' }); } diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index ccc0c29..e049285 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -145,6 +145,7 @@ export default class RommIntegration implements PluginType { this.isSteamDeck = isSteamDeckGameMode(); ctx.setProgress(0, "Logging Into Romm"); + await this.updateClient(); await checkLoginAndRefreshRomm(); await this.updateClient(); @@ -270,7 +271,8 @@ export default class RommIntegration implements PluginType metadata: rom.metadatum, files, auth: await this.getAuthToken(), - extract_path + extract_path, + id: "romm" }; return [info]; diff --git a/src/bun/api/plugins/plugin-manager.ts b/src/bun/api/plugins/plugin-manager.ts index e22b2c2..86944f3 100644 --- a/src/bun/api/plugins/plugin-manager.ts +++ b/src/bun/api/plugins/plugin-manager.ts @@ -90,7 +90,9 @@ export class PluginManager { if (plugin.enabled || plugin.description.canDisable === false) { + console.log("Loading Plugin", plugin.description.name); await plugin.plugin.load(ctx); + console.log("Loaded Plugin", plugin.description.name); plugin.loaded = true; } } catch (error) @@ -119,11 +121,13 @@ export class PluginManager { if (p.loaded) { + console.log("Starting", p.description.name, "plugin cleanup"); await p.plugin.cleanup!(); + console.log(p.description.name, "cleanup complete"); } } catch (error) { - console.log("Error for plugin", p.description.name, "while cleaning up"); + console.error("Error for plugin", p.description.name, "while cleaning up"); console.error(error); } })); diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 927c00d..ccc3fb9 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -2,8 +2,8 @@ import Elysia from "elysia"; import open from 'open'; import z from "zod"; import os from 'node:os'; -import { cachePath, config, events, taskQueue } from "./app"; -import { isSteamDeck, openExternal } from "../utils"; +import { cache, cachePath, config, events, taskQueue } from "./app"; +import { getAppVersion, isSteamDeck, openExternal } from "../utils"; import fs from 'node:fs/promises'; import buildNotificationsStream from "./notifications"; import path, { dirname } from "node:path"; @@ -14,23 +14,15 @@ import si from 'systeminformation'; import { getStoreFolder } from "./store/services/gamesService"; import ReloadPluginsJob from "./jobs/reload-plugins-job"; import { semver } from "bun"; -import packageDef from '~/package.json'; -import { getOrCached, githubRequestQueue } from "./cache"; +import { getOrCached, getOrCachedGithubRelease, githubRequestQueue } from "./cache"; +import SelfUpdateJob from "./jobs/self-update-job"; -async function checkUpdate () +async function checkUpdate (force?: boolean) { - return getOrCached('check-for-update', async () => githubRequestQueue.add(async () => - { - const latest = await fetch('https://api.github.com/repos/simeonradivoev/gameflow-deck/releases/latest'); - if (latest.ok) - { - const data = await latest.json(); - const hasUpdate = semver.order(data.tag_name, packageDef.version); - return hasUpdate; - } - - return 0; - }), { expireMs: 1000 * 60 * 60 }); + const latest = await getOrCachedGithubRelease('simeonradivoev/gameflow-deck', force); + if (!latest || !latest.tag_name) return { hasUpdate: 0, version: getAppVersion() }; + const hasUpdate = semver.order(latest.tag_name, getAppVersion()); + return { hasUpdate, version: latest.tag_name }; } export const system = new Elysia({ prefix: '/api/system' }) @@ -71,7 +63,8 @@ export const system = new Elysia({ prefix: '/api/system' }) machine: os.machine(), source, cacheSize: (await fs.stat(cachePath)).size, - storeSize: (await getFolderSize(getStoreFolder())).size + storeSize: (await getFolderSize(getStoreFolder())).size, + version: getAppVersion() }; }) .get('/notifications', ({ set }) => @@ -120,17 +113,25 @@ export const system = new Elysia({ prefix: '/api/system' }) dispose.push(taskQueue.on('progress', e => { - if (e.id !== ReloadPluginsJob.id) return; - ws.send({ type: "loading", progress: e.progress, state: e.state }); + if (e.id === ReloadPluginsJob.id) + { + ws.send({ type: "loading", progress: e.progress, state: e.state }); + } + else if (e.id === SelfUpdateJob.id) + { + ws.send({ type: "loading", progress: e.progress, state: e.state }); + } })); dispose.push(taskQueue.on('started', e => { - if (e.id !== ReloadPluginsJob.id) return; - ws.send({ type: "loading", progress: 0 }); + if (e.id === ReloadPluginsJob.id) + ws.send({ type: "loading", progress: e.job.progress, state: e.job.state }); + else if (e.id === SelfUpdateJob.id) + ws.send({ type: "loading", progress: e.job.progress, state: e.job.state }); })); dispose.push(taskQueue.on('ended', e => { - if (e.id !== ReloadPluginsJob.id) return; + if (e.id !== ReloadPluginsJob.id && e.id !== SelfUpdateJob.id) return; ws.send({ type: "loaded" }); })); @@ -268,4 +269,12 @@ export const system = new Elysia({ prefix: '/api/system' }) .get('/update', async () => { return checkUpdate(); + }) + .post('/update', async () => + { + return taskQueue.enqueue(SelfUpdateJob.id, new SelfUpdateJob()); + }) + .post('/update/check', async () => + { + return checkUpdate(true); }); \ No newline at end of file diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index 34459ed..bb890df 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -106,7 +106,18 @@ export class TaskQueue { this.queue = []; this.activeQueue.forEach(c => c.abort()); - return Promise.all(this.activeQueue.map(c => c.promise.promise.catch(e => console.error("Error During Task Queue Closing")))); + return Promise.all(this.activeQueue.map(c => + { + return new Promise(resolve => + { + c.promise.promise.then(resolve).catch(e => + { + console.error("Error During Task Queue Closing"); + resolve(false); + }); + setTimeout(resolve, 5000); + }); + })); } } diff --git a/src/bun/browser.ts b/src/bun/browser.ts index c86dfef..8d71427 100644 --- a/src/bun/browser.ts +++ b/src/bun/browser.ts @@ -6,24 +6,42 @@ import { dlopen, FFIType, Pointer } from "bun:ffi"; import { SERVER_URL } from '@/shared/constants'; import { host } from './utils/host'; import fs from 'node:fs/promises'; +import { ensureDir } from 'fs-extra'; +import path from 'node:path'; -export default async function init (events: EventEmitter, forceBrowser: boolean, params: BrowserParams) +export default async function init (events: EventEmitter, params: BrowserParams) { - if (forceBrowser) + if (params.forceNWJS) { - await runBrowser(events, params); - } else - { - try - { - await runWebview(events, params); - } catch (error) - { - await runBrowser(events, params); - } + await runNW(events, params); + return; } - await runNW(events, params); + if (params.forceBrowser) + { + await runBrowser(events, params); + return; + } + + try + { + await runWebview(events, params); + return; + } catch (error) + { + console.error(error); + } + + try + { + await runNW(events, params); + return; + } catch (error) + { + console.error(error); + } + + await runBrowser(events, params); } function focusWindow (id: Pointer) @@ -51,17 +69,50 @@ function focusWindow (id: Pointer) async function runNW (events: EventEmitter, params: BrowserParams) { - const path = process.platform === 'win32' ? './bin/nw/nw.exe' : './bin/nw/nw'; - if (!await fs.exists(path)) + let nwPath = process.platform === 'win32' ? './bin/nw/nw.exe' : './bin/nw/nw'; + if (process.env.FLATPAK_BUILD) { - console.error("Could not find NW.js"); - return; + nwPath = '/app/bin/nw/nw'; + } else if (process.env.APPIMAGE) + { + nwPath = path.join(process.env.APPDIR ?? '', 'usr', 'bin', 'nw'); + } + + if (!await fs.exists(nwPath)) + { + throw new Error(`Could not find NW.js at ${nwPath}`); } const signalHandler = new AbortController(); + const chromeArgs: string[] = ['--in-process-gpu']; + if (params.isSteamDeckGameMode) + { + chromeArgs.push('--kiosk'); + chromeArgs.push(`--window-size=1280,800`); + } else if (params.windowSize) + { + chromeArgs.push(`--window-size=${params.windowSize.width},${params.windowSize.height}`); + } + if (params.windowPosition) chromeArgs.push(`--window-position=${params.windowPosition.x},${params.windowPosition.y}`); events.on('exitapp', () => signalHandler.abort()); - const args = [path, `--url=${SERVER_URL(host)}`]; - if (process.env.NODE_ENV !== 'development') args.push("--disable-devtools"); - const nwProcess = Bun.spawn(args, { signal: signalHandler.signal }); + const configPath = path.join(params.configPath, 'nw-user-data'); + await ensureDir(configPath); + console.log("NW config path at:", configPath); + const args = [nwPath, `--url=${SERVER_URL(host)}`, `--user-data-dir=${configPath}`]; + + if (process.env.NODE_ENV !== 'development') + { + console.log("Disabling devtools"); + args.push("--disable-devtools"); + } + console.log("Launching NW.js"); + const nwProcess = Bun.spawn(args, { + signal: signalHandler.signal, + killSignal: "SIGKILL", + env: { + ...process.env, + NW_PRE_ARGS: chromeArgs.join(" ") + } + }); await nwProcess.exited; } @@ -131,8 +182,7 @@ async function runBrowser (events: EventEmitter, params: BrowserParams) const browserParams = await BuildParams(params); if (!browserParams) { - console.error("Could not find valid browser"); - return Promise.resolve(); + throw new Error("Could not find valid browser"); } else if (!Bun.env.HEADLESS) { diff --git a/src/bun/index.ts b/src/bun/index.ts index 146c195..a2ba31d 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -5,14 +5,28 @@ import { dirname } from 'pathe'; import { createInterface } from 'readline'; import { isSteamDeckGameMode } from './utils'; -async function cleanup () +async function cleanup (code: number) { - await app.cleanup(); - process.exit(0); + app.cleanup() + .then(() => + { + process.exit(code); + }) + .catch(e => console.error); } await app.load(); +async function shutdown (code: number) +{ + console.log("Graceful Shutdown"); + await cleanup(code); +} + +process.on("SIGINT", () => shutdown(0)); +process.on("SIGTERM", () => shutdown(0)); +process.on('SIGUSR1', () => shutdown(3)); + if (process.env.HEADLESS) { const rl = createInterface({ input: process.stdin }); @@ -22,7 +36,7 @@ if (process.env.HEADLESS) if (line.trim() === "shutdown") { console.log("Graceful Shutdown"); - await cleanup(); + await cleanup(0); } }); @@ -30,23 +44,23 @@ if (process.env.HEADLESS) app.events.on('exitapp', () => { process.stdout.write('exitapp\n'); - cleanup(); + process.send?.("exitapp"); + cleanup(0); }); app.events.on('focus', () => { process.stdout.write("focus\n"); + process.send?.("focus"); }); } else { - await init(app.events, process.env.FORCE_BROWSER === "true", { + await init(app.events, { configPath: dirname(app.config.path), windowPosition: app.config.get('windowPosition'), windowSize: app.config.get('windowSize'), - isSteamDeckGameMode: isSteamDeckGameMode() + isSteamDeckGameMode: isSteamDeckGameMode(), + forceBrowser: process.env.FORCE_BROWSER === "true", + forceNWJS: process.env.FORCE_NWJS === "true" }); - await cleanup(); -} - - - - + await cleanup(0); +} \ No newline at end of file diff --git a/src/bun/types/types.d.ts b/src/bun/types/types.d.ts index 1bc7a22..ee43a63 100644 --- a/src/bun/types/types.d.ts +++ b/src/bun/types/types.d.ts @@ -29,4 +29,14 @@ declare interface AppEventMap exitapp: []; notification: [FrontendNotification]; focus: []; +} + +declare module '*.bat' { + const content: string; + export default content; +} + +declare module '*.sh' { + const content: string; + export default content; } \ No newline at end of file diff --git a/src/bun/utils.ts b/src/bun/utils.ts index c21a78b..23d17c0 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { SettingsType } from '@/shared/constants'; import { config } from './api/app'; import fs from 'node:fs/promises'; +import packageDef from '~/package.json'; export function checkRunning (pid: number) { @@ -172,4 +173,9 @@ export async function moveAllFiles (srcDir: string, destDir: string) }); } } +} + +export function getAppVersion () +{ + return process.env.VERSION_OVERRIDE ?? packageDef.version; } \ No newline at end of file diff --git a/src/bun/utils/browser-params.ts b/src/bun/utils/browser-params.ts index 0023219..5059137 100644 --- a/src/bun/utils/browser-params.ts +++ b/src/bun/utils/browser-params.ts @@ -11,6 +11,8 @@ export interface BrowserParams windowPosition?: { x: number, y: number; }; windowSize?: { width?: number, height?: number; }; isSteamDeckGameMode: boolean; + forceBrowser?: boolean; + forceNWJS?: boolean; } export async function BuildParams (data: BrowserParams) @@ -54,6 +56,13 @@ export async function BuildParams (data: BrowserParams) args.push('--allow-insecure-localhost'); args.push('--auto-accept-camera-and-microphone-capture'); + if (process.env.FLATPAK_BUILD) + { + args.push('--no-sandbox'); + args.push('--disable-gpu-sandbox'); + args.push('--test-type'); + } + if (data.isSteamDeckGameMode) { args.push('--kiosk'); diff --git a/src/bun/utils/downloader.ts b/src/bun/utils/downloader.ts index 8d443c2..f4f7d95 100644 --- a/src/bun/utils/downloader.ts +++ b/src/bun/utils/downloader.ts @@ -27,15 +27,22 @@ export class Downloader onProgress?: (stats: ProgressStats) => void; signal?: AbortSignal; activeFile?: DownloadFileEntry; - downloadPath: string; + downloadPath: string | undefined; id: string; tmpPath: string; tmpPathMeta: string; + /** + * + * @param id Id of the download. Should be unique + * @param files All the files to download + * @param downloadPath The destination path when all downloads are complete they will bemoved here. If undefined they will remain in the tmp path. + */ constructor( id: string, files: DownloadFileEntry[], - downloadPath: string, init?: { + downloadPath: string | undefined, + init?: { headers?: Record, onProgress?: (stats: ProgressStats) => void; signal?: AbortSignal; @@ -210,11 +217,19 @@ export class Downloader }); } - await moveAllFiles(this.tmpPath, this.downloadPath); - if (await fs.exists(this.tmpPath)) - await fs.rm(this.tmpPath, { recursive: true }); - await fs.rm(this.tmpPathMeta); + if (this.downloadPath === undefined) + { + await fs.rm(this.tmpPathMeta); + return this.files.map(f => path.join(this.tmpPath, f.file_path, f.file_name)); + } else + { + await moveAllFiles(this.tmpPath, this.downloadPath); + if (await fs.exists(this.tmpPath)) + await fs.rm(this.tmpPath, { recursive: true }); + await fs.rm(this.tmpPathMeta); + + return this.files.map(f => path.join(this.downloadPath!, f.file_path, f.file_name)); + } - return this.files.map(f => path.join(this.downloadPath, f.file_path, f.file_name)); } } \ No newline at end of file diff --git a/src/bun/utils/update-gameflow-linux.sh b/src/bun/utils/update-gameflow-linux.sh new file mode 100644 index 0000000..1bd1c81 --- /dev/null +++ b/src/bun/utils/update-gameflow-linux.sh @@ -0,0 +1,6 @@ +#!/bin/bash +sleep 2 +mv "{{{tempFile}}}" "{{{appImagePath}}}" +chmod +x "{{{appImagePath}}}" +"{{{appImagePath}}}" & +rm -- "$0" \ No newline at end of file diff --git a/src/bun/utils/update-gameflow-windows.bat b/src/bun/utils/update-gameflow-windows.bat new file mode 100644 index 0000000..11dc3c8 --- /dev/null +++ b/src/bun/utils/update-gameflow-windows.bat @@ -0,0 +1,6 @@ +@echo off +timeout /t 2 /nobreak +powershell -Command "Expand-Archive -Force '{{{tempFile}}}' '{{{installDir}}}'" +del "{{{tempFile}}}" +start "" /D "{{{installDir}}}" "{{{exePath}}}" +del "%~f0" \ No newline at end of file diff --git a/src/mainview/components/AnimatedBackground.tsx b/src/mainview/components/AnimatedBackground.tsx index 31ea52a..36caedf 100644 --- a/src/mainview/components/AnimatedBackground.tsx +++ b/src/mainview/components/AnimatedBackground.tsx @@ -25,17 +25,13 @@ export function AnimatedBackground (data: { ) : useState(); - const [lastBackgroundUrl, setLastBackgroundUrl] = useState(undefined); const backgroundElementRef = useRef(null); useEffect(() => { - const lastBg = backgroundUrl; - if (data.backgroundUrl != backgroundUrl) { setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined); - setLastBackgroundUrl(lastBg); } }, [data.backgroundUrl]); @@ -44,13 +40,6 @@ export function AnimatedBackground (data: { { finalBackgroundUrl = backgroundUrl ? new URL(backgroundUrl) : undefined; } catch { } - - let finalLastBackgroundUrl: URL | undefined; - try - { - finalLastBackgroundUrl = lastBackgroundUrl ? new URL(lastBackgroundUrl) : undefined; - } catch { } - const blur = useLocalSetting('backgroundBlur'); if (blur) { @@ -59,13 +48,7 @@ export function AnimatedBackground (data: { finalBackgroundUrl?.searchParams.set('blur', String(24)); } - if (!finalLastBackgroundUrl?.searchParams.has('blur')) - { - finalLastBackgroundUrl?.searchParams.set('blur', String(24)); - } - finalBackgroundUrl?.searchParams.set('height', String(320)); - finalLastBackgroundUrl?.searchParams.set('height', String(320)); } useEffect(() => @@ -90,8 +73,6 @@ export function AnimatedBackground (data: { function handleSetBackground (url: string) { - - setLastBackgroundUrl(backgroundUrl); setBackgroundUrl(url); } @@ -120,7 +101,7 @@ export function AnimatedBackground (data: { > {!data.scrolling &&
    - {blur && finalLastBackgroundUrl && } + {finalBackgroundUrl ? + { + sub.close(); + }; }, []); return diff --git a/src/mainview/components/AutoFocus.tsx b/src/mainview/components/AutoFocus.tsx index 74c6da4..ae0bb33 100644 --- a/src/mainview/components/AutoFocus.tsx +++ b/src/mainview/components/AutoFocus.tsx @@ -1,5 +1,5 @@ import { doesFocusableExist, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; -import { useLayoutEffect } from "react"; +import { useEffect, useLayoutEffect } from "react"; export function AutoFocus (data: { parentKey?: string; @@ -8,11 +8,15 @@ export function AutoFocus (data: { delay?: number; }) { - useLayoutEffect(() => + useEffect(() => { let delayTimeout: number | undefined; - if (data.force || !getCurrentFocusKey() || getCurrentFocusKey() === data.parentKey || !doesFocusableExist(getCurrentFocusKey())) + const focusDoesntExist = !doesFocusableExist(getCurrentFocusKey()); + const parentFocus = getCurrentFocusKey() === data.parentKey; + const noFocus = !getCurrentFocusKey(); + + if (data.force || noFocus || parentFocus || focusDoesntExist) { if (data.delay) { @@ -21,8 +25,8 @@ export function AutoFocus (data: { { data.focus({ instant: true }); } - } + return () => { if (delayTimeout) diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index 5de871d..d311167 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -32,7 +32,7 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara oneShot('click'); }; - useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]); + useShortcuts(data.game.focusKey, () => [{ label: "Details", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]); return ( 0, + focusable: data.games.length > 0 || (!!data.finalElement && (Array.isArray(data.finalElement) ? data.finalElement.length > 0 : !!data.finalElement)), preferredChildFocusKey: data.focus }); diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index b9bb9e3..cf36fe0 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -30,7 +30,7 @@ import { TwitchIcon } from "../scripts/brandIcons"; import { rommLoggedInQuery } from "../scripts/queries/romm"; import { twitchLoginVerificationQuery } from "../scripts/queries/settings"; import { SystemInfoContext } from "../scripts/contexts"; -import { useRouter } from "@tanstack/react-router"; +import { useNavigate, useRouter } from "@tanstack/react-router"; import { oneShot } from "../scripts/audio/audio"; import { hasUpdateQuery } from "../scripts/queries/system"; @@ -87,16 +87,24 @@ export interface HeaderAccount function UpdateStatus () { + const handleSelect = () => + { + navigate({ to: '/settings/about' }); + }; const hasUnread = false; - return
    - + const navigate = useNavigate(); + const { ref } = useFocusable({ + focusKey: 'update-bt', onEnterPress: handleSelect + }); + return
    +
    ; } function NotificationStatus () { const hasUnread = false; - return
    + return
    ; } @@ -219,14 +227,17 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) router.navigate({ to: '/settings/accounts' }); oneShot('click'); }; - const { ref } = useFocusable({ - focusKey: 'accounts', onEnterPress: handleSelect - }); const accounts: HeaderAccount[] = []; if (data.accounts) accounts.push(...data.accounts); const router = useRouter(); + const { ref } = useFocusable({ + focusKey: 'accounts', + onEnterPress: handleSelect, + focusable: accounts.length > 0 + }); + if (rommUser.data?.hasLogin || rommUser.isError) { accounts.push({ @@ -259,7 +270,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; }) { const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' }); - const { data: hasUpdate } = useQuery(hasUpdateQuery); + const { data: update } = useQuery(hasUpdateQuery); return
    @@ -267,7 +278,7 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement - {!!hasUpdate && hasUpdate >= 1 && } + {!!update && update.hasUpdate >= 1 && }
    {!!data.buttons &&
    } diff --git a/src/mainview/components/ImageWithFallbacks.tsx b/src/mainview/components/ImageWithFallbacks.tsx index 7954da3..6779b26 100644 --- a/src/mainview/components/ImageWithFallbacks.tsx +++ b/src/mainview/components/ImageWithFallbacks.tsx @@ -13,7 +13,20 @@ export default function ImageWithFallbacks (data: { { img.dataset.index = String(nextIndex); img.src = data.src[nextIndex].href; + } }; - return ; + return + { + e.currentTarget.dataset.loaded = "true"; + }} + > + + ; } \ No newline at end of file diff --git a/src/mainview/components/backgrounds/dots.tsx b/src/mainview/components/backgrounds/dots.tsx index ce6e0af..5971082 100644 --- a/src/mainview/components/backgrounds/dots.tsx +++ b/src/mainview/components/backgrounds/dots.tsx @@ -1,8 +1,9 @@ +import { Ref, RefObject } from 'react'; import './dots.css'; -export default function DotsLoading () +export default function DotsLoading (data: { ref?: Ref; }) { - return
    + return
    diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index c70369a..96a120f 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -109,7 +109,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so if (!cmd) return; if (cmd.emulator === 'EMULATORJS') { - const params = new URLSearchParams(cmd.command); + 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 { @@ -120,14 +120,15 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so let mainButton: any | undefined = undefined; if (status === 'installed') { - mainButton =
    handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details} - key="primary" - type='primary' - id="mainAction" - > - + mainButton =
    + handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details} + key="primary" + type='primary' + id="mainAction" + > + - + {validCommands.length > 1 && showAllCommands(true, 'allActionsBtn')}> diff --git a/src/mainview/gen/static-icon-assets.gen.ts b/src/mainview/gen/static-icon-assets.gen.ts index cb3fe1b..1d1a4aa 100644 --- a/src/mainview/gen/static-icon-assets.gen.ts +++ b/src/mainview/gen/static-icon-assets.gen.ts @@ -464,7 +464,7 @@ const assets = new Set([ ]); // Store basePath resolved from Vite config -const BASE_PATH = "./"; +const BASE_PATH = "/"; /** diff --git a/src/mainview/index.css b/src/mainview/index.css index eb09eb3..532b330 100644 --- a/src/mainview/index.css +++ b/src/mainview/index.css @@ -7,7 +7,7 @@ @theme { --breakpoint-sm: 0px; - --breakpoint-md: 1280px; + --breakpoint-md: 1024px; --page-scroll-bg: transparent; --animation-size: 1; diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 103d90f..d0a346e 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -166,7 +166,6 @@ function RouteComponent () return ( - setUpdate(v => v + 1) }} > @@ -214,6 +213,7 @@ function RouteComponent ()
    + ); } \ No newline at end of file diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index e2e6844..527fea9 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -13,10 +13,13 @@ import LayoutGrid, PlusCircle, Plus, + LucideIcon, } from "lucide-react"; import { createFileRoute, + PathParamOptions, + ToPathOption, useRouter, } from "@tanstack/react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; @@ -52,6 +55,7 @@ import { FloatingShortcuts } from "../components/Shortcuts"; import SelectMenu from "../components/SelectMenu"; import HeaderSearchField from "../components/HeaderSearchField"; import CardElement from "../components/CardElement"; +import { Router } from ".."; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -114,24 +118,30 @@ function Preview (data: { index: number; children?: any; })
    ; } -function GetStoreGamesCard () +function AdditionalCard (data: { + id: string, + route: keyof typeof Router.routesByPath, + title: string, + subTitle: string, + index: number, + actionLabel: string; + icon: LucideIcon | string; + badgeIcon?: LucideIcon; +}) { const router = useRouter(); - const handleNavigate = () => - { - router.navigate({ to: '/store/tab/games' }); - }; - return ]} onAction={handleNavigate} title="Gameflow Store" subtitle="Get Free Games" preview={} focusKey='store-games-btn' index={0} id="store-games-btn" />; -} -function ShowAllGamesCard () -{ - const router = useRouter(); const handleNavigate = () => { - router.navigate({ to: '/games' }); + router.navigate({ to: data.route as any }); }; - return } focusKey='all-games-btn' index={0} id="all-games-btn" />; + useShortcuts(data.id, () => [{ label: data.actionLabel, button: GamePadButtonCode.A, action: handleNavigate }]); + return ] : undefined} onAction={handleNavigate} title={data.title} subtitle={data.subTitle} preview={ + {typeof data.icon === 'string' ? + : + + } + } focusKey={data.id} index={0} id={data.id} />; } function HomeList (data: { @@ -197,8 +207,13 @@ function HomeList (data: { id="games-list" setBackground={bg.setBackground} filters={{ limit: 12, orderBy: 'activity' }} - finalElement={[, ]} - emptyElement={[]} + finalElement={[ + , + + ]} + emptyElement={[ + + ]} /> ; diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index 4254395..e66de07 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -5,7 +5,8 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/ import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts'; import { useJobStatus } from '../scripts/utils'; -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; +import { rommApi } from '../scripts/clientApi'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, @@ -39,7 +40,7 @@ function RouteComponent () useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); - const { data, state } = useJobStatus('launch-game', { + const { state, data } = useJobStatus('launch-game', { onProgress (process, data) { if (progressRef.current) @@ -55,6 +56,7 @@ function RouteComponent () }, }, [progressRef.current, HandleGoBack]); + useBlocker({ shouldBlockFn: () => !!data }); return diff --git a/src/mainview/routes/settings/about.tsx b/src/mainview/routes/settings/about.tsx index d30b291..3351ff5 100644 --- a/src/mainview/routes/settings/about.tsx +++ b/src/mainview/routes/settings/about.tsx @@ -1,8 +1,11 @@ -import { systemInfoQuery } from '@queries/system'; -import { useQuery } from '@tanstack/react-query'; +import { Button } from '@/mainview/components/options/Button'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { checkUpdateMutation, hasUpdateQuery, systemInfoQuery, updateMutation } from '@queries/system'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; +import { ArrowUpCircle, CircleFadingArrowUp, RefreshCcw } from 'lucide-react'; import prettyBytes from 'pretty-bytes'; export const Route = createFileRoute('/settings/about')({ @@ -12,58 +15,87 @@ export const Route = createFileRoute('/settings/about')({ function RouteComponent () { const { data: systemInfo } = useQuery(systemInfoQuery); - return
    !cw*kH(Hm$AT;6L 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 diff --git a/vendors/es-de/emulators.linux.arm.sqlite b/vendors/es-de/emulators.linux.arm.sqlite index cb191788d01e3d9317bab51486f6dc28dc453869..349ec0fdcbe155921c3c83384c16b9784e5479c7 100644 GIT binary patch delta 13876 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

    NN81o*UA9b)nH5ciZ^7-A9prbMM!2>MbwK*f02 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 diff --git a/vendors/es-de/emulators.linux.x64.sqlite b/vendors/es-de/emulators.linux.x64.sqlite index 2db0497218fdfbe499c830955326c92027705a96..56fe557d022273b00e645faf985e8720d790e1ab 100644 GIT binary patch delta 19102 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 CC6Pz~ delta 14529 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 G*ZeLLec%lR%hEDx{G@Z|6+0pkf?l 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.nanodata.armsx2.debug/kr.co.iefriends.pcsx2.MainActivity + + + + aenu.ax360e/aenu.ax360e.EmulatorActivity + aenu.ax360e.free/aenu.ax360e.EmulatorActivity + + @@ -372,6 +379,12 @@ com.PceEmu/com.imagine.BaseActivity + + + + io.wip.pico8/com.godot.game.GodotAppLauncher + + @@ -462,6 +475,12 @@ com.fms.speccy/com.fms.emulib.TVActivity + + + + com.izzy2lost.super3/com.izzy2lost.super3.MainActivity + + @@ -513,6 +532,12 @@ com.cmodded.winlator/com.winlator.XServerDisplayActivity + + + + com.izzy2lost.x1box/.LauncherActivity + + diff --git a/vendors/es-de/systems/android/es_systems.xml b/vendors/es-de/systems/android/es_systems.xml index 0314f9d..6387636 100644 --- a/vendors/es-de/systems/android/es_systems.xml +++ b/vendors/es-de/systems/android/es_systems.xml @@ -387,6 +387,7 @@ %EMULATOR_RETROARCH% %EXTRA_CONFIGFILE%=%EXTERNALDATA%/Android/data/%ANDROIDPACKAGE%/files/retroarch.cfg %EXTRA_LIBRETRO%=%INTERNALDATA%/%ANDROIDPACKAGE%/cores/flycast_libretro_android.so %EXTRA_ROM%=%ROM% %EMULATOR_FLYCAST% %ACTION%=android.intent.action.VIEW %DATA%=%ROMSAF% %EMULATOR_PLAY!% %ACTION%=android.intent.action.VIEW %DATA%=%ROMSAF% + %EMULATOR_DOLPHIN% %ACTION%=android.intent.action.MAIN %CATEGORY%=android.intent.category.LEANBACK_LAUNCHER %EXTRA_AutoStartFile%=%ROMSAF% arcade consolearcade @@ -1060,6 +1061,7 @@ Sega Model 3 %ROMPATH%/model3 .7z .7Z .zip .ZIP + %EMULATOR_SUPER3% %ACTION%=android.intent.action.VIEW %DATA%=%ROMSAF% %EMULATOR_RETROARCH% %EXTRA_CONFIGFILE%=%EXTERNALDATA%/Android/data/%ANDROIDPACKAGE%/files/retroarch.cfg %EXTRA_LIBRETRO%=%INTERNALDATA%/%ANDROIDPACKAGE%/cores/mamearcade_libretro_android.so %EXTRA_ROM%=%ROM% %EMULATOR_MAME4DROID-CURRENT% %ACTION%=android.intent.action.VIEW %EXTRA_cli_params%="-rompath '%GAMEDIRRAW%;%ROMPATHRAW%/model3'" %DATA%=%ROMPROVIDER% arcade @@ -1426,6 +1428,7 @@ PICO-8 Fantasy Console %ROMPATH%/pico8 .p8 .P8 .png .PNG + %EMULATOR_PICO-8% %DATA%=%ROMSAF% %EMULATOR_RETROARCH% %EXTRA_CONFIGFILE%=%EXTERNALDATA%/Android/data/%ANDROIDPACKAGE%/files/retroarch.cfg %EXTRA_LIBRETRO%=%INTERNALDATA%/%ANDROIDPACKAGE%/cores/fake08_libretro_android.so %EXTRA_ROM%=%ROM% %EMULATOR_RETROARCH% %EXTRA_CONFIGFILE%=%EXTERNALDATA%/Android/data/%ANDROIDPACKAGE%/files/retroarch.cfg %EXTRA_LIBRETRO%=%INTERNALDATA%/%ANDROIDPACKAGE%/cores/retro8_libretro_android.so %EXTRA_ROM%=%ROM% %EMULATOR_INFINITY% %ACTION%=android.intent.action.VIEW %DATA%=%ROMPROVIDER% @@ -1764,7 +1767,7 @@ steam Valve Steam %ROMPATH%/steam - .steam + .pcgame .steam %EMULATOR_GAMENATIVE% %ACTION%=app.gamenative.LAUNCH_GAME %EXTRAINTEGER_app_id%=%INJECT%=%ROM% %EMULATOR_GAMEHUB-LITE% %ACTION%=gamehub.lite.LAUNCH_GAME %EXTRABOOL_autoStartGame%=true %EXTRA_steamAppId%=%INJECT%=%ROM% %EXTRA_localGameId%=%INJECT%=%ROM% %EMULATOR_GAMEHUB-LITE% %ACTION%=gamehub.lite.LAUNCH_GAME %EXTRABOOL_autoStartGame%=true %EXTRA_localGameId%=%INJECT%=%ROM% @@ -1914,8 +1917,8 @@ triforce Namco-Sega-Nintendo Triforce %ROMPATH%/triforce - .7z .7Z .zip .ZIP - PLACEHOLDER %ROM% + .ciso .CISO .dff .DFF .dol .DOL .elf .ELF .gcm .GCM .gcz .GCZ .iso .ISO .json .JSON .m3u .M3U .rvz .RVZ .tgc .TGC .wad .WAD .wbfs .WBFS .wia .WIA .7z .7Z .zip .ZIP + %EMULATOR_DOLPHIN% %ACTION%=android.intent.action.MAIN %CATEGORY%=android.intent.category.LEANBACK_LAUNCHER %EXTRA_AutoStartFile%=%ROMSAF% arcade triforce @@ -2048,7 +2051,7 @@ windows Microsoft Windows %ROMPATH%/windows - .desktop .steam + .desktop .epic .gog .pcgame .steam %EMULATOR_WINLATOR-CMOD% %ACTIVITY_CLEAR_TASK% %ACTIVITY_CLEAR_TOP% %EXTRA_shortcut_path%=%ROM% %EMULATOR_WINLATOR-GLIBC% %ACTIVITY_CLEAR_TASK% %ACTIVITY_CLEAR_TOP% %EXTRA_shortcut_path%=%ROM% %EMULATOR_WINLATOR-PROOT% %ACTIVITY_CLEAR_TASK% %ACTIVITY_CLEAR_TOP% %EXTRA_shortcut_path%=%ROM% @@ -2121,8 +2124,8 @@ xbox Microsoft Xbox %ROMPATH%/xbox - .7z .7Z .zip .ZIP - PLACEHOLDER %ROM% + .iso .ISO .xiso .XISO + %EMULATOR_X1-BOX% %ACTION%=android.intent.action.VIEW %DATA%=%ROMSAF% xbox xbox @@ -2130,8 +2133,8 @@ xbox360 Microsoft Xbox 360 %ROMPATH%/xbox360 - .7z .7Z .zip .ZIP - PLACEHOLDER %ROM% + . .iso .ISO .xex .XEX .zar .ZAR + %EMULATOR_AX360E% %ACTION%=aenu.intent.action.AX360E %EXTRA_game_uri%=%ROMSAF% xbox360 xbox360 diff --git a/vendors/es-de/systems/linux/es_systems.xml b/vendors/es-de/systems/linux/es_systems.xml index 442ee96..3d51bda 100644 --- a/vendors/es-de/systems/linux/es_systems.xml +++ b/vendors/es-de/systems/linux/es_systems.xml @@ -439,6 +439,7 @@ %EMULATOR_PLAY!% --fullscreen --disc %ROM% %ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM% %EMULATOR_RPCS3% --no-gui %RPCS3_GAMEID%:%INJECT%=%BASENAME%.ps3 + %INJECT%=%BASENAME%.esprefix %EMULATOR_DOLPHIN% -b -e %ROM% %INJECT%=%BASENAME%.esprefix %EMULATOR_TRIFORCE% -b -e %ROM% %INJECT%=%BASENAME%.esprefix %EMULATOR_XEMU% -dvd_path %ROM% %ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM% @@ -2141,6 +2142,7 @@ Namco-Sega-Nintendo Triforce %ROMPATH%/triforce .ciso .CISO .dff .DFF .dol .DOL .elf .ELF .gcm .GCM .gcz .GCZ .iso .ISO .json .JSON .m3u .M3U .rvz .RVZ .tgc .TGC .wad .WAD .wbfs .WBFS .wia .WIA .7z .7Z .zip .ZIP + %INJECT%=%BASENAME%.esprefix %EMULATOR_DOLPHIN% -b -e %ROM% %INJECT%=%BASENAME%.esprefix %EMULATOR_TRIFORCE% -b -e %ROM% arcade triforce @@ -2373,7 +2375,7 @@ xbox Microsoft Xbox %ROMPATH%/xbox - .iso .ISO + .iso .ISO .xiso .XISO %INJECT%=%BASENAME%.esprefix %EMULATOR_XEMU% -dvd_path %ROM% xbox xbox diff --git a/vendors/es-de/systems/linuxarm/es_systems.xml b/vendors/es-de/systems/linuxarm/es_systems.xml index 1a6dfb7..004c79f 100644 --- a/vendors/es-de/systems/linuxarm/es_systems.xml +++ b/vendors/es-de/systems/linuxarm/es_systems.xml @@ -2244,7 +2244,7 @@ xbox Microsoft Xbox %ROMPATH%/xbox - .iso .ISO + .iso .ISO .xiso .XISO %INJECT%=%BASENAME%.esprefix %EMULATOR_XEMU% -dvd_path %ROM% xbox xbox diff --git a/vendors/es-de/systems/macos/es_systems.xml b/vendors/es-de/systems/macos/es_systems.xml index cb2bec9..4a6742e 100644 --- a/vendors/es-de/systems/macos/es_systems.xml +++ b/vendors/es-de/systems/macos/es_systems.xml @@ -421,6 +421,7 @@ %EMULATOR_PLAY!% --fullscreen --disc %ROM% %RUNINBACKGROUND% %ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM% %EMULATOR_RPCS3% --no-gui %RPCS3_GAMEID%:%INJECT%=%BASENAME%.ps3 + %EMULATOR_DOLPHIN% -b -e %ROM% %EMULATOR_XEMU% -dvd_path %ROM% %ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM% arcade @@ -2005,7 +2006,7 @@ Namco-Sega-Nintendo Triforce %ROMPATH%/triforce .ciso .CISO .dff .DFF .dol .DOL .elf .ELF .gcm .GCM .gcz .GCZ .iso .ISO .json .JSON .m3u .M3U .rvz .RVZ .tgc .TGC .wad .WAD .wbfs .WBFS .wia .WIA .7z .7Z .zip .ZIP - PLACEHOLDER %ROM% + %EMULATOR_DOLPHIN% -b -e %ROM% arcade triforce @@ -2218,7 +2219,7 @@ xbox Microsoft Xbox %ROMPATH%/xbox - .iso .ISO + .iso .ISO .xiso .XISO %EMULATOR_XEMU% -dvd_path %ROM% xbox xbox diff --git a/vendors/es-de/systems/unix/es_systems.xml b/vendors/es-de/systems/unix/es_systems.xml index 4d7eb15..04aa599 100644 --- a/vendors/es-de/systems/unix/es_systems.xml +++ b/vendors/es-de/systems/unix/es_systems.xml @@ -424,6 +424,7 @@ %EMULATOR_MEDNAFEN% -force_module ss %ROM% %ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM% %EMULATOR_RPCS3% --no-gui %RPCS3_GAMEID%:%INJECT%=%BASENAME%.ps3 + %INJECT%=%BASENAME%.esprefix %EMULATOR_DOLPHIN% -b -e %ROM% %INJECT%=%BASENAME%.esprefix %EMULATOR_TRIFORCE% -b -e %ROM% %INJECT%=%BASENAME%.esprefix %EMULATOR_XEMU% -dvd_path %ROM% %ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM% @@ -2060,6 +2061,7 @@ Namco-Sega-Nintendo Triforce %ROMPATH%/triforce .ciso .CISO .dff .DFF .dol .DOL .elf .ELF .gcm .GCM .gcz .GCZ .iso .ISO .json .JSON .m3u .M3U .rvz .RVZ .tgc .TGC .wad .WAD .wbfs .WBFS .wia .WIA .7z .7Z .zip .ZIP + %INJECT%=%BASENAME%.esprefix %EMULATOR_DOLPHIN% -b -e %ROM% %INJECT%=%BASENAME%.esprefix %EMULATOR_TRIFORCE% -b -e %ROM% arcade triforce @@ -2277,7 +2279,7 @@ xbox Microsoft Xbox %ROMPATH%/xbox - .iso .ISO + .iso .ISO .xiso .XISO %INJECT%=%BASENAME%.esprefix %EMULATOR_XEMU% -dvd_path %ROM% xbox xbox diff --git a/vendors/es-de/systems/windows/es_systems.xml b/vendors/es-de/systems/windows/es_systems.xml index 937b7ae..92c18bc 100644 --- a/vendors/es-de/systems/windows/es_systems.xml +++ b/vendors/es-de/systems/windows/es_systems.xml @@ -437,6 +437,7 @@ %STARTDIR%=%EMUDIR% %EMULATOR_PLAY!% --fullscreen --disc %ROM% %HIDEWINDOW% %ESCAPESPECIALS% %EMULATOR_OS-SHELL% /C %ROM% %EMULATOR_RPCS3% --no-gui %RPCS3_GAMEID%:%INJECT%=%BASENAME%.ps3 + %EMULATOR_DOLPHIN% -b -e %ROM% %EMULATOR_TRIFORCE% -b -e %ROM% %STARTDIR%=%EMUDIR% %EMULATOR_XEMU% -dvd_path %ROM% %STARTDIR%=%EMUDIR% %EMULATOR_CXBX-RELOADED% %ROM% @@ -1642,7 +1643,7 @@ ps3 Sony PlayStation 3 %ROMPATH%\ps3 - .lnk .LNK .ps3 .PS3 .ps3dir .PS3DIR + .lnk .LNK .ps3 .PS3 .ps3dir .PS3DIR .iso .ISO %HIDEWINDOW% %ESCAPESPECIALS% %EMULATOR_OS-SHELL% /C %ROM% %EMULATOR_RPCS3% --no-gui %RPCS3_GAMEID%:%INJECT%=%BASENAME%.ps3 %EMULATOR_RPCS3% --no-gui %ROM% @@ -2132,6 +2133,7 @@ Namco-Sega-Nintendo Triforce %ROMPATH%\triforce .ciso .CISO .dff .DFF .dol .DOL .elf .ELF .gcm .GCM .gcz .GCZ .iso .ISO .json .JSON .m3u .M3U .rvz .RVZ .tgc .TGC .wad .WAD .wbfs .WBFS .wia .WIA .7z .7Z .zip .ZIP + %EMULATOR_DOLPHIN% -b -e %ROM% %EMULATOR_TRIFORCE% -b -e %ROM% arcade triforce @@ -2353,7 +2355,7 @@ xbox Microsoft Xbox %ROMPATH%\xbox - .iso .ISO .xbe .XBE + .iso .ISO .xbe .XBE .xiso .XISO %STARTDIR%=%EMUDIR% %EMULATOR_XEMU% -dvd_path %ROM% %STARTDIR%=%EMUDIR% %EMULATOR_CXBX-RELOADED% %ROM% xbox diff --git a/vite.config.ts b/vite.config.ts index e87e1c7..7029442 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,7 +8,6 @@ import staticAssetsPlugin from 'vite-static-assets-plugin'; import os from 'node:os'; import tsconfigPaths from 'vite-tsconfig-paths'; import { host } from "./src/bun/utils/host"; -import { VitePWA } from 'vite-plugin-pwa'; export default defineConfig(({ command }) => { @@ -29,7 +28,7 @@ export default defineConfig(({ command }) => target: 'react', routesDirectory: "./routes/", generatedRouteTree: "./gen/routeTree.gen.ts", - autoCodeSplitting: command === 'build', + autoCodeSplitting: true, routeFileIgnorePrefix: "-", quoteStyle: "single" }), @@ -71,7 +70,6 @@ export default defineConfig(({ command }) => return 'zod'; if (id.includes('node_modules/@tanstack')) return 'tanstack'; - console.log(id); if (id.includes('node_modules')) return 'vendor'; if (id.endsWith('SvgIcon.tsx')) From 91ee7196332313518324cf7195f64d0e92b2cc8b Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 22 Mar 2026 16:34:33 +0200 Subject: [PATCH 12/65] feat: moved to npm package for the store --- src/bun/api/jobs/update-store.ts | 82 ++++++---------------- src/bun/api/store/services/gamesService.ts | 9 ++- src/shared/constants.ts | 1 + 3 files changed, 29 insertions(+), 63 deletions(-) diff --git a/src/bun/api/jobs/update-store.ts b/src/bun/api/jobs/update-store.ts index a842b1d..74872c3 100644 --- a/src/bun/api/jobs/update-store.ts +++ b/src/bun/api/jobs/update-store.ts @@ -1,80 +1,40 @@ import { ensureDir } from "fs-extra"; import { IJob, JobContext } from "../task-queue"; -import { getStoreFolder } from "../store/services/gamesService"; -import z from "zod"; +import { getStoreRootFolder } from "../store/services/gamesService"; +import { STORE_VERSION } from "@/shared/constants"; +import { tmpdir } from "node:os"; +import path from "node:path"; export default class UpdateStoreJob implements IJob { static id = "update-store" as const; - static origin = "https://github.com/simeonradivoev/gameflow-store.git"; - static branch = "master"; - static dataSchema = z.never(); + packageName: string; + registry: URL; + storeVersion: string; - async gitCommand (commands: string[], dir: string) + constructor() { - const proc = Bun.spawn(['git', ...commands], { - cwd: dir, - stdout: "pipe", - stderr: "pipe", - }); - - const [output] = await Promise.all([ - new Response(proc.stdout).text(), - proc.exited, - ]); - - return output.trim(); - } - - async isGitRepo (dir: string) - { - return (await this.gitCommand(["rev-parse", "--is-inside-work-tree"], dir)) === 'true'; - } - - async getOrigin (dir: string) - { - const origin = await this.gitCommand(["remote", "get-url", "origin"], dir); - return origin; - } - - async hasChanges (dir: string) - { - return (await this.gitCommand(["status", "--porcelain"], dir)).length > 0; + 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 ?? STORE_VERSION; } async start (context: JobContext) { if (process.env.CUSTOM_STORE_PATH) return; - const storeFolder = getStoreFolder(); + const tempCache = path.join(tmpdir(), "gameflow-bun-cache"); + const storeFolder = getStoreRootFolder(); await ensureDir(storeFolder); - context.setProgress(10); - if (await this.isGitRepo(storeFolder)) - { - const existingOrigin = await this.getOrigin(storeFolder); - if (existingOrigin !== UpdateStoreJob.origin) - { - throw new Error(`Git Repo in downloads is not valid. It has origin of ${existingOrigin}. Repo must be of ${UpdateStoreJob.origin}`); - } - // check for uncommitted changes - const status = await this.gitCommand([" status", "--porcelain"], storeFolder); - if (status.length > 0) - { - console.log("Cleaning local changes..."); - await this.gitCommand(["reset", "--hard"], storeFolder); - await this.gitCommand(["clean", "-fd"], storeFolder); + await Bun.spawn([process.execPath, "install", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], { + cwd: storeFolder, + stdout: 'pipe', + stderr: 'pipe', + env: { + BUN_BE_BUN: "1", + BUN_INSTALL_CACHE_DIR: tempCache } - - // fetch & reset to remote - await this.gitCommand(["fetch", "origin"], storeFolder); - await this.gitCommand(["reset", "--hard", `origin/${UpdateStoreJob.branch}`], storeFolder); - console.log("Shop Repo updated"); - } else - { - context.setProgress(50); - await this.gitCommand(["clone", "--depth", "1", "--branch", UpdateStoreJob.branch, UpdateStoreJob.origin, '.'], storeFolder); - context.setProgress(100); - } + }).exited; } } \ No newline at end of file diff --git a/src/bun/api/store/services/gamesService.ts b/src/bun/api/store/services/gamesService.ts index 3ebb355..9247823 100644 --- a/src/bun/api/store/services/gamesService.ts +++ b/src/bun/api/store/services/gamesService.ts @@ -75,11 +75,16 @@ export async function getStoreGameFromPath (path: string) return game; } +export function getStoreRootFolder () +{ + const downlodDir = config.get('downloadPath'); + return path.join(downlodDir, "store"); +} + export function getStoreFolder () { if (process.env.CUSTOM_STORE_PATH) return process.env.CUSTOM_STORE_PATH; - const downlodDir = config.get('downloadPath'); - return path.join(downlodDir, "store"); + return path.join(getStoreRootFolder(), "node_modules", process.env.STORE_PACKAGE_NAME ?? "@simeonradivoev/gameflow-store"); } export async function getStoreEmulatorPackage (id: string) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index fe49793..a97a739 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -14,6 +14,7 @@ export const RPC_PORT = 8787; export const RPC_URL = (host: string) => `http://${host}:${RPC_PORT}`; export const EMULATORJS_URL = (host: string) => `http://${host}:${EMULATORJS_PORT}`; export const SOCKETS_URL = (host: string) => `ws://${host}:${RPC_PORT}`; +export const STORE_VERSION = "^0"; export const DefaultRommStaleTime = 60 * 1000; // A minute export interface GameMeta From d85268fad78b51b802f2d752e5c118bde733c57d Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 22 Mar 2026 16:39:30 +0200 Subject: [PATCH 13/65] doc: Added license --- LICENSE | 661 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 661 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. From a78e75335f937048b24ec0e4b1177c3156b44b6a Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Wed, 25 Mar 2026 21:51:10 +0200 Subject: [PATCH 14/65] feat First implementation of plugins system feat: Added PCSX2 integration feat: Revamped UI a bit made it look better on light mode --- bun.lock | 7 + package.json | 3 + scripts/package-bun.ts | 12 +- src/bun/api/app.ts | 32 +- src/bun/api/drives.ts | 1 - src/bun/api/games/games.ts | 77 +-- src/bun/api/games/platforms.ts | 1 - .../api/games/services/launchGameService.ts | 111 +--- src/bun/api/games/services/statusService.ts | 41 +- src/bun/api/games/services/utils.ts | 7 +- src/bun/api/hooks/app.ts | 6 + src/bun/api/hooks/emulators.ts | 21 + src/bun/api/jobs/bios-download-job.ts | 85 +++ src/bun/api/jobs/emulator-download-job.ts | 72 ++- src/bun/api/jobs/install-job.ts | 20 +- src/bun/api/jobs/jobs.ts | 38 +- src/bun/api/jobs/launch-game-job.ts | 121 ++++ src/bun/api/jobs/login-job.ts | 2 +- src/bun/api/jobs/update-store.ts | 14 +- src/bun/api/notifications.ts | 6 +- .../PCSX2.ini | 493 +++++++++++++++++ .../package.json | 14 + .../pcsx2.ts | 55 ++ src/bun/api/plugins/plugin-manager.ts | 94 ++++ src/bun/api/plugins/plugins.ts | 37 ++ src/bun/api/plugins/register-plugins.ts | 25 + src/bun/api/rpc.ts | 4 +- src/bun/api/settings/services.ts | 5 +- .../api/store/services/emulatorsService.ts | 17 +- src/bun/api/store/store.ts | 42 +- src/bun/api/system.ts | 2 +- src/bun/api/task-queue.ts | 13 +- src/bun/types/types.d.ts | 21 +- src/bun/types/typesc.schema.ts | 35 ++ src/bun/utils.ts | 34 +- src/bun/utils/downloader.ts | 5 +- .../components/AnimatedBackground.tsx | 9 +- src/mainview/components/CardElement.tsx | 5 +- src/mainview/components/CardList.tsx | 1 + src/mainview/components/CollectionList.tsx | 2 +- src/mainview/components/CollectionsDetail.tsx | 2 - src/mainview/components/ContextDialog.tsx | 68 +-- src/mainview/components/Error.tsx | 1 + src/mainview/components/FilePicker.tsx | 2 +- src/mainview/components/FocusDots.tsx | 2 +- src/mainview/components/FocusTooltip.tsx | 36 ++ src/mainview/components/FrontEndGameCard.tsx | 4 +- src/mainview/components/GameList.tsx | 8 +- src/mainview/components/Header.tsx | 116 ++-- src/mainview/components/LoadMoreButton.tsx | 1 - src/mainview/components/NotFound.tsx | 1 + src/mainview/components/Notifications.tsx | 4 +- src/mainview/components/Screenshots.tsx | 2 +- src/mainview/components/Shortcuts.tsx | 80 +-- src/mainview/components/game/Achievements.tsx | 2 +- src/mainview/components/game/ActionButton.tsx | 42 ++ .../components/game/ActionButtons.tsx | 84 +++ src/mainview/components/game/Details.tsx | 95 ++++ src/mainview/components/game/MainActions.tsx | 207 +++++++ src/mainview/components/options/Button.tsx | 22 +- .../components/options/OptionDropdown.tsx | 3 +- .../components/options/OptionInput.tsx | 14 +- .../components/options/PathSettingsOption.tsx | 11 +- .../components/options/SettingsOption.tsx | 27 +- .../components/store/EmulatorsSection.tsx | 11 +- .../components/store/GamesSection.tsx | 3 +- .../store/MissingEmulatorsSection.tsx | 2 +- .../components/store/StoreEmulatorCard.tsx | 19 +- src/mainview/emulatorjs/emulator.ts | 2 +- src/mainview/gen/routeTree.gen.ts | 21 + src/mainview/index.css | 51 +- src/mainview/index.html | 2 +- src/mainview/routes/__root.tsx | 18 +- src/mainview/routes/game/$source.$id.tsx | 519 ++---------------- src/mainview/routes/index.tsx | 14 +- src/mainview/routes/launcher.$source.$id.tsx | 24 +- src/mainview/routes/settings/directories.tsx | 6 +- src/mainview/routes/settings/emulators.tsx | 73 ++- src/mainview/routes/settings/plugins.tsx | 55 ++ src/mainview/routes/settings/route.tsx | 7 + .../routes/store/details.emulator.$id.tsx | 136 ++++- src/mainview/routes/store/tab/index.tsx | 16 +- src/mainview/routes/store/tab/route.tsx | 11 +- src/mainview/scripts/clientApi.ts | 5 +- src/mainview/scripts/contexts.ts | 1 - src/mainview/scripts/queries/plugins.ts | 21 + src/mainview/scripts/queries/romm.ts | 4 +- src/mainview/scripts/queries/settings.ts | 3 +- src/mainview/scripts/queries/store.ts | 16 +- src/mainview/scripts/types.ts | 4 +- src/mainview/scripts/utils.ts | 33 +- src/mainview/types.d.ts | 10 +- src/shared/constants.ts | 182 +----- src/shared/public-types.ts | 3 - src/shared/types..d.ts | 202 +++++++ 95 files changed, 2639 insertions(+), 1259 deletions(-) create mode 100644 src/bun/api/hooks/app.ts create mode 100644 src/bun/api/hooks/emulators.ts create mode 100644 src/bun/api/jobs/bios-download-job.ts create mode 100644 src/bun/api/jobs/launch-game-job.ts create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts create mode 100644 src/bun/api/plugins/plugin-manager.ts create mode 100644 src/bun/api/plugins/plugins.ts create mode 100644 src/bun/api/plugins/register-plugins.ts create mode 100644 src/bun/types/typesc.schema.ts create mode 100644 src/mainview/components/FocusTooltip.tsx create mode 100644 src/mainview/components/game/ActionButton.tsx create mode 100644 src/mainview/components/game/ActionButtons.tsx create mode 100644 src/mainview/components/game/Details.tsx create mode 100644 src/mainview/components/game/MainActions.tsx create mode 100644 src/mainview/routes/settings/plugins.tsx create mode 100644 src/mainview/scripts/queries/plugins.ts delete mode 100644 src/shared/public-types.ts create mode 100644 src/shared/types..d.ts diff --git a/bun.lock b/bun.lock index e4c0aff..be22c73 100644 --- a/bun.lock +++ b/bun.lock @@ -18,12 +18,14 @@ "fs-extra": "^11.3.3", "get-folder-size": "^5.0.0", "jimp": "^1.6.0", + "mustache": "^4.2.0", "node-disk-info": "^1.3.0", "node-downloader-helper": "^2.1.10", "node-stream-zip": "^1.15.0", "open": "^11.0.0", "pathe": "^2.0.3", "systeminformation": "^5.31.1", + "tapable": "^2.3.0", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", "ts-igdb-client": "^0.4.2", @@ -48,6 +50,7 @@ "@tanstack/zod-adapter": "^1.162.4", "@types/bun": "latest", "@types/fs-extra": "^11.0.4", + "@types/mustache": "^4.2.6", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", @@ -606,6 +609,8 @@ "@types/minimist": ["@types/minimist@1.2.5", "", {}, "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag=="], + "@types/mustache": ["@types/mustache@4.2.6", "", {}, "sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw=="], + "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], @@ -1210,6 +1215,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], diff --git a/package.json b/package.json index a0070eb..23a31de 100644 --- a/package.json +++ b/package.json @@ -53,12 +53,14 @@ "fs-extra": "^11.3.3", "get-folder-size": "^5.0.0", "jimp": "^1.6.0", + "mustache": "^4.2.0", "node-disk-info": "^1.3.0", "node-downloader-helper": "^2.1.10", "node-stream-zip": "^1.15.0", "open": "^11.0.0", "pathe": "^2.0.3", "systeminformation": "^5.31.1", + "tapable": "^2.3.0", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", "ts-igdb-client": "^0.4.2", @@ -83,6 +85,7 @@ "@tanstack/zod-adapter": "^1.162.4", "@types/bun": "latest", "@types/fs-extra": "^11.0.4", + "@types/mustache": "^4.2.6", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", diff --git a/scripts/package-bun.ts b/scripts/package-bun.ts index f22dd96..e83eb8b 100644 --- a/scripts/package-bun.ts +++ b/scripts/package-bun.ts @@ -1,17 +1,24 @@ import fs from "node:fs/promises"; import path, { } from "node:path"; import os from "node:os"; +import app from '../package.json'; const system = getPlatform(); const buildSubDir = process.env.BUILD_DIR ?? `./build/${system.platform}`; const compileOption: Bun.CompileBuildOptions = { outfile: "gameflow", - execArgv: ['--windows-hide-console'], autoloadTsconfig: true, autoloadPackageJson: true, autoloadDotenv: true, autoloadBunfig: true, + windows: { + hideConsole: true, + icon: './src/mainview/public/favicon.ico', + title: app.displayName, + description: app.description, + version: app.version + }, }; if (process.env.TARGET) @@ -63,8 +70,9 @@ await Bun.build({ } } }); - build.onEnd(async () => + build.onEnd(async (b) => { + await fs.cp('./dist', `${buildSubDir}/dist`, { recursive: true }); await fs.cp('./drizzle', `${buildSubDir}/drizzle`, { recursive: true }); await fs.cp(`./vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, `${buildSubDir}/vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, { recursive: true }); diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index b7b4050..39b84ed 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -8,21 +8,21 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { drizzle } from "drizzle-orm/bun-sqlite"; import Conf from "conf"; import projectPackage from '~/package.json'; -import { Notification, SettingsSchema, SettingsType } from "@shared/constants"; +import { SettingsSchema, SettingsType } from "@shared/constants"; import { client } from "@clients/romm/client.gen"; import * as schema from "@schema/app"; import cacheSchema from "@schema/cache"; import * as emulatorSchema from "@schema/emulators"; import { login, logout } from "./auth"; import os from 'node:os'; -import { ActiveGame } from "../types/types"; import EventEmitter from "node:events"; -import { ErrorLike } from "bun"; -import { appPath, getErrorMessage } from "../utils"; +import { appPath } from "../utils"; import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; import { ensureDir } from "fs-extra"; import UpdateStoreJob from "./jobs/update-store"; import { getStoreFolder } from "./store/services/gamesService"; +import { PluginManager } from "./plugins/plugin-manager"; +import registerPlugins from "./plugins/register-plugins"; export const config = new Conf({ projectName: projectPackage.name, @@ -31,7 +31,7 @@ export const config = new Conf({ defaults: SettingsSchema.parse({ downloadPath: path.join(os.homedir(), "gameflow"), windowSize: { width: 1280, height: 800 } - } satisfies SettingsType), + }), }); export const customEmulators = new Conf>({ projectName: projectPackage.name, @@ -64,21 +64,9 @@ export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema }); export const taskQueue = new TaskQueue(); config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v })); await login(); -export let activeGame: ActiveGame | undefined; -export function setActiveGame (game: ActiveGame) -{ - if (activeGame) throw new Error("Only one active game at a time"); - return activeGame = game; -} +export const plugins = new PluginManager(); +registerPlugins(plugins); export const events = new EventEmitter(); -events.addListener('activegameexit', ({ error }) => -{ - activeGame = undefined; - if (error) - { - events.emit('notification', { message: getErrorMessage(error), type: 'error' }); - } -}); config.onDidChange('downloadPath', () => reloadDatabase()); taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); @@ -110,9 +98,3 @@ export async function reloadDatabase () `); } -interface AppEventMap -{ - activegameexit: [{ source: string, id: string, subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }]; - exitapp: []; - notification: [Notification]; -} \ No newline at end of file diff --git a/src/bun/api/drives.ts b/src/bun/api/drives.ts index e714038..2df0dd8 100644 --- a/src/bun/api/drives.ts +++ b/src/bun/api/drives.ts @@ -1,4 +1,3 @@ -import { Drive } from "@/shared/constants"; import si from 'systeminformation'; import fs from 'node:fs'; import os from "node:os"; diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 7f67457..c065657 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -1,27 +1,27 @@ import Elysia, { status } from "elysia"; -import { activeGame, config, db, emulatorsDb, events, taskQueue } from "../app"; -import { and, eq, getTableColumns, inArray, not, or, sql } from "drizzle-orm"; -import z, { number } from "zod"; +import { config, db, emulatorsDb, taskQueue } from "../app"; +import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm"; +import z from "zod"; import * as schema from "@schema/app"; import fs from "node:fs/promises"; -import { FrontEndEmulator, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedEmulator, GameListFilterSchema, SERVER_URL } from "@shared/constants"; -import { getCurrentUserApiUsersMeGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm"; +import { GameListFilterSchema, SERVER_URL } from "@shared/constants"; +import { getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm"; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; -import { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameDetailed, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; +import { convertLocalToFrontend, convertRomToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService"; import { errorToResponse } from "elysia/adapter/bun/handler"; import { getEmulatorsForSystem, launchCommand } from "./services/launchGameService"; -import { getErrorMessage, SeededRandom, shuffleInPlace } from "@/bun/utils"; +import { getErrorMessage, SeededRandom } from "@/bun/utils"; import { defaultFormats, defaultPlugins } from 'jimp'; import { createJimp } from "@jimp/core"; import webp from "@jimp/wasm-webp"; import * as emulatorSchema from '@schema/emulators'; -import { buildStoreFrontendEmulatorSystems, extractStoreGameSourceId, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGame, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService"; +import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService"; import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService"; -import { use } from "react"; import { CACHE_KEYS, getOrCached } from "../cache"; import { host } from "@/bun/utils/host"; +import { LaunchGameJob } from "../jobs/launch-game-job"; // A custom jimp that supports webp const Jimp = createJimp({ @@ -31,23 +31,30 @@ const Jimp = createJimp({ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height, noBlur }: { blur?: number, width?: number, height?: number; noBlur?: boolean; }) { - if (blur && !noBlur) - { - const jimp = await Jimp.read(img); - if (width) - { - jimp.resize({ w: width, h: height }); - } - if (height) - { - jimp.resize({ w: width, h: height }); - } - if (blur) - { - jimp.blur(blur); - } - return jimp.getBuffer('image/png'); + try + { + if ((blur && !noBlur) || width || height) + { + const jimp = await Jimp.read(img); + + if (blur && !noBlur) + { + jimp.blur(blur); + } + + if (width) + { + jimp.resize({ w: width, h: height }); + } else if (height) + { + jimp.resize({ w: width, h: height }); + } + return jimp.getBuffer('image/webp'); + } + } catch (e) + { + } if (typeof img === 'string') @@ -267,7 +274,7 @@ export default new Elysia() { return { name: 'EMULATORJS', - validSource: { binPath: SERVER_URL(host), type: 'js', exists: true }, + validSource: { binPath: SERVER_URL(host), type: 'embedded', exists: true }, logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, systems: [], gameCount: 0 @@ -312,11 +319,11 @@ export default new Elysia() }) .post('/game/:source/:id/install', async ({ params: { id, source } }) => { - if (!taskQueue.findJob(`install-rom-${source}-${id}`, InstallJob)) + if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob)) { if (source === 'romm' || source === 'store') { - taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id, { dryRun: true })); + taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, id, { dryRun: true })); return status(200); } @@ -359,7 +366,7 @@ export default new Elysia() if (validCommand) { // launch command waits for the game to exit, we don't want that. - launchCommand(validCommand, source, id, validCommands.gameId); + await launchCommand(validCommand, source, id, validCommands.gameId); return { type: 'application', command: null }; } else { @@ -380,13 +387,10 @@ export default new Elysia() }) .post("/stop", async ({ }) => { - if (activeGame) + const job = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob); + if (job) { - events.emit('activegameexit', { - source: 'local', id: String(activeGame.gameId), - exitCode: null, - signalCode: null - }); + job.abort('cancel'); } }) .get('/emulatorjs/data/cores/*', async ({ params }) => @@ -564,6 +568,9 @@ export default new Elysia() if (g.platform_slug === sourceData.platform_slug) rank += 1; + if (g.id.source === 'local') + rank -= 0.2; + if (g.metadata) { if (g.metadata.companies instanceof Array && g.metadata.companies.some((c: string) => sourceCompaniesSet.has(c))) diff --git a/src/bun/api/games/platforms.ts b/src/bun/api/games/platforms.ts index ee92a3f..64371c0 100644 --- a/src/bun/api/games/platforms.ts +++ b/src/bun/api/games/platforms.ts @@ -3,7 +3,6 @@ import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRo import z from "zod"; import { and, count, eq, getTableColumns, not } from "drizzle-orm"; import { db } from "../app"; -import { FrontEndPlatformType } from "@shared/constants"; import * as schema from "@schema/app"; import { CACHE_KEYS, getOrCached } from "../cache"; diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index 2b7276d..8269285 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -3,103 +3,23 @@ import { which } from 'bun'; import fs from 'node:fs/promises'; import { existsSync, readFileSync } from 'node:fs'; import * as schema from '@schema/emulators'; -import * as appSchema from "@schema/app"; import { eq } from 'drizzle-orm'; -import { activeGame, config, customEmulators, db, emulatorsDb, events, setActiveGame } from '../../app'; +import { config, customEmulators, emulatorsDb, taskQueue } from '../../app'; import os from 'node:os'; -import { $ } from 'bun'; -import { spawn } from 'node:child_process'; -import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm'; -import { CommandEntry, EmulatorSourceType } from '@/shared/constants'; import { cores } from '../../emulatorjs/emulatorjs'; +import { LaunchGameJob } from '../../jobs/launch-game-job'; export const varRegex = /%([^%]+)%/g; export const assignRegex = /(%\w+%)=(\S+) /g; -export async function launchCommand (validCommand: { command: string, startDir?: string; }, source: string, sourceId: string, id: number) +export async function launchCommand (validCommand: CommandEntry, source: string, sourceId: string, id: number) { - if (activeGame && activeGame.process?.killed === false) + if (taskQueue.hasActiveOfType(LaunchGameJob)) { - throw new Error(`${activeGame.name} currently running`); + throw new Error(`${id} currently running`); } - const localGame = await db.query.games.findFirst({ - where: eq(appSchema.games.id, id), columns: { - name: true, - source_id: true, - source: true - } - }); - - await new Promise((resolve, reject) => - { - const game = spawn(validCommand.command, { - shell: true, - cwd: validCommand.startDir - }); - game.stdout.on('data', data => console.log(data)); - game.on('close', (code) => - { - events.emit('activegameexit', { source, id: sourceId, exitCode: code, signalCode: null }); - resolve(code); - }); - game.on('error', e => - { - console.error(e); - events.emit('notification', { message: e.message, type: 'error' }); - reject(e); - }); - - setActiveGame({ - process: game, - name: localGame?.name ?? "Unknown", - gameId: id, - command: validCommand - }); - - function updateRommProps (id: number) - { - updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } }); - events.emit('notification', { message: "Updated Last Played", type: 'success' }); - } - - if (source === 'romm') - { - updateRommProps(Number(sourceId)); - } - else if (localGame?.source === 'romm' && localGame.source_id) - { - updateRommProps(Number(localGame.source_id)); - } - }); - - /* Old spawn lanching, cases issues, needs to be ran as shell - - const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]); - const game = setActiveGame({ - process: Bun.spawn({ - cmd, - env: { - ...process.env - }, - onExit (subprocess, exitCode, signalCode, error) - { - events.emit('activegameexit', { subprocess, exitCode, signalCode, error }); - }, - stdin: "ignore", - stdout: "inherit", - stderr: "inherit", - }), - name: localGame?.name ?? "Unknown", - gameId: validCommand.gameId, - command: validCommand.command.command - }); - - await game.process.exited; - if (game.process.exitCode && game.process.exitCode > 0) - { - return status('Internal Server Error'); - }*/ + taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId)); } /** @@ -277,11 +197,14 @@ export async function getValidLaunchCommands (data: { let validExec = execs.find(e => e.exists); emulator = emulatorName; - return [[value, validExec ? validExec.path : undefined], ['%EMUDIR%', validExec ? escapeWindowsArg(path.dirname(validExec.path)) : undefined]]; + return [ + [value, validExec ? validExec.binPath : undefined] as [string, string | undefined], + [`%EMUSOURCE%`, validExec?.type] as [string, string | undefined], + ['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined]]; } const key = value[0].substring(1, value.length - 1); - return [[value, process.env[key]]]; + return [[value, process.env[key]] as [string, string | undefined]]; })); const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars }; @@ -311,7 +234,13 @@ export async function getValidLaunchCommands (data: { label: label ?? undefined, command: formattedCommand, startDir, - valid: !invalid, emulator + valid: !invalid, emulator, + emulatorSource: vars['%EMUSOURCE%'] as any, + metadata: { + romPath: staticVars['%ROM%'], + emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1], + emulatorDir: vars['%EMUDIR%'] + } } satisfies CommandEntry; })); @@ -328,7 +257,7 @@ export async function findExecsByName (emulatorName: string) return findExecs(emulatorName, emulator); } -export function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): EmulatorSourceType | undefined +export function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): EmulatorSourceEntryType | undefined { const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id); const storeExecName = emulator?.systempath.find(name => existsSync(path.join(storeEmulatorFolder, name))); @@ -342,7 +271,7 @@ export function findStoreEmulatorExec (id: string, emulator?: { systempath: stri export async function findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; }) { - const execs: EmulatorSourceType[] = []; + const execs: EmulatorSourceEntryType[] = []; if (customEmulators.has(id)) { diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 8b56b51..63b621f 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -1,5 +1,5 @@ -import { GameInstallProgress, GameStatusType, RPC_URL, } from "@shared/constants"; -import { activeGame, config, customEmulators, db, events, taskQueue } from "../../app"; +import { RPC_URL, } from "@shared/constants"; +import { config, customEmulators, db, taskQueue } from "../../app"; import { getValidLaunchCommands } from "./launchGameService"; import * as schema from '@schema/app'; import { eq } from "drizzle-orm"; @@ -7,14 +7,13 @@ import { getErrorMessage } from "@/bun/utils"; import { getLocalGameMatch } from "./utils"; import { getRomApiRomsIdGet } from "@/clients/romm"; import fs from 'node:fs/promises'; -import { ErrorLike } from "elysia/universal"; import { getStoreGameFromId } from "../../store/services/gamesService"; import { cores } from "../../emulatorjs/emulatorjs"; import { host } from "@/bun/utils/host"; import Elysia from "elysia"; import z from "zod"; -import data from "@emulators"; import { InstallJob, InstallJobStates } from "../../jobs/install-job"; +import { LaunchGameJob } from "../../jobs/launch-game-job"; class CommandSearchError extends Error { @@ -62,7 +61,10 @@ export async function getValidLaunchCommandsForGame (source: string, id: string) label: "Emulator JS", command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`, valid: true, - emulator: 'EMULATORJS' + emulator: 'EMULATORJS', + metadata: { + romPath: gameUrl + } }); } @@ -111,19 +113,19 @@ export default function buildStatusResponse () { if (data === 'cancel') { - const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob); + const activeTask = taskQueue.findJob(InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }), InstallJob); activeTask?.abort('cancel'); } }, async open (ws) { sendLatests(); + const installJobId = InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }); async function sendLatests () { if (ws.readyState > 1) return; - const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source), columns: { id: true } }); - const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob); + const activeTask = taskQueue.findJob(InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }), InstallJob); if (activeTask) { if (activeTask.status === 'queued') @@ -134,7 +136,7 @@ export default function buildStatusResponse () ws.send({ status: activeTask.state as InstallJobStates, progress: activeTask.progress }); } - } else if (activeGame && activeGame.gameId === localGame?.id) + } else if (taskQueue.hasActiveOfType(LaunchGameJob)) { ws.send({ status: 'playing', details: 'Playing' }); } @@ -189,7 +191,7 @@ export default function buildStatusResponse () } const dispose: Function[] = []; - const handleActiveExit = async (data: { error?: ErrorLike; }) => + const handleActiveExit = async (data: { error?: unknown; }) => { if (data.error) { @@ -200,38 +202,41 @@ export default function buildStatusResponse () } await sendLatests(); }; - events.on('activegameexit', handleActiveExit); - dispose.push(() => events.off('activegameexit', handleActiveExit)); dispose.push(taskQueue.on('progress', (data) => { - if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`) + if (data.id === installJobId) { - ws.send({ status: data.job.state as InstallJobStates, progress: data.progress }); } })); dispose.push(taskQueue.on('queued', (data) => { - if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`) + if (data.id === installJobId) { ws.send({ status: 'queued' }); } })); - dispose.push(taskQueue.on('completed', (data) => + dispose.push(taskQueue.on('ended', (data) => { - if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`) + if (data.id === installJobId) { ws.send({ status: 'refresh' }); + } else if (data.job.job instanceof LaunchGameJob) + { + handleActiveExit({}); } })); dispose.push(taskQueue.on('error', (data) => { - if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`) + if (data.id === installJobId) { ws.send({ status: 'error', error: getErrorMessage(data.error) }); + } else if (data.job.job instanceof LaunchGameJob) + { + handleActiveExit({ error: data.error }); } })); diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index 5cc3a96..ca49808 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -4,11 +4,11 @@ import path from "node:path"; import { config, db, emulatorsDb } from "../../app"; import { and, eq } from "drizzle-orm"; import * as schema from "@schema/app"; -import { FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, StoreGameType } from "@shared/constants"; +import { StoreGameType } from "@shared/constants"; import { DetailedRomSchema, getCurrentUserApiUsersMeGet, getRomApiRomsIdGet, SimpleRomSchema } from "@clients/romm"; import * as emulatorSchema from "@schema/emulators"; -import romm from "@/mainview/scripts/queries/romm"; import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService"; +import { isSteamDeck, isSteamDeckGameMode } from "@/bun/utils"; export async function calculateSize (installPath: string | null) { @@ -29,9 +29,10 @@ export function getLocalGameMatch (id: string, source: string) export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType { + const steamDeck = isSteamDeckGameMode(); const game: FrontEndGameType = { id: { id: String(rom.id), source: 'romm' }, - path_cover: `/api/romm/image/romm${rom.path_cover_large}`, + path_cover: `/api/romm/image/romm${steamDeck ? rom.path_cover_small : rom.path_cover_large}`, last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null, updated_at: new Date(rom.updated_at), slug: rom.slug, diff --git a/src/bun/api/hooks/app.ts b/src/bun/api/hooks/app.ts new file mode 100644 index 0000000..83b797d --- /dev/null +++ b/src/bun/api/hooks/app.ts @@ -0,0 +1,6 @@ +import { GameHooks } from "./emulators"; + +export class GameflowHooks +{ + games = new GameHooks(); +} \ No newline at end of file diff --git a/src/bun/api/hooks/emulators.ts b/src/bun/api/hooks/emulators.ts new file mode 100644 index 0000000..54e783b --- /dev/null +++ b/src/bun/api/hooks/emulators.ts @@ -0,0 +1,21 @@ +import { SyncBailHook, AsyncSeriesHook, SyncWaterfallHook, AsyncSeriesBailHook } from 'tapable'; + +export class GameHooks +{ + /** override the launch command for an emulator + * @param ctx.autoValidCommands The auto generated command for example based on the ES-DE listing + * @param ctx.emulator The emulator ID if any + * @param ctx.game.source The source of the game + * @param ctx.game.sourceId The ID of the source. This could be for example the ROMM ID the game was + * @returns The argument list to be used when running the emulator. + * If no emulator bin in the command entry is found the actual command will be used as the bin. + */ + emulatorLaunch = new AsyncSeriesBailHook<[ctx: { + autoValidCommand: CommandEntry; + game: { + source: string; + sourceId: string; + id: number; + }; + }], string[] | undefined>(['ctx']); +} \ No newline at end of file diff --git a/src/bun/api/jobs/bios-download-job.ts b/src/bun/api/jobs/bios-download-job.ts new file mode 100644 index 0000000..145d701 --- /dev/null +++ b/src/bun/api/jobs/bios-download-job.ts @@ -0,0 +1,85 @@ +import z from "zod"; +import { IJob, JobContext } from "../task-queue"; +import { CACHE_KEYS, getOrCached } from "../cache"; +import { config } from "../app"; +import { getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet } from "@/clients/romm"; +import fs from 'node:fs/promises'; +import { hashFile, simulateProgress } from "@/bun/utils"; +import { Downloader, FileEntry } from "@/bun/utils/downloader"; +import path from 'node:path'; +import { ensureDir } from "fs-extra"; +import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService"; + +export class BiosDownloadJob implements IJob, "download"> +{ + static id = "bios-download-job" as const; + static dataSchema = z.object({ emulator: z.string() }); + static query = (q: { id: string; }) => `${BiosDownloadJob.id}-${q.id}`; + group: string = "bios-download"; + emulator: string; + dryRun: boolean; + + constructor(emulator: string, init?: { dryRun?: boolean; }) + { + this.emulator = emulator; + this.dryRun = init?.dryRun ?? false; + } + + async start (context: JobContext, never, "download">) + { + const allRommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data); + + const emulator = await getStoreEmulatorPackage(this.emulator); + if (!emulator) throw new Error("Could Not Find Emulator"); + + const systems = await buildStoreFrontendEmulatorSystems(emulator); + + const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); + await ensureDir(biosFolder); + const rommPlatforms = systems.filter(s => s.romm_slug).map(s => allRommPlatforms.find(p => p.slug == s.romm_slug)).filter(r => !!r); + + const firmwaresToDownload: FileEntry[] = []; + + for (const rommPlatform of rommPlatforms) + { + const firmwares = await getPlatformFirmwareApiFirmwareGet({ query: { platform_id: rommPlatform.id } }).then(d => d.data); + if (firmwares) + { + for (const firmware of firmwares) + { + const firmwarePath = path.join(biosFolder, firmware.file_name); + const exists = await fs.exists(firmwarePath); + + if (exists && await hashFile(firmwarePath, 'sha1')) + { + return; + } + + firmwaresToDownload.push({ file_name: firmware.file_name, file_path: '', url: new URL(`http://romm.simeonradivoev.com/api/firmware/${firmware.id}/content/${encodeURIComponent(firmware.file_name)}`) }); + } + } + } + + if (this.dryRun) + { + await simulateProgress((p) => context.setProgress(p, 'download'), context.abortSignal); + } else + { + const downloader = new Downloader('bios-download', firmwaresToDownload, biosFolder, { + signal: context.abortSignal, + onProgress (stats) + { + context.setProgress(stats.progress, "download"); + }, + }); + + await downloader.start(); + } + + } + + exposeData () + { + return { emulator: this.emulator }; + } +} \ No newline at end of file diff --git a/src/bun/api/jobs/emulator-download-job.ts b/src/bun/api/jobs/emulator-download-job.ts index 1e4a673..270d1ee 100644 --- a/src/bun/api/jobs/emulator-download-job.ts +++ b/src/bun/api/jobs/emulator-download-job.ts @@ -10,6 +10,7 @@ import _7z from '7zip-min'; import fs from "node:fs/promises"; import { Downloader } from "@/bun/utils/downloader"; import { move } from "fs-extra"; +import { simulateProgress } from "@/bun/utils"; type EmulatorDownloadStates = "download" | "extract"; @@ -20,11 +21,13 @@ export class EmulatorDownloadJob implements IJob, EmulatorDownloadStates>) @@ -56,44 +59,53 @@ export class EmulatorDownloadJob implements IJob context.setProgress(p, "download"), context.abortSignal); + await simulateProgress(p => context.setProgress(p, "extract"), context.abortSignal); + } else + { + const tmpFolder = path.join(config.get("downloadPath"), ".tmp"); + const downloader = new Downloader(this.emulator, + [{ url: new URL(downloadUrl), file_name: path.basename(downloadUrl), file_path: this.emulator }], + tmpFolder, { - let destinationPath = destinationPaths[0]; - await _7z.unpack(destinationPath, emulatorsFolder); - await fs.rm(destinationPath, { recursive: true }); - - // check if 1 root folder we need to get rid of - const contents = await fs.readdir(emulatorsFolder); - if (contents.length === 1) + signal: context.abortSignal, + onProgress (stats) { - const stat = await fs.stat(path.join(emulatorsFolder, contents[0])); - if (stat.isDirectory()) + context.setProgress(stats.progress, 'download'); + }, + }); + + const destinationPaths = await downloader.start(); + if (destinationPaths) + { + if (isArchive) + { + if (await downloader.start() && destinationPaths[0]) + { + let destinationPath = destinationPaths[0]; + await _7z.unpack(destinationPath, emulatorsFolder); + await fs.rm(destinationPath, { recursive: true }); + + // check if 1 root folder we need to get rid of + const contents = await fs.readdir(emulatorsFolder); + if (contents.length === 1) { - console.log("Found 1 root folder, using that instead"); - const tmpEmulatorsFolder = `${emulatorsFolder} (1)`; - await move(path.join(emulatorsFolder, contents[0]), tmpEmulatorsFolder, { overwrite: true }); - await move(tmpEmulatorsFolder, emulatorsFolder, { overwrite: true }); + const stat = await fs.stat(path.join(emulatorsFolder, contents[0])); + if (stat.isDirectory()) + { + console.log("Found 1 root folder, using that instead"); + const tmpEmulatorsFolder = `${emulatorsFolder} (1)`; + await move(path.join(emulatorsFolder, contents[0]), tmpEmulatorsFolder, { overwrite: true }); + await move(tmpEmulatorsFolder, emulatorsFolder, { overwrite: true }); + } } } } } } + } exposeData () diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index b095e8f..8ffd7a2 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -6,14 +6,14 @@ import * as schema from "@schema/app"; import * as emulatorSchema from "@schema/emulators"; import path from 'node:path'; import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet, PlatformSchema } from "@clients/romm"; -import { config, db, emulatorsDb, events, jar } from "../app"; +import { config, db, emulatorsDb, events } from "../app"; import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService"; import * as igdb from 'ts-igdb-client'; import secrets from "../secrets"; -import { hashFile } from "@/bun/utils"; +import { hashFile, simulateProgress } from "@/bun/utils"; import { Downloader } from "@/bun/utils/downloader"; -import { sleep } from "bun"; import _7z from '7zip-min'; +import z from "zod"; interface JobConfig { @@ -25,11 +25,14 @@ export type InstallJobStates = 'download' | 'extract'; export class InstallJob implements IJob { + static id = "install-job" as const; + static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`; + static dataSchema = z.never(); public gameId: string; public source: string; public sourceId: string; public config?: JobConfig; - static id = "install-job" as const; + public group = InstallJob.id; constructor(id: string, source: string, sourceId: string, config?: JobConfig) @@ -53,7 +56,6 @@ export class InstallJob implements IJob file_name: string; size?: number; }[] = []; - let cookie: string = ''; let screenshotUrls: string[]; let coverUrl: string; let rommPlatform: PlatformSchema | undefined; @@ -115,7 +117,6 @@ export class InstallJob implements IJob })); files.push(...rommFiles.filter(f => f !== undefined)); - cookie = await jar.getCookieString(config.get('rommAddress') ?? ''); break; case 'store': const game = await getStoreGameFromId(this.gameId); @@ -295,12 +296,7 @@ export class InstallJob implements IJob }); } else { - for (let i = 0; i < 10; i++) - { - cx.setProgress(i * 10, "download"); - if (cx.abortSignal.aborted) return; - await sleep(1000); - } + await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal); } diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index 2c4e3c2..8f836a5 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -1,5 +1,5 @@ import Elysia from "elysia"; -import z, { _ZodType, ZodAny, ZodObject, ZodTypeAny } from "zod"; +import z, { _ZodType } from "zod"; import { taskQueue } from "../app"; import { LoginJob } from "./login-job"; import TwitchLoginJob from "./twitch-login-job"; @@ -7,22 +7,27 @@ import UpdateStoreJob from "./update-store"; import { EmulatorDownloadJob } from "./emulator-download-job"; import { getErrorMessage } from "@/bun/utils"; import { IJob } from "../task-queue"; +import { LaunchGameJob } from "./launch-game-job"; +import { BiosDownloadJob } from "./bios-download-job"; +import { InstallJob } from "./install-job"; function registerJob< const Path extends string, - const Schema extends ZodTypeAny, + const Schema extends z.ZodTypeAny, + const Query extends z.ZodTypeAny, const States extends string, T extends IJob, States> -> (_job: { id: Path; dataSchema: Schema; } & (new (...args: any[]) => T)) +> (_job: { id: Path; dataSchema: Schema; query?: (q: any) => string; } & (new (...args: any[]) => T)) { return new Elysia().ws(_job.id, { body: z.discriminatedUnion('type', [ z.object({ type: z.literal('cancel') }) ]), + query: z.record(z.string(), z.any()), response: z.discriminatedUnion('type', [ z.object({ type: z.literal(['data', 'started', 'progress']), - status: z.string(), + state: z.string().optional(), progress: z.number(), data: _job.dataSchema }), @@ -31,44 +36,45 @@ function registerJob< ]), open (ws) { - const job = taskQueue.findJob(_job.id, _job); + const jobId = (_job.query ? _job.query(ws.data.query) : _job.id); + const job = taskQueue.findJob(jobId, _job); if (job) { - ws.send({ type: 'data', status: job.status, progress: job.progress, data: job.job.exposeData?.() }); + ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() }); } (ws.data as any).cleanup = [ taskQueue.on('started', ({ id, job }) => { - if (id === _job.id) + if (id === jobId) { - ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() }); + ws.send({ type: 'started', state: job.state, progress: job.progress, data: job.job.exposeData?.() }); } }), taskQueue.on('progress', ({ id, job }) => { - if (id === _job.id) + if (id === jobId) { - ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() }); + ws.send({ type: 'progress', state: job.state, progress: job.progress, data: job.job.exposeData?.() }); } }), taskQueue.on('completed', ({ id, job }) => { - if (id === _job.id) + if (id === jobId) { ws.send({ type: 'completed', data: job.job.exposeData?.() }); } }), taskQueue.on('ended', ({ id, job }) => { - if (id === _job.id) + if (id === jobId) { ws.send({ type: 'ended', data: job.job.exposeData?.() }); } }), taskQueue.on('error', ({ id, error }) => { - if (id === _job.id) + if (id === jobId) { ws.send({ type: 'error', error: getErrorMessage(error) }); } @@ -83,7 +89,8 @@ function registerJob< { if (message.type === 'cancel') { - taskQueue.findJob(_job.id, _job)?.abort('cancel'); + const jobId = (_job.query ? _job.query(this.query) : _job.id); + taskQueue.findJob(jobId, _job)?.abort('cancel'); } }, }); @@ -93,4 +100,7 @@ export const jobs = new Elysia({ prefix: '/api/jobs' }) .use(registerJob(LoginJob)) .use(registerJob(TwitchLoginJob)) .use(registerJob(UpdateStoreJob)) + .use(registerJob(LaunchGameJob)) + .use(registerJob(BiosDownloadJob)) + .use(registerJob(InstallJob)) .use(registerJob(EmulatorDownloadJob)); diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts new file mode 100644 index 0000000..8e97175 --- /dev/null +++ b/src/bun/api/jobs/launch-game-job.ts @@ -0,0 +1,121 @@ +import z from "zod"; +import { IJob, JobContext } from "../task-queue"; +import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema"; +import { db, events, plugins } from "../app"; +import * as appSchema from "@schema/app"; +import { eq } from "drizzle-orm"; +import { spawn } from 'node:child_process'; +import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm'; + +export class LaunchGameJob implements IJob, "playing"> +{ + static id = "launch-game" as const; + static dataSchema = z.optional(ActiveGameSchema); + group = "launch-game"; + activeGame?: ActiveGameType; + gameId: number; + validCommand: CommandEntry; + gameSource: string; + gameSourceId: string; + + constructor(gameId: number, validCommand: CommandEntry, source: string, sourceId: string) + { + this.gameId = gameId; + this.validCommand = validCommand; + this.gameSource = source; + this.gameSourceId = sourceId; + } + + async start (context: JobContext, ActiveGameType, "playing">) + { + const localGame = await db.query.games.findFirst({ + where: eq(appSchema.games.id, this.gameId), columns: { + name: true, + source_id: true, + source: true + } + }); + + const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({ + autoValidCommand: this.validCommand, + game: { source: this.gameSource, sourceId: this.gameSourceId, id: this.gameId } + }); + const command = commandArgs ? this.validCommand.metadata.emulatorBin ?? this.validCommand.command : this.validCommand.command; + + await new Promise((resolve, reject) => + { + const game = spawn(command, commandArgs, { + shell: true, + cwd: this.validCommand.startDir, + signal: context.abortSignal + }); + + game.stdout.on('data', data => console.log(data)); + game.on('close', (code) => + { + resolve(code); + }); + game.on('error', e => + { + console.error(e); + reject(e); + }); + + this.activeGame = { + process: game, + name: localGame?.name ?? "Unknown", + gameId: this.gameId, + command: this.validCommand + }; + + function updateRommProps (id: number) + { + updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } }); + events.emit('notification', { message: "Updated Last Played", type: 'success' }); + } + + if (this.gameSource === 'romm') + { + updateRommProps(Number(this.gameSourceId)); + } + else if (localGame?.source === 'romm' && localGame.source_id) + { + updateRommProps(Number(localGame.source_id)); + } + }); + + /* Old spawn lanching, cases issues, needs to be ran as shell + + const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]); + const game = setActiveGame({ + process: Bun.spawn({ + cmd, + env: { + ...process.env + }, + onExit (subprocess, exitCode, signalCode, error) + { + events.emit('activegameexit', { subprocess, exitCode, signalCode, error }); + }, + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + }), + name: localGame?.name ?? "Unknown", + gameId: validCommand.gameId, + command: validCommand.command.command + }); + + await game.process.exited; + if (game.process.exitCode && game.process.exitCode > 0) + { + return status('Internal Server Error'); + }*/ + } + + exposeData () + { + return this.activeGame; + } + +} \ No newline at end of file diff --git a/src/bun/api/jobs/login-job.ts b/src/bun/api/jobs/login-job.ts index af4d5a6..b10e087 100644 --- a/src/bun/api/jobs/login-job.ts +++ b/src/bun/api/jobs/login-job.ts @@ -1,5 +1,5 @@ import Elysia, { status } from "elysia"; -import { IJob, JobBase, JobContext, JobContextFromClass } from "../task-queue"; +import { IJob, JobContext } from "../task-queue"; import { LOGIN_PORT, SERVER_URL } from "@/shared/constants"; import { host, localIp } from "@/bun/utils/host"; import cors from "@elysiajs/cors"; diff --git a/src/bun/api/jobs/update-store.ts b/src/bun/api/jobs/update-store.ts index 74872c3..cd99584 100644 --- a/src/bun/api/jobs/update-store.ts +++ b/src/bun/api/jobs/update-store.ts @@ -4,10 +4,12 @@ import { getStoreRootFolder } from "../store/services/gamesService"; import { STORE_VERSION } from "@/shared/constants"; import { tmpdir } from "node:os"; import path from "node:path"; +import z from "zod"; export default class UpdateStoreJob implements IJob { static id = "update-store" as const; + static dataSchema = z.never(); packageName: string; registry: URL; storeVersion: string; @@ -27,7 +29,8 @@ export default class UpdateStoreJob implements IJob const storeFolder = getStoreRootFolder(); await ensureDir(storeFolder); - await Bun.spawn([process.execPath, "install", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], { + console.log("Updating Store"); + const proc = Bun.spawn([process.execPath, "add", `${this.packageName}@${this.storeVersion}`, "--production", "--registry", this.registry.href], { cwd: storeFolder, stdout: 'pipe', stderr: 'pipe', @@ -35,6 +38,13 @@ export default class UpdateStoreJob implements IJob BUN_BE_BUN: "1", BUN_INSTALL_CACHE_DIR: tempCache } - }).exited; + }); + + const stdout = await new Response(proc.stdout).text(); + console.log(stdout); + const stderr = await new Response(proc.stderr).text(); + if (stderr) + console.error(stderr); + await proc.exited; } } \ No newline at end of file diff --git a/src/bun/api/notifications.ts b/src/bun/api/notifications.ts index c20a67d..1a49080 100644 --- a/src/bun/api/notifications.ts +++ b/src/bun/api/notifications.ts @@ -1,4 +1,4 @@ -import { Notification } from '@shared/constants'; + import { events } from './app'; export default function buildNotificationsStream () @@ -10,7 +10,7 @@ export default function buildNotificationsStream () { const encoder = new TextEncoder(); - function enqueue (data: Notification, event?: 'notification') + function enqueue (data: FrontendNotification, event?: 'notification') { const evntString = event ? `event: ${event}\n` : ''; controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`)); @@ -30,7 +30,7 @@ export default function buildNotificationsStream () } }, 15000); - const notificationHandler = (notification: Notification) => + const notificationHandler = (notification: FrontendNotification) => { enqueue(notification, 'notification'); }; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini new file mode 100644 index 0000000..cbafaf8 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini @@ -0,0 +1,493 @@ +[UI] +SettingsVersion = 1 +InhibitScreensaver = true +ConfirmShutdown = true +StartPaused = false +PauseOnFocusLoss = false +StartFullscreen = false +DoubleClickTogglesFullscreen = true +HideMouseCursor = false +RenderToSeparateWindow = false +HideMainWindowWhenRunning = false +DisableWindowResize = false +Theme = darkfusion +SetupWizardIncomplete = false + + +[EmuCore] +CdvdVerboseReads = false +CdvdDumpBlocks = false +CdvdShareWrite = false +EnablePatches = true +EnableCheats = false +EnablePINE = false +EnableWideScreenPatches = false +EnableNoInterlacingPatches = false +EnableRecordingTools = true +EnableGameFixes = true +SaveStateOnShutdown = false +EnableDiscordPresence = false +InhibitScreensaver = true +ConsoleToStdio = false +HostFs = false +BackupSavestate = true +SavestateZstdCompression = true +McdEnableEjection = true +McdFolderAutoManage = true +WarnAboutUnsafeSettings = true +GzipIsoIndexTemplate = $(f).pindex.tmp +BlockDumpSaveDirectory = +EnableFastBoot = true + + +[EmuCore/Speedhacks] +EECycleRate = 0 +EECycleSkip = 0 +fastCDVD = false +IntcStat = true +WaitLoop = true +vuFlagHack = true +vuThread = true +vu1Instant = true + + +[EmuCore/CPU] +FPU.DenormalsAreZero = true +FPU.FlushToZero = true +FPU.Roundmode = 3 +AffinityControlMode = 0 +VU0.DenormalsAreZero = true +VU0.FlushToZero = true +VU0.Roundmode = 3 +VU1.DenormalsAreZero = true +VU1.FlushToZero = true +VU1.Roundmode = 3 + + +[EmuCore/CPU/Recompiler] +EnableEE = true +EnableIOP = true +EnableEECache = false +EnableVU0 = true +EnableVU1 = true +EnableFastmem = true +PauseOnTLBMiss = false +vu0Overflow = true +vu0ExtraOverflow = false +vu0SignOverflow = false +vu0Underflow = false +vu1Overflow = true +vu1ExtraOverflow = false +vu1SignOverflow = false +vu1Underflow = false +fpuOverflow = true +fpuExtraOverflow = false +fpuFullMode = false + + +[EmuCore/GS] +VsyncQueueSize = 2 +FrameLimitEnable = true +VsyncEnable = 0 +FramerateNTSC = 59.94 +FrameratePAL = 50 +SyncToHostRefreshRate = false +AspectRatio = Auto 4:3/3:2 +FMVAspectRatioSwitch = Off +ScreenshotSize = 0 +ScreenshotFormat = 0 +ScreenshotQuality = 50 +StretchY = 100 +CropLeft = 0 +CropTop = 0 +CropRight = 0 +CropBottom = 0 +pcrtc_antiblur = true +disable_interlace_offset = false +pcrtc_offsets = false +pcrtc_overscan = false +IntegerScaling = false +UseDebugDevice = false +UseBlitSwapChain = false +disable_shader_cache = false +DisableDualSourceBlend = false +DisableFramebufferFetch = false +DisableThreadedPresentation = false +SkipDuplicateFrames = false +OsdShowMessages = true +OsdShowSpeed = false +OsdShowFPS = false +OsdShowCPU = false +OsdShowGPU = false +OsdShowResolution = false +OsdShowGSStats = false +OsdShowIndicators = true +OsdShowSettings = false +OsdShowInputs = false +OsdShowFrameTimes = false +HWSpinGPUForReadbacks = false +HWSpinCPUForReadbacks = false +paltex = false +autoflush_sw = true +preload_frame_with_gs_data = false +mipmap = true +UserHacks = false +UserHacks_align_sprite_X = false +UserHacks_AutoFlush = false +UserHacks_CPU_FB_Conversion = false +UserHacks_ReadTCOnClose = false +UserHacks_DisableDepthSupport = false +UserHacks_DisablePartialInvalidation = false +UserHacks_Disable_Safe_Features = false +UserHacks_merge_pp_sprite = false +UserHacks_WildHack = false +UserHacks_TextureInsideRt = 0 +UserHacks_TargetPartialInvalidation = false +UserHacks_EstimateTextureRegion = false +fxaa = false +ShadeBoost = false +dump = false +save = false +savef = false +savet = false +savez = false +DumpReplaceableTextures = false +DumpReplaceableMipmaps = false +DumpTexturesWithFMVActive = false +DumpDirectTextures = true +DumpPaletteTextures = true +LoadTextureReplacements = false +LoadTextureReplacementsAsync = true +PrecacheTextureReplacements = false +EnableVideoCapture = true +EnableVideoCaptureParameters = false +VideoCaptureAutoResolution = false +EnableAudioCapture = true +EnableAudioCaptureParameters = false +linear_present_mode = 1 +deinterlace_mode = 0 +OsdScale = 100 +Renderer = 14 +upscale_multiplier = 1 +mipmap_hw = -1 +accurate_blending_unit = 1 +crc_hack_level = -1 +filter = 2 +texture_preloading = 2 +GSDumpCompression = 2 +HWDownloadMode = 0 +CASMode = 0 +CASSharpness = 50 +dithering_ps2 = 2 +MaxAnisotropy = 0 +extrathreads = 3 +extrathreads_height = 4 +TVShader = 0 +UserHacks_SkipDraw_Start = 0 +UserHacks_SkipDraw_End = 0 +UserHacks_Half_Bottom_Override = -1 +UserHacks_HalfPixelOffset = 0 +UserHacks_round_sprite_offset = 0 +UserHacks_TCOffsetX = 0 +UserHacks_TCOffsetY = 0 +UserHacks_CPUSpriteRenderBW = 0 +UserHacks_CPUCLUTRender = 0 +UserHacks_GPUTargetCLUTMode = 0 +TriFilter = -1 +OverrideTextureBarriers = -1 +OverrideGeometryShaders = -1 +ShadeBoost_Brightness = 50 +ShadeBoost_Contrast = 50 +ShadeBoost_Saturation = 50 +png_compression_level = 1 +saven = 0 +savel = 5000 +CaptureContainer = mp4 +VideoCaptureCodec = +VideoCaptureParameters = +AudioCaptureCodec = +AudioCaptureParameters = +VideoCaptureBitrate = 6000 +VideoCaptureWidth = 640 +VideoCaptureHeight = 480 +AudioCaptureBitrate = 160 +Adapter = (Default) +HWDumpDirectory = +SWDumpDirectory = + + +[SPU2/Debug] +Global_Enable = false +Show_Messages = false +Show_Messages_Key_On_Off = false +Show_Messages_Voice_Off = false +Show_Messages_DMA_Transfer = false +Show_Messages_AutoDMA = false +Show_Messages_Overruns = false +Show_Messages_CacheStats = false +Log_Register_Access = false +Log_DMA_Transfers = false +Log_WAVE_Output = false +Dump_Info = false +Dump_Memory = false +Dump_Regs = false + + +[SPU2/Mixing] +FinalVolume = 100 + + +[SPU2/Output] +OutputModule = cubeb +BackendName = +DeviceName = +Latency = 60 +OutputLatency = 20 +OutputLatencyMinimal = false +SynchMode = 0 +SpeakerConfiguration = 0 +DplDecodingLevel = 0 + + +[DEV9/Eth] +EthEnable = false +EthApi = Unset +EthDevice = +EthLogDNS = false +InterceptDHCP = false +PS2IP = 0.0.0.0 +Mask = 0.0.0.0 +Gateway = 0.0.0.0 +DNS1 = 0.0.0.0 +DNS2 = 0.0.0.0 +AutoMask = true +AutoGateway = true +ModeDNS1 = Auto +ModeDNS2 = Auto + + +[DEV9/Eth/Hosts] +Count = 0 + + +[DEV9/Hdd] +HddEnable = false +HddFile = DEV9hdd.raw +HddSizeSectors = 83886080 + + +[EmuCore/Gamefixes] +VuAddSubHack = false +FpuMulHack = false +FpuNegDivHack = false +XgKickHack = false +EETimingHack = false +InstantDMAHack = false +SoftwareRendererFMVHack = false +SkipMPEGHack = false +OPHFlagHack = false +DMABusyHack = false +VIFFIFOHack = false +VIF1StallHack = false +GIFFIFOHack = false +GoemonTlbHack = false +IbitHack = false +VUSyncHack = false +VUOverflowHack = false +BlitInternalFPSHack = false +FullVU0SyncHack = false + + +[EmuCore/Profiler] +Enabled = false +RecBlocks_EE = true +RecBlocks_IOP = true +RecBlocks_VU0 = true +RecBlocks_VU1 = true + + +[EmuCore/Debugger] +ShowDebuggerOnStart = false +AlignMemoryWindowStart = true +FontWidth = 8 +FontHeight = 12 +WindowWidth = 0 +WindowHeight = 0 +MemoryViewBytesPerRow = 16 + + +[EmuCore/TraceLog] +Enabled = false +EE.bitset = 0 +IOP.bitset = 0 + + +[USB1] +Type = None + + +[USB2] +Type = None + + +[Achievements] +Enabled = false +TestMode = false +UnofficialTestMode = false +RichPresence = true +ChallengeMode = false +Leaderboards = true +Notifications = true +SoundEffects = true +PrimedIndicators = true + + +[Filenames] +BIOS = + + +[Framerate] +NominalScalar = 1 +TurboScalar = 2 +SlomoScalar = 0.5 + + +[MemoryCards] +Slot1_Enable = true +Slot1_Filename = Mcd001.ps2 +Slot2_Enable = true +Slot2_Filename = Mcd002.ps2 +Multitap1_Slot2_Enable = false +Multitap1_Slot2_Filename = Mcd-Multitap1-Slot02.ps2 +Multitap1_Slot3_Enable = false +Multitap1_Slot3_Filename = Mcd-Multitap1-Slot03.ps2 +Multitap1_Slot4_Enable = false +Multitap1_Slot4_Filename = Mcd-Multitap1-Slot04.ps2 +Multitap2_Slot2_Enable = false +Multitap2_Slot2_Filename = Mcd-Multitap2-Slot02.ps2 +Multitap2_Slot3_Enable = false +Multitap2_Slot3_Filename = Mcd-Multitap2-Slot03.ps2 +Multitap2_Slot4_Enable = false +Multitap2_Slot4_Filename = Mcd-Multitap2-Slot04.ps2 + + +[Folders] +Bios = {{{BIOS_PATH}}} +Snapshots = {{{SNAPSHOTS_PATH}}} +SaveStates = {{{SAVE_STATES_PATH}}} +MemoryCards = {{{MEMORY_CARDS_PATH}}} +Cache = {{{CACHE_PATH}}} +Covers = {{{COVERS_PATH}}} +Logs = logs +Textures = {{{TEXTURES_PATH}}} +Videos = videos + + +[InputSources] +Keyboard = true +Mouse = true +SDL = true +SDLControllerEnhancedMode = false + + +[Hotkeys] +ToggleFullscreen = SDL-0/Start & SDL-0/LeftStick +CycleInterlaceMode = Keyboard/F5 +CycleMipmapMode = Keyboard/Insert +GSDumpMultiFrame = Keyboard/Control & Keyboard/Shift & Keyboard/F8 +Screenshot = Keyboard/F8 +GSDumpSingleFrame = Keyboard/Shift & Keyboard/F8 +ZoomIn = Keyboard/Control & Keyboard/Plus +ZoomOut = Keyboard/Control & Keyboard/Minus +InputRecToggleMode = Keyboard/Shift & Keyboard/R +LoadStateFromSlot = SDL-0/Back & SDL-0/LeftShoulder +SaveStateToSlot = SDL-0/Back & SDL-0/RightShoulder +ShutdownVM = SDL-0/Back & SDL-0/Start +ToggleFrameLimit = Keyboard/F4 +TogglePause = SDL-0/Back & SDL-0/A +ToggleSlowMotion = SDL-0/Back & SDL-0/+LeftTrigger +ToggleTurbo = SDL-0/Back & SDL-0/+RightTrigger +HoldTurbo = Keyboard/Period +ResetVM = SDL-0/Back & SDL-0/LeftStick +OpenPauseMenu = SDL-0/Back & SDL-0/RightStick +IncreaseUpscaleMultiplier = SDL-0/Start & SDL-0/DPadUp +DecreaseUpscaleMultiplier = SDL-0/Start & SDL-0/DPadDown +CycleAspectRatio = SDL-0/Start & SDL-0/DPadRight +ToggleSoftwareRendering = SDL-0/Start & SDL-0/DPadLeft +ToggleSoftwareRendering = Keyboard/F9 +NextSaveStateSlot = SDL-0/Start & SDL-0/RightShoulder +PreviousSaveStateSlot = SDL-0/Start & SDL-0/LeftShoulder + +[Pad1] +Type = DualShock2 +Deadzone = 0.000000 +AxisScale = 1.330000 +LargeMotorScale = 1.000000 +SmallMotorScale = 1.000000 +PressureModifier = 0.5 +Up = SDL-0/DPadUp +Right = SDL-0/DPadRight +Down = SDL-0/DPadDown +Left = SDL-0/DPadLeft +Triangle = SDL-0/Y +Circle = SDL-0/B +Cross = SDL-0/A +Square = SDL-0/X +Select = SDL-0/Back +Start = SDL-0/Start +L1 = SDL-0/LeftShoulder +L2 = SDL-0/+LeftTrigger +R1 = SDL-0/RightShoulder +R2 = SDL-0/+RightTrigger +L3 = SDL-0/LeftStick +R3 = SDL-0/RightStick +LUp = SDL-0/-LeftY +LRight = SDL-0/+LeftX +LDown = SDL-0/+LeftY +LLeft = SDL-0/-LeftX +RUp = SDL-0/-RightY +RRight = SDL-0/+RightX +RDown = SDL-0/+RightY +RLeft = SDL-0/-RightX +Analog = SDL-0/Guide +LargeMotor = SDL-0/LargeMotor +SmallMotor = SDL-0/SmallMotor +Pressure = Keyboard/S + +[Pad2] +Type = DualShock2 +Deadzone = 0.000000 +AxisScale = 1.330000 +LargeMotorScale = 1.000000 +SmallMotorScale = 1.000000 +PressureModifier = 0.300000 +Up = SDL-1/DPadUp +Right = SDL-1/DPadRight +Down = SDL-1/DPadDown +Left = SDL-1/DPadLeft +Triangle = SDL-1/Y +Circle = SDL-1/B +Cross = SDL-1/A +Square = SDL-1/X +Select = SDL-1/Back +Start = SDL-1/Start +L1 = SDL-1/LeftShoulder +L2 = SDL-1/+LeftTrigger +R1 = SDL-1/RightShoulder +R2 = SDL-1/+RightTrigger +L3 = SDL-1/LeftStick +R3 = SDL-1/RightStick +Analog = SDL-1/Guide +LUp = SDL-1/-LeftY +LRight = SDL-1/+LeftX +LDown = SDL-1/+LeftY +LLeft = SDL-1/-LeftX +RUp = SDL-1/-RightY +RRight = SDL-1/+RightX +RDown = SDL-1/+RightY +RLeft = SDL-1/-RightX +LargeMotor = SDL-1/LargeMotor +SmallMotor = SDL-1/SmallMotor + +[GameList] +RecursivePaths = {{{RECURSIVE_PATHS}}} diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json new file mode 100644 index 0000000..bab4f08 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json @@ -0,0 +1,14 @@ +{ + "name": "com.simeonradivoev.gameflow.pcsx2", + "displayName": "PCSX2 Integration", + "version": "0.0.1", + "description": "PCSX2 Emulator Integration", + "main": "./pcsx2.ts", + "icon": "https://upload.wikimedia.org/wikipedia/commons/2/2b/PCSX2_logo4.png", + "keywords": [ + "integration", + "emulator", + "ps2", + "pcsx2" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts new file mode 100644 index 0000000..8539377 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -0,0 +1,55 @@ + +import { config, db } from "@/bun/api/app"; +import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import configFile from './PCSX2.ini' with { type: 'file' }; +import Mustache from 'mustache'; +import path from 'node:path'; +import { ensureDir } from "fs-extra"; +import desc from './package.json'; + +export default class PCSX2Integration implements PluginType +{ + load (ctx: PluginContextType) + { + ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => + { + if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir) + { + const args = ["-batch"]; + if (config.get('launchInFullscreen')) + { + args.push("-fullscreen"); + } + args.push(...["-bigpicture", "-portable", "--", ctx.autoValidCommand.metadata.romPath]); + + const configFileContents = await Bun.file(configFile).text(); + + const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2'); + const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); + const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); + + const view = { + BIOS_PATH: biosFolder, + SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'), + SAVE_STATES_PATH: path.join(savesFolder, 'states'), + MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'), + CACHE_PATH: path.join(storageFolder, 'cache'), + COVERS_PATH: path.join(storageFolder, 'covers'), + TEXTURES_PATH: path.join(storageFolder, 'textures'), + RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), + }; + + await Promise.all(Object.values(view).map(p => ensureDir(p))); + + await Bun.write(path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis', 'PCSX2.ini'), Mustache.render(configFileContents, view)); + + return args; + } + }); + } + + async downloadBios (id: number) + { + + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/plugin-manager.ts b/src/bun/api/plugins/plugin-manager.ts new file mode 100644 index 0000000..9959289 --- /dev/null +++ b/src/bun/api/plugins/plugin-manager.ts @@ -0,0 +1,94 @@ +import { GameflowHooks } from "../hooks/app"; +import { PluginContextType, PluginDescriptionType, PluginType } from "../../types/typesc.schema"; +import { config } from "../app"; + +export class PluginManager +{ + hooks = new GameflowHooks(); + plugins: Record = {}; + + async register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType) + { + try + { + if (this.plugins[description.name]) + { + console.error("Plugin with name", description.name, "already registered"); + } + else + { + if (plugin.setup) await plugin.setup(); + this.plugins[description.name] = { + enabled: !config.get('disabledPlugins').includes(description.name), + loaded: false, plugin: plugin, + source: source, + description: description + }; + this.reload(description.name); + console.log("Plugin", description.name, "registered"); + } + + } + catch (error) + { + console.log("Error While Registering plugin"); + console.error(error); + }; + } + + private reload (name: string) + { + const plugin = this.plugins[name]; + if (plugin) + { + const ctx: PluginContextType = { hooks: this.hooks }; + + if (plugin.loaded) + { + plugin.plugin.onBeforeReload?.(ctx); + plugin.loaded = false; + } + + try + { + if (plugin.enabled) + { + plugin.plugin.load(ctx); + plugin.loaded = true; + } + } catch (error) + { + console.log("Error for plugin", plugin.description.name, "while loading"); + console.error(error); + } + } + } + + reloadAll () + { + this.hooks = new GameflowHooks(); + Object.keys(this.plugins).forEach(id => this.reload(id)); + } + + async cleanup () + { + await Promise.all(Object.values(this.plugins).filter(p => p.loaded && p.plugin.cleanup).map(async p => + { + try + { + await p.plugin.cleanup!(); + } catch (error) + { + console.log("Error for plugin", p.description.name, "while cleaning up"); + console.error(error); + } + })); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/plugins.ts b/src/bun/api/plugins/plugins.ts new file mode 100644 index 0000000..e276f92 --- /dev/null +++ b/src/bun/api/plugins/plugins.ts @@ -0,0 +1,37 @@ +import Elysia, { status } from "elysia"; +import { plugins } from "../app"; +import z from "zod"; +import { toggleElementInConfig } from "@/bun/utils"; + +export default new Elysia({ prefix: '/plugins' }) + .get('/', async () => + { + return Object.values(plugins.plugins).map(p => + { + const plugin: FrontendPlugin = { + enabled: p.enabled, + name: p.description.name, + displayName: p.description.displayName, + description: p.description.description, + source: p.source, + version: p.description.version, + icon: p.description.icon + }; + return plugin; + }); + }) + .post('/:id', async ({ params: { id }, body: { enabled } }) => + { + const plugin = plugins.plugins[id]; + if (plugin) + { + plugin.enabled = enabled; + toggleElementInConfig('disabledPlugins', plugin.description.name, enabled); + plugins.reloadAll(); + } else + { + return status("Not Found"); + } + }, { + body: z.object({ enabled: z.boolean() }) + }); \ No newline at end of file diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts new file mode 100644 index 0000000..9226d82 --- /dev/null +++ b/src/bun/api/plugins/register-plugins.ts @@ -0,0 +1,25 @@ +import { PluginManager } from "./plugin-manager"; + +import pcsx2 from './builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json'; +import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema"; +import path from "node:path"; + +export default async function register (pluginManager: PluginManager) +{ + + const plugins: (PluginDescriptionType & { main: string; root: string; })[] = [ + { ...pcsx2, root: './builtin/emulators/com.simeonradivoev.gameflow.pcsx2' } + ]; + + await Promise.all(plugins.map(async (pluginPackage) => + { + const file = await import(`./${path.join(pluginPackage.root, pluginPackage.main)}`); + if (file.default && typeof file.default === 'function') + { + const pluginInstance = new file.default(); + const plugin = await PluginSchema.parseAsync(pluginInstance); + const description = await PluginDescriptionSchema.parseAsync(pluginPackage); + pluginManager.register(plugin, description, 'builtin'); + } + })); +} \ No newline at end of file diff --git a/src/bun/api/rpc.ts b/src/bun/api/rpc.ts index 6ad55d3..55ef126 100644 --- a/src/bun/api/rpc.ts +++ b/src/bun/api/rpc.ts @@ -7,15 +7,17 @@ import { system } from "./system"; import { store } from "./store/store"; import { host } from "../utils/host"; import { jobs } from "./jobs/jobs"; +import plugins from "./plugins/plugins"; const api = new Elysia({ serve: {} }) - .use([cors(), clients, settings, system, store, jobs]); + .use([cors(), clients, settings, system, store, jobs, plugins]); export type RommAPIType = typeof clients; export type SettingsAPIType = typeof settings; export type SystemAPIType = typeof system; export type StoreAPIType = typeof store; export type JobsAPIType = typeof jobs; +export type PluginsAPIType = typeof plugins; export function RunAPIServer () { diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index 04efda2..6d505f3 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -2,10 +2,9 @@ import * as appSchema from '@schema/app'; import * as emulatorSchema from "@schema/emulators"; import { eq, inArray } from 'drizzle-orm'; -import { customEmulators, db, emulatorsDb } from '../app'; -import fs from 'node:fs/promises'; +import { db, emulatorsDb } from '../app'; import { cores } from '../emulatorjs/emulatorjs'; -import { FrontEndEmulator, SERVER_URL } from '@/shared/constants'; +import { SERVER_URL } from '@/shared/constants'; import { findExecsByName } from '../games/services/launchGameService'; import { host } from '@/bun/utils/host'; diff --git a/src/bun/api/store/services/emulatorsService.ts b/src/bun/api/store/services/emulatorsService.ts index d7595bf..753eda3 100644 --- a/src/bun/api/store/services/emulatorsService.ts +++ b/src/bun/api/store/services/emulatorsService.ts @@ -1,5 +1,5 @@ -import { EmulatorPackageType, EmulatorSourceType, FrontEndEmulator } from "@/shared/constants"; -import { emulatorsDb } from "../../app"; +import { EmulatorPackageType } from "@/shared/constants"; +import { emulatorsDb, plugins } from "../../app"; import * as emulatorSchema from '@schema/emulators'; import { findExecs } from "../../games/services/launchGameService"; import { eq } from "drizzle-orm"; @@ -10,7 +10,7 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT icon: string; }[]) { - let execPath: EmulatorSourceType | undefined; + let execPath: EmulatorSourceEntryType | undefined; const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) }); if (esEmulator) @@ -24,8 +24,17 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT logo: emulator.logo, systems, gameCount, - validSource: execPath + validSource: execPath, + integration: findEmulatorPluginIntegration(emulator.name) }; return em; +} + +export function findEmulatorPluginIntegration (name: string) +{ + const lowerCaseName = name.toLowerCase(); + const integration = Object.entries(plugins.plugins).find(p => p[1].description.keywords?.includes(lowerCaseName)); + if (!integration) return undefined; + return { name: integration[0], version: integration[1].description.version }; } \ No newline at end of file diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index f7e96bc..ab5ba97 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -3,17 +3,18 @@ import Elysia, { status } from "elysia"; import { config, db, taskQueue } from "../app"; import path from "node:path"; import fs from 'node:fs/promises'; -import { FrontEndEmulatorDetailed, FrontEndEmulatorDetailedDownload, StoreGameSchema } from "@/shared/constants"; +import { StoreGameSchema } from "@/shared/constants"; import { findExecsByName } from "../games/services/launchGameService"; import * as appSchema from '@schema/app'; import z from "zod"; import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; import { getPlatformsApiPlatformsGet } from "@/clients/romm"; import { CACHE_KEYS, getOrCached, getOrCachedGithubRelease } from "../cache"; -import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage } from "./services/gamesService"; +import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "./services/gamesService"; import { EmulatorDownloadJob } from "../jobs/emulator-download-job"; import { Glob } from "bun"; -import { convertStoreEmulatorToFrontend } from "./services/emulatorsService"; +import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration } from "./services/emulatorsService"; +import { BiosDownloadJob } from "../jobs/bios-download-job"; export const store = new Elysia({ prefix: '/api/store' }) .get('/emulators', async ({ query }) => @@ -97,13 +98,11 @@ export const store = new Elysia({ prefix: '/api/store' }) }) .get('/screenshot/emulator/:id/:name', async ({ params: { id, name } }) => { - const downlodDir = config.get('downloadPath'); - return Bun.file(path.join(downlodDir, "store", "media", "screenshots", id, name)); + return Bun.file(path.join(getStoreFolder(), "media", "screenshots", id, name)); }, { params: z.object({ id: z.string(), name: z.string() }) }) .get('/emulator/:id', async ({ params: { id } }) => { - const downlodDir = config.get('downloadPath'); const emulatorPackage = await getStoreEmulatorPackage(id); if (!emulatorPackage) return status("Not Found"); @@ -111,9 +110,12 @@ export const store = new Elysia({ prefix: '/api/store' }) const execPaths = await findExecsByName(emulatorPackage.name); - const emulatorScreenshotsPath = path.join(downlodDir, "store", "media", "screenshots", id); + const emulatorScreenshotsPath = path.join(getStoreFolder(), "media", "screenshots", id); const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : []; const validExec = execPaths.find(p => p.exists); + const biosDirPath = path.join(config.get('downloadPath'), 'bios', id); + const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : []; + const emulator: FrontEndEmulatorDetailed = { name: emulatorPackage.name, description: emulatorPackage.description, @@ -138,7 +140,10 @@ export const store = new Elysia({ prefix: '/api/store' }) return { name: d.type, type: "Unknown" }; }) ?? []), logo: emulatorPackage.logo, - sources: execPaths + sources: execPaths, + biosRequirement: emulatorPackage.bios, + bios: biosFiles, + integration: findEmulatorPluginIntegration(emulatorPackage.name) }; return emulator; @@ -154,7 +159,6 @@ export const store = new Elysia({ prefix: '/api/store' }) }) .delete('/emulator/:id', async ({ params: { id } }) => { - const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id); if (await fs.exists(storeEmulatorFolder)) { @@ -162,4 +166,24 @@ export const store = new Elysia({ prefix: '/api/store' }) return status("OK"); } return status("Not Found"); + }) + .post('/download/bios/:id', async ({ params: { id } }) => + { + if (taskQueue.findJob(BiosDownloadJob.query({ id }), BiosDownloadJob)) + { + return status("Conflict", "Bios Download Already Active"); + } + + return taskQueue.enqueue(BiosDownloadJob.query({ id }), new BiosDownloadJob(id)); + }) + .delete('/bios/:id', async ({ params: { id } }) => + { + const biosFolder = path.join(config.get('downloadPath'), "bios", id); + if (await fs.exists(biosFolder)) + { + await fs.rm(biosFolder, { recursive: true }); + } else + { + return status("Not Found"); + } }); \ No newline at end of file diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 5c592b1..52f1dd5 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -7,7 +7,7 @@ import { isSteamDeck, openExternal } from "../utils"; import fs from 'node:fs/promises'; import buildNotificationsStream from "./notifications"; import path, { dirname } from "node:path"; -import { DirSchema, DownloadsDrive } from "@/shared/constants"; +import { DirSchema } from "@/shared/constants"; import { getDevices, getDevicesCurated } from "./drives"; import getFolderSize from "get-folder-size"; import si from 'systeminformation'; diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index 51f4fd2..e4d8ff5 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -1,12 +1,12 @@ -import { JobStatus } from '@/shared/constants'; + import EventEmitter from 'node:events'; -import z, { ZodTypeAny } from 'zod'; +import z from 'zod'; export class TaskQueue { - private activeQueue: { context: JobContext, promise?: Promise; }[] = []; - private queue?: { context: JobContext, promise?: Promise; }[] = []; + private activeQueue: { context: JobContext, any, string>, promise?: Promise; }[] = []; + private queue?: { context: JobContext, any, string>, promise?: Promise; }[] = []; private events?: EventEmitter = new EventEmitter(); public enqueue> (id: string, job: T) @@ -36,6 +36,8 @@ export class TaskQueue { const index = this.activeQueue.indexOf(job.job); this.activeQueue.splice(index, 1); + // We need to call it after it has been removed from the queue, so that the has active of type doesn't return true + this.events?.emit('ended', { id: job.job.context.id, job: job.job.context }); setTimeout(() => this.processQueue(), 0); }); }); @@ -162,7 +164,7 @@ type JobClassWithStatics = JobClass & { export type JobContextFromClass = JobContext< InstanceType, - C extends { dataSchema: ZodTypeAny; } + C extends { dataSchema: z.ZodAny; } ? z.infer : never, C['id'] @@ -215,7 +217,6 @@ export class JobContext, TData, TState extends str } finally { this.running = false; - this.events.emit('ended', { id: this.m_id, job: this }); } } diff --git a/src/bun/types/types.d.ts b/src/bun/types/types.d.ts index 4ba73c2..b9208d8 100644 --- a/src/bun/types/types.d.ts +++ b/src/bun/types/types.d.ts @@ -1,15 +1,4 @@ -import { ChildProcess } from "node:child_process"; - -declare const IS_BINARY: string; - -export type ActiveGame = { - process?: ChildProcess; - gameId: number; - name: string; - command: { command: string, startDir?: string; }; -}; - -interface ObjectConstructor +declare interface ObjectConstructor { /** * Groups members of an iterable according to the return value of the passed callback. @@ -22,7 +11,7 @@ interface ObjectConstructor ): Partial>; } -interface MapConstructor +declare interface MapConstructor { /** * Groups members of an iterable according to the return value of the passed callback. @@ -33,4 +22,10 @@ interface MapConstructor items: Iterable, keySelector: (item: T, index: number) => K, ): Map; +} + +declare interface AppEventMap +{ + exitapp: []; + notification: [FrontendNotification]; } \ No newline at end of file diff --git a/src/bun/types/typesc.schema.ts b/src/bun/types/typesc.schema.ts new file mode 100644 index 0000000..09d29e6 --- /dev/null +++ b/src/bun/types/typesc.schema.ts @@ -0,0 +1,35 @@ +import z from "zod"; +import { GameflowHooks } from "../api/hooks/app"; +import { ChildProcess } from "node:child_process"; + +export const PluginContextSchema = z.object({ + hooks: z.instanceof(GameflowHooks) +}); + +export const PluginDescriptionSchema = z.object({ + name: z.string(), + displayName: z.string(), + version: z.string(), + description: z.string(), + icon: z.url().optional(), + keywords: z.array(z.string()).optional() +}); + +export const PluginSchema = z.object({ + setup: z.function().output(z.promise(z.void())).optional(), + load: z.function().input([PluginContextSchema]).output(z.void()), + onBeforeReload: z.function().input([PluginContextSchema]).output(z.void()).optional(), + cleanup: z.function().output(z.promise(z.void())).optional() +}); + +export type PluginType = z.infer; +export type PluginContextType = z.infer; +export type PluginDescriptionType = z.infer; + +export const ActiveGameSchema = z.object({ + process: z.instanceof(ChildProcess).optional(), + gameId: z.number(), + name: z.string(), + command: z.object({ command: z.string(), startDir: z.string().optional() }) +}); +export type ActiveGameType = z.infer; \ No newline at end of file diff --git a/src/bun/utils.ts b/src/bun/utils.ts index 487719a..f7a2e4e 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -1,7 +1,9 @@ -import { $ } from 'bun'; +import { $, sleep } from 'bun'; import path from 'node:path'; import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; +import { SettingsType } from '@/shared/constants'; +import { config } from './api/app'; export function checkRunning (pid: number) { @@ -111,3 +113,33 @@ export function shuffleInPlace (array: any[], startSeed?: number) [array[i], array[j]] = [array[j], array[i]]; } } + +export function toggleElementInConfig (id: KeysWithValueAssignableTo>, element: T, enabled: boolean) +{ + const disabled = config.get(id as any) as T[]; + if (enabled) + { + const index = disabled.indexOf(element); + if (index < 0) + { + config.set('disabledPlugins', disabled.concat(element)); + } + } else + { + const index = disabled.indexOf(element); + if (index >= 0) + { + config.set('disabledPlugins', disabled.toSpliced(index, 1)); + } + } +} + +export async function simulateProgress (setProgress: (p: number) => void, signal?: AbortSignal) +{ + for (let i = 0; i < 10; i++) + { + setProgress(i * 10); + if (signal && signal.aborted) return; + await sleep(1000); + } +} \ No newline at end of file diff --git a/src/bun/utils/downloader.ts b/src/bun/utils/downloader.ts index 92a4893..e239905 100644 --- a/src/bun/utils/downloader.ts +++ b/src/bun/utils/downloader.ts @@ -4,7 +4,6 @@ import fs from 'node:fs/promises'; import { createWriteStream } from "node:fs"; import { config, jar } from "../api/app"; -import { file } from "bun"; export interface FileEntry { @@ -24,6 +23,10 @@ interface TmpDownloadMetadata files: FileEntry[]; } +/** + * It download files and reports progress. + * It also automatically applies cookies from the jar store. + */ export class Downloader { files: FileEntry[]; diff --git a/src/mainview/components/AnimatedBackground.tsx b/src/mainview/components/AnimatedBackground.tsx index 8aa67ab..1482f6f 100644 --- a/src/mainview/components/AnimatedBackground.tsx +++ b/src/mainview/components/AnimatedBackground.tsx @@ -119,16 +119,18 @@ export function AnimatedBackground (data: { > {!data.scrolling &&

    diff --git a/src/mainview/components/CardElement.tsx b/src/mainview/components/CardElement.tsx index a00d306..1d27805 100644 --- a/src/mainview/components/CardElement.tsx +++ b/src/mainview/components/CardElement.tsx @@ -25,6 +25,7 @@ export interface GameCardParams type?: string; subtitle: string | JSX.Element; preview?: string | JSX.Element | ((p: { focused: boolean; }) => JSX.Element); + srcset?: string; focusKey: string; index: number; id: string; @@ -64,7 +65,7 @@ export default function CardElement (data: GameCardParams & InteractParams) data.onAction?.(); }} className={twMerge( - "relative game-card bg-base-300 flex flex-col z-5 overflow-hidden transition-all duration-200 not-mobile:drop-shadow-lg cursor-pointer focusable focusable-primary focusable-hover select-none focused focused:not-control-mouse:animate-wiggle focused:not-control-mouse:bg-base-content focused:not-control-mouse:text-base-300 focused:not-control-mouse:drop-shadow-xl focused:not-control-mouse:drop-shadow-black/30 focused:not-control-mouse:scale-102 focused:not-control-mouse:z-10 group control-mouse:hover:bg-base-200 h-full [--tw-border-style:inset] border-2 border-base-content/5 backdrop-opacity-0 active:bg-base-content! active:text-base-100 active:transition-none", + "relative game-card light:bg-base-100 dark:bg-base-300 flex flex-col z-5 overflow-hidden transition-all duration-200 not-mobile:drop-shadow-lg cursor-pointer focusable focusable-primary focusable-hover select-none focused focused:not-control-mouse:animate-wiggle focused:not-control-mouse:bg-base-content focused:not-control-mouse:text-base-300 focused:not-control-mouse:drop-shadow-lg focused:not-control-mouse:drop-shadow-black/30 focused:not-control-mouse:scale-102 focused:not-control-mouse:z-10 group control-mouse:hover:bg-base-200 h-full [--tw-border-style:inset] border-2 border-base-content/5 backdrop-opacity-0 active:bg-base-content! active:text-base-100 active:transition-none", data.className )} > @@ -75,7 +76,7 @@ export default function CardElement (data: GameCardParams & InteractParams) classNames({ "h-full": typeof data.preview === "string" }) )}> {typeof data.preview === "string" ? ( - + ) : ( typeof data.preview === 'function' ? data.preview({ focused }) : data.preview )} diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index f3710ec..306d42d 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -56,6 +56,7 @@ export function CardList (data: { data-index={i} title={g.title} subtitle={g.subtitle ?? ""} + srcset={g.previewSrcset} onFocus={(id, node, details) => { g.onFocus?.(details); diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index f9470ef..208f2f9 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -34,7 +34,7 @@ export default function CollectionList (data: { title: g.name, focusKey: `collection-${g.id}`, subtitle: g.owner_username, - previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_large[0]}`, + previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_small[0]}`, badges: [ {g.rom_count} diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index fa072b0..1b31049 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -7,10 +7,8 @@ import { JSX, Suspense, useEffect } from 'react'; import Shortcuts from './Shortcuts'; import { AutoFocus } from './AutoFocus'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; -import { PopNavigateSource } from '../scripts/spatialNavigation'; import { GameListFilterType } from '@/shared/constants'; import { GameCardFocusHandler } from './CardElement'; -import { Router } from '..'; import { HandleGoBack } from '../scripts/utils'; export interface CollectionsDetailParams diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index 60d6269..c32ccb7 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -5,6 +5,7 @@ import { twMerge } from "tailwind-merge"; import { X } from "lucide-react"; import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"; import { ContextDialogContext } from "../scripts/contexts"; +import { FOCUS_KEYS } from "../scripts/types"; export function ContextList (data: { options?: DialogEntry[]; className?: string; showCloseButton?: boolean; }) { @@ -25,18 +26,18 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class }; const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined; const { ref, focusSelf, focusKey } = useFocusable({ - focusKey: `${context.id}-list-option-${data.id}`, + focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id), onEnterPress: data.shortcuts ? undefined : handleAction, onFocus: handleFocus, trackChildren: typeof data.content !== 'string' }); const colors = { - primary: "active:bg-primary control-pointer:hover:bg-primary focused:bg-primary focused:text-primary-content in-focused:bg-primary in-focused:text-primary-content", - secondary: "active:bg-secondary control-pointer:hover:bg-secondary focused:bg-secondary focused:text-secondary-content in-focused:bg-secondary in-focused:text-secondary-content", - accent: "active:bg-accent control-pointer:hover:bg-accent focused:bg-accent focused:text-accent-content in-focused:bg-accent in-focused:text-accent-content", - info: "active:bg-info control-pointer:hover:bg-info focused:bg-info focused:text-info-content in-focused:bg-info in-focused:text-info-content", - warning: "active:bg-warning control-pointer:hover:bg-warning focused:bg-warning focused:text-warning-content in-focused:bg-warning in-focused:text-warning-content", - error: "active:bg-error control-pointer:hover:bg-error focused:bg-error focused:text-error-content in-focused:bg-error in-focused:text-error-content" + primary: "active:bg-primary control-pointer:hover:bg-primary control-pointer:hover:text-primary-content focused:bg-primary focused:text-primary-content in-focused:bg-primary in-focused:text-primary-content", + secondary: "active:bg-secondary control-pointer:hover:bg-secondary control-pointer:hover:text-secondary-content focused:bg-secondary focused:text-secondary-content in-focused:bg-secondary in-focused:text-secondary-content", + accent: "active:bg-accent control-pointer:hover:bg-accent control-pointer:hover:text-accent-content focused:bg-accent focused:text-accent-content in-focused:bg-accent in-focused:text-accent-content", + info: "active:bg-info control-pointer:hover:bg-info control-pointer:hover:text-info-content focused:bg-info focused:text-info-content in-focused:bg-info in-focused:text-info-content", + warning: "active:bg-warning control-pointer:hover:bg-warning control-pointer:hover:text-warning-content focused:bg-warning focused:text-warning-content in-focused:bg-warning in-focused:text-warning-content", + error: "active:bg-error control-pointer:hover:bg-error control-pointer:hover:text-error-content focused:bg-error focused:text-error-content in-focused:bg-error in-focused:text-error-content" }; if (data.shortcuts) { @@ -47,9 +48,10 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class className={ twMerge("flex cursor-pointer sm:text-sm md:text-base")}> -
    + colors[data.type], + "active:bg-base-content! active:text-base-300! active:transition-none")}> {data.icon} {data.content}
    @@ -71,33 +73,34 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla { const [open, setOpen] = useState(false); const [sourceFocusKey, setSourceFocusKey] = useState(undefined); - const dialog = + const handleClose = (value: boolean, newSourceFocusKey?: string) => { - setOpen(false); - data.onClose?.(); - }} className={data.className} sourceFocusKey={sourceFocusKey} preferredChildFocusKey={data.preferredChildFocusKey}> + if (value === open) return; + if (value) + { + setOpen(true); + setSourceFocusKey(newSourceFocusKey); + } else + { + setOpen(false); + data.onClose?.(); + if (newSourceFocusKey) + { + setFocus(newSourceFocusKey); + } else if (sourceFocusKey) + { + setFocus(sourceFocusKey); + } + } + + }; + const dialog = {data.content} ; return { dialog, open, - setOpen: (value: boolean, sourceFocusKey?: string) => - { - if (value === open) return; - if (value) - { - setOpen(true); - setSourceFocusKey(sourceFocusKey); - } else - { - setOpen(false); - if (sourceFocusKey) - { - setFocus(sourceFocusKey); - } - } - - } + setOpen: handleClose }; } @@ -108,7 +111,6 @@ export function ContextDialog (data: { close: (open: boolean) => void; className?: string; preferredChildFocusKey?: string; - sourceFocusKey?: string; }) { const { ref, focusKey, focusSelf } = useFocusable({ @@ -137,7 +139,7 @@ export function ContextDialog (data: { }] : [], [data.open]); return @@ -145,7 +147,7 @@ export function ContextDialog (data: {
    Return Home
    +
    ; diff --git a/src/mainview/components/FilePicker.tsx b/src/mainview/components/FilePicker.tsx index cf14db2..b0f6608 100644 --- a/src/mainview/components/FilePicker.tsx +++ b/src/mainview/components/FilePicker.tsx @@ -2,7 +2,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { ContextList, DialogEntry } from "./ContextDialog"; import { systemApi } from "../scripts/clientApi"; import { useContext, useRef, useState } from "react"; -import path, { dirname } from "pathe"; +import path from "pathe"; import { Check, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { DirType } from "@/shared/constants"; diff --git a/src/mainview/components/FocusDots.tsx b/src/mainview/components/FocusDots.tsx index 8d37849..0fc2af1 100644 --- a/src/mainview/components/FocusDots.tsx +++ b/src/mainview/components/FocusDots.tsx @@ -2,7 +2,7 @@ import { setFocus } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; import { twMerge } from "tailwind-merge"; import { useGlobalFocus } from "../scripts/spatialNavigation"; -import { JSX, RefObject, useMemo, useState } from "react"; +import { RefObject, useMemo, useState } from "react"; import { useEventListener } from "usehooks-ts"; function ScrollDot (data: { index: number; parent: RefObject, peers: HTMLElement[]; }) diff --git a/src/mainview/components/FocusTooltip.tsx b/src/mainview/components/FocusTooltip.tsx new file mode 100644 index 0000000..5a165b1 --- /dev/null +++ b/src/mainview/components/FocusTooltip.tsx @@ -0,0 +1,36 @@ +import { Ref, RefObject, useEffect, useState } from "react"; +import { useFocusEventListener } from "../scripts/spatialNavigation"; +import useActiveControl from "../scripts/gamepads"; +import { twMerge } from "tailwind-merge"; + +export default function FocusTooltip (data: { parentRef: RefObject; visible?: boolean; }) +{ + const [hoverText, setHoverText] = useState(undefined); + const [hoverTextType, setHoverTextType] = useState('accent'); + + const handleTooltipSet = (e: HTMLElement) => + { + const dataTooltip = e.getAttribute('data-tooltip'); + setHoverText(dataTooltip ?? undefined); + setHoverTextType(e.getAttribute('data-tooltip_type') ?? 'accent'); + }; + + const { isPointer } = useActiveControl(); + + useFocusEventListener('focuschanged', (e) => + { + if (e.target instanceof HTMLElement) + { + handleTooltipSet(e.target); + } + + }, data.parentRef); + + const tooltipStyles = { + base: 'bg-base-100 text-base-content', + accent: 'bg-accent text-accent-content', + error: 'bg-error text-error-content' + }; + + return !!hoverText && (data.visible ?? true) && !isPointer &&

    {hoverText}

    ; +} \ No newline at end of file diff --git a/src/mainview/components/FrontEndGameCard.tsx b/src/mainview/components/FrontEndGameCard.tsx index ad5751b..533eb29 100644 --- a/src/mainview/components/FrontEndGameCard.tsx +++ b/src/mainview/components/FrontEndGameCard.tsx @@ -1,4 +1,4 @@ -import { FrontEndGameType, FrontEndId, RPC_URL } from "@/shared/constants"; +import { RPC_URL } from "@/shared/constants"; import CardElement from "./CardElement"; import { Router } from ".."; import { FileQuestion, HardDrive, Store } from "lucide-react"; @@ -57,7 +57,7 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG subtitle={subtitle} focusKey={FOCUS_KEYS.GAME_CARD(data.game.id)} className={data.game.id.source === 'local' ? 'ring-offset-info/40 ring-offset-2' : ""} - previewClassName={data.game.id.source === 'local' ? "not-in-focused:opacity-40" : ""} + previewClassName={data.game.id.source === 'local' ? "dark:not-in-focused:opacity-40 light:not-in-focus:opacity-60" : ""} index={data.index} id={`game-${data.game.id.source}-${data.game.id.id}`} />; diff --git a/src/mainview/components/GameList.tsx b/src/mainview/components/GameList.tsx index a8c421f..8d86dad 100644 --- a/src/mainview/components/GameList.tsx +++ b/src/mainview/components/GameList.tsx @@ -1,8 +1,8 @@ -import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { useSuspenseQuery } from "@tanstack/react-query"; import { GameMetaExtra, CardList } from "./CardList"; -import { FrontEndGameType, FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants"; +import { GameListFilterType, RPC_URL } from "@shared/constants"; import { useNavigate } from "@tanstack/react-router"; -import { FileQuestion, HardDrive, Store } from "lucide-react"; +import { HardDrive } from "lucide-react"; import { JSX, useContext } from "react"; import { GameCardFocusHandler } from "./CardElement"; import { useLocalSetting } from "../scripts/utils"; @@ -75,7 +75,7 @@ export function GameList (data: GameListParams) const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`); previewUrl.searchParams.delete('ts'); - previewUrl.searchParams.set('width', "16"); + const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); platformUrl.searchParams.set('width', "64"); diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index 395601f..f79d348 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -14,7 +14,7 @@ import Bell, Bluetooth, Clock, - User, + Settings, Wifi, WifiHigh, WifiLow, @@ -22,70 +22,44 @@ import } from "lucide-react"; import { RoundButton } from "./RoundButton"; import { useQuery } from "@tanstack/react-query"; -import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@clients/romm/@tanstack/react-query.gen"; import { RPC_URL } from "../../shared/constants"; -import { JSX, Ref, RefObject, useEffect, useRef, useState } from "react"; +import { JSX, RefObject, useEffect, useRef, useState } from "react"; import { systemApi } from "../scripts/clientApi"; import { Router } from ".."; import { useStickyDataAttr } from "../scripts/utils"; +import { twMerge } from "tailwind-merge"; +import { TwitchIcon } from "../scripts/brandIcons"; +import { rommUserQuery } from "../scripts/queries/romm"; +import { twitchLoginVerificationQuery } from "../scripts/queries/settings"; function HeaderAvatar (data: { id: string; - imageSrc?: string | string[]; + preview?: string | JSX.Element; className?: string; active?: boolean; - status?: HeaderAccount['status']; locked?: boolean; - type?: HeaderAccount['type']; onSelect?: () => void; }) { - const { ref, focused } = useFocusable({ focusKey: data.id, onEnterPress: data.onSelect }); - const bgColors = { - primary: " text-primary-content", - secondary: " text-secondary-content", - accent: " text-accent-content", - base: "bg-base-100", - none: undefined, - }; return (
    - {data.imageSrc ? ( + {typeof data.preview === 'string' ? (
    - {typeof data.imageSrc === 'string' && } - {Array.isArray(data.imageSrc) && data.imageSrc.map((s, i) => - { - if (i === (data.imageSrc!.length - 1)) - { - return ; - } - return ; - })} + +
    - ) : ( - - )} - - + ) : data.preview}
    ); } @@ -101,7 +75,7 @@ export interface HeaderButton export interface HeaderAccount { id: string; - previewUrl?: string | string[]; + preview?: string | JSX.Element; status?: "status-error" | "status-success" | "status-neutral"; type?: "base" | "primary" | "secondary" | "accent"; locked?: boolean; @@ -228,32 +202,52 @@ function BatteryStatus () export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) { - const user = useQuery({ - ...getCurrentUserApiUsersMeGetOptions(), + const rommUser = useQuery({ + ...rommUserQuery(), refetchOnWindowFocus: false, retry: 1 }); + const twitchStatus = useQuery({ + ...twitchLoginVerificationQuery, refetchOnWindowFocus: false, + retry: 1 + }); - const accounts: HeaderAccount[] = [{ - id: 'romm', previewUrl: [ - `${RPC_URL(__HOST__)}/api/romm/assets/logos/romm_logo_xbox_one_square.svg`, - ], - action: () => - { - Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } }); - }, - status: user.data ? "status-success" : 'status-error', - type: 'secondary' - }, ...data.accounts ?? []]; + const { ref } = useFocusable({ focusKey: 'accounts' }); - return
    + const accounts: HeaderAccount[] = []; + if (data.accounts) accounts.push(...data.accounts); + + if (rommUser.data) + { + accounts.push({ + id: 'romm', preview: `${RPC_URL(__HOST__)}/api/romm/assets/logos/romm_logo_xbox_one_square.svg`, + action: () => + { + Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } }); + }, + status: rommUser.data ? "status-success" : 'status-error', + type: 'secondary' + }); + } + + if (twitchStatus.data) + { + accounts.push({ + id: 'twitch', preview: TwitchIcon, + action: () => + { + Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } }); + }, + type: 'secondary' + }); + } + + return
    {accounts?.map(a => )}
    ; @@ -273,7 +267,7 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
    {data.buttonElements ?? data.buttons?.map(b => + { + Router.navigate({ to: '/settings/accounts' }); + }; return (
    {data.title} - + , id: "settings", action: goToSettings, external: true }]} />
    ); diff --git a/src/mainview/components/LoadMoreButton.tsx b/src/mainview/components/LoadMoreButton.tsx index a7ff050..cdcde29 100644 --- a/src/mainview/components/LoadMoreButton.tsx +++ b/src/mainview/components/LoadMoreButton.tsx @@ -1,7 +1,6 @@ import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { FOCUS_KEYS } from "../scripts/types"; import { useIntersectionObserver } from "usehooks-ts"; -import { FrontEndId } from "@/shared/constants"; export default function LoadMoreButton (data: { isFetching: boolean; lastId?: FrontEndId; } & FocusParams & InteractParams) { diff --git a/src/mainview/components/NotFound.tsx b/src/mainview/components/NotFound.tsx index ec0a638..ea70534 100644 --- a/src/mainview/components/NotFound.tsx +++ b/src/mainview/components/NotFound.tsx @@ -25,6 +25,7 @@ export default function NotFound ()
    +
    ; diff --git a/src/mainview/components/Notifications.tsx b/src/mainview/components/Notifications.tsx index 66cbe2f..37edb26 100644 --- a/src/mainview/components/Notifications.tsx +++ b/src/mainview/components/Notifications.tsx @@ -1,4 +1,4 @@ -import { Notification, RPC_URL } from "@/shared/constants"; +import { RPC_URL } from "@/shared/constants"; import { useEffect } from "react"; import toast, { ToastOptions } from "react-hot-toast"; @@ -9,7 +9,7 @@ export default function Notifications (data: {}) const es = new EventSource(`${RPC_URL(__HOST__)}/api/system/notifications`); es.addEventListener('notification', (e) => { - const notification = JSON.parse(e.data) as Notification; + const notification = JSON.parse(e.data) as FrontendNotification; const options: ToastOptions = { removeDelay: notification.duration }; if (notification.type === 'error') { diff --git a/src/mainview/components/Screenshots.tsx b/src/mainview/components/Screenshots.tsx index a3ea021..3689b61 100644 --- a/src/mainview/components/Screenshots.tsx +++ b/src/mainview/components/Screenshots.tsx @@ -22,7 +22,7 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n } }); 4096; return
    - focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" /> + focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" decoding="async" />
    data.onAction?.(e.nativeEvent)}>
    ; } diff --git a/src/mainview/components/Shortcuts.tsx b/src/mainview/components/Shortcuts.tsx index a1253c5..d8fc94c 100644 --- a/src/mainview/components/Shortcuts.tsx +++ b/src/mainview/components/Shortcuts.tsx @@ -3,48 +3,48 @@ import { GamePadButtonCode, Shortcut } from '../scripts/shortcuts'; import ShortcutPrompt from './ShortcutPrompt'; import { IconType } from './SvgIcon'; -const iconMap: Record = { - [GamePadButtonCode.A]: 'steamdeck_button_a', - [GamePadButtonCode.B]: 'steamdeck_button_b', - [GamePadButtonCode.X]: 'steamdeck_button_x', - [GamePadButtonCode.Y]: 'steamdeck_button_y', - [GamePadButtonCode.L1]: 'steamdeck_button_l1', - [GamePadButtonCode.R1]: 'steamdeck_button_r1', - [GamePadButtonCode.L2]: 'steamdeck_button_l2', - [GamePadButtonCode.R2]: 'steamdeck_button_r2', - [GamePadButtonCode.Select]: 'steamdeck_button_guide', - [GamePadButtonCode.Start]: 'steamdeck_button_options', - [GamePadButtonCode.LJoy]: 'steamdeck_stick_l_press', - [GamePadButtonCode.RJoy]: 'steamdeck_stick_r_press', - [GamePadButtonCode.Up]: 'steamdeck_dpad_up', - [GamePadButtonCode.Down]: 'steamdeck_dpad_down', - [GamePadButtonCode.Left]: 'steamdeck_dpad_left', - [GamePadButtonCode.Right]: 'steamdeck_dpad_right', - [GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess' -}; - -const keyboardMap: Record = { - [GamePadButtonCode.A]: 'ENTER', - [GamePadButtonCode.B]: 'ESC', - [GamePadButtonCode.X]: 'BACKSPACE', - [GamePadButtonCode.Y]: 'SPACE', - [GamePadButtonCode.L1]: 'Q', - [GamePadButtonCode.R1]: 'E', - [GamePadButtonCode.L2]: '', - [GamePadButtonCode.R2]: '', - [GamePadButtonCode.Select]: '', - [GamePadButtonCode.Start]: '', - [GamePadButtonCode.LJoy]: '', - [GamePadButtonCode.RJoy]: '', - [GamePadButtonCode.Up]: '', - [GamePadButtonCode.Down]: '', - [GamePadButtonCode.Left]: '', - [GamePadButtonCode.Right]: '', - [GamePadButtonCode.Steam]: '' -}; - export default function Shortcuts (data: { shortcuts?: Shortcut[]; }) { + const iconMap: Record = { + [GamePadButtonCode.A]: 'steamdeck_button_a', + [GamePadButtonCode.B]: 'steamdeck_button_b', + [GamePadButtonCode.X]: 'steamdeck_button_x', + [GamePadButtonCode.Y]: 'steamdeck_button_y', + [GamePadButtonCode.L1]: 'steamdeck_button_l1', + [GamePadButtonCode.R1]: 'steamdeck_button_r1', + [GamePadButtonCode.L2]: 'steamdeck_button_l2', + [GamePadButtonCode.R2]: 'steamdeck_button_r2', + [GamePadButtonCode.Select]: 'steamdeck_button_guide', + [GamePadButtonCode.Start]: 'steamdeck_button_options', + [GamePadButtonCode.LJoy]: 'steamdeck_stick_l_press', + [GamePadButtonCode.RJoy]: 'steamdeck_stick_r_press', + [GamePadButtonCode.Up]: 'steamdeck_dpad_up', + [GamePadButtonCode.Down]: 'steamdeck_dpad_down', + [GamePadButtonCode.Left]: 'steamdeck_dpad_left', + [GamePadButtonCode.Right]: 'steamdeck_dpad_right', + [GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess' + }; + + const keyboardMap: Record = { + [GamePadButtonCode.A]: 'ENTER', + [GamePadButtonCode.B]: 'ESC', + [GamePadButtonCode.X]: 'BACKSPACE', + [GamePadButtonCode.Y]: 'SPACE', + [GamePadButtonCode.L1]: 'Q', + [GamePadButtonCode.R1]: 'E', + [GamePadButtonCode.L2]: '', + [GamePadButtonCode.R2]: '', + [GamePadButtonCode.Select]: '', + [GamePadButtonCode.Start]: '', + [GamePadButtonCode.LJoy]: '', + [GamePadButtonCode.RJoy]: '', + [GamePadButtonCode.Up]: '', + [GamePadButtonCode.Down]: '', + [GamePadButtonCode.Left]: '', + [GamePadButtonCode.Right]: '', + [GamePadButtonCode.Steam]: '' + }; + const { control } = useActiveControl(); const showKeyboard = control === 'keyboard' || control === 'mouse'; return ( diff --git a/src/mainview/components/game/Achievements.tsx b/src/mainview/components/game/Achievements.tsx index f901b1d..9296403 100644 --- a/src/mainview/components/game/Achievements.tsx +++ b/src/mainview/components/game/Achievements.tsx @@ -1,4 +1,4 @@ -import { FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement } from "@/shared/constants"; + import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { Medal } from "lucide-react"; diff --git a/src/mainview/components/game/ActionButton.tsx b/src/mainview/components/game/ActionButton.tsx new file mode 100644 index 0000000..9c9f38c --- /dev/null +++ b/src/mainview/components/game/ActionButton.tsx @@ -0,0 +1,42 @@ +import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import classNames from "classnames"; +import { JSX } from "react"; +import { twMerge } from "tailwind-merge"; + +export default function ActionButton (data: { + id: string, + icon?: JSX.Element, + children?: any | any[]; + className?: string; + type: "primary" | 'base' | "accent" | 'error'; + square?: boolean, + onFocus?: () => void; + tooltip?: string, + tooltip_type?: 'accent' | 'error'; + onAction?: () => void; + disabled?: boolean; +}) +{ + const { ref } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true }); + const styles = { + primary: "bg-primary text-primary-content focused:bg-base-content focused:text-base-300 focusable focusable-primary", + base: " text-base-content border-dashed border-base-content/20 border-2 focused:bg-base-content focused:text-base-300 focusable focusable-primary", + accent: "bg-accent text-accent-content focusable focusable-primary focusable:bg-base-content focusable:text-base-300", + error: "bg-error text-error-content focused:bg-error focused:text-error-content", + }; + return ( +
    + +
    + ); +} \ No newline at end of file diff --git a/src/mainview/components/game/ActionButtons.tsx b/src/mainview/components/game/ActionButtons.tsx new file mode 100644 index 0000000..2d62a2e --- /dev/null +++ b/src/mainview/components/game/ActionButtons.tsx @@ -0,0 +1,84 @@ +import { deleteGameMutation } from "@/mainview/scripts/queries/romm"; +import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { useMutation } from "@tanstack/react-query"; +import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; +import { getErrorMessage } from "react-error-boundary"; +import toast from "react-hot-toast"; +import { Settings, Trash, Trophy } from "lucide-react"; +import MainActions from "./MainActions"; +import ActionButton from "./ActionButton"; +import { useLocalStorage } from "usehooks-ts"; +import FocusTooltip from "../FocusTooltip"; + +function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractParams) +{ + if (!data.game.achievements) + { + return false; + } + + return +
    +
    + + {`${data.game.achievements.unlocked}/${data.game.achievements.total}`} +
    + +
    +
    ; +} + +export default function ActionButtons (data: { game: FrontEndGameTypeDetailed, source: string, id: string; }) +{ + const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots'); + + const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: 'actions', trackChildren: true }); + const deleteMutation = useMutation({ + ...deleteGameMutation(data.game.id), + onSuccess: () => + { + location.reload(); + console.log("Deleted"); + }, + onError (error) + { + toast.error(getErrorMessage(error) ?? "Error While Deleting"); + } + }); + + const contextOptions: DialogEntry[] = []; + if (data.game.local) + { + contextOptions.push({ + id: 'delete', + action: () => + { + deleteMutation.mutate(); + }, + icon: , + content: "Delete", + type: 'error' + }); + } + + const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: }); + + return
    + + + + { + setDetailsSection("achievements"); + if (data.game.achievements?.entires[0]) + { + setFocus(data.game.achievements.entires[0].id); + } + + }} /> + setOpen(true, 'settings')} type="base" id="settings" icon={} > + + {settingsDialog} + + +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/game/Details.tsx b/src/mainview/components/game/Details.tsx new file mode 100644 index 0000000..2c4c187 --- /dev/null +++ b/src/mainview/components/game/Details.tsx @@ -0,0 +1,95 @@ +import { scrollIntoViewHandler } from "@/mainview/scripts/utils"; +import { RPC_URL } from "@/shared/constants"; +import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import classNames from "classnames"; +import { Clock, CloudDownload, HardDrive, Store, TriangleAlert } from "lucide-react"; +import prettyBytes from "pretty-bytes"; +import { JSX } from "react"; +import ActionButtons from "./ActionButtons"; + + +export function DetailElement (data: { icon: JSX.Element; children?: any | any[]; }) +{ + return ( +
    + {data.icon} + {data.children} +
    + ); +} + +export default function Details (data: { + game?: FrontEndGameTypeDetailed, + source: string, + id: string; +}) +{ + const { ref, focusKey } = useFocusable({ + focusKey: 'main-details', + onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'end', behavior: 'smooth' })(focusKey, ref.current, d), + preferredChildFocusKey: "play-btn", + saveLastFocusedChild: false + }); + + const platformCoverImg = data.game?.path_platform_cover ? new URL(`${RPC_URL(__HOST__)}${data.game?.path_platform_cover}`) : undefined; + if (platformCoverImg) + platformCoverImg.searchParams.set("width", "64"); + const gameCoverImg = data.game?.path_cover ? `${RPC_URL(__HOST__)}${data.game?.path_cover}` : undefined; + + let fileSizeIcon: JSX.Element | undefined; + if (!data.game) + { + fileSizeIcon = ; + } else if (data.game.missing) + { + fileSizeIcon = ; + } else if (data.game.local) + { + fileSizeIcon = ; + } else + { + fileSizeIcon = ; + } + + return
    + +
    +
    + {gameCoverImg ? + : +
    + } +
    +
    +
    + } >{data.game?.last_played ? new Date(data.game.last_played).toDateString() : "Never"} + {!!data.game && (data.game.fs_size_bytes !== null || data.game.missing) && +
    +
    + {data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)} +
    +
    } + :
    } >{data.game?.platform_display_name ??
    }
    + + } > + {data.game?.source ?? data.game?.id.source} + {data.game?.local && local} +
    +
    +
    + {data.game?.summary ??
    +
    +
    +
    +
    +
    +
    +
    } +
    + {!!data.game && } +
    +
    +
    +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx new file mode 100644 index 0000000..6c914c0 --- /dev/null +++ b/src/mainview/components/game/MainActions.tsx @@ -0,0 +1,207 @@ +import { Router } from "@/mainview"; +import { rommApi } from "@/mainview/scripts/clientApi"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { JSX, useEffect, useRef, useState } from "react"; +import { getErrorMessage } from "react-error-boundary"; +import toast from "react-hot-toast"; +import { useLocalStorage } from "usehooks-ts"; +import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; +import { Clock, Download, EllipsisVertical, PackageOpen, Play, TriangleAlert } from "lucide-react"; +import { installMutation, playMutation } from "@/mainview/scripts/queries/romm"; +import ActionButton from "./ActionButton"; + +export default function MainActions (data: { game: FrontEndGameTypeDetailed, source: string, id: string; }) +{ + const installMut = useMutation(installMutation(data.source, data.id)); + const playMut = useMutation({ + ...playMutation, onError (error) + { + toast.error(error.message); + }, + onSuccess (data, { source, id }, onMutateResult, context) + { + Router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id }, replace: true }); + }, + }); + const ws = useRef<{ send: (data: string) => void; }>(undefined); + const [progress, setProgress] = useState(undefined); + const [status, setStatus] = useState(undefined); + const [error, setError] = useState(undefined); + const [details, setDetails] = useState(undefined); + const [commands, setCommands] = useState(undefined); + const [preferredCommand, setPreferredCommand] = useLocalStorage(`${data.game.source ?? data.game.id.source}-${data.game.source_id ?? data.game.id.id}-preferred-command`, undefined); + const queryClient = useQueryClient(); + const validCommands = commands ? commands.filter(c => c.valid) : []; + const validDefaultCommand = commands?.find(c => + { + if (!c.valid) return false; + if (preferredCommand && c.id !== preferredCommand) return false; + return true; + }); + + useEffect(() => + { + const sub = rommApi.api.romm.status({ source: data.game.id.source })({ id: data.game.id.id }).subscribe(); + ws.current = sub.ws; + + sub.subscribe((e) => + { + setStatus(e.data.status); + setProgress((e.data as any).progress); + setDetails((e.data as any).details); + setCommands((e.data as any).commands); + + if (e.data.status === 'refresh') + { + queryClient.invalidateQueries({ queryKey: ['game', data.id] }); + Router.navigate({ to: '/game/$source/$id', params: { id: data.id, source: data.source }, replace: true }); + } else if (e.data.status === 'error') + { + const errorMessage = getErrorMessage(e.data.error); + if (!errorMessage) return; + toast.error(errorMessage); + setError(errorMessage); + } + }); + + return () => + { + sub.close(); + ws.current = undefined; + }; + }, [data.game.id]); + + let progressIcon: JSX.Element | undefined = undefined; + switch (status) + { + case 'download': + progressIcon = ; + break; + case 'queued': + progressIcon = ; + break; + case 'extract': + progressIcon = ; + break; + } + + 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(cmd.command); + Router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()), replace: true }); + } else + { + playMut.mutate({ source: data.game.id.source, id: data.game.id.id, command_id: cmd.id }); + } + }; + + let mainButton: any | undefined = undefined; + if (status === 'installed') + { + mainButton =
    handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details} + key="primary" + type='primary' + id="mainAction" + > + + + + + {validCommands.length > 1 && + showAllCommands(true, 'allActionsBtn')}> + + }
    ; + } + else if (error) + { + mainButton = + { + if (status === 'missing-emulator') + { + Router.navigate({ to: '/settings/directories' }); + } + }} + id="mainAction"> + + ; + } + else + { + mainButton = + { + if (status === 'install') + { + installMut.mutate(); + } + }} + tooltip={details ?? status} + type='primary' + id="mainAction"> + {status === 'install' ? : } + ; + } + + const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', { + content: + { + const commands: DialogEntry = { + id: String(c.id), + content: c.label ?? "", + type: 'primary', + action (ctx) + { + setPreferredCommand(c.id); + handlePlay(c); + }, + }; + return commands; + })} />, + preferredChildFocusKey: String(preferredCommand) + }); + + const { dialog: installOptionsDialog, setOpen: showInstallOptions } = useContextDialog('install-options-dialog', { + content: + }); + + return
    + {mainButton} +
    + {showProgress && showInstallOptions(true, "progress")} key="progress" square tooltip={details} type="base" id="progress" > +
    +
    + {progressIcon} +
    + +
    +
    } + {installOptionsDialog} + {allCommandDialog} +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/options/Button.tsx b/src/mainview/components/options/Button.tsx index ce3c44c..1c6e63c 100644 --- a/src/mainview/components/options/Button.tsx +++ b/src/mainview/components/options/Button.tsx @@ -11,14 +11,14 @@ import { CSSProperties } from "react"; export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'; const styles = { - base: 'bg-base-200 text-base-content active:bg-base-300! active:text-base-content! active:ring-offset-base-content', - accent: "bg-accent text-accent-content active:bg-base-content! active:text-base-content active:ring-offset-accent", - primary: "bg-primary text-primary-content active:bg-base-content! active:text-base-content! active:ring-offset-primary", - secondary: "bg-secondary text-secondary-content active:bg-base-content! active:text-base-content! active:ring-offset-secondary", - info: "bg-info text-info-content active:bg-base-content! active:text-base-content! active:ring-offset-info", - success: "bg-success text-success-content active:bg-base-content! active:text-base-content! active:ring-offset-success", - warning: "bg-warning text-warning-content active:bg-base-content! active:text-base-content! active:ring-offset-warning", - error: "bg-error text-error-content active:bg-base-content! active:text-base-content! active:ring-offset-error", + 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", + 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", + info: "bg-info text-info-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-info", + success: "bg-success text-success-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-success", + warning: "bg-warning text-warning-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-warning", + error: "bg-error text-error-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-error", }; export function Button (data: { @@ -31,6 +31,8 @@ export function Button (data: { shortcutLabel?: string; focusClassName?: string; cssStyle?: CSSProperties; + tooltip?: string; + tooltipType?: "base" | "accent" | "error"; } & InteractParams & FocusParams) { const { ref, focused, focusKey } = useFocusable({ @@ -49,8 +51,10 @@ export function Button (data: { ref={ref} onClick={e => data.onAction?.(e.nativeEvent)} disabled={data.disabled} + data-tooltip={data.tooltip} + data-tooltip_type={data.tooltipType} style={data.cssStyle} - className={twMerge("flex items-center justify-center px-4 py-2 disabled:bg-base-200/40 disabled:text-base-content/40 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:bg-base-content control-mouse:hover:text-base-100 active:transition-none active: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'], focused ? data.focusClassName : undefined, classNames({ diff --git a/src/mainview/components/options/OptionDropdown.tsx b/src/mainview/components/options/OptionDropdown.tsx index 3045e74..071b9f6 100644 --- a/src/mainview/components/options/OptionDropdown.tsx +++ b/src/mainview/components/options/OptionDropdown.tsx @@ -3,6 +3,7 @@ import { twMerge } from "tailwind-merge"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { ContextDialog, ContextList, DialogEntry } from "../ContextDialog"; import { ChevronDown } from "lucide-react"; +import { FOCUS_KEYS } from "@/mainview/scripts/types"; export function OptionDropdown (data: { name: string; @@ -38,7 +39,7 @@ export function OptionDropdown (data: { setOpen(true); }} className={'flex items-center justify-center border h-10 border-base-content/30 px-4 py-2 rounded-full cursor-pointer grow not-in-focused:bg-base-200 focusable focusable-accent hover:border-base-content hover:bg-base-content hover:text-base-300'}>{data.value} - {open && + {open && ({ content: v, id: String(i), diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index bd903c6..1f43246 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -11,7 +11,7 @@ export function OptionInput (data: { className?: string; placeholder?: string; icon?: JSX.Element; - value?: string; + value?: string | boolean; defaultValue?: string | boolean; autocomplete?: HTMLInputAutoCompleteAttribute; onBlur?: FocusEventHandler; @@ -58,7 +58,7 @@ export function OptionInput (data: { id={data.name} data-focus={"input"} name={data.name} - value={data.value} + value={String(data.value)} defaultValue={typeof data.defaultValue === 'string' ? data.defaultValue : undefined} type={data.type} autoComplete={data.autocomplete} @@ -68,24 +68,22 @@ export function OptionInput (data: { onBlur={data.onBlur} defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined} className={twMerge( - "flex text-base-content px-4 py-2 items-center justify-center border border-base-content/20 grow rounded-full focus:ring-base-content in-focused:bg-base-200 focusable focusable-accent focus:not-focused:ring-7 control-mouse:ring-0! hover:border-base-content", + "flex text-base-content px-4 py-2 items-center justify-center border bg-base-200 border-base-content/20 grow rounded-full focus:ring-base-content in-focused:bg-base-100 focusable focusable-accent focus:not-focused:ring-7 control-mouse:ring-0! hover:border-base-content", data.className )} />} - {data.type === 'checkbox' &&
    + {data.type === 'checkbox' &&
    data.onChange?.(typeof data.defaultValue === 'boolean' ? e.target.checked : e.target.value)} + onChange={e => data.onChange?.(e.target.checked)} onBlur={data.onBlur} - defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined} className={twMerge( data.className )} diff --git a/src/mainview/components/options/PathSettingsOption.tsx b/src/mainview/components/options/PathSettingsOption.tsx index 5dcef7f..29ba634 100644 --- a/src/mainview/components/options/PathSettingsOption.tsx +++ b/src/mainview/components/options/PathSettingsOption.tsx @@ -10,10 +10,6 @@ import FilePicker from "../FilePicker"; import { setFocus } from "@noriginmedia/norigin-spatial-navigation"; import { getSettingQuery, setSettingMutation } from "@queries/settings"; -type KeysWithValueAssignableTo = { - [K in keyof T]: Exclude extends Value ? K : never; -}[keyof T]; - export interface PathSettingsOptionParams { label: string; @@ -68,11 +64,8 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & { useEffect(() => { - if (!data.isDirty) - { - data.setLocalValue(String(defaultValue)); - } - }, [data.isDirty, defaultValue]); + data.setLocalValue(String(defaultValue)); + }, [defaultValue]); const handleSelectPath = (path: string) => { diff --git a/src/mainview/components/options/SettingsOption.tsx b/src/mainview/components/options/SettingsOption.tsx index 79cafb0..3775958 100644 --- a/src/mainview/components/options/SettingsOption.tsx +++ b/src/mainview/components/options/SettingsOption.tsx @@ -1,17 +1,13 @@ -import { HTMLInputTypeAttribute, JSX, useCallback, useState } from "react"; +import { HTMLInputTypeAttribute, JSX, useCallback, useEffect, useState } from "react"; import { SettingsType } from "../../../shared/constants"; import { useMutation, useQuery } from "@tanstack/react-query"; import { OptionSpace } from "./OptionSpace"; import { OptionInput } from "./OptionInput"; import { getSettingQuery, setSettingMutation } from "@queries/settings"; -type KeysWithValueAssignableTo = { - [K in keyof T]: Exclude extends Value ? K : never; -}[keyof T]; - export function SettingsOption (data: { label: string; - id: KeysWithValueAssignableTo; + id: KeysWithValueAssignableTo; type: HTMLInputTypeAttribute; placeholder?: string; icon?: JSX.Element; @@ -19,10 +15,16 @@ export function SettingsOption (data: { }) { const [dirty, setDirty] = useState(false); - const [localValue, setLocalValue] = useState(); - useQuery(getSettingQuery(data.id)); + const [localValue, setLocalValue] = useState(); + const { data: serverValue } = useQuery(getSettingQuery(data.id)); const setMutation = useMutation(setSettingMutation(data.id)); + useEffect(() => + { + setLocalValue(serverValue as any); + setDirty(false); + }, [serverValue]); + const handleSave = useCallback(() => { if (dirty) @@ -43,7 +45,14 @@ export function SettingsOption (data: { onChange={(v) => { setLocalValue(v); - setDirty(true); + + if (data.type === 'checkbox') + { + setMutation.mutate(v); + } else + { + setDirty(true); + } }} value={localValue} /> diff --git a/src/mainview/components/store/EmulatorsSection.tsx b/src/mainview/components/store/EmulatorsSection.tsx index 1b0a179..a846406 100644 --- a/src/mainview/components/store/EmulatorsSection.tsx +++ b/src/mainview/components/store/EmulatorsSection.tsx @@ -11,7 +11,6 @@ import FocusDots from "../FocusDots"; import { Router } from "@/mainview"; import { StoreEmulatorCard } from "./StoreEmulatorCard"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; -import { FrontEndEmulator } from "@/shared/constants"; import Carousel from "../Carousel"; function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant: boolean; }) => void; }) @@ -51,18 +50,18 @@ export function EmulatorsSection (data: { return ( -
    +
    {data.header ?? <> -
    - -

    +
    + +

    Recommended Emulators

    }
    - + {data.emulators?.map((em) => ( data.onSelect?.(em.name, focusKey)} onFocus={({ node, details }) => { diff --git a/src/mainview/components/store/GamesSection.tsx b/src/mainview/components/store/GamesSection.tsx index 38327f8..54f4057 100644 --- a/src/mainview/components/store/GamesSection.tsx +++ b/src/mainview/components/store/GamesSection.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, Ref, RefObject, useEffect, useRef } from "react"; +import { Ref, useEffect, useRef } from "react"; import { useFocusable, @@ -6,7 +6,6 @@ import } from "@noriginmedia/norigin-spatial-navigation"; import { scrollIntoNearestParent, useDragScroll } from "@/mainview/scripts/utils"; import FocusDots from "../FocusDots"; -import { FrontEndGameType, FrontEndId } from "@/shared/constants"; import FrontEndGameCard from "../FrontEndGameCard"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import Carousel from "../Carousel"; diff --git a/src/mainview/components/store/MissingEmulatorsSection.tsx b/src/mainview/components/store/MissingEmulatorsSection.tsx index 0613d3a..b064ec8 100644 --- a/src/mainview/components/store/MissingEmulatorsSection.tsx +++ b/src/mainview/components/store/MissingEmulatorsSection.tsx @@ -7,7 +7,7 @@ import { Button } from "../options/Button"; import useActiveControl from "@/mainview/scripts/gamepads"; import { ChevronRight, CircleQuestionMark, SearchAlert } from "lucide-react"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; -import { FrontEndEmulator, RPC_URL } from "@/shared/constants"; +import { RPC_URL } from "@/shared/constants"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; // ── Single missing-emulator card ─────────────────────────────────────────── diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index f6dd24c..78fd7d4 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -1,11 +1,11 @@ import { twMerge } from "tailwind-merge"; -import { FrontEndEmulator, RPC_URL } from "@/shared/constants"; +import { RPC_URL } from "@/shared/constants"; import { Button } from "../options/Button"; import useActiveControl from "@/mainview/scripts/gamepads"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; -import { ChevronRight, EllipsisVertical, FileQuestion, IceCream2, Package, Store } from "lucide-react"; +import { BadgeCheck, ChevronRight, EllipsisVertical, FileQuestion, IceCream2, Package, Sparkles, Store, WandSparkles } from "lucide-react"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import { FlatpackIcon } from "@/mainview/scripts/brandIcons"; import { JSX } from "react"; @@ -54,14 +54,13 @@ export function StoreEmulatorCard (data: {
    -

    {data.emulator.name}

    +

    {data.emulator.name}

      {data.emulator.systems.map(({ id, name, icon }) => { @@ -75,15 +74,15 @@ export function StoreEmulatorCard (data: {
    -
    +
    + {!!data.emulator.integration && data.emulator.validSource?.type === 'store' &&
    +
    +
    } {!!data.emulator.validSource &&
    -
    +
    {emulatorStatusIcons[data.emulator.validSource?.type ?? '']}
    } - {data.emulator.gameCount > 0 &&
    -
    {data.emulator.gameCount}
    -
    } {isMouse && <> diff --git a/src/mainview/emulatorjs/emulator.ts b/src/mainview/emulatorjs/emulator.ts index ce99e6c..61e570b 100644 --- a/src/mainview/emulatorjs/emulator.ts +++ b/src/mainview/emulatorjs/emulator.ts @@ -61,4 +61,4 @@ const moduleUrls = import.meta.glob // emulatorjs expects basenames instead of paths for some reason window.EJS_paths = Object.fromEntries(await Promise.all(Object.entries(moduleUrls).map(async ([key, value]) => [basename(key), await value()]))); -await import('@emulatorjs/emulatorjs/data/loader.js'); \ No newline at end of file +await import('@emulatorjs/emulatorjs/data/loader.js' as any); \ No newline at end of file diff --git a/src/mainview/gen/routeTree.gen.ts b/src/mainview/gen/routeTree.gen.ts index 38b4102..d730600 100644 --- a/src/mainview/gen/routeTree.gen.ts +++ b/src/mainview/gen/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './../routes/__root' import { Route as GamesRouteImport } from './../routes/games' import { Route as SettingsRouteRouteImport } from './../routes/settings/route' import { Route as IndexRouteImport } from './../routes/index' +import { Route as SettingsPluginsRouteImport } from './../routes/settings/plugins' import { Route as SettingsInterfaceRouteImport } from './../routes/settings/interface' import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emulators' import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/directories' @@ -43,6 +44,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const SettingsPluginsRoute = SettingsPluginsRouteImport.update({ + id: '/plugins', + path: '/plugins', + getParentRoute: () => SettingsRouteRoute, +} as any) const SettingsInterfaceRoute = SettingsInterfaceRouteImport.update({ id: '/interface', path: '/interface', @@ -130,6 +136,7 @@ export interface FileRoutesByFullPath { '/settings/directories': typeof SettingsDirectoriesRoute '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute + '/settings/plugins': typeof SettingsPluginsRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute @@ -149,6 +156,7 @@ export interface FileRoutesByTo { '/settings/directories': typeof SettingsDirectoriesRoute '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute + '/settings/plugins': typeof SettingsPluginsRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute @@ -170,6 +178,7 @@ export interface FileRoutesById { '/settings/directories': typeof SettingsDirectoriesRoute '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute + '/settings/plugins': typeof SettingsPluginsRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute @@ -192,6 +201,7 @@ export interface FileRouteTypes { | '/settings/directories' | '/settings/emulators' | '/settings/interface' + | '/settings/plugins' | '/embedded/$source/$id' | '/game/$source/$id' | '/launcher/$source/$id' @@ -211,6 +221,7 @@ export interface FileRouteTypes { | '/settings/directories' | '/settings/emulators' | '/settings/interface' + | '/settings/plugins' | '/embedded/$source/$id' | '/game/$source/$id' | '/launcher/$source/$id' @@ -231,6 +242,7 @@ export interface FileRouteTypes { | '/settings/directories' | '/settings/emulators' | '/settings/interface' + | '/settings/plugins' | '/embedded/$source/$id' | '/game/$source/$id' | '/launcher/$source/$id' @@ -277,6 +289,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/settings/plugins': { + id: '/settings/plugins' + path: '/plugins' + fullPath: '/settings/plugins' + preLoaderRoute: typeof SettingsPluginsRouteImport + parentRoute: typeof SettingsRouteRoute + } '/settings/interface': { id: '/settings/interface' path: '/interface' @@ -391,6 +410,7 @@ interface SettingsRouteRouteChildren { SettingsDirectoriesRoute: typeof SettingsDirectoriesRoute SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute SettingsInterfaceRoute: typeof SettingsInterfaceRoute + SettingsPluginsRoute: typeof SettingsPluginsRoute } const SettingsRouteRouteChildren: SettingsRouteRouteChildren = { @@ -399,6 +419,7 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = { SettingsDirectoriesRoute: SettingsDirectoriesRoute, SettingsEmulatorsRoute: SettingsEmulatorsRoute, SettingsInterfaceRoute: SettingsInterfaceRoute, + SettingsPluginsRoute: SettingsPluginsRoute, } const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren( diff --git a/src/mainview/index.css b/src/mainview/index.css index f4be94e..3bad22c 100644 --- a/src/mainview/index.css +++ b/src/mainview/index.css @@ -3,6 +3,7 @@ @plugin "daisyui"; @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); +@custom-variant light (&:where([data-theme=light], [data-theme=light] *)); @theme { --breakpoint-sm: 0px; @@ -194,6 +195,7 @@ html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; + background-color: var(--color-base-100); } body { @@ -344,18 +346,21 @@ body { width: 100%; height: 100%; z-index: -1; + background-repeat: repeat; --bg-gradient-opacity: 15%; - background: - radial-gradient(at 10% 20%, rgb(from var(--color-error) r g b / var(--bg-gradient-opacity)), transparent 60%), - radial-gradient(at 80% 30%, rgb(from var(--color-info) r g b / var(--bg-gradient-opacity)), transparent 60%), - radial-gradient(at 40% 90%, rgb(from var(--color-success) r g b / var(--bg-gradient-opacity)), transparent 60%), - radial-gradient(at 90% 80%, rgb(from var(--color-warning) r g b / var(--bg-gradient-opacity)), transparent 60%); + @variant dark { + background: + radial-gradient(at 10% 20%, rgb(from var(--color-error) r g b / var(--bg-gradient-opacity)), transparent 60%), + radial-gradient(at 80% 30%, rgb(from var(--color-info) r g b / var(--bg-gradient-opacity)), transparent 60%), + radial-gradient(at 40% 90%, rgb(from var(--color-success) r g b / var(--bg-gradient-opacity)), transparent 60%), + radial-gradient(at 90% 80%, rgb(from var(--color-warning) r g b / var(--bg-gradient-opacity)), transparent 60%); + background-color: var(--color-base-100); + } - background-blend-mode: lighten; - background-repeat: repeat; - background-color: var(--color-base-100); - @apply mobile:hidden; + @variant light { + background-color: var(--color-base-300); + } } .bg-noise { @@ -368,6 +373,26 @@ body { opacity: 0.1; } + .bg-dots { + position: absolute; + width: 100%; + height: 100%; + z-index: -1; + background-image: radial-gradient(var(--color-neutral) 0.1rem, transparent 0.1rem); + background-size: 2rem 2rem; + background-position: -1rem -1rem; + + @variant dark { + opacity: 0.5; + @apply mask-radial-at-center mask-radial-from-0 mask-radial-farthest-corner; + } + + @variant light { + opacity: 0.3; + @apply mask-radial-at-center mask-radial-from-0 mask-radial-farthest-corner; + } + } + .bg-gradient-back { --bg-opacity: 90%; @@ -407,22 +432,22 @@ body { html:active-view-transition-type(zoom-in) { &::view-transition-old(root) { - animation: fade-out 300ms ease-in forwards; + animation: fade-out 200ms ease-in forwards; } &::view-transition-new(root) { - animation: zoom-in-fade-in 300ms ease-in-out forwards; + animation: zoom-in-fade-in 200ms ease-out forwards; } } html:active-view-transition-type(zoom-out) { &::view-transition-old(root) { - animation: zoom-out-fade-out 300ms ease-in-out forwards; + animation: zoom-out-fade-out 200ms ease-out forwards; } &::view-transition-new(root) { - animation: zoom-start-small-in-fade-in 300ms ease-in-out forwards; + animation: zoom-start-small-in-fade-in 200ms ease-out forwards; } } diff --git a/src/mainview/index.html b/src/mainview/index.html index d468edc..43c30a6 100644 --- a/src/mainview/index.html +++ b/src/mainview/index.html @@ -1,5 +1,5 @@ - + diff --git a/src/mainview/routes/__root.tsx b/src/mainview/routes/__root.tsx index 73c5fa6..243d778 100644 --- a/src/mainview/routes/__root.tsx +++ b/src/mainview/routes/__root.tsx @@ -4,6 +4,7 @@ import Notifications from "../components/Notifications"; import { Toaster } from "react-hot-toast"; import { mobileCheck, useLocalSetting } from "../scripts/utils"; import useActiveControl from "../scripts/gamepads"; +import { useEffect } from "react"; export const Route = createRootRouteWithContext()({ component: RootComponent, @@ -14,9 +15,24 @@ function RootComponent () const isMobile = mobileCheck(); const theme = useLocalSetting('theme'); const { control } = useActiveControl(); + useEffect(() => + { + if (theme === 'auto') + { + const preferred = window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + + window.document.documentElement.dataset.theme = preferred; + } else + { + window.document.documentElement.dataset.theme = theme; + } + + }, [theme]); return ( -
    +
    diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 3d35ec4..1969410 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -1,23 +1,16 @@ import { createFileRoute, ErrorComponentProps } from "@tanstack/react-router"; -import { CommandEntry, RPC_URL } from "@shared/constants"; -import { twMerge } from "tailwind-merge"; -import { JSX, RefObject, useEffect, useRef, useState } from "react"; +import { RPC_URL } from "@shared/constants"; +import { useEffect, useRef, useState } from "react"; import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import classNames from "classnames"; -import { Calendar, Clock, CloudDownload, Download, EllipsisVertical, Folder, Gamepad2, HardDrive, Image, Info, PackageOpen, Play, Settings, Store, Trash, TriangleAlert, Trophy } from "lucide-react"; +import { Calendar, Clock, Folder, Gamepad2, Image, Info, Store, TriangleAlert, Trophy } from "lucide-react"; import { HeaderUI } from "../../components/Header"; -import prettyBytes from 'pretty-bytes'; -import { useFocusEventListener } from "../../scripts/spatialNavigation"; import { AnimatedBackground } from "../../components/AnimatedBackground"; -import toast from "react-hot-toast"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Router } from "../.."; -import { ContextDialog, ContextList, DialogEntry, useContextDialog } from "../../components/ContextDialog"; import Shortcuts from "../../components/Shortcuts"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; import Screenshots from "@/mainview/components/Screenshots"; import { HandleGoBack, scrollIntoViewHandler, useStickyDataAttr } from "@/mainview/scripts/utils"; -import useActiveControl from "@/mainview/scripts/gamepads"; import { FilterUI } from "@/mainview/components/Filters"; import StatList, { StatEntry } from "@/mainview/components/StatList"; import { useIntersectionObserver, useLocalStorage } from "usehooks-ts"; @@ -25,19 +18,17 @@ import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection"; import { zodValidator } from "@tanstack/zod-adapter"; import z from "zod"; import Achievements from "@/mainview/components/game/Achievements"; -import { getErrorMessage } from "react-error-boundary"; import { GameDetailsContext } from "@/mainview/scripts/contexts"; -import { rommApi } from "@/mainview/scripts/clientApi"; -import { deleteGameMutation, gameQuery, gamesRecommendedBasedOnGameQuery, installMutation, playMutation } from "@queries/romm"; +import { gameQuery, gamesRecommendedBasedOnGameQuery } from "@queries/romm"; import { GamesSection } from "@/mainview/components/store/GamesSection"; +import Details, { DetailElement } from "@/mainview/components/game/Details"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => { - const data = await context.queryClient.fetchQuery(gameQuery(params.source, params.id)); - return { data }; + context.queryClient.prefetchQuery(gameQuery(params.source, params.id)); }, - component: GameDetailsUI, + component: RouteComponent, pendingComponent: GameDetailsUIPending, errorComponent: Error, validateSearch: zodValidator(z.object({ focus: z.string().optional() })) @@ -92,13 +83,13 @@ function MainDetailsPending ()
    - } > - } >
    - } > + } >
    + } > -
    +
    @@ -155,9 +146,8 @@ function GameDetailsUIPending () ; } -function MoreDetails (data: {}) +function MoreDetails (data: { game: FrontEndGameTypeDetailed | undefined; }) { - const { data: game } = Route.useLoaderData(); const [details] = useDetailsSection(); const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: "game-more-details-section", @@ -167,456 +157,41 @@ function MoreDetails (data: {}) return
    - +
    - {details === 'screenshots' &&
    } - {details === 'stats' && } - {details === 'achievements' && } + {details === 'screenshots' && !!data.game &&
    } + {details === 'stats' && } + {details === 'achievements' && !!data.game && }
    ; } -function Details (data: { mainAreaRef: RefObject; }) +function Stats (data: { game: FrontEndGameTypeDetailed | undefined; }) { - const { data: game } = Route.useLoaderData(); - const { ref, focusKey } = useFocusable({ - focusKey: 'main-details', - onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'end', behavior: 'smooth' })(focusKey, ref.current, d), - preferredChildFocusKey: "play-btn", - saveLastFocusedChild: false - }); - - const platformCoverImg = new URL(`${RPC_URL(__HOST__)}${game?.path_platform_cover ?? ''}`); - platformCoverImg.searchParams.set("width", "64"); - const gameCoverImg = game?.path_cover ? `${RPC_URL(__HOST__)}${game?.path_cover}` : undefined; - - let fileSizeIcon: JSX.Element | undefined; - if (!game) - { - fileSizeIcon = ; - } else if (game.missing) - { - fileSizeIcon = ; - } else if (game.local) - { - fileSizeIcon = ; - } else - { - fileSizeIcon = ; - } - - return
    - -
    -
    - {gameCoverImg ? - : -
    - } -
    -
    -
    - } >{game?.last_played ? new Date(game.last_played).toDateString() : "Never"} - {!!game && (game.fs_size_bytes !== null || game.missing) && -
    -
    - {game.missing ? 'Missing' : prettyBytes(game.fs_size_bytes!)} -
    -
    } - } >{game?.platform_display_name ??
    }
    - - } > - {game?.source ?? game?.id.source} - {game?.local && local} -
    -
    -
    - {game?.summary ??
    -
    -
    -
    -
    -
    -
    -
    } -
    - {!!game && } -
    -
    -
    -
    ; -} - -function AchievementsInfo (data: InteractParams) -{ - const { data: game } = Route.useLoaderData(); - if (!game.achievements) - { - return false; - } - - return -
    -
    - - {`${game.achievements.unlocked}/${game.achievements.total}`} -
    - -
    -
    ; -} - -function MainActions () -{ - const { data } = Route.useLoaderData(); - const { source, id } = Route.useParams(); - const installMut = useMutation(installMutation(source, id)); - const playMut = useMutation({ - ...playMutation, onError (error) - { - toast.error(error.message); - }, - }); - const ws = useRef<{ send: (data: string) => void; }>(undefined); - const [progress, setProgress] = useState(undefined); - const [status, setStatus] = useState(undefined); - const [error, setError] = useState(undefined); - const [details, setDetails] = useState(undefined); - const [commands, setCommands] = useState(undefined); - const [preferredCommand, setPreferredCommand] = useLocalStorage(`${data.source ?? data.id.source}-${data.source_id ?? data.id.id}-preferred-command`, undefined); - const queryClient = useQueryClient(); - const validCommands = commands ? commands.filter(c => c.valid) : []; - const validDefaultCommand = commands?.find(c => - { - if (!c.valid) return false; - if (preferredCommand && c.id !== preferredCommand) return false; - return true; - }); - - useEffect(() => - { - const sub = rommApi.api.romm.status({ source: data.id.source })({ id: data.id.id }).subscribe(); - ws.current = sub.ws; - - sub.subscribe((e) => - { - setStatus(e.data.status); - setProgress((e.data as any).progress); - setDetails((e.data as any).details); - setCommands((e.data as any).commands); - - if (e.data.status === 'refresh') - { - queryClient.invalidateQueries({ queryKey: ['game', data.id] }); - Router.navigate({ to: '/game/$source/$id', params: { id, source }, replace: true }); - } else if (e.data.status === 'error') - { - const errorMessage = getErrorMessage(e.data.error); - if (!errorMessage) return; - toast.error(errorMessage); - setError(errorMessage); - } - }); - - return () => - { - sub.close(); - ws.current = undefined; - }; - }, [data.id]); - - let progressIcon: JSX.Element | undefined = undefined; - switch (status) - { - case 'download': - progressIcon = ; - break; - case 'queued': - progressIcon = ; - break; - case 'extract': - progressIcon = ; - break; - } - - 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(cmd.command); - Router.navigate({ to: '/embedded/$source/$id', params: { source, id }, search: Object.fromEntries(params.entries()), replace: true }); - } else - { - playMut.mutate({ source: data.id.source, id: data.id.id, command_id: cmd.id }); - Router.navigate({ to: '/launcher/$source/$id', params: { source, id }, replace: true }); - } - }; - - let mainButton: any | undefined = undefined; - if (status === 'installed') - { - mainButton =
    handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details} - key="primary" - type='primary' - id="mainAction" - > - - - - - {validCommands.length > 1 && - showAllCommands(true, 'allActionsBtn')}> - - }
    ; - } - else if (error) - { - mainButton = - { - if (status === 'missing-emulator') - { - Router.navigate({ to: '/settings/directories' }); - } - }} - id="mainAction"> - - ; - } - else - { - mainButton = - { - if (status === 'install') - { - installMut.mutate(); - } - }} - tooltip={details ?? status} - type='primary' - id="mainAction"> - {status === 'install' ? : } - ; - } - - const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', { - content: - { - const commands: DialogEntry = { - id: String(c.id), - content: c.label ?? "", - type: 'primary', - action (ctx) - { - setPreferredCommand(c.id); - handlePlay(c); - }, - }; - return commands; - })} />, - preferredChildFocusKey: String(preferredCommand) - }); - - const { dialog: installOptionsDialog, setOpen: showInstallOptions } = useContextDialog('install-options-dialog', { - content: - }); - - return
    - {mainButton} -
    - {showProgress && showInstallOptions(true, "progress")} key="progress" square tooltip={details} type="base" id="progress" > -
    -
    - {progressIcon} -
    - -
    -
    } - {installOptionsDialog} - {allCommandDialog} -
    ; -} - -function ActionButtons (data: {}) -{ - const [, setDetailsSection] = useDetailsSection(); - const { data: game } = Route.useLoaderData(); - const [hoverText, setHoverText] = useState(undefined); - const [hoverTextType, setHoverTextType] = useState('accent'); - const { ref, focusKey } = useFocusable({ focusKey: 'actions', onBlur: () => setHoverText(undefined) }); - const [open, setOpen] = useState(false); - const deleteMutation = useMutation({ - ...deleteGameMutation(game.id), - onSuccess: () => - { - location.reload(); - console.log("Deleted"); - }, - onError (error) - { - toast.error(getErrorMessage(error) ?? "Error While Deleting"); - } - }); - - const contextOptions: DialogEntry[] = []; - if (game.local) - { - contextOptions.push({ - id: 'delete', - action: () => - { - deleteMutation.mutate(); - }, - icon: , - content: "Delete", - type: 'error' - }); - } - - const handleTooltipSet = (e: HTMLElement) => - { - const dataTooltip = e.getAttribute('data-tooltip'); - setHoverText(dataTooltip ?? undefined); - setHoverTextType(e.getAttribute('data-tooltip_type') ?? 'accent'); - }; - - useFocusEventListener('focuschanged', (e) => - { - if (e.target instanceof HTMLElement) - { - handleTooltipSet(e.target); - } - - }, ref); - - const { isPointer } = useActiveControl(); - - const tooltipStyles = { - base: 'bg-base-100 text-base-content', - accent: 'bg-accent text-accent-content', - error: 'bg-error text-error-content' - }; - - return
    - - - - { - setDetailsSection("achievements"); - if (game.achievements?.entires[0]) - { - setFocus(game.achievements.entires[0].id); - } - - }} /> - setOpen(true)} type="base" id="settings" icon={} > - - - - - - {!!hoverText && !isPointer &&

    {hoverText}

    } -
    -
    ; -} - -function Detail (data: { icon: JSX.Element; children?: any | any[]; }) -{ - return ( -
    - {data.icon} - {data.children} -
    - ); -} - -function ActionButton (data: { - id: string, - icon?: JSX.Element, - children?: any | any[]; - className?: string; - type: "primary" | 'base' | "accent" | 'error'; - square?: boolean, - onFocus?: () => void; - tooltip?: string, - tooltip_type?: 'accent' | 'error'; - onAction?: () => void; - disabled?: boolean; -}) -{ - const { ref } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true }); - const styles = { - primary: "bg-primary text-primary-content focused:bg-base-content focused:text-base-300 focusable focusable-primary", - base: " text-base-content border-dashed border-base-content/20 border-2 focused:bg-base-content focused:text-base-300 focusable focusable-primary", - accent: "bg-accent text-accent-content focusable focusable-primary focusable:bg-base-content focusable:text-base-300", - error: "bg-error text-error-content focused:bg-error focused:text-error-content", - }; - return ( -
    - -
    - ); -} - -function Stats () -{ - const { data } = Route.useLoaderData(); const stats: StatEntry[] = []; - if (data.path_fs) - stats.push({ label: "Location", content: data.path_fs, icon: }); - if (data.companies) - stats.push({ label: "Companies", content: data.companies }); - if (data.genres) - stats.push({ label: 'Genres', content: data.genres }); - if (data.release_date) - stats.push({ label: "Release Date", content: data.release_date.toLocaleDateString(), icon: }); - if (data.emulators) - stats.push({ label: "Emulators", content: data.emulators.map(e => e.name) }); - return ; + if (data.game) + { + if (data.game.path_fs) + stats.push({ label: "Location", content: data.game.path_fs, icon: }); + if (data.game.companies) + stats.push({ label: "Companies", content: data.game.companies }); + if (data.game.genres) + stats.push({ label: 'Genres', content: data.game.genres }); + if (data.game.release_date) + stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: }); + if (data.game.emulators) + stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) }); + } + + return ; } -function Divider (data: { rootFocusKey: string; showShortcuts: boolean; }) +function Divider (data: { rootFocusKey: string; showShortcuts: boolean; game: FrontEndGameTypeDetailed | undefined; }) { const [details, setDetails] = useDetailsSection(); - const { data: game } = Route.useLoaderData(); const { ref, focusKey } = useFocusable({ focusKey: "details-divider", onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'nearest', behavior: 'smooth' })(focusKey, ref.current, d), @@ -625,7 +200,7 @@ function Divider (data: { rootFocusKey: string; showShortcuts: boolean; }) stats: { label: "Stats", selected: details === 'stats', icon: }, screenshots: { label: "Screenshots", selected: details === 'screenshots', icon: }, }; - if (game.achievements) + if (data.game?.achievements) { detailFilter.achievements = { label: "Achievements", selected: details === 'achievements', icon: }; } @@ -637,18 +212,18 @@ function Divider (data: { rootFocusKey: string; showShortcuts: boolean; })
    ; } -export default function GameDetailsUI () +function RouteComponent () { const [recommendedGamesVisible, setRecommendedGamesVisible] = useState(false); - const { data } = Route.useLoaderData(); + const { source, id } = Route.useParams(); + const { data } = useQuery(gameQuery(source, id)); const { focus } = Route.useSearch(); const [, setUpdate] = useState(0); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" }); const headerRef = useRef(null); const sentinelRef = useRef(null); - const backgroundImage = data.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined; - const mainAreaRef = useRef(null); - const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data.id.source, data.id.id), enabled: recommendedGamesVisible }); + const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_cover}`) : undefined; + const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible }); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); const { shortcuts } = useShortcutContext(); @@ -666,7 +241,7 @@ export default function GameDetailsUI () }, []); useStickyDataAttr(headerRef, sentinelRef, ref); - const recommendedEmulators = data.emulators?.filter(e => e.store_exists); + const recommendedEmulators = data?.emulators?.filter(e => e.validSource); const { ref: intersct } = useIntersectionObserver({ onChange: (isIntersecting, entry) => @@ -686,13 +261,14 @@ export default function GameDetailsUI ()
    -
    -
    +
    +
    - -
    + +
    +
    {!!recommendedEmulators && recommendedEmulators.length > 0 &&

    Related Emulators @@ -703,6 +279,7 @@ export default function GameDetailsUI () Router.navigate({ to: '/store/details/emulator/$id', params: { id } }); }} emulators={recommendedEmulators} />} +

    diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 9f2bf7b..6ac40a5 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -1,4 +1,4 @@ -import { JSX, Suspense, useContext, useEffect, useState } from "react"; +import { JSX, Suspense, useContext, useState } from "react"; import { Gamepad2, @@ -14,7 +14,6 @@ import import { createFileRoute, - useNavigate, } from "@tanstack/react-router"; import { useMutation } from "@tanstack/react-query"; import @@ -25,7 +24,7 @@ import } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; import { useEventListener } from "usehooks-ts"; -import { HeaderAccounts, HeaderStatusBar } from "../components/Header"; +import { HeaderAccounts, HeaderButton, HeaderStatusBar } from "../components/Header"; import { FilterUI } from "../components/Filters"; import { AnimatedBackground } from "../components/AnimatedBackground"; import { GameList } from "../components/GameList"; @@ -43,7 +42,6 @@ import CollectionList from "../components/CollectionList"; import { zodValidator } from '@tanstack/zod-adapter'; import { mobileCheck, useDragScroll } from "../scripts/utils"; import { AnimatedBackgroundContext } from "../scripts/contexts"; -import { FrontEndId } from "@/shared/constants"; import Carousel from "../components/Carousel"; import { closeMutation } from "@queries/system"; @@ -301,10 +299,14 @@ export default function ConsoleHomeUI () const setFilter = (filter: string) => Router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true }); const { shortcuts } = useShortcutContext(); - const headerButtons = []; + const headerButtons: HeaderButton[] = []; if (mobileCheck()) headerButtons.push({ id: "fullscreen", icon: , action: handleFullscreen }); - headerButtons.push({ id: "search", icon: }, { id: "power-button", icon: , external: true, action: () => close.mutate() }); + headerButtons.push( + { id: "search-header-button", icon: }, + { id: "power-button", icon: , external: true, action: () => close.mutate() }, + { id: "settings-header-button", icon: , external: true, action: () => Router.navigate({ to: "/settings/accounts" }) } + ); return ( diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index b9bb2a5..89c4a65 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -1,6 +1,5 @@ import { AnimatedBackground } from '@/mainview/components/AnimatedBackground'; import { createFileRoute } from '@tanstack/react-router'; -import { GameInstallProgress, RPC_URL } from '@/shared/constants'; import DotsLoading from '../components/backgrounds/dots'; import { Router } from '..'; import { useEffect } from 'react'; @@ -9,6 +8,7 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/ import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import Shortcuts from '../components/Shortcuts'; import { gameQuery } from '@queries/romm'; +import { rommApi } from '../scripts/clientApi'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, @@ -30,30 +30,22 @@ function RouteComponent () useEffect(() => { - const es = new EventSource(`${RPC_URL(__HOST__)}/api/romm/status/${source}/${id}`); + if (!data) return; + const sub = rommApi.api.romm.status({ source: data.id.source })({ id: data.id.id }).subscribe(); - es.onmessage = ({ data }) => + sub.subscribe((e) => { - const stats = JSON.parse(data) as GameInstallProgress; - if (stats.status !== 'playing') + if (e.data.status !== 'playing') { HandleGoBack(); } - }; - - es.addEventListener('refresh', () => - { - HandleGoBack(); }); - es.onerror = () => + return () => { - HandleGoBack(); + sub.close(); }; - - return () => es.close(); - }, []); - + }, [data?.id]); return
    diff --git a/src/mainview/routes/settings/directories.tsx b/src/mainview/routes/settings/directories.tsx index a755625..5a32eec 100644 --- a/src/mainview/routes/settings/directories.tsx +++ b/src/mainview/routes/settings/directories.tsx @@ -2,7 +2,6 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-naviga import { Block, createFileRoute } from '@tanstack/react-router'; import DownloadDirectoryOption from '@/mainview/components/options/DownloadDirectoryOption'; import { useIsMutating, useMutation, useQuery } from '@tanstack/react-query'; -import { DownloadsDrive } from '@/shared/constants'; import prettyBytes from 'pretty-bytes'; import classNames from 'classnames'; import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts'; @@ -13,6 +12,7 @@ import { Button } from '@/mainview/components/options/Button'; import { systemApi } from '@/mainview/scripts/clientApi'; import useActiveControl from '@/mainview/scripts/gamepads'; import { changeDownloadsMutation } from '@queries/settings'; +import { downloadDrivesQuery } from '@/mainview/scripts/queries/system'; export const Route = createFileRoute('/settings/directories')({ component: RouteComponent, @@ -79,8 +79,8 @@ function RouteComponent () preferredChildFocusKey: focus }); - const isMoving = useIsMutating(queries.settings.changeDownloadsMutation); - const { data: drives, refetch } = useQuery({ ...queries.system.downloadDrivesQuery, refetchInterval: isMoving > 0 ? 1000 : undefined }); + const isMoving = useIsMutating(changeDownloadsMutation); + const { data: drives, refetch } = useQuery({ ...downloadDrivesQuery, refetchInterval: isMoving > 0 ? 1000 : undefined }); return isMoving > 0} withResolver={false} /> diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index 44e4e76..3ed6ead 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'; import { OptionSpace } from '../../components/options/OptionSpace'; import { OptionInput } from '../../components/options/OptionInput'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Button } from '../../components/options/Button'; import { Check, ChevronDown, FolderSearch, SearchAlert, Trash, TriangleAlert } from 'lucide-react'; import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog'; @@ -15,6 +15,10 @@ import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; import FilePicker from '@/mainview/components/FilePicker'; import { dirname } from 'pathe'; import { autoEmulatorsQuery, customEmulatorAddMutation, customEmulatorDeleteMutation, customEmulatorRemoveValueQuery, customEmulatorsQuery, setCustomEmulatorMutation } from '@queries/settings'; +import Carousel from '@/mainview/components/Carousel'; +import { FOCUS_KEYS } from '@/mainview/scripts/types'; +import { scrollIntoNearestParent, scrollIntoViewHandler, useDragScroll } from '@/mainview/scripts/utils'; +import { SettingsOption } from '@/mainview/components/options/SettingsOption'; export const Route = createFileRoute('/settings/emulators')({ component: RouteComponent, @@ -99,6 +103,7 @@ function EmulatorPath (data: { id: string; }) const [dirty, setDirty] = useState(false); const [localValue, setLocalValue] = useState(); const { data: remoteValue } = useQuery(customEmulatorRemoveValueQuery(data.id)); + useEffect(() => { setLocalValue(remoteValue); }, [remoteValue]); const setSettingMutation = useMutation(setCustomEmulatorMutation(data.id, (v) => { setLocalValue(v); @@ -128,7 +133,7 @@ function EmulatorPath (data: { id: string; }) }; return ( - <>

    {data.id}

    {emulators[data.id]} @@ -140,7 +145,6 @@ function EmulatorPath (data: { id: string; }) type="text" onBlur={handleSave} autocomplete="off" - defaultValue={remoteValue} onChange={(v) => { setLocalValue(v); @@ -187,22 +191,22 @@ function EmulatorBadge (data: { isCritical: boolean; pathCover?: string; addOverride: (emulator: string) => void; -}) +} & FocusParams) { const { focusKey, ref, focused } = useFocusable({ - focusKey: `badge-${data.emulator}`, onFocus: () => - { - (ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - } + focusKey: FOCUS_KEYS.EMULATOR_CARD(data.emulator), + onFocus (l, p, details) { data.onFocus?.(focusKey, ref.current, details); } }); useShortcuts(focusKey, () => [{ - label: 'Add Override', button: GamePadButtonCode.A, action: () => + label: 'Add Override', + button: GamePadButtonCode.A, + action: () => data.addOverride(data.emulator) }], [data.addOverride]); - return
    -
    +
    ; } -function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) => void; }) +function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) => void; } & FocusParams) { - const { data: autoEmulators } = useQuery(autoEmulatorsQuery); - const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators && autoEmulators.length > 0 }); - return
    + const { data: autoEmulators } = useQuery({ + ...autoEmulatorsQuery, + select (data) + { + return data.toSorted((a, b) => + { + const sourceCompare = (b.validSource ? 1 : 0) - (a.validSource ? 1 : 0); + if (sourceCompare !== 0) + { + return sourceCompare; + } else + { + return b.name.localeCompare(b.name); + } + }); + } + }); + const { ref, focusKey } = useFocusable({ + focusKey: `emulator-badges`, + focusable: !!autoEmulators && autoEmulators.length > 0, + onFocus (l, p, details) { data.onFocus?.(focusKey, ref.current, details); } + }); + useDragScroll(ref); + return + - {autoEmulators?.map(e => )} + {autoEmulators?.map(e => scrollIntoNearestParent(n)} key={e.name} isCritical={e.isCritical} addOverride={data.addOverride} pathCover={e.logo} path={e.validSource?.binPath} exists={!!e.validSource} emulator={e.name} />)} + -
    ; + ; } function RouteComponent () @@ -242,11 +269,19 @@ function RouteComponent () const { data: customEmulators } = useQuery(customEmulatorsQuery); - const addOverrideMutation = useMutation(customEmulatorAddMutation); + const addOverrideMutation = useMutation({ + ...customEmulatorAddMutation, async onSuccess (data, variables, onMutateResult, context) + { + await context.client.invalidateQueries({ queryKey: ['custom-emulators'] }); + setFocus(FOCUS_KEYS.EMULATOR_CUSTOM_PATH(variables)); + }, + }); return
      - + +
      Preferences
      +
      Overrides
      {!!customEmulators && customEmulators.map((key) => )} diff --git a/src/mainview/routes/settings/plugins.tsx b/src/mainview/routes/settings/plugins.tsx new file mode 100644 index 0000000..bccca6a --- /dev/null +++ b/src/mainview/routes/settings/plugins.tsx @@ -0,0 +1,55 @@ +import { Button } from '@/mainview/components/options/Button'; +import { OptionInput } from '@/mainview/components/options/OptionInput'; +import { OptionSpace } from '@/mainview/components/options/OptionSpace'; +import { enablePluginMutation, getAllPluginsQuery } from '@/mainview/scripts/queries/plugins'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; +import { Puzzle, Search } from 'lucide-react'; + +export const Route = createFileRoute('/settings/plugins')({ + component: RouteComponent, + loader (ctx) + { + ctx.context.queryClient.prefetchQuery(getAllPluginsQuery); + }, +}); + +function Plugin (data: { + plugin: FrontendPlugin, + setEnabled: (enabled: boolean) => void; +}) +{ + return +
      + {data.plugin.icon ? : } +
      +
      +
      {data.plugin.displayName}
      +
      {data.plugin.name} ({data.plugin.version})
      +
      +
    } className='flex p-4 bg-base-200 rounded-3xl'> + + + ; +} + +function RouteComponent () +{ + const { data: plugins, refetch: refetchPlugins } = useQuery(getAllPluginsQuery); + const pluginMutation = useMutation({ + ...enablePluginMutation, onSuccess (data, variables, onMutateResult, context) + { + refetchPlugins(); + }, + }); + + return <> + {!!plugins && Object.entries(Object.groupBy(plugins, p => p.source)).map(([source, plugins]) => + { + return <> +
    {source === 'builtin' ? "Built In" : "Store"}
    + {plugins.map(p => pluginMutation.mutate({ id: p.name, enabled: v })} />)} + ; + })} + ; +} diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index 2fe2467..c6e8198 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -19,6 +19,7 @@ import Info, Joystick, MonitorCog, + Puzzle, } from "lucide-react"; import { JSX, useEffect } from "react"; import { twMerge } from "tailwind-merge"; @@ -141,6 +142,12 @@ function SettingsMenu (data: {}) label="Directories" icon={} /> + } + /> { if (data.homepage) systemApi.api.system.open.post({ url: data.homepage }); @@ -58,10 +59,44 @@ function TitleArea (data: { { const queryClient = useQueryClient(); const deleteMutation = useMutation({ - ...storeEmulatorDeleteMutation, onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(variables)), + ...storeEmulatorDeleteMutation, + onSuccess (data, variables, onMutateResult, context) + { + context.client.refetchQueries(storeEmulatorDetailsQuery(variables)); + }, + }); + const downloadBios = useMutation(downloadBiosMutation(data.emulator?.name ?? '')); + const deleteBios = useMutation({ + ...deleteBiosMutation, + onSuccess (data, variables, onMutateResult, context) + { + context.client.refetchQueries(storeEmulatorDetailsQuery(variables)); + toast.success("BIOS Deleted", { icon: }); + }, }); const installProgressRef = useRef(null); - const { data: installJob, status: installStatus } = useJobStatus('download-emulator', { + const { data: biosInstallJob, state: biosDownloadState } = useJobStatus('bios-download-job', { + query: { id: data.emulator?.name }, + onError (error) + { + console.log(error); + toast.error(getErrorMessage(error) ?? "Error During Bios Download"); + }, + onProgress (process) + { + if (installProgressRef.current) + installProgressRef.current.value = process; + }, + onCompleted (data) + { + toast.success("BIOS Downloaded", { icon: }); + }, + onEnded (data) + { + queryClient.refetchQueries(storeEmulatorDetailsQuery(data.emulator)); + }, + }); + const { data: installJob, state: installState } = useJobStatus('download-emulator', { onError (error) { console.log(error); @@ -80,12 +115,13 @@ function TitleArea (data: { }, }); - const isInstalling = !!installJob; + const isInstalling = !!installJob || !!biosInstallJob; const options: DialogEntry[] = []; + const installedFromStore = !!data.emulator?.sources.find(s => s.type === 'store' && s.exists); if (data.emulator) { - if (!isInstalling && !data.emulator?.validSource) + if (!isInstalling && !installedFromStore) { options.push(...data.emulator.downloads.map(d => { @@ -101,7 +137,7 @@ function TitleArea (data: { }; return entry; })); - } else if (data.emulator.sources.find(s => s.type === 'store' && s.exists)) + } else if (installedFromStore) { options.push({ content: "Delete", @@ -114,12 +150,43 @@ function TitleArea (data: { }, id: "delete" }); + + if (!data.emulator.bios || data.emulator.bios.length <= 0) + { + options.push({ + content: "Download BIOS", + type: "primary", + icon: , + action (ctx) + { + downloadBios.mutate(); + ctx.close(); + }, + id: "download-bios" + }); + } else + { + options.push({ + content: "Delete BIOS", + type: "error", + icon: , + action (ctx) + { + if (!data.emulator) return; + deleteBios.mutate(data.emulator.name); + ctx.close(); + }, + id: "download-bios" + }); + } + } } - const { ref, focusKey } = useFocusable({ + const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: 'title-area', preferredChildFocusKey: "install-btn", + trackChildren: true, onFocus: () => { (ref.current as HTMLElement).scrollIntoView({ behavior: "smooth", block: 'end' }); } }); @@ -131,7 +198,16 @@ function TitleArea (data: { } else if (isInstalling) { - installButtonContent = <>{installStatus}; + const status: any = { + bios: { + download: "Downloading BIOS" + }, + install: { + download: "Downloading", + extract: "Extracting" + } + }; + installButtonContent = <>{installState ? status.install[installState] : biosDownloadState ? status.bios[biosDownloadState] : undefined}; } else if (data.emulator.validSource) { installButtonContent = <> Options; @@ -155,25 +231,37 @@ function TitleArea (data: { return
    - {data.emulator ? :
    } + {data.emulator ? :
    }
    -

    {data.emulator?.name ??
    }

    +

    {data.emulator?.name ??
    }

    {data.emulator?.systems.map(({ id, name, icon }) => { return
    {!!icon && } -

    {name}

    +

    {name}

    ; }) ?? <>
    }
    +
    + {!!data.emulator?.bios?.[0] &&
    +
    +
    } + {data.emulator && !!data.emulator.integration && data.emulator.validSource?.type === 'store' &&
    +
    +
    }
    -
    - +
    } +

    diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index 6c914c0..5a3b995 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -6,11 +6,11 @@ import { getErrorMessage } from "react-error-boundary"; import toast from "react-hot-toast"; import { useLocalStorage } from "usehooks-ts"; import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; -import { Clock, Download, EllipsisVertical, PackageOpen, Play, TriangleAlert } from "lucide-react"; +import { Clock, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react"; import { installMutation, playMutation } from "@/mainview/scripts/queries/romm"; import ActionButton from "./ActionButton"; -export default function MainActions (data: { game: FrontEndGameTypeDetailed, source: string, id: string; }) +export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; }) { const installMut = useMutation(installMutation(data.source, data.id)); const playMut = useMutation({ @@ -29,7 +29,7 @@ export default function MainActions (data: { game: FrontEndGameTypeDetailed, sou const [error, setError] = useState(undefined); const [details, setDetails] = useState(undefined); const [commands, setCommands] = useState(undefined); - const [preferredCommand, setPreferredCommand] = useLocalStorage(`${data.game.source ?? data.game.id.source}-${data.game.source_id ?? data.game.id.id}-preferred-command`, undefined); + const [preferredCommand, setPreferredCommand] = useLocalStorage(`${data.game?.source ?? data.game?.id.source}-${data.game?.source_id ?? data.game?.id.id}-preferred-command`, undefined); const queryClient = useQueryClient(); const validCommands = commands ? commands.filter(c => c.valid) : []; const validDefaultCommand = commands?.find(c => @@ -41,7 +41,7 @@ export default function MainActions (data: { game: FrontEndGameTypeDetailed, sou useEffect(() => { - const sub = rommApi.api.romm.status({ source: data.game.id.source })({ id: data.game.id.id }).subscribe(); + const sub = rommApi.api.romm.status({ source: data.source })({ id: data.id }).subscribe(); ws.current = sub.ws; sub.subscribe((e) => @@ -69,7 +69,7 @@ export default function MainActions (data: { game: FrontEndGameTypeDetailed, sou sub.close(); ws.current = undefined; }; - }, [data.game.id]); + }, [data.source, data.id]); let progressIcon: JSX.Element | undefined = undefined; switch (status) @@ -101,7 +101,7 @@ export default function MainActions (data: { game: FrontEndGameTypeDetailed, sou Router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()), replace: true }); } else { - playMut.mutate({ source: data.game.id.source, id: data.game.id.id, command_id: cmd.id }); + playMut.mutate({ source: data.source, id: data.id, command_id: cmd.id }); } }; @@ -142,20 +142,31 @@ export default function MainActions (data: { game: FrontEndGameTypeDetailed, sou } else { + let icon = ; + if (status === 'install') + { + icon = ; + } else if (status === 'present') + { + icon = ; + } mainButton = { - if (status === 'install') + switch (status) { - installMut.mutate(); + case 'present': + case 'install': + installMut.mutate(); + break; } }} tooltip={details ?? status} type='primary' id="mainAction"> - {status === 'install' ? : } + {icon} ; } diff --git a/src/mainview/components/options/OptionDropdown.tsx b/src/mainview/components/options/OptionDropdown.tsx index 071b9f6..083b6c4 100644 --- a/src/mainview/components/options/OptionDropdown.tsx +++ b/src/mainview/components/options/OptionDropdown.tsx @@ -44,6 +44,7 @@ export function OptionDropdown (data: { content: v, id: String(i), type: 'primary', + selected: data.value === v, action: () => { data.onChange?.(v); diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index 78fd7d4..ccf81cb 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -45,7 +45,7 @@ export function StoreEmulatorCard (data: { ref={ref} role="button" tabIndex={0} - data-installed={!!data.emulator.validSource} + data-installed={data.emulator.validSources.some(s => s.exists)} onClick={isTouch ? handleSelect : undefined} className={twMerge("relative focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none", data.className)} > @@ -62,10 +62,10 @@ export function StoreEmulatorCard (data: {

    {data.emulator.name}

      - {data.emulator.systems.map(({ id, name, icon }) => + {data.emulator.systems.map(({ id, name, iconUrl }) => { return
      - {!!icon && } + {!!iconUrl && }

      {name}

      ; })} @@ -75,17 +75,19 @@ export function StoreEmulatorCard (data: {
    - {!!data.emulator.integration && data.emulator.validSource?.type === 'store' &&
    + {!!data.emulator.integration && data.emulator.validSources.some(s => s.type === 'store') &&
    } - {!!data.emulator.validSource &&
    -
    - {emulatorStatusIcons[data.emulator.validSource?.type ?? '']} -
    -
    } + {data.emulator.validSources.slice(0, 3).map(s => + { + return
    +
    + {emulatorStatusIcons[s.type]} +
    +
    ; + })} {isMouse && <> - }
    diff --git a/src/mainview/gen/routeTree.gen.ts b/src/mainview/gen/routeTree.gen.ts index d730600..5988dfd 100644 --- a/src/mainview/gen/routeTree.gen.ts +++ b/src/mainview/gen/routeTree.gen.ts @@ -18,7 +18,6 @@ import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emul import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/directories' import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts' import { Route as SettingsAboutRouteImport } from './../routes/settings/about' -import { Route as CollectionIdRouteImport } from './../routes/collection.$id' import { Route as StoreTabRouteRouteImport } from './../routes/store/tab/route' import { Route as StoreTabIndexRouteImport } from './../routes/store/tab/index' import { Route as StoreTabGamesRouteImport } from './../routes/store/tab/games' @@ -27,6 +26,7 @@ import { Route as PlatformSourceIdRouteImport } from './../routes/platform.$sour import { Route as LauncherSourceIdRouteImport } from './../routes/launcher.$source.$id' import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id' import { Route as EmbeddedSourceIdRouteImport } from './../routes/embedded.$source.$id' +import { Route as CollectionSourceIdRouteImport } from './../routes/collection.$source.$id' import { Route as StoreDetailsEmulatorIdRouteImport } from './../routes/store/details.emulator.$id' const GamesRoute = GamesRouteImport.update({ @@ -74,11 +74,6 @@ const SettingsAboutRoute = SettingsAboutRouteImport.update({ path: '/about', getParentRoute: () => SettingsRouteRoute, } as any) -const CollectionIdRoute = CollectionIdRouteImport.update({ - id: '/collection/$id', - path: '/collection/$id', - getParentRoute: () => rootRouteImport, -} as any) const StoreTabRouteRoute = StoreTabRouteRouteImport.update({ id: '/store/tab', path: '/store/tab', @@ -119,6 +114,11 @@ const EmbeddedSourceIdRoute = EmbeddedSourceIdRouteImport.update({ path: '/embedded/$source/$id', getParentRoute: () => rootRouteImport, } as any) +const CollectionSourceIdRoute = CollectionSourceIdRouteImport.update({ + id: '/collection/$source/$id', + path: '/collection/$source/$id', + getParentRoute: () => rootRouteImport, +} as any) const StoreDetailsEmulatorIdRoute = StoreDetailsEmulatorIdRouteImport.update({ id: '/store/details/emulator/$id', path: '/store/details/emulator/$id', @@ -130,13 +130,13 @@ export interface FileRoutesByFullPath { '/settings': typeof SettingsRouteRouteWithChildren '/games': typeof GamesRoute '/store/tab': typeof StoreTabRouteRouteWithChildren - '/collection/$id': typeof CollectionIdRoute '/settings/about': typeof SettingsAboutRoute '/settings/accounts': typeof SettingsAccountsRoute '/settings/directories': typeof SettingsDirectoriesRoute '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute '/settings/plugins': typeof SettingsPluginsRoute + '/collection/$source/$id': typeof CollectionSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute @@ -150,13 +150,13 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/settings': typeof SettingsRouteRouteWithChildren '/games': typeof GamesRoute - '/collection/$id': typeof CollectionIdRoute '/settings/about': typeof SettingsAboutRoute '/settings/accounts': typeof SettingsAccountsRoute '/settings/directories': typeof SettingsDirectoriesRoute '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute '/settings/plugins': typeof SettingsPluginsRoute + '/collection/$source/$id': typeof CollectionSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute @@ -172,13 +172,13 @@ export interface FileRoutesById { '/settings': typeof SettingsRouteRouteWithChildren '/games': typeof GamesRoute '/store/tab': typeof StoreTabRouteRouteWithChildren - '/collection/$id': typeof CollectionIdRoute '/settings/about': typeof SettingsAboutRoute '/settings/accounts': typeof SettingsAccountsRoute '/settings/directories': typeof SettingsDirectoriesRoute '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute '/settings/plugins': typeof SettingsPluginsRoute + '/collection/$source/$id': typeof CollectionSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute @@ -195,13 +195,13 @@ export interface FileRouteTypes { | '/settings' | '/games' | '/store/tab' - | '/collection/$id' | '/settings/about' | '/settings/accounts' | '/settings/directories' | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/collection/$source/$id' | '/embedded/$source/$id' | '/game/$source/$id' | '/launcher/$source/$id' @@ -215,13 +215,13 @@ export interface FileRouteTypes { | '/' | '/settings' | '/games' - | '/collection/$id' | '/settings/about' | '/settings/accounts' | '/settings/directories' | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/collection/$source/$id' | '/embedded/$source/$id' | '/game/$source/$id' | '/launcher/$source/$id' @@ -236,13 +236,13 @@ export interface FileRouteTypes { | '/settings' | '/games' | '/store/tab' - | '/collection/$id' | '/settings/about' | '/settings/accounts' | '/settings/directories' | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/collection/$source/$id' | '/embedded/$source/$id' | '/game/$source/$id' | '/launcher/$source/$id' @@ -258,7 +258,7 @@ export interface RootRouteChildren { SettingsRouteRoute: typeof SettingsRouteRouteWithChildren GamesRoute: typeof GamesRoute StoreTabRouteRoute: typeof StoreTabRouteRouteWithChildren - CollectionIdRoute: typeof CollectionIdRoute + CollectionSourceIdRoute: typeof CollectionSourceIdRoute EmbeddedSourceIdRoute: typeof EmbeddedSourceIdRoute GameSourceIdRoute: typeof GameSourceIdRoute LauncherSourceIdRoute: typeof LauncherSourceIdRoute @@ -331,13 +331,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsAboutRouteImport parentRoute: typeof SettingsRouteRoute } - '/collection/$id': { - id: '/collection/$id' - path: '/collection/$id' - fullPath: '/collection/$id' - preLoaderRoute: typeof CollectionIdRouteImport - parentRoute: typeof rootRouteImport - } '/store/tab': { id: '/store/tab' path: '/store/tab' @@ -394,6 +387,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof EmbeddedSourceIdRouteImport parentRoute: typeof rootRouteImport } + '/collection/$source/$id': { + id: '/collection/$source/$id' + path: '/collection/$source/$id' + fullPath: '/collection/$source/$id' + preLoaderRoute: typeof CollectionSourceIdRouteImport + parentRoute: typeof rootRouteImport + } '/store/details/emulator/$id': { id: '/store/details/emulator/$id' path: '/store/details/emulator/$id' @@ -447,7 +447,7 @@ const rootRouteChildren: RootRouteChildren = { SettingsRouteRoute: SettingsRouteRouteWithChildren, GamesRoute: GamesRoute, StoreTabRouteRoute: StoreTabRouteRouteWithChildren, - CollectionIdRoute: CollectionIdRoute, + CollectionSourceIdRoute: CollectionSourceIdRoute, EmbeddedSourceIdRoute: EmbeddedSourceIdRoute, GameSourceIdRoute: GameSourceIdRoute, LauncherSourceIdRoute: LauncherSourceIdRoute, diff --git a/src/mainview/gen/static-icon-assets.gen.ts b/src/mainview/gen/static-icon-assets.gen.ts index cb3fe1b..1d1a4aa 100644 --- a/src/mainview/gen/static-icon-assets.gen.ts +++ b/src/mainview/gen/static-icon-assets.gen.ts @@ -464,7 +464,7 @@ const assets = new Set([ ]); // Store basePath resolved from Vite config -const BASE_PATH = "./"; +const BASE_PATH = "/"; /** diff --git a/src/mainview/index.css b/src/mainview/index.css index 3bad22c..eb09eb3 100644 --- a/src/mainview/index.css +++ b/src/mainview/index.css @@ -365,8 +365,10 @@ body { .bg-noise { position: absolute; - width: 100%; - height: 100%; + top: 0; + left: 0; + right: 0; + bottom: 0; z-index: -1; background-image: url("https://momentsingraphics.de/Media/BlueNoise/BlueNoise470.png"); mix-blend-mode: color-dodge; @@ -375,8 +377,10 @@ body { .bg-dots { position: absolute; - width: 100%; - height: 100%; + top: 0; + left: 0; + right: 0; + bottom: 0; z-index: -1; background-image: radial-gradient(var(--color-neutral) 0.1rem, transparent 0.1rem); background-size: 2rem 2rem; @@ -421,11 +425,11 @@ body { html:active-view-transition-type(slide-up) { &::view-transition-old(root) { - animation: fade-out 300ms ease-in forwards; + animation: fade-out 200ms ease-in forwards; } &::view-transition-new(root) { - animation: slide-up 300ms ease-in-out forwards; + animation: slide-up 200ms ease-out forwards; } } diff --git a/src/mainview/index.html b/src/mainview/index.html index 43c30a6..a4bb3db 100644 --- a/src/mainview/index.html +++ b/src/mainview/index.html @@ -18,7 +18,9 @@ GameFlow + +
    diff --git a/src/mainview/index.tsx b/src/mainview/index.tsx index 1256fd2..c76e8e9 100644 --- a/src/mainview/index.tsx +++ b/src/mainview/index.tsx @@ -45,7 +45,7 @@ export const Router = createRouter({ history: hashHistory, defaultPreload: "intent", context: { queryClient }, - scrollRestoration: true, + scrollRestoration: false, defaultNotFoundComponent: NotFound, defaultPendingMs: 300, defaultErrorComponent: Error, @@ -67,6 +67,7 @@ export const Router = createRouter({ }); const focusMap = new Map(); +export const focusQueue: string[] = []; Router.history.subscribe((op) => { @@ -77,7 +78,8 @@ Router.history.subscribe((op) => { if (focusMap.has(op.location.state.__TSR_index)) { - setFocus(focusMap.get(op.location.state.__TSR_index)!); + focusQueue.pop(); + focusQueue.push(focusMap.get(op.location.state.__TSR_index)!); focusMap.delete(op.location.state.__TSR_index); } } diff --git a/src/mainview/preload.tsx b/src/mainview/preload.tsx new file mode 100644 index 0000000..c95a05b --- /dev/null +++ b/src/mainview/preload.tsx @@ -0,0 +1,21 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; + +const rootElement = document.getElementById("preload")!; + +if (!rootElement.innerHTML) +{ + const root = createRoot(rootElement); + root.render( + +
    + +
    +
    +
    + Loading Gameflow +
    +
    , + ); +} diff --git a/src/mainview/routes/__root.tsx b/src/mainview/routes/__root.tsx index 243d778..bdee113 100644 --- a/src/mainview/routes/__root.tsx +++ b/src/mainview/routes/__root.tsx @@ -4,7 +4,10 @@ import Notifications from "../components/Notifications"; import { Toaster } from "react-hot-toast"; import { mobileCheck, useLocalSetting } from "../scripts/utils"; import useActiveControl from "../scripts/gamepads"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import { SystemInfoContext } from "../scripts/contexts"; +import { SystemInfoType } from "@/shared/constants"; +import { systemApi } from "../scripts/clientApi"; export const Route = createRootRouteWithContext()({ component: RootComponent, @@ -31,11 +34,25 @@ function RootComponent () }, [theme]); + const [systemInfo, setSystemInfo] = useState(); + useEffect(() => + { + const sub = systemApi.api.system.info.system.subscribe(); + sub.subscribe(({ data }) => + { + setSystemInfo(data); + }); + + document.documentElement.dataset.loaded = "true"; + }, []); + return (
    - + + + - + {/*import.meta.env.DEV && !isMobile && <> diff --git a/src/mainview/routes/collection.$id.tsx b/src/mainview/routes/collection.$id.tsx deleted file mode 100644 index 6df7530..0000000 --- a/src/mainview/routes/collection.$id.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { CollectionsDetail } from '../components/CollectionsDetail'; -import { getRomsApiRomsGetOptions } from '@clients/romm/@tanstack/react-query.gen'; -import { DefaultRommStaleTime } from '@shared/constants'; -import { useQuery } from '@tanstack/react-query'; -import { useContext } from 'react'; -import { AnimatedBackgroundContext } from '../scripts/contexts'; -import { getCollectionQuery } from '@queries/romm'; - -export const Route = createFileRoute('/collection/$id')({ - component: RouteComponent, - loader: ({ params, context }) => context.queryClient.fetchQuery({ - ...getRomsApiRomsGetOptions({ query: { collection_id: Number(params.id) } }), - staleTime: DefaultRommStaleTime, - }) -}); - -function RouteComponent () -{ - const { id } = Route.useParams(); - const { data: collection } = useQuery(getCollectionQuery(Number(id))); - const animatedBgContext = useContext(AnimatedBackgroundContext); - - return ( - {collection?.name}
    } filters={{ collection_id: Number(id) }} /> - ); -} diff --git a/src/mainview/routes/collection.$source.$id.tsx b/src/mainview/routes/collection.$source.$id.tsx new file mode 100644 index 0000000..2f62d91 --- /dev/null +++ b/src/mainview/routes/collection.$source.$id.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { CollectionsDetail } from '../components/CollectionsDetail'; +import { useQuery } from '@tanstack/react-query'; +import { useContext } from 'react'; +import { AnimatedBackgroundContext } from '../scripts/contexts'; +import { getCollectionQuery } from '@queries/romm'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; + +export const Route = createFileRoute('/collection/$source/$id')({ + component: RouteComponent, + validateSearch: zodValidator(z.object({ countHint: z.number().optional() })) +}); + +function RouteComponent () +{ + const { source, id } = Route.useParams(); + const { countHint } = Route.useSearch(); + const { data: collection } = useQuery(getCollectionQuery(source, id)); + const animatedBgContext = useContext(AnimatedBackgroundContext); + + return ( + {collection?.name}
    } filters={{ collection_id: Number(id), collection_source: source }} /> + ); +} diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 1969410..6f97fbc 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -22,6 +22,7 @@ import { GameDetailsContext } from "@/mainview/scripts/contexts"; import { gameQuery, gamesRecommendedBasedOnGameQuery } from "@queries/romm"; import { GamesSection } from "@/mainview/components/store/GamesSection"; import Details, { DetailElement } from "@/mainview/components/game/Details"; +import { AutoFocus } from "@/mainview/components/AutoFocus"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => @@ -29,7 +30,6 @@ export const Route = createFileRoute("/game/$source/$id")({ context.queryClient.prefetchQuery(gameQuery(params.source, params.id)); }, component: RouteComponent, - pendingComponent: GameDetailsUIPending, errorComponent: Error, validateSearch: zodValidator(z.object({ focus: z.string().optional() })) }); @@ -71,81 +71,6 @@ function Error (data: ErrorComponentProps) ; } -function MainDetailsPending () -{ - - const { ref } = useFocusable({ focusKey: "main-details" }); - - return
    -
    -
    -
    -
    -
    -
    - } > - } >
    - - } > - - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    ; -} - -function GameDetailsUIPending () -{ - const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" }); - - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); - const { shortcuts } = useShortcutContext(); - useEffect(() => - { - focusSelf(); - }, []); - - return -
    - -
    -
    - -
    -
    - -
    -
    -
    Screenshots
    -
    -
    - {Array.from({ length: 5 }).map((s, i) =>
    )} -
    -
    -
    - -
    -
    - -
    - ; -} - function MoreDetails (data: { game: FrontEndGameTypeDetailed | undefined; }) { const [details] = useDetailsSection(); @@ -219,7 +144,7 @@ function RouteComponent () const { data } = useQuery(gameQuery(source, id)); const { focus } = Route.useSearch(); const [, setUpdate] = useState(0); - const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" }); + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details", forceFocus: true }); const headerRef = useRef(null); const sentinelRef = useRef(null); const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_cover}`) : undefined; @@ -228,20 +153,8 @@ function RouteComponent () useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); const { shortcuts } = useShortcutContext(); - useEffect(() => - { - if (focus) - { - setFocus(focus, { instant: true }); - } else - { - focusSelf(); - } - - }, []); - useStickyDataAttr(headerRef, sentinelRef, ref); - const recommendedEmulators = data?.emulators?.filter(e => e.validSource); + const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists)); const { ref: intersct } = useIntersectionObserver({ onChange: (isIntersecting, entry) => @@ -252,6 +165,7 @@ function RouteComponent () return ( + setUpdate(v => v + 1) }} > diff --git a/src/mainview/routes/games.tsx b/src/mainview/routes/games.tsx index b9ae196..d1071fa 100644 --- a/src/mainview/routes/games.tsx +++ b/src/mainview/routes/games.tsx @@ -12,10 +12,5 @@ function RouteComponent () { const { focus } = Route.useSearch(); - return ( -
    - -
    - ); + return ; } \ No newline at end of file diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 6ac40a5..945b6d7 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -15,7 +15,7 @@ import { createFileRoute, } from "@tanstack/react-router"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { FocusContext, @@ -44,6 +44,7 @@ import { mobileCheck, useDragScroll } from "../scripts/utils"; import { AnimatedBackgroundContext } from "../scripts/contexts"; import Carousel from "../components/Carousel"; import { closeMutation } from "@queries/system"; +import { gameQuery } from "../scripts/queries/romm"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -101,6 +102,7 @@ function HomeList (data: { selectedFilter: string; }) { + const queryClient = useQueryClient(); const [initFocus, setInitFocus] = useState(false); const bg = useContext(AnimatedBackgroundContext); const { } = Route.useSearch; @@ -125,28 +127,20 @@ function HomeList (data: { Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); }; - const handleCollectionSelect = (id: string) => - { - Router.navigate({ to: `/collection/${id}` }); - }; - - const handlePlatformSelect = (source: string, id: string) => - { - Router.navigate({ to: `/platform/${source}/${id}` }); - }; - let activeList: JSX.Element; switch (data.selectedFilter) { case 'consoles': activeList = <> - - + }> + + + ; break; case 'collections': activeList = <> - + ; break; @@ -155,12 +149,17 @@ function HomeList (data: { + { + const [source, id] = l.split('@'); + queryClient.prefetchQuery(gameQuery(source, id)); + handleNodeFocus(l, n, d); + }} className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} - filters={{ limit: 12 }} + filters={{ limit: 12, orderBy: 'activity' }} finalElement={} /> @@ -201,7 +200,7 @@ function HomeList (data: { }}>
    }> - }> + }> {activeList} @@ -223,6 +222,7 @@ function MainMenu () ref={ref} save-child-focus="session" className="flex items-center gap-y-1 sm:portrait:bg-base-100 sm:portrait:p-2 sm:portrait:rounded-full sm:gap-1 md:gap-3" + style={{ viewTransitionName: "main-menu" }} >
    - {!!data.pathCover && } - {data.platformName} + {!!platform && } + {platform?.name}
    ; } @@ -22,14 +28,15 @@ function PlatformTitle (data: { pathCover: string | null, platformName?: string; function RouteComponent () { const { source, id } = Route.useParams(); - const { data: platform } = useQuery(platformQuery(source, id)); + const { countHint } = Route.useSearch(); return (
    - {!!platform && } - filters={{ platform_id: Number(id), platform_slug: platform.slug, platform_source: source }} - />} + } + filters={{ platform_id: Number(id), platform_source: source }} + />
    ); } diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index 65cf0a0..7c0faf5 100644 --- a/src/mainview/routes/settings/accounts.tsx +++ b/src/mainview/routes/settings/accounts.tsx @@ -7,7 +7,7 @@ import import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import classNames from "classnames"; -import { Key, Link, Lock, LogOut, Save, ScanQrCode, Trash, User, X } from "lucide-react"; +import { Key, Link, Lock, LogIn, LogOut, Save, ScanQrCode, Trash, User, X } from "lucide-react"; import { useEffect, @@ -24,7 +24,8 @@ import { useJobStatus } from "@/mainview/scripts/utils"; import { useInterval } from "usehooks-ts"; import { TwitchIcon } from "@/mainview/scripts/brandIcons"; import { twitchLoginMutation, twitchLoginVerificationQuery, twitchLogoutMutation } from "@queries/settings"; -import { rommGetOptionsQuery, rommHasPasswordQuery, rommHostnameQuery, rommLoginMutation, rommLogoutMutation, rommQrLoginMutation, rommUsernameQuery, rommUserQuery } from "@queries/romm"; +import { rommGetOptionsQuery, rommLoggedInQuery, rommHostnameQuery, rommLoginMutation, rommLogoutMutation, rommQrLoginMutation, rommUsernameQuery, rommUserQuery } from "@queries/romm"; +import { systemApi } from "@/mainview/scripts/clientApi"; export const Route = createFileRoute("/settings/accounts")({ component: RouteComponent, @@ -47,7 +48,10 @@ function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: {!!data.code &&

    Code: {data.code}

    } - +
    + + +
    ; } @@ -83,11 +87,12 @@ function TwitchLogin ()
    ; } -function LoginControls (data: { hasPassword: boolean; }) +function LoginControls (data: {}) { - const user = useQuery(rommUserQuery()); + const user = useQuery(rommUserQuery); const loginMutation = useMutation(rommQrLoginMutation); const { data: statusValue, wsRef } = useJobStatus('login-job'); + const { data: loginStatusData } = useQuery(rommLoggedInQuery); const context = useSettingsFormContext({}); const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0; const logoutMutation = useMutation({ @@ -107,15 +112,15 @@ function LoginControls (data: { hasPassword: boolean; }) } - {data.hasPassword && + {loginStatusData?.hasLogin && } + {open && ({ diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index 1f43246..2181b93 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -4,6 +4,7 @@ import { useOptionContext } from "./OptionSpace"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { systemApi } from "../../scripts/clientApi"; import { CheckIcon, X } from "lucide-react"; +import { oneShot } from "@/mainview/scripts/audio/audio"; export function OptionInput (data: { name: string; @@ -27,6 +28,7 @@ export function OptionInput (data: { { inputRef.current?.focus(); } + oneShot('click'); }; const { ref } = useFocusable({ focusKey: data.name, onEnterPress: handlePress @@ -79,12 +81,14 @@ export function OptionInput (data: { name={data.name} checked={Boolean(data.value)} type={data.type} + onClick={() => { oneShot("click"); }} autoComplete={data.autocomplete} onFocus={handleFocus} placeholder={data.placeholder} onChange={e => data.onChange?.(e.target.checked)} onBlur={data.onBlur} className={twMerge( + "active:bg-base-content rounded-full", data.className )} /> diff --git a/src/mainview/components/options/PathSettingsOption.tsx b/src/mainview/components/options/PathSettingsOption.tsx index 29ba634..395767e 100644 --- a/src/mainview/components/options/PathSettingsOption.tsx +++ b/src/mainview/components/options/PathSettingsOption.tsx @@ -80,7 +80,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & { const handleCloseSeatch = () => { setIsBrowsing(false); - setFocus(`${data.id}-browse`); + setFocus(`${data.id}-browse`, { instant: true }); }; const handleInputBlur = () => diff --git a/src/mainview/components/store/EmulatorsSection.tsx b/src/mainview/components/store/EmulatorsSection.tsx index a846406..93a0419 100644 --- a/src/mainview/components/store/EmulatorsSection.tsx +++ b/src/mainview/components/store/EmulatorsSection.tsx @@ -8,12 +8,12 @@ import { ChevronRight, Joystick } from "lucide-react"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import { scrollIntoNearestParent, useDragScroll } from "@/mainview/scripts/utils"; import FocusDots from "../FocusDots"; -import { Router } from "@/mainview"; import { StoreEmulatorCard } from "./StoreEmulatorCard"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import Carousel from "../Carousel"; +import { useRouter } from "@tanstack/react-router"; -function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant: boolean; }) => void; }) +function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant?: boolean; }) => void; }) { const { ref, focusKey } = useFocusable({ focusKey: data.id, @@ -39,6 +39,7 @@ export function EmulatorsSection (data: { header?: any; } & FocusParams) { + const router = useRouter(); const { ref, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.EMULATOR_SECTION(data.id), trackChildren: true, @@ -68,7 +69,7 @@ export function EmulatorsSection (data: { scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' }); }} /> )) ?? Array.from({ length: 8 }).map((_, i) =>
    )} - Router.navigate({ to: '/store/tab/emulators', viewTransition: { types: ['zoom-in'] } })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} /> + router.navigate({ to: '/store/tab/emulators', viewTransition: { types: ['zoom-in'] } })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} />
    diff --git a/src/mainview/components/store/GamesSection.tsx b/src/mainview/components/store/GamesSection.tsx index 54f4057..843e8e7 100644 --- a/src/mainview/components/store/GamesSection.tsx +++ b/src/mainview/components/store/GamesSection.tsx @@ -30,7 +30,7 @@ export function GamesSection (data: { useEffect(() => { if (focused) - focusSelf(); + focusSelf({ instant: true }); }, [!!data.games]); return ( diff --git a/src/mainview/components/store/MissingEmulatorsSection.tsx b/src/mainview/components/store/MissingEmulatorsSection.tsx index b064ec8..fc5efd8 100644 --- a/src/mainview/components/store/MissingEmulatorsSection.tsx +++ b/src/mainview/components/store/MissingEmulatorsSection.tsx @@ -9,6 +9,7 @@ import { ChevronRight, CircleQuestionMark, SearchAlert } from "lucide-react"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import { RPC_URL } from "@/shared/constants"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; +import { oneShot } from "@/mainview/scripts/audio/audio"; // ── Single missing-emulator card ─────────────────────────────────────────── interface MissingCardProps @@ -19,7 +20,11 @@ interface MissingCardProps function MissingCard ({ emulator: em, onSelect }: MissingCardProps) { - const handleSelect = () => onSelect?.(em.name, focusKey); + const handleSelect = () => + { + onSelect?.(em.name, focusKey); + oneShot('click'); + }; const { ref, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.MISSING_CARD(em.name), diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index ccf81cb..202c38c 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -9,6 +9,7 @@ import { BadgeCheck, ChevronRight, EllipsisVertical, FileQuestion, IceCream2, Pa import { FOCUS_KEYS } from "@/mainview/scripts/types"; import { FlatpackIcon } from "@/mainview/scripts/brandIcons"; import { JSX } from "react"; +import { oneShot } from "@/mainview/scripts/audio/audio"; export const emulatorStatusIcons: Record = { store: , @@ -26,7 +27,11 @@ export function StoreEmulatorCard (data: { className?: string; }) { - const handleSelect = () => data.onSelect?.(data.emulator.name, focusKey); + const handleSelect = () => + { + data.onSelect?.(data.emulator.name, focusKey); + oneShot('click'); + }; const { ref, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.EMULATOR_CARD(data.id), @@ -45,6 +50,7 @@ export function StoreEmulatorCard (data: { ref={ref} role="button" tabIndex={0} + data-sound-category="emulator" data-installed={data.emulator.validSources.some(s => s.exists)} onClick={isTouch ? handleSelect : undefined} className={twMerge("relative focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none", data.className)} @@ -87,7 +93,7 @@ export function StoreEmulatorCard (data: {
    ; })} {isMouse && <> - + }
    diff --git a/src/mainview/gen/static-icon-assets.gen.ts b/src/mainview/gen/static-icon-assets.gen.ts index cb3fe1b..1d1a4aa 100644 --- a/src/mainview/gen/static-icon-assets.gen.ts +++ b/src/mainview/gen/static-icon-assets.gen.ts @@ -464,7 +464,7 @@ const assets = new Set([ ]); // Store basePath resolved from Vite config -const BASE_PATH = "./"; +const BASE_PATH = "/"; /** diff --git a/src/mainview/index.tsx b/src/mainview/index.tsx index c76e8e9..f5639f9 100644 --- a/src/mainview/index.tsx +++ b/src/mainview/index.tsx @@ -9,15 +9,13 @@ import } from "@tanstack/react-router"; import { routeTree } from "./gen/routeTree.gen"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { RPC_URL } from "../shared/constants"; import "./scripts/gamepads"; import "./scripts/windowEvents"; -import { client as rommClient } from "../clients/romm/client.gen"; import "./scripts/spatialNavigation"; import NotFound from "./components/NotFound"; import Error from "./components/Error"; import serviceWorker from './scripts/serviceWorker?worker&url'; -import { getCurrentFocusKey, setFocus } from "@noriginmedia/norigin-spatial-navigation"; +import App from "./App"; if ('serviceWorker' in navigator) { @@ -26,12 +24,6 @@ if ('serviceWorker' in navigator) const hashHistory = createHashHistory({}); -rommClient.setConfig({ - baseUrl: `${RPC_URL(__HOST__)}/api/romm`, - credentials: "include", - mode: "cors", -}); - const queryClient = new QueryClient(); export interface RouterContext @@ -66,25 +58,6 @@ export const Router = createRouter({ } }); -const focusMap = new Map(); -export const focusQueue: string[] = []; - -Router.history.subscribe((op) => -{ - if (op.action.type === 'PUSH') - { - focusMap.set(op.location.state.__TSR_index - 1, getCurrentFocusKey()); - } else if (op.action.type === 'BACK') - { - if (focusMap.has(op.location.state.__TSR_index)) - { - focusQueue.pop(); - focusQueue.push(focusMap.get(op.location.state.__TSR_index)!); - focusMap.delete(op.location.state.__TSR_index); - } - } -}); - // Register things for typesafety declare module "@tanstack/react-router" { interface Register @@ -100,9 +73,11 @@ if (!rootElement.innerHTML) const root = createRoot(rootElement); root.render( - - - + + + + + , ); } diff --git a/src/mainview/routes/__root.tsx b/src/mainview/routes/__root.tsx index 345a707..2687614 100644 --- a/src/mainview/routes/__root.tsx +++ b/src/mainview/routes/__root.tsx @@ -4,10 +4,7 @@ import Notifications from "../components/Notifications"; import { Toaster } from "react-hot-toast"; import { mobileCheck, useLocalSetting } from "../scripts/utils"; import useActiveControl from "../scripts/gamepads"; -import { useEffect, useState } from "react"; -import { SystemInfoContext } from "../scripts/contexts"; -import { SystemInfoType } from "@/shared/constants"; -import { systemApi } from "../scripts/clientApi"; +import { useEffect } from "react"; import AppCommunication from "../components/AppCommunication"; export const Route = createRootRouteWithContext()({ diff --git a/src/mainview/routes/embedded.$source.$id.tsx b/src/mainview/routes/embedded.$source.$id.tsx index 9d67605..7b7fa7d 100644 --- a/src/mainview/routes/embedded.$source.$id.tsx +++ b/src/mainview/routes/embedded.$source.$id.tsx @@ -1,9 +1,8 @@ import { RPC_URL, SERVER_URL } from '@/shared/constants'; -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useRouter } from '@tanstack/react-router'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; import { RefObject, useEffect, useRef, useState } from 'react'; -import { Router } from '..'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { ButtonStyle } from '../components/options/Button'; import { DoorOpen, RefreshCw, Undo } from 'lucide-react'; @@ -57,7 +56,7 @@ function Overlay (data: { { if (data.open) { - focusSelf(); + focusSelf({ instant: true }); } }, [data.open]); @@ -122,6 +121,7 @@ function Frame (data: { ref: RefObject; }) function RouteComponent () { + const router = useRouter(); const { ref, focusSelf, focusKey } = useFocusable({ focusKey: 'emulatorjs', preferredChildFocusKey: 'frame', @@ -133,7 +133,7 @@ function RouteComponent () function HandleGoBack () { - Router.navigate({ to: '/game/$source/$id', params: { source, id }, replace: true }); + router.navigate({ to: '/game/$source/$id', params: { source, id }, replace: true }); } useEventListener('message', e => @@ -173,7 +173,7 @@ function RouteComponent () }; useEffect(() => setPaused(overlayOpen), [overlayOpen]); const { shortcuts } = useShortcutContext(); - useEffect(() => { if (!overlayOpen) focusSelf(); }, [overlayOpen]); + useEffect(() => { if (!overlayOpen) focusSelf({ instant: true }); }, [overlayOpen]); function handleClose () { setOverlayOpen(false); diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 6f97fbc..7147e79 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -1,16 +1,15 @@ -import { createFileRoute, ErrorComponentProps } from "@tanstack/react-router"; +import { createFileRoute, ErrorComponentProps, useRouter, useRouterState } from "@tanstack/react-router"; import { RPC_URL } from "@shared/constants"; import { useEffect, useRef, useState } from "react"; -import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import { Calendar, Clock, Folder, Gamepad2, Image, Info, Store, TriangleAlert, Trophy } from "lucide-react"; +import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { Calendar, Folder, Gamepad2, Image, Info, TriangleAlert, Trophy } from "lucide-react"; import { HeaderUI } from "../../components/Header"; import { AnimatedBackground } from "../../components/AnimatedBackground"; import { useQuery } from "@tanstack/react-query"; -import { Router } from "../.."; import Shortcuts from "../../components/Shortcuts"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; import Screenshots from "@/mainview/components/Screenshots"; -import { HandleGoBack, scrollIntoViewHandler, useStickyDataAttr } from "@/mainview/scripts/utils"; +import { HandleGoBack, scrollIntoViewHandler, useOnNavigateBack, useStickyDataAttr } from "@/mainview/scripts/utils"; import { FilterUI } from "@/mainview/components/Filters"; import StatList, { StatEntry } from "@/mainview/components/StatList"; import { useIntersectionObserver, useLocalStorage } from "usehooks-ts"; @@ -21,7 +20,7 @@ import Achievements from "@/mainview/components/game/Achievements"; import { GameDetailsContext } from "@/mainview/scripts/contexts"; import { gameQuery, gamesRecommendedBasedOnGameQuery } from "@queries/romm"; import { GamesSection } from "@/mainview/components/store/GamesSection"; -import Details, { DetailElement } from "@/mainview/components/game/Details"; +import Details from "@/mainview/components/game/Details"; import { AutoFocus } from "@/mainview/components/AutoFocus"; export const Route = createFileRoute("/game/$source/$id")({ @@ -31,7 +30,11 @@ export const Route = createFileRoute("/game/$source/$id")({ }, component: RouteComponent, errorComponent: Error, - validateSearch: zodValidator(z.object({ focus: z.string().optional() })) + validateSearch: zodValidator(z.object({ focus: z.string().optional() })), + staticData: { + enterSound: 'openDetails', + goBackSound: "returnDetails" + }, }); function useDetailsSection () @@ -45,10 +48,6 @@ function Error (data: ErrorComponentProps) useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); const { shortcuts } = useShortcutContext(); - useEffect(() => - { - focusSelf(); - }, []); return
    @@ -68,6 +67,7 @@ function Error (data: ErrorComponentProps)
    + ; } @@ -139,10 +139,10 @@ function Divider (data: { rootFocusKey: string; showShortcuts: boolean; game: Fr function RouteComponent () { + const router = useRouter(); const [recommendedGamesVisible, setRecommendedGamesVisible] = useState(false); const { source, id } = Route.useParams(); const { data } = useQuery(gameQuery(source, id)); - const { focus } = Route.useSearch(); const [, setUpdate] = useState(0); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details", forceFocus: true }); const headerRef = useRef(null); @@ -150,7 +150,12 @@ function RouteComponent () const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_cover}`) : undefined; const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible }); - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); + useShortcuts(focusKey, () => [{ + label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) + }], [router]); + + useOnNavigateBack((s) => s.sound = 'returnDetails'); + const { shortcuts } = useShortcutContext(); useStickyDataAttr(headerRef, sentinelRef, ref); @@ -190,7 +195,7 @@ function RouteComponent () onFocus={scrollIntoViewHandler({ block: 'center' })} onSelect={(id, focus) => { - Router.navigate({ to: '/store/details/emulator/$id', params: { id } }); + router.navigate({ to: '/store/details/emulator/$id', params: { id } }); }} emulators={recommendedEmulators} />} @@ -206,7 +211,7 @@ function RouteComponent () { - Router.navigate({ to: '/game/$source/$id', params: { id: id.id, source: id.source } }); + router.navigate({ to: '/game/$source/$id', params: { id: id.id, source: id.source } }); }} onFocus={scrollIntoViewHandler({ block: 'center', inline: 'nearest' })} games={recommendedGames} /> diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 945b6d7..d285464 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -14,6 +14,7 @@ import import { createFileRoute, + useRouter, } from "@tanstack/react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import @@ -37,7 +38,6 @@ import Shortcuts from "../components/Shortcuts"; import { PlatformsList } from "../components/PlatformsList"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts"; import z from "zod"; -import { Router } from ".."; import CollectionList from "../components/CollectionList"; import { zodValidator } from '@tanstack/zod-adapter'; import { mobileCheck, useDragScroll } from "../scripts/utils"; @@ -45,6 +45,7 @@ import { AnimatedBackgroundContext } from "../scripts/contexts"; import Carousel from "../components/Carousel"; import { closeMutation } from "@queries/system"; import { gameQuery } from "../scripts/queries/romm"; +import { oneShot } from "../scripts/audio/audio"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -90,9 +91,10 @@ function HomeListError (data: { focused: boolean; }) function ShowAllGamesCard () { + const router = useRouter(); const handleNavigate = () => { - Router.navigate({ to: '/games', viewTransition: { types: ['zoom-in'] } }); + router.navigate({ to: '/games', viewTransition: { types: ['zoom-in'] } }); }; const { ref } = useFocusable({ focusKey: 'all-games-btn', onEnterPress: handleNavigate }); return
    All Games
    ; @@ -102,6 +104,7 @@ function HomeList (data: { selectedFilter: string; }) { + const router = useRouter(); const queryClient = useQueryClient(); const [initFocus, setInitFocus] = useState(false); const bg = useContext(AnimatedBackgroundContext); @@ -124,7 +127,7 @@ function HomeList (data: { function handleGameSelect (id: FrontEndId, source: string | null, sourceId: string | null) { - Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); + router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); }; let activeList: JSX.Element; @@ -213,9 +216,11 @@ function HomeList (data: { function MainMenu () { + const router = useRouter(); const { ref, focusKey } = useFocusable({ focusKey: `main-menu`, trackChildren: true, + focusBoundaryDirections: ['up', 'down'] }); return (
      Router.navigate({ to: "/games" })} + action={() => router.navigate({ to: "/games" })} icon={} label="Home" type="secondary" /> } label="News" /> - } action={() => Router.navigate({ to: "/store/tab" })} label="Shop" /> + } action={() => router.navigate({ to: "/store/tab" })} label="Shop" /> } label="Album" /> } @@ -241,7 +246,7 @@ function MainMenu () { - Router.navigate({ to: '/settings/accounts' }); + router.navigate({ to: '/settings/accounts' }); }} icon={} label="Settings" @@ -259,11 +264,16 @@ function CircleIcon (data: { icon?: JSX.Element; }) { + const handleAction = () => + { + data.action?.(); + oneShot('click'); + }; const { ref, focusKey } = useFocusable({ - focusKey: `navigation-icon-${data.label}`, - onEnterPress: data.action, + focusKey: `menu-navigation-icon-${data.label}`, + onEnterPress: handleAction, }); - useShortcuts(focusKey, () => [{ label: data.label, action: (e) => data.action?.(), button: GamePadButtonCode.A }]); + useShortcuts(focusKey, () => [{ label: data.label, action: handleAction, button: GamePadButtonCode.A }]); const typeClasses = { secondary: "bg-secondary text-secondary-content", accent: "bg-accent text-accent-content", @@ -273,7 +283,8 @@ function CircleIcon (data: { return (
    • @@ -287,7 +298,7 @@ export default function ConsoleHomeUI () const { filter } = Route.useSearch(); const close = useMutation(closeMutation); - + const router = useRouter(); const { ref, focusKey } = useFocusable({ forceFocus: true, autoRestoreFocus: false, @@ -296,7 +307,7 @@ export default function ConsoleHomeUI () preferredChildFocusKey: `home-list`, }); - const setFilter = (filter: string) => Router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true }); + const setFilter = (filter: string) => router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true }); const { shortcuts } = useShortcutContext(); const headerButtons: HeaderButton[] = []; diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index 89c4a65..49011d7 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -1,7 +1,6 @@ import { AnimatedBackground } from '@/mainview/components/AnimatedBackground'; -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useRouter } from '@tanstack/react-router'; import DotsLoading from '../components/backgrounds/dots'; -import { Router } from '..'; import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; @@ -16,9 +15,10 @@ export const Route = createFileRoute('/launcher/$source/$id')({ function RouteComponent () { + const router = useRouter(); function HandleGoBack () { - Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); + router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); } const { source, id } = Route.useParams(); diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index 7c0faf5..841702b 100644 --- a/src/mainview/routes/settings/accounts.tsx +++ b/src/mainview/routes/settings/accounts.tsx @@ -171,7 +171,7 @@ function RouteComponent () { if (focus) { - focusSelf(); + focusSelf({ instant: true }); } }, [focus]); diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index 4d7c177..b5e25c8 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useRouter } from '@tanstack/react-router'; import { OptionSpace } from '../../components/options/OptionSpace'; import { OptionInput } from '../../components/options/OptionInput'; import { useMutation, useQuery } from '@tanstack/react-query'; @@ -19,7 +19,6 @@ import Carousel from '@/mainview/components/Carousel'; import { FOCUS_KEYS } from '@/mainview/scripts/types'; import { scrollIntoNearestParent, scrollIntoViewHandler, useDragScroll } from '@/mainview/scripts/utils'; import { SettingsOption } from '@/mainview/components/options/SettingsOption'; -import { Router } from '@/mainview'; export const Route = createFileRoute('/settings/emulators')({ component: RouteComponent, @@ -76,7 +75,7 @@ function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAdd const handleCloseContext = () => { setNewEmulatorTypeOpen(false); - setFocus('emulator'); + setFocus('emulator', { instant: true }); }; @@ -123,7 +122,7 @@ function EmulatorPath (data: { id: string; }) const handleCloseSearch = () => { setIsSearching(false); - setFocus(`search-${data.id}`); + setFocus(`search-${data.id}`, { instant: true }); }; const handleSelectPath = (path: string) => @@ -192,6 +191,7 @@ function EmulatorBadge (data: { addOverride: (emulator: string) => void; } & FocusParams) { + const router = useRouter(); const { focusKey, ref, focused } = useFocusable({ focusKey: FOCUS_KEYS.EMULATOR_CARD(data.emulator.name), onFocus (l, p, details) { data.onFocus?.(focusKey, ref.current, details); } @@ -212,12 +212,12 @@ function EmulatorBadge (data: { label: "Visit Store", action () { - Router.navigate({ to: '/store/details/emulator/$id', params: { id: data.emulator.name } }); + router.navigate({ to: '/store/details/emulator/$id', params: { id: data.emulator.name } }); }, }); } return shortcuts; - }, [data.addOverride]); + }, [data.addOverride, router]); let statusIcon = ; @@ -255,7 +255,7 @@ function EmulatorBadge (data: { case 'store': icon = ; className = "hover:bg-base-content hover:text-base-100 cursor-pointer bg-accent text-accent-content"; - action = () => { Router.navigate({ to: '/store/details/emulator/$id', params: { id: data.emulator.name } }); }; + action = () => { router.navigate({ to: '/store/details/emulator/$id', params: { id: data.emulator.name } }); }; break; case 'embedded': icon = ; diff --git a/src/mainview/routes/settings/interface.tsx b/src/mainview/routes/settings/interface.tsx index c1c94f9..ddca3a8 100644 --- a/src/mainview/routes/settings/interface.tsx +++ b/src/mainview/routes/settings/interface.tsx @@ -19,6 +19,7 @@ function RouteComponent () +
    ; } diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index c6e8198..5c76442 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -8,6 +8,7 @@ import Outlet, createFileRoute, useMatch, + useRouter, } from "@tanstack/react-router"; import { ViewTransitionOptions } from "@tanstack/router-core"; import classNames from "classnames"; @@ -21,20 +22,24 @@ import MonitorCog, Puzzle, } from "lucide-react"; -import { JSX, useEffect } from "react"; +import { JSX } from "react"; import { twMerge } from "tailwind-merge"; import z from "zod"; import { SettingsSchema } from "../../../shared/constants"; -import { Router } from "../.."; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; import Shortcuts from "@/mainview/components/Shortcuts"; import { HandleGoBack } from "@/mainview/scripts/utils"; +import { AutoFocus } from "@/mainview/components/AutoFocus"; +import { oneShot } from "@/mainview/scripts/audio/audio"; export const Route = createFileRoute("/settings")({ component: SettingsUI, validateSearch: z.object({ focus: z.keyof(SettingsSchema).optional() - }) + }), + staticData: { + enterSound: 'openSettings' + } }); function MenuItem (data: { @@ -48,17 +53,18 @@ function MenuItem (data: { label: string; }) { + const router = useRouter(); const acitve = !!useMatch({ from: data.route as any, shouldThrow: false });; const handleNonFocusSelect = () => { if (data.return) { - HandleGoBack(); + HandleGoBack(router); } else if (!acitve) { - Router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true }); + router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true }); } - + oneShot('click'); }; const { ref, focusSelf } = useFocusable({ focusKey: `menu-item-${data.route}`, @@ -67,7 +73,7 @@ function MenuItem (data: { { if (data.focusSelect && !acitve) { - Router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true }); + router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true }); } (ref.current as HTMLElement).scrollIntoView({ inline: 'center' }); }, @@ -81,6 +87,7 @@ function MenuItem (data: {
  • - { - focusSelf(); - }, []); - - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }], [router]); const { shortcuts } = useShortcutContext(); return ( @@ -196,6 +199,7 @@ export function SettingsUI () + ); } diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 1593bd9..9ad4678 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -4,9 +4,8 @@ import useFocusable, FocusContext, } from "@noriginmedia/norigin-spatial-navigation"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useRouter } from "@tanstack/react-router"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; -import { Router } from "@/mainview"; import Shortcuts from "@/mainview/components/Shortcuts"; import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; import { systemApi } from "@/mainview/scripts/clientApi"; @@ -18,7 +17,7 @@ import Screenshots from "@/mainview/components/Screenshots"; import { StickyHeaderUI } from "@/mainview/components/Header"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection"; -import { HandleGoBack, scrollIntoViewHandler, useJobStatus } from "@/mainview/scripts/utils"; +import { HandleGoBack, scrollIntoViewHandler, useJobStatus, useOnNavigateBack } from "@/mainview/scripts/utils"; import toast from "react-hot-toast"; import { getErrorMessage } from "react-error-boundary"; import { emulatorStatusIcons } from "@/mainview/components/store/StoreEmulatorCard"; @@ -27,6 +26,7 @@ import { GamesSection } from "@/mainview/components/store/GamesSection"; import { deleteBiosMutation, downloadBiosMutation, installEmulatorMutation, storeEmulatorDeleteMutation, storeEmulatorDetailsQuery, storeEmulatorsRecommendedQuery } from "@queries/store"; import { gamesRecommendedBasedOnEmulatorQuery } from "@queries/romm"; import FocusTooltip from "@/mainview/components/FocusTooltip"; +import { AutoFocus } from "@/mainview/components/AutoFocus"; export const Route = createFileRoute('/store/details/emulator/$id')({ component: RouteComponent, @@ -35,6 +35,10 @@ export const Route = createFileRoute('/store/details/emulator/$id')({ ctx.context.queryClient.prefetchQuery(storeEmulatorDetailsQuery(ctx.params.id)); ctx.context.queryClient.prefetchQuery(storeEmulatorsRecommendedQuery(ctx.params.id)); ctx.context.queryClient.prefetchQuery(gamesRecommendedBasedOnEmulatorQuery(ctx.params.id)); + }, + staticData: { + enterSound: "openDetails", + goBackSound: "returnDetails" } }); @@ -288,7 +292,7 @@ function Description (data: { emulator?: FrontEndEmulatorDetailed; }) export function RouteComponent () { const { id } = Route.useParams(); - + const router = useRouter(); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: `GAME_DETAIL_${id}`, trackChildren: true, @@ -301,22 +305,16 @@ export function RouteComponent () useShortcuts(focusKey, () => [{ label: "Return", - action: HandleGoBack, + action: () => HandleGoBack(router), button: GamePadButtonCode.B - }]); + }], [router]); const installMutation = useMutation({ ...installEmulatorMutation(id), onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)), }); - useEffect(() => - { - focusSelf(); - }, []); - const { shortcuts } = useShortcutContext(); - const stats: StatEntry[] = []; if (emulator) { @@ -341,6 +339,7 @@ export function RouteComponent () return ( +
    @@ -370,7 +369,7 @@ export function RouteComponent () onFocus={scrollIntoViewHandler({ block: 'center' })} onSelect={(id, focus) => { - Router.navigate({ + router.navigate({ to: '/store/details/emulator/$id', params: { id } }); }} @@ -386,7 +385,7 @@ export function RouteComponent ()
    { - Router.navigate({ + router.navigate({ to: '/game/$source/$id', params: { id: id.id, source: id.source } }); }} games={recommendedGames} />} diff --git a/src/mainview/routes/store/tab/route.tsx b/src/mainview/routes/store/tab/route.tsx index e656b8e..2f8f091 100644 --- a/src/mainview/routes/store/tab/route.tsx +++ b/src/mainview/routes/store/tab/route.tsx @@ -1,4 +1,4 @@ -import { Router } from '@/mainview'; +import { AutoFocus } from '@/mainview/components/AutoFocus'; import { FilterUI } from '@/mainview/components/Filters'; import { HeaderUI } from '@/mainview/components/Header'; import Shortcuts from '@/mainview/components/Shortcuts'; @@ -6,19 +6,21 @@ import { StoreContext } from '@/mainview/scripts/contexts'; import { gameQuery } from '@/mainview/scripts/queries/romm'; import { storeEmulatorDetailsQuery } from '@/mainview/scripts/queries/store'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '@/mainview/scripts/shortcuts'; -import { mobileCheck, useStickyDataAttr } from '@/mainview/scripts/utils'; +import { HandleGoBack, mobileCheck, useStickyDataAttr } from '@/mainview/scripts/utils'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { useQueryClient } from '@tanstack/react-query'; -import { useMatchRoute } from '@tanstack/react-router'; +import { useMatchRoute, useRouter } from '@tanstack/react-router'; import { createFileRoute, Outlet } from '@tanstack/react-router'; import { zodValidator } from '@tanstack/zod-adapter'; -import { Settings } from 'lucide-react'; -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; import z from 'zod'; export const Route = createFileRoute('/store/tab')({ component: RouteComponent, - validateSearch: zodValidator(z.object({ focus: z.string().optional() })) + validateSearch: zodValidator(z.object({ focus: z.string().optional() })), + staticData: { + enterSound: 'openStore' + } }); function useIsSettings (subPath: string) @@ -33,6 +35,7 @@ function useIsSettings (subPath: string) function TopArea (data: { filters: Record; }) { + const router = useRouter(); const { ref, focusKey } = useFocusable({ focusKey: 'top-area', preferredChildFocusKey: `store-tabs`, @@ -44,13 +47,13 @@ function TopArea (data: { filters: Record; }) useShortcuts("STORE_ROOT", () => [{ label: "Return", - action: () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-out'] } }), + action: () => HandleGoBack(router), button: GamePadButtonCode.B - }], []); + }], [router]); const handleNavigate = (s: string) => { - Router.navigate({ to: `/store/tab/${s === 'home' ? '' : s}`, viewTransition: { types: ['slide-up'] }, replace: true }); + router.navigate({ to: `/store/tab/${s === 'home' ? '' : s}`, viewTransition: { types: ['slide-up'] }, replace: true }); }; return
    @@ -76,6 +79,7 @@ function StoreOutlet () function RouteComponent () { // Root spatial nav container + const router = useRouter(); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "STORE_ROOT", preferredChildFocusKey: 'top-area', @@ -93,25 +97,16 @@ function RouteComponent () const { shortcuts } = useShortcutContext(); const { focus } = Route.useSearch(); - useEffect(() => - { - if (!focus) - { - focusSelf(); - } - }, []); - const handleDetails = (type: string, source: string, id: string, focus: string) => { if (type === 'emulator') { - Router.navigate({ to: '/store/details/emulator/$id', params: { id } }); + router.navigate({ to: '/store/details/emulator/$id', params: { id } }); } else if (type === 'game') { - Router.navigate({ to: '/game/$source/$id', params: { source: source, id: id } }); + router.navigate({ to: '/game/$source/$id', params: { source: source, id: id } }); } - }; const handlePrefetch = (type: string, source: string, id: string) => @@ -150,5 +145,6 @@ function RouteComponent ()
    + ; } diff --git a/src/mainview/scripts/audio/audio.ts b/src/mainview/scripts/audio/audio.ts new file mode 100644 index 0000000..6084ac4 --- /dev/null +++ b/src/mainview/scripts/audio/audio.ts @@ -0,0 +1,70 @@ +import { Howl } from 'howler'; +import sounds from '../../assets/sounds.ogg'; +import soundSprites from '../../assets/sounds.json'; +import { getLocalSetting } from '../utils'; + +const timingMap = new Map(); + +const sound = new Howl({ + src: [sounds], + sprite: soundSprites.sprite as any, + volume: 0.5, +}); +import.meta.hot?.dispose(() => { sound.unload(); }); + +declare module '@tanstack/react-router' { + interface StaticDataRouteOption + { + enterSound?: keyof typeof soundMap | null; + goBackSound?: keyof typeof soundMap | null; + } +} + +const volumeVariation = 0.05; +const rateVariation = 0.01; + +export const soundMap = { + openDetails: { key: 'Classic UI SFX - Chords #1' }, + returnGeneric: { key: 'Classic UI SFX - Short - Low #2' }, + returnDetails: { key: 'Classic UI SFX - Short - Low #5' }, + openGeneric: { key: 'Classic UI SFX - Short - High #9' }, + select: { key: 'Classic UI SFX - Short - High #5', rateVariation, volumeVariation }, + selectAlt: { key: "Classic UI SFX - Short - High #6", rateVariation, volumeVariation }, + selectMenu: { key: 'Classic UI SFX - Short - High #7', rateVariation, volumeVariation }, + selectFilter: { key: 'Classic UI SFX - Short - High #3', volumeVariation }, + closeContext: { key: 'Classic UI SFX - Short - High #19' }, + openContext: { key: 'Classic UI SFX - Short - High #22' }, + openStore: { key: 'Classic UI SFX - Chords #16' }, + openSettings: { key: 'Classic UI SFX - Short - High #8' }, + click: { key: "UI_Single_Set 16_03", rateVariation, volumeVariation }, + clickAlt: { key: "UI_Single_Set 16_01", rateVariation, volumeVariation }, + invalidNavigation: { key: "Classic UI SFX - Short - Low #6", rateVariation, volumeVariation }, +} satisfies Record; + +function sinRanom () +{ + return Math.sin(new Date().getMilliseconds() / 1000 * Math.PI); +} + +function cosRandom () +{ + return Math.sin(new Date().getMilliseconds() / 1000 * Math.PI); +} + +function random () +{ + return Math.random() * 2 - 1; +} + +export function oneShot (id: keyof typeof soundMap) +{ + const currentDate = timingMap.get(id); + if (!getLocalSetting('soundEffects')) return; + if (currentDate && new Date().getTime() - currentDate.getTime() <= 100) return; + const soundValue = soundMap[id] as { key: keyof typeof soundSprites.sprite, rateVariation?: number; volumeVariation?: number; }; + const instanceId = sound.play(soundValue.key); + sound.volume(sound.volume() + random() * (soundValue.volumeVariation ?? 0), instanceId); + sound.rate(1 + random() * (soundValue.rateVariation ?? 0), instanceId); + timingMap.set(id, new Date()); +} + diff --git a/src/mainview/scripts/audio/audioCallbacks.ts b/src/mainview/scripts/audio/audioCallbacks.ts new file mode 100644 index 0000000..4a8f744 --- /dev/null +++ b/src/mainview/scripts/audio/audioCallbacks.ts @@ -0,0 +1,90 @@ +import { Router } from "@/mainview"; +import { oneShot, soundMap } from "./audio"; +export default function load () +{ + let lastLocationPath: string | undefined; + const unsub = Router.history.subscribe((op) => + { + if (op.action.type === 'PUSH') + { + lastLocationPath = op.location.pathname; + + const routes = Router.matchRoutes(op.location.pathname); + const soundRoute = routes.find(r => r.staticData.enterSound !== undefined); + if (soundRoute) + { + if (soundRoute.staticData.enterSound) oneShot(soundRoute.staticData.enterSound); + } else + { + oneShot("openGeneric"); + } + + } else if (op.action.type === 'BACK') + { + if (lastLocationPath) + { + const soundRoutes = Router.matchRoutes(lastLocationPath); + const soundRoute = soundRoutes.find(r => r.staticData.goBackSound !== undefined); + if (soundRoute) + { + if (soundRoute.staticData.goBackSound) oneShot(soundRoute.staticData.goBackSound); + } else + { + oneShot("returnGeneric"); + } + } else + { + oneShot("returnGeneric"); + } + + lastLocationPath = op.location.state.key; + } + }); + + let focusChangeDebounced: undefined | NodeJS.Timeout; + + const focuschangedHandler = (e: CustomEvent) => + { + clearTimeout(focusChangeDebounced); + if (!e.detail.focusKeyChanged) return; + + if (e.detail.nativeEvent || e.detail.event) + { + let sound: keyof typeof soundMap; + if (e.detail.node && e.detail.node.matches('[data-sound-category="menu"]')) + { + sound = 'selectMenu'; + + } else if (e.detail.node && e.detail.node.matches('[data-sound-category="filter"]')) + { + sound = "selectFilter"; + } + else if (e.detail.node && e.detail.node.matches('[data-sound-category="emulator"]')) + { + sound = "selectAlt"; + } + else if (!e.detail.node || !e.detail.node.matches('[data-sound-disable="focus"]')) + { + sound = e.detail.sound as any ?? 'select'; + } + + setTimeout(() => + { + if (e.detail.nativeEvent || e.detail.event) + { + oneShot(sound); + } + }, 10); + } + }; + + window.addEventListener('focuschanged', focuschangedHandler as any); + + return { + cleanup: () => + { + unsub(); + window.removeEventListener('focuschanged', focuschangedHandler as any); + } + }; +} \ No newline at end of file diff --git a/src/mainview/scripts/gamepads.ts b/src/mainview/scripts/gamepads.ts index fed3f3d..e7edf3b 100644 --- a/src/mainview/scripts/gamepads.ts +++ b/src/mainview/scripts/gamepads.ts @@ -2,6 +2,7 @@ import { getCurrentFocusKey, navigateByDirection } from "@noriginmedia/norigin-s import { GetFocusedElement } from "./spatialNavigation"; import { useEffect, useState } from "react"; import { mobileCheck } from "./utils"; +import { oneShot } from "./audio/audio"; let loopStarted = false; let isTouching = false; @@ -104,7 +105,10 @@ function throttleNav (key: string, dir: string, event: Event) const speed = Math.max(maxSpeed - (maxSpeed - minSpeed) * (acceleration / 6), minSpeed); if ((currentDate.getTime() - (lastTime ?? 0) > speed)) { + const currentFocusKey = getCurrentFocusKey(); navigateByDirection(dir, { event }); + if (currentFocusKey === getCurrentFocusKey()) + oneShot('invalidNavigation'); throttleMap.set(key, currentDate.getTime()); throttleAcceleration.set(key, acceleration + 1); return true; diff --git a/src/mainview/scripts/spatialNavigation.ts b/src/mainview/scripts/spatialNavigation.ts index 3d4b182..3129f2a 100644 --- a/src/mainview/scripts/spatialNavigation.ts +++ b/src/mainview/scripts/spatialNavigation.ts @@ -1,6 +1,5 @@ import { - FocusDetails, getCurrentFocusKey, init, SpatialNavigation, @@ -9,7 +8,7 @@ import UseFocusableResult, } from "@noriginmedia/norigin-spatial-navigation"; import { RefObject, useEffect, useState } from "react"; -import { focusQueue, Router } from ".."; +import { focusQueue } from "../App"; init({ shouldFocusDOMNode: false, @@ -97,13 +96,21 @@ SpatialNavigation.updateLayout = (focusKey) => SpatialNavigation.setFocus = (newFocusKey, focusDetails) => { setFocus(newFocusKey, focusDetails); - dispatchFocusedEvent(new CustomEvent('focuschanged', { bubbles: true, detail: focusDetails })); }; SpatialNavigation.setCurrentFocusedKey = (newFocusKey, focusDetails) => { + const details: FocusEventDetails = { + ...focusDetails, + focusKey: newFocusKey, + focusKeyChanged: newFocusKey !== getCurrentFocusKey(), + node: GetFocusedElement(newFocusKey) + }; setCurrentFocusedKey(newFocusKey, focusDetails); - window.dispatchEvent(new CustomEvent('focuschanged', { bubbles: true, detail: focusDetails })); + window.dispatchEvent(new CustomEvent('focuschanged', { + bubbles: true, + detail: details + })); }; SpatialNavigation.updateFocusable = (key, data) => diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index fd2572b..a9666be 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -3,7 +3,8 @@ import { RefObject, useEffect, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; import { jobsApi } from "./clientApi"; import { JobsAPIType } from "@/bun/api/rpc"; -import { Router } from ".."; +import { AnyRouter, Router, useRouter } from "@tanstack/react-router"; +import { soundMap } from "./audio/audio"; export type ScrollSaveParams = { id: string; @@ -59,6 +60,13 @@ export function mobileCheck () return check; }; +export function getLocalSetting (key: TKey) +{ + const localValueRaw = localStorage.getItem(key); + if (!localValueRaw) return LocalSettingsSchema.shape[key].parse(undefined); + return LocalSettingsSchema.shape[key].parse(JSON.parse(localValueRaw)); +} + export function useLocalSetting (key: TKey) { const [localValue] = useLocalStorage(key, LocalSettingsSchema.shape[key].parse(undefined), { deserializer: (value) => LocalSettingsSchema.shape[key].parse(JSON.parse(value)) }); @@ -218,7 +226,7 @@ export function scrollIntoViewHandler (params?: ScrollIntoViewOptions) return (focusKey: string, node: HTMLElement, details: any) => { if (details.nativeEvent instanceof PointerEvent) return; - node.scrollIntoView({ ...params, behavior: details.instant ? 'instant' : 'smooth' }); + node.scrollIntoView({ ...params, behavior: details.instant || !details.event ? 'instant' : 'smooth' }); }; } @@ -315,13 +323,37 @@ export function useJobStatus) => void) +{ + const router = useRouter(); + const prevIndex = useRef(router.history.location.state.__TSR_index); + + useEffect(() => + { + const unsub = router.history.subscribe(() => + { + const currentIndex = router.history.location.state.__TSR_index; + const isBack = currentIndex < prevIndex.current; + + if (isBack) + { + callback(router.history.location.state); + } + + prevIndex.current = currentIndex; + }); + + return unsub; + }, [router]); } \ No newline at end of file diff --git a/src/mainview/types.d.ts b/src/mainview/types.d.ts index 699ca3c..00aca5e 100644 --- a/src/mainview/types.d.ts +++ b/src/mainview/types.d.ts @@ -16,6 +16,25 @@ declare global "save-scroll"?: boolean; } } + + module "@noriginmedia/norigin-spatial-navigation" { + declare interface FocusDetails + { + instant?: boolean; + sound?: string; + } + } +} + +declare interface FocusEventDetails +{ + focusKey: string; + instant?: boolean; + sound?: string; + nativeEvent?: any; + event?: Event; + node: HTMLElement | undefined; + focusKeyChanged: boolean; } declare interface FocusParams diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 66e3a78..acced4c 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -41,7 +41,8 @@ export const SettingsSchema = z.object({ export const LocalSettingsSchema = z.object({ backgroundBlur: z.stringbool().or(z.boolean()).default(true), backgroundAnimation: z.stringbool().or(z.boolean()).default(true), - theme: z.enum(['dark', 'light', 'auto']).default('auto') + theme: z.enum(['dark', 'light', 'auto']).default('auto'), + soundEffects: z.boolean().default(true) }); export const GameListFilterSchema = z.object({ diff --git a/src/sounds/Classic UI SFX - Chords #1.wav b/src/sounds/Classic UI SFX - Chords #1.wav new file mode 100644 index 0000000..eef68fe --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #1.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cce497234f22db3e9d1c0e720c36242b3e0d68a8e5ebed0d99df9dbfb5a7ac85 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #10.wav b/src/sounds/Classic UI SFX - Chords #10.wav new file mode 100644 index 0000000..dff9ef9 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #10.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f5e37d4692690781115bef69f033c371c89fc5e3be3415c01bcfb47f5b03c9f +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #11.wav b/src/sounds/Classic UI SFX - Chords #11.wav new file mode 100644 index 0000000..975f24d --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #11.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61c21f7ec7440719aef486730104cc1110c881de5a38e58151101088b7f63e89 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #12.wav b/src/sounds/Classic UI SFX - Chords #12.wav new file mode 100644 index 0000000..b6db6ee --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #12.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80bbc3cddf2f040a96a989e4febf66885a031a42438e705267ecad60e69caf23 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #13.wav b/src/sounds/Classic UI SFX - Chords #13.wav new file mode 100644 index 0000000..bdd6474 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #13.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:532526fee3cea70f093cceef20d1a2587e334f13c293461c152b9e4faf5c8ab5 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #14.wav b/src/sounds/Classic UI SFX - Chords #14.wav new file mode 100644 index 0000000..86c4cf1 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #14.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca7fadf4951a5beace5c5db4a24fd86f8907d0071a7f9a3bc600e973399b001f +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #15.wav b/src/sounds/Classic UI SFX - Chords #15.wav new file mode 100644 index 0000000..40c31fb --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #15.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54ed638f5ecca2615e0ac4bd42031efa89de810f18b0b3c0b0f2920bfaf021f6 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #16.wav b/src/sounds/Classic UI SFX - Chords #16.wav new file mode 100644 index 0000000..c019363 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #16.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e67390430ce0b19689f5322ad6ff78cd0a7f01cea6b55d02fef19c72ea77a653 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #17.wav b/src/sounds/Classic UI SFX - Chords #17.wav new file mode 100644 index 0000000..e3f84cb --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #17.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:90fe98d297a58235bda0a9a4bfc367914f052a550a7289fe652617f30f0b5e32 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #18.wav b/src/sounds/Classic UI SFX - Chords #18.wav new file mode 100644 index 0000000..8883284 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #18.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb19e2996301ccfcfdf328c2f2b553ff9aa0dfe1ff4982ae1c54c9d6a2ba2438 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #19.wav b/src/sounds/Classic UI SFX - Chords #19.wav new file mode 100644 index 0000000..0edb657 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #19.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31630542c8751e4fcdd0a9ab211816f09aea6c4bf6069694e291b18b0774d7df +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #2.wav b/src/sounds/Classic UI SFX - Chords #2.wav new file mode 100644 index 0000000..4b38c6b --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #2.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5dc42fc6845b2ef1ee3db50d81335e93e97902cde85fcf8fe3e5ee29c83163e9 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #20.wav b/src/sounds/Classic UI SFX - Chords #20.wav new file mode 100644 index 0000000..7d61fa7 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #20.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52d3abeb064f3e749d0f4fd29f92b19e54e1477ec80155aca906ae8975e2709f +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #3.wav b/src/sounds/Classic UI SFX - Chords #3.wav new file mode 100644 index 0000000..a9778fb --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #3.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1295f494ec9d295710e7bb1cf467fb118e102891dc9009b86b0ae9960d32e382 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #4.wav b/src/sounds/Classic UI SFX - Chords #4.wav new file mode 100644 index 0000000..b2f258e --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #4.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5e387f33652387eda2189fc9f6c2194b36a9aac272b36ccc64c4e468b77e214 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #5.wav b/src/sounds/Classic UI SFX - Chords #5.wav new file mode 100644 index 0000000..6f2dc11 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #5.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:739c571b6b1ab60a002b9b357878ae91bb9df576d52e5480f07c17886a53f805 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #6.wav b/src/sounds/Classic UI SFX - Chords #6.wav new file mode 100644 index 0000000..fc36385 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #6.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d80b6464291c88b7f33748d4a4e93ed07ee1756d5c623b3862bd3847767d7b54 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #7.wav b/src/sounds/Classic UI SFX - Chords #7.wav new file mode 100644 index 0000000..33f80af --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #7.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:796918fa72911e50c7d762f0fb599427c5924d53b627a1b735f16ca1a9f54fe3 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #8.wav b/src/sounds/Classic UI SFX - Chords #8.wav new file mode 100644 index 0000000..047aa68 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #8.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0439d8fbe9c4734acd7b40e440027d2a7a1c1ae20218f1ca2e3dacd318d776f9 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #9.wav b/src/sounds/Classic UI SFX - Chords #9.wav new file mode 100644 index 0000000..7368828 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #9.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe8e930484f41092b1518f0f3839b617561f1c62ce4b7532158c0cf484fd4d6f +size 769182 diff --git a/src/sounds/Classic UI SFX - Short - High #1.wav b/src/sounds/Classic UI SFX - Short - High #1.wav new file mode 100644 index 0000000..a29dbce --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #1.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa96866433176c69bf23254badee5abd87741816fe833a07a26d6031020464a2 +size 489182 diff --git a/src/sounds/Classic UI SFX - Short - High #10.wav b/src/sounds/Classic UI SFX - Short - High #10.wav new file mode 100644 index 0000000..8c9c510 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #10.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e58d993037d6c05797c1ecadd738f484d2c1428db6d96381522ef1254b6f794f +size 490182 diff --git a/src/sounds/Classic UI SFX - Short - High #11.wav b/src/sounds/Classic UI SFX - Short - High #11.wav new file mode 100644 index 0000000..558786a --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #11.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d648dc7030de8bc59abc70a0153e37b08cf4267228aab41a321eeaa3f03fb2fc +size 562182 diff --git a/src/sounds/Classic UI SFX - Short - High #12.wav b/src/sounds/Classic UI SFX - Short - High #12.wav new file mode 100644 index 0000000..cbd310c --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #12.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b4c11124a6a9542a3e672e4707d9dd67ea5f805ec239c310218c53d61634d4e +size 562182 diff --git a/src/sounds/Classic UI SFX - Short - High #13.wav b/src/sounds/Classic UI SFX - Short - High #13.wav new file mode 100644 index 0000000..fa8a598 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #13.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5911be904de2655606bd16834391d21f56313d6fce28ed5cfaaf560331a4be80 +size 576182 diff --git a/src/sounds/Classic UI SFX - Short - High #14.wav b/src/sounds/Classic UI SFX - Short - High #14.wav new file mode 100644 index 0000000..e7e21ca --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #14.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:815699bd8283ecd8af0cdfcf384a97a6a61a8aa6ab9400d0fa079267eee0567e +size 538182 diff --git a/src/sounds/Classic UI SFX - Short - High #15.wav b/src/sounds/Classic UI SFX - Short - High #15.wav new file mode 100644 index 0000000..142bc6f --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #15.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95b2b932aade06ea1575755fa516aec836224397145d6b22270096ae74078b7c +size 523182 diff --git a/src/sounds/Classic UI SFX - Short - High #16.wav b/src/sounds/Classic UI SFX - Short - High #16.wav new file mode 100644 index 0000000..d37ab0c --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #16.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8340972a758f262ff71b319a43bcdebb685d3c7036a169276b095b726c22000b +size 562182 diff --git a/src/sounds/Classic UI SFX - Short - High #17.wav b/src/sounds/Classic UI SFX - Short - High #17.wav new file mode 100644 index 0000000..112b03e --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #17.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0fbe6ca64226581e8bafa0891fabfd465d9045b395c4a21534a04af435342971 +size 553182 diff --git a/src/sounds/Classic UI SFX - Short - High #18.wav b/src/sounds/Classic UI SFX - Short - High #18.wav new file mode 100644 index 0000000..34ef574 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #18.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95539682288858aaf0b37bc35715b71060e76bf15b81db3898a0f739f062b243 +size 453182 diff --git a/src/sounds/Classic UI SFX - Short - High #19.wav b/src/sounds/Classic UI SFX - Short - High #19.wav new file mode 100644 index 0000000..b165ece --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #19.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d312f707829b975fb625bed15879118479a7927d9e29bd62d2c1c716d35b367f +size 586182 diff --git a/src/sounds/Classic UI SFX - Short - High #2.wav b/src/sounds/Classic UI SFX - Short - High #2.wav new file mode 100644 index 0000000..f6d5fa2 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #2.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89c235075af8db515f97c2bfd50f5350c28c2ba2079e6fad8fdfb963f24a12a1 +size 546182 diff --git a/src/sounds/Classic UI SFX - Short - High #20.wav b/src/sounds/Classic UI SFX - Short - High #20.wav new file mode 100644 index 0000000..7e23a1e --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #20.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61a585819286d0f11314813ee89bc8f121ca4b362d1721e2edfe14172a6358e7 +size 387182 diff --git a/src/sounds/Classic UI SFX - Short - High #21.wav b/src/sounds/Classic UI SFX - Short - High #21.wav new file mode 100644 index 0000000..49cb131 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #21.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:480c9e31c1a033c959888bd2a2691c772ef80bbec1c5f8dc3fccb7e86da6c153 +size 385182 diff --git a/src/sounds/Classic UI SFX - Short - High #22.wav b/src/sounds/Classic UI SFX - Short - High #22.wav new file mode 100644 index 0000000..acced97 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #22.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ab262921ec2763a84c25d3ec00e1ade68e96393fbd475c28659aa661af9d41f +size 478182 diff --git a/src/sounds/Classic UI SFX - Short - High #23.wav b/src/sounds/Classic UI SFX - Short - High #23.wav new file mode 100644 index 0000000..4d106a7 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #23.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0f09d67685a7cf0b82b66f082ad77cf0b536557306fbf6862ba3ef0b92b8280 +size 472182 diff --git a/src/sounds/Classic UI SFX - Short - High #24.wav b/src/sounds/Classic UI SFX - Short - High #24.wav new file mode 100644 index 0000000..e3d0dc8 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #24.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd3520550af14b8d19b275bba18fb7f2610f156e6eea780837748a068d6a858d +size 402182 diff --git a/src/sounds/Classic UI SFX - Short - High #25.wav b/src/sounds/Classic UI SFX - Short - High #25.wav new file mode 100644 index 0000000..6632b69 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #25.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f18ca1455d31a7c430572c64b85c0c600a0c77986e6411a0f9cff783445393c3 +size 385182 diff --git a/src/sounds/Classic UI SFX - Short - High #3.wav b/src/sounds/Classic UI SFX - Short - High #3.wav new file mode 100644 index 0000000..767c819 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #3.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:140b58fa531bb20dcb284e8babea35ef55fa5d58aff16e8d98b505ccf02115e5 +size 550182 diff --git a/src/sounds/Classic UI SFX - Short - High #4.wav b/src/sounds/Classic UI SFX - Short - High #4.wav new file mode 100644 index 0000000..5eb2d78 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #4.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d11499b350e8e95990d298dc96ce01026973ccf5bd58891c9b505d3ba32b1a82 +size 582182 diff --git a/src/sounds/Classic UI SFX - Short - High #5.wav b/src/sounds/Classic UI SFX - Short - High #5.wav new file mode 100644 index 0000000..8cc1019 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #5.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e92d3e60c23ea4927444a28174a53fcb668d83f850df2494f53d5870613143a +size 499182 diff --git a/src/sounds/Classic UI SFX - Short - High #6.wav b/src/sounds/Classic UI SFX - Short - High #6.wav new file mode 100644 index 0000000..3e496cb --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #6.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ec68d1b1549fed1bccaf62ea4fd80c5c59b9b50750fa324b858e1c370344270 +size 466182 diff --git a/src/sounds/Classic UI SFX - Short - High #7.wav b/src/sounds/Classic UI SFX - Short - High #7.wav new file mode 100644 index 0000000..8fae195 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #7.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14f2dd1c42c354343f80c631721c8dceadb80b8e03ecec4e93294c8ffb21cfcf +size 474182 diff --git a/src/sounds/Classic UI SFX - Short - High #8.wav b/src/sounds/Classic UI SFX - Short - High #8.wav new file mode 100644 index 0000000..a25618d --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #8.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:683fc09fbf580efa6990504bd17861f48495555ac81f4ed2b9e85f14628348fe +size 560182 diff --git a/src/sounds/Classic UI SFX - Short - High #9.wav b/src/sounds/Classic UI SFX - Short - High #9.wav new file mode 100644 index 0000000..9ee3b5f --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #9.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b60cd47d7591fc5e2cf49c8139781479d3833d621bba129903d78843d7929380 +size 432182 diff --git a/src/sounds/Classic UI SFX - Short - Low #1.wav b/src/sounds/Classic UI SFX - Short - Low #1.wav new file mode 100644 index 0000000..dba2e40 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #1.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:419e970729e384fcba3a9c4bb3c55cec906dfd20f969c1cf0cbf2ffcbc00638e +size 386182 diff --git a/src/sounds/Classic UI SFX - Short - Low #10.wav b/src/sounds/Classic UI SFX - Short - Low #10.wav new file mode 100644 index 0000000..4eebb5b --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #10.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9749c07cacebb0bfe9924bbf8f0142630c96c0d4d5d0e727f5daeb354f9505a8 +size 580182 diff --git a/src/sounds/Classic UI SFX - Short - Low #11.wav b/src/sounds/Classic UI SFX - Short - Low #11.wav new file mode 100644 index 0000000..d4cbb0d --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #11.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4b7c87005eae8d0b0d329329135a67cd318a775cf2c75dd5ae68974537f9b7c +size 472182 diff --git a/src/sounds/Classic UI SFX - Short - Low #12.wav b/src/sounds/Classic UI SFX - Short - Low #12.wav new file mode 100644 index 0000000..65b3517 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #12.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96018d0ee84d1e27e0fc655c03d4afd3fa0839aecca09fe7ef2f0f104f424e60 +size 557182 diff --git a/src/sounds/Classic UI SFX - Short - Low #13.wav b/src/sounds/Classic UI SFX - Short - Low #13.wav new file mode 100644 index 0000000..ac2b8bf --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #13.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a3de21306c039abc81ace6a9e2e18f581c4993c532c8120126df055be7c9767 +size 546182 diff --git a/src/sounds/Classic UI SFX - Short - Low #14.wav b/src/sounds/Classic UI SFX - Short - Low #14.wav new file mode 100644 index 0000000..ce57f07 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #14.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:848195440e0a64566d175a62aa13685872fdb012bf491e5742984d5059524f5f +size 602182 diff --git a/src/sounds/Classic UI SFX - Short - Low #15.wav b/src/sounds/Classic UI SFX - Short - Low #15.wav new file mode 100644 index 0000000..e8218d5 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #15.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aee89025f7fb8517deb610d2814b941136fda09ebcbbacc78cddfe834c5b348a +size 519182 diff --git a/src/sounds/Classic UI SFX - Short - Low #16.wav b/src/sounds/Classic UI SFX - Short - Low #16.wav new file mode 100644 index 0000000..187b096 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #16.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a0bafde7f6a2b75589faac13b7d2b929239189ebc554c338fd2741e3ea46e78 +size 552182 diff --git a/src/sounds/Classic UI SFX - Short - Low #17.wav b/src/sounds/Classic UI SFX - Short - Low #17.wav new file mode 100644 index 0000000..4a4608d --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #17.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b2daee3db271eb4244dd40ea3abb5a7f036e69e637222f7a611c89a5f0a9a3e +size 562182 diff --git a/src/sounds/Classic UI SFX - Short - Low #18.wav b/src/sounds/Classic UI SFX - Short - Low #18.wav new file mode 100644 index 0000000..efbf4cc --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #18.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3bda2874577cb666819141de8eba8375bddcb07bf1e813d33ad718f6828227f1 +size 587182 diff --git a/src/sounds/Classic UI SFX - Short - Low #19.wav b/src/sounds/Classic UI SFX - Short - Low #19.wav new file mode 100644 index 0000000..6efa7e3 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #19.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f577ca8f4b9d172c1e8d10553451128ea424a0cd4e8fc2ba217f1703de74eedc +size 475182 diff --git a/src/sounds/Classic UI SFX - Short - Low #2.wav b/src/sounds/Classic UI SFX - Short - Low #2.wav new file mode 100644 index 0000000..07e9d9a --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #2.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36527b664e371bc952fa2ef6cd6f5479fcfd183144812091caf234be45dd8d3f +size 496182 diff --git a/src/sounds/Classic UI SFX - Short - Low #20.wav b/src/sounds/Classic UI SFX - Short - Low #20.wav new file mode 100644 index 0000000..c8d717c --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #20.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:598800de963759dbe0bca69f66facbe31546c0e39f76f0fe59950ac3dd8f1c08 +size 483182 diff --git a/src/sounds/Classic UI SFX - Short - Low #21.wav b/src/sounds/Classic UI SFX - Short - Low #21.wav new file mode 100644 index 0000000..ea90758 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #21.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca62b2181585cbc11f1c66ba1e1d83b2987e50f9a5adb236c9c80dc29a43675b +size 500182 diff --git a/src/sounds/Classic UI SFX - Short - Low #22.wav b/src/sounds/Classic UI SFX - Short - Low #22.wav new file mode 100644 index 0000000..3505329 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #22.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:49ad78c33edc1c0bcfdb5d9d2d6d559b7530403cec0d7ccfb770fb4fb3356527 +size 582182 diff --git a/src/sounds/Classic UI SFX - Short - Low #23.wav b/src/sounds/Classic UI SFX - Short - Low #23.wav new file mode 100644 index 0000000..5cac1d4 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #23.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3c1f824ac3a97213b56c1493b4493ef11d3292bff84eac4eeb9134b9546941c +size 564182 diff --git a/src/sounds/Classic UI SFX - Short - Low #24.wav b/src/sounds/Classic UI SFX - Short - Low #24.wav new file mode 100644 index 0000000..d3b0245 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #24.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb1948113b38e53a46ca720b3096a6b991c4dce76f9b3dbdec2268566587242b +size 501182 diff --git a/src/sounds/Classic UI SFX - Short - Low #25.wav b/src/sounds/Classic UI SFX - Short - Low #25.wav new file mode 100644 index 0000000..aa73af8 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #25.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44dc28db05d46c9eba8710a3f9fade5722551f7bbbd4318f391a78cfc2995538 +size 504182 diff --git a/src/sounds/Classic UI SFX - Short - Low #3.wav b/src/sounds/Classic UI SFX - Short - Low #3.wav new file mode 100644 index 0000000..9d62309 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #3.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b78146a7c13a9dc54279b0d6909ce7d33bd8521f53d8f70d5cc35941b04be055 +size 543182 diff --git a/src/sounds/Classic UI SFX - Short - Low #4.wav b/src/sounds/Classic UI SFX - Short - Low #4.wav new file mode 100644 index 0000000..42499ed --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #4.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfcb2172fc599b189deecc57f0ceaa8fa54059442194829e5ae5ef78bb4960a5 +size 502182 diff --git a/src/sounds/Classic UI SFX - Short - Low #5.wav b/src/sounds/Classic UI SFX - Short - Low #5.wav new file mode 100644 index 0000000..6092153 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #5.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d120f6502806f3b7c1259d89aa90a0cf851f7f4826d454bd34b740f40b73f59 +size 607182 diff --git a/src/sounds/Classic UI SFX - Short - Low #6.wav b/src/sounds/Classic UI SFX - Short - Low #6.wav new file mode 100644 index 0000000..8708e29 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #6.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70ba897bce8a0ec2ce6c0c2b16dfb2fd1b5cefa3eb00c152a489de35188bbf83 +size 448182 diff --git a/src/sounds/Classic UI SFX - Short - Low #7.wav b/src/sounds/Classic UI SFX - Short - Low #7.wav new file mode 100644 index 0000000..64a7cce --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #7.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d465d28fc3c3c9a1dc2992724b3a674ff4d31aa9721c222cd00434ba53f4086 +size 487182 diff --git a/src/sounds/Classic UI SFX - Short - Low #8.wav b/src/sounds/Classic UI SFX - Short - Low #8.wav new file mode 100644 index 0000000..0ed12c7 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #8.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62dc712e485e785716d944f0764c0588f00dd64185e4a205393f7a7df651ccfb +size 505182 diff --git a/src/sounds/Classic UI SFX - Short - Low #9.wav b/src/sounds/Classic UI SFX - Short - Low #9.wav new file mode 100644 index 0000000..18c0b1a --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #9.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2b0db41db5cddc7e8d6884c856b86f0b77e771958a1da9867455a6212407074 +size 518182 diff --git a/src/sounds/UI_Single_Set 16_01.ogg b/src/sounds/UI_Single_Set 16_01.ogg new file mode 100644 index 0000000..b6c70e0 --- /dev/null +++ b/src/sounds/UI_Single_Set 16_01.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d04ded8bae2d120e79e7969910c7016dc00ec8b22680ab4ed5842d257616c348 +size 6983 diff --git a/src/sounds/UI_Single_Set 16_02.ogg b/src/sounds/UI_Single_Set 16_02.ogg new file mode 100644 index 0000000..1cdb93a --- /dev/null +++ b/src/sounds/UI_Single_Set 16_02.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dee515a756b38ea169ebfabd91320a5d50873993765095920ec2f9e142dfd00d +size 7069 diff --git a/src/sounds/UI_Single_Set 16_03.ogg b/src/sounds/UI_Single_Set 16_03.ogg new file mode 100644 index 0000000..4bdfd18 --- /dev/null +++ b/src/sounds/UI_Single_Set 16_03.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8d972d72b3570f0638e094e84791ad0aa8a39b9d4806b8a5b441802a73bf3ac +size 7156 diff --git a/src/sounds/UI_TwoNote_Set 15_01.ogg b/src/sounds/UI_TwoNote_Set 15_01.ogg new file mode 100644 index 0000000..7d8a3c2 --- /dev/null +++ b/src/sounds/UI_TwoNote_Set 15_01.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad955c552cc1a111a9885fcb09f460e03f6b31ef9ecd1637a49970c0edc5fef2 +size 7624 diff --git a/src/sounds/UI_TwoNote_Set 15_02.ogg b/src/sounds/UI_TwoNote_Set 15_02.ogg new file mode 100644 index 0000000..6a5a4c9 --- /dev/null +++ b/src/sounds/UI_TwoNote_Set 15_02.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78e1b6c3fcff1f9f4a3a2247b8b835d26585b089ea36f21ba212d99ea6f60ba2 +size 7596 From a69147a4f73cf626b92622a8ee22b54f538d41a9 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Thu, 2 Apr 2026 14:20:30 +0300 Subject: [PATCH 29/65] feat: Implemented dolphin integration --- src/bun/api/cache.ts | 1 + .../api/games/services/launchGameService.ts | 22 ++++++++- src/bun/api/hooks/games.ts | 8 ++++ src/bun/api/jobs/emulator-download-job.ts | 29 +++++++++++ src/bun/api/jobs/update-store.ts | 2 +- .../dolphin.ts | 37 ++++++++++++++ .../package.json | 15 ++++++ .../pcsx2.ts | 8 ++++ .../ppsspp.ts | 9 ++++ src/bun/api/plugins/register-plugins.ts | 2 + .../api/store/services/emulatorsService.ts | 31 +++++++++--- src/bun/api/store/services/gamesService.ts | 9 +--- src/bun/api/store/store.ts | 2 +- src/mainview/components/Header.tsx | 48 ++++++++++--------- .../components/store/InvalidStoreError.tsx | 10 ++++ .../components/store/StoreEmulatorCard.tsx | 4 +- src/mainview/routes/game/$source.$id.tsx | 9 +--- src/mainview/routes/index.tsx | 4 +- src/mainview/routes/store/tab/emulators.tsx | 8 ++-- src/mainview/routes/store/tab/games.tsx | 4 +- src/mainview/scripts/queries/store.ts | 2 +- src/mainview/scripts/spatialNavigation.ts | 2 +- src/shared/constants.ts | 10 ++++ src/shared/types..d.ts | 3 +- 24 files changed, 220 insertions(+), 59 deletions(-) create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json create mode 100644 src/mainview/components/store/InvalidStoreError.tsx diff --git a/src/bun/api/cache.ts b/src/bun/api/cache.ts index 941ba7a..6aa465a 100644 --- a/src/bun/api/cache.ts +++ b/src/bun/api/cache.ts @@ -20,6 +20,7 @@ export async function getOrCached (key: string, getter: () => Promise, opt } const data = await getter(); + if (data === undefined) return data; const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000); diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index add3c59..894f6db 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -10,6 +10,8 @@ import { cores } from '../../emulatorjs/emulatorjs'; import { LaunchGameJob } from '../../jobs/launch-game-job'; import { EmulatorPackageType } from '@/shared/constants'; import { getStoreEmulatorPackage, getStoreFolder } from '../../store/services/gamesService'; +import { getOrCached } from '../../cache'; +import { getScoopPackage } from '../../store/services/emulatorsService'; export const varRegex = /%([^%]+)%/g; export const assignRegex = /(%\w+%)=(\S+) /g; @@ -285,11 +287,27 @@ export async function findStoreEmulatorExec (id: string, emulator?: { systempath const storeExecName = (await Promise.all(storeEmulator.downloads[`${process.platform}:${process.arch}`].map(async dl => { // glob file search causes issues so do manual search - const glob = new Glob(dl.pattern); if (await fs.exists(storeEmulatorFolder)) { + const glob = (dl as any).pattern ? new Glob((dl as any).pattern) : undefined; + let bin: string | undefined = (dl as any).bin; + if (!bin && dl.type === 'scoop') + { + const data = await getScoopPackage(id, dl.url); + + if (data) + { + bin = data.bin; + } + } + const files = (await fs.readdir(storeEmulatorFolder)) - .filter(f => glob.match(f)); + .filter(f => + { + if (glob && glob.match(f)) return true; + if (bin && f === bin) return true; + }); + return files.map(f => path.join(storeEmulatorFolder, f)); } return []; diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index ff3ec04..824c59c 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -18,6 +18,14 @@ export class GameHooks id: number; }; }], string[] | undefined>(['ctx']); + /** + * Is the given emulator for the given command supported + * @returns The possible value is if it can support it but not right now. To show grayed out icon. + */ + emulatorLaunchSupport = new SyncBailHook<[ctx: { + emulator: string; + source?: EmulatorSourceEntryType; + }], { id: string; possible: boolean; } | undefined>(['ctx']); /** * Fetches and returns a list of games converted to frontend. * @param ctx.localGameIds This is local game ids in the format '@' diff --git a/src/bun/api/jobs/emulator-download-job.ts b/src/bun/api/jobs/emulator-download-job.ts index f79db72..42d20b6 100644 --- a/src/bun/api/jobs/emulator-download-job.ts +++ b/src/bun/api/jobs/emulator-download-job.ts @@ -12,6 +12,7 @@ import { Downloader } from "@/bun/utils/downloader"; import { ensureDir, move } from "fs-extra"; import { simulateProgress } from "@/bun/utils"; import { path7za } from "7zip-bin"; +import { getScoopPackage } from "../store/services/emulatorsService"; type EmulatorDownloadStates = "download" | "extract"; @@ -55,6 +56,34 @@ export class EmulatorDownloadJob implements IJob await ensureDir(storeFolder); console.log("Updating Store"); - const proc = Bun.spawn([process.execPath, "add", `${this.packageName}@${this.storeVersion}`, "--production", "--registry", this.registry.href], { + const proc = Bun.spawn([process.execPath, "install", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], { cwd: storeFolder, stdout: 'pipe', stderr: 'pipe', diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts new file mode 100644 index 0000000..dc0e28d --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -0,0 +1,37 @@ + +import { config, db } from "@/bun/api/app"; +import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import path from 'node:path'; +import desc from './package.json'; + +export default class DOLPHINIntegration implements PluginType +{ + load (ctx: PluginContextType) + { + ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => + { + if (ctx.emulator === 'DOLPHIN') + return { id: desc.name, possible: !!ctx.source }; + }); + + ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => + { + if (ctx.autoValidCommand.emulator === 'DOLPHIN' && ctx.autoValidCommand.metadata.emulatorDir) + { + const args = ["--batch"]; + + const storageFolder = path.join(config.get('downloadPath'), "saves", 'DOLPHIN'); + + args.push(...[`--user=${storageFolder}`, `--exec=${ctx.autoValidCommand.metadata.romPath}`]); + args.push(`--config=Dolphin.Display.Fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); + args.push(`--config=Dolphin.General.ISOPath0=${path.join(config.get('downloadPath'), 'roms', 'gc')}`); + args.push(`--config=Dolphin.General.ISOPath1=${path.join(config.get('downloadPath'), 'roms', 'wii')}`); + args.push(`--config=Dolphin.Interface.ConfirmStop=False`); + args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`); + args.push(`--config=Dolphin.Analytics.PermissionAsked=True`); + + return args; + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json new file mode 100644 index 0000000..07fe38d --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json @@ -0,0 +1,15 @@ +{ + "name": "com.simeonradivoev.gameflow.dolphin", + "displayName": "DOLPHIN Integration", + "version": "0.0.1", + "description": "DOLPHIN Emulator Integration", + "main": "./dolphin.ts", + "icon": "https://upload.wikimedia.org/wikipedia/commons/5/53/Dolphin_Emulator_Logo_Refresh.svg", + "keywords": [ + "integration", + "emulator", + "wiiu", + "gc", + "dolphin" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index 072752b..e4c2cbd 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -11,6 +11,14 @@ export default class PCSX2Integration implements PluginType { load (ctx: PluginContextType) { + ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => + { + if (ctx.emulator === 'PCSX2') + { + return { id: desc.name, possible: ctx.source?.type === 'store' }; + } + }); + ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => { if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir) diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index 8384213..7fb3fd9 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -14,6 +14,15 @@ export default class PCSX2Integration implements PluginType { load (ctx: PluginContextType) { + + ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => + { + if (ctx.emulator === 'PPSSPP') + { + return { id: desc.name, possible: ctx.source?.type === 'store' }; + } + }); + ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => { if (ctx.autoValidCommand.emulator === 'PPSSPP' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir) diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts index 9f223b2..99b9d17 100644 --- a/src/bun/api/plugins/register-plugins.ts +++ b/src/bun/api/plugins/register-plugins.ts @@ -2,6 +2,7 @@ import { PluginManager } from "./plugin-manager"; import pcsx2 from './builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json'; import ppsspp from './builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json'; +import dolphin from './builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json'; import romm from './builtin/sources/com.simeonradivoev.gameflow.romm/package.json'; import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema"; @@ -11,6 +12,7 @@ export default async function register (pluginManager: PluginManager) const plugins: (PluginDescriptionType & { main: string; load: () => Promise; })[] = [ { ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') }, { ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') }, + { ...dolphin, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin') }, { ...romm, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.romm/romm') }, ]; diff --git a/src/bun/api/store/services/emulatorsService.ts b/src/bun/api/store/services/emulatorsService.ts index 1340731..d326fe6 100644 --- a/src/bun/api/store/services/emulatorsService.ts +++ b/src/bun/api/store/services/emulatorsService.ts @@ -1,8 +1,9 @@ -import { EmulatorPackageType } from "@/shared/constants"; +import { EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants"; import { emulatorsDb, plugins } from "../../app"; import * as emulatorSchema from '@schema/emulators'; import { findExecs } from "../../games/services/launchGameService"; import { eq } from "drizzle-orm"; +import { getOrCached } from "../../cache"; export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: EmulatorSystem[]) { @@ -21,16 +22,32 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT systems, gameCount, validSources: execPaths, - integration: findEmulatorPluginIntegration(emulator.name) + integration: findEmulatorPluginIntegration(emulator.name, execPaths) }; return em; } -export function findEmulatorPluginIntegration (name: string) +export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]) { - const lowerCaseName = name.toLowerCase(); - const integration = Object.entries(plugins.plugins).find(p => p[1].description.keywords?.includes(lowerCaseName)); - if (!integration) return undefined; - return { name: integration[0], version: integration[1].description.version }; + const hasSupport = validSources.concat(undefined).map(s => plugins.hooks.games.emulatorLaunchSupport.call({ emulator: name, source: s })).filter(s => !!s); + + if (hasSupport.length <= 0) return undefined; + return { name: hasSupport[0].id, version: plugins.plugins[hasSupport[0].id]?.description.version, possible: hasSupport.some(s => s.possible) }; +} + +export async function getScoopPackage (id: string, url: string) +{ + const data = await getOrCached(`scoop-dl-${id}`, async () => + { + const res = await fetch(url); + if (res.ok) + { + return ScoopPackageSchema.parseAsync(await res.json()); + } + console.error(res.statusText); + return undefined; + }); + + return data; } \ No newline at end of file diff --git a/src/bun/api/store/services/gamesService.ts b/src/bun/api/store/services/gamesService.ts index ae0181f..99aa15a 100644 --- a/src/bun/api/store/services/gamesService.ts +++ b/src/bun/api/store/services/gamesService.ts @@ -101,14 +101,7 @@ export async function getAllStoreEmulatorPackages () const emulators = await fs.readdir(emulatorsBucket); const emulatorsRawData = await Promise.all(emulators.map(e => fs.readFile(path.join(emulatorsBucket, e), 'utf-8'))); - const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.safeParse(JSON.parse(d))).filter(e => - { - if (e.error) - { - console.error(e.error); - } - return e.data; - }).map(e => e.data!); + const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.parse(JSON.parse(d))); return emulatesParsed; } diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index d29746d..074d8bd 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -148,7 +148,7 @@ export const store = new Elysia({ prefix: '/api/store' }) sources: execPaths, biosRequirement: emulatorPackage.bios, bios: biosFiles, - integration: findEmulatorPluginIntegration(emulatorPackage.name) + integration: findEmulatorPluginIntegration(emulatorPackage.name, execPaths) }; return emulator; diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index 76b87b6..cc17aba 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -70,6 +70,7 @@ export interface HeaderButton icon: JSX.Element; external?: boolean; action?: () => void; + className?: string; } export interface HeaderAccount @@ -247,25 +248,28 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; }) { - return
    -
    - - - - - -
    - {!!data.buttons &&
    } -
    - {data.buttonElements ?? data.buttons?.map(b => {b.icon})} -
    + const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' }); + return
    + +
    + + + + + +
    + {!!data.buttons &&
    } +
    + {data.buttonElements ?? data.buttons?.map(b => {b.icon})} +
    +
    ; } @@ -296,13 +300,13 @@ export function HeaderUI (data: HeaderUIParams) > {data.title} - , id: "settings", action: goToSettings, external: true }]} /> + , id: "header-settings-btn", action: goToSettings, external: true }]} /> ); } -export function StickyHeaderUI (data: { ref: RefObject; } & HeaderUIParams) +export function StickyHeaderUI (data: { ref: RefObject; className?: string; } & HeaderUIParams) { const [isStuck, setIsStuck] = useState(false); const headerRef = useRef(null); @@ -311,7 +315,7 @@ export function StickyHeaderUI (data: { ref: RefObject; } & HeaderUIParams) return <>
    -
    +
    ; diff --git a/src/mainview/components/store/InvalidStoreError.tsx b/src/mainview/components/store/InvalidStoreError.tsx new file mode 100644 index 0000000..646721d --- /dev/null +++ b/src/mainview/components/store/InvalidStoreError.tsx @@ -0,0 +1,10 @@ +import { ErrorComponentProps } from "@tanstack/react-router"; +import { TriangleAlert } from "lucide-react"; + +export default function Error (data: ErrorComponentProps) +{ + return
    +
    Invalid Store. Update App.
    +
    {data.error.message}
    +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index 202c38c..2102503 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -81,8 +81,8 @@ export function StoreEmulatorCard (data: {
    - {!!data.emulator.integration && data.emulator.validSources.some(s => s.type === 'store') &&
    -
    + {!!data.emulator.integration &&
    +
    } {data.emulator.validSources.slice(0, 3).map(s => { diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 7147e79..ec57e71 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -3,7 +3,7 @@ import { RPC_URL } from "@shared/constants"; import { useEffect, useRef, useState } from "react"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { Calendar, Folder, Gamepad2, Image, Info, TriangleAlert, Trophy } from "lucide-react"; -import { HeaderUI } from "../../components/Header"; +import { HeaderUI, StickyHeaderUI } from "../../components/Header"; import { AnimatedBackground } from "../../components/AnimatedBackground"; import { useQuery } from "@tanstack/react-query"; import Shortcuts from "../../components/Shortcuts"; @@ -146,7 +146,6 @@ function RouteComponent () const [, setUpdate] = useState(0); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details", forceFocus: true }); const headerRef = useRef(null); - const sentinelRef = useRef(null); const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_cover}`) : undefined; const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible }); @@ -158,7 +157,6 @@ function RouteComponent () const { shortcuts } = useShortcutContext(); - useStickyDataAttr(headerRef, sentinelRef, ref); const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists)); const { ref: intersct } = useIntersectionObserver({ @@ -176,10 +174,7 @@ function RouteComponent () }} >
    -
    -
    - -
    +
    diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index d285464..fade167 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -315,8 +315,8 @@ export default function ConsoleHomeUI () headerButtons.push({ id: "fullscreen", icon: , action: handleFullscreen }); headerButtons.push( { id: "search-header-button", icon: }, - { id: "power-button", icon: , external: true, action: () => close.mutate() }, - { id: "settings-header-button", icon: , external: true, action: () => Router.navigate({ to: "/settings/accounts" }) } + { id: "power-button", icon: , external: true, action: () => close.mutate(), className: "focusable-error!" }, + { id: "settings-header-button", icon: , external: true, action: () => router.navigate({ to: "/settings/accounts" }) } ); return ( diff --git a/src/mainview/routes/store/tab/emulators.tsx b/src/mainview/routes/store/tab/emulators.tsx index 7fd1e0f..7d1aafd 100644 --- a/src/mainview/routes/store/tab/emulators.tsx +++ b/src/mainview/routes/store/tab/emulators.tsx @@ -1,7 +1,7 @@ -import { createFileRoute, useSearch } from '@tanstack/react-router'; -import { Joystick } from 'lucide-react'; +import { createFileRoute, ErrorComponentProps, useSearch } from '@tanstack/react-router'; +import { Joystick, TriangleAlert } from 'lucide-react'; import { useContext, useEffect } from 'react'; import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard'; @@ -9,9 +9,11 @@ import { StoreContext } from '@/mainview/scripts/contexts'; import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import { useQuery } from '@tanstack/react-query'; import { storeEmulatorsQuery } from '@queries/store'; +import InvalidStoreError from '@/mainview/components/store/InvalidStoreError'; export const Route = createFileRoute('/store/tab/emulators')({ component: RouteComponent, + errorComponent: InvalidStoreError }); function RouteComponent () @@ -22,7 +24,7 @@ function RouteComponent () preferredChildFocusKey: focus }); const storeContext = useContext(StoreContext); - const { data: emulators } = useQuery(storeEmulatorsQuery); + const { data: emulators } = useQuery({ ...storeEmulatorsQuery, retry: false, throwOnError: true }); useEffect(() => { diff --git a/src/mainview/routes/store/tab/games.tsx b/src/mainview/routes/store/tab/games.tsx index 7bbf93e..7aee585 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -8,9 +8,11 @@ import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import LoadMoreButton from '@/mainview/components/LoadMoreButton'; import { storeGamesInfiniteQuery } from '@queries/store'; import { StoreContext } from '@/mainview/scripts/contexts'; +import InvalidStoreError from '@/mainview/components/store/InvalidStoreError'; export const Route = createFileRoute('/store/tab/games')({ - component: RouteComponent + component: RouteComponent, + errorComponent: InvalidStoreError }); function RouteComponent () diff --git a/src/mainview/scripts/queries/store.ts b/src/mainview/scripts/queries/store.ts index e7b84f1..bdd6337 100644 --- a/src/mainview/scripts/queries/store.ts +++ b/src/mainview/scripts/queries/store.ts @@ -6,7 +6,7 @@ export const storeEmulatorsQuery = queryOptions({ queryKey: ['store-emulators'], queryFn: async () => { const { data, error } = await storeApi.api.store.emulators.get(); - if (error) throw error; + if (error) throw new Error(JSON.stringify(error.value)); return data; } }); diff --git a/src/mainview/scripts/spatialNavigation.ts b/src/mainview/scripts/spatialNavigation.ts index 3129f2a..51d212a 100644 --- a/src/mainview/scripts/spatialNavigation.ts +++ b/src/mainview/scripts/spatialNavigation.ts @@ -107,7 +107,7 @@ SpatialNavigation.setCurrentFocusedKey = (newFocusKey, focusDetails) => node: GetFocusedElement(newFocusKey) }; setCurrentFocusedKey(newFocusKey, focusDetails); - window.dispatchEvent(new CustomEvent('focuschanged', { + (GetFocusedElement(newFocusKey) ?? window).dispatchEvent(new CustomEvent('focuschanged', { bubbles: true, detail: details })); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index acced4c..33531f7 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -108,12 +108,22 @@ export const EmulatorPackageSchema = z.object({ z.object({ type: z.literal('direct'), url: z.url(), + }), + z.object({ + type: z.literal('scoop'), + url: z.url(), }) ]))).optional(), systems: z.array(z.string()), bios: z.literal(["required", "optional"]).optional() }); +export const ScoopPackageSchema = z.object({ + version: z.string(), + url: z.url().optional(), + architecture: z.record(z.string(), z.object({ url: z.url(), hash: z.string().optional() })).optional() +}); + export const SystemInfoSchema = z.object({ battery: z.object({ percent: z.number(), diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index 760fc7f..7bc8ba7 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -18,7 +18,8 @@ declare interface FrontEndEmulator validSources: EmulatorSourceEntryType[]; integration?: { name: string; - version: string; + version?: string; + possible: boolean; }; } From 34db717ec5cbcf8b1ae54fbda33bf9a78f01bd17 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Fri, 3 Apr 2026 23:02:22 +0300 Subject: [PATCH 30/65] feat: Implemented emulator versions and updating --- src/bun/api/games/games.ts | 6 +- .../api/games/services/launchGameService.ts | 4 +- src/bun/api/hooks/emulators.ts | 14 +- src/bun/api/hooks/games.ts | 8 +- src/bun/api/jobs/emulator-download-job.ts | 79 +++-------- src/bun/api/jobs/update-store.ts | 26 +++- .../dolphin.ts | 7 +- .../pcsx2.ts | 62 +++++---- .../ppsspp.ts | 88 +++++++----- src/bun/api/settings/services.ts | 7 +- .../api/store/services/emulatorsService.ts | 129 ++++++++++++++++-- src/bun/api/store/store.ts | 46 ++++--- src/mainview/components/FocusTooltip.tsx | 7 +- src/mainview/components/StatList.tsx | 10 +- src/mainview/components/game/ActionButton.tsx | 2 +- src/mainview/components/game/MainActions.tsx | 2 +- src/mainview/components/options/Button.tsx | 4 +- .../components/store/StoreEmulatorCard.tsx | 31 +++-- .../routes/store/details.emulator.$id.tsx | 61 +++++++-- src/mainview/scripts/queries/store.ts | 12 +- src/shared/constants.ts | 22 ++- src/shared/types..d.ts | 19 ++- 22 files changed, 434 insertions(+), 212 deletions(-) diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 9666d44..73b9e1f 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -303,7 +303,8 @@ export default new Elysia() validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }], logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, systems: [], - gameCount: 0 + gameCount: 0, + integrations: [] } satisfies FrontEndGameTypeDetailedEmulator; } else @@ -313,7 +314,8 @@ export default new Elysia() logo: "", systems: [], gameCount: 0, - validSources: [] + validSources: [], + integrations: [] } satisfies FrontEndGameTypeDetailedEmulator; } diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index 894f6db..a91e6ca 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -11,7 +11,7 @@ import { LaunchGameJob } from '../../jobs/launch-game-job'; import { EmulatorPackageType } from '@/shared/constants'; import { getStoreEmulatorPackage, getStoreFolder } from '../../store/services/gamesService'; import { getOrCached } from '../../cache'; -import { getScoopPackage } from '../../store/services/emulatorsService'; +import { getOrCachedScoopPackage } from '../../store/services/emulatorsService'; export const varRegex = /%([^%]+)%/g; export const assignRegex = /(%\w+%)=(\S+) /g; @@ -293,7 +293,7 @@ export async function findStoreEmulatorExec (id: string, emulator?: { systempath let bin: string | undefined = (dl as any).bin; if (!bin && dl.type === 'scoop') { - const data = await getScoopPackage(id, dl.url); + const data = await getOrCachedScoopPackage(id, dl.url); if (data) { diff --git a/src/bun/api/hooks/emulators.ts b/src/bun/api/hooks/emulators.ts index f48ea9f..ed2d742 100644 --- a/src/bun/api/hooks/emulators.ts +++ b/src/bun/api/hooks/emulators.ts @@ -1,4 +1,5 @@ -import { AsyncSeriesBailHook } from "tapable"; +import { EmulatorDownloadInfoType, EmulatorPackageType } from "@/shared/constants"; +import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; export class EmulatorHooks { @@ -7,4 +8,15 @@ export class EmulatorHooks systems: EmulatorSystem[]; biosFolder: string; }], { auth?: string, files: DownloadFileEntry[]; } | undefined>(['ctx']); + + /** + * Triggered when emulator is downloaded or updated + */ + emulatorPostInstall = new AsyncSeriesHook<[ctx: { + emulator: string; + emulatorPackage?: EmulatorPackageType; + path: string; + update: boolean; + info: EmulatorDownloadInfoType; + }]>(['ctx']); } \ No newline at end of file diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index 824c59c..ea50476 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -1,5 +1,5 @@ import { EmulatorPackageType, GameListFilterType } from '@/shared/constants'; -import { SyncBailHook, AsyncSeriesHook, SyncWaterfallHook, AsyncSeriesBailHook, AsyncHook, AsyncParallelHook, SyncHook, AsyncSeriesWaterfallHook } from 'tapable'; +import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable'; export class GameHooks { @@ -13,6 +13,7 @@ export class GameHooks */ emulatorLaunch = new AsyncSeriesBailHook<[ctx: { autoValidCommand: CommandEntry; + dryRun: boolean, game: { source: string; id: number; @@ -20,12 +21,13 @@ export class GameHooks }], string[] | undefined>(['ctx']); /** * Is the given emulator for the given command supported - * @returns The possible value is if it can support it but not right now. To show grayed out icon. + * @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects. + * */ emulatorLaunchSupport = new SyncBailHook<[ctx: { emulator: string; source?: EmulatorSourceEntryType; - }], { id: string; possible: boolean; } | undefined>(['ctx']); + }], EmulatorSupport | undefined>(['ctx']); /** * Fetches and returns a list of games converted to frontend. * @param ctx.localGameIds This is local game ids in the format '@' diff --git a/src/bun/api/jobs/emulator-download-job.ts b/src/bun/api/jobs/emulator-download-job.ts index 42d20b6..74e13d2 100644 --- a/src/bun/api/jobs/emulator-download-job.ts +++ b/src/bun/api/jobs/emulator-download-job.ts @@ -2,17 +2,15 @@ import { EmulatorPackageType } from "@/shared/constants"; import { getStoreEmulatorPackage } from "../store/services/gamesService"; import { IJob, JobContext } from "../task-queue"; import z from "zod"; -import { Glob } from "bun"; -import { config } from "../app"; +import { config, plugins } from "../app"; import path from 'node:path'; -import { getOrCachedGithubRelease } from "../cache"; import Seven from 'node-7z'; import fs from "node:fs/promises"; import { Downloader } from "@/bun/utils/downloader"; import { ensureDir, move } from "fs-extra"; import { simulateProgress } from "@/bun/utils"; import { path7za } from "7zip-bin"; -import { getScoopPackage } from "../store/services/emulatorsService"; +import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService"; type EmulatorDownloadStates = "download" | "extract"; @@ -23,73 +21,24 @@ export class EmulatorDownloadJob implements IJob, EmulatorDownloadStates>) { this.emulatorPackage = await getStoreEmulatorPackage(this.emulator); if (!this.emulatorPackage) throw new Error("Emulator not found"); - if (!this.emulatorPackage.downloads) throw new Error("Emulator has no downloads"); + const { url, info } = await getEmulatorDownload(this.emulatorPackage, this.downloadSource); - const validDownloads = this.emulatorPackage.downloads[`${process.platform}:${process.arch}`]; - if (!validDownloads) throw new Error(`Now downloads in ${this.emulatorPackage.name} for platform ${process.platform}:${process.arch}`); - - const validDownload = validDownloads.find(d => d.type === this.downloadSource); - if (!validDownload) throw new Error(`Download type ${this.downloadSource} not found`); - - let downloadUrl: URL; - if (validDownload.type === 'github') - { - console.log("Trying To Download from ", `https://api.github.com/repos/${validDownload.path}/releases/latest`); - const latestRelease = await getOrCachedGithubRelease(validDownload.path); - const glob = new Glob(validDownload.pattern); - const validAsset = latestRelease.assets.find(a => glob.match(a.name)); - if (!validAsset) throw new Error("Could Not Find Valid Asset"); - downloadUrl = new URL(validAsset.browser_download_url); - } else if (validDownload.type === 'direct') - { - downloadUrl = new URL(validDownload.url); - } else if (validDownload.type === 'scoop') - { - const data = await getScoopPackage(this.emulator, validDownload.url); - let scoopDownload: URL | undefined; - if (data) - { - if (data.url) - { - scoopDownload = new URL(data.url); - } else if (data.architecture) - { - if (process.arch === 'x64' && data.architecture["64bit"]) - { - scoopDownload = new URL(data.architecture["64bit"].url); - } else if (process.arch === "arm64" && data.architecture["arm64"]) - { - scoopDownload = new URL(data.architecture["arm64"].url); - } - } - } - - if (scoopDownload) - { - downloadUrl = scoopDownload; - } else - { - throw new Error("Could not find scoop download"); - } - } else - { - throw new Error("Download Type Unsupported"); - } - - const emulatorsFolder = path.join(config.get('downloadPath'), "emulators", this.emulator); + const emulatorsFolder = getEmulatorPath(this.emulator); if (this.dryRun) { @@ -99,7 +48,7 @@ export class EmulatorDownloadJob implements IJob const storeFolder = getStoreRootFolder(); await ensureDir(storeFolder); - console.log("Updating Store"); - const proc = Bun.spawn([process.execPath, "install", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], { + 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', @@ -40,9 +40,27 @@ export default class UpdateStoreJob implements IJob } }); - const stdout = await new Response(proc.stdout).text(); + let stdout = await new Response(proc.stdout).text(); console.log(stdout); - const stderr = await new Response(proc.stderr).text(); + 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; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index dc0e28d..af12a7d 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -11,7 +11,12 @@ export default class DOLPHINIntegration implements PluginType ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => { if (ctx.emulator === 'DOLPHIN') - return { id: desc.name, possible: !!ctx.source }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] }; + }); + + ctx.hooks.emulators.emulatorPostInstall.tapPromise(desc.name, async (ctx) => + { + await Bun.write(path.join(ctx.path, "portable.txt"), ""); }); ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index e4c2cbd..c2de7e3 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -15,13 +15,26 @@ export default class PCSX2Integration implements PluginType { if (ctx.emulator === 'PCSX2') { - return { id: desc.name, possible: ctx.source?.type === 'store' }; + const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; + + if (ctx.source?.type === 'store') + { + return { + id: desc.name, + supportLevel: "full", + capabilities: [...baseCapabilities, "resolution", "config"] + }; + } + else + { + return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; + } } }); ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => { - if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir) + if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.metadata.emulatorDir) { const args = ["-batch"]; if (config.get('launchInFullscreen')) @@ -30,32 +43,35 @@ export default class PCSX2Integration implements PluginType } args.push(...["-bigpicture", "-portable", "--", ctx.autoValidCommand.metadata.romPath]); - const configFileContents = await Bun.file(configFile).text(); + if (ctx.autoValidCommand.emulatorSource === 'store' && !ctx.dryRun) + { + const configFileContents = await Bun.file(configFile).text(); - const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2'); - const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); - const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); + const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2'); + const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); + const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); - const view = { - BIOS_PATH: biosFolder, - SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'), - SAVE_STATES_PATH: path.join(savesFolder, 'states'), - MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'), - CACHE_PATH: path.join(storageFolder, 'cache'), - COVERS_PATH: path.join(storageFolder, 'covers'), - TEXTURES_PATH: path.join(storageFolder, 'textures'), - RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), - }; + const view = { + BIOS_PATH: biosFolder, + SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'), + SAVE_STATES_PATH: path.join(savesFolder, 'states'), + MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'), + CACHE_PATH: path.join(storageFolder, 'cache'), + COVERS_PATH: path.join(storageFolder, 'covers'), + TEXTURES_PATH: path.join(storageFolder, 'textures'), + RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), + }; - await Promise.all(Object.values(view).map(p => ensureDir(p))); + await Promise.all(Object.values(view).map(p => ensureDir(p))); - let pscx2Path = ''; - if (process.platform === 'win32') - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); - else - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis'); + let pscx2Path = ''; + if (process.platform === 'win32') + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); + else + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis'); - await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); + await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); + } return args; } diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index 7fb3fd9..fddf25c 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -14,18 +14,31 @@ export default class PCSX2Integration implements PluginType { load (ctx: PluginContextType) { - ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => { if (ctx.emulator === 'PPSSPP') { - return { id: desc.name, possible: ctx.source?.type === 'store' }; + const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; + + if (ctx.source?.type === 'store') + { + return { + id: desc.name, + supportLevel: "full", + capabilities: [...baseCapabilities, "resolution", "config"] + }; + } + else + { + return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; + } + } }); ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => { - if (ctx.autoValidCommand.emulator === 'PPSSPP' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir) + if (ctx.autoValidCommand.emulator === 'PPSSPP' && ctx.autoValidCommand.metadata.emulatorDir) { const args = [ctx.autoValidCommand.metadata.romPath, "--escape-exit", "--pause-menu-exit"]; if (config.get('launchInFullscreen')) @@ -33,44 +46,47 @@ export default class PCSX2Integration implements PluginType args.push("--fullscreen"); } - let confPath: string | undefined = undefined; - let controlsPath: string | undefined = undefined; - - switch (process.platform) + if (ctx.autoValidCommand.emulatorSource === 'store' && !ctx.dryRun) { - case "win32": - confPath = configFilePathWin32; - controlsPath = configControlsFilePathWin32; - break; - case 'linux': - confPath = configFilePathLinux; - controlsPath = configControlsFilePathLinux; - break; - } + let confPath: string | undefined = undefined; + let controlsPath: string | undefined = undefined; - let ppssppPath = ''; - if (process.platform === 'win32') - { - ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'); - } else - { - //TODO: Use way to set custom memstick path when they support it - ensureDir(path.join(homedir(), '.config', 'ppsspp')); - ppssppPath = path.join(homedir(), '.config', 'ppsspp', 'PSP', 'SYSTEM'); - } + switch (process.platform) + { + case "win32": + confPath = configFilePathWin32; + controlsPath = configControlsFilePathWin32; + break; + case 'linux': + confPath = configFilePathLinux; + controlsPath = configControlsFilePathLinux; + break; + } - ensureDir(ppssppPath); + let ppssppPath = ''; + if (process.platform === 'win32') + { + ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'); + } else + { + //TODO: Use way to set custom memstick path when they support it + ensureDir(path.join(homedir(), '.config', 'ppsspp')); + ppssppPath = path.join(homedir(), '.config', 'ppsspp', 'PSP', 'SYSTEM'); + } - if (confPath) - { - const configFileContents = await Bun.file(confPath).text(); - await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {})); - } + ensureDir(ppssppPath); - if (controlsPath) - { - const controlsFileContents = await Bun.file(controlsPath).text(); - await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); + if (confPath) + { + const configFileContents = await Bun.file(confPath).text(); + await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {})); + } + + if (controlsPath) + { + const controlsFileContents = await Bun.file(controlsPath).text(); + await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); + } } return args; diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index afaa5fe..7b7a89e 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -7,6 +7,7 @@ import { cores } from '../emulatorjs/emulatorjs'; import { SERVER_URL } from '@/shared/constants'; import { findExecsByName } from '../games/services/launchGameService'; import { host } from '@/bun/utils/host'; +import { findEmulatorPluginIntegration } from '../store/services/emulatorsService'; /** * Get emulators based on local games. Only the ones we probably need. @@ -73,7 +74,8 @@ export async function getRelevantEmulators () systems: systems.map(s => platformLookup.get(s)).filter(s => !!s).map(e => ({ iconUrl: `/api/romm/image/romm/assets/platforms/${e.es_slug}.svg`, name: e.platform_name ?? 'Unknown', id: e.es_slug ?? '' })), gameCount: 0, isCritical: false, - validSources: execPaths + validSources: execPaths, + integrations: findEmulatorPluginIntegration(emulator, execPaths) }; return em; @@ -86,7 +88,8 @@ export async function getRelevantEmulators () systems: [], gameCount: 0, isCritical: false, - description: "Embedded Emulator. Uses Retroarch Cores" + description: "Embedded Emulator. Uses Retroarch Cores", + integrations: [] }); return finalEmulators.map(e => diff --git a/src/bun/api/store/services/emulatorsService.ts b/src/bun/api/store/services/emulatorsService.ts index d326fe6..1682ecb 100644 --- a/src/bun/api/store/services/emulatorsService.ts +++ b/src/bun/api/store/services/emulatorsService.ts @@ -1,9 +1,11 @@ -import { EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants"; -import { emulatorsDb, plugins } from "../../app"; +import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants"; +import { config, emulatorsDb, plugins } from "../../app"; import * as emulatorSchema from '@schema/emulators'; import { findExecs } from "../../games/services/launchGameService"; import { eq } from "drizzle-orm"; -import { getOrCached } from "../../cache"; +import { getOrCached, getOrCachedGithubRelease } from "../../cache"; +import path from "node:path"; +import fs from "node:fs/promises"; export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: EmulatorSystem[]) { @@ -22,21 +24,130 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT systems, gameCount, validSources: execPaths, - integration: findEmulatorPluginIntegration(emulator.name, execPaths) + integrations: findEmulatorPluginIntegration(emulator.name, execPaths) }; return em; } -export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]) +export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]): EmulatorSupport[] { - const hasSupport = validSources.concat(undefined).map(s => plugins.hooks.games.emulatorLaunchSupport.call({ emulator: name, source: s })).filter(s => !!s); + const hasSupport = validSources.concat(undefined).map(s => + { + const support = plugins.hooks.games.emulatorLaunchSupport.call({ emulator: name, source: s }); + if (support) + { + return { ...support, source: s }; + } - if (hasSupport.length <= 0) return undefined; - return { name: hasSupport[0].id, version: plugins.plugins[hasSupport[0].id]?.description.version, possible: hasSupport.some(s => s.possible) }; + return undefined; + }).filter(s => !!s); + + if (hasSupport.length <= 0) return []; + return hasSupport; } -export async function getScoopPackage (id: string, url: string) +export function getEmulatorPath (emulator: string) +{ + return path.join(config.get('downloadPath'), "emulators", emulator); +} + +export async function getExistingStoreEmulatorDownload (emulator: EmulatorPackageType): Promise<(EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined> +{ + const existingPackagePath = `${getEmulatorPath(emulator.name)}.json`; + if (await fs.exists(existingPackagePath)) + { + const existingPackage = await EmulatorDownloadInfoSchema.parseAsync(await Bun.file(existingPackagePath).json()); + const download = await getEmulatorDownload(emulator, existingPackage.type).catch(d => undefined); + if (!download) return { ...existingPackage, hasUpdate: false }; + if (download.info.version) + { + if (existingPackage.version !== download.info.version) return { ...existingPackage, hasUpdate: true }; + } else if (existingPackage.id !== download.info.id) + { + return { ...existingPackage, hasUpdate: true }; + } + + return { ...existingPackage, hasUpdate: false }; + } + + // this should only happen if download info is missing maybe manually deleted or wasn't saved. + return undefined; +} + +export async function getEmulatorDownload (emulator: EmulatorPackageType, source: string) +{ + if (!emulator.downloads) throw new Error("Emulator has no downloads"); + + const validDownloads = emulator.downloads[`${process.platform}:${process.arch}`]; + if (!validDownloads) throw new Error(`Now downloads in ${emulator.name} for platform ${process.platform}:${process.arch}`); + + const validDownload = validDownloads.find(d => d.type === source); + if (!validDownload) throw new Error(`Download type ${source} not found`); + + let downloadUrl: URL; + let versionInfo: EmulatorDownloadInfoType = { + id: "", + downloadDate: new Date(), + type: validDownload.type + }; + if (validDownload.type === 'github') + { + const latestRelease = await getOrCachedGithubRelease(validDownload.path); + const glob = new Bun.Glob(validDownload.pattern); + const validAsset = latestRelease.assets.find(a => glob.match(a.name)); + if (!validAsset) throw new Error("Could Not Find Valid Asset"); + downloadUrl = new URL(validAsset.browser_download_url); + versionInfo.version = latestRelease.tag_name; + versionInfo.url = latestRelease.url; + versionInfo.id = String(latestRelease.id); + versionInfo.description = latestRelease.body; + + } else if (validDownload.type === 'direct') + { + downloadUrl = new URL(validDownload.url); + versionInfo.id = validDownload.url; + versionInfo.url = validDownload.url; + } else if (validDownload.type === 'scoop') + { + const data = await getOrCachedScoopPackage(emulator.name, validDownload.url); + let scoopDownload: URL | undefined; + if (data) + { + if (data.url) + { + scoopDownload = new URL(data.url); + } else if (data.architecture) + { + if (process.arch === 'x64' && data.architecture["64bit"]) + { + scoopDownload = new URL(data.architecture["64bit"].url); + } else if (process.arch === "arm64" && data.architecture["arm64"]) + { + scoopDownload = new URL(data.architecture["arm64"].url); + } + } + } + + if (scoopDownload) + { + downloadUrl = scoopDownload; + versionInfo.version = data?.version; + versionInfo.url = data?.url; + versionInfo.description = data?.description; + } else + { + throw new Error("Could not find scoop download"); + } + } else + { + throw new Error("Download Type Unsupported"); + } + + return { url: downloadUrl, info: versionInfo }; +} + +export async function getOrCachedScoopPackage (id: string, url: string) { const data = await getOrCached(`scoop-dl-${id}`, async () => { diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 074d8bd..2736fe2 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -3,17 +3,16 @@ import Elysia, { status } from "elysia"; import { config, db, taskQueue } from "../app"; import path from "node:path"; import fs from 'node:fs/promises'; -import { StoreGameSchema } from "@/shared/constants"; +import { EmulatorDownloadInfoSchema, StoreGameSchema } from "@/shared/constants"; import { findExecsByName } from "../games/services/launchGameService"; import * as appSchema from '@schema/app'; import z from "zod"; import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; import { getPlatformsApiPlatformsGet } from "@/clients/romm"; -import { CACHE_KEYS, getOrCached, getOrCachedGithubRelease } from "../cache"; +import { CACHE_KEYS, getOrCached } from "../cache"; import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "./services/gamesService"; import { EmulatorDownloadJob } from "../jobs/emulator-download-job"; -import { Glob } from "bun"; -import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration } from "./services/emulatorsService"; +import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration, getEmulatorDownload, getExistingStoreEmulatorDownload } from "./services/emulatorsService"; import { BiosDownloadJob } from "../jobs/bios-download-job"; export const store = new Elysia({ prefix: '/api/store' }) @@ -107,6 +106,15 @@ export const store = new Elysia({ prefix: '/api/store' }) return Bun.file(path.join(getStoreFolder(), "media", "screenshots", id, name)); }, { params: z.object({ id: z.string(), name: z.string() }) }) + .get('/emulator/:id/update', async ({ params: { id } }) => + { + const emulatorPackage = await getStoreEmulatorPackage(id); + const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage!); + return downloadInfo; + }, + { + response: z.union([z.intersection(EmulatorDownloadInfoSchema, z.object({ hasUpdate: z.boolean() })), z.undefined()]) + }) .get('/emulator/:id', async ({ params: { id } }) => { const emulatorPackage = await getStoreEmulatorPackage(id); @@ -120,6 +128,7 @@ export const store = new Elysia({ prefix: '/api/store' }) const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : []; const biosDirPath = path.join(config.get('downloadPath'), 'bios', id); const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : []; + const storeDownloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage); const emulator: FrontEndEmulatorDetailed = { name: emulatorPackage.name, @@ -129,38 +138,31 @@ export const store = new Elysia({ prefix: '/api/store' }) screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`), gameCount: 0, homepage: emulatorPackage.homepage, - downloads: await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d => + downloads: (await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d => { - if (d.type === 'github' && d.path) - { - const release = await getOrCachedGithubRelease(d.path); - const glob = new Glob(d.pattern); - const download: FrontEndEmulatorDetailedDownload = { - name: d.type, - type: release.assets.find(a => glob.match(a.name))?.content_type - }; - return download; - }; - - return { name: d.type, type: "Unknown" }; - }) ?? []), + const download = await getEmulatorDownload(emulatorPackage, d.type).catch(e => undefined); + return download?.info; + }) ?? [])).filter(d => !!d).map(d => ({ name: d.type, type: d.type, version: d.version })), logo: emulatorPackage.logo, - sources: execPaths, biosRequirement: emulatorPackage.bios, bios: biosFiles, - integration: findEmulatorPluginIntegration(emulatorPackage.name, execPaths) + integrations: findEmulatorPluginIntegration(emulatorPackage.name, execPaths), + storeDownloadInfo: storeDownloadInfo, + hasUpdate: storeDownloadInfo?.hasUpdate ?? null }; return emulator; }, { params: z.object({ id: z.string() }) }) - .post('/install/emulator/:id/:source', async ({ params: { source, id } }) => + .post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) => { if (taskQueue.hasActiveOfType(EmulatorDownloadJob)) { return status("Conflict", "Installation already running"); } - const job = new EmulatorDownloadJob(id, source); + const job = new EmulatorDownloadJob(id, source, { isUpdate }); return taskQueue.enqueue(EmulatorDownloadJob.id, job); + }, { + body: z.object({ isUpdate: z.boolean().optional() }) }) .delete('/emulator/:id', async ({ params: { id } }) => { diff --git a/src/mainview/components/FocusTooltip.tsx b/src/mainview/components/FocusTooltip.tsx index 5a165b1..6916c1e 100644 --- a/src/mainview/components/FocusTooltip.tsx +++ b/src/mainview/components/FocusTooltip.tsx @@ -12,7 +12,7 @@ export default function FocusTooltip (data: { parentRef: RefObject; visible { const dataTooltip = e.getAttribute('data-tooltip'); setHoverText(dataTooltip ?? undefined); - setHoverTextType(e.getAttribute('data-tooltip_type') ?? 'accent'); + setHoverTextType(e.getAttribute('data-tooltip-type') ?? 'accent'); }; const { isPointer } = useActiveControl(); @@ -29,7 +29,10 @@ export default function FocusTooltip (data: { parentRef: RefObject; visible const tooltipStyles = { base: 'bg-base-100 text-base-content', accent: 'bg-accent text-accent-content', - error: 'bg-error text-error-content' + error: 'bg-error text-error-content', + warning: 'bg-warning text-warning-content', + info: 'bg-info text-info-content', + success: 'bg-success text-success-content' }; return !!hoverText && (data.visible ?? true) && !isPointer &&

    {hoverText}

    ; diff --git a/src/mainview/components/StatList.tsx b/src/mainview/components/StatList.tsx index 3ff22f9..de1c231 100644 --- a/src/mainview/components/StatList.tsx +++ b/src/mainview/components/StatList.tsx @@ -29,7 +29,7 @@ export default function StatList (data: { return
      - {data.stats.map((s, i) => + {data.stats.flatMap((s, i) => { let content: any = undefined; if (s.content instanceof Array) @@ -37,13 +37,9 @@ export default function StatList (data: { content =
      {s.content.map((c, ci) => {c})}
      ; } else { - content =
      {s.icon}{s.content}
      ; + content =
      {s.icon}{s.content}
      ; } - const element = <> -
    ; diff --git a/src/mainview/components/game/ActionButton.tsx b/src/mainview/components/game/ActionButton.tsx index c0f3b78..3e5ebed 100644 --- a/src/mainview/components/game/ActionButton.tsx +++ b/src/mainview/components/game/ActionButton.tsx @@ -31,7 +31,7 @@ export default function ActionButton (data: { ref={ref} onClick={data.onAction} data-tooltip={data.tooltip} - data-tooltip_type={data.tooltip_type} + data-tooltip-type={data.tooltip_type} className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30 active:bg-base-100 active:transition-none active:text-base-content", "hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md:size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}> {data.icon} diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index e89f910..2ee89c6 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -137,7 +137,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so mainButton = { diff --git a/src/mainview/components/options/Button.tsx b/src/mainview/components/options/Button.tsx index faef3fa..c9ba6a3 100644 --- a/src/mainview/components/options/Button.tsx +++ b/src/mainview/components/options/Button.tsx @@ -33,7 +33,7 @@ export function Button (data: { focusClassName?: string; cssStyle?: CSSProperties; tooltip?: string; - tooltipType?: "base" | "accent" | "error"; + tooltipType?: "base" | "accent" | "error" | "warning"; } & InteractParams & FocusParams) { const handleAction = (e?: any) => @@ -58,7 +58,7 @@ export function Button (data: { onClick={handleAction} disabled={data.disabled} data-tooltip={data.tooltip} - data-tooltip_type={data.tooltipType} + data-tooltip-type={data.tooltipType} style={data.cssStyle} 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'], diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index 2102503..a9f7720 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -5,11 +5,13 @@ import { Button } from "../options/Button"; import useActiveControl from "@/mainview/scripts/gamepads"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; -import { BadgeCheck, ChevronRight, EllipsisVertical, FileQuestion, IceCream2, Package, Sparkles, Store, WandSparkles } from "lucide-react"; +import { BadgeCheck, ChevronRight, CircleFadingArrowUp, EllipsisVertical, FileQuestion, IceCream2, Package, Sparkles, Store, WandSparkles } from "lucide-react"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import { FlatpackIcon } from "@/mainview/scripts/brandIcons"; import { JSX } from "react"; import { oneShot } from "@/mainview/scripts/audio/audio"; +import { useQuery } from "@tanstack/react-query"; +import { getUpdateInfoForEmulator } from "@/mainview/scripts/queries/store"; export const emulatorStatusIcons: Record = { store: , @@ -42,8 +44,9 @@ export function StoreEmulatorCard (data: { } }); + const { data: updateInfo } = useQuery(getUpdateInfoForEmulator(data.emulator.name)); + useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]); - const { isMouse, isTouch } = useActiveControl(); return (
    s.exists)} - onClick={isTouch ? handleSelect : undefined} - className={twMerge("relative focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none", data.className)} + onClick={handleSelect} + className={twMerge("relative focusable focusable-info focusable-hover bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none cursor-pointer", data.className)} >
    @@ -81,21 +84,27 @@ export function StoreEmulatorCard (data: {
    - {!!data.emulator.integration &&
    -
    + {updateInfo?.hasUpdate &&
    +
    + +
    +
    } + {data.emulator.integrations.length > 0 &&
    i.supportLevel)} + data-full-support={data.emulator.integrations.some(i => i.supportLevel === 'full')} + className="tooltip not-aria-disabled:tooltip-primary" + data-tip={data.emulator.integrations.some(i => i.supportLevel) ? data.emulator.integrations.some(i => i.supportLevel === 'full') ? "Full Support" : "Partial SUpport" : "Can Integrate"} + > +
    } {data.emulator.validSources.slice(0, 3).map(s => { return
    -
    +
    {emulatorStatusIcons[s.type]}
    ; })} - {isMouse && <> - - } -
    diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 9ad4678..a40e2b0 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -10,7 +10,7 @@ import Shortcuts from "@/mainview/components/Shortcuts"; import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; import { systemApi } from "@/mainview/scripts/clientApi"; import { Button } from "@/mainview/components/options/Button"; -import { ChevronDown, Cpu, Download, Gamepad2, Info, Puzzle, Settings, Trash2, TriangleAlert, WandSparkles } from "lucide-react"; +import { ChevronDown, CircleFadingArrowUp, Cpu, Download, Gamepad2, Info, Puzzle, Settings, Trash2, TriangleAlert, WandSparkles } from "lucide-react"; import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog"; import { RPC_URL } from "@/shared/constants"; import Screenshots from "@/mainview/components/Screenshots"; @@ -59,6 +59,7 @@ function HomePageLink (data: { homepage?: string; }) function TitleArea (data: { emulator?: FrontEndEmulatorDetailed; onInstall: (source: string) => void; + onUpdate: (source: string) => void; }) { const queryClient = useQueryClient(); @@ -70,6 +71,7 @@ function TitleArea (data: { }, }); const downloadBios = useMutation(downloadBiosMutation(data.emulator?.name ?? '')); + const updateToVersion = data.emulator?.downloads.find(d => d.version === data.emulator!.storeDownloadInfo?.type)?.version ?? data.emulator?.downloads[0]?.version; const deleteBios = useMutation({ ...deleteBiosMutation, onSuccess (data, variables, onMutateResult, context) @@ -122,7 +124,7 @@ function TitleArea (data: { const isInstalling = !!installJob || !!biosInstallJob; const options: DialogEntry[] = []; - const installedFromStore = !!data.emulator?.sources.find(s => s.type === 'store' && s.exists); + const installedFromStore = !!data.emulator?.validSources.find(s => s.type === 'store' && s.exists); if (data.emulator) { if (!isInstalling && !installedFromStore) @@ -155,6 +157,22 @@ function TitleArea (data: { id: "delete" }); + if ((!data.emulator.storeDownloadInfo || data.emulator.storeDownloadInfo.hasUpdate)) + { + options.push({ + content: `Update ${data.emulator.storeDownloadInfo?.type}: ${data.emulator.storeDownloadInfo?.version ?? "Unknown"} > ${updateToVersion}`, + type: 'warning', + icon: , + action (ctx) + { + const source = data.emulator?.storeDownloadInfo?.type ?? data.emulator?.downloads[0]?.type; + if (source) data.onUpdate(source); + ctx.close(); + }, + id: 'update' + }); + } + if (!data.emulator.bios || data.emulator.bios.length <= 0) { options.push({ @@ -183,7 +201,6 @@ function TitleArea (data: { id: "download-bios" }); } - } } @@ -253,13 +270,16 @@ function TitleArea (data: { {!!data.emulator?.bios?.[0] &&
    } - {data.emulator && !!data.emulator.integration && data.emulator.validSources.some(s => s.type === 'store') &&
    + {data.emulator && data.emulator.integrations.length > 0 &&
    }
    + {(data.emulator?.storeDownloadInfo?.hasUpdate || !data.emulator?.storeDownloadInfo) && installedFromStore && !!updateToVersion &&
    + +
    } {(!data.emulator?.bios || data.emulator.bios.length <= 0) && (data.emulator?.biosRequirement === 'required') && installedFromStore &&
    } @@ -310,7 +330,8 @@ export function RouteComponent () }], [router]); const installMutation = useMutation({ - ...installEmulatorMutation(id), onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)), + ...installEmulatorMutation(id), + onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)), }); const { shortcuts } = useShortcutContext(); @@ -320,21 +341,33 @@ export function RouteComponent () { if (emulator.keywords) stats.push({ label: "Tags", content: emulator.keywords }); + if (emulator.storeDownloadInfo) + stats.push({ label: "Version", content: `${emulator.storeDownloadInfo.version ?? "Unknown"} (${emulator.storeDownloadInfo.type})` }); stats.push({ label: "Systems", content: emulator.systems.map(s => s.name) }); - stats.push(...emulator.sources.flatMap(s => [{ - label: "Source", content:
    -
    {emulatorStatusIcons[s.type]}{s.type}:
    -
    {s.binPath}
    + stats.push(...emulator.validSources.flatMap(s => [{ + label: "Source", content:
    +
    +
    {emulatorStatusIcons[s.type]}{s.type}
    +
    {s.binPath}
    +
    + {emulator.integrations.some(i => i.source?.type === s.type) &&
    } + {emulator.integrations.filter(i => i.source?.type === s.type).map(i => + { + return
    +
    + +
    {i.id}
    +
    +
    {`${i.capabilities?.join(", ")}`}
    +
    ; + })}
    }])); if (emulator.bios) stats.push({ label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios :
    Missing
    }); - if (emulator.integration) - { - stats.push({ label: "Integration", icon: , content: `${emulator.integration.name} (${emulator.integration.version})` }); - } + } return ( @@ -344,7 +377,7 @@ export function RouteComponent ()
    - + installMutation.mutate({ source: s, isUpdate: false })} onUpdate={s => installMutation.mutate({ source: s, isUpdate: true })} />
    diff --git a/src/mainview/scripts/queries/store.ts b/src/mainview/scripts/queries/store.ts index bdd6337..4a881cd 100644 --- a/src/mainview/scripts/queries/store.ts +++ b/src/mainview/scripts/queries/store.ts @@ -64,9 +64,9 @@ export const storeGetStatsQuery = queryOptions({ }); export const installEmulatorMutation = (id: string) => mutationOptions({ mutationKey: ['install', 'emulator', id], - mutationFn: async (source: string) => + mutationFn: async (ctx: { source: string, isUpdate: boolean; }) => { - const { data, error } = await storeApi.api.store.install.emulator({ id })({ source }).post(); + const { data, error } = await storeApi.api.store.install.emulator({ id })({ source: ctx.source }).post({ isUpdate: ctx.isUpdate }); if (error) throw error; return data; } @@ -85,4 +85,12 @@ export const deleteBiosMutation = mutationOptions({ const { error } = await storeApi.api.store.bios({ id }).delete(); if (error) throw error; } +}); +export const getUpdateInfoForEmulator = (id: string) => queryOptions({ + queryKey: ['emulator', 'update'], queryFn: async () => + { + const { data, error } = await storeApi.api.store.emulator({ id }).update.get(); + if (error) throw error; + return data; + } }); \ No newline at end of file diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 33531f7..3eff7d5 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -121,7 +121,13 @@ export const EmulatorPackageSchema = z.object({ export const ScoopPackageSchema = z.object({ version: z.string(), url: z.url().optional(), - architecture: z.record(z.string(), z.object({ url: z.url(), hash: z.string().optional() })).optional() + description: z.string(), + bin: z.string().optional(), + architecture: z.record(z.string(), z.object({ + url: z.url(), + hash: z.string().optional(), + extract_dir: z.string().optional() + })).optional() }); export const SystemInfoSchema = z.object({ @@ -137,6 +143,10 @@ export const SystemInfoSchema = z.object({ }); export const GithubReleaseSchema = z.object({ + id: z.number(), + tag_name: z.string().optional(), + url: z.url(), + body: z.string(), assets: z.array(z.object({ name: z.string(), browser_download_url: z.url(), @@ -144,9 +154,19 @@ export const GithubReleaseSchema = z.object({ })) }); +export const EmulatorDownloadInfoSchema = z.object({ + id: z.string(), + version: z.string().optional(), + url: z.url().optional(), + description: z.string().optional(), + downloadDate: z.coerce.date(), + type: z.string() +}); + export type EmulatorPackageType = z.infer; export type StoreGameType = z.infer; export type SettingsType = z.infer; export type LocalSettingsType = z.infer; export const PlatformSchema = z.object({ slug: z.string() }); export type SystemInfoType = z.infer; +export type EmulatorDownloadInfoType = z.infer; diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index 7bc8ba7..85812b2 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -16,11 +16,7 @@ declare interface FrontEndEmulator description?: string; gameCount: number; validSources: EmulatorSourceEntryType[]; - integration?: { - name: string; - version?: string; - possible: boolean; - }; + integrations: EmulatorSupport[]; } declare interface EmulatorSystem { id: string, romm_slug?: string, name: string, iconUrl: string; } @@ -29,6 +25,7 @@ declare interface FrontEndEmulatorDetailedDownload { name: string; type: string | undefined; + version?: string; } declare interface FrontEndEmulatorDetailed extends FrontEndEmulator @@ -38,9 +35,9 @@ declare interface FrontEndEmulatorDetailed extends FrontEndEmulator downloads: FrontEndEmulatorDetailedDownload[]; keywords?: string[]; screenshots: string[]; - sources: EmulatorSourceEntryType[]; biosRequirement?: "required" | "optional"; bios?: string[]; + storeDownloadInfo?: { hasUpdate: boolean; version?: string, type: string; }; } declare interface FrontEndGameTypeDetailedAchievement @@ -265,4 +262,14 @@ declare interface FrontEndCollection description: string; path_platform_cover: string | null; game_count: number; +} + +declare type EmulatorCapabilities = "saves" | "fullscreen" | "resolution" | "batch" | "states" | "config"; + +declare interface EmulatorSupport +{ + id: string; + source?: EmulatorSourceEntryType; + supportLevel?: "partial" | "full"; + capabilities?: EmulatorCapabilities[]; } \ No newline at end of file From 04d5856f7d71c944c82877d2a1457facea4b6d31 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Fri, 3 Apr 2026 23:18:29 +0300 Subject: [PATCH 31/65] fix: Fixed emulator details buttons not showing --- src/mainview/routes/store/details.emulator.$id.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index a40e2b0..02d5c22 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -246,7 +246,7 @@ function TitleArea (data: { const handleOptionsOpen = () => { - if (isInstalling || !data.emulator || data.emulator.downloads.length <= 0) return false; + if (isInstalling || !data.emulator) return false; setOpen(true, 'install-btn'); }; From 09b8b9c6f850cea3b897308925faf9be02cefa1a Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sat, 4 Apr 2026 03:13:09 +0300 Subject: [PATCH 32/65] feat: Implemented emulator launching Fixes #1 --- src/bun/api/games/games.ts | 2 +- .../api/games/services/launchGameService.ts | 10 +- src/bun/api/games/services/statusService.ts | 27 +++- src/bun/api/hooks/emulators.ts | 37 ++++- src/bun/api/hooks/games.ts | 43 +++++- src/bun/api/jobs/jobs.ts | 6 +- src/bun/api/jobs/launch-game-job.ts | 73 +++++---- .../dolphin.ts | 49 +++--- .../pcsx2.ts | 113 +++++++------- .../ppsspp.ts | 139 +++++++++--------- src/bun/api/store/store.ts | 3 +- src/bun/types/typesc.schema.ts | 4 +- src/mainview/routes/game/$source.$id.tsx | 3 +- src/mainview/routes/launcher.$source.$id.tsx | 42 +++--- src/mainview/routes/settings/route.tsx | 2 +- .../routes/store/details.emulator.$id.tsx | 14 +- src/mainview/scripts/utils.ts | 8 +- src/shared/types..d.ts | 2 +- src/tests/downloads.test.ts | 2 +- src/tests/preload.ts | 3 +- 20 files changed, 351 insertions(+), 231 deletions(-) diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 73b9e1f..7c91954 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -389,7 +389,7 @@ export default new Elysia() if (validCommand) { // launch command waits for the game to exit, we don't want that. - await launchCommand(validCommand, source, id, validCommands.gameId); + await launchCommand(validCommand, validCommands.gameId, validCommands.source, validCommands.sourceId); return { type: 'application', command: null }; } else { diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index a91e6ca..362ff41 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -5,22 +5,20 @@ import { existsSync, readFileSync } from 'node:fs'; import * as schema from '@schema/emulators'; import { eq } from 'drizzle-orm'; import { config, customEmulators, emulatorsDb, taskQueue } from '../../app'; -import os, { platform } from 'node:os'; +import os from 'node:os'; import { cores } from '../../emulatorjs/emulatorjs'; import { LaunchGameJob } from '../../jobs/launch-game-job'; -import { EmulatorPackageType } from '@/shared/constants'; -import { getStoreEmulatorPackage, getStoreFolder } from '../../store/services/gamesService'; -import { getOrCached } from '../../cache'; +import { getStoreEmulatorPackage } from '../../store/services/gamesService'; import { getOrCachedScoopPackage } from '../../store/services/emulatorsService'; export const varRegex = /%([^%]+)%/g; export const assignRegex = /(%\w+%)=(\S+) /g; -export async function launchCommand (validCommand: CommandEntry, source: string, sourceId: string, id: number) +export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string) { if (taskQueue.hasActiveOfType(LaunchGameJob)) { - throw new Error(`${id} currently running`); + throw new Error(`Game currently running`); } taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId)); diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index af9e62a..4f1d99b 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -1,6 +1,6 @@ import { RPC_URL, } from "@shared/constants"; import { config, customEmulators, db, emulatorsDb, plugins, taskQueue } from "../../app"; -import { getValidLaunchCommands } from "./launchGameService"; +import { findExecs, getValidLaunchCommands } from "./launchGameService"; import * as emulatorSchema from '@schema/emulators'; import { and, eq } from "drizzle-orm"; import { getErrorMessage, hashFile } from "@/bun/utils"; @@ -26,7 +26,7 @@ class CommandSearchError extends Error export async function getLocalGame (source: string, id: string) { const localGame = await db.query.games.findFirst({ - columns: { id: true, path_fs: true }, + columns: { id: true, path_fs: true, source: true, source_id: true }, where: getLocalGameMatch(id, source), with: { platform: { columns: { slug: true } } @@ -36,8 +36,27 @@ export async function getLocalGame (source: string, id: string) return localGame; } -export async function getValidLaunchCommandsForGame (source: string, id: string) +export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined> { + if (source === 'emulator') + { + const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, id) }); + const allExecs = await findExecs(id, esEmulator); + return { + commands: allExecs.map(exec => ({ + command: exec.binPath, + id: exec.type, + emulator: id, + emulatorSource: exec.type, + metadata: { + emulatorBin: exec.binPath, + emulatorDir: exec.rootPath + }, + valid: true + } satisfies CommandEntry)), + gameId: { source: "emulator", id: id } + }; + } const localGame = await getLocalGame(source, id); if (localGame) { @@ -70,7 +89,7 @@ export async function getValidLaunchCommandsForGame (source: string, id: string) const validCommand = commands.find(c => c.valid); if (validCommand) { - return { commands: commands.filter(c => c.valid), gameId: localGame.id, source: source, sourceId: id }; + return { commands: commands.filter(c => c.valid), gameId: { id: String(localGame.id), source: 'local' }, source: localGame.source ?? source, sourceId: String(localGame.source_id) ?? id }; } else { diff --git a/src/bun/api/hooks/emulators.ts b/src/bun/api/hooks/emulators.ts index ed2d742..b968197 100644 --- a/src/bun/api/hooks/emulators.ts +++ b/src/bun/api/hooks/emulators.ts @@ -1,5 +1,15 @@ import { EmulatorDownloadInfoType, EmulatorPackageType } from "@/shared/constants"; import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; +import { any } from "zod"; + +interface EmulatorPostInstallContext +{ + emulator: string; + emulatorPackage?: EmulatorPackageType; + path: string; + update: boolean; + info: EmulatorDownloadInfoType; +} export class EmulatorHooks { @@ -12,11 +22,24 @@ export class EmulatorHooks /** * Triggered when emulator is downloaded or updated */ - emulatorPostInstall = new AsyncSeriesHook<[ctx: { - emulator: string; - emulatorPackage?: EmulatorPackageType; - path: string; - update: boolean; - info: EmulatorDownloadInfoType; - }]>(['ctx']); + emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContext], { emulator: string; }>(['ctx']); + + constructor() + { + this.emulatorPostInstall.intercept({ + register (tap) + { + return { + ...tap, + fn: async (ctx: EmulatorPostInstallContext, ...rest: any[]) => + { + if (ctx.emulator === tap.emulator) + { + tap.fn(ctx, ...rest); + } + } + }; + }, + }); + } } \ No newline at end of file diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index ea50476..dd893d1 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -15,10 +15,11 @@ export class GameHooks autoValidCommand: CommandEntry; dryRun: boolean, game: { - source: string; - id: number; + source?: string; + sourceId?: string; + id: FrontEndId; }; - }], string[] | undefined>(['ctx']); + }], string[] | undefined, { emulator: string; }>(['ctx']); /** * Is the given emulator for the given command supported * @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects. @@ -27,7 +28,7 @@ export class GameHooks emulatorLaunchSupport = new SyncBailHook<[ctx: { emulator: string; source?: EmulatorSourceEntryType; - }], EmulatorSupport | undefined>(['ctx']); + }], EmulatorSupport | undefined, { emulator: string; }>(['ctx']); /** * Fetches and returns a list of games converted to frontend. * @param ctx.localGameIds This is local game ids in the format '@' @@ -71,4 +72,38 @@ export class GameHooks updatePlayed = new AsyncSeriesWaterfallHook<[ctx: { source: string, id: string; }], boolean>(["ctx"]); fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']); fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']); + + constructor() + { + this.emulatorLaunchSupport.intercept({ + register (tap) + { + return { + ...tap, + fn: (e: any, ...rest: any[]) => + { + if (e.emulator === tap.emulator) + { + return tap.fn(e, ...rest); + } + } + }; + }, + }); + this.emulatorLaunch.intercept({ + register (tap) + { + return { + ...tap, + fn: async (e: any, ...rest: any[]) => + { + if ((e.autoValidCommand as CommandEntry).emulator === tap.emulator) + { + return tap.fn(e, ...rest); + } + } + }; + }, + }); + } } \ No newline at end of file diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index 8f836a5..7bc92c7 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -32,6 +32,7 @@ function registerJob< data: _job.dataSchema }), z.object({ type: z.literal(['completed', 'ended']), data: _job.dataSchema }), + z.object({ type: z.literal('waiting') }), z.object({ type: z.literal('error'), error: z.string() }) ]), open (ws) @@ -41,6 +42,9 @@ function registerJob< if (job) { ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() }); + } else + { + ws.send({ type: 'waiting' }); } (ws.data as any).cleanup = [ @@ -97,10 +101,10 @@ function registerJob< } export const jobs = new Elysia({ prefix: '/api/jobs' }) + .use(registerJob(LaunchGameJob)) .use(registerJob(LoginJob)) .use(registerJob(TwitchLoginJob)) .use(registerJob(UpdateStoreJob)) - .use(registerJob(LaunchGameJob)) .use(registerJob(BiosDownloadJob)) .use(registerJob(InstallJob)) .use(registerJob(EmulatorDownloadJob)); diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 91004bb..18b4fd1 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -4,40 +4,51 @@ import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema"; import { db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; import { eq, sql } from "drizzle-orm"; -import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; +import { spawn } from 'node:child_process'; export class LaunchGameJob implements IJob, "playing"> { static id = "launch-game" as const; - static dataSchema = z.optional(ActiveGameSchema); + static dataSchema = z.nullable(ActiveGameSchema); group = "launch-game"; - activeGame?: ActiveGameType; - gameId: number; + activeGame: ActiveGameType | null; + gameId: FrontEndId; validCommand: CommandEntry; - gameSource: string; - gameSourceId: string; + gameSource?: string; + gameSourceId?: string; - constructor(gameId: number, validCommand: CommandEntry, source: string, sourceId: string) + constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string) { this.gameId = gameId; this.validCommand = validCommand; this.gameSource = source; this.gameSourceId = sourceId; + this.activeGame = null; } async start (context: JobContext, "playing">, z.infer, "playing">) { - const localGame = await db.query.games.findFirst({ - where: eq(appSchema.games.id, this.gameId), columns: { - name: true, - source_id: true, - source: true - } - }); + let gameInfo: { name?: string, source_id?: string, source?: string; }; + if (this.gameId.source === 'emulator') + { + gameInfo = { name: this.gameId.id }; + } else + { + const localGame = await db.query.games.findFirst({ + where: eq(appSchema.games.id, Number(this.gameId.id)), columns: { + name: true, + source_id: true, + source: true + } + }); + if (localGame) + gameInfo = { name: localGame.name ?? undefined, source_id: localGame.source_id ?? undefined, source: localGame.source ?? undefined }; + } const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({ autoValidCommand: this.validCommand, - game: { source: this.gameSource, id: this.gameId } + game: { source: this.gameSource, sourceId: this.gameSourceId, id: this.gameId }, + dryRun: false }); await new Promise((resolve, reject) => @@ -70,10 +81,15 @@ export class LaunchGameJob implements IJob + context.abortSignal.addEventListener('abort', reject); + + bunGame.exited.then(e => + { + resolve(true); + }).catch(e => { console.error(e); reject(e); @@ -87,28 +103,27 @@ export class LaunchGameJob implements IJob + const updatePlayed = async (id: FrontEndId, source?: string, sourceId?: string) => { - await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, this.gameId)); - await plugins.hooks.games.updatePlayed.promise({ source, id }).then(v => + if (this.gameId.source === 'local') + { + await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(this.gameId.id))); + } + + await plugins.hooks.games.updatePlayed.promise({ source: source ?? id.source, id: sourceId ?? id.id }).then(v => { if (v) events.emit('notification', { message: "Updated Last Played", type: 'success' }); }); }; - if (this.gameSource !== 'local') - { - updatePlayed(this.gameSource, this.gameSourceId); - } - else if (localGame?.source && localGame?.source !== 'local' && localGame.source_id) - { - updatePlayed(localGame.source, localGame.source_id); - } + updatePlayed(this.gameId, this.gameSource, this.gameSourceId); }); /* Old spawn lanching, cases issues, needs to be ran as shell diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index af12a7d..59cc505 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -6,37 +6,48 @@ import desc from './package.json'; export default class DOLPHINIntegration implements PluginType { + emulator = 'DOLPHIN'; + + load (ctx: PluginContextType) { - ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - if (ctx.emulator === 'DOLPHIN') - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] }; }); - ctx.hooks.emulators.emulatorPostInstall.tapPromise(desc.name, async (ctx) => + ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => { await Bun.write(path.join(ctx.path, "portable.txt"), ""); }); - ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => { - if (ctx.autoValidCommand.emulator === 'DOLPHIN' && ctx.autoValidCommand.metadata.emulatorDir) + const args: string[] = []; + + const storageFolder = path.join(config.get('downloadPath'), "storage", 'DOLPHIN'); + args.push(`--user=${storageFolder}`); + + args.push(`--config=Dolphin.Display.Fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); + args.push(`--config=Dolphin.General.ISOPath0=${path.join(config.get('downloadPath'), 'roms', 'gc')}`); + args.push(`--config=Dolphin.General.ISOPath1=${path.join(config.get('downloadPath'), 'roms', 'wii')}`); + args.push(`--config=Dolphin.Interface.ConfirmStop=False`); + args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`); + args.push(`--config=Dolphin.Analytics.PermissionAsked=True`); + + const savesPath = path.join(config.get('downloadPath'), "saves", 'DOLPHIN'); + + args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`); + args.push(`--config=Dolphin.General.WiiSDCardSyncFolder=${path.join(savesPath, 'WiiSDSync')}`); + args.push(`--config=Dolphin.GBA.SavesPath=${path.join(savesPath, 'GBA')}`); + + if (ctx.autoValidCommand.metadata.romPath) { - const args = ["--batch"]; - - const storageFolder = path.join(config.get('downloadPath'), "saves", 'DOLPHIN'); - - args.push(...[`--user=${storageFolder}`, `--exec=${ctx.autoValidCommand.metadata.romPath}`]); - args.push(`--config=Dolphin.Display.Fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); - args.push(`--config=Dolphin.General.ISOPath0=${path.join(config.get('downloadPath'), 'roms', 'gc')}`); - args.push(`--config=Dolphin.General.ISOPath1=${path.join(config.get('downloadPath'), 'roms', 'wii')}`); - args.push(`--config=Dolphin.Interface.ConfirmStop=False`); - args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`); - args.push(`--config=Dolphin.Analytics.PermissionAsked=True`); - - return args; + args.push("--batch"); + args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`); } + + return args; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index c2de7e3..2e944d2 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -9,72 +9,73 @@ import desc from './package.json'; export default class PCSX2Integration implements PluginType { + emulator = "PCSX2"; + load (ctx: PluginContextType) { - ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - if (ctx.emulator === 'PCSX2') - { - const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; + const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; - if (ctx.source?.type === 'store') - { - return { - id: desc.name, - supportLevel: "full", - capabilities: [...baseCapabilities, "resolution", "config"] - }; - } - else - { - return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; - } + if (ctx.source?.type === 'store') + { + return { + id: desc.name, + supportLevel: "full", + capabilities: [...baseCapabilities, "resolution", "config"] + }; + } + else + { + return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; } }); - ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => { - if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.metadata.emulatorDir) + const args: string[] = []; + if (ctx.autoValidCommand.metadata.romPath) { - const args = ["-batch"]; - if (config.get('launchInFullscreen')) - { - args.push("-fullscreen"); - } - args.push(...["-bigpicture", "-portable", "--", ctx.autoValidCommand.metadata.romPath]); - - if (ctx.autoValidCommand.emulatorSource === 'store' && !ctx.dryRun) - { - const configFileContents = await Bun.file(configFile).text(); - - const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2'); - const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); - const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); - - const view = { - BIOS_PATH: biosFolder, - SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'), - SAVE_STATES_PATH: path.join(savesFolder, 'states'), - MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'), - CACHE_PATH: path.join(storageFolder, 'cache'), - COVERS_PATH: path.join(storageFolder, 'covers'), - TEXTURES_PATH: path.join(storageFolder, 'textures'), - RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), - }; - - await Promise.all(Object.values(view).map(p => ensureDir(p))); - - let pscx2Path = ''; - if (process.platform === 'win32') - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); - else - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis'); - - await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); - } - - return args; + args.push(ctx.autoValidCommand.metadata.romPath); + args.push("-batch"); } + if (config.get('launchInFullscreen')) + { + args.push("-fullscreen"); + } + args.push(...["-bigpicture", "-portable", "--"]); + + if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun) + { + const configFileContents = await Bun.file(configFile).text(); + + const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2'); + const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); + const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); + + const view = { + BIOS_PATH: biosFolder, + SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'), + SAVE_STATES_PATH: path.join(savesFolder, 'states'), + MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'), + CACHE_PATH: path.join(storageFolder, 'cache'), + COVERS_PATH: path.join(storageFolder, 'covers'), + TEXTURES_PATH: path.join(storageFolder, 'textures'), + RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), + }; + + await Promise.all(Object.values(view).map(p => ensureDir(p))); + + let pscx2Path = ''; + if (process.platform === 'win32') + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); + else + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis'); + + await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); + } + + return args; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index fddf25c..b0d0a44 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -12,85 +12,86 @@ import { homedir } from "node:os"; export default class PCSX2Integration implements PluginType { + emulator = "PPSSPP"; + load (ctx: PluginContextType) { - ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - if (ctx.emulator === 'PPSSPP') + const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; + + if (ctx.source?.type === 'store') { - const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; - - if (ctx.source?.type === 'store') - { - return { - id: desc.name, - supportLevel: "full", - capabilities: [...baseCapabilities, "resolution", "config"] - }; - } - else - { - return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; - } - + return { + id: desc.name, + supportLevel: "full", + capabilities: [...baseCapabilities, "resolution", "config"] + }; + } + else + { + return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; } }); - ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => { - if (ctx.autoValidCommand.emulator === 'PPSSPP' && ctx.autoValidCommand.metadata.emulatorDir) + const args: string[] = []; + if (ctx.autoValidCommand.metadata.romPath) { - const args = [ctx.autoValidCommand.metadata.romPath, "--escape-exit", "--pause-menu-exit"]; - if (config.get('launchInFullscreen')) - { - args.push("--fullscreen"); - } - - if (ctx.autoValidCommand.emulatorSource === 'store' && !ctx.dryRun) - { - let confPath: string | undefined = undefined; - let controlsPath: string | undefined = undefined; - - switch (process.platform) - { - case "win32": - confPath = configFilePathWin32; - controlsPath = configControlsFilePathWin32; - break; - case 'linux': - confPath = configFilePathLinux; - controlsPath = configControlsFilePathLinux; - break; - } - - let ppssppPath = ''; - if (process.platform === 'win32') - { - ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'); - } else - { - //TODO: Use way to set custom memstick path when they support it - ensureDir(path.join(homedir(), '.config', 'ppsspp')); - ppssppPath = path.join(homedir(), '.config', 'ppsspp', 'PSP', 'SYSTEM'); - } - - ensureDir(ppssppPath); - - if (confPath) - { - const configFileContents = await Bun.file(confPath).text(); - await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {})); - } - - if (controlsPath) - { - const controlsFileContents = await Bun.file(controlsPath).text(); - await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); - } - } - - return args; + args.push(ctx.autoValidCommand.metadata.romPath); } + + args.push("--escape-exit", "--pause-menu-exit"); + if (config.get('launchInFullscreen')) + { + args.push("--fullscreen"); + } + + if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun) + { + let confPath: string | undefined = undefined; + let controlsPath: string | undefined = undefined; + + switch (process.platform) + { + case "win32": + confPath = configFilePathWin32; + controlsPath = configControlsFilePathWin32; + break; + case 'linux': + confPath = configFilePathLinux; + controlsPath = configControlsFilePathLinux; + break; + } + + let ppssppPath = ''; + if (process.platform === 'win32') + { + ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'); + } else + { + //TODO: Use way to set custom memstick path when they support it + ensureDir(path.join(homedir(), '.config', 'ppsspp')); + ppssppPath = path.join(homedir(), '.config', 'ppsspp', 'PSP', 'SYSTEM'); + } + + ensureDir(ppssppPath); + + if (confPath) + { + const configFileContents = await Bun.file(confPath).text(); + await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {})); + } + + if (controlsPath) + { + const controlsFileContents = await Bun.file(controlsPath).text(); + await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); + } + } + + return args; }); } } \ No newline at end of file diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 2736fe2..2a1c42e 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -147,8 +147,7 @@ export const store = new Elysia({ prefix: '/api/store' }) biosRequirement: emulatorPackage.bios, bios: biosFiles, integrations: findEmulatorPluginIntegration(emulatorPackage.name, execPaths), - storeDownloadInfo: storeDownloadInfo, - hasUpdate: storeDownloadInfo?.hasUpdate ?? null + storeDownloadInfo: storeDownloadInfo }; return emulator; diff --git a/src/bun/types/typesc.schema.ts b/src/bun/types/typesc.schema.ts index fba1435..ee1da2b 100644 --- a/src/bun/types/typesc.schema.ts +++ b/src/bun/types/typesc.schema.ts @@ -28,7 +28,9 @@ export type PluginDescriptionType = z.infer; export const ActiveGameSchema = z.object({ process: z.any().optional(), - gameId: z.number(), + gameId: z.object({ id: z.string(), source: z.string() }), + source: z.string().optional(), + sourceId: z.string().optional(), name: z.string(), command: z.object({ command: z.string(), startDir: z.string().optional() }) }); diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index ec57e71..511ed9b 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -46,7 +46,8 @@ function Error (data: ErrorComponentProps) { const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" }); - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); + const router = useRouter(); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }]); const { shortcuts } = useShortcutContext(); return diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index 49011d7..8eac18d 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -1,13 +1,10 @@ import { AnimatedBackground } from '@/mainview/components/AnimatedBackground'; -import { createFileRoute, useRouter } from '@tanstack/react-router'; +import { createFileRoute, useBlocker, useRouter } from '@tanstack/react-router'; import DotsLoading from '../components/backgrounds/dots'; -import { useEffect } from 'react'; -import { useQuery } from '@tanstack/react-query'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import Shortcuts from '../components/Shortcuts'; -import { gameQuery } from '@queries/romm'; -import { rommApi } from '../scripts/clientApi'; +import { useJobStatus } from '../scripts/utils'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, @@ -18,34 +15,33 @@ function RouteComponent () const router = useRouter(); function HandleGoBack () { - router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); + if (router.history.canGoBack()) + { + router.history.back(); + } else + { + router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); + } } const { source, id } = Route.useParams(); const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` }); - const { data } = useQuery(gameQuery(source, id)); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); const { shortcuts } = useShortcutContext(); - useEffect(() => - { - if (!data) return; - const sub = rommApi.api.romm.status({ source: data.id.source })({ id: data.id.id }).subscribe(); - - sub.subscribe((e) => + const { data } = useJobStatus('launch-game', { + onEnded (data) { - if (e.data.status !== 'playing') - { - HandleGoBack(); - } - }); - - return () => + HandleGoBack(); + }, + onWaiting () { - sub.close(); - }; - }, [data?.id]); + HandleGoBack(); + }, + }); + + useBlocker({ shouldBlockFn: () => !!data }); return
    diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index 5c76442..89168ea 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -116,7 +116,7 @@ function SettingsMenu (data: {}) const { ref, focusKey } = useFocusable({ focusable: true, focusKey: 'settings-menu', - preferredChildFocusKey: location.hash.replaceAll(/#|(\?.+)/g, '') + preferredChildFocusKey: `menu-item-${location.hash.replaceAll(/#|(\?.+)/g, '')}` }); return
      void; }) { + const navigation = useNavigate(); const queryClient = useQueryClient(); const deleteMutation = useMutation({ ...storeEmulatorDeleteMutation, @@ -202,6 +203,15 @@ function TitleArea (data: { }); } } + + options.push(...data.emulator.validSources.filter(s => s.exists).map(s => ({ + content: `Launch: ${s.type}`, type: 'primary', icon: emulatorStatusIcons[s.type], action (ctx) + { + if (!data.emulator) return; + rommApi.api.romm.game({ source: 'emulator' })({ id: data.emulator.name }).play.post({ command_id: s.type }); + navigation({ to: '/launcher/$source/$id', params: { source: 'emulator', id: data.emulator.name } }); + }, id: `open-${s.type}` + } satisfies DialogEntry))); } const { ref, focusKey, hasFocusedChild } = useFocusable({ diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index a9666be..a1f325d 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -3,7 +3,7 @@ import { RefObject, useEffect, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; import { jobsApi } from "./clientApi"; import { JobsAPIType } from "@/bun/api/rpc"; -import { AnyRouter, Router, useRouter } from "@tanstack/react-router"; +import { AnyRouter, useRouter } from "@tanstack/react-router"; import { soundMap } from "./audio/audio"; export type ScrollSaveParams = { @@ -267,6 +267,7 @@ export function useJobStatus, onProgress?: (process: number, data: ExtractField, "data" | "started" | "progress" | "completed" | "ended", 'data'>) => void, + onWaiting?: () => void, onEnded?: (data: ExtractField, "completed" | "ended", 'data'>) => void; onCompleted?: (data: ExtractField, "completed" | "ended", 'data'>) => void; onError?: (error: string) => void; @@ -306,6 +307,11 @@ export function useJobStatus Date: Sun, 5 Apr 2026 12:46:50 +0300 Subject: [PATCH 33/65] fix: Made store downloads extract in their own folder feat: Implemented cemu integration --- src/bun/api/games/games.ts | 15 +-- .../api/games/services/launchGameService.ts | 97 +++++++++++++------ src/bun/api/jobs/install-job.ts | 5 +- src/bun/api/jobs/launch-game-job.ts | 9 +- .../com.simeonradivoev.gameflow.cemu/cemu.ts | 35 +++++++ .../package.json | 14 +++ .../dolphin.ts | 4 +- .../package.json | 2 +- .../pcsx2.ts | 8 +- .../com.simeonradivoev.gameflow.romm/romm.ts | 17 +++- src/mainview/emulatorjs/emulator.ts | 2 +- 11 files changed, 156 insertions(+), 52 deletions(-) create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 7c91954..1618c52 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -10,7 +10,7 @@ import path from "node:path"; import { convertLocalToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService"; import { errorToResponse } from "elysia/adapter/bun/handler"; -import { getEmulatorsForSystem, launchCommand } from "./services/launchGameService"; +import { getEmulatorsForSystem, getRomFilePaths, launchCommand } from "./services/launchGameService"; import { getErrorMessage, SeededRandom } from "@/bun/utils"; import { defaultFormats, defaultPlugins } from 'jimp'; import { createJimp } from "@jimp/core"; @@ -255,7 +255,8 @@ export default new Elysia() { const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), - columns: { path_fs: true } + columns: { path_fs: true }, + with: { platform: { columns: { es_slug: true } } } }); if (!localGame?.path_fs) @@ -265,13 +266,15 @@ export default new Elysia() const downloadPath = config.get('downloadPath'); const path_fs = path.join(downloadPath, localGame.path_fs); - const stats = await fs.stat(path_fs); - if (stats.isDirectory()) + + const filesPaths = await getRomFilePaths(path_fs, localGame.platform.es_slug ?? undefined); + + if (filesPaths.length <= 0) { - return status("Not Found", "Rom is a folder"); + throw new Error("No Valid Roms Found"); } - return Bun.file(path_fs); + return Bun.file(filesPaths[0]); }, { params: z.object({ source: z.string(), id: z.string() }) }) diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index 362ff41..4dde81b 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -67,6 +67,70 @@ export async function getEmulatorsForSystem (systemSlug: string) return Array.from(emulators); } +export async function getRomFilePaths (gamePath: string, systemSlug?: string) +{ + if (!existsSync(gamePath)) + { + throw new Error(`Provided rom path is missing: '${gamePath}'`); + } + + const gamePathStat = await fs.stat(gamePath); + const validFiles: string[] = []; + + if (gamePathStat.isDirectory()) + { + if (!systemSlug) throw new Error("Needs system to find valid file"); + + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(schema.systems.name, systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${systemSlug}'`); + } + + const extensionList = system.extension.join(','); + + for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`))) + { + validFiles.push(file); + } + + if (validFiles.length <= 0) + { + throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`); + } + } else if (systemSlug) + { + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(schema.systems.name, systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${systemSlug}'`); + } + + if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase()))) + { + validFiles.push(gamePath); + } + else + { + const extensionList = system.extension.join(','); + throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`); + } + } else + { + validFiles.push(gamePath); + } + + return validFiles; +} + /** * * @param data Uses es-de system slug @@ -96,38 +160,7 @@ export async function getValidLaunchCommands (data: { const downloadPath = config.get('downloadPath'); const gamePath = path.join(downloadPath, data.gamePath); - const validFiles: string[] = []; - if (!existsSync(gamePath)) - { - throw new Error(`Provided rom path is missing: '${gamePath}'`); - } - - const gamePathStat = await fs.stat(gamePath); - - const extensionList = system.extension.join(','); - - if (gamePathStat.isDirectory()) - { - for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`))) - { - validFiles.push(file); - } - - if (validFiles.length <= 0) - { - throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`); - } - } else - { - if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase()))) - { - validFiles.push(gamePath); - } - else - { - throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`); - } - } + const validFiles: string[] = await getRomFilePaths(gamePath, data.systemSlug); function escapeWindowsArg (arg: string): string { diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index a45fe7b..39c4687 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -70,7 +70,8 @@ export class InstallJob implements IJob name: game.title, summary: game.description, system_slug: gameId.system, - extract_path: path.join('roms', gameId.system), + path_fs: path.join('roms', gameId.system, game.title), + extract_path: path.join('roms', gameId.system, game.title), }; break; @@ -218,7 +219,7 @@ export class InstallJob implements IJob source_id: info.source_id, source: this.source, slug: info.slug, - path_fs: info.path_fs, + path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined), last_played: info.last_played, platform_id: platformId, igdb_id: info.igdb_id, diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 18b4fd1..c58119c 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -1,10 +1,11 @@ import z from "zod"; import { IJob, JobContext } from "../task-queue"; import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema"; -import { db, events, plugins } from "../app"; +import { config, db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; import { eq, sql } from "drizzle-orm"; import { spawn } from 'node:child_process'; +import path from "node:path"; export class LaunchGameJob implements IJob, "playing"> { @@ -60,7 +61,9 @@ export class LaunchGameJob implements IJob console.log(data)); @@ -82,6 +85,8 @@ export class LaunchGameJob implements IJob + { + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] }; + }); + + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => + { + const args: string[] = []; + + args.push(`--fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); + + const savesPath = path.join(config.get('downloadPath'), "saves", 'DOLPHIN'); + + args.push(`--mlc=${savesPath}`); + + if (ctx.autoValidCommand.metadata.romPath) + { + args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`); + } + + return args; + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json new file mode 100644 index 0000000..bbabba6 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json @@ -0,0 +1,14 @@ +{ + "name": "com.simeonradivoev.gameflow.cemu", + "displayName": "CEMU Integration", + "version": "0.0.1", + "description": "CEMU Emulator Integration", + "main": "./cemu.ts", + "icon": "https://upload.wikimedia.org/wikipedia/commons/9/9e/Cemu_Emulator_Official_Logo.png", + "keywords": [ + "integration", + "emulator", + "wiiu", + "cemu" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index 59cc505..d4ec3ca 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -25,7 +25,7 @@ export default class DOLPHINIntegration implements PluginType { const args: string[] = []; - const storageFolder = path.join(config.get('downloadPath'), "storage", 'DOLPHIN'); + const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator); args.push(`--user=${storageFolder}`); args.push(`--config=Dolphin.Display.Fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); @@ -35,7 +35,7 @@ export default class DOLPHINIntegration implements PluginType args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`); args.push(`--config=Dolphin.Analytics.PermissionAsked=True`); - const savesPath = path.join(config.get('downloadPath'), "saves", 'DOLPHIN'); + const savesPath = path.join(config.get('downloadPath'), "saves", this.emulator); args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`); args.push(`--config=Dolphin.General.WiiSDCardSyncFolder=${path.join(savesPath, 'WiiSDSync')}`); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json index 07fe38d..146b910 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json @@ -8,7 +8,7 @@ "keywords": [ "integration", "emulator", - "wiiu", + "wii", "gc", "dolphin" ] diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index 2e944d2..bd3b78f 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -49,9 +49,9 @@ export default class PCSX2Integration implements PluginType { const configFileContents = await Bun.file(configFile).text(); - const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2'); - const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); - const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); + const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); + const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator); + const savesFolder = path.join(config.get('downloadPath'), "saves", this.emulator); const view = { BIOS_PATH: biosFolder, @@ -70,7 +70,7 @@ export default class PCSX2Integration implements PluginType if (process.platform === 'win32') pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); else - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis'); + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis'); await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); } diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 654fb2a..644a505 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -191,6 +191,18 @@ export default class RommIntegration implements PluginType return file; })); + let extract_path: string | undefined = undefined; + let path_fs = path.join(rom.fs_path, rom.fs_name); + if (files.length === 1) + { + const name = files[0].file_name.toLocaleLowerCase(); + if (name.endsWith('.zip') || name.endsWith('.7z') || name.endsWith('.rar')) + { + extract_path = rom.name ?? path.parse(name).name; + path_fs = path.join(rom.fs_path, extract_path); + } + } + const info: DownloadInfo = { platform: { slug: rommPlatform.slug, @@ -204,13 +216,14 @@ export default class RommIntegration implements PluginType ra_id: rom.ra_id ?? undefined, summary: rom.summary ?? undefined, name: rom.name ?? "Unknown", - path_fs: path.join(rom.fs_path, rom.fs_name), + path_fs, source_id: String(rom.id), slug: rom.slug ?? undefined, system_slug: rommPlatform.slug, metadata: rom.metadatum, files, - auth: await this.getAuthToken() + auth: await this.getAuthToken(), + extract_path }; return info; diff --git a/src/mainview/emulatorjs/emulator.ts b/src/mainview/emulatorjs/emulator.ts index 61e570b..b5a730e 100644 --- a/src/mainview/emulatorjs/emulator.ts +++ b/src/mainview/emulatorjs/emulator.ts @@ -28,7 +28,7 @@ window.addEventListener('message', (e) => }); -window.EJS_threads = true; +window.EJS_threads = !__PUBLIC__; window.EJS_player = "#game"; window.EJS_lightgun = false; window.EJS_startOnLoaded = true; From 05fafced07c853deb656d7c17d05184c42ee507c Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Mon, 6 Apr 2026 00:05:00 +0300 Subject: [PATCH 34/65] feat: Added more ways to detect duplicates feat: Added resolution and widescreen settings feat: Added Xenia and Xemu integration --- bun.lock | 6 ++ package.json | 2 + src/bun/api/games/games.ts | 65 +++++++++++--- src/bun/api/hooks/games.ts | 2 +- src/bun/api/jobs/install-job.ts | 16 +++- .../com.simeonradivoev.gameflow.cemu/cemu.ts | 6 +- .../dolphin.ts | 13 ++- .../PCSX2.ini | 6 +- .../pcsx2.ts | 11 ++- .../linux/ppsspp.ini | 4 +- .../ppsspp.ts | 26 +++++- .../win32/ppsspp.ini | 4 +- .../eeprom.bin | Bin 0 -> 256 bytes .../package.json | 14 +++ .../com.simeonradivoev.gameflow.xemu/xemu.ts | 76 ++++++++++++++++ .../package.json | 15 ++++ .../xenia.ts | 82 ++++++++++++++++++ .../com.simeonradivoev.gameflow.romm/romm.ts | 13 +-- src/bun/api/plugins/register-plugins.ts | 6 ++ src/bun/api/task-queue.ts | 17 ++-- .../components/options/OptionDropdown.tsx | 1 - .../components/options/SettingsDropdown.tsx | 55 ++++++++++++ src/mainview/routes/settings/emulators.tsx | 5 +- src/shared/constants.ts | 5 +- src/shared/types..d.ts | 6 ++ 25 files changed, 407 insertions(+), 49 deletions(-) create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/eeprom.bin create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts create mode 100644 src/mainview/components/options/SettingsDropdown.tsx diff --git a/bun.lock b/bun.lock index b111ebe..1514d12 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,8 @@ "node-stream-zip": "^1.15.0", "open": "^11.0.0", "pathe": "^2.0.3", + "slugify": "^1.6.9", + "smol-toml": "^1.6.1", "systeminformation": "^5.31.5", "tapable": "^2.3.0", "tough-cookie": "^6.0.0", @@ -1516,6 +1518,10 @@ "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], + "slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="], + + "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + "socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="], "socket.io-adapter": ["socket.io-adapter@2.5.6", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.18.3" } }, "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ=="], diff --git a/package.json b/package.json index 7b6b2e6..fc73e47 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,8 @@ "node-stream-zip": "^1.15.0", "open": "^11.0.0", "pathe": "^2.0.3", + "slugify": "^1.6.9", + "smol-toml": "^1.6.1", "systeminformation": "^5.31.5", "tapable": "^2.3.0", "tough-cookie": "^6.0.0", diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 1618c52..531dc93 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -201,31 +201,68 @@ export default new Elysia() .groupBy(schema.games.id) .where(and(...where)); - localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`)); + localGamesSet = new Set( + localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`) + .concat(localGames.filter(g => !!g.igdb_id).map(g => `igdb@${g.igdb_id}`)) + ); - if (!query.collection_id) + function localGameExistsPredicate (game: { id: FrontEndId, igdb_id?: number | null, ra_id?: number | null; }) + { + if (localGamesSet?.has(`${game.id.source}@${game.id.id}`)) return true; + if (game.igdb_id && localGamesSet?.has(`igdb@${game.igdb_id}`)) return true; + if (game.ra_id && localGamesSet?.has(`ra@${game.ra_id}`)) return true; + return false; + } + + if (query.collection_id) + { + // Collections are just a remote thing for now. + const remoteGames: FrontEndGameTypeWithIds[] = []; + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); + games.push(...remoteGames.map(g => + { + if (localGameExistsPredicate(g)) + { + return convertLocalToFrontend(localGames.find(g => localGameExistsPredicate({ id: { id: g.source_id ?? '', source: g.source ?? '' }, igdb_id: g.igdb_id, ra_id: g.ra_id }))!); + } + else + { + return g; + } + })); + + } else { games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).map(g => { return convertLocalToFrontend(g); })); - const remoteGames: FrontEndGameType[] = []; + const remoteGames: FrontEndGameTypeWithIds[] = []; + const remoteGameSet = new Set(); await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); - games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`))); - } else - { - const remoteGames: FrontEndGameType[] = []; - await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); - games.push(...remoteGames.map(g => + games.push(...remoteGames.filter(g => { - if (localGamesSet?.has(`${g.id.source}@${g.id.id}`)) + if (localGameExistsPredicate(g)) { - return convertLocalToFrontend(localGames.find(l => l.source === g.id.source && l.source_id === g.id.id)!); - } else - { - return g; + return false; } + + if (g.igdb_id) + { + const igdbId = `igdb@${g.igdb_id}`; + if (remoteGameSet.has(igdbId)) return false; + remoteGameSet.add(igdbId); + } + + if (g.ra_id) + { + const raId = `ra@${g.ra_id}`; + if (remoteGameSet.has(raId)) return false; + remoteGameSet.add(raId); + } + + return true; })); } } diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index dd893d1..d276463 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -35,7 +35,7 @@ export class GameHooks */ fetchGames = new AsyncSeriesHook<[ctx: { query: GameListFilterType; - games: FrontEndGameType[]; + games: FrontEndGameTypeWithIds[]; }]>(['ctx']); fetchGame = new AsyncSeriesBailHook<[ctx: { source: string; diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 39c4687..18407ea 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -15,6 +15,7 @@ import z from "zod"; import { checkFiles } from "../games/services/utils"; import { ensureDir } from "fs-extra"; import { path7za } from "7zip-bin"; +import slugify from 'slugify'; interface JobConfig { @@ -70,8 +71,8 @@ export class InstallJob implements IJob name: game.title, summary: game.description, system_slug: gameId.system, - path_fs: path.join('roms', gameId.system, game.title), - extract_path: path.join('roms', gameId.system, game.title), + path_fs: path.join('roms', gameId.system, slugify(game.title)), + extract_path: '.', }; break; @@ -104,13 +105,17 @@ export class InstallJob implements IJob }); const downloadedFiles = await downloader.start(); + if (!downloadedFiles) + { + return; + } if (info.extract_path && downloadedFiles) { let progress = 0; const progressDelta = 1 / downloadedFiles.length; for (const filePath of downloadedFiles) { - const extractPath = path.join(config.get('downloadPath'), info.extract_path); + const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path); await new Promise((resolve, reject) => { const seven = Seven.extractFull(filePath, extractPath, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true }); @@ -119,7 +124,10 @@ export class InstallJob implements IJob cx.setProgress(progress + p.percent * progressDelta, "extract"); }); - seven.on('error', e => reject(e)); + seven.on('error', e => + { + reject(e); + }); seven.on('end', async () => { await fs.rm(filePath); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts index 60f8973..f42e221 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts @@ -3,7 +3,7 @@ import desc from './package.json'; import path from 'node:path'; import { config } from "@/bun/api/app"; -export default class DOLPHINIntegration implements PluginType +export default class CEMUIntegration implements PluginType { emulator = 'CEMU'; @@ -11,7 +11,7 @@ export default class DOLPHINIntegration implements PluginType { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; }); ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => @@ -20,7 +20,7 @@ export default class DOLPHINIntegration implements PluginType args.push(`--fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); - const savesPath = path.join(config.get('downloadPath'), "saves", 'DOLPHIN'); + const savesPath = path.join(config.get('downloadPath'), "saves", this.emulator); args.push(`--mlc=${savesPath}`); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index d4ec3ca..05fde39 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -13,7 +13,7 @@ export default class DOLPHINIntegration implements PluginType { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "resolution", "fullscreen", "states"] }; }); ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => @@ -35,6 +35,17 @@ export default class DOLPHINIntegration implements PluginType args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`); args.push(`--config=Dolphin.Analytics.PermissionAsked=True`); + const resolution = config.get('emulatorResolution'); + const resolutionMapping = { + "720p": 2, + "1080p": 3, + "1440p": 4, + "4k": 6 + }; + args.push(`--config=GFX.Settings.InternalResolution=${resolutionMapping[resolution] ?? 1}`); + args.push(`--config=GFX.Settings.wideScreenHack=${config.get('emulatorWidescreen') ? "True" : "False"}`); + args.push(`--config=GFX.Settings.AspectRatio=${config.get('emulatorWidescreen') ? "1" : "0"}`); + const savesPath = path.join(config.get('downloadPath'), "saves", this.emulator); args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini index cbafaf8..72985fb 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini @@ -21,7 +21,7 @@ CdvdShareWrite = false EnablePatches = true EnableCheats = false EnablePINE = false -EnableWideScreenPatches = false +EnableWideScreenPatches = {{ENABLE_WIDESCREEN}} EnableNoInterlacingPatches = false EnableRecordingTools = true EnableGameFixes = true @@ -92,7 +92,7 @@ VsyncEnable = 0 FramerateNTSC = 59.94 FrameratePAL = 50 SyncToHostRefreshRate = false -AspectRatio = Auto 4:3/3:2 +AspectRatio = {{ASPECT_RATIO}} FMVAspectRatioSwitch = Off ScreenshotSize = 0 ScreenshotFormat = 0 @@ -168,7 +168,7 @@ linear_present_mode = 1 deinterlace_mode = 0 OsdScale = 100 Renderer = 14 -upscale_multiplier = 1 +upscale_multiplier = {{UPSCALE_MULTIPLIER}} mipmap_hw = -1 accurate_blending_unit = 1 crc_hack_level = -1 diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index bd3b78f..a5fa18f 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -22,7 +22,7 @@ export default class PCSX2Integration implements PluginType return { id: desc.name, supportLevel: "full", - capabilities: [...baseCapabilities, "resolution", "config"] + capabilities: [...baseCapabilities, "config", "resolution"] }; } else @@ -52,6 +52,12 @@ export default class PCSX2Integration implements PluginType const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator); const savesFolder = path.join(config.get('downloadPath'), "saves", this.emulator); + const resolutionMapping = { + "720p": 2, + "1080p": 3, + "1440p": 4, + "4k": 6, + }; const view = { BIOS_PATH: biosFolder, @@ -62,6 +68,9 @@ export default class PCSX2Integration implements PluginType COVERS_PATH: path.join(storageFolder, 'covers'), TEXTURES_PATH: path.join(storageFolder, 'textures'), RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), + ENABLE_WIDESCREEN: config.get('emulatorWidescreen'), + ASPECT_RATIO: config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2", + UPSCALE_MULTIPLIER: resolutionMapping[config.get('emulatorResolution')] ?? 1 }; await Promise.all(Object.values(view).map(p => ensureDir(p))); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini index edd196b..afc914c 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini @@ -96,7 +96,7 @@ HardwareTransform = True SoftwareSkinning = True TextureFiltering = 1 BufferFiltering = 1 -InternalResolution = 3 +InternalResolution = {{RESOLUTION}} AndroidHwScale = 1 HighQualityDepth = 1 FrameSkip = 0 @@ -109,7 +109,7 @@ AnisotropyLevel = 4 VertexDecCache = False TextureBackoffCache = False TextureSecondaryCache = False -FullScreen = True +FullScreen = {{FULLSCREEN}} FullScreenMulti = False SmallDisplayZoomType = 2 SmallDisplayOffsetX = 0.500000 diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index b0d0a44..1f6572f 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -10,12 +10,21 @@ import Mustache from "mustache"; import { ensureDir } from "fs-extra"; import { homedir } from "node:os"; -export default class PCSX2Integration implements PluginType +export default class PPSSPPIntegration implements PluginType { emulator = "PPSSPP"; load (ctx: PluginContextType) { + ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => + { + await Bun.write(path.join(ctx.path, "portable.txt"), ""); + if (process.platform === 'win32') + { + await Bun.write(path.join(ctx.path, "installed.txt"), path.join(config.get('downloadPath'), 'saves', this.emulator)); + } + }); + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; @@ -25,7 +34,7 @@ export default class PCSX2Integration implements PluginType return { id: desc.name, supportLevel: "full", - capabilities: [...baseCapabilities, "resolution", "config"] + capabilities: [...baseCapabilities, "config", "resolution"] }; } else @@ -68,7 +77,7 @@ export default class PCSX2Integration implements PluginType let ppssppPath = ''; if (process.platform === 'win32') { - ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'); + ppssppPath = path.join(config.get('downloadPath'), 'saves', this.emulator, 'PSP', 'SYSTEM'); } else { //TODO: Use way to set custom memstick path when they support it @@ -80,8 +89,17 @@ export default class PCSX2Integration implements PluginType if (confPath) { + const resolutionMapping = { + "720p": "2", + "1080p": "4", + "1440p": "6", + "4k": "8" + }; const configFileContents = await Bun.file(confPath).text(); - await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {})); + await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, { + RESOLUTION: resolutionMapping[config.get('emulatorResolution')] ?? 0, + FULLSCREEN: config.get('launchInFullscreen') ? "True" : "False" + })); } if (controlsPath) diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini index f24ea4b..21a71c3 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini @@ -96,7 +96,7 @@ HardwareTransform = True SoftwareSkinning = True TextureFiltering = 1 BufferFiltering = 1 -InternalResolution = 3 +InternalResolution = {{RESOLUTION}} AndroidHwScale = 1 HighQualityDepth = 1 FrameSkip = 0 @@ -109,7 +109,7 @@ AnisotropyLevel = 4 VertexDecCache = False TextureBackoffCache = False TextureSecondaryCache = False -FullScreen = True +FullScreen = {{FULLSCREEN}} FullScreenMulti = False SmallDisplayZoomType = 2 SmallDisplayOffsetX = 0.500000 diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/eeprom.bin b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/eeprom.bin new file mode 100644 index 0000000000000000000000000000000000000000..55874b0f314b7e6741c4d0b632dbc235c14dbc26 GIT binary patch literal 256 zcmXR{kM}L+Eiz$Z<=*&3$H2+>`~OMi!t#r+CwM z?q$bGQDzpVh9;&KM&<^lMhpR;Qol+wFidRsa!8r>@pnM4h$f$I5hkZ mh<5i4VQ>l#0WraVi + { + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; + }); + + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => + { + const args: string[] = []; + + if (config.get('launchInFullscreen')) + { + args.push("-full-screen"); + } + + if (ctx.autoValidCommand.metadata.romPath) + { + args.push("-dvd_path"); + args.push(ctx.autoValidCommand.metadata.romPath); + } + + const configPath = path.join(config.get('downloadPath'), 'storage', this.emulator, 'xemu.toml'); + let configFile: { general: TomlTable & { misc: TomlTable; }, sys: TomlTable & { files: TomlTable; }; } = { general: { misc: {} }, sys: { files: {} } }; + if (await Bun.file(configPath).exists()) + { + configFile = toml.parse(await Bun.file(configPath).text()) as any; + } + + configFile.general.misc ??= {}; + configFile.general.misc.skip_boot_anim = true; + configFile.general.show_welcome = false; + configFile.general.games_dir = path.join(config.get('downloadPath'), 'roms', 'xbox'); + configFile.sys.mem_limit = '128'; + const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); + if (await fs.exists(biosFolder)) + { + const biosPaths = (await fs.readdir(biosFolder)); + const flash = biosPaths.find(f => f.endsWith('.bin') && !f.includes('mcpx')); + const bootrom = biosPaths.find(f => f.endsWith('.bin') && f.includes('mcpx')); + const hardDrive = biosPaths.find(f => f.endsWith('qcow2')); + if (flash) configFile.sys.files.flashrom_path = path.join(biosFolder, flash); + if (bootrom) configFile.sys.files.bootrom_path = path.join(biosFolder, bootrom); + if (hardDrive) configFile.sys.files.hdd_path = path.join(biosFolder, hardDrive); + } + + if (!ctx.dryRun) + { + const eepromPath = path.join(config.get('downloadPath'), "storage", this.emulator, 'eeprom.bin'); + await Bun.write(eepromPath, await Bun.file(bin).arrayBuffer()); + configFile.sys.files.eeprom_path = eepromPath; + + await Bun.write(configPath, toml.stringify(configFile)); + args.push("-config_path"); + args.push(configPath); + } + + + return args; + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json new file mode 100644 index 0000000..a6b3d25 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json @@ -0,0 +1,15 @@ +{ + "name": "com.simeonradivoev.gameflow.xenia", + "displayName": "XENIA Integration", + "version": "0.0.1", + "description": "XENIA Emulator Integration", + "main": "./xenia.ts", + "icon": "https://xenia.jp/images/logo-256x256.png", + "keywords": [ + "integration", + "emulator", + "xbox360", + "xenia", + "xenia-edge" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts new file mode 100644 index 0000000..7257559 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts @@ -0,0 +1,82 @@ +import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import desc from './package.json'; +import { GameflowHooks } from "@/bun/api/hooks/app"; +import { config } from "@/bun/api/app"; +import path from "node:path"; +import { ensureDir } from "fs-extra"; +import toml, { TomlTable } from 'smol-toml'; +import fs from 'node:fs/promises'; + +export default class XENIAIntegration implements PluginType +{ + emulator = 'XENIA'; + emulatorEdge = 'XENIA-EDGE'; + + async handlePostInstall (ctx: Parameters['0']) + { + await Bun.write(path.join(ctx.path, "portable.txt"), ""); + } + + async handleLaunch (ctx: Parameters['0']) + { + const args: string[] = []; + + if (ctx.autoValidCommand.metadata.romPath) + { + args.push(ctx.autoValidCommand.metadata.romPath); + } + + const configPath = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, `${ctx.autoValidCommand.emulator}.toml`); + + if (!ctx.dryRun) + { + await ensureDir(path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!)); + let configFile: TomlTable & { Storage: TomlTable, GPU: TomlTable, Display: TomlTable; } = { Storage: {}, GPU: {}, Display: {} }; + if (await fs.exists(configPath)) + { + configFile = toml.parse(await Bun.file(configPath).text()) as any; + } + + const resolutionMapping = { + "720p": 1, + "1080p": 2, + "1440p": 3, + "4k": 3 + }; + + configFile.Display.fullscreen = config.get('launchInFullscreen'); + configFile.GPU.draw_resolution_scale_x = resolutionMapping[config.get('emulatorResolution')] ?? 1; + configFile.GPU.draw_resolution_scale_y = resolutionMapping[config.get('emulatorResolution')] ?? 1; + await ensureDir(path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!)); + configFile.Storage.content_root = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!); + configFile.Storage.storage_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'config'); + configFile.Storage.cache_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'cache'); + + await Bun.write(configPath, toml.stringify(configFile)); + }; + + args.push(`--config`, configPath); + + if (config.get('launchInFullscreen')) + { + args.push(`--fullscreen`); + } + + return args; + } + + handleEmulatorLaunchSupport (ctx: Parameters['0']): + ReturnType + { + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; + } + + load (ctx: PluginContextType) + { + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, this.handleEmulatorLaunchSupport); + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulatorEdge }, this.handleEmulatorLaunchSupport); + + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, this.handleLaunch); + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulatorEdge }, this.handleLaunch); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 644a505..ac5439b 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -2,7 +2,7 @@ import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; -import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomsApiRomsGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; +import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; import { config } from "@/bun/api/app"; import path from 'node:path'; import fs from 'node:fs/promises'; @@ -138,7 +138,9 @@ export default class RommIntegration implements PluginType }); games.push(...rommGames.data.items.map(g => { - return this.convertRomToFrontend(g); + const game: FrontEndGameType & { igdb_id?: number; } = this.convertRomToFrontend(g); + game.igdb_id = g.igdb_id ?? undefined; + return game; })); } }); @@ -181,8 +183,9 @@ export default class RommIntegration implements PluginType const files = await Promise.all(rom.files.map(async f => { + getRomContentApiRomsIdContentFileNameGet; const file: DownloadFileEntry = { - url: new URL(`${config.get('rommAddress')}/api/romsfiles/${f.id}/content/${f.file_name}`), + url: new URL(`${config.get('rommAddress')}/api/roms/${f.id}/files/content/${f.file_name}`), file_name: f.file_name, file_path: f.file_path, size: f.file_size_bytes, @@ -198,8 +201,8 @@ export default class RommIntegration implements PluginType const name = files[0].file_name.toLocaleLowerCase(); if (name.endsWith('.zip') || name.endsWith('.7z') || name.endsWith('.rar')) { - extract_path = rom.name ?? path.parse(name).name; - path_fs = path.join(rom.fs_path, extract_path); + extract_path = '.'; + path_fs = path.join(rom.fs_path, rom.slug ?? rom.fs_name_no_ext); } } diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts index 99b9d17..3c49311 100644 --- a/src/bun/api/plugins/register-plugins.ts +++ b/src/bun/api/plugins/register-plugins.ts @@ -3,6 +3,9 @@ import { PluginManager } from "./plugin-manager"; import pcsx2 from './builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json'; import ppsspp from './builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json'; import dolphin from './builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json'; +import cemu from './builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json'; +import xenia from './builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json'; +import xemu from './builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json'; import romm from './builtin/sources/com.simeonradivoev.gameflow.romm/package.json'; import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema"; @@ -13,6 +16,9 @@ export default async function register (pluginManager: PluginManager) { ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') }, { ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') }, { ...dolphin, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin') }, + { ...cemu, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu') }, + { ...xenia, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia') }, + { ...xemu, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu') }, { ...romm, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.romm/romm') }, ]; diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index 2ef241c..f331bb6 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -105,7 +105,7 @@ export class TaskQueue { this.queue = []; this.activeQueue.forEach(c => c.abort()); - return Promise.all(this.activeQueue.map(c => c.promise.promise)); + return Promise.all(this.activeQueue.map(c => c.promise.promise.catch(e => console.error("Error During Task Queue Closing")))); } } @@ -212,10 +212,15 @@ export class JobContext, TData, TState extends str { this.events.emit('started', { id: this.m_id, job: this }); await this.m_job.start(this); - this.completed = true; - this.events.emit('completed', { id: this.m_id, job: this }); - this.m_promise.resolve(this.m_job.exposeData?.()); - + if (!this.abortSignal.aborted) + { + this.completed = true; + this.events.emit('completed', { id: this.m_id, job: this }); + this.m_promise.resolve(this.m_job.exposeData?.()); + } else + { + this.m_promise.resolve(undefined); + } } catch (error) { if (error !== 'cancel') @@ -225,7 +230,7 @@ export class JobContext, TData, TState extends str this.events.emit('error', { id: this.m_id, job: this, error }); this.error = error; - this.m_promise.reject(error); + this.m_promise.resolve(undefined); } finally { this.running = false; diff --git a/src/mainview/components/options/OptionDropdown.tsx b/src/mainview/components/options/OptionDropdown.tsx index a661739..239f70c 100644 --- a/src/mainview/components/options/OptionDropdown.tsx +++ b/src/mainview/components/options/OptionDropdown.tsx @@ -8,7 +8,6 @@ import { oneShot } from "@/mainview/scripts/audio/audio"; export function OptionDropdown (data: { name: string; - type: HTMLInputTypeAttribute; className?: string; placeholder?: string; icon?: JSX.Element; diff --git a/src/mainview/components/options/SettingsDropdown.tsx b/src/mainview/components/options/SettingsDropdown.tsx new file mode 100644 index 0000000..91f8451 --- /dev/null +++ b/src/mainview/components/options/SettingsDropdown.tsx @@ -0,0 +1,55 @@ +import { HTMLInputTypeAttribute, JSX, useCallback, useEffect, useState } from "react"; +import { SettingsType } from "../../../shared/constants"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { OptionSpace } from "./OptionSpace"; +import { OptionInput } from "./OptionInput"; +import { getSettingQuery, setSettingMutation } from "@queries/settings"; +import { OptionDropdown } from "./OptionDropdown"; + +export function SettingsDropdown (data: { + label: string; + id: KeysWithValueAssignableTo; + values: string[]; + placeholder?: string; + icon?: JSX.Element; + children?: any; +}) +{ + const [dirty, setDirty] = useState(false); + const [localValue, setLocalValue] = useState(); + const { data: serverValue } = useQuery(getSettingQuery(data.id)); + const setMutation = useMutation(setSettingMutation(data.id)); + + useEffect(() => + { + setLocalValue(serverValue as any); + setDirty(false); + }, [serverValue]); + + const handleSave = useCallback(() => + { + if (dirty) + { + setDirty(false); + setMutation.mutate(localValue); + } + }, [dirty, setDirty, localValue]); + + return ( + + + { + setLocalValue(v); + setMutation.mutate(v); + }} + value={localValue} values={data.values} + /> + {data.children} + + ); +} \ No newline at end of file diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index b5e25c8..6a09f01 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -8,7 +8,7 @@ import { Check, ChevronDown, FileQuestion, FolderSearch, Plug, SearchAlert, Stor import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog'; import classNames from 'classnames'; import { twMerge } from 'tailwind-merge'; -import { RPC_URL } from '../../../shared/constants'; +import { RPC_URL, SettingsSchema } from '../../../shared/constants'; import emulators from '@emulators'; import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts'; @@ -19,6 +19,7 @@ import Carousel from '@/mainview/components/Carousel'; import { FOCUS_KEYS } from '@/mainview/scripts/types'; import { scrollIntoNearestParent, scrollIntoViewHandler, useDragScroll } from '@/mainview/scripts/utils'; import { SettingsOption } from '@/mainview/components/options/SettingsOption'; +import { SettingsDropdown } from '@/mainview/components/options/SettingsDropdown'; export const Route = createFileRoute('/settings/emulators')({ component: RouteComponent, @@ -328,6 +329,8 @@ function RouteComponent ()
      Preferences
      + +
      Overrides
      {!!customEmulators && customEmulators.map((key) => )} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 3eff7d5..dfcd52a 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,5 +1,6 @@ +import { emulators } from '@/bun/api/schema/emulators'; import { FocusDetails } from '@noriginmedia/norigin-spatial-navigation'; import { JSX } from 'react'; import * as z from 'zod'; @@ -35,7 +36,9 @@ export const SettingsSchema = z.object({ windowPosition: z.object({ x: z.number(), y: z.number() }).optional(), downloadPath: z.string(), launchInFullscreen: z.boolean().default(true), - disabledPlugins: z.array(z.string()).default([]) + disabledPlugins: z.array(z.string()).default([]), + emulatorResolution: z.enum(['720p', '1080p', '1440p', '4k']).default('720p'), + emulatorWidescreen: z.boolean().default(true) }); export const LocalSettingsSchema = z.object({ diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index e41afc6..a417dce 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -153,6 +153,12 @@ declare interface FrontEndPlatformType paths_screenshots: string[]; } +declare interface FrontEndGameTypeWithIds extends FrontEndGameType +{ + igdb_id: number | null; + ra_id: number | null; +} + declare interface FrontEndGameType { platform_display_name: string | null, From 02a4f2c9a9aff8f7282c531de0e2797dacc2c2f8 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Mon, 6 Apr 2026 00:13:53 +0300 Subject: [PATCH 35/65] refactor: Removed unused vars and imports --- package.json | 3 ++- src/bun/api/auth.ts | 2 +- src/bun/api/games/services/statusService.ts | 4 ++-- src/bun/api/games/services/utils.ts | 3 +-- src/bun/api/hooks/emulators.ts | 1 - src/bun/api/jobs/launch-game-job.ts | 5 ++--- .../com.simeonradivoev.gameflow.dolphin/dolphin.ts | 2 +- .../com.simeonradivoev.gameflow.pcsx2/pcsx2.ts | 12 ++++++++---- .../com.simeonradivoev.gameflow.xemu/xemu.ts | 2 -- .../sources/com.simeonradivoev.gameflow.romm/romm.ts | 7 +++++-- src/bun/api/system.ts | 2 +- src/bun/types/typesc.schema.ts | 1 - src/bun/utils/downloader.ts | 2 +- src/mainview/components/AutoFocus.tsx | 2 +- src/mainview/components/FocusTooltip.tsx | 2 +- src/mainview/components/LoadMoreButton.tsx | 1 - src/mainview/components/options/LocalOption.tsx | 1 - src/mainview/components/options/OptionDropdown.tsx | 2 +- src/mainview/components/options/SettingsDropdown.tsx | 3 +-- src/mainview/components/store/StoreEmulatorCard.tsx | 4 +--- src/mainview/routes/game/$source.$id.tsx | 6 +++--- src/mainview/routes/settings/accounts.tsx | 2 +- src/mainview/routes/settings/emulators.tsx | 4 ++-- src/mainview/routes/store/details.emulator.$id.tsx | 4 ++-- src/mainview/routes/store/tab/emulators.tsx | 4 ++-- src/mainview/routes/store/tab/route.tsx | 1 - src/shared/constants.ts | 1 - 27 files changed, 39 insertions(+), 44 deletions(-) diff --git a/package.json b/package.json index fc73e47..6869a4b 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "package:Linux": "bun run build:prod:appimage", "package:Windows": "bun run build:prod", "download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium", - "build:audiosprites": "bun ./scripts/generate-audio-sprites.ts" + "build:audiosprites": "bun ./scripts/generate-audio-sprites.ts", + "tsc": "tsc --noEmit" }, "dependencies": { "7zip-bin": "^5.2.0", diff --git a/src/bun/api/auth.ts b/src/bun/api/auth.ts index b171ed0..6cf37eb 100644 --- a/src/bun/api/auth.ts +++ b/src/bun/api/auth.ts @@ -1,5 +1,5 @@ import Elysia, { status } from "elysia"; -import { config, events, jar, plugins, taskQueue } from "./app"; +import { config, events, plugins, taskQueue } from "./app"; import z from "zod"; import { getCurrentUserApiUsersMeGet, tokenApiTokenPost, UserSchema } from "@clients/romm"; import secrets from '../api/secrets'; diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 4f1d99b..79aaa10 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -1,9 +1,9 @@ import { RPC_URL, } from "@shared/constants"; -import { config, customEmulators, db, emulatorsDb, plugins, taskQueue } from "../../app"; +import { config, db, emulatorsDb, plugins, taskQueue } from "../../app"; import { findExecs, getValidLaunchCommands } from "./launchGameService"; import * as emulatorSchema from '@schema/emulators'; import { and, eq } from "drizzle-orm"; -import { getErrorMessage, hashFile } from "@/bun/utils"; +import { getErrorMessage } from "@/bun/utils"; import { checkFiles, getLocalGameMatch } from "./utils"; import fs from 'node:fs/promises'; import { getStoreGameFromId } from "../../store/services/gamesService"; diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index f40eb8a..cb53377 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -5,10 +5,9 @@ import { config, db, emulatorsDb, plugins } from "../../app"; import { and, eq } from "drizzle-orm"; import * as schema from "@schema/app"; import { StoreGameType } from "@shared/constants"; -import { DetailedRomSchema, getCurrentUserApiUsersMeGet, getRomApiRomsIdGet, SimpleRomSchema } from "@clients/romm"; import * as emulatorSchema from "@schema/emulators"; import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService"; -import { hashFile, isSteamDeck, isSteamDeckGameMode } from "@/bun/utils"; +import { hashFile } from "@/bun/utils"; export async function calculateSize (installPath: string | null) { diff --git a/src/bun/api/hooks/emulators.ts b/src/bun/api/hooks/emulators.ts index b968197..6740b30 100644 --- a/src/bun/api/hooks/emulators.ts +++ b/src/bun/api/hooks/emulators.ts @@ -1,6 +1,5 @@ import { EmulatorDownloadInfoType, EmulatorPackageType } from "@/shared/constants"; import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; -import { any } from "zod"; interface EmulatorPostInstallContext { diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index c58119c..183e985 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -1,11 +1,10 @@ import z from "zod"; import { IJob, JobContext } from "../task-queue"; import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema"; -import { config, db, events, plugins } from "../app"; +import { db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; -import { eq, sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { spawn } from 'node:child_process'; -import path from "node:path"; export class LaunchGameJob implements IJob, "playing"> { diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index 05fde39..038cdfb 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -1,5 +1,5 @@ -import { config, db } from "@/bun/api/app"; +import { config } from "@/bun/api/app"; import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import path from 'node:path'; import desc from './package.json'; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index a5fa18f..5317395 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -1,5 +1,5 @@ -import { config, db } from "@/bun/api/app"; +import { config } from "@/bun/api/app"; import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import configFile from './PCSX2.ini' with { type: 'file' }; import Mustache from 'mustache'; @@ -59,7 +59,7 @@ export default class PCSX2Integration implements PluginType "4k": 6, }; - const view = { + const paths = { BIOS_PATH: biosFolder, SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'), SAVE_STATES_PATH: path.join(savesFolder, 'states'), @@ -68,13 +68,17 @@ export default class PCSX2Integration implements PluginType COVERS_PATH: path.join(storageFolder, 'covers'), TEXTURES_PATH: path.join(storageFolder, 'textures'), RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), + }; + + await Promise.all(Object.values(paths).map(p => ensureDir(p))); + + const view = { + ...paths, ENABLE_WIDESCREEN: config.get('emulatorWidescreen'), ASPECT_RATIO: config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2", UPSCALE_MULTIPLIER: resolutionMapping[config.get('emulatorResolution')] ?? 1 }; - await Promise.all(Object.values(view).map(p => ensureDir(p))); - let pscx2Path = ''; if (process.platform === 'win32') pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts index d81722a..49e56f3 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts @@ -1,9 +1,7 @@ import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; -import { GameflowHooks } from "@/bun/api/hooks/app"; import { config } from "@/bun/api/app"; import path from "node:path"; -import { ensureDir } from "fs-extra"; import toml, { TomlTable } from 'smol-toml'; import fs from 'node:fs/promises'; import bin from './eeprom.bin' with { type: 'file' }; diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index ac5439b..2b3621c 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -138,8 +138,11 @@ export default class RommIntegration implements PluginType }); games.push(...rommGames.data.items.map(g => { - const game: FrontEndGameType & { igdb_id?: number; } = this.convertRomToFrontend(g); - game.igdb_id = g.igdb_id ?? undefined; + const game: FrontEndGameTypeWithIds = { + ...this.convertRomToFrontend(g), + igdb_id: g.igdb_id, + ra_id: g.ra_id + }; return game; })); } diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index aa9207e..720a10b 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -10,7 +10,7 @@ import path, { dirname } from "node:path"; import { DirSchema, SystemInfoSchema } from "@/shared/constants"; import { getDevices, getDevicesCurated } from "./drives"; import getFolderSize from "get-folder-size"; -import si, { battery } from 'systeminformation'; +import si from 'systeminformation'; import { getStoreFolder } from "./store/services/gamesService"; export const system = new Elysia({ prefix: '/api/system' }) diff --git a/src/bun/types/typesc.schema.ts b/src/bun/types/typesc.schema.ts index ee1da2b..aafc779 100644 --- a/src/bun/types/typesc.schema.ts +++ b/src/bun/types/typesc.schema.ts @@ -1,6 +1,5 @@ import z from "zod"; import { GameflowHooks } from "../api/hooks/app"; -import { ChildProcess } from "node:child_process"; export const PluginContextSchema = z.object({ hooks: z.instanceof(GameflowHooks) diff --git a/src/bun/utils/downloader.ts b/src/bun/utils/downloader.ts index 2a014b9..8d443c2 100644 --- a/src/bun/utils/downloader.ts +++ b/src/bun/utils/downloader.ts @@ -1,4 +1,4 @@ -import { ensureDir, move } from "fs-extra"; +import { ensureDir } from "fs-extra"; import path from 'node:path'; import fs from 'node:fs/promises'; diff --git a/src/mainview/components/AutoFocus.tsx b/src/mainview/components/AutoFocus.tsx index 8c42502..74c6da4 100644 --- a/src/mainview/components/AutoFocus.tsx +++ b/src/mainview/components/AutoFocus.tsx @@ -1,5 +1,5 @@ import { doesFocusableExist, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; -import { useEffect, useLayoutEffect } from "react"; +import { useLayoutEffect } from "react"; export function AutoFocus (data: { parentKey?: string; diff --git a/src/mainview/components/FocusTooltip.tsx b/src/mainview/components/FocusTooltip.tsx index 6916c1e..28f4cf1 100644 --- a/src/mainview/components/FocusTooltip.tsx +++ b/src/mainview/components/FocusTooltip.tsx @@ -1,4 +1,4 @@ -import { Ref, RefObject, useEffect, useState } from "react"; +import { RefObject, useState } from "react"; import { useFocusEventListener } from "../scripts/spatialNavigation"; import useActiveControl from "../scripts/gamepads"; import { twMerge } from "tailwind-merge"; diff --git a/src/mainview/components/LoadMoreButton.tsx b/src/mainview/components/LoadMoreButton.tsx index 84db100..afcd9b7 100644 --- a/src/mainview/components/LoadMoreButton.tsx +++ b/src/mainview/components/LoadMoreButton.tsx @@ -1,7 +1,6 @@ import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { FOCUS_KEYS } from "../scripts/types"; import { useIntersectionObserver } from "usehooks-ts"; -import { useEffect } from "react"; export default function LoadMoreButton (data: { isFetching: boolean; lastId?: FrontEndId; } & FocusParams & InteractParams) { diff --git a/src/mainview/components/options/LocalOption.tsx b/src/mainview/components/options/LocalOption.tsx index 72de9ef..7e55506 100644 --- a/src/mainview/components/options/LocalOption.tsx +++ b/src/mainview/components/options/LocalOption.tsx @@ -21,7 +21,6 @@ export function LocalOption (data: { {data.type === 'dropdown' && data.values && diff --git a/src/mainview/components/options/OptionDropdown.tsx b/src/mainview/components/options/OptionDropdown.tsx index 239f70c..e2ed246 100644 --- a/src/mainview/components/options/OptionDropdown.tsx +++ b/src/mainview/components/options/OptionDropdown.tsx @@ -1,4 +1,4 @@ -import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useState } from "react"; +import { FocusEventHandler, HTMLInputAutoCompleteAttribute, JSX, useState } from "react"; import { twMerge } from "tailwind-merge"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { ContextDialog, ContextList, DialogEntry } from "../ContextDialog"; diff --git a/src/mainview/components/options/SettingsDropdown.tsx b/src/mainview/components/options/SettingsDropdown.tsx index 91f8451..563b859 100644 --- a/src/mainview/components/options/SettingsDropdown.tsx +++ b/src/mainview/components/options/SettingsDropdown.tsx @@ -1,8 +1,7 @@ -import { HTMLInputTypeAttribute, JSX, useCallback, useEffect, useState } from "react"; +import { JSX, useCallback, useEffect, useState } from "react"; import { SettingsType } from "../../../shared/constants"; import { useMutation, useQuery } from "@tanstack/react-query"; import { OptionSpace } from "./OptionSpace"; -import { OptionInput } from "./OptionInput"; import { getSettingQuery, setSettingMutation } from "@queries/settings"; import { OptionDropdown } from "./OptionDropdown"; diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index a9f7720..d9b81f7 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -1,11 +1,9 @@ import { twMerge } from "tailwind-merge"; import { RPC_URL } from "@/shared/constants"; -import { Button } from "../options/Button"; -import useActiveControl from "@/mainview/scripts/gamepads"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; -import { BadgeCheck, ChevronRight, CircleFadingArrowUp, EllipsisVertical, FileQuestion, IceCream2, Package, Sparkles, Store, WandSparkles } from "lucide-react"; +import { CircleFadingArrowUp, FileQuestion, IceCream2, Package, Store, WandSparkles } from "lucide-react"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import { FlatpackIcon } from "@/mainview/scripts/brandIcons"; import { JSX } from "react"; diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 511ed9b..5f9246c 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -1,6 +1,6 @@ -import { createFileRoute, ErrorComponentProps, useRouter, useRouterState } from "@tanstack/react-router"; +import { createFileRoute, ErrorComponentProps, useRouter } from "@tanstack/react-router"; import { RPC_URL } from "@shared/constants"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { Calendar, Folder, Gamepad2, Image, Info, TriangleAlert, Trophy } from "lucide-react"; import { HeaderUI, StickyHeaderUI } from "../../components/Header"; @@ -9,7 +9,7 @@ import { useQuery } from "@tanstack/react-query"; import Shortcuts from "../../components/Shortcuts"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; import Screenshots from "@/mainview/components/Screenshots"; -import { HandleGoBack, scrollIntoViewHandler, useOnNavigateBack, useStickyDataAttr } from "@/mainview/scripts/utils"; +import { HandleGoBack, scrollIntoViewHandler, useOnNavigateBack } from "@/mainview/scripts/utils"; import { FilterUI } from "@/mainview/components/Filters"; import StatList, { StatEntry } from "@/mainview/components/StatList"; import { useIntersectionObserver, useLocalStorage } from "usehooks-ts"; diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index 841702b..4ac5625 100644 --- a/src/mainview/routes/settings/accounts.tsx +++ b/src/mainview/routes/settings/accounts.tsx @@ -7,7 +7,7 @@ import import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import classNames from "classnames"; -import { Key, Link, Lock, LogIn, LogOut, Save, ScanQrCode, Trash, User, X } from "lucide-react"; +import { Key, Link, Lock, LogIn, LogOut, ScanQrCode, User, X } from "lucide-react"; import { useEffect, diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index 6a09f01..f9921a3 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -2,9 +2,9 @@ import { createFileRoute, useRouter } from '@tanstack/react-router'; import { OptionSpace } from '../../components/options/OptionSpace'; import { OptionInput } from '../../components/options/OptionInput'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { JSX, useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Button } from '../../components/options/Button'; -import { Check, ChevronDown, FileQuestion, FolderSearch, Plug, SearchAlert, Store, Trash, TriangleAlert } from 'lucide-react'; +import { Check, ChevronDown, FileQuestion, FolderSearch, Plug, SearchAlert, Store, Trash } from 'lucide-react'; import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog'; import classNames from 'classnames'; import { twMerge } from 'tailwind-merge'; diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 3bbfd74..b2543d7 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useRef } from "react"; import { useFocusable, @@ -17,7 +17,7 @@ import Screenshots from "@/mainview/components/Screenshots"; import { StickyHeaderUI } from "@/mainview/components/Header"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection"; -import { HandleGoBack, scrollIntoViewHandler, useJobStatus, useOnNavigateBack } from "@/mainview/scripts/utils"; +import { HandleGoBack, scrollIntoViewHandler, useJobStatus } from "@/mainview/scripts/utils"; import toast from "react-hot-toast"; import { getErrorMessage } from "react-error-boundary"; import { emulatorStatusIcons } from "@/mainview/components/store/StoreEmulatorCard"; diff --git a/src/mainview/routes/store/tab/emulators.tsx b/src/mainview/routes/store/tab/emulators.tsx index 7d1aafd..524e20a 100644 --- a/src/mainview/routes/store/tab/emulators.tsx +++ b/src/mainview/routes/store/tab/emulators.tsx @@ -1,7 +1,7 @@ -import { createFileRoute, ErrorComponentProps, useSearch } from '@tanstack/react-router'; -import { Joystick, TriangleAlert } from 'lucide-react'; +import { createFileRoute, useSearch } from '@tanstack/react-router'; +import { Joystick } from 'lucide-react'; import { useContext, useEffect } from 'react'; import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard'; diff --git a/src/mainview/routes/store/tab/route.tsx b/src/mainview/routes/store/tab/route.tsx index 2f8f091..2171770 100644 --- a/src/mainview/routes/store/tab/route.tsx +++ b/src/mainview/routes/store/tab/route.tsx @@ -95,7 +95,6 @@ function RouteComponent () }; const { shortcuts } = useShortcutContext(); - const { focus } = Route.useSearch(); const handleDetails = (type: string, source: string, id: string, focus: string) => { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index dfcd52a..b7e3236 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,6 +1,5 @@ -import { emulators } from '@/bun/api/schema/emulators'; import { FocusDetails } from '@noriginmedia/norigin-spatial-navigation'; import { JSX } from 'react'; import * as z from 'zod'; From 54dd9256e361877d0950a84061d9402616706352 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Tue, 7 Apr 2026 15:28:56 +0300 Subject: [PATCH 36/65] feat: implemented haptics feat: Implemented a select menu fix: Only used audio clips compile --- README.md | 1 + scripts/generate-audio-sprites.ts | 29 +- src/mainview/App.tsx | 2 +- src/mainview/assets/intro.ogg | 3 + src/mainview/assets/sounds.json | 326 +++--------------- src/mainview/assets/sounds.ogg | 4 +- src/mainview/components/CardList.tsx | 5 +- src/mainview/components/CollectionsDetail.tsx | 11 +- src/mainview/components/ContextDialog.tsx | 15 +- src/mainview/components/Error.tsx | 5 +- src/mainview/components/FocusDots.tsx | 4 +- src/mainview/components/NotFound.tsx | 5 +- src/mainview/components/SelectMenu.tsx | 106 ++++++ src/mainview/components/Shortcuts.tsx | 42 ++- src/mainview/components/game/MainActions.tsx | 4 +- .../components/options/LocalOption.tsx | 26 +- .../components/options/OptionInput.tsx | 110 +++++- .../components/options/OptionSpace.tsx | 10 +- src/mainview/routes/embedded.$source.$id.tsx | 18 +- src/mainview/routes/game/$source.$id.tsx | 21 +- src/mainview/routes/index.tsx | 27 +- src/mainview/routes/launcher.$source.$id.tsx | 10 +- src/mainview/routes/settings/interface.tsx | 2 + src/mainview/routes/settings/route.tsx | 19 +- .../routes/store/details.emulator.$id.tsx | 8 +- src/mainview/routes/store/tab/index.tsx | 16 +- src/mainview/routes/store/tab/route.tsx | 15 +- src/mainview/scripts/audio/audio.ts | 41 +-- src/mainview/scripts/audio/audioConstants.ts | 23 ++ src/mainview/scripts/contexts.ts | 11 +- ...audioCallbacks.ts => feedbackCallbacks.ts} | 14 +- src/mainview/scripts/gamepads.ts | 43 ++- src/mainview/scripts/shortcuts.ts | 1 + src/mainview/scripts/spatialNavigation.ts | 2 +- src/mainview/scripts/types.ts | 1 + src/mainview/scripts/utils.ts | 15 +- src/mainview/types.d.ts | 8 + src/shared/constants.ts | 4 +- src/sounds/UI SFX_InGameMenu_Open.ogg | 3 + src/sounds/UI_Flourish Down_Set 14_01.wav | 3 + src/sounds/UI_Flourish Up_Set 14_01.wav | 3 + src/sounds/UI_Single_Set 11_01.wav | 3 + src/sounds/UI_Single_Set 11_02.wav | 3 + src/sounds/UI_Single_Set 11_03.wav | 3 + src/sounds/UI_Single_Set 5_02.wav | 3 + src/sounds/UI_TwoNote Down_Set 11_01.wav | 3 + src/sounds/UI_TwoNote Down_Set 14_01.wav | 3 + src/sounds/UI_TwoNote Up_Set 11_01.wav | 3 + src/sounds/UI_TwoNote Up_Set 11_02.wav | 3 + src/sounds/UI_TwoNote Up_Set 11_03.wav | 3 + src/sounds/UI_TwoNote Up_Set 14_01.wav | 3 + 51 files changed, 580 insertions(+), 466 deletions(-) create mode 100644 src/mainview/assets/intro.ogg create mode 100644 src/mainview/components/SelectMenu.tsx create mode 100644 src/mainview/scripts/audio/audioConstants.ts rename src/mainview/scripts/{audio/audioCallbacks.ts => feedbackCallbacks.ts} (81%) create mode 100644 src/sounds/UI SFX_InGameMenu_Open.ogg create mode 100644 src/sounds/UI_Flourish Down_Set 14_01.wav create mode 100644 src/sounds/UI_Flourish Up_Set 14_01.wav create mode 100644 src/sounds/UI_Single_Set 11_01.wav create mode 100644 src/sounds/UI_Single_Set 11_02.wav create mode 100644 src/sounds/UI_Single_Set 11_03.wav create mode 100644 src/sounds/UI_Single_Set 5_02.wav create mode 100644 src/sounds/UI_TwoNote Down_Set 11_01.wav create mode 100644 src/sounds/UI_TwoNote Down_Set 14_01.wav create mode 100644 src/sounds/UI_TwoNote Up_Set 11_01.wav create mode 100644 src/sounds/UI_TwoNote Up_Set 11_02.wav create mode 100644 src/sounds/UI_TwoNote Up_Set 11_03.wav create mode 100644 src/sounds/UI_TwoNote Up_Set 14_01.wav diff --git a/README.md b/README.md index 040a745..e3b2c46 100644 --- a/README.md +++ b/README.md @@ -96,3 +96,4 @@ Focused on building a simple user experience and intuitive UI as a curated commu - UI Sounds - [CC BY 4.0 - Credit: JC Sounds](https://opengameart.org/content/jc-sounds-ui-utility-pack-vol-1) - [Sounds by: Chhoff](https://chhoffmusic.itch.io/classic-ui-sfx) + - [UI Sound Effects by lolurio](https://lolurio.itch.io/lolurios-free-cozy-ui-sfx) diff --git a/scripts/generate-audio-sprites.ts b/scripts/generate-audio-sprites.ts index bcce3e8..1625362 100644 --- a/scripts/generate-audio-sprites.ts +++ b/scripts/generate-audio-sprites.ts @@ -1,24 +1,31 @@ import audioSprite from 'audiosprite'; -import { $, which } from 'bun'; -import fs from "node:fs/promises"; +import { $ } from 'bun'; import path from 'node:path'; +import { soundMap } from '../src/mainview/scripts/audio/audioConstants'; -var files = await Array.fromAsync(new Bun.Glob('*.{ogg,wav}').scan({ cwd: './src/sounds' })); +var allFiles = await Array.fromAsync(new Bun.Glob('*.{ogg,wav}').scan({ cwd: './src/sounds' })); +const files = Object.values(soundMap).map(v => +{ + const existingFile = allFiles.find(f => f.startsWith(v.key)); + if (!existingFile) throw new Error(`Could not find file for sound ${v.key}`); + const filePath = path.join(path.resolve('./src/sounds'), existingFile); + return filePath; +}); console.log("Loaded", files.join(",")); await new Promise((resolve) => { - audioSprite( - files.map(f => path.join(path.resolve('./src/sounds'), f)), + audioSprite(files, { output: path.resolve('./src/mainview/assets/sounds'), path: path.resolve('./src/sounds'), format: 'howler', export: 'ogg' - }, async function (err, obj: any) - { - if (err) return console.error(err); - delete obj.urls; - Bun.file('./src/mainview/assets/sounds.json').write(JSON.stringify(obj, null, 2)).then(r => resolve(true)); - }); + }, + async function (err, obj: any) + { + if (err) return console.error(err); + delete obj.urls; + Bun.file('./src/mainview/assets/sounds.json').write(JSON.stringify(obj, null, 2)).then(r => resolve(true)); + }); }); \ No newline at end of file diff --git a/src/mainview/App.tsx b/src/mainview/App.tsx index fb0d2db..76dcda3 100644 --- a/src/mainview/App.tsx +++ b/src/mainview/App.tsx @@ -1,7 +1,7 @@ import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; import { Router } from "."; import { useEffect } from "react"; -import audioCallbacks from "./scripts/audio/audioCallbacks"; +import audioCallbacks from "./scripts/feedbackCallbacks"; import { client as rommClient } from "../clients/romm/client.gen"; import { RPC_URL } from "@/shared/constants"; diff --git a/src/mainview/assets/intro.ogg b/src/mainview/assets/intro.ogg new file mode 100644 index 0000000..e1505f9 --- /dev/null +++ b/src/mainview/assets/intro.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:231ac69f71f4a0a770ae4bbfd42db9ea136dad6813ddae68a211c74a16e21778 +size 74296 diff --git a/src/mainview/assets/sounds.json b/src/mainview/assets/sounds.json index ae3bae3..97b63f9 100644 --- a/src/mainview/assets/sounds.json +++ b/src/mainview/assets/sounds.json @@ -1,304 +1,64 @@ { "sprite": { - "Classic UI SFX - Chords #1": [ + "Classic UI SFX - Chords #2": [ 0, 4005.215419501134 ], - "Classic UI SFX - Chords #10": [ - 6000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #11": [ - 12000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #12": [ - 18000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #13": [ - 24000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #14": [ - 30000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #15": [ - 36000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #16": [ - 42000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #17": [ - 48000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #18": [ - 54000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #19": [ - 60000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #2": [ - 66000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #20": [ - 72000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #3": [ - 78000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #4": [ - 84000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #5": [ - 90000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #6": [ - 96000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #7": [ - 102000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #8": [ - 108000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #9": [ - 114000, - 4005.215419501127 - ], - "Classic UI SFX - Short - High #1": [ - 120000, - 2546.893424036284 - ], - "Classic UI SFX - Short - High #10": [ - 124000, - 2552.0861678004535 - ], - "Classic UI SFX - Short - High #11": [ - 128000, - 2927.0975056689394 - ], - "Classic UI SFX - Short - High #12": [ - 132000, - 2927.0975056689394 - ], - "Classic UI SFX - Short - High #13": [ - 136000, - 3000 - ], - "Classic UI SFX - Short - High #14": [ - 140000, - 2802.0861678004394 - ], - "Classic UI SFX - Short - High #15": [ - 144000, - 2723.9455782312803 - ], - "Classic UI SFX - Short - High #16": [ - 148000, - 2927.0975056689394 - ], - "Classic UI SFX - Short - High #17": [ - 152000, - 2880.226757369627 - ], - "Classic UI SFX - Short - High #18": [ - 156000, - 2359.387755102034 - ], - "Classic UI SFX - Short - High #19": [ - 160000, - 3052.0861678004394 - ], - "Classic UI SFX - Short - High #2": [ - 165000, - 2843.7641723355964 - ], - "Classic UI SFX - Short - High #20": [ - 169000, - 2015.6462585034092 - ], - "Classic UI SFX - Short - High #21": [ - 173000, - 2005.215419501127 - ], - "Classic UI SFX - Short - High #22": [ - 177000, - 2489.5918367346894 - ], - "Classic UI SFX - Short - High #23": [ - 181000, - 2458.3446712018144 - ], - "Classic UI SFX - Short - High #24": [ - 185000, - 2093.7641723355964 - ], - "Classic UI SFX - Short - High #25": [ - 189000, - 2005.215419501127 - ], - "Classic UI SFX - Short - High #3": [ - 193000, - 2864.6031746031613 - ], - "Classic UI SFX - Short - High #4": [ - 197000, - 3031.2698412698464 - ], - "Classic UI SFX - Short - High #5": [ - 202000, - 2598.9795918367236 - ], - "Classic UI SFX - Short - High #6": [ - 206000, - 2427.0975056689394 - ], - "Classic UI SFX - Short - High #7": [ - 210000, - 2468.752834467125 - ], - "Classic UI SFX - Short - High #8": [ - 214000, - 2916.666666666657 - ], - "Classic UI SFX - Short - High #9": [ - 218000, - 2250 - ], - "Classic UI SFX - Short - Low #1": [ - 222000, - 2010.4308390022538 - ], - "Classic UI SFX - Short - Low #10": [ - 226000, - 3020.8390022675644 - ], - "Classic UI SFX - Short - Low #11": [ - 231000, - 2458.3446712018144 - ], - "Classic UI SFX - Short - Low #12": [ - 235000, - 2901.0430839002197 - ], - "Classic UI SFX - Short - Low #13": [ - 239000, - 2843.7641723355964 - ], - "Classic UI SFX - Short - Low #14": [ - 243000, - 3135.4195011337824 - ], - "Classic UI SFX - Short - Low #15": [ - 248000, - 2703.1292517006877 - ], - "Classic UI SFX - Short - Low #16": [ - 252000, - 2875.011337868472 - ], - "Classic UI SFX - Short - Low #17": [ - 256000, - 2927.0975056689394 - ], - "Classic UI SFX - Short - Low #18": [ - 260000, - 3057.2789115646515 - ], - "Classic UI SFX - Short - Low #19": [ - 265000, - 2473.9455782312803 - ], "Classic UI SFX - Short - Low #2": [ - 269000, - 2583.3333333333144 - ], - "Classic UI SFX - Short - Low #20": [ - 273000, - 2515.646258503409 - ], - "Classic UI SFX - Short - Low #21": [ - 277000, - 2604.172335600879 - ], - "Classic UI SFX - Short - Low #22": [ - 281000, - 3031.2698412698182 - ], - "Classic UI SFX - Short - Low #23": [ - 286000, - 2937.50566893425 - ], - "Classic UI SFX - Short - Low #24": [ - 290000, - 2609.387755102034 - ], - "Classic UI SFX - Short - Low #25": [ - 294000, - 2625.0113378685 - ], - "Classic UI SFX - Short - Low #3": [ - 298000, - 2828.140589569159 - ], - "Classic UI SFX - Short - Low #4": [ - 302000, - 2614.6031746031895 + 6000, + 2583.333333333334 ], "Classic UI SFX - Short - Low #5": [ - 306000, - 3161.4739229024735 + 10000, + 3161.473922902495 ], - "Classic UI SFX - Short - Low #6": [ - 311000, - 2333.3333333333144 + "Classic UI SFX - Short - High #9": [ + 15000, + 2250 ], - "Classic UI SFX - Short - Low #7": [ - 315000, - 2536.4625850340303 + "UI_TwoNote Up_Set 11_01": [ + 21000, + 129.16099773242706 ], - "Classic UI SFX - Short - Low #8": [ - 319000, - 2630.2267573695985 + "UI_TwoNote Up_Set 11_02": [ + 23000, + 250 ], - "Classic UI SFX - Short - Low #9": [ - 323000, - 2697.936507936504 + "Classic UI SFX - Short - High #3": [ + 25000, + 2864.6031746031754 ], - "UI_Single_Set 16_01": [ - 327000, - 309.5918367346826 + "Classic UI SFX - Short - High #19": [ + 29000, + 3052.0861678004535 ], - "UI_Single_Set 16_02": [ - 329000, - 309.5918367346826 + "Classic UI SFX - Short - High #22": [ + 34000, + 2489.5918367346967 + ], + "Classic UI SFX - Chords #16": [ + 38000, + 4005.215419501134 + ], + "Classic UI SFX - Short - High #8": [ + 44000, + 2916.6666666666642 ], "UI_Single_Set 16_03": [ - 331000, - 309.5918367346826 + 48000, + 309.5918367346968 ], - "UI_TwoNote_Set 15_01": [ - 333000, - 335.2380952380827 + "UI_Single_Set 16_01": [ + 50000, + 309.5918367346968 ], - "UI_TwoNote_Set 15_02": [ - 335000, - 309.5918367346826 + "Classic UI SFX - Short - Low #6": [ + 52000, + 2333.3333333333358 + ], + "UI SFX_InGameMenu_Open": [ + 56000, + 2614.104308390026 ] } } \ No newline at end of file diff --git a/src/mainview/assets/sounds.ogg b/src/mainview/assets/sounds.ogg index b8e00d5..0b7ac9b 100644 --- a/src/mainview/assets/sounds.ogg +++ b/src/mainview/assets/sounds.ogg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a3bb2f9a59e20e5ea49fec7fca68cda5c9167df332ff25d24c29870af834af7 -size 2229386 +oid sha256:c5dd2b1e23a878efe84694fa354e92e07f9394d88217b0f1d925f3b16f044e55 +size 353897 diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index 0518d2c..2744585 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -8,6 +8,8 @@ import CardElement, { GameCardFocusHandler, GameCardParams } from "./CardElement import { JSX } from "react"; import { twMerge } from "tailwind-merge"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { oneShot } from "../scripts/audio/audio"; +import { GamepadButtonEvent } from "../scripts/gamepads"; export interface GameMetaExtra extends GameMeta { @@ -24,10 +26,11 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara preview = data.game.previewUrl; } - const handleAction = () => + const handleAction = (e?: Event) => { data.game.onSelect?.(); data.onAction?.(); + oneShot('click'); }; useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]); diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index ac0437f..717e986 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -3,9 +3,9 @@ import { StickyHeaderUI } from './Header'; import { GameList } from './GameList'; import { Search, Settings2 } from 'lucide-react'; import { JSX, Suspense } from 'react'; -import Shortcuts from './Shortcuts'; +import { FloatingShortcuts } from './Shortcuts'; import { AutoFocus } from './AutoFocus'; -import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; +import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts'; import { GameListFilterType } from '@/shared/constants'; import { GameCardFocusHandler } from './CardElement'; import { HandleGoBack } from '../scripts/utils'; @@ -13,6 +13,7 @@ import LoadingCardList from './LoadingCardList'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { gameQuery } from '../scripts/queries/romm'; import { useRouter } from '@tanstack/react-router'; +import SelectMenu from './SelectMenu'; export interface CollectionsDetailParams { @@ -43,8 +44,7 @@ export function CollectionsDetail (data: CollectionsDetailParams) preferredChildFocusKey: `${focusKey}-list` }); - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }], [router]); - const { shortcuts } = useShortcutContext(); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]); const handleScroll: GameCardFocusHandler = (cardId, node, details) => { @@ -83,9 +83,10 @@ export function CollectionsDetail (data: CollectionsDetailParams)
      {data.footer}
      - +
    + ); } \ No newline at end of file diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index 94d31a8..6024311 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -7,6 +7,7 @@ import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts" import { ContextDialogContext } from "../scripts/contexts"; import { FOCUS_KEYS } from "../scripts/types"; import { oneShot } from "../scripts/audio/audio"; +import { oneShotRumble } from "../scripts/gamepads"; export function ContextList (data: { options?: DialogEntry[]; @@ -18,7 +19,7 @@ export function ContextList (data: { const context = useContext(ContextDialogContext); return
      {data.options?.map(o => )} -
      + {data.showCloseButton !== false &&
      } {data.showCloseButton !== false && } action={() => context.close()} id="close-context-dialog" content="Close" />}
    ; } @@ -85,9 +86,9 @@ export interface DialogEntry shortcuts?: Shortcut[]; } -export function useContextDialog (id: string, data: { content?: JSX.Element; className?: string; preferredChildFocusKey?: string; onClose?: () => void; canClose?: boolean; }) +export function useContextDialog (id: string, data: { content?: JSX.Element; className?: string; preferredChildFocusKey?: string; onClose?: () => void; canClose?: boolean; defaultOpen?: boolean; backdropClassName?: string; }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(data.defaultOpen ?? false); const [sourceFocusKey, setSourceFocusKey] = useState(undefined); const handleClose = (value: boolean, newSourceFocusKey?: string) => { @@ -111,7 +112,7 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla } }; - const dialog = + const dialog = {data.content} ; return { @@ -127,12 +128,13 @@ export function ContextDialog (data: { open: boolean, close: (open: boolean) => void; className?: string; + backdropClassName?: string; preferredChildFocusKey?: string; }) { const { ref, focusKey, focusSelf } = useFocusable({ focusable: data.open, - focusKey: `${data.id}-context-dialog`, + focusKey: FOCUS_KEYS.CONTEXT_DIALOG(data.id), isFocusBoundary: true, saveLastFocusedChild: !data.preferredChildFocusKey, preferredChildFocusKey: data.preferredChildFocusKey @@ -148,6 +150,7 @@ export function ContextDialog (data: { { focusSelf({ instant: true }); oneShot('openContext'); + oneShotRumble('openContext', { all: true }); } }, [data.open]); @@ -159,7 +162,7 @@ export function ContextDialog (data: { return diff --git a/src/mainview/components/Error.tsx b/src/mainview/components/Error.tsx index 6bafd95..81bcb63 100644 --- a/src/mainview/components/Error.tsx +++ b/src/mainview/components/Error.tsx @@ -1,7 +1,7 @@ import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { Home, TriangleAlert } from "lucide-react"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts"; -import Shortcuts from "./Shortcuts"; +import { FloatingShortcuts } from "./Shortcuts"; import { Button } from "./options/Button"; import { useEffect } from "react"; import { ErrorComponentProps, useRouter } from "@tanstack/react-router"; @@ -12,7 +12,6 @@ export default function Error (data: ErrorComponentProps) const router = useRouter(); const handleReturn = () => router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } }); useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]); - const { shortcuts } = useShortcutContext(); useEffect(() => { focusSelf({ instant: true }); }, []); @@ -30,7 +29,7 @@ export default function Error (data: ErrorComponentProps)
    -
    +
    ; } \ No newline at end of file diff --git a/src/mainview/components/FocusDots.tsx b/src/mainview/components/FocusDots.tsx index 0fc2af1..192e388 100644 --- a/src/mainview/components/FocusDots.tsx +++ b/src/mainview/components/FocusDots.tsx @@ -51,7 +51,7 @@ export default function FocusDots (data: { { const focused = em === focusedKey; return ; }); @@ -69,7 +69,7 @@ export default function FocusDots (data: { } }, [data.elements, data.scrollElement?.current]); - return
    + return
    {elements}
    ; } \ No newline at end of file diff --git a/src/mainview/components/NotFound.tsx b/src/mainview/components/NotFound.tsx index 0172729..6985609 100644 --- a/src/mainview/components/NotFound.tsx +++ b/src/mainview/components/NotFound.tsx @@ -1,10 +1,10 @@ import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { Home, TriangleAlert } from "lucide-react"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts"; -import Shortcuts from "./Shortcuts"; import { Button } from "./options/Button"; import { useEffect } from "react"; import { useRouter } from "@tanstack/react-router"; +import { FloatingShortcuts } from "./Shortcuts"; export default function NotFound () { @@ -12,7 +12,6 @@ export default function NotFound () const router = useRouter(); const handleReturn = () => router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } }); useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]); - const { shortcuts } = useShortcutContext(); useEffect(() => { focusSelf({ instant: true }); }, []); @@ -27,7 +26,7 @@ export default function NotFound ()
    -
    +
    ; } \ No newline at end of file diff --git a/src/mainview/components/SelectMenu.tsx b/src/mainview/components/SelectMenu.tsx new file mode 100644 index 0000000..4e68b68 --- /dev/null +++ b/src/mainview/components/SelectMenu.tsx @@ -0,0 +1,106 @@ +import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog"; +import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { MatchRoute, useMatch, useMatchRoute, useNavigate, useRouterState } from "@tanstack/react-router"; +import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; +import { DoorOpen, Gamepad2, RefreshCcw, Settings, Store } from "lucide-react"; +import { systemApi } from "../scripts/clientApi"; +import { FOCUS_KEYS } from "../scripts/types"; + +export default function SelectMenu (data: { rootFocusKey: string; }) +{ + const navigate = useNavigate(); + const routeState = useRouterState(); + const matchRoute = useMatchRoute(); + + const options: DialogEntry[] = [ + { + content: "Home", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/" }); + }, + selected: !!matchRoute({ to: '/' }), + type: "primary", + id: "home-m" + }, + { + content: "Library", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/games" }); + }, + selected: !!matchRoute({ to: '/games' }), + type: "secondary", + id: "library-m" + }, + { + content: "Store", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/store/tab" }); + }, + selected: !!matchRoute({ to: '/store/tab' }), + type: "info", + id: "store-m" + }, + { + content: "Settings", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/settings/accounts" }); + }, + selected: !!matchRoute({ to: '/settings/accounts' }), + type: "accent", + id: "settings-m" + }, + { + content: "Reload", + icon: , + action (ctx) + { + setOpen(false); + navigation.reload(); + }, + type: "accent", + id: "reload-m" + }, + { + content: "Quit", + icon: , + action (ctx) + { + systemApi.api.system.exit.post(); + }, + type: 'error', + id: "quit-m" + } + ]; + const { dialog, setOpen, open } = useContextDialog('select-menu', { + content: , + className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none', + preferredChildFocusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION('select-menu', options.find(o => o.selected)?.id ?? '') + }); + useShortcuts(data.rootFocusKey, () => [{ + label: "Menu", side: 'left', button: GamePadButtonCode.Select, action (e) + { + if (open) + { + setOpen(false); + } else + { + setOpen(true, getCurrentFocusKey()); + } + + }, + }], [open]); + + return <>{dialog}; +} \ No newline at end of file diff --git a/src/mainview/components/Shortcuts.tsx b/src/mainview/components/Shortcuts.tsx index d8fc94c..03d5e03 100644 --- a/src/mainview/components/Shortcuts.tsx +++ b/src/mainview/components/Shortcuts.tsx @@ -1,9 +1,16 @@ +import { useContext } from 'react'; import useActiveControl, { GamepadButtonEvent } from '../scripts/gamepads'; -import { GamePadButtonCode, Shortcut } from '../scripts/shortcuts'; +import { GamePadButtonCode, Shortcut, useShortcutContext } from '../scripts/shortcuts'; import ShortcutPrompt from './ShortcutPrompt'; import { IconType } from './SvgIcon'; +import { ShortcutsContext } from '../scripts/contexts'; -export default function Shortcuts (data: { shortcuts?: Shortcut[]; }) +export function FloatingShortcuts () +{ + return
    ; +} + +export default function Shortcuts (data: { centerElement?: any; }) { const iconMap: Record = { [GamePadButtonCode.A]: 'steamdeck_button_a', @@ -47,15 +54,28 @@ export default function Shortcuts (data: { shortcuts?: Shortcut[]; }) const { control } = useActiveControl(); const showKeyboard = control === 'keyboard' || control === 'mouse'; + const { shortcuts } = useShortcutContext(); return ( -
    - {data.shortcuts?.filter(s => !!s.label).map((s, i) => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} - icon={showKeyboard ? undefined : iconMap[s.button]} - label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} /> - )} -
    + <> +
    + {shortcuts?.filter(s => !!s.label && s.side === 'left').map((s, i) => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} + icon={showKeyboard ? undefined : iconMap[s.button]} + label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} /> + )} +
    + {data.centerElement} +
    + {shortcuts?.filter(s => !!s.label && s.side !== 'left').map((s, i) => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} + icon={showKeyboard ? undefined : iconMap[s.button]} + label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} /> + )} +
    + ); } diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index 2ee89c6..d4067ee 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -21,7 +21,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so }, onSuccess (data, { source, id }, onMutateResult, context) { - router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id }, replace: true }); + router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id } }); }, }); const ws = useRef<{ send: (data: string) => void; }>(undefined); @@ -108,7 +108,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so if (cmd.emulator === 'EMULATORJS') { const params = new URLSearchParams(cmd.command); - router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()), replace: true }); + 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 }); diff --git a/src/mainview/components/options/LocalOption.tsx b/src/mainview/components/options/LocalOption.tsx index 7e55506..8636bf1 100644 --- a/src/mainview/components/options/LocalOption.tsx +++ b/src/mainview/components/options/LocalOption.tsx @@ -9,13 +9,18 @@ export function LocalOption (data: { label: string; id: keyof LocalSettingsType; type: HTMLInputTypeAttribute | 'dropdown'; + min?: number; + max?: number; + step?: number; placeholder?: string; values?: string[]; icon?: JSX.Element; children?: any; }) { - const [localValue, setLocalValue] = useLocalStorage(data.id, LocalSettingsSchema.shape[data.id].parse(undefined), { deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v)) }); + const [localValue, setLocalValue] = useLocalStorage(data.id, LocalSettingsSchema.shape[data.id].parse(undefined), { + deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v)) + }); return ( @@ -25,30 +30,21 @@ export function LocalOption (data: { defaultValue={localValue} onChange={(v) => { - if (data.type === 'checkbox') - { - setLocalValue(v); - } else - { - setLocalValue(v); - } + setLocalValue(v); }} value={localValue} />} {data.type !== 'dropdown' && { - if (data.type === 'checkbox') - { - setLocalValue(v); - } else - { - setLocalValue(v); - } + setLocalValue(v); }} value={localValue} />} diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index 2181b93..31c1d27 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -1,10 +1,11 @@ -import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react"; +import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useEffect, useRef, useState } from "react"; import { twMerge } from "tailwind-merge"; import { useOptionContext } from "./OptionSpace"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { systemApi } from "../../scripts/clientApi"; import { CheckIcon, X } from "lucide-react"; import { oneShot } from "@/mainview/scripts/audio/audio"; +import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; export function OptionInput (data: { name: string; @@ -12,11 +13,14 @@ export function OptionInput (data: { className?: string; placeholder?: string; icon?: JSX.Element; - value?: string | boolean; - defaultValue?: string | boolean; + value?: string | boolean | number; + min?: number; + max?: number; + step?: number; + defaultValue?: string | boolean | number; autocomplete?: HTMLInputAutoCompleteAttribute; onBlur?: FocusEventHandler; - onChange?: (value: any) => void; + onChange?: (value: string | number | boolean) => void; }) { const handlePress = () => @@ -30,16 +34,74 @@ export function OptionInput (data: { } oneShot('click'); }; - const { ref } = useFocusable({ - focusKey: data.name, onEnterPress: handlePress - }); + const [inputFocused, setInputFocused] = useState(false); const inputRef = useRef(null); + const { ref, focusKey } = useFocusable({ + focusKey: data.name, + onEnterPress: handlePress, + onBlur: () => inputRef.current?.blur() + }); + const option = useOptionContext({ onOptionEnterPress: handlePress, }); - const handleFocus = () => + + useEffect(() => + { + if (data.type === 'range') + { + option.setFocusBoundary(inputFocused); + option.setFocusBoundaryDirections(['left', 'right']); + } + }, [inputFocused, option, data.type]); + + useShortcuts(focusKey, () => + { + + const shortcuts: Shortcut[] = []; + if (inputFocused && data.type === 'range') + { + shortcuts.push( + { + label: "Decrease", + button: GamePadButtonCode.Left, + action () + { + if (!inputRef.current) return; + inputRef.current?.stepDown(); + data.onChange?.(inputRef.current.valueAsNumber); + } + }, + { + label: "Increase", + button: GamePadButtonCode.Right, + action (e) + { + if (!inputRef.current) return; + inputRef.current?.stepUp(); + data.onChange?.(inputRef.current.valueAsNumber); + } + } + ); + } + if (inputFocused) + { + shortcuts.push({ + label: "Unfocus", + button: GamePadButtonCode.B, + action (e) + { + inputRef.current?.blur(); + } + }); + } + return shortcuts; + }, [inputFocused, data.type]); + + const handleInputFocus = () => { option.focus(); + setInputFocused(true); if (inputRef.current) { var rect = inputRef.current?.getBoundingClientRect(); @@ -52,25 +114,47 @@ export function OptionInput (data: { } }; + const handleInputBlur = (e: any) => + { + data.onBlur?.(e); + setInputFocused(false); + }; + return (
  • { const data = await ctx.context.queryClient.fetchQuery(gameQuery(ctx.params.source, ctx.params.id)); @@ -133,7 +136,13 @@ function RouteComponent () function HandleGoBack () { - router.navigate({ to: '/game/$source/$id', params: { source, id }, replace: true }); + if (router.history.canGoBack()) + { + router.history.back(); + } else + { + router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); + } } useEventListener('message', e => @@ -172,7 +181,6 @@ function RouteComponent () } }; useEffect(() => setPaused(overlayOpen), [overlayOpen]); - const { shortcuts } = useShortcutContext(); useEffect(() => { if (!overlayOpen) focusSelf({ instant: true }); }, [overlayOpen]); function handleClose () { @@ -185,9 +193,7 @@ function RouteComponent ()
    -
    - -
    + ; } \ No newline at end of file diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 5f9246c..ab8c857 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -6,7 +6,7 @@ import { Calendar, Folder, Gamepad2, Image, Info, TriangleAlert, Trophy } from " import { HeaderUI, StickyHeaderUI } from "../../components/Header"; import { AnimatedBackground } from "../../components/AnimatedBackground"; import { useQuery } from "@tanstack/react-query"; -import Shortcuts from "../../components/Shortcuts"; +import Shortcuts, { FloatingShortcuts } from "../../components/Shortcuts"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; import Screenshots from "@/mainview/components/Screenshots"; import { HandleGoBack, scrollIntoViewHandler, useOnNavigateBack } from "@/mainview/scripts/utils"; @@ -22,6 +22,7 @@ import { gameQuery, gamesRecommendedBasedOnGameQuery } from "@queries/romm"; import { GamesSection } from "@/mainview/components/store/GamesSection"; import Details from "@/mainview/components/game/Details"; import { AutoFocus } from "@/mainview/components/AutoFocus"; +import SelectMenu from "@/mainview/components/SelectMenu"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => @@ -47,8 +48,7 @@ function Error (data: ErrorComponentProps) const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" }); const router = useRouter(); - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }]); - const { shortcuts } = useShortcutContext(); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }]); return
    @@ -60,12 +60,6 @@ function Error (data: ErrorComponentProps)
    {JSON.stringify(data.error, null, 3)}
    -
    - -
    - -
    -
    @@ -151,13 +145,11 @@ function RouteComponent () const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible }); useShortcuts(focusKey, () => [{ - label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) + label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]); useOnNavigateBack((s) => s.sound = 'returnDetails'); - const { shortcuts } = useShortcutContext(); - const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists)); const { ref: intersct } = useIntersectionObserver({ @@ -211,11 +203,10 @@ function RouteComponent () }} onFocus={scrollIntoViewHandler({ block: 'center', inline: 'nearest' })} games={recommendedGames} /> + -
    - -
    +
    ); diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index fade167..eee1194 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -34,7 +34,6 @@ import { AutoFocus } from "../components/AutoFocus"; import SaveScroll from "../components/SaveScroll"; import { ErrorBoundary, useErrorBoundary } from "react-error-boundary"; import { twMerge } from "tailwind-merge"; -import Shortcuts from "../components/Shortcuts"; import { PlatformsList } from "../components/PlatformsList"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts"; import z from "zod"; @@ -46,6 +45,8 @@ import Carousel from "../components/Carousel"; import { closeMutation } from "@queries/system"; import { gameQuery } from "../scripts/queries/romm"; import { oneShot } from "../scripts/audio/audio"; +import { FloatingShortcuts } from "../components/Shortcuts"; +import SelectMenu from "../components/SelectMenu"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -94,7 +95,7 @@ function ShowAllGamesCard () const router = useRouter(); const handleNavigate = () => { - router.navigate({ to: '/games', viewTransition: { types: ['zoom-in'] } }); + router.navigate({ to: '/games' }); }; const { ref } = useFocusable({ focusKey: 'all-games-btn', onEnterPress: handleNavigate }); return
    All Games
    ; @@ -231,22 +232,22 @@ function MainMenu () > router.navigate({ to: "/games" })} + onAction={(e) => router.navigate({ to: "/games", state: { eventType: e?.type } })} icon={} label="Home" type="secondary" /> } label="News" /> - } action={() => router.navigate({ to: "/store/tab" })} label="Shop" /> + } onAction={(e) => router.navigate({ to: "/store/tab", state: { eventType: e?.type } })} label="Shop" /> } label="Album" /> } label="Controllers" /> + onAction={(e) => { - router.navigate({ to: '/settings/accounts' }); + router.navigate({ to: '/settings/accounts', state: { eventType: e?.type } }); }} icon={} label="Settings" @@ -258,15 +259,14 @@ function MainMenu () } function CircleIcon (data: { - action?: () => void; type?: "secondary" | "accent" | "info"; label?: string; icon?: JSX.Element; -}) +} & InteractParams) { - const handleAction = () => + const handleAction = (e?: Event) => { - data.action?.(); + data.onAction?.(e); oneShot('click'); }; const { ref, focusKey } = useFocusable({ @@ -284,7 +284,7 @@ function CircleIcon (data: {
  • handleAction(e.nativeEvent)} className={twMerge( `portrait:sm:size-12 sm:w-14 sm:h-10 menu-icon text-base-300 md:w-20 md:h-20 rounded-full flex items-center justify-center drop-shadow-lg cursor-pointer transition-all focusable focusable-primary focused:drop-shadow-2xl focused:animate-scale focusable-hover bg-base-content border-6 md:border-12 border-base-content focused:border-0 hover:border-0 z-1 active:border-0 active:bg-base-300 active:text-base-content active:transition-none`, typeClasses[data.type ?? 'none'])} > @@ -309,7 +309,6 @@ export default function ConsoleHomeUI () const setFilter = (filter: string) => router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true }); - const { shortcuts } = useShortcutContext(); const headerButtons: HeaderButton[] = []; if (mobileCheck()) headerButtons.push({ id: "fullscreen", icon: , action: handleFullscreen }); @@ -348,9 +347,9 @@ export default function ConsoleHomeUI ()
    - +
    - + ); diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index 8eac18d..63f0907 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -3,11 +3,14 @@ import { createFileRoute, useBlocker, useRouter } from '@tanstack/react-router'; import DotsLoading from '../components/backgrounds/dots'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import Shortcuts from '../components/Shortcuts'; +import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts'; import { useJobStatus } from '../scripts/utils'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, + staticData: { + enterSound: 'launch' + }, }); function RouteComponent () @@ -28,7 +31,6 @@ function RouteComponent () const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` }); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); - const { shortcuts } = useShortcutContext(); const { data } = useJobStatus('launch-game', { onEnded (data) @@ -48,8 +50,6 @@ function RouteComponent ()

    Launching {data?.name} ...

    -
    - -
    + ; } diff --git a/src/mainview/routes/settings/interface.tsx b/src/mainview/routes/settings/interface.tsx index ddca3a8..9b930e4 100644 --- a/src/mainview/routes/settings/interface.tsx +++ b/src/mainview/routes/settings/interface.tsx @@ -20,6 +20,8 @@ function RouteComponent () + + ; } diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index 89168ea..2385ed6 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -27,10 +27,11 @@ import { twMerge } from "tailwind-merge"; import z from "zod"; import { SettingsSchema } from "../../../shared/constants"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; -import Shortcuts from "@/mainview/components/Shortcuts"; +import Shortcuts, { FloatingShortcuts } from "@/mainview/components/Shortcuts"; import { HandleGoBack } from "@/mainview/scripts/utils"; import { AutoFocus } from "@/mainview/components/AutoFocus"; import { oneShot } from "@/mainview/scripts/audio/audio"; +import SelectMenu from "@/mainview/components/SelectMenu"; export const Route = createFileRoute("/settings")({ component: SettingsUI, @@ -55,11 +56,11 @@ function MenuItem (data: { { const router = useRouter(); const acitve = !!useMatch({ from: data.route as any, shouldThrow: false });; - const handleNonFocusSelect = () => + const handleNonFocusSelect = (e?: Event) => { if (data.return) { - HandleGoBack(router); + HandleGoBack(router, e); } else if (!acitve) { router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true }); @@ -88,7 +89,7 @@ function MenuItem (data: { ref={ref} key={data.route} data-sound-category={"menu"} - onClick={data.focusSelect ? focusSelf : handleNonFocusSelect} + onClick={data.focusSelect ? focusSelf : (e) => handleNonFocusSelect(e.nativeEvent)} onFocus={focusSelf} className={twMerge("flex group-focusable cursor-pointer", data.className)} > @@ -180,8 +181,7 @@ export function SettingsUI () preferredChildFocusKey: 'settings-menu' }); - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }], [router]); - const { shortcuts } = useShortcutContext(); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]); return ( @@ -195,10 +195,13 @@ export function SettingsUI () -
    - +
    +
    + } />
    +
    ); diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index b2543d7..889ee00 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -6,7 +6,7 @@ import } from "@noriginmedia/norigin-spatial-navigation"; import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; -import Shortcuts from "@/mainview/components/Shortcuts"; +import Shortcuts, { FloatingShortcuts } from "@/mainview/components/Shortcuts"; import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; import { rommApi, systemApi } from "@/mainview/scripts/clientApi"; import { Button } from "@/mainview/components/options/Button"; @@ -335,7 +335,7 @@ export function RouteComponent () useShortcuts(focusKey, () => [{ label: "Return", - action: () => HandleGoBack(router), + action: (e) => HandleGoBack(router, e), button: GamePadButtonCode.B }], [router]); @@ -344,8 +344,6 @@ export function RouteComponent () onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)), }); - const { shortcuts } = useShortcutContext(); - const stats: StatEntry[] = []; if (emulator) { @@ -434,7 +432,7 @@ export function RouteComponent () }} games={recommendedGames} />}
    - +
    diff --git a/src/mainview/routes/store/tab/index.tsx b/src/mainview/routes/store/tab/index.tsx index b596807..2002a08 100644 --- a/src/mainview/routes/store/tab/index.tsx +++ b/src/mainview/routes/store/tab/index.tsx @@ -56,11 +56,11 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; }) return
    - {game ?
    + {game ?
    { @@ -72,13 +72,13 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
    -
    -
    - {!!data.games && } +
    +
    + {!!data.games && }
    -

    {game.name}

    -

    {game.summary}

    +

    {game.name}

    +

    {game.summary}

    @@ -140,7 +140,7 @@ export function RouteComponent ()
    -
    +

    Featured Games diff --git a/src/mainview/routes/store/tab/route.tsx b/src/mainview/routes/store/tab/route.tsx index 2171770..fd2f76b 100644 --- a/src/mainview/routes/store/tab/route.tsx +++ b/src/mainview/routes/store/tab/route.tsx @@ -1,7 +1,8 @@ import { AutoFocus } from '@/mainview/components/AutoFocus'; import { FilterUI } from '@/mainview/components/Filters'; import { HeaderUI } from '@/mainview/components/Header'; -import Shortcuts from '@/mainview/components/Shortcuts'; +import SelectMenu from '@/mainview/components/SelectMenu'; +import Shortcuts, { FloatingShortcuts } from '@/mainview/components/Shortcuts'; import { StoreContext } from '@/mainview/scripts/contexts'; import { gameQuery } from '@/mainview/scripts/queries/romm'; import { storeEmulatorDetailsQuery } from '@/mainview/scripts/queries/store'; @@ -19,7 +20,8 @@ export const Route = createFileRoute('/store/tab')({ component: RouteComponent, validateSearch: zodValidator(z.object({ focus: z.string().optional() })), staticData: { - enterSound: 'openStore' + enterSound: 'openStore', + enterHaptic: 'navigateStore' } }); @@ -47,7 +49,7 @@ function TopArea (data: { filters: Record; }) useShortcuts("STORE_ROOT", () => [{ label: "Return", - action: () => HandleGoBack(router), + action: (e) => HandleGoBack(router, e), button: GamePadButtonCode.B }], [router]); @@ -94,8 +96,6 @@ function RouteComponent () games: { label: "Games", selected: useIsSettings('games') } }; - const { shortcuts } = useShortcutContext(); - const handleDetails = (type: string, source: string, id: string, focus: string) => { if (type === 'emulator') @@ -133,17 +133,16 @@ function RouteComponent ()

    -
    - -
    {!isMobile && <>
    }
    + +
    ; } diff --git a/src/mainview/scripts/audio/audio.ts b/src/mainview/scripts/audio/audio.ts index 6084ac4..bbf8712 100644 --- a/src/mainview/scripts/audio/audio.ts +++ b/src/mainview/scripts/audio/audio.ts @@ -2,46 +2,36 @@ import { Howl } from 'howler'; import sounds from '../../assets/sounds.ogg'; import soundSprites from '../../assets/sounds.json'; import { getLocalSetting } from '../utils'; +import { hapticMap } from '../gamepads'; +import { soundMap } from './audioConstants'; const timingMap = new Map(); +// Browsers need input to start any sound, so intro doesn't auto play. +/*const introSound = new Howl({ + src: [intro], + volume: getLocalSetting("soundEffectsVolume") / 100, + autoplay: true, +});*/ + const sound = new Howl({ src: [sounds], sprite: soundSprites.sprite as any, - volume: 0.5, + volume: getLocalSetting("soundEffectsVolume") / 100, }); + import.meta.hot?.dispose(() => { sound.unload(); }); declare module '@tanstack/react-router' { interface StaticDataRouteOption { enterSound?: keyof typeof soundMap | null; + enterHaptic?: keyof typeof hapticMap | null; goBackSound?: keyof typeof soundMap | null; } } -const volumeVariation = 0.05; -const rateVariation = 0.01; - -export const soundMap = { - openDetails: { key: 'Classic UI SFX - Chords #1' }, - returnGeneric: { key: 'Classic UI SFX - Short - Low #2' }, - returnDetails: { key: 'Classic UI SFX - Short - Low #5' }, - openGeneric: { key: 'Classic UI SFX - Short - High #9' }, - select: { key: 'Classic UI SFX - Short - High #5', rateVariation, volumeVariation }, - selectAlt: { key: "Classic UI SFX - Short - High #6", rateVariation, volumeVariation }, - selectMenu: { key: 'Classic UI SFX - Short - High #7', rateVariation, volumeVariation }, - selectFilter: { key: 'Classic UI SFX - Short - High #3', volumeVariation }, - closeContext: { key: 'Classic UI SFX - Short - High #19' }, - openContext: { key: 'Classic UI SFX - Short - High #22' }, - openStore: { key: 'Classic UI SFX - Chords #16' }, - openSettings: { key: 'Classic UI SFX - Short - High #8' }, - click: { key: "UI_Single_Set 16_03", rateVariation, volumeVariation }, - clickAlt: { key: "UI_Single_Set 16_01", rateVariation, volumeVariation }, - invalidNavigation: { key: "Classic UI SFX - Short - Low #6", rateVariation, volumeVariation }, -} satisfies Record; - -function sinRanom () +function sinRandom () { return Math.sin(new Date().getMilliseconds() / 1000 * Math.PI); } @@ -63,8 +53,9 @@ export function oneShot (id: keyof typeof soundMap) if (currentDate && new Date().getTime() - currentDate.getTime() <= 100) return; const soundValue = soundMap[id] as { key: keyof typeof soundSprites.sprite, rateVariation?: number; volumeVariation?: number; }; const instanceId = sound.play(soundValue.key); - sound.volume(sound.volume() + random() * (soundValue.volumeVariation ?? 0), instanceId); - sound.rate(1 + random() * (soundValue.rateVariation ?? 0), instanceId); + const baseVolume = getLocalSetting("soundEffectsVolume") / 100; + sound.volume(Math.min(baseVolume * (1 + random() * (soundValue.volumeVariation ?? 0), 1)), instanceId); + sound.rate(1 + sinRandom() * (soundValue.rateVariation ?? 0), instanceId); timingMap.set(id, new Date()); } diff --git a/src/mainview/scripts/audio/audioConstants.ts b/src/mainview/scripts/audio/audioConstants.ts new file mode 100644 index 0000000..a877e12 --- /dev/null +++ b/src/mainview/scripts/audio/audioConstants.ts @@ -0,0 +1,23 @@ +import soundSprites from '../../assets/sounds.json'; + +const volumeVariation = 0.05; +const rateVariation = 0.02; + +export const soundMap = { + openDetails: { key: 'Classic UI SFX - Chords #2' }, + returnGeneric: { key: 'Classic UI SFX - Short - Low #2' }, + returnDetails: { key: 'Classic UI SFX - Short - Low #5' }, + openGeneric: { key: 'Classic UI SFX - Short - High #9' }, + select: { key: "UI_TwoNote Up_Set 11_01", rateVariation, volumeVariation }, + selectAlt: { key: "UI_TwoNote Up_Set 11_01", rateVariation, volumeVariation }, + selectMenu: { key: "UI_TwoNote Up_Set 11_02", rateVariation, volumeVariation }, + selectFilter: { key: 'Classic UI SFX - Short - High #3', volumeVariation }, + closeContext: { key: 'Classic UI SFX - Short - High #19' }, + openContext: { key: 'Classic UI SFX - Short - High #22' }, + openStore: { key: 'Classic UI SFX - Chords #16' }, + openSettings: { key: 'Classic UI SFX - Short - High #8' }, + click: { key: "UI_Single_Set 16_03", rateVariation, volumeVariation }, + clickAlt: { key: "UI_Single_Set 16_01", rateVariation, volumeVariation }, + invalidNavigation: { key: "Classic UI SFX - Short - Low #6", rateVariation, volumeVariation }, + launch: { key: "UI SFX_InGameMenu_Open" } +} satisfies Record; \ No newline at end of file diff --git a/src/mainview/scripts/contexts.ts b/src/mainview/scripts/contexts.ts index 3257bfb..d987199 100644 --- a/src/mainview/scripts/contexts.ts +++ b/src/mainview/scripts/contexts.ts @@ -1,6 +1,7 @@ import { SystemInfoType } from "@/shared/constants"; -import { FocusDetails } from "@noriginmedia/norigin-spatial-navigation"; +import { Direction, FocusDetails } from "@noriginmedia/norigin-spatial-navigation"; import { createContext } from "react"; +import { Shortcut } from "./shortcuts"; export const StoreContext = createContext({} as { showDetails: (type: 'emulator' | 'game', source: string, id: string, focusSource: string) => void; @@ -20,6 +21,8 @@ export const OptionContext = createContext( focused: boolean; focus: (focusDetails?: FocusDetails | undefined) => void; eventTarget: EventTarget; + setFocusBoundary: (b: boolean) => void; + setFocusBoundaryDirections: (dirs: Direction[]) => void; }, ); @@ -34,6 +37,12 @@ export const FilePickerContext = createContext<{ activeDrive: Drive | undefined; }>({} as any); +export const ShortcutsContext = createContext({} as { + shortcuts: ({ + key: string; + } & Shortcut)[] | undefined; +}); + export const SystemInfoContext = createContext({} as SystemInfoType | undefined); export const GameDetailsContext = createContext<{ diff --git a/src/mainview/scripts/audio/audioCallbacks.ts b/src/mainview/scripts/feedbackCallbacks.ts similarity index 81% rename from src/mainview/scripts/audio/audioCallbacks.ts rename to src/mainview/scripts/feedbackCallbacks.ts index 4a8f744..f103d68 100644 --- a/src/mainview/scripts/audio/audioCallbacks.ts +++ b/src/mainview/scripts/feedbackCallbacks.ts @@ -1,5 +1,8 @@ import { Router } from "@/mainview"; -import { oneShot, soundMap } from "./audio"; +import { soundMap } from "./audio/audioConstants"; +import { oneShotRumble } from "./gamepads"; +import { oneShot } from "./audio/audio"; + export default function load () { let lastLocationPath: string | undefined; @@ -13,12 +16,18 @@ export default function load () const soundRoute = routes.find(r => r.staticData.enterSound !== undefined); if (soundRoute) { - if (soundRoute.staticData.enterSound) oneShot(soundRoute.staticData.enterSound); + oneShot(soundRoute.staticData.enterSound!); } else { oneShot("openGeneric"); } + if (op.location.state.eventType === 'gamepadbuttondown') + { + const hapticRoute = routes.find(r => r.staticData.enterHaptic !== undefined); + if (hapticRoute) oneShotRumble(hapticRoute.staticData.enterHaptic!, { all: true }); + else oneShotRumble('navigateForward', { all: true }); + } } else if (op.action.type === 'BACK') { if (lastLocationPath) @@ -73,6 +82,7 @@ export default function load () if (e.detail.nativeEvent || e.detail.event) { oneShot(sound); + oneShotRumble('select', { event: e.detail.event }); } }, 10); } diff --git a/src/mainview/scripts/gamepads.ts b/src/mainview/scripts/gamepads.ts index e7edf3b..15d48a6 100644 --- a/src/mainview/scripts/gamepads.ts +++ b/src/mainview/scripts/gamepads.ts @@ -1,7 +1,7 @@ import { getCurrentFocusKey, navigateByDirection } from "@noriginmedia/norigin-spatial-navigation"; import { GetFocusedElement } from "./spatialNavigation"; import { useEffect, useState } from "react"; -import { mobileCheck } from "./utils"; +import { getLocalSetting, mobileCheck } from "./utils"; import { oneShot } from "./audio/audio"; let loopStarted = false; @@ -280,4 +280,45 @@ function updateStatus () } requestAnimationFrame(updateStatus); +} + +export const hapticMap = { + select: [{ duration: 50, strongMagnitude: 0, weakMagnitude: 1 }], + navigateForward: [{ duration: 50, strongMagnitude: 0.2, weakMagnitude: 0.2 }, { duration: 100, strongMagnitude: 0.5, weakMagnitude: 0.5 }], + navigateBack: [{ duration: 100, strongMagnitude: 0.5, weakMagnitude: 0.5 }, { duration: 50, strongMagnitude: 0.2, weakMagnitude: 0.2 }], + navigateStore: [{ duration: 200, strongMagnitude: 0.5, weakMagnitude: 0.5 }, { duration: 300, strongMagnitude: 0.2, weakMagnitude: 0.2 }], + openContext: [{ duration: 50, strongMagnitude: 0.5, weakMagnitude: 0.5 }, { duration: 50, strongMagnitude: 0.0, weakMagnitude: 0.0 }, { duration: 50, strongMagnitude: 0.2, weakMagnitude: 0.2 }], +} satisfies Record; + +let lastRumble: AbortController; + +export function oneShotRumble (effect: keyof typeof hapticMap, init?: { event?: Event, all?: boolean; }) +{ + if (!getLocalSetting('hapticsEffects')) return; + + async function play (g: Gamepad) + { + lastRumble = new AbortController(); + for (const e of hapticMap[effect]) + { + await new Promise(resolve => + { + g.vibrationActuator.playEffect('dual-rumble', e); + const timeout = setTimeout(() => resolve(true), e.duration + 50); + lastRumble.signal.onabort = () => clearTimeout(timeout); + if (lastRumble.signal.aborted) resolve(false); + }); + + if (lastRumble.signal.aborted) return; + } + } + + if (lastRumble) lastRumble.abort(); + if (init?.event instanceof GamepadEvent || init?.event instanceof GamepadButtonEvent) + { + if (init?.event.gamepad) play(init?.event.gamepad); + } else if (init?.all) + { + navigator.getGamepads().filter(g => !!g).forEach(g => play(g)); + } } \ No newline at end of file diff --git a/src/mainview/scripts/shortcuts.ts b/src/mainview/scripts/shortcuts.ts index 5defdf7..c198037 100644 --- a/src/mainview/scripts/shortcuts.ts +++ b/src/mainview/scripts/shortcuts.ts @@ -34,6 +34,7 @@ export interface Shortcut button: GamePadButtonCode; heldTime?: number; action?: (e: GamepadButtonEvent) => void; + side?: "left" | "right"; } let isDirty = false; diff --git a/src/mainview/scripts/spatialNavigation.ts b/src/mainview/scripts/spatialNavigation.ts index 51d212a..d076df4 100644 --- a/src/mainview/scripts/spatialNavigation.ts +++ b/src/mainview/scripts/spatialNavigation.ts @@ -32,7 +32,7 @@ export function GetFocusedElement (focusKey: string) export function GetFocusedTree (leaf: string): string[] { - const tree: string[] = []; + const tree: string[] = ["window"]; let component = (SpatialNavigation as any).focusableComponents[leaf]; while (component) { diff --git a/src/mainview/scripts/types.ts b/src/mainview/scripts/types.ts index ca39657..e7630dc 100644 --- a/src/mainview/scripts/types.ts +++ b/src/mainview/scripts/types.ts @@ -6,6 +6,7 @@ export const FOCUS_KEYS = { EMULATOR_SECTION: (id: string) => `EMULATOR_SECTION_${id}`, EMULATOR_CUSTOM_PATH: (id: string) => `EMULATOR_CUSTOM_PATH_${id}`, CONTEXT_DIALOG_OPTION: (contextId: string, id: string) => `${contextId}_LIST_OPTION${id}`, + CONTEXT_DIALOG: (contextId: string) => `${contextId}_CONTEXT_DIALOG`, EMULATOR_CARD: (id: string) => `EMULATOR_${id}`, GAME_SECTION: "GAME_SECTION", GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`, diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index a1f325d..e84b7b7 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -4,7 +4,8 @@ import { useLocalStorage } from "usehooks-ts"; import { jobsApi } from "./clientApi"; import { JobsAPIType } from "@/bun/api/rpc"; import { AnyRouter, useRouter } from "@tanstack/react-router"; -import { soundMap } from "./audio/audio"; +import { soundMap } from "./audio/audioConstants"; +import { GamepadButtonEvent, oneShotRumble } from "./gamepads"; export type ScrollSaveParams = { id: string; @@ -60,11 +61,11 @@ export function mobileCheck () return check; }; -export function getLocalSetting (key: TKey) +export function getLocalSetting (key: TKey): LocalSettingsType[TKey] { const localValueRaw = localStorage.getItem(key); - if (!localValueRaw) return LocalSettingsSchema.shape[key].parse(undefined); - return LocalSettingsSchema.shape[key].parse(JSON.parse(localValueRaw)); + if (!localValueRaw) return LocalSettingsSchema.shape[key].parse(undefined) as any; + return LocalSettingsSchema.shape[key].parse(JSON.parse(localValueRaw)) as any; } export function useLocalSetting (key: TKey) @@ -329,11 +330,15 @@ export function useJobStatus Date: Thu, 9 Apr 2026 17:15:37 +0300 Subject: [PATCH 37/65] feat: Implemented romm saves for dolphin and xenia feat: Implemented save backups for emulatorjs fix: Added support for rar archives fix: Moved to individual ini adjustments for pcsx2 and ppsspp to allow for user editing of configs --- bun.lock | 8 + package.json | 2 + src/bun/api/auth.ts | 2 +- src/bun/api/clients.ts | 3 +- src/bun/api/controls/controls.ts | 2 +- src/bun/api/emulatorjs/emulatorjs.ts | 62 ++++- src/bun/api/games/services/statusService.ts | 37 ++- src/bun/api/hooks/games.ts | 25 +- src/bun/api/jobs/install-job.ts | 52 +++- src/bun/api/jobs/launch-game-job.ts | 261 +++++++++++------- .../com.simeonradivoev.gameflow.cemu/cemu.ts | 4 +- .../dolphin.ts | 26 +- .../utils.ts | 164 +++++++++++ .../PCSX2.ini | 17 -- .../pcsx2.ts | 52 ++-- .../linux/ppsspp.ini | 2 - .../ppsspp.ts | 50 ++-- .../win32/ppsspp.ini | 2 - .../com.simeonradivoev.gameflow.xemu/xemu.ts | 4 +- .../utils.ts | 141 ++++++++++ .../xenia.ts | 42 ++- .../com.simeonradivoev.gameflow.romm/romm.ts | 146 +++++++++- src/bun/api/task-queue.ts | 24 +- src/mainview/components/Notifications.tsx | 16 +- src/mainview/components/game/Details.tsx | 19 +- src/mainview/emulatorjs/emulator.ts | 59 +++- src/mainview/emulatorjs/types.d.ts | 3 + src/mainview/routes/embedded.$source.$id.tsx | 42 ++- src/mainview/routes/game/$source.$id.tsx | 2 + src/mainview/routes/launcher.$source.$id.tsx | 22 +- .../routes/store/details.emulator.$id.tsx | 17 +- src/mainview/scripts/audio/audio.ts | 1 + src/mainview/scripts/gamepads.ts | 9 +- src/mainview/scripts/utils.ts | 7 +- src/mainview/types.d.ts | 9 +- src/shared/types..d.ts | 12 +- 36 files changed, 1103 insertions(+), 243 deletions(-) create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/utils.ts diff --git a/bun.lock b/bun.lock index 1514d12..0b550d6 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "node-disk-info": "^1.3.0", "node-downloader-helper": "^2.1.10", "node-stream-zip": "^1.15.0", + "node-unrar-js": "^2.0.2", "open": "^11.0.0", "pathe": "^2.0.3", "slugify": "^1.6.9", @@ -78,6 +79,7 @@ "howler": "^2.2.4", "lucide-react": "^0.563.0", "pretty-bytes": "^7.1.0", + "pretty-ms": "^9.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-error-boundary": "^6.1.0", @@ -1278,6 +1280,8 @@ "node-stream-zip": ["node-stream-zip@1.15.0", "", {}, "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw=="], + "node-unrar-js": ["node-unrar-js@2.0.2", "", {}, "sha512-hLNmoJzqaKJnod8yiTVGe9hnlNRHotUi0CreSv/8HtfRi/3JnRC8DvsmKfeGGguRjTEulhZK6zXX5PXoVuDZ2w=="], + "normalize-package-data": ["normalize-package-data@3.0.3", "", { "dependencies": { "hosted-git-info": "^4.0.1", "is-core-module": "^2.5.0", "semver": "^7.3.4", "validate-npm-package-license": "^3.0.1" } }, "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -1322,6 +1326,8 @@ "parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], @@ -1376,6 +1382,8 @@ "pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], diff --git a/package.json b/package.json index 6869a4b..3d5f135 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "node-disk-info": "^1.3.0", "node-downloader-helper": "^2.1.10", "node-stream-zip": "^1.15.0", + "node-unrar-js": "^2.0.2", "open": "^11.0.0", "pathe": "^2.0.3", "slugify": "^1.6.9", @@ -118,6 +119,7 @@ "howler": "^2.2.4", "lucide-react": "^0.563.0", "pretty-bytes": "^7.1.0", + "pretty-ms": "^9.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-error-boundary": "^6.1.0", diff --git a/src/bun/api/auth.ts b/src/bun/api/auth.ts index 6cf37eb..a0740bc 100644 --- a/src/bun/api/auth.ts +++ b/src/bun/api/auth.ts @@ -181,7 +181,7 @@ export async function tryLoginAndSave ({ host, username, password }: { host: str body: { password, username, - scope: 'me.read roms.read platforms.read assets.read firmware.read roms.user.read collections.read me.write roms.user.write' + scope: 'me.read roms.read platforms.read assets.read assets.write firmware.read roms.user.read collections.read me.write roms.user.write' }, baseUrl: host }); diff --git a/src/bun/api/clients.ts b/src/bun/api/clients.ts index 7d117d3..470faf8 100644 --- a/src/bun/api/clients.ts +++ b/src/bun/api/clients.ts @@ -5,9 +5,10 @@ import games from "./games/games"; import platforms from "./games/platforms"; import auth from "./auth"; import collections from "./games/collections"; +import emulatorjs from "./emulatorjs/emulatorjs"; export default new Elysia({ prefix: "/api/romm" }) - .use([games, platforms, collections, auth]) + .use([games, platforms, collections, auth, emulatorjs]) .all("/*", async ({ request, set }) => { set.headers["cross-origin-resource-policy"] = 'cross-origin'; diff --git a/src/bun/api/controls/controls.ts b/src/bun/api/controls/controls.ts index 4aa417a..cc3c455 100644 --- a/src/bun/api/controls/controls.ts +++ b/src/bun/api/controls/controls.ts @@ -14,8 +14,8 @@ export default async function Initialize () const launchGameTask = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob); if (launchGameTask) { - launchGameTask.abort('exit'); taskQueue.waitForJob(LaunchGameJob.id).then(() => setTimeout(() => events.emit('focus'), 300)); + launchGameTask.abort('exit'); } else { events.emit('focus'); diff --git a/src/bun/api/emulatorjs/emulatorjs.ts b/src/bun/api/emulatorjs/emulatorjs.ts index c8018de..247ce6a 100644 --- a/src/bun/api/emulatorjs/emulatorjs.ts +++ b/src/bun/api/emulatorjs/emulatorjs.ts @@ -1,4 +1,11 @@ // ES-DE to emulator JS mapping + +import Elysia, { status } from "elysia"; +import z from "zod"; +import path from 'node:path'; +import { config, events, plugins } from "../app"; +import { getLocalGame, updateLocalLastPlayed } from "../games/services/statusService"; + // TODO: use the retroarch cores based on ES-DE export const cores: Record = { "atari5200": "atari5200", @@ -43,4 +50,57 @@ export const cores: Record = { "plus4": "plus4", "vic20": "vic20", "dos": "dos" -}; \ No newline at end of file +}; + +export default new Elysia({ prefix: '/emulatorjs' }) + .put('/save', async ({ body: { save, screenshot } }) => + { + await Bun.write(path.join(config.get('downloadPath'), 'saves', "EMULATORJS", save.name), save); + }, { + body: z.object({ + save: z.file(), + screenshot: z.file().optional() + }) + }).get('/load', async ({ query: { filePath } }) => + { + return Bun.file(path.join(config.get('downloadPath'), 'saves', "EMULATORJS", filePath)); + }, { query: z.object({ filePath: z.string() }) }) + .post('/post_play/:source/:id', async ({ params: { source, id }, body: { save } }) => + { + const localGame = await getLocalGame(source, id); + if (!localGame) return status("Not Found"); + + const changedSaveFiles: SaveFileChange[] = []; + if (save) + { + const savesPath = path.join(config.get('downloadPath'), 'saves', "EMULATORJS"); + const saveFile = path.join(savesPath, save.name); + await Bun.write(saveFile, save); + changedSaveFiles.push({ subPath: save.name, cwd: savesPath }); + events.emit('notification', { message: "Save Backed Up", type: "success", icon: "save" }); + } + await updateLocalLastPlayed(localGame.id); + await plugins.hooks.games.postPlay.promise({ + source, + id, + saveFolderPath: path.join(config.get('downloadPath'), "saves", "EMULATORJS"), + gameInfo: { platformSlug: localGame?.platform.slug }, + changedSaveFiles: changedSaveFiles, + validChangedSaveFiles: changedSaveFiles, + command: { + id: "EMULATORJS", + command: "", + emulator: "EMULATORJS", + valid: true, + metadata: { + romPath: localGame?.path_fs ?? undefined, + emulatorBin: undefined, + emulatorDir: undefined + } + } + }); + }, { + body: z.object({ + save: z.file().optional() + }) + }); \ No newline at end of file diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 79aaa10..97f0d8a 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -13,6 +13,7 @@ import Elysia from "elysia"; import z from "zod"; import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { LaunchGameJob } from "../../jobs/launch-game-job"; +import * as appSchema from "@schema/app"; class CommandSearchError extends Error { @@ -26,7 +27,14 @@ class CommandSearchError extends Error export async function getLocalGame (source: string, id: string) { const localGame = await db.query.games.findFirst({ - columns: { id: true, path_fs: true, source: true, source_id: true }, + columns: { + id: true, + path_fs: true, + source: true, + source_id: true, + igdb_id: true, + ra_id: true + }, where: getLocalGameMatch(id, source), with: { platform: { columns: { slug: true } } @@ -36,6 +44,33 @@ export async function getLocalGame (source: string, id: string) return localGame; } +export async function validateGameSource (source: string, id: string): Promise<{ valid: boolean, reason?: string; }> +{ + const localGame = await getLocalGame(source, id); + if (!localGame) throw new Error("Could not find local game"); + if (localGame.source && localGame.source_id) + { + const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id }); + if (!sourceGame) return { valid: false, reason: "Source Missing" }; + if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined)) + { + return { valid: false, reason: "IGDB Miss Match" }; + } + + if (sourceGame.ra_id !== (localGame.ra_id ?? undefined)) + { + return { valid: false, reason: "RA Miss Match" }; + } + } + + return { valid: true }; +} + +export async function updateLocalLastPlayed (id: number) +{ + await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(id))); +} + export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined> { if (source === 'emulator') diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index d276463..38016aa 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -18,8 +18,9 @@ export class GameHooks source?: string; sourceId?: string; id: FrontEndId; + platformSlug?: string; }; - }], string[] | undefined, { emulator: string; }>(['ctx']); + }], { args: string[], savesPath?: string; } | undefined, { emulator: string; }>(['ctx']); /** * Is the given emulator for the given command supported * @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects. @@ -69,7 +70,27 @@ export class GameHooks fetchPlatforms = new AsyncSeriesHook<[ctx: { platforms: FrontEndPlatformType[]; }]>(['ctx']); - updatePlayed = new AsyncSeriesWaterfallHook<[ctx: { source: string, id: string; }], boolean>(["ctx"]); + prePlay = new AsyncSeriesHook<[ctx: { + source: string, + id: string; + saveFolderPath?: string; + setProgress: (progress: number, state: string) => void, + command: CommandEntry; + gameInfo: { + platformSlug?: string; + }; + }]>(["ctx"]); + postPlay = new AsyncSeriesHook<[ctx: { + source: string, + id: string; + saveFolderPath?: string; + changedSaveFiles: SaveFileChange[], + validChangedSaveFiles: SaveFileChange[], + command: CommandEntry; + gameInfo: { + platformSlug?: string; + }; + }]>(["ctx"]); fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']); fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']); diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 18407ea..0564111 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -3,7 +3,7 @@ import { and, eq, or } from 'drizzle-orm'; import fs from 'node:fs/promises'; import * as schema from "@schema/app"; import * as emulatorSchema from "@schema/emulators"; -import path from 'node:path'; +import path, { join } from 'node:path'; import { config, db, emulatorsDb, events, plugins } from "../app"; import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService"; import * as igdb from 'ts-igdb-client'; @@ -13,9 +13,12 @@ import { Downloader } from "@/bun/utils/downloader"; import Seven from 'node-7z'; import z from "zod"; import { checkFiles } from "../games/services/utils"; -import { ensureDir } from "fs-extra"; +import { ensureDir, existsSync } from "fs-extra"; import { path7za } from "7zip-bin"; import slugify from 'slugify'; +import StreamZip from 'node-stream-zip'; +import { createExtractorFromFile } from 'node-unrar-js'; +import { which } from "bun"; interface JobConfig { @@ -116,23 +119,62 @@ export class InstallJob implements IJob for (const filePath of downloadedFiles) { const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path); - await new Promise((resolve, reject) => + await new Promise(async (resolve, reject) => { - const seven = Seven.extractFull(filePath, extractPath, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true }); + 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')) + { + console.warn("Could not extract", filePath, "with 7zip trying zip extractor"); + await ensureDir(extractPath); + const zip = new StreamZip.async({ file: filePath }); + const count = await zip.extract(null, extractPath); + console.log(`Extracted ${count} entries`); + await zip.close(); + } else + { + throw e; + } }); progress += progressDelta * 100; } diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 183e985..b60cb76 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -5,8 +5,11 @@ import { db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; import { eq } from "drizzle-orm"; import { spawn } from 'node:child_process'; +import { watch } from "node:fs"; +import fs from "node:fs/promises"; +import { updateLocalLastPlayed } from "../games/services/statusService"; -export class LaunchGameJob implements IJob, "playing"> +export class LaunchGameJob implements IJob, string> { static id = "launch-game" as const; static dataSchema = z.nullable(ActiveGameSchema); @@ -16,6 +19,8 @@ export class LaunchGameJob implements IJob; + saveFolderPath?: string; constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string) { @@ -24,11 +29,46 @@ export class LaunchGameJob implements IJob, "playing">, z.infer, "playing">) + async postPlay (gameInfo: { platformSlug?: string; }) { - let gameInfo: { name?: string, source_id?: string, source?: string; }; + if (this.gameId.source === 'local') + { + await updateLocalLastPlayed(Number(this.gameId.id)); + } + + const source = this.gameSource ?? this.gameId.source; + const id = this.gameSourceId ?? this.gameId.id; + + await plugins.hooks.games.postPlay.promise( + { + source, + id, + command: this.validCommand, + saveFolderPath: this.saveFolderPath, + changedSaveFiles: Array.from(this.changedSaveFiles.values()), + validChangedSaveFiles: [], + gameInfo + }).catch(e => console.error(e)); + } + + prePlay (setProgress: (progress: number, state: string) => void, gameInfo: { platformSlug?: string; }) + { + return plugins.hooks.games.prePlay.promise({ + source: this.gameSource ?? this.gameId.source, + id: this.gameSourceId ?? this.gameId.id, + saveFolderPath: this.saveFolderPath, + command: this.validCommand, + setProgress: setProgress, + gameInfo + }); + } + + async start (context: JobContext, string>, z.infer, string>) + { + let gameInfo: { name?: string, source_id?: string, source?: string; platformSlug?: string; } | undefined = undefined; if (this.gameId.source === 'emulator') { gameInfo = { name: this.gameId.id }; @@ -38,125 +78,140 @@ export class LaunchGameJob implements IJob + await new Promise(async (resolve, reject) => { - let game: any; - if (!commandArgs) + try { - // ES-DE commands require shell execution. Some emulators fail otherwise. - const spawnGame = spawn(this.validCommand.command, { - shell: true, - cwd: this.validCommand.startDir, - signal: context.abortSignal, - env: { + let game: any; + if (!commandArgs) + { + await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }).catch(e => reject(e)); + + // ES-DE commands require shell execution. Some emulators fail otherwise. + const spawnGame = spawn(this.validCommand.command, { + shell: true, + cwd: this.validCommand.startDir, + signal: context.abortSignal, + env: { + } + }); + + context.setProgress(0, "playing"); + + spawnGame.stdout.on('data', data => console.log(data)); + spawnGame.on('close', (code) => + { + resolve(code); + }); + spawnGame.on('error', e => + { + console.error(e); + reject(e); + }); + + game = spawnGame; + } + else if (this.validCommand.metadata.emulatorBin) + { + this.saveFolderPath = commandArgs.savesPath; + + await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }); + + // We have full control over launching integrated emulators better to use bun spawn + const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs.args], { + cwd: this.validCommand.startDir, + signal: context.abortSignal, + env: { + } + }); + + context.setProgress(0, "playing"); + + if (commandArgs.savesPath && await fs.exists(commandArgs.savesPath)) + { + const savesWatcher = watch(commandArgs.savesPath, { recursive: true, signal: context.abortSignal }); + console.log("Starting To Watch", commandArgs.savesPath, "for save file changes"); + savesWatcher.on('change', (type, filename) => + { + if (typeof filename === 'string') + { + console.log("Save File Changed", filename); + this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath! }); + } + }); + + bunGame.exited.then(() => + { + savesWatcher.close(); + console.log("Closing Save File Watching for", commandArgs.savesPath); + }); } - }); - spawnGame.stdout.on('data', data => console.log(data)); - spawnGame.on('close', (code) => + bunGame.exited.then(e => + { + resolve(true); + }).catch(e => + { + console.error(e); + reject(e); + }); + + game = bunGame; + + } else { - resolve(code); - }); - spawnGame.on('error', e => - { - console.error(e); - reject(e); - }); - - game = spawnGame; - } - else if (this.validCommand.metadata.emulatorBin) - { - // We have full control over launching integrated emulators better to use bun spawn - const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs], { - cwd: this.validCommand.startDir, - signal: context.abortSignal, - env: { - } - }); - - context.abortSignal.addEventListener('abort', reject); - - bunGame.exited.then(e => - { - resolve(true); - }).catch(e => - { - console.error(e); - reject(e); - }); - game = bunGame; - } else - { - reject(new Error("No Emulator Bin")); - return; - } - - this.activeGame = { - process: game, - name: gameInfo?.name ?? "Unknown", - gameId: this.gameId, - source: this.gameSource, - sourceId: this.gameSourceId, - command: this.validCommand - }; - - const updatePlayed = async (id: FrontEndId, source?: string, sourceId?: string) => - { - if (this.gameId.source === 'local') - { - await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(this.gameId.id))); + reject(new Error("No Emulator Bin")); + return; } - await plugins.hooks.games.updatePlayed.promise({ source: source ?? id.source, id: sourceId ?? id.id }).then(v => - { - if (v) events.emit('notification', { message: "Updated Last Played", type: 'success' }); - }); - }; - - updatePlayed(this.gameId, this.gameSource, this.gameSourceId); + this.activeGame = { + process: game, + name: gameInfo?.name ?? "Unknown", + gameId: this.gameId, + source: this.gameSource, + sourceId: this.gameSourceId, + command: this.validCommand + }; + } catch (e) + { + context.abort(e); + reject(e); + } }); - /* Old spawn lanching, cases issues, needs to be ran as shell - - const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]); - const game = setActiveGame({ - process: Bun.spawn({ - cmd, - env: { - ...process.env - }, - onExit (subprocess, exitCode, signalCode, error) - { - events.emit('activegameexit', { subprocess, exitCode, signalCode, error }); - }, - stdin: "ignore", - stdout: "inherit", - stderr: "inherit", - }), - name: localGame?.name ?? "Unknown", - gameId: validCommand.gameId, - command: validCommand.command.command - }); - - await game.process.exited; - if (game.process.exitCode && game.process.exitCode > 0) - { - return status('Internal Server Error'); - }*/ + await this.postPlay({ platformSlug: gameInfo?.platformSlug }); } exposeData () diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts index f42e221..cc3cfc7 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts @@ -11,7 +11,7 @@ export default class CEMUIntegration implements PluginType { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] }; }); ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => @@ -29,7 +29,7 @@ export default class CEMUIntegration implements PluginType args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`); } - return args; + return { args, savesPath: savesPath }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index 038cdfb..aa993a3 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -3,17 +3,18 @@ import { config } from "@/bun/api/app"; import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import path from 'node:path'; import desc from './package.json'; +import { ensureDir } from "fs-extra"; +import { getSavePaths, getType } from "./utils"; export default class DOLPHINIntegration implements PluginType { emulator = 'DOLPHIN'; - load (ctx: PluginContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "resolution", "fullscreen", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "resolution", "fullscreen", "saves"] }; }); ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => @@ -51,14 +52,33 @@ export default class DOLPHINIntegration implements PluginType args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`); args.push(`--config=Dolphin.General.WiiSDCardSyncFolder=${path.join(savesPath, 'WiiSDSync')}`); args.push(`--config=Dolphin.GBA.SavesPath=${path.join(savesPath, 'GBA')}`); + args.push(`--config=Dolphin.Core.GCIFolderAPath=${path.join(savesPath, 'GC')}`); + if (!ctx.dryRun) + { + await ensureDir(path.join(savesPath, 'GC', "JAP")); + await ensureDir(path.join(savesPath, 'GC', "EUR")); + await ensureDir(path.join(savesPath, 'GC', "USA")); + } + + let finalSavesPath: string | undefined = undefined; if (ctx.autoValidCommand.metadata.romPath) { args.push("--batch"); args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`); + + finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder; } - return args; + return { args, savesPath: finalSavesPath }; + }); + + ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) => + { + if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath) + { + validChangedSaveFiles.push(...await getSavePaths(command.metadata.romPath, saveFolderPath, command.metadata.emulatorDir)); + } }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts new file mode 100644 index 0000000..2514790 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts @@ -0,0 +1,164 @@ +import { join } from "path"; +import { platform } from "os"; +import fs from "node:fs/promises"; +import path from "node:path"; + +type DolphinLocation = + | { type: "path"; toolPath: string; } + | { type: "appimage"; appImagePath: string; }; + +async function findDolphinTool (bundledDir?: string): Promise +{ + const os = platform(); + const toolName = os === "win32" ? "DolphinTool.exe" : "dolphin-tool"; + + if (bundledDir) + { + if (os === "linux") + { + const glob = new Bun.Glob("*.AppImage"); + for await (const file of glob.scan(bundledDir)) + { + return { type: "appimage", appImagePath: join(bundledDir, file) }; + } + throw new Error(`No AppImage found in ${bundledDir}`); + } else + { + return { type: "path", toolPath: join(bundledDir, toolName) }; + } + } + + // Fallback 1: check PATH + const inPath = Bun.which(toolName); + if (inPath) return { type: "path", toolPath: inPath }; + + // Fallback 2: platform default install locations + if (os === "win32") + { + const candidates = [ + "C:/Program Files/Dolphin/DolphinTool.exe", + "C:/Program Files (x86)/Dolphin/DolphinTool.exe", + ]; + for (const candidate of candidates) + { + if (await Bun.file(candidate).exists()) + { + return { type: "path", toolPath: candidate }; + } + } + } else if (os === "darwin") + { + const candidate = "/Applications/Dolphin.app/Contents/MacOS/dolphin-tool"; + if (await Bun.file(candidate).exists()) + { + return { type: "path", toolPath: candidate }; + } + } else if (os === "linux") + { + const home = process.env.HOME ?? ""; + const candidates = [ + join(home, "Applications/Dolphin-x86_64.AppImage"), + join(home, "Applications/Dolphin.AppImage"), + "/opt/Dolphin-x86_64.AppImage", + ]; + for (const candidate of candidates) + { + if (await Bun.file(candidate).exists()) + { + return { type: "appimage", appImagePath: candidate }; + } + } + } + + throw new Error(`Could not find ${toolName}. Install Dolphin or pass its folder path explicitly.`); +} + +async function runDolphinTool (args: string[], location: DolphinLocation): Promise +{ + if (location.type === "path") + { + const proc = Bun.spawnSync([location.toolPath, ...args]); + if (!proc.success) throw new Error(`dolphin-tool failed: ${proc.stderr.toString()}`); + return proc.stdout.toString(); + } else + { + const mount = Bun.spawn([location.appImagePath, "--appimage-mount"], { + stdout: "pipe", + stderr: "pipe", + }); + const mountPoint = (await new Response(mount.stdout).text()).trim(); + try + { + const proc = Bun.spawnSync([`${mountPoint}/usr/bin/dolphin-tool`, ...args]); + if (!proc.success) throw new Error(`dolphin-tool failed: ${proc.stderr.toString()}`); + return proc.stdout.toString(); + } finally + { + mount.kill(); + } + } +} + +async function readGameId (romPath: string, location: DolphinLocation): Promise +{ + const output = await runDolphinTool(["header", "-i", romPath], location); + const match = output.match(/Game ID:\s*(\w{6})/); + if (!match) throw new Error("Could not read game ID"); + return match[1]; +} + +function getRegion (regionCode: string) +{ + switch (regionCode) + { + case "E": return "USA"; + case "P": return "EUR"; + case "J": return "JAP"; + default: return "USA"; + } +} + +async function getGCSavePaths (romPath: string, savesPath: string, location: DolphinLocation) +{ + const gameId = await readGameId(romPath, location); + const region = getRegion(gameId[3]); + + const makerCode = gameId.slice(4, 6); // e.g. "01" or "7D" — already the right format + const gameCode = gameId.slice(0, 4); // e.g. "GZLE" or "GM5E" + const cardPath = join(savesPath, "GC", region); + + const glob = new Bun.Glob(`${makerCode}-${gameCode}-*.gci`); + const saves: SaveFileChange[] = []; + for await (const file of glob.scan(cardPath)) + { + saves.push({ subPath: path.join("GC", region, file), cwd: savesPath, shared: false }); + } + + return saves; +} + +export async function getType (romPath: string, bundledEmulatorDir?: string): Promise<"gamecube" | "wii"> +{ + const location = await findDolphinTool(bundledEmulatorDir); + const gameId = await readGameId(romPath, location); + const isGameCube = gameId[0] === "G" || gameId[0] === "D"; + return isGameCube ? "gamecube" : "wii"; +} + +export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise +{ + const location = await findDolphinTool(bundledEmulatorDir); + const gameId = await readGameId(romPath, location); + const isGameCube = gameId[0] === "G" || gameId[0] === "D"; + + if (isGameCube) + { + return getGCSavePaths(romPath, savesPath, location); + } else + { + const folder = Buffer.from(gameId.slice(0, 4), "ascii").toString("hex").toUpperCase(); + const rootFolder = join(savesPath, "Wii", "title", "00010000", folder); + const files = await fs.readdir(rootFolder, { recursive: true }); + return files.map(f => ({ subPath: path.join("Wii", "title", "00010000", f), cwd: savesPath, shared: false })); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini index 72985fb..e1403c5 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini @@ -21,7 +21,6 @@ CdvdShareWrite = false EnablePatches = true EnableCheats = false EnablePINE = false -EnableWideScreenPatches = {{ENABLE_WIDESCREEN}} EnableNoInterlacingPatches = false EnableRecordingTools = true EnableGameFixes = true @@ -168,7 +167,6 @@ linear_present_mode = 1 deinterlace_mode = 0 OsdScale = 100 Renderer = 14 -upscale_multiplier = {{UPSCALE_MULTIPLIER}} mipmap_hw = -1 accurate_blending_unit = 1 crc_hack_level = -1 @@ -371,18 +369,6 @@ Multitap2_Slot4_Enable = false Multitap2_Slot4_Filename = Mcd-Multitap2-Slot04.ps2 -[Folders] -Bios = {{{BIOS_PATH}}} -Snapshots = {{{SNAPSHOTS_PATH}}} -SaveStates = {{{SAVE_STATES_PATH}}} -MemoryCards = {{{MEMORY_CARDS_PATH}}} -Cache = {{{CACHE_PATH}}} -Covers = {{{COVERS_PATH}}} -Logs = logs -Textures = {{{TEXTURES_PATH}}} -Videos = videos - - [InputSources] Keyboard = true Mouse = true @@ -488,6 +474,3 @@ RDown = SDL-1/+RightY RLeft = SDL-1/-RightX LargeMotor = SDL-1/LargeMotor SmallMotor = SDL-1/SmallMotor - -[GameList] -RecursivePaths = {{{RECURSIVE_PATHS}}} diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index 5317395..db405a2 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -1,11 +1,11 @@ import { config } from "@/bun/api/app"; import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; -import configFile from './PCSX2.ini' with { type: 'file' }; -import Mustache from 'mustache'; +import defaultConfig from './PCSX2.ini' with { type: 'file' }; import path from 'node:path'; import { ensureDir } from "fs-extra"; import desc from './package.json'; +import ini from 'ini'; export default class PCSX2Integration implements PluginType { @@ -15,7 +15,7 @@ export default class PCSX2Integration implements PluginType { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; + const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"]; if (ctx.source?.type === 'store') { @@ -47,7 +47,16 @@ export default class PCSX2Integration implements PluginType if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun) { - const configFileContents = await Bun.file(configFile).text(); + let pscx2Path = ''; + if (process.platform === 'win32') + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); + else + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis'); + + const configPath = path.join(pscx2Path, 'PCSX2.ini'); + const existingConfigFile = Bun.file(configPath); + + const configFile = await existingConfigFile.exists() ? ini.parse(await existingConfigFile.text()) : ini.parse(await Bun.file(defaultConfig).text()); const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator); @@ -67,28 +76,37 @@ export default class PCSX2Integration implements PluginType CACHE_PATH: path.join(storageFolder, 'cache'), COVERS_PATH: path.join(storageFolder, 'covers'), TEXTURES_PATH: path.join(storageFolder, 'textures'), + VIDEOS_PATH: path.join(storageFolder, 'videos'), + LOGS_PATH: path.join(storageFolder, 'logs'), RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), }; await Promise.all(Object.values(paths).map(p => ensureDir(p))); - const view = { - ...paths, - ENABLE_WIDESCREEN: config.get('emulatorWidescreen'), - ASPECT_RATIO: config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2", - UPSCALE_MULTIPLIER: resolutionMapping[config.get('emulatorResolution')] ?? 1 - }; + configFile.EmuCore ??= {}; + configFile.EmuCore.EnableWideScreenPatches = config.get('emulatorWidescreen'); + configFile['EmuCore/GS'] ??= {}; + configFile['EmuCore/GS'].AspectRatio = config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2"; + configFile['EmuCore/GS'].upscale_multiplier = resolutionMapping[config.get('emulatorResolution')] ?? 1; + configFile.Folders ??= {}; + configFile.Folders.Bios = paths.BIOS_PATH; + configFile.Folders.Snapshots = paths.SNAPSHOTS_PATH; + configFile.Folders.SaveStates = paths.SAVE_STATES_PATH; + configFile.Folders.MemoryCards = paths.MEMORY_CARDS_PATH; + configFile.Folders.Cache = paths.CACHE_PATH; + configFile.Folders.Covers = paths.COVERS_PATH; + configFile.Folders.Textures = paths.TEXTURES_PATH; + configFile.Folders.Videos = paths.VIDEOS_PATH; + configFile.Folders.Logs = paths.LOGS_PATH; + configFile.GameList ??= {}; + configFile.GameList.RecursivePaths = paths.RECURSIVE_PATHS; - let pscx2Path = ''; - if (process.platform === 'win32') - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); - else - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis'); + await Bun.write(configPath, ini.stringify(configFile)); - await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); + return { args, savesPath: paths.MEMORY_CARDS_PATH }; } - return args; + return { args }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini index afc914c..c138918 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini @@ -96,7 +96,6 @@ HardwareTransform = True SoftwareSkinning = True TextureFiltering = 1 BufferFiltering = 1 -InternalResolution = {{RESOLUTION}} AndroidHwScale = 1 HighQualityDepth = 1 FrameSkip = 0 @@ -109,7 +108,6 @@ AnisotropyLevel = 4 VertexDecCache = False TextureBackoffCache = False TextureSecondaryCache = False -FullScreen = {{FULLSCREEN}} FullScreenMulti = False SmallDisplayZoomType = 2 SmallDisplayOffsetX = 0.500000 diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index 1f6572f..b6ff93d 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -9,6 +9,7 @@ import path from "node:path"; import Mustache from "mustache"; import { ensureDir } from "fs-extra"; import { homedir } from "node:os"; +import ini from 'ini'; export default class PPSSPPIntegration implements PluginType { @@ -27,7 +28,7 @@ export default class PPSSPPIntegration implements PluginType ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; + const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"]; if (ctx.source?.type === 'store') { @@ -59,18 +60,18 @@ export default class PPSSPPIntegration implements PluginType if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun) { - let confPath: string | undefined = undefined; - let controlsPath: string | undefined = undefined; + let defaultConfigPath: string | undefined = undefined; + let defaultControlsPath: string | undefined = undefined; switch (process.platform) { case "win32": - confPath = configFilePathWin32; - controlsPath = configControlsFilePathWin32; + defaultConfigPath = configFilePathWin32; + defaultControlsPath = configControlsFilePathWin32; break; case 'linux': - confPath = configFilePathLinux; - controlsPath = configControlsFilePathLinux; + defaultConfigPath = configFilePathLinux; + defaultControlsPath = configControlsFilePathLinux; break; } @@ -87,29 +88,36 @@ export default class PPSSPPIntegration implements PluginType ensureDir(ppssppPath); - if (confPath) + if (defaultConfigPath) { - const resolutionMapping = { - "720p": "2", - "1080p": "4", - "1440p": "6", - "4k": "8" + const resolutionMapping: Record = { + "720p": 2, + "1080p": 4, + "1440p": 6, + "4k": 8 }; - const configFileContents = await Bun.file(confPath).text(); - await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, { - RESOLUTION: resolutionMapping[config.get('emulatorResolution')] ?? 0, - FULLSCREEN: config.get('launchInFullscreen') ? "True" : "False" - })); + const configPath = path.join(ppssppPath, 'ppsspp.ini'); + const configFile = Bun.file(configPath); + + const ppssppConfig = await configFile.exists() ? ini.parse(await configFile.text()) : ini.parse(await Bun.file(defaultConfigPath).text()); + + ppssppConfig.Graphics ??= {}; + ppssppConfig.Graphics.InternalResolution = resolutionMapping[config.get('emulatorResolution')] ?? 0; + ppssppConfig.Graphics.FullScreen = config.get('launchInFullscreen'); + + await Bun.write(configPath, ini.stringify(ppssppConfig)); } - if (controlsPath) + if (defaultControlsPath) { - const controlsFileContents = await Bun.file(controlsPath).text(); + const controlsFileContents = await Bun.file(defaultControlsPath).text(); await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); } + + return { args, savesPath: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") }; } - return args; + return { args }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini index 21a71c3..f448165 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini @@ -96,7 +96,6 @@ HardwareTransform = True SoftwareSkinning = True TextureFiltering = 1 BufferFiltering = 1 -InternalResolution = {{RESOLUTION}} AndroidHwScale = 1 HighQualityDepth = 1 FrameSkip = 0 @@ -109,7 +108,6 @@ AnisotropyLevel = 4 VertexDecCache = False TextureBackoffCache = False TextureSecondaryCache = False -FullScreen = {{FULLSCREEN}} FullScreenMulti = False SmallDisplayZoomType = 2 SmallDisplayOffsetX = 0.500000 diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts index 49e56f3..010430c 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts @@ -14,7 +14,7 @@ export default class XEMUIntegration implements PluginType { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] }; }); ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => @@ -68,7 +68,7 @@ export default class XEMUIntegration implements PluginType } - return args; + return { args }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/utils.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/utils.ts new file mode 100644 index 0000000..ceef07e --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/utils.ts @@ -0,0 +1,141 @@ +import { join } from "path"; +import { platform } from "os"; + +const SECTOR_SIZE = 0x800; +const MAGIC = "MICROSOFT*XBOX*MEDIA"; + +const PARTITION_OFFSETS: Record = { + XSF: 0x0, + GDF: 0xFD90000, + XGD3: 0x2080000, +}; + +async function readBytes (file: ReturnType, offset: number, length: number): Promise +{ + return Buffer.from(await file.slice(offset, offset + length).arrayBuffer()); +} + +async function parseTitleIdFromXexReader ( + read: (offset: number, length: number) => Promise +): Promise +{ + // Read just the fixed header (magic + flags + offsets + header count) + const header = await read(0, 0x18); + if (header.toString("ascii", 0, 4) !== "XEX2") + { + throw new Error("Not a valid XEX2 file"); + } + + const headerCount = header.readUInt32BE(0x14); + const EXEC_INFO_KEY = 0x40006; + + // Read the optional header table + const table = await read(0x18, headerCount * 8); + + for (let i = 0; i < headerCount; i++) + { + const key = table.readUInt32BE(i * 8); + const valueOrOffset = table.readUInt32BE(i * 8 + 4); + + if (key === EXEC_INFO_KEY) + { + // valueOrOffset is a file offset — read the exec info struct there + // TitleID is at +0x0C within it + const execInfo = await read(valueOrOffset, 0x18); + return execInfo.readUInt32BE(0x0C) + .toString(16).toUpperCase().padStart(8, "0"); + } + } + + throw new Error("Execution info header not found in XEX"); +} + +async function titleIdFromXexFile (xexPath: string): Promise +{ + const file = Bun.file(xexPath); + return parseTitleIdFromXexReader((offset, length) => + readBytes(file, offset, length) + ); +} + +async function titleIdFromIso (isoPath: string): Promise +{ + const file = Bun.file(isoPath); + const fileSize = file.size; + + for (const partitionOffset of Object.values(PARTITION_OFFSETS)) + { + const vdOffset = partitionOffset + 0x20 * SECTOR_SIZE; + if (vdOffset + 28 > fileSize) continue; + + const vd = await readBytes(file, vdOffset, 28); + if (vd.toString("ascii", 0, 20) !== MAGIC) continue; + + const rootSector = vd.readUInt32LE(20); + const rootSize = vd.readUInt32LE(24); + const rootOffset = partitionOffset + rootSector * SECTOR_SIZE; + const dir = await readBytes(file, rootOffset, rootSize); + + let pos = 0; + while (pos < dir.length) + { + if (dir[pos] === 0xFF) break; + if (pos + 14 > dir.length) break; + + const nameLen = dir[pos + 13]; + if (nameLen === 0 || nameLen === 0xFF) break; + if (pos + 14 + nameLen > dir.length) break; + + const name = dir.toString("ascii", pos + 14, pos + 14 + nameLen); + const fileSector = dir.readUInt32LE(pos + 4); + + if (name.toLowerCase() === "default.xex") + { + const xexBase = partitionOffset + fileSector * SECTOR_SIZE; + // Reader that translates relative XEX offsets to absolute ISO offsets + return parseTitleIdFromXexReader((offset, length) => + readBytes(file, xexBase + offset, length) + ); + } + + const entryLen = 14 + nameLen; + pos += (entryLen + 3) & ~3; + } + } + + throw new Error("Not a valid Xbox 360 ISO or default.xex not found"); +} + +async function titleIdFromFolder (folderPath: string): Promise +{ + return titleIdFromXexFile(join(folderPath, "default.xex")); +} + +type XeniaRomType = "iso" | "xex" | "folder"; + +function detectRomType (romPath: string): XeniaRomType +{ + const lower = romPath.toLowerCase(); + if (lower.endsWith(".iso")) return "iso"; + if (lower.endsWith(".xex")) return "xex"; + return "folder"; // extracted game folder containing default.xex +} + +async function getTitleId (romPath: string): Promise +{ + switch (detectRomType(romPath)) + { + case "iso": return titleIdFromIso(romPath); + case "xex": return titleIdFromXexFile(romPath); + case "folder": return titleIdFromFolder(romPath); + } +} + +export async function getXeniaSavePaths ( + romPath: string, + xeniaDir: string +): Promise +{ + const titleId = await getTitleId(romPath); + return join(xeniaDir, titleId); +}; \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts index 7257559..6a021da 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { ensureDir } from "fs-extra"; import toml, { TomlTable } from 'smol-toml'; import fs from 'node:fs/promises'; +import { getXeniaSavePaths } from "./utils"; export default class XENIAIntegration implements PluginType { @@ -17,7 +18,8 @@ export default class XENIAIntegration implements PluginType await Bun.write(path.join(ctx.path, "portable.txt"), ""); } - async handleLaunch (ctx: Parameters['0']) + async handleLaunch (ctx: Parameters['0']): + ReturnType { const args: string[] = []; @@ -28,6 +30,13 @@ export default class XENIAIntegration implements PluginType const configPath = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, `${ctx.autoValidCommand.emulator}.toml`); + args.push(`--config`, configPath); + + if (config.get('launchInFullscreen')) + { + args.push(`--fullscreen`); + } + if (!ctx.dryRun) { await ensureDir(path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!)); @@ -47,28 +56,30 @@ export default class XENIAIntegration implements PluginType configFile.Display.fullscreen = config.get('launchInFullscreen'); configFile.GPU.draw_resolution_scale_x = resolutionMapping[config.get('emulatorResolution')] ?? 1; configFile.GPU.draw_resolution_scale_y = resolutionMapping[config.get('emulatorResolution')] ?? 1; - await ensureDir(path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!)); + const savesPath = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!); + await ensureDir(savesPath); configFile.Storage.content_root = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!); configFile.Storage.storage_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'config'); configFile.Storage.cache_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'cache'); await Bun.write(configPath, toml.stringify(configFile)); + + let finalSavesPath: string | undefined = undefined; + if (ctx.autoValidCommand.metadata.romPath) + { + finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath); + } + + return { args, savesPath: finalSavesPath }; }; - args.push(`--config`, configPath); - - if (config.get('launchInFullscreen')) - { - args.push(`--fullscreen`); - } - - return args; + return { args }; } handleEmulatorLaunchSupport (ctx: Parameters['0']): ReturnType { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves"] }; } load (ctx: PluginContextType) @@ -78,5 +89,14 @@ export default class XENIAIntegration implements PluginType ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, this.handleLaunch); ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulatorEdge }, this.handleLaunch); + + ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) => + { + if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath) + { + const files = await fs.readdir(saveFolderPath, { recursive: true }); + validChangedSaveFiles.push(...files.map(f => ({ subPath: f, cwd: saveFolderPath, shared: false } satisfies SaveFileChange))); + } + }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 2b3621c..46c64db 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -2,8 +2,8 @@ import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; -import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; -import { config } from "@/bun/api/app"; +import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; +import { config, events } from "@/bun/api/app"; import path from 'node:path'; import fs from 'node:fs/promises'; import { hashFile, isSteamDeckGameMode } from "@/bun/utils"; @@ -11,6 +11,7 @@ import { CACHE_KEYS, getOrCached } from "@/bun/api/cache"; import secrets from "@/bun/api/secrets"; import { getAuthToken } from "@/clients/romm/core/auth.gen"; import { client } from "@/clients/romm/client.gen"; +import { validateGameSource } from "@/bun/api/games/services/statusService"; export default class RommIntegration implements PluginType { @@ -75,7 +76,9 @@ export default class RommIntegration implements PluginType missing: rom.missing_from_fs, genres: rom.metadatum.genres, companies: rom.metadatum.companies, - release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined + release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined, + imdb_id: rom.igdb_id ?? undefined, + ra_id: rom.ra_id ?? undefined }; const userData = await getCurrentUserApiUsersMeGet(); @@ -371,12 +374,143 @@ export default class RommIntegration implements PluginType } }); - ctx.hooks.games.updatePlayed.tapPromise(desc.name, async ({ source, id }) => + ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderPath, setProgress }) => { - if (source !== 'romm') return false; + if (source !== 'romm') return; + if (saveFolderPath) + { + setProgress(0, "saves"); + + const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } }); + if (saveFiles.error) + { + console.error(saveFiles.error); + } else + { + for (let i = 0; i < saveFiles.data.slots.length; i++) + { + const slot = saveFiles.data.slots[i]; + const savePath = path.join(saveFolderPath, slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`); + if (await fs.exists(savePath)) + { + const existingSaveSync = await fs.stat(savePath); + const updatedAtTime = new Date(slot.latest.updated_at).getTime(); + + if (existingSaveSync.mtimeMs > updatedAtTime) + { + console.log("Newer save file", savePath, "Server:", new Date(slot.latest.updated_at), "Local:", existingSaveSync.mtime); + // Newer file + continue; + } else if (updatedAtTime === existingSaveSync.mtimeMs) + { + //TODO: do checksum comparison when that works on romm + console.log("Same save file", savePath); + continue; + } + } + + const auth = await this.getAuthToken(); + const headers: Record = {}; + if (auth) + headers['Authorization'] = auth; + + const saveResponse = await fetch(`${config.get('rommAddress')}${slot.latest.download_path}`, { headers }); + if (!saveResponse.ok) + { + console.error("Error downloading save", saveResponse.statusText); + break; + } + await Bun.write(savePath, saveResponse); + console.log("Loaded", savePath); + setProgress((i / saveFiles.data.slots.length) * 100, "saves"); + } + } + + setProgress(1, "saves"); + await Bun.sleep(1000); + } + }); + + ctx.hooks.games.postPlay.tapPromise(desc.name, async ({ source, id, validChangedSaveFiles, saveFolderPath, command }) => + { + if (source !== 'romm') return; + + const sourceValidation = await validateGameSource(source, id); + if (!sourceValidation.valid) + { + console.warn("Invalid Source", sourceValidation.reason, "Skipping updates"); + return; + } + + const finalSavePaths = validChangedSaveFiles.filter(f => !f.shared); + + const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } }); + if (saveFiles.error) + { + console.error(saveFiles.error); + } else if (saveFolderPath) + { + for (let i = 0; i < saveFiles.data.slots.length; i++) + { + const slot = saveFiles.data.slots[i]; + const savePath = path.join(saveFolderPath, slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`); + if (await fs.exists(savePath)) + { + const stat = await fs.stat(savePath); + if (stat.mtimeMs > new Date(slot.latest.updated_at).getTime()) + { + const subPath = path.join(slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`); + if (!finalSavePaths.some(f => f.subPath === subPath)) + { + // Add newer files to the list, maybe they were changed offscreen. + finalSavePaths.push({ subPath, cwd: saveFolderPath, shared: false }); + } + } + } + } + } + + if (finalSavePaths.length > 0) + { + console.log("Files Changed:", finalSavePaths.map(f => f.subPath)?.join(", ")); + + await Promise.all(finalSavePaths.map(async f => + { + const absolutePath = path.join(f.cwd, f.subPath); + if (!await fs.exists(absolutePath)) return; + const stat = await fs.stat(absolutePath); + if (stat.isDirectory()) return; + const data: FormData = new FormData(); + data.append('saveFile', Bun.file(absolutePath), path.basename(f.subPath)); + + const url = new URL(`${config.get('rommAddress')}/api/saves`); + url.searchParams.set('rom_id', id); + url.searchParams.set('slot', path.dirname(f.subPath)); + url.searchParams.set('autocleanup', "true"); + url.searchParams.set('autocleanup_limit', "2"); + if (command.emulator) + url.searchParams.set('emulator', command.emulator); + url.searchParams.set('overwrite', "true"); + + const auth = await this.getAuthToken(); + const headers: Record = {}; + if (auth) + headers['Authorization'] = auth; + + const response = await fetch(url, { + body: data, + method: "POST", + headers + }); + if (!response.ok) console.error(response.statusText); + })); + + events.emit('notification', { message: "Saves Uploaded", icon: 'upload', type: "success" }); + } + const resp = await updateRomUserApiRomsIdPropsPut({ path: { id: Number(id) }, body: { update_last_played: true } }); if (resp.error) console.error(resp.error); - return resp.response.ok; + events.emit('notification', { message: "Updated Played", type: "success", icon: "clock" }); }); ctx.hooks.games.fetchCollections.tapPromise(desc.name, async ({ collections }) => diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index f331bb6..308f217 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -223,14 +223,28 @@ export class JobContext, TData, TState extends str } } catch (error) { - if (error !== 'cancel') + try { - console.error(error); + if (error instanceof Event) + { + if (error.target instanceof AbortSignal) + { + + } else + { + console.error(error); + } + } else + { + console.error(error); + this.events.emit('error', { id: this.m_id, job: this, error }); + this.error = error; + } + } finally + { + this.m_promise.resolve(undefined); } - this.events.emit('error', { id: this.m_id, job: this, error }); - this.error = error; - this.m_promise.resolve(undefined); } finally { this.running = false; diff --git a/src/mainview/components/Notifications.tsx b/src/mainview/components/Notifications.tsx index 37edb26..a069069 100644 --- a/src/mainview/components/Notifications.tsx +++ b/src/mainview/components/Notifications.tsx @@ -1,7 +1,15 @@ import { RPC_URL } from "@/shared/constants"; +import { Clock, CloudUpload, Save } from "lucide-react"; import { useEffect } from "react"; import toast, { ToastOptions } from "react-hot-toast"; + +const customIconMap = { + save: , + upload: , + clock: +}; + export default function Notifications (data: {}) { useEffect(() => @@ -10,7 +18,13 @@ export default function Notifications (data: {}) es.addEventListener('notification', (e) => { const notification = JSON.parse(e.data) as FrontendNotification; - const options: ToastOptions = { removeDelay: notification.duration }; + const options: ToastOptions = { + removeDelay: notification.duration, + style: { + borderRadius: "64px" + } + }; + if (notification.icon) options.icon = customIconMap[notification.icon]; if (notification.type === 'error') { toast.error(notification.message, options); diff --git a/src/mainview/components/game/Details.tsx b/src/mainview/components/game/Details.tsx index 5523f52..c3b1fcc 100644 --- a/src/mainview/components/game/Details.tsx +++ b/src/mainview/components/game/Details.tsx @@ -2,16 +2,16 @@ import { scrollIntoViewHandler } from "@/mainview/scripts/utils"; import { RPC_URL } from "@/shared/constants"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; -import { Clock, CloudDownload, HardDrive, Store, TriangleAlert } from "lucide-react"; +import { Clock, CloudBackup, CloudDownload, CloudUpload, HardDrive, Store, TriangleAlert } from "lucide-react"; import prettyBytes from "pretty-bytes"; import { JSX } from "react"; import ActionButtons from "./ActionButtons"; +import prettyMilliseconds from 'pretty-ms'; - -export function DetailElement (data: { icon: JSX.Element; children?: any | any[]; }) +export function DetailElement (data: { icon: JSX.Element; tooltip?: string | null, children?: any | any[]; }) { return ( -
    +
    {data.icon} {data.children}
    @@ -62,15 +62,14 @@ export default function Details (data: { }
    -
    - } >{data.game?.last_played ? new Date(data.game.last_played).toDateString() : "Never"} +
    + } >{data.game?.last_played ? `${prettyMilliseconds(new Date().getTime() - new Date(data.game.last_played).getTime(), { compact: true, verbose: true })} ago` : "Never"} {!!data.game && (data.game.fs_size_bytes !== null || data.game.missing) && -
    -
    - {data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)} -
    +
    + {data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)}
    } :
    } >{data.game?.platform_display_name ??
    }
    + {data.game?.emulators?.some(e => e.integrations.some(i => i.capabilities?.includes('saves'))) && } />} } > diff --git a/src/mainview/emulatorjs/emulator.ts b/src/mainview/emulatorjs/emulator.ts index b5a730e..48ba808 100644 --- a/src/mainview/emulatorjs/emulator.ts +++ b/src/mainview/emulatorjs/emulator.ts @@ -10,10 +10,11 @@ Array.from(params.entries()).forEach(([key, value]) => window.addEventListener('message', (e) => { - switch (e.data.type) + const data = e.data as EmulatorJsMessage; + switch (data.type) { case 'pause': - if (e.data.data === true) + if (data.paused) { window.EJS_emulator.pause(); } else @@ -24,14 +25,51 @@ window.addEventListener('message', (e) => case 'restart': window.EJS_emulator.elements.bottomBar.restart[0].click(); break; + case 'requestSave': + window.EJS_emulator.elements.bottomBar.saveSavFiles[0].click(); + break; } }); +function postMessage (m: EmulatorJsMessage) +{ + window.parent.postMessage( + m, + "*" + ); +} + +export function loadEmulatorJSSave (save: Uint8Array) +{ + const FS = window.EJS_emulator.gameManager.FS; + const path = window.EJS_emulator.gameManager.getSaveFilePath(); + const paths = path.split("/"); + let cp = ""; + for (let i = 0; i < paths.length - 1; i++) + { + if (paths[i] === "") continue; + cp += "/" + paths[i]; + if (!FS.analyzePath(cp).exists) FS.mkdir(cp); + } + if (FS.analyzePath(path).exists) FS.unlink(path); + FS.writeFile(path, save); + window.EJS_emulator.gameManager.loadSaveFiles(); +} + window.EJS_threads = !__PUBLIC__; window.EJS_player = "#game"; window.EJS_lightgun = false; window.EJS_startOnLoaded = true; +window.EJS_onGameStart = async () => +{ + const savesResponse = await fetch(`${RPC_URL(__HOST__)}/api/romm/emulatorjs/load?filePath=${encodeURIComponent(window.EJS_emulator.gameManager.getSaveFilePath())}`); + if (savesResponse.ok) + { + loadEmulatorJSSave(new Uint8Array(await savesResponse.arrayBuffer())); + postMessage({ type: "loaded" }); + } +}; // For core downloads, it either redirects to CDN or uses local if downloaded window.EJS_pathtodata = `${RPC_URL(__HOST__)}/api/romm/emulatorjs/data`; window.EJS_Buttons = { @@ -40,10 +78,8 @@ window.EJS_Buttons = { displayName: "Exit", callback: () => { - window.parent.postMessage( - { type: "exit" }, - "*" - ); + const saveFile = window.EJS_emulator.gameManager.getSaveFile(false); + postMessage({ type: "exit", save: saveFile ? new File([saveFile], window.EJS_emulator.gameManager.getSaveFilePath()) : undefined }); } } }; @@ -58,7 +94,18 @@ const moduleUrls = import.meta.glob import: 'default', }); +function handeSave (ctx: { save: ArrayBuffer, screenshot: ArrayBuffer | undefined, format: string; }) +{ + window.parent.postMessage({ type: 'save', save: new File([ctx.save], window.EJS_emulator.gameManager.getSaveFilePath()) }); +} + // emulatorjs expects basenames instead of paths for some reason window.EJS_paths = Object.fromEntries(await Promise.all(Object.entries(moduleUrls).map(async ([key, value]) => [basename(key), await value()]))); +window.EJS_onSaveUpdate = (ctx: { hash: string, save: ArrayBuffer, screenshot: ArrayBuffer | undefined, format: string; }) => handeSave(ctx); +window.EJS_onSaveSave = (ctx: { + save: ArrayBuffer; + screenshot: ArrayBuffer; + format: string; +}) => handeSave(ctx); await import('@emulatorjs/emulatorjs/data/loader.js' as any); \ No newline at end of file diff --git a/src/mainview/emulatorjs/types.d.ts b/src/mainview/emulatorjs/types.d.ts index 11b8f1f..4021f5d 100644 --- a/src/mainview/emulatorjs/types.d.ts +++ b/src/mainview/emulatorjs/types.d.ts @@ -14,6 +14,7 @@ export declare global EJS_cheats: string[][], EJS_fullscreenOnLoaded: boolean, EJS_startOnLoaded: boolean, + EJS_onGameStart, EJS_core: string, EJS_lightgun: boolean, EJS_biosUrl: string, @@ -56,7 +57,9 @@ export declare global EJS_browserMode, EJS_shaders, EJS_fixedSaveInterval, + EJS_onSaveUpdate, EJS_disableAutoUnload, EJS_disableBatchBootup; + EJS_onSaveSave; } } \ No newline at end of file diff --git a/src/mainview/routes/embedded.$source.$id.tsx b/src/mainview/routes/embedded.$source.$id.tsx index df6ec54..e1704c3 100644 --- a/src/mainview/routes/embedded.$source.$id.tsx +++ b/src/mainview/routes/embedded.$source.$id.tsx @@ -5,20 +5,24 @@ import z from 'zod'; import { RefObject, useEffect, useRef, useState } from 'react'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { ButtonStyle } from '../components/options/Button'; -import { DoorOpen, RefreshCw, Undo } from 'lucide-react'; -import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; -import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts'; +import { CloudDownload, DoorOpen, RefreshCw, Save, Undo } from 'lucide-react'; +import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts'; +import { FloatingShortcuts } from '../components/Shortcuts'; import { useEventListener } from 'usehooks-ts'; import useActiveControl from '../scripts/gamepads'; import { twMerge } from 'tailwind-merge'; import { HeaderAccounts, HeaderStatusBar } from '../components/Header'; import { RoundButton } from '../components/RoundButton'; import { gameQuery } from '@queries/romm'; +import { rommApi } from '../scripts/clientApi'; +import toast from 'react-hot-toast'; +import { getErrorMessage } from 'react-error-boundary'; export const Route = createFileRoute('/embedded/$source/$id')({ component: RouteComponent, staticData: { - enterSound: 'launch' + enterSound: 'launch', + missNavSound: false }, loader: async (ctx) => { @@ -45,7 +49,7 @@ function OverlayButton (data: { function Overlay (data: { open: boolean; - iframeRef: RefObject; + postMessage: (m: EmulatorJsMessage) => void; close: () => void; goBack: () => void; }) @@ -64,7 +68,6 @@ function Overlay (data: { }, [data.open]); const { isPointer } = useActiveControl(); - const handleEvent = (type: string, value?: any) => data.iframeRef.current?.contentWindow?.postMessage({ type, data: value }); return
    @@ -78,7 +81,7 @@ function Overlay (data: { { data.close(); - handleEvent('restart'); + data.postMessage({ type: 'restart' }); }} > @@ -132,6 +135,7 @@ function RouteComponent () }); const iframeRef = useRef(null); const [overlayOpen, setOverlayOpen] = useState(false); + const postMessage = (m: EmulatorJsMessage) => iframeRef.current?.contentWindow?.postMessage(m); const { source, id } = Route.useParams(); function HandleGoBack () @@ -147,9 +151,23 @@ function RouteComponent () useEventListener('message', e => { - if (e.data.type === 'exit') + const data = e.data as EmulatorJsMessage; + switch (data.type) { - HandleGoBack(); + case "exit": + rommApi.api.romm.emulatorjs.post_play({ source })({ id }).post({ save: data.save }); + HandleGoBack(); + break; + case "loaded": + toast.success("Save Loaded", { icon: }); + break; + case "save": + rommApi.api.romm.emulatorjs.save.put({ save: data.save }).then(r => + { + if (r.error) toast.error(getErrorMessage(r.error.value) ?? "Error While Saving"); + else toast.success("Save Backed Up"); + }); + break; } }); @@ -173,11 +191,11 @@ function RouteComponent () const setPaused = (paused: boolean) => { - if (paused) iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: true }); + if (paused) postMessage({ type: 'pause', paused: true }); else { // we want to prevent input from closing the overlay spilling - setTimeout(() => iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: false }), 100); + setTimeout(() => postMessage({ type: 'pause', paused: false }), 100); } }; useEffect(() => setPaused(overlayOpen), [overlayOpen]); @@ -191,7 +209,7 @@ function RouteComponent ()
    - +
    diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index ab8c857..f77fd9d 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -104,6 +104,8 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; }) stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: }); if (data.game.emulators) stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) }); + const integrations = new Set(data.game.emulators?.flatMap(e => e.integrations).flatMap(i => i.capabilities).filter(c => !!c)); + stats.push({ label: "Integrations", content: Array.from(integrations) }); } return ; diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index 63f0907..bba950b 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -5,6 +5,7 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/ import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts'; import { useJobStatus } from '../scripts/utils'; +import { useRef } from 'react'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, @@ -13,6 +14,10 @@ export const Route = createFileRoute('/launcher/$source/$id')({ }, }); +const stateLookup: Record = { + saves: "Syncing Saves" +}; + function RouteComponent () { const router = useRouter(); @@ -27,12 +32,18 @@ function RouteComponent () } } + const progressRef = useRef(null); const { source, id } = Route.useParams(); const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` }); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); - const { data } = useJobStatus('launch-game', { + const { data, state } = useJobStatus('launch-game', { + onProgress (process, data) + { + if (progressRef.current) + progressRef.current.value = process; + }, onEnded (data) { HandleGoBack(); @@ -41,14 +52,19 @@ function RouteComponent () { HandleGoBack(); }, - }); + }, [progressRef.current, HandleGoBack]); useBlocker({ shouldBlockFn: () => !!data }); return
    -

    Launching {data?.name} ...

    + {!!state && !!stateLookup[state] ? + <> +

    Launching {data?.name} ...

    + + : +

    Launching {data?.name} ...

    }
    ; diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 889ee00..ad65539 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -10,7 +10,7 @@ import Shortcuts, { FloatingShortcuts } from "@/mainview/components/Shortcuts"; import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; import { rommApi, systemApi } from "@/mainview/scripts/clientApi"; import { Button } from "@/mainview/components/options/Button"; -import { ChevronDown, CircleFadingArrowUp, Cpu, Download, Gamepad2, Info, Puzzle, Settings, Trash2, TriangleAlert, WandSparkles } from "lucide-react"; +import { ChevronDown, CircleFadingArrowUp, CloudUpload, Cpu, Download, Fullscreen, Gamepad2, Info, Monitor, Puzzle, Save, Settings, Settings2, Terminal, Trash2, TriangleAlert, WandSparkles } from "lucide-react"; import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog"; import { RPC_URL } from "@/shared/constants"; import Screenshots from "@/mainview/components/Screenshots"; @@ -283,6 +283,9 @@ function TitleArea (data: { {data.emulator && data.emulator.integrations.length > 0 &&
    } + {data.emulator?.integrations.some(s => s.capabilities?.includes('saves')) &&
    +
    +
    }
    @@ -319,6 +322,14 @@ function Description (data: { emulator?: FrontEndEmulatorDetailed; })
    ; } +const capabilityIconMap: Record = { + saves: , + fullscreen: , + resolution: , + config: , + batch: +}; + export function RouteComponent () { const { id } = Route.useParams(); @@ -366,7 +377,9 @@ export function RouteComponent ()
    {i.id}
    -
    {`${i.capabilities?.join(", ")}`}
    +
    + {i.capabilities?.map(c => <>
    {capabilityIconMap[c]}{c}
    )} +
    ; })}
    diff --git a/src/mainview/scripts/audio/audio.ts b/src/mainview/scripts/audio/audio.ts index bbf8712..743b4ea 100644 --- a/src/mainview/scripts/audio/audio.ts +++ b/src/mainview/scripts/audio/audio.ts @@ -28,6 +28,7 @@ declare module '@tanstack/react-router' { enterSound?: keyof typeof soundMap | null; enterHaptic?: keyof typeof hapticMap | null; goBackSound?: keyof typeof soundMap | null; + missNavSound?: boolean; } } diff --git a/src/mainview/scripts/gamepads.ts b/src/mainview/scripts/gamepads.ts index 15d48a6..251f000 100644 --- a/src/mainview/scripts/gamepads.ts +++ b/src/mainview/scripts/gamepads.ts @@ -3,6 +3,7 @@ import { GetFocusedElement } from "./spatialNavigation"; import { useEffect, useState } from "react"; import { getLocalSetting, mobileCheck } from "./utils"; import { oneShot } from "./audio/audio"; +import { Router } from "@/mainview"; let loopStarted = false; let isTouching = false; @@ -108,7 +109,13 @@ function throttleNav (key: string, dir: string, event: Event) const currentFocusKey = getCurrentFocusKey(); navigateByDirection(dir, { event }); if (currentFocusKey === getCurrentFocusKey()) - oneShot('invalidNavigation'); + { + const routes = Router.matchRoutes(Router.history.location.pathname); + if (!routes.some(r => r.staticData.missNavSound === false)) + { + oneShot('invalidNavigation'); + } + } throttleMap.set(key, currentDate.getTime()); throttleAcceleration.set(key, acceleration + 1); return true; diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index e84b7b7..428be6e 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -1,5 +1,5 @@ import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants"; -import { RefObject, useEffect, useRef, useState } from "react"; +import { DependencyList, RefObject, useEffect, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; import { jobsApi } from "./clientApi"; import { JobsAPIType } from "@/bun/api/rpc"; @@ -272,7 +272,8 @@ export function useJobStatus, "completed" | "ended", 'data'>) => void; onCompleted?: (data: ExtractField, "completed" | "ended", 'data'>) => void; onError?: (error: string) => void; - } + }, + deps?: DependencyList ) { type Response = JobResponse; @@ -325,7 +326,7 @@ export function useJobStatus Date: Fri, 10 Apr 2026 02:00:11 +0300 Subject: [PATCH 38/65] feat: Added way to update the local games from romm when IDs change based on IGDB or Retro Achievement ID Fixes #2 --- scripts/dev.ts | 10 +-- src/bun/api/games/games.ts | 11 ++- src/bun/api/games/services/statusService.ts | 51 +++++++++---- src/bun/api/hooks/games.ts | 5 ++ .../com.simeonradivoev.gameflow.romm/romm.ts | 11 ++- .../components/game/ActionButtons.tsx | 71 ++++++++++++------- src/mainview/components/game/Details.tsx | 16 ++++- src/mainview/routes/game/$source.$id.tsx | 3 + src/mainview/scripts/queries/romm.ts | 17 ++++- 9 files changed, 143 insertions(+), 52 deletions(-) diff --git a/scripts/dev.ts b/scripts/dev.ts index ef3ad70..b7c07f5 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -26,15 +26,7 @@ function spawnServer () killSignal: 'SIGUSR1', onExit (subprocess, exitCode, signalCode) { - if (exitCode === 1 && retries <= 3) - { - server = spawnServer(); - retries++; - } else - { - process.exit(); - } - + process.exit(); } }); const rl = createInterface({ input: Readable.fromWeb(s.stdout as any) }); diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 531dc93..6d4f6b2 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -8,7 +8,7 @@ import { GameListFilterSchema, SERVER_URL } from "@shared/constants"; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; import { convertLocalToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; -import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService"; +import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, validateGameSource } from "./services/statusService"; import { errorToResponse } from "elysia/adapter/bun/handler"; import { getEmulatorsForSystem, getRomFilePaths, launchCommand } from "./services/launchGameService"; import { getErrorMessage, SeededRandom } from "@/bun/utils"; @@ -412,6 +412,15 @@ export default new Elysia() params: z.object({ id: z.string(), source: z.string() }), response: z.any() }) + .get('/game/:source/:id/validate', async ({ params: { id, source } }) => + { + const valid = await validateGameSource(source, id); + return { valid: valid.valid, reason: valid.reason }; + }) + .post('/game/:source/:id/fix_source', async ({ params: { id, source } }) => + { + return fixSource(source, id); + }) .post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) => { const validCommands = await getValidLaunchCommandsForGame(source, id); diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 97f0d8a..0255d7b 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -44,26 +44,53 @@ export async function getLocalGame (source: string, id: string) return localGame; } -export async function validateGameSource (source: string, id: string): Promise<{ valid: boolean, reason?: string; }> +export async function fixSource (source: string, id: string) +{ + const valid = await validateGameSource(source, id); + if (!valid.valid) + { + if (!valid.localGame) throw new Error("No Local Game"); + if (!valid.localGame.source) throw new Error("No Valid Source"); + + const foundGame = await plugins.hooks.games.searchGame.promise({ + igdb_id: valid.localGame.igdb_id ?? undefined, + ra_id: valid.localGame.ra_id ?? undefined, + source: valid.localGame.source + }); + + if (foundGame) + { + await db.update(appSchema.games).set({ source: foundGame.id.source, source_id: foundGame.id.id }).where(eq(appSchema.games.id, valid.localGame.id)); + return true; + } else + { + throw new Error("Could not find Source Game"); + } + } else + { + throw new Error("Game Source Already Valid"); + } +} + +export async function validateGameSource (source: string, id: string): Promise<{ + valid: boolean, + localGame?: { id: number; igdb_id: number | null; ra_id: number | null; source: string | null; }, + reason?: string; +}> { const localGame = await getLocalGame(source, id); - if (!localGame) throw new Error("Could not find local game"); + if (!localGame) return { valid: true }; if (localGame.source && localGame.source_id) { const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id }); - if (!sourceGame) return { valid: false, reason: "Source Missing" }; - if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined)) + if (!sourceGame) return { valid: false, reason: "Source Missing", localGame }; + if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined)) { - return { valid: false, reason: "IGDB Miss Match" }; - } - - if (sourceGame.ra_id !== (localGame.ra_id ?? undefined)) - { - return { valid: false, reason: "RA Miss Match" }; + return { valid: false, reason: "Metadata Missmatch", localGame }; } } - return { valid: true }; + return { valid: true, localGame }; } export async function updateLocalLastPlayed (id: number) @@ -174,7 +201,7 @@ export default function buildStatusResponse () }, async open (ws) { - sendLatests(); + sendLatests().catch(e => ws.send({ status: 'error', error: JSON.stringify(e) })); const installJobId = InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }); async function sendLatests () diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index 38016aa..b53a00f 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -43,6 +43,11 @@ export class GameHooks localGame?: FrontEndGameTypeDetailed; id: string; }], FrontEndGameTypeDetailed | undefined>(['ctx']); + searchGame = new AsyncSeriesBailHook<[ctx: { + source: string; + igdb_id?: number; + ra_id?: number; + }], FrontEndGameTypeDetailed | undefined>(['ctx']); /** Get download file URLs * @param ctx.checksum Check if file already exists using checksums */ diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 46c64db..8ec3a62 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -2,7 +2,7 @@ import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; -import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; +import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; import { config, events } from "@/bun/api/app"; import path from 'node:path'; import fs from 'node:fs/promises'; @@ -557,5 +557,14 @@ export default class RommIntegration implements PluginType const platforms = await this.getAllRommPlatforms(); return platforms.find(p => p.id === Number(id)); }); + + ctx.hooks.games.searchGame.tapPromise(desc.name, async ({ source, igdb_id, ra_id }) => + { + if (source !== 'romm') return; + const roms = await getRomByMetadataProviderApiRomsByMetadataProviderGet({ query: { igdb_id, ra_id } }); + if (roms.error) throw roms.error; + if (!roms.data) return; + return this.convertRomToFrontendDetailed(roms.data); + }); } } \ No newline at end of file diff --git a/src/mainview/components/game/ActionButtons.tsx b/src/mainview/components/game/ActionButtons.tsx index d931fa6..8730779 100644 --- a/src/mainview/components/game/ActionButtons.tsx +++ b/src/mainview/components/game/ActionButtons.tsx @@ -1,10 +1,10 @@ -import { deleteGameMutation, gameInvalidationQuery } from "@/mainview/scripts/queries/romm"; +import { deleteGameMutation, fixSourceMutation, gameInvalidationQuery, validateSourceQuery } from "@/mainview/scripts/queries/romm"; import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; import { getErrorMessage } from "react-error-boundary"; import toast from "react-hot-toast"; -import { Settings, Trash, Trophy } from "lucide-react"; +import { Hammer, Settings, Trash, Trophy } from "lucide-react"; import MainActions from "./MainActions"; import ActionButton from "./ActionButton"; import { useLocalStorage } from "usehooks-ts"; @@ -33,6 +33,18 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, { const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots'); + const fixMutation = useMutation({ + ...fixSourceMutation, onSuccess (data, variables, onMutateResult, context) + { + if (onMutateResult) toast.success("Updated Source"); + context.client.invalidateQueries(gameInvalidationQuery(variables.id, variables.source)).then(() => router.history.back()); + }, + onError (error) + { + toast.error(getErrorMessage(error) ?? "Error While Trying To Fix"); + } + }); + const { data: validation } = useQuery(validateSourceQuery(data.source, data.id)); const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: 'actions', forceFocus: true, trackChildren: true, preferredChildFocusKey: 'mainAction' }); const router = useRouter(); const deleteMutation = useMutation({ @@ -47,32 +59,41 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, } }); - useBlocker({ shouldBlockFn: () => deleteMutation.isPending }); + useBlocker({ + shouldBlockFn: () => + { + return deleteMutation.isPending || fixMutation.isPending; + } + }); const contextOptions: DialogEntry[] = []; if (data.game?.local) { - if (deleteMutation.isPending) - { - contextOptions.push({ - id: 'delete', - icon: , - content: "Deleting", - type: 'error' - }); - } else - { - contextOptions.push({ - id: 'delete', - action: () => - { - deleteMutation.mutate(); - }, - icon: , - content: "Delete", - type: 'error' - }); - } + contextOptions.push({ + id: 'delete', + action: () => + { + deleteMutation.mutate(); + }, + icon: deleteMutation.isPending ? : , + content: deleteMutation.isPending ? "Deleting" : "Delete", + type: 'error' + }); + } + + if (!validation?.valid) + { + contextOptions.push({ + id: "fix_source", + action (ctx) + { + if (data.game) + fixMutation.mutate({ source: data.game.id.source, id: data.game.id.id }); + }, + icon: fixMutation.isPending ? : , + content: "Try Fix Source", + type: "warning" + }); } const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: , canClose: !deleteMutation.isPending }); diff --git a/src/mainview/components/game/Details.tsx b/src/mainview/components/game/Details.tsx index c3b1fcc..570e670 100644 --- a/src/mainview/components/game/Details.tsx +++ b/src/mainview/components/game/Details.tsx @@ -2,11 +2,13 @@ import { scrollIntoViewHandler } from "@/mainview/scripts/utils"; import { RPC_URL } from "@/shared/constants"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; -import { Clock, CloudBackup, CloudDownload, CloudUpload, HardDrive, Store, TriangleAlert } from "lucide-react"; +import { Clock, CloudBackup, CloudDownload, CloudUpload, Gamepad2, HardDrive, Store, TriangleAlert } from "lucide-react"; import prettyBytes from "pretty-bytes"; import { JSX } from "react"; import ActionButtons from "./ActionButtons"; import prettyMilliseconds from 'pretty-ms'; +import { useQuery } from "@tanstack/react-query"; +import { validateSourceQuery } from "@/mainview/scripts/queries/romm"; export function DetailElement (data: { icon: JSX.Element; tooltip?: string | null, children?: any | any[]; }) { @@ -18,6 +20,12 @@ export function DetailElement (data: { icon: JSX.Element; tooltip?: string | nul ); } +const sourceIconMap: Record = { + store: , + local: , + romm: +}; + export default function Details (data: { game?: FrontEndGameTypeDetailed, source: string, @@ -32,6 +40,8 @@ export default function Details (data: { forceFocus: true }); + const { data: validation } = useQuery(validateSourceQuery(data.source, data.id)); + const platformCoverImg = data.game?.path_platform_cover ? new URL(`${RPC_URL(__HOST__)}${data.game?.path_platform_cover}`) : undefined; if (platformCoverImg) platformCoverImg.searchParams.set("width", "64"); @@ -70,8 +80,8 @@ export default function Details (data: {
    } :
    } >{data.game?.platform_display_name ??
    }
    {data.game?.emulators?.some(e => e.integrations.some(i => i.capabilities?.includes('saves'))) && } />} - + : } > {data.game?.source ?? data.game?.id.source} {data.game?.local && local} diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index f77fd9d..42d23f1 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -23,6 +23,7 @@ import { GamesSection } from "@/mainview/components/store/GamesSection"; import Details from "@/mainview/components/game/Details"; import { AutoFocus } from "@/mainview/components/AutoFocus"; import SelectMenu from "@/mainview/components/SelectMenu"; +import { stat } from "node:fs"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => @@ -104,6 +105,8 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; }) stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: }); if (data.game.emulators) stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) }); + if (data.game.source) + stats.push({ label: "Source", content: `${data.game.source} - ${data.game.source_id}` }); const integrations = new Set(data.game.emulators?.flatMap(e => e.integrations).flatMap(i => i.capabilities).filter(c => !!c)); stats.push({ label: "Integrations", content: Array.from(integrations) }); } diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts index 4ecf6ca..3f73bcf 100644 --- a/src/mainview/scripts/queries/romm.ts +++ b/src/mainview/scripts/queries/romm.ts @@ -1,6 +1,6 @@ import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants"; import { rommApi, settingsApi } from "../clientApi"; -import { mutationOptions, QueryFilters, queryOptions } from "@tanstack/react-query"; +import { mutationOptions, QueryFilters, queryOptions, useMutation } from "@tanstack/react-query"; import z from "zod"; import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen"; @@ -155,4 +155,19 @@ export const gameInvalidationQuery = (source: string, id: string): QueryFilters if (query.queryKey.includes(source) && query.queryKey.includes(id)) return true; return false; }, +}); +export const validateSourceQuery = (source: string, id: string) => queryOptions({ + queryKey: ["game", source, id, "validate"], queryFn: async () => + { + const { data, error } = await rommApi.api.romm.game({ source })({ id }).validate.get(); + return data; + } +}); +export const fixSourceMutation = mutationOptions({ + mutationKey: ['game', "fix_source"], mutationFn: async ({ source, id }: { source: string, id: string; }) => + { + const { data, error } = await rommApi.api.romm.game({ source })({ id }).fix_source.post(); + if (error) throw error; + return data; + } }); \ No newline at end of file From 444d8c4c278c6032b37f44a884cb6d7bf0b54c85 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 12 Apr 2026 22:19:24 +0300 Subject: [PATCH 39/65] feat: Implemented filtering and searching --- src/bun/api/games/games.ts | 140 +++++++++++++---- src/bun/api/games/services/statusService.ts | 17 +- src/bun/api/games/services/utils.ts | 68 ++++---- src/bun/api/hooks/games.ts | 4 + src/bun/api/jobs/launch-game-job.ts | 2 +- .../com.simeonradivoev.gameflow.romm/romm.ts | 63 ++++++-- src/bun/api/schema/app.ts | 10 +- src/bun/api/store/store.ts | 20 ++- src/mainview/components/CardElement.tsx | 17 +- src/mainview/components/CardList.tsx | 26 ++-- src/mainview/components/CollectionList.tsx | 1 - src/mainview/components/CollectionsDetail.tsx | 67 ++++---- src/mainview/components/Constants.tsx | 7 + src/mainview/components/ContextDialog.tsx | 17 +- src/mainview/components/GameList.tsx | 18 ++- src/mainview/components/Header.tsx | 8 +- src/mainview/components/HeaderSearchField.tsx | 102 ++++++++++++ src/mainview/components/LoadMoreButton.tsx | 6 +- src/mainview/components/PlatformsList.tsx | 6 +- src/mainview/components/Screenshots.tsx | 6 +- src/mainview/components/SelectMenu.tsx | 3 +- src/mainview/components/SideFilters.tsx | 147 ++++++++++++++++++ src/mainview/components/StatList.tsx | 2 +- src/mainview/components/game/ActionButton.tsx | 7 +- src/mainview/components/game/Details.tsx | 9 +- src/mainview/components/options/Button.tsx | 8 +- .../components/options/PathSettingsOption.tsx | 2 +- .../components/options/SettingsAppForm.tsx | 2 +- .../components/options/SettingsOption.tsx | 2 +- .../components/store/EmulatorsSection.tsx | 6 +- .../components/store/StoreEmulatorCard.tsx | 4 +- src/mainview/routes/__root.tsx | 13 +- .../routes/collection.$source.$id.tsx | 16 +- src/mainview/routes/game/$source.$id.tsx | 13 +- src/mainview/routes/games.tsx | 25 ++- src/mainview/routes/index.tsx | 18 ++- src/mainview/routes/platform.$source.$id.tsx | 12 +- src/mainview/routes/settings/emulators.tsx | 2 +- src/mainview/routes/settings/interface.tsx | 6 + src/mainview/routes/settings/plugins.tsx | 2 +- .../routes/store/details.emulator.$id.tsx | 34 ---- src/mainview/routes/store/tab/emulators.tsx | 6 +- src/mainview/routes/store/tab/games.tsx | 87 ++++++++--- src/mainview/routes/store/tab/route.tsx | 21 ++- src/mainview/scripts/queries/store.ts | 13 +- src/mainview/scripts/shortcuts.ts | 4 +- src/mainview/types.d.ts | 8 +- src/shared/constants.ts | 14 +- src/shared/types..d.ts | 40 ++++- 49 files changed, 841 insertions(+), 290 deletions(-) create mode 100644 src/mainview/components/Constants.tsx create mode 100644 src/mainview/components/HeaderSearchField.tsx create mode 100644 src/mainview/components/SideFilters.tsx diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 6d4f6b2..8e489cf 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -1,6 +1,6 @@ import Elysia, { status } from "elysia"; import { config, db, emulatorsDb, plugins, taskQueue } from "../app"; -import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm"; +import { and, eq, getTableColumns, ilike, inArray, like, sql } from "drizzle-orm"; import z from "zod"; import * as schema from "@schema/app"; import fs from "node:fs/promises"; @@ -20,6 +20,7 @@ import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmula import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService"; import { host } from "@/bun/utils/host"; import { LaunchGameJob } from "../jobs/launch-game-job"; +import { cores } from "../emulatorjs/emulatorjs"; // A custom jimp that supports webp const Jimp = createJimp({ @@ -134,12 +135,24 @@ export default new Elysia() .get('/games', async ({ query, set }) => { const games: FrontEndGameType[] = []; + const filterSets: FrontEndFilterSets = { + age_ratings: new Set(), + player_counts: new Set(), + languages: new Set(), + companies: new Set(), + genres: new Set() + }; if (query.source === 'store') { const shuffledGames = await getShuffledStoreGames(); set.headers['x-max-items'] = shuffledGames.length; - const storeGames = await Promise.all(shuffledGames + const storeGames = await Promise.all(shuffledGames.filter(g => + { + if (query.search) + return path.basename(g.path).toLocaleLowerCase().includes(query.search.toLocaleLowerCase()); + return true; + }) .slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length)) .map(async (e) => { @@ -185,6 +198,11 @@ export default new Elysia() } } + if (query.search) + { + where.push(like(schema.games.name, query.search)); + } + if (query.source) { where.push(eq(schema.games.source, query.source)); @@ -218,7 +236,7 @@ export default new Elysia() { // Collections are just a remote thing for now. const remoteGames: FrontEndGameTypeWithIds[] = []; - await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e)); games.push(...remoteGames.map(g => { if (localGameExistsPredicate(g)) @@ -233,37 +251,74 @@ export default new Elysia() } else { - games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).map(g => + games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).filter(g => + { + if (query.genres && query.genres.length > 0) + { + if (!g.metadata) return false; + if (!g.metadata.genres) return false; + if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false; + } + + return true; + }).map(g => { return convertLocalToFrontend(g); })); - const remoteGames: FrontEndGameTypeWithIds[] = []; - const remoteGameSet = new Set(); - await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); - games.push(...remoteGames.filter(g => + if (query.localOnly !== true) { - if (localGameExistsPredicate(g)) + const remoteGames: FrontEndGameTypeWithIds[] = []; + const remoteGameSet = new Set(); + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e)); + games.push(...remoteGames.filter(g => { - return false; - } + if (localGameExistsPredicate(g)) + { + return false; + } - if (g.igdb_id) + if (g.igdb_id) + { + const igdbId = `igdb@${g.igdb_id}`; + if (remoteGameSet.has(igdbId)) return false; + remoteGameSet.add(igdbId); + } + + if (g.ra_id) + { + const raId = `ra@${g.ra_id}`; + if (remoteGameSet.has(raId)) return false; + remoteGameSet.add(raId); + } + + return true; + })); + } else + { + await plugins.hooks.games.fetchFilters.promise({ filters: filterSets }).catch(e => console.error(e)); + } + + localGames.map(g => + { + const metadata: any = g.metadata; + if (metadata.genres && Array.isArray(metadata.genres)) { - const igdbId = `igdb@${g.igdb_id}`; - if (remoteGameSet.has(igdbId)) return false; - remoteGameSet.add(igdbId); + metadata.genres.forEach((g: string) => filterSets.genres.add(g)); } - - if (g.ra_id) + if (metadata.age_ratings && Array.isArray(metadata.age_ratings)) { - const raId = `ra@${g.ra_id}`; - if (remoteGameSet.has(raId)) return false; - remoteGameSet.add(raId); + metadata.age_ratings.forEach((g: string) => filterSets.age_ratings.add(g)); } - - return true; - })); + if (metadata.companies && Array.isArray(metadata.companies)) + { + metadata.companies.forEach((g: string) => filterSets.companies.add(g)); + } + if (metadata.player_count) + { + filterSets.player_counts.add(metadata.player_count); + } + }); } } @@ -280,11 +335,22 @@ export default new Elysia() case 'name': games.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); break; + case "release": + games.sort((a, b) => (b.metadata.first_release_date?.getTime() ?? 0) - (a.metadata.first_release_date?.getTime() ?? 0)); + break; } } - return { games }; + const filterLists: FrontEndFilterLists = { + age_ratings: Array.from(filterSets.age_ratings), + player_counts: Array.from(filterSets.player_counts), + languages: Array.from(filterSets.languages), + companies: Array.from(filterSets.companies), + genres: Array.from(filterSets.genres) + }; + + return { games, filters: filterLists }; }, { query: GameListFilterSchema, }) @@ -341,8 +407,22 @@ export default new Elysia() return { name: 'EMULATORJS', validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }], - logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, - systems: [], + logo: 'https://emulatorjs.org/logo/EmulatorJS.png', + systems: await Promise.all(Object.keys(cores).map(async c => + { + const mapping = await emulatorsDb.query.systemMappings.findFirst({ + where (fields, operators) + { + return operators.and(operators.eq(fields.source, "romm"), operators.eq(fields.system, c)); + }, columns: { sourceSlug: true } + }); + const system: EmulatorSystem = { + id: c, + name: c, + iconUrl: `/api/romm/image/romm/assets/platforms/${mapping?.sourceSlug}.svg` + }; + return system; + })), gameCount: 0, integrations: [] } satisfies FrontEndGameTypeDetailedEmulator; @@ -536,8 +616,8 @@ export default new Elysia() const sourceData = await getSourceGameDetailed(source, id); if (!sourceData) return status("Not Found"); - const sourceCompaniesSet = new Set(sourceData.companies); - const sourceGenresSet = new Set(sourceData.genres); + const sourceCompaniesSet = new Set(sourceData.metadata.companies); + const sourceGenresSet = new Set(sourceData.metadata.genres); const esSystem = sourceData.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug)), columns: { system: true } }) : undefined; @@ -550,7 +630,7 @@ export default new Elysia() const localGamesSourceSet = new Set(localGames.filter(g => g.source).map(g => `${g.source}@${g.source_id}`)); - games.push(...localGames.map(g => ({ ...convertLocalToFrontend(g), metadata: g.metadata }))); + games.push(...localGames.map(g => convertLocalToFrontend(g))); const shuffledGames = await getShuffledStoreGames(); const storeGames = await Promise.all(shuffledGames @@ -559,7 +639,7 @@ export default new Elysia() const system = path.dirname(g.path); const id = path.basename(g.path, path.extname(g.path)); - if (localGamesSourceSet.has(`${system}@${id}`)) + if (localGamesSourceSet.has(`store@${system}@${id}`)) return false; if (esSystem) diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 0255d7b..53ec3de 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -60,7 +60,19 @@ export async function fixSource (source: string, id: string) if (foundGame) { - await db.update(appSchema.games).set({ source: foundGame.id.source, source_id: foundGame.id.id }).where(eq(appSchema.games.id, valid.localGame.id)); + await db.update(appSchema.games).set({ + source: foundGame.id.source, + source_id: foundGame.id.id, + metadata: { + age_ratings: foundGame.metadata.age_ratings, + genres: foundGame.metadata.genres, + player_count: foundGame.metadata.player_count ?? undefined, + companies: foundGame.metadata.companies, + game_modes: foundGame.metadata.game_modes, + average_rating: foundGame.metadata.average_rating ?? undefined, + first_release_date: foundGame.metadata.first_release_date?.getTime() ?? undefined, + } + }).where(eq(appSchema.games.id, valid.localGame.id)); return true; } else { @@ -82,6 +94,9 @@ export async function validateGameSource (source: string, id: string): Promise<{ if (!localGame) return { valid: true }; if (localGame.source && localGame.source_id) { + // Store should be immutable + if (localGame.source === 'store') return { valid: true, localGame }; + const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id }); if (!sourceGame) return { valid: false, reason: "Source Missing", localGame }; if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined)) diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index cb53377..955d1e7 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -32,7 +32,7 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & { }) { const game: FrontEndGameType = { - platform_display_name: g.platform?.name ?? "Local", + platform_display_name: g.platform?.name ?? null, id: { id: String(g.id), source: 'local' }, updated_at: g.created_at, path_cover: `/api/romm/game/local/${g.id}/cover`, @@ -45,17 +45,24 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & { slug: g.slug, name: g.name, platform_id: g.platform_id, - platform_slug: g.platform?.slug ?? null + platform_slug: g.platform?.slug ?? null, + metadata: { + first_release_date: g.metadata?.first_release_date !== undefined ? new Date(g.metadata?.first_release_date) : null + } }; return game; } -export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & { - platform?: typeof schema.platforms.$inferSelect | null; +export async function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & { + platform?: { name: string | null, slug: string | null; } | null; screenshotIds?: number[]; }) { + + const exists = await checkInstalled(g.path_fs); + const fileSize = await calculateSize(g.path_fs); + const game: FrontEndGameTypeDetailed = { platform_display_name: g.platform?.name ?? "Local", id: { id: String(g.id), source: 'local' }, @@ -72,9 +79,18 @@ export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSel platform_id: g.platform_id, platform_slug: g.platform?.slug ?? null, summary: g.summary, - fs_size_bytes: 0, - missing: false, - local: true + fs_size_bytes: fileSize, + missing: !exists, + local: true, + metadata: { + genres: g.metadata.genres ?? [], + companies: g.metadata.companies ?? [], + game_modes: g.metadata.game_modes ?? [], + age_ratings: g.metadata.age_ratings ?? [], + player_count: g.metadata.player_count ?? null, + average_rating: g.metadata.average_rating ?? null, + first_release_date: g.metadata.first_release_date ? new Date(g.metadata.first_release_date) : null + } }; return game; @@ -107,7 +123,10 @@ export async function convertStoreToFrontend (system: string, id: string, storeG name: storeGame.title, platform_id: null, platform_slug: rommSystem?.sourceSlug ?? system, - paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [] + paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [], + metadata: { + first_release_date: null + } }; return game; @@ -131,6 +150,15 @@ export async function convertStoreToFrontendDetailed (system: string, id: string fs_size_bytes: size, missing: false, local: false, + metadata: { + genres: storeGame.tags, + companies: [], + game_modes: [], + age_ratings: [], + player_count: "", + average_rating: null, + first_release_date: null + } }; return detailed; @@ -148,29 +176,7 @@ export async function getLocalGameDetailed (match: any) if (localGame) { - const exists = await checkInstalled(localGame.path_fs); - const fileSize = await calculateSize(localGame.path_fs); - const game: FrontEndGameTypeDetailed = { - path_cover: `/api/romm/game/local/${localGame.id}/cover`, - updated_at: localGame.created_at, - id: { id: String(localGame.id), source: 'local' }, - path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`, - fs_size_bytes: fileSize ?? null, - paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`), - local: true, - missing: !exists, - platform_display_name: localGame.platform?.name, - summary: localGame.summary, - source: localGame.source, - source_id: localGame.source_id, - path_fs: localGame.path_fs, - last_played: localGame.last_played, - slug: localGame.slug, - name: localGame.name, - platform_id: localGame.platform_id, - platform_slug: localGame.platform.slug - }; - return game; + return convertLocalToFrontendDetailed({ ...localGame, screenshotIds: localGame.screenshots.map(s => s.id) }); } return undefined; diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index b53a00f..f1f4a6a 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -37,6 +37,10 @@ export class GameHooks fetchGames = new AsyncSeriesHook<[ctx: { query: GameListFilterType; games: FrontEndGameTypeWithIds[]; + filters: FrontEndFilterSets; + }]>(['ctx']); + fetchFilters = new AsyncSeriesHook<[ctx: { + filters: FrontEndFilterSets; }]>(['ctx']); fetchGame = new AsyncSeriesBailHook<[ctx: { source: string; diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index b60cb76..bcea594 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -168,7 +168,7 @@ export class LaunchGameJob implements IJob = { + added: "created_at", + activity: "created_at", + name: "name", + release: "metadatum.first_release_date" + }; async updateClient () { @@ -49,8 +55,11 @@ export default class RommIntegration implements PluginType const game: FrontEndGameType = { id: { id: String(rom.id), source: 'romm' }, path_cover: `/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`, - last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null, + last_played: rom.rom_user.last_played !== null ? new Date(rom.rom_user.last_played) : null, updated_at: new Date(rom.created_at), + metadata: { + first_release_date: rom.metadatum.first_release_date !== null ? new Date(rom.metadatum.first_release_date) : null, + }, slug: rom.slug, platform_id: rom.platform_id, platform_display_name: rom.platform_display_name, @@ -74,11 +83,17 @@ export default class RommIntegration implements PluginType fs_size_bytes: rom.fs_size_bytes, local: false, missing: rom.missing_from_fs, - genres: rom.metadatum.genres, - companies: rom.metadatum.companies, - release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined, imdb_id: rom.igdb_id ?? undefined, - ra_id: rom.ra_id ?? undefined + ra_id: rom.ra_id ?? undefined, + metadata: { + age_ratings: rom.metadatum.age_ratings, + genres: rom.metadatum.genres, + companies: rom.metadatum.companies, + game_modes: rom.metadatum.game_modes, + player_count: rom.metadatum.player_count, + average_rating: rom.metadatum.average_rating, + first_release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : null + } }; const userData = await getCurrentUserApiUsersMeGet(); @@ -119,26 +134,32 @@ export default class RommIntegration implements PluginType load (ctx: PluginContextType) { - ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) => + ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games, filters }) => { if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm')) { - const orderByMap: Record = { - added: "created_at", - activity: "created_at", - name: "name" - }; - const rommGames = await getRomsApiRomsGet({ query: { platform_ids: query.platform_id ? [query.platform_id] : undefined, collection_id: query.collection_id, limit: query.limit, offset: query.offset, - order_by: orderByMap[query.orderBy ?? ''] + order_by: this.orderByMap[query.orderBy ?? ''], + with_filter_values: true, + genres: query.genres, + genres_logic: "all", + age_ratings: query.age_ratings, + search_term: query.search, }, throwOnError: true }); + + rommGames.data.filter_values.age_ratings.forEach(r => filters.age_ratings.add(r)); + rommGames.data.filter_values.companies.forEach(r => filters.companies.add(r)); + rommGames.data.filter_values.languages.forEach(r => filters.languages.add(r)); + rommGames.data.filter_values.player_counts.forEach(r => filters.player_counts.add(r)); + rommGames.data.filter_values.genres.forEach(r => filters.genres.add(r)); + games.push(...rommGames.data.items.map(g => { const game: FrontEndGameTypeWithIds = { @@ -151,6 +172,16 @@ export default class RommIntegration implements PluginType } }); + ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters }) => + { + const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true }); + rommFilters.data.age_ratings.forEach(r => filters.age_ratings.add(r)); + rommFilters.data.companies.forEach(r => filters.companies.add(r)); + rommFilters.data.languages.forEach(r => filters.languages.add(r)); + rommFilters.data.player_counts.forEach(r => filters.player_counts.add(r)); + rommFilters.data.genres.forEach(r => filters.genres.add(r)); + }); + ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) => { if (service !== 'romm') return; @@ -277,10 +308,10 @@ export default class RommIntegration implements PluginType const rommPlatform = rommPlatforms.find(p => p.slug === game.platform_slug); if (rommPlatform) { - const rommGames = await getRomsApiRomsGet({ query: { genres: game.genres, genres_logic: 'any' } }); + const rommGames = await getRomsApiRomsGet({ query: { genres: game.metadata.genres, genres_logic: 'any' } }); if (rommGames.data) { - games.push(...rommGames.data.items.map(g => ({ ...this.convertRomToFrontend(g), metadata: g.metadatum }))); + games.push(...rommGames.data.items.map(g => ({ ...this.convertRomToFrontend(g) }))); } } } diff --git a/src/bun/api/schema/app.ts b/src/bun/api/schema/app.ts index fafa32a..35c9c5a 100644 --- a/src/bun/api/schema/app.ts +++ b/src/bun/api/schema/app.ts @@ -11,7 +11,15 @@ export const games = sqliteTable('games', { path_fs: text("path_fs"), last_played: integer("last_played", { mode: 'timestamp' }), created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(), - metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`), + metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type<{ + genres?: string[], + companies?: string[], + game_modes?: string[], + age_ratings?: string[]; + player_count?: string; + first_release_date?: number; + average_rating?: number; + }>().notNull(), slug: text("slug").unique(), platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(), cover: blob("cover", { mode: 'buffer' }), diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 2a1c42e..5145232 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -25,7 +25,22 @@ export const store = new Elysia({ prefix: '/api/store' }) }); const emulatesParsed = await getAllStoreEmulatorPackages(); let frontEndEmulators = await Promise.all(emulatesParsed - .filter(e => e.os.includes(process.platform as any)) + .filter(e => + { + if (!e.os.includes(process.platform as any)) return false; + if (query.search) + { + const lowerCaseSearch = query.search.toLocaleLowerCase(); + + if (e.name.toLocaleLowerCase().includes(lowerCaseSearch) || e.systems.some(s => s.toLocaleLowerCase().includes(lowerCaseSearch)) || e.keywords?.some(k => k.toLocaleLowerCase().includes(lowerCaseSearch))) + { + return true; + } + + return false; + } + return true; + }) .map(async (emulator) => { const systems = await buildStoreFrontendEmulatorSystems(emulator); @@ -77,7 +92,8 @@ export const store = new Elysia({ prefix: '/api/store' }) limit: z.coerce.number().optional(), missing: z.stringbool().optional().describe("Show Only Non Installed emulators"), orderBy: z.enum(['name', 'recently_updated', 'importance']).optional(), - related: z.string().optional() + related: z.string().optional(), + search: z.string().optional() }) }) .get('/games/featured', async () => diff --git a/src/mainview/components/CardElement.tsx b/src/mainview/components/CardElement.tsx index 885d073..fdbee68 100644 --- a/src/mainview/components/CardElement.tsx +++ b/src/mainview/components/CardElement.tsx @@ -18,9 +18,7 @@ export function GameCardSkeleton () ); } -export type GameCardFocusHandler = (id: string, node: HTMLElement, details: FocusDetails) => void; - -export interface GameCardParams +export interface GameCardParams extends FocusParams { title: string; subtitle: string | JSX.Element; @@ -31,7 +29,6 @@ export interface GameCardParams id: string; badges?: JSX.Element[]; className?: string; - onFocus?: GameCardFocusHandler; onBlur?: (id: string) => void; clickFocuses?: boolean; previewClassName?: string; @@ -39,14 +36,14 @@ export interface GameCardParams export default function CardElement (data: GameCardParams & InteractParams) { - const handleAction = () => + const handleAction = (event?: Event) => { - data.onAction?.(); + data.onAction?.({ event, focusKey }); oneShot('click'); }; - const { ref, focused, focusSelf } = useFocusable({ + const { ref, focused, focusSelf, focusKey } = useFocusable({ focusKey: data.focusKey, - onFocus: (l, p, details) => data.onFocus?.(data.id, ref.current as any, details), + onFocus: (l, p, details) => data.onFocus?.(focusKey, ref.current as any, details), onEnterPress: handleAction, onBlur: () => data.onBlur?.(data.id), }); @@ -63,10 +60,10 @@ export default function CardElement (data: GameCardParams & InteractParams) scrollSnapAlign: isPointer ? "center" : "none" }} onFocus={focusSelf} - onClick={() => + onClick={(e) => { focusSelf(); - handleAction(); + handleAction(e.nativeEvent); }} className={twMerge( "relative game-card light:bg-base-100 dark:bg-base-300 flex flex-col z-5 overflow-hidden transition-all duration-200 not-mobile:drop-shadow-lg cursor-pointer focusable focusable-primary focusable-hover select-none focused focused:not-control-mouse:animate-wiggle focused:not-control-mouse:bg-base-content focused:not-control-mouse:text-base-300 focused:not-control-mouse:drop-shadow-lg focused:not-control-mouse:drop-shadow-black/30 focused:not-control-mouse:scale-102 focused:not-control-mouse:z-10 group control-mouse:hover:bg-base-200 h-full [--tw-border-style:inset] border-2 border-base-content/5 backdrop-opacity-0 active:bg-base-content! active:text-base-100 active:transition-none", diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index 2744585..671018f 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -4,12 +4,11 @@ import useFocusable, } from "@noriginmedia/norigin-spatial-navigation"; import { GameMeta } from "../../shared/constants"; -import CardElement, { GameCardFocusHandler, GameCardParams } from "./CardElement"; +import CardElement, { GameCardParams } from "./CardElement"; import { JSX } from "react"; import { twMerge } from "tailwind-merge"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { oneShot } from "../scripts/audio/audio"; -import { GamepadButtonEvent } from "../scripts/gamepads"; export interface GameMetaExtra extends GameMeta { @@ -26,13 +25,14 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara preview = data.game.previewUrl; } - const handleAction = (e?: Event) => + const handleAction = (ctx: InteractParamsArgs) => { data.game.onSelect?.(); - data.onAction?.(); + data.onAction?.({ event, focusKey: data.game.focusKey }); oneShot('click'); }; - useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]); + + useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]); return ( + onFocus={(focusKey, node, details) => { - data.game.onFocus?.(details); - data.onFocus?.(id, node, details); + data.game.onFocus?.(focusKey, node, details); + data.onFocus?.(focusKey, node, details); }} onAction={handleAction} preview={preview} @@ -61,16 +61,18 @@ export function CardList (data: { games: GameMetaExtra[]; grid?: boolean; onSelectGame?: (id: string) => void; - onGameFocus?: GameCardFocusHandler; + focus?: string; className?: string; finalElement?: JSX.Element; saveChildFocus?: 'session' | 'local'; -}) +} & FocusParams) { const { ref, focusKey } = useFocusable({ focusKey: data.id, forceFocus: true, - autoRestoreFocus: true + autoRestoreFocus: true, + focusable: data.games.length > 0, + preferredChildFocusKey: data.focus }); return ( @@ -92,7 +94,7 @@ export function CardList (data: { > {data.games.map((g, i) => data.onSelectGame?.(g.id)} i={i} />)} + key={g.id} onFocus={data.onFocus} game={g} onAction={() => data.onSelectGame?.(g.id)} i={i} />)} {data.finalElement} diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index 15b8d51..121be98 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -1,7 +1,6 @@ import { RPC_URL } from "@/shared/constants"; import { useSuspenseQuery } from "@tanstack/react-query"; import { CardList, GameMetaExtra } from "./CardList"; -import { GameCardFocusHandler } from "./CardElement"; import { getCollectionsQuery } from "@queries/romm"; import { useRouter } from "@tanstack/react-router"; diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index 717e986..0b9e0e1 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -1,44 +1,50 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import { StickyHeaderUI } from './Header'; +import { HeaderButton, StickyHeaderUI } from './Header'; import { GameList } from './GameList'; -import { Search, Settings2 } from 'lucide-react'; -import { JSX, Suspense } from 'react'; +import { ArrowDownAz, CalendarArrowDown, ClockArrowDown, Drama, Filter, FunnelX, HardDrive, Rocket, Search, Settings2, SortDesc, Store, Tags, User, UserLock } from 'lucide-react'; +import { JSX, Suspense, useRef, useState } from 'react'; import { FloatingShortcuts } from './Shortcuts'; import { AutoFocus } from './AutoFocus'; import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts'; -import { GameListFilterType } from '@/shared/constants'; -import { GameCardFocusHandler } from './CardElement'; +import { GameListFilterSchema, GameListFilterType } from '@/shared/constants'; import { HandleGoBack } from '../scripts/utils'; import LoadingCardList from './LoadingCardList'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { gameQuery } from '../scripts/queries/romm'; -import { useRouter } from '@tanstack/react-router'; +import { useNavigate, useRouter } from '@tanstack/react-router'; import SelectMenu from './SelectMenu'; +import { RoundButton } from './RoundButton'; +import { ContextList, DialogEntry, useContextDialog } from './ContextDialog'; +import classNames from 'classnames'; +import { sourceIconMap } from './Constants'; +import { stat } from 'fs-extra'; +import { FilterUI } from './Filters'; +import SideFilters from './SideFilters'; export interface CollectionsDetailParams { id?: string; setBackground?: (url: string) => void; filters?: GameListFilterType; - builder?: () => Promise<{ filter?: GameListFilterType, title?: JSX.Element; }>; + setLocalFilter: (filter: GameListFilterType) => void, + localFilter: GameListFilterType, headerTitle?: JSX.Element; + headerChildren?: any; title?: JSX.Element; footer?: JSX.Element; focus?: string; - countHit?: number; + countHint?: number; + headerButtons?: HeaderButton[]; + headerButtonElements?: JSX.Element | JSX.Element[]; } export function CollectionsDetail (data: CollectionsDetailParams) { const router = useRouter(); - const builtData = useQuery({ - queryKey: ['filter', data.id], queryFn: async () => - { - return data.builder?.() ?? { filter: data.filters, title: data.title }; - } - }); + const [filterValues, setFilterValues] = useState(); const queryClient = useQueryClient(); - const focusKey = `game-list-${data.id}-${data?.filters ? Object.values(data?.filters).map(f => String(f)).join(",") : ''}`; + const finalFilter = { ...data.localFilter, ...data.filters }; + const focusKey = `game-list-${data.id}`; const { ref, focusSelf } = useFocusable({ focusKey, preferredChildFocusKey: `${focusKey}-list` @@ -46,9 +52,8 @@ export function CollectionsDetail (data: CollectionsDetailParams) useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]); - const handleScroll: GameCardFocusHandler = (cardId, node, details) => + const handleScroll: FocusParams['onFocus'] = (cardId, node, details) => { - const [source, id] = cardId.split('@'); queryClient.prefetchQuery(gameQuery(source, id)); @@ -61,22 +66,27 @@ export function CollectionsDetail (data: CollectionsDetailParams) return (
    - }, { id: "filter", icon: }]} ref={ref} /> -
    -
    - {builtData.data?.filter && data.title} - {(builtData.data?.filter || (!data.filters && !data.builder)) && }> + + {data.headerChildren} + +
    +
    +
    +
    +
    + {finalFilter && data.title} + {}> - + } -
    -
    -
    @@ -85,6 +95,9 @@ export function CollectionsDetail (data: CollectionsDetailParams)
    +
    + +
    diff --git a/src/mainview/components/Constants.tsx b/src/mainview/components/Constants.tsx new file mode 100644 index 0000000..f6de5ae --- /dev/null +++ b/src/mainview/components/Constants.tsx @@ -0,0 +1,7 @@ +import { Gamepad2, HardDrive, Store } from "lucide-react"; + +export const sourceIconMap: Record = { + store: , + local: , + romm: +}; \ No newline at end of file diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index 6024311..b0acd8f 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -35,7 +35,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class const handleAction = () => { if (data.disabled === true) return; - data.action?.({ close: context.close, focus: focusSelf }); + data.action?.({ close: context.close, focus: focusSelf, selected: data.selected }); oneShot('click'); }; const { ref, focusSelf, focusKey } = useFocusable({ @@ -82,7 +82,7 @@ export interface DialogEntry icon?: string | JSX.Element; type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error'; selected?: boolean; - action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; }) => void; + action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; selected?: boolean; }) => void; shortcuts?: Shortcut[]; } @@ -102,6 +102,7 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla { setOpen(false); data.onClose?.(); + oneShot('closeContext'); if (newSourceFocusKey) { setFocus(newSourceFocusKey, { instant: true }); @@ -118,7 +119,12 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla return { dialog, open, - setOpen: handleClose + setOpen: handleClose, + setToggle: (focNewSourceFocusKey?: string | undefined) => + { + if (open) handleClose(false, focNewSourceFocusKey); + else handleClose(true, focNewSourceFocusKey); + } }; } @@ -142,7 +148,6 @@ export function ContextDialog (data: { const handleClose = () => { data.close(false); - oneShot('closeContext'); }; useEffect(() => { @@ -161,7 +166,7 @@ export function ContextDialog (data: { }] : [], [data.open]); return @@ -169,7 +174,7 @@ export function ContextDialog (data: {
    void; onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void; - onFocus?: GameCardFocusHandler; + focus?: string; className?: string; finalElement?: JSX.Element; saveChildFocus?: "session" | "local"; + setFilterValues?: (filters: FrontEndFilterLists) => void; } export function GameList (data: GameListParams) { - const games = useSuspenseQuery({ ...allGamesQuery(data.filters), staleTime: DefaultRommStaleTime }); + const games = useSuspenseQuery({ ...allGamesQuery(data.filters), queryKey: ['games', data.filters ?? 'all'], staleTime: DefaultRommStaleTime }); const navigator = useNavigate(); const blur = useLocalSetting('backgroundBlur'); const backgroundContext = useContext(AnimatedBackgroundContext); @@ -48,6 +48,11 @@ export function GameList (data: GameListParams) } }; + useEffect(() => + { + data.setFilterValues?.(games.data.filters); + }, [games.data.filters]); + function handleDefaultSelect (g: FrontEndGameType) { navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source } }); @@ -60,9 +65,10 @@ export function GameList (data: GameListParams) type="game" grid={data.grid} className={data.className} - onGameFocus={data.onFocus} + onFocus={data.onFocus} finalElement={data.finalElement} saveChildFocus={data.saveChildFocus} + focus={data.focus} games={games.data?.games .map( (g) => diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index cc17aba..3a2e8dd 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -234,7 +234,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) }); } - return
    + return
    {accounts?.map(a => {!!data.buttons &&
    }
    - {data.buttonElements ?? data.buttons?.map(b => ; className?: string; } & HeaderUIParams) +export function StickyHeaderUI (data: { ref: RefObject; className?: string; children?: any; } & HeaderUIParams) { const [isStuck, setIsStuck] = useState(false); const headerRef = useRef(null); @@ -317,6 +318,7 @@ export function StickyHeaderUI (data: { ref: RefObject; className?: string;
    + {data.children}
    ; } \ No newline at end of file diff --git a/src/mainview/components/HeaderSearchField.tsx b/src/mainview/components/HeaderSearchField.tsx new file mode 100644 index 0000000..3845987 --- /dev/null +++ b/src/mainview/components/HeaderSearchField.tsx @@ -0,0 +1,102 @@ +import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { Ref, RefObject, useEffect, useRef, useState } from "react"; +import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { oneShot } from "../scripts/audio/audio"; +import { Search } from "lucide-react"; +import { RoundButton } from "./RoundButton"; +import { useEventListener } from "usehooks-ts"; + +function SearchInput (data: { + id: string; + autoSearch?: boolean; + search: string | undefined; + compact: boolean | undefined; + onInputFocus: () => void; + setShowInput: (show: boolean) => void; + onSubmit: (search: string | undefined) => void; +} & FocusParams) +{ + const { ref, focusKey } = useFocusable({ + onBlur: () => inputRef.current?.blur(), + onFocus: (l, p, d) => + { + data.onFocus?.(focusKey, ref.current, { ...d, inputRef }); + if (data.autoSearch) inputRef.current?.focus(); + }, + focusKey: data.id, + onEnterPress: () => + { + if (document.activeElement === inputRef.current) + { + if (inputRef.current) + data.onSubmit?.(inputRef.current.value); + } else + { + inputRef.current?.focus(); + } + } + }); + + const inputRef = useRef(null); + const [localSearch, setLocalSearch] = useState(data.search); + + useEffect(() => + { + setLocalSearch(data.search ?? ""); + }, [data.search]); + + useShortcuts(focusKey, () => document.activeElement === inputRef.current ? [{ + label: "Cancel", + button: GamePadButtonCode.B, action (e) + { + inputRef.current?.blur(); + oneShot('returnGeneric'); + }, + }] : [], [inputRef.current, document.activeElement]); + + useEventListener('search' as any, e => + { + data.onSubmit?.(undefined); + }, inputRef as any); + + return ; +} + +export default function HeaderSearchField (data: { + id: string; + autoSearch?: boolean; + search: string | undefined, + onSubmit: (search: string | undefined) => void; + compact?: boolean; +} & FocusParams) +{ + const [showInput, setShowInput] = useState(false); + + const { ref, focusKey, focusSelf } = useFocusable({ + focusKey: data.id, + focusBoundaryDirections: ['left', "right"], + isFocusBoundary: data.compact && showInput + }); + + return
    + + {(!data.compact || showInput) && } + {data.compact && !showInput && setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} >} + +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/LoadMoreButton.tsx b/src/mainview/components/LoadMoreButton.tsx index afcd9b7..0a70f93 100644 --- a/src/mainview/components/LoadMoreButton.tsx +++ b/src/mainview/components/LoadMoreButton.tsx @@ -4,9 +4,9 @@ import { useIntersectionObserver } from "usehooks-ts"; export default function LoadMoreButton (data: { isFetching: boolean; lastId?: FrontEndId; } & FocusParams & InteractParams) { - const handleAction = (e?: Event) => + const handleAction = (event?: Event) => { - data.onAction?.(e); + data.onAction?.({ event, focusKey }); if (data.lastId && focused) setFocus(FOCUS_KEYS.GAME_CARD(data.lastId)); }; @@ -18,8 +18,6 @@ export default function LoadMoreButton (data: { isFetching: boolean; lastId?: Fr onEnterPress: handleAction }); - - const { ref: intersct } = useIntersectionObserver({ initialIsIntersecting: true, rootMargin: "20%", diff --git a/src/mainview/components/PlatformsList.tsx b/src/mainview/components/PlatformsList.tsx index 48af4a3..039e20a 100644 --- a/src/mainview/components/PlatformsList.tsx +++ b/src/mainview/components/PlatformsList.tsx @@ -5,7 +5,6 @@ import { CardList, GameMetaExtra } from "./CardList"; import { rommApi } from "../scripts/clientApi"; import { JSX, useMemo } from "react"; import { HardDrive } from "lucide-react"; -import { GameCardFocusHandler } from "./CardElement"; import { mobileCheck } from "../scripts/utils"; import { twMerge } from "tailwind-merge"; @@ -13,11 +12,10 @@ export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; - onFocus?: GameCardFocusHandler; grid?: boolean; onSelect?: (source: string, id: string) => void; saveChildFocus?: "session" | "local"; -}) +} & FocusParams) { const isMobile = mobileCheck(); const navigate = useNavigate(); @@ -88,7 +86,7 @@ export function PlatformsList (data: { id={data.id} grid={data.grid} className={twMerge('*:aspect-8/10! md:py-12', data.className)} - onGameFocus={data.onFocus} + onFocus={data.onFocus} games={platformsMapped} onSelectGame={(id) => { diff --git a/src/mainview/components/Screenshots.tsx b/src/mainview/components/Screenshots.tsx index ae5af90..42d76d3 100644 --- a/src/mainview/components/Screenshots.tsx +++ b/src/mainview/components/Screenshots.tsx @@ -12,9 +12,9 @@ import { twMerge } from "tailwind-merge"; function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams) { const imageRef = useRef(null); - const { ref, focusSelf } = useFocusable({ + const { ref, focusSelf, focusKey } = useFocusable({ focusKey: `screenshot-${data.index}`, - onEnterPress: () => data.onAction?.(), + onEnterPress: () => data.onAction?.({ focusKey }), onFocus: (e, p, details) => { data.setFocused?.(data.index); @@ -23,7 +23,7 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n }); 4096; return
    focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" decoding="async" /> -
    data.onAction?.(e.nativeEvent)}>
    +
    data.onAction?.({ event: e.nativeEvent, focusKey })}>
    ; } diff --git a/src/mainview/components/SelectMenu.tsx b/src/mainview/components/SelectMenu.tsx index 4e68b68..4ad4878 100644 --- a/src/mainview/components/SelectMenu.tsx +++ b/src/mainview/components/SelectMenu.tsx @@ -9,7 +9,6 @@ import { FOCUS_KEYS } from "../scripts/types"; export default function SelectMenu (data: { rootFocusKey: string; }) { const navigate = useNavigate(); - const routeState = useRouterState(); const matchRoute = useMatchRoute(); const options: DialogEntry[] = [ @@ -85,7 +84,7 @@ export default function SelectMenu (data: { rootFocusKey: string; }) ]; const { dialog, setOpen, open } = useContextDialog('select-menu', { content: , - className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none', + className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none max-h-screen', preferredChildFocusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION('select-menu', options.find(o => o.selected)?.id ?? '') }); useShortcuts(data.rootFocusKey, () => [{ diff --git a/src/mainview/components/SideFilters.tsx b/src/mainview/components/SideFilters.tsx new file mode 100644 index 0000000..117577c --- /dev/null +++ b/src/mainview/components/SideFilters.tsx @@ -0,0 +1,147 @@ +import { GameListFilterType } from "@/shared/constants"; +import { RoundButton } from "./RoundButton"; +import classNames from "classnames"; +import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { useFocusable, FocusContext } from "@noriginmedia/norigin-spatial-navigation"; +import { ArrowDownAz, ClockArrowDown, CalendarArrowDown, Rocket, HardDrive, SortDesc, User, Drama, FunnelX, Store } from "lucide-react"; +import { sourceIconMap } from "./Constants"; +import { useContextDialog, ContextList, DialogEntry } from "./ContextDialog"; + +function FilterButton (data: { + id: string, + filters?: GameListFilterType, + tooltip: string, + icon: any; + dialog: { + setToggle: (focNewSourceFocusKey?: string | undefined) => void; + }; + isActive: boolean; +}) +{ + const handleAction = () => data.dialog.setToggle(data.id); + useShortcuts(data.id, () => [{ label: data.tooltip, action: handleAction, button: GamePadButtonCode.A }]); + return
    + + {data.icon} + +
    ; +} + +export default function SideFilters (data: { + id: string, + filters?: GameListFilterType; + setLocalFilter: (filter: GameListFilterType) => void, + localFilter: GameListFilterType, + filterValues: FrontEndFilterLists | undefined; +}) +{ + + const { ref, focusKey } = useFocusable({ focusKey: data.id }); + + const orderByDialog = useContextDialog('order-by-dialog', { + content: }, + { stat: "activity", icon: }, + { stat: "added", icon: }, + { stat: "release", icon: }, + ] 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}` + }); + + const sourceFilterDialog = useContextDialog('source-filter-dialog', { + content: (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(); + }, + })).concat({ + content: "Local Only", + icon: , + 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: ({ + 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: ({ + 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
    + + } /> + 0} icon={} /> + 0} icon={} /> + {!data.filters?.source && + } /> + } + {Object.values(data.localFilter).some(v => v !== undefined) && + <> +
    + data.setLocalFilter({})} className='p-3 drop-shadow-md!' > + + } + {orderByDialog.dialog} + {sourceFilterDialog.dialog} + {genreFilterDialog.dialog} + {ageRatingFilterDialog.dialog} +
    +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/StatList.tsx b/src/mainview/components/StatList.tsx index de1c231..bdc9a02 100644 --- a/src/mainview/components/StatList.tsx +++ b/src/mainview/components/StatList.tsx @@ -37,7 +37,7 @@ export default function StatList (data: { content =
    {s.content.map((c, ci) => {c})}
    ; } else { - content =
    {s.icon}{s.content}
    ; + content =
    {s.icon}{s.content}
    ; } return [
    ; } diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index d9b81f7..47c07c0 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -95,9 +95,9 @@ export function StoreEmulatorCard (data: { >
    } - {data.emulator.validSources.slice(0, 3).map(s => + {data.emulator.validSources.slice(0, 3).map((s, i) => { - return
    + return
    {emulatorStatusIcons[s.type]}
    diff --git a/src/mainview/routes/__root.tsx b/src/mainview/routes/__root.tsx index 2687614..fbe2f26 100644 --- a/src/mainview/routes/__root.tsx +++ b/src/mainview/routes/__root.tsx @@ -6,6 +6,8 @@ import { mobileCheck, useLocalSetting } from "../scripts/utils"; import useActiveControl from "../scripts/gamepads"; import { useEffect } from "react"; import AppCommunication from "../components/AppCommunication"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; export const Route = createRootRouteWithContext()({ component: RootComponent, @@ -32,6 +34,9 @@ function RootComponent () }, [theme]); + const queryDevOptions = useLocalSetting('showQueryDevOptions'); + const routerDevOptions = useLocalSetting('showRouterDevOptions'); + return (
    @@ -39,12 +44,8 @@ function RootComponent () - {/*import.meta.env.DEV && !isMobile && - <> - - - - */} + {queryDevOptions && } + {routerDevOptions && }
    ); } diff --git a/src/mainview/routes/collection.$source.$id.tsx b/src/mainview/routes/collection.$source.$id.tsx index 2f62d91..3b73d25 100644 --- a/src/mainview/routes/collection.$source.$id.tsx +++ b/src/mainview/routes/collection.$source.$id.tsx @@ -6,10 +6,14 @@ import { AnimatedBackgroundContext } from '../scripts/contexts'; import { getCollectionQuery } from '@queries/romm'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; +import { GameListFilterType } from '@/shared/constants'; +import { useLocalStorage } from 'usehooks-ts'; export const Route = createFileRoute('/collection/$source/$id')({ component: RouteComponent, - validateSearch: zodValidator(z.object({ countHint: z.number().optional() })) + validateSearch: zodValidator(z.object({ + countHint: z.number().optional() + })) }); function RouteComponent () @@ -18,8 +22,16 @@ function RouteComponent () const { countHint } = Route.useSearch(); const { data: collection } = useQuery(getCollectionQuery(source, id)); const animatedBgContext = useContext(AnimatedBackgroundContext); + const [filter, setFilter] = useLocalStorage("collection-filter", {}); return ( - {collection?.name}
    } filters={{ collection_id: Number(id), collection_source: source }} /> + {collection?.name}
    } + filters={{ collection_id: Number(id), collection_source: source }} + /> ); } diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 42d23f1..fa54647 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -23,7 +23,6 @@ import { GamesSection } from "@/mainview/components/store/GamesSection"; import Details from "@/mainview/components/game/Details"; import { AutoFocus } from "@/mainview/components/AutoFocus"; import SelectMenu from "@/mainview/components/SelectMenu"; -import { stat } from "node:fs"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => @@ -97,12 +96,12 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; }) { if (data.game.path_fs) stats.push({ label: "Location", content: data.game.path_fs, icon: }); - if (data.game.companies) - stats.push({ label: "Companies", content: data.game.companies }); - if (data.game.genres) - stats.push({ label: 'Genres', content: data.game.genres }); - if (data.game.release_date) - stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: }); + if (data.game.metadata.companies) + stats.push({ label: "Companies", content: data.game.metadata.companies }); + if (data.game.metadata.genres) + stats.push({ label: 'Genres', content: data.game.metadata.genres }); + if (data.game.metadata.first_release_date) + stats.push({ label: "Release Date", content: data.game.metadata.first_release_date.toLocaleDateString(), icon: }); if (data.game.emulators) stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) }); if (data.game.source) diff --git a/src/mainview/routes/games.tsx b/src/mainview/routes/games.tsx index d1071fa..3742e83 100644 --- a/src/mainview/routes/games.tsx +++ b/src/mainview/routes/games.tsx @@ -2,15 +2,36 @@ import { createFileRoute } from '@tanstack/react-router'; import { CollectionsDetail } from '../components/CollectionsDetail'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; +import { GameListFilterType } from '@/shared/constants'; +import { useSessionStorage } from 'usehooks-ts'; +import HeaderSearchField from '../components/HeaderSearchField'; +import { useEffect, useState } from 'react'; +import { setFocus } from '@noriginmedia/norigin-spatial-navigation'; export const Route = createFileRoute('/games')({ component: RouteComponent, - validateSearch: zodValidator(z.object({ focus: z.string().optional() })) + validateSearch: zodValidator(z.object({ + focus: z.string().optional(), + search: z.string().optional() + })) }); function RouteComponent () { const { focus } = Route.useSearch(); + const { search } = Route.useSearch(); + const [filter, setFilter] = useSessionStorage('all-games-filters', {}); - return ; + useEffect(() => + { + setFilter(v => ({ ...v, search })); + }, [search]); + + return setFilter({ ...filter, search: v })} search={filter.search} id='search-filter' />} + localFilter={filter} + setLocalFilter={setFilter} + focus={focus} + id='all-games' + />; } \ No newline at end of file diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index eee1194..1e758ad 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -47,6 +47,7 @@ import { gameQuery } from "../scripts/queries/romm"; import { oneShot } from "../scripts/audio/audio"; import { FloatingShortcuts } from "../components/Shortcuts"; import SelectMenu from "../components/SelectMenu"; +import HeaderSearchField from "../components/HeaderSearchField"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -232,13 +233,13 @@ function MainMenu () > router.navigate({ to: "/games", state: { eventType: e?.type } })} + onAction={(e) => router.navigate({ to: "/games", state: { eventType: e?.event?.type } })} icon={} label="Home" type="secondary" /> } label="News" /> - } onAction={(e) => router.navigate({ to: "/store/tab", state: { eventType: e?.type } })} label="Shop" /> + } onAction={(e) => router.navigate({ to: "/store/tab", state: { eventType: e?.event?.type } })} label="Shop" /> } label="Album" /> } @@ -247,7 +248,7 @@ function MainMenu () { - router.navigate({ to: '/settings/accounts', state: { eventType: e?.type } }); + router.navigate({ to: '/settings/accounts', state: { eventType: e?.event?.type } }); }} icon={} label="Settings" @@ -264,9 +265,9 @@ function CircleIcon (data: { icon?: JSX.Element; } & InteractParams) { - const handleAction = (e?: Event) => + const handleAction = (event?: Event) => { - data.onAction?.(e); + data.onAction?.({ event, focusKey }); oneShot('click'); }; const { ref, focusKey } = useFocusable({ @@ -313,10 +314,13 @@ export default function ConsoleHomeUI () if (mobileCheck()) headerButtons.push({ id: "fullscreen", icon: , action: handleFullscreen }); headerButtons.push( - { id: "search-header-button", icon: }, { id: "power-button", icon: , external: true, action: () => close.mutate(), className: "focusable-error!" }, { id: "settings-header-button", icon: , external: true, action: () => router.navigate({ to: "/settings/accounts" }) } ); + const handleSearch = (search: string | undefined) => + { + router.navigate({ to: '/games', search: { search } }); + }; return ( @@ -334,7 +338,7 @@ export default function ConsoleHomeUI () />
    - + } />
    ("platforms-filters", {}); return (
    } filters={{ platform_id: Number(id), platform_source: source }} /> diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index f9921a3..8f52831 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -148,7 +148,7 @@ function EmulatorPath (data: { id: string; }) autocomplete="off" onChange={(v) => { - setLocalValue(v); + setLocalValue(v as string); setDirty(true); }} value={localValue} diff --git a/src/mainview/routes/settings/interface.tsx b/src/mainview/routes/settings/interface.tsx index 9b930e4..ee0ec8f 100644 --- a/src/mainview/routes/settings/interface.tsx +++ b/src/mainview/routes/settings/interface.tsx @@ -1,6 +1,7 @@ import { LocalOption } from '@/mainview/components/options/LocalOption'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { createFileRoute } from '@tanstack/react-router'; +import { Terminal } from 'lucide-react'; export const Route = createFileRoute('/settings/interface')({ component: RouteComponent, @@ -22,6 +23,11 @@ function RouteComponent () + {import.meta.env.DEV && <> +
    Dev Settings
    + + + } ; } diff --git a/src/mainview/routes/settings/plugins.tsx b/src/mainview/routes/settings/plugins.tsx index 7b1a8da..c0c7dab 100644 --- a/src/mainview/routes/settings/plugins.tsx +++ b/src/mainview/routes/settings/plugins.tsx @@ -28,7 +28,7 @@ function Plugin (data: {
    {data.plugin.name} ({data.plugin.version})
    } className='flex p-4 bg-base-200 rounded-3xl'> - + data.setEnabled(!!v)} value={data.plugin.enabled} name={data.plugin.name} type="checkbox" /> ; } diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index ad65539..13a1295 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -356,40 +356,6 @@ export function RouteComponent () }); const stats: StatEntry[] = []; - if (emulator) - { - if (emulator.keywords) - stats.push({ label: "Tags", content: emulator.keywords }); - if (emulator.storeDownloadInfo) - stats.push({ label: "Version", content: `${emulator.storeDownloadInfo.version ?? "Unknown"} (${emulator.storeDownloadInfo.type})` }); - stats.push({ label: "Systems", content: emulator.systems.map(s => s.name) }); - stats.push(...emulator.validSources.flatMap(s => [{ - label: "Source", content:
    -
    -
    {emulatorStatusIcons[s.type]}{s.type}
    -
    {s.binPath}
    -
    - {emulator.integrations.some(i => i.source?.type === s.type) &&
    } - {emulator.integrations.filter(i => i.source?.type === s.type).map(i => - { - return
    -
    - -
    {i.id}
    -
    -
    - {i.capabilities?.map(c => <>
    {capabilityIconMap[c]}{c}
    )} -
    -
    ; - })} -
    - }])); - if (emulator.bios) - stats.push({ - label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios :
    Missing
    - }); - - } return ( diff --git a/src/mainview/routes/store/tab/emulators.tsx b/src/mainview/routes/store/tab/emulators.tsx index 524e20a..67a5724 100644 --- a/src/mainview/routes/store/tab/emulators.tsx +++ b/src/mainview/routes/store/tab/emulators.tsx @@ -10,6 +10,7 @@ import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import { useQuery } from '@tanstack/react-query'; import { storeEmulatorsQuery } from '@queries/store'; import InvalidStoreError from '@/mainview/components/store/InvalidStoreError'; +import { useSessionStorage } from 'usehooks-ts'; export const Route = createFileRoute('/store/tab/emulators')({ component: RouteComponent, @@ -18,13 +19,14 @@ export const Route = createFileRoute('/store/tab/emulators')({ function RouteComponent () { - const { focus } = useSearch({ from: '/store/tab' }); + const { focus } = Route.useSearch(); + const [search] = useSessionStorage(`${Route.to}-search`, undefined); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus }); const storeContext = useContext(StoreContext); - const { data: emulators } = useQuery({ ...storeEmulatorsQuery, retry: false, throwOnError: true }); + const { data: emulators } = useQuery({ ...storeEmulatorsQuery({ search }), retry: false, throwOnError: true }); useEffect(() => { diff --git a/src/mainview/routes/store/tab/games.tsx b/src/mainview/routes/store/tab/games.tsx index 7aee585..9f7da33 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -1,27 +1,43 @@ import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import { createFileRoute, useSearch } from '@tanstack/react-router'; -import { Gamepad2 } from 'lucide-react'; -import { useContext, useEffect } from 'react'; -import { useInfiniteQuery } from '@tanstack/react-query'; +import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'; +import { Gamepad2, HardDrive } from 'lucide-react'; +import { JSX, useContext, useEffect, useState } from 'react'; +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import FrontEndGameCard from '@/mainview/components/FrontEndGameCard'; import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import LoadMoreButton from '@/mainview/components/LoadMoreButton'; import { storeGamesInfiniteQuery } from '@queries/store'; import { StoreContext } from '@/mainview/scripts/contexts'; import InvalidStoreError from '@/mainview/components/store/InvalidStoreError'; +import { CardList, GameMetaExtra } from '@/mainview/components/CardList'; +import { GameListFilterType, RPC_URL } from '@/shared/constants'; +import { useSessionStorage } from 'usehooks-ts'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; +import SideFilters from '@/mainview/components/SideFilters'; export const Route = createFileRoute('/store/tab/games')({ component: RouteComponent, - errorComponent: InvalidStoreError + errorComponent: InvalidStoreError, + validateSearch: zodValidator(z.object({ + search: z.string().optional() + })) }); function RouteComponent () { - const { focus } = useSearch({ from: '/store/tab' }); + const { focus } = Route.useSearch(); + const [search] = useSessionStorage(`${Route.to}-search`, undefined); + const navigator = useNavigate(); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus }); + const [filter, setFilter] = useSessionStorage('store-games-filters', {}); + const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery(filter)); + const [filterValues, setFilterValues] = useState(); - const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery); - const storeContext = useContext(StoreContext); + useEffect(() => + { + setFilter(v => ({ ...v, search })); + }, [search]); useEffect(() => { @@ -38,6 +54,11 @@ function RouteComponent () node.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'center' }); }; + function handleDefaultSelect (g: FrontEndGameType) + { + navigator({ to: '/game/$source/$id', params: { id: g.id.id, source: g.id.source } }); + }; + return <>
    @@ -47,19 +68,8 @@ function RouteComponent () Games
    -
    - {data?.pages.flatMap((page) => ( - page.data.map((g, i) => - { - storeContext.prefetchDetails('game', g.id.source, g.id.id); - handleFocus(k, n, d); - }} key={g.id.id} game={g} index={i} />)) - ) ?? Array.from({ length: 20 }).map((_, i) =>
    -
    -
    -
    -
    )} - + + }} />} games={data?.pages.flatMap((page) => page.data.map((g) => + { + const badges: JSX.Element[] = []; + if (g.id.source === 'local') + { + badges.push(); + } + + const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`); + previewUrl.searchParams.delete('ts'); + + const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); + platformUrl.searchParams.set('width', "64"); + + return { + id: `${g.id.source}@${g.id.id}`, + focusKey: `${g.id.source}@${g.id.id}`, + title: g.name ?? "", + subtitle: ( +
    + {!!g.path_platform_cover && } +

    {g.platform_display_name}

    +
    + ), + previewUrl: previewUrl.href, + badges: badges, + onSelect: () => handleDefaultSelect(g), + onFocus: (k, n, d) => handleFocus(k, n, d) + } satisfies GameMetaExtra as GameMetaExtra; + }) + ) ?? []} id={'store-games'} /> +
    +
    +
    diff --git a/src/mainview/routes/store/tab/route.tsx b/src/mainview/routes/store/tab/route.tsx index fd2f76b..f459099 100644 --- a/src/mainview/routes/store/tab/route.tsx +++ b/src/mainview/routes/store/tab/route.tsx @@ -1,6 +1,7 @@ import { AutoFocus } from '@/mainview/components/AutoFocus'; import { FilterUI } from '@/mainview/components/Filters'; import { HeaderUI } from '@/mainview/components/Header'; +import HeaderSearchField from '@/mainview/components/HeaderSearchField'; import SelectMenu from '@/mainview/components/SelectMenu'; import Shortcuts, { FloatingShortcuts } from '@/mainview/components/Shortcuts'; import { StoreContext } from '@/mainview/scripts/contexts'; @@ -13,7 +14,8 @@ import { useQueryClient } from '@tanstack/react-query'; import { useMatchRoute, useRouter } from '@tanstack/react-router'; import { createFileRoute, Outlet } from '@tanstack/react-router'; import { zodValidator } from '@tanstack/zod-adapter'; -import { useRef } from 'react'; +import { useRef, useState } from 'react'; +import { useSessionStorage } from 'usehooks-ts'; import z from 'zod'; export const Route = createFileRoute('/store/tab')({ @@ -95,6 +97,8 @@ function RouteComponent () emulators: { label: "Emulators", selected: useIsSettings('emulators') }, games: { label: "Games", selected: useIsSettings('games') } }; + const [search, setSearch] = useSessionStorage(`${router.history.location.pathname}-search`, undefined); + const [, setGamesSearch] = useSessionStorage(`/store/tab/games-search`, undefined); const handleDetails = (type: string, source: string, id: string, focus: string) => { @@ -120,6 +124,19 @@ function RouteComponent () } }; + const handleSearch = (search: string | undefined) => + { + if (filters['home'].selected) + { + setGamesSearch(search); + router.navigate({ to: '/store/tab/games', replace: true, viewTransition: { types: ['slide-up'] } }); + } else + { + setSearch(search); + } + + }; + const isMobile = mobileCheck(); useStickyDataAttr(headerRef, sentinelRef, ref); @@ -129,7 +146,7 @@ function RouteComponent ()
    - + } />
    diff --git a/src/mainview/scripts/queries/store.ts b/src/mainview/scripts/queries/store.ts index 4a881cd..648f9ef 100644 --- a/src/mainview/scripts/queries/store.ts +++ b/src/mainview/scripts/queries/store.ts @@ -1,11 +1,12 @@ import { infiniteQueryOptions, mutationOptions, queryOptions } from "@tanstack/react-query"; import { rommApi, storeApi } from "../clientApi"; +import { GameListFilterType } from "@/shared/constants"; -export const storeEmulatorsQuery = queryOptions({ - queryKey: ['store-emulators'], queryFn: async () => +export const storeEmulatorsQuery = (filters: { search?: string; }) => queryOptions({ + queryKey: ['store-emulators', filters], queryFn: async () => { - const { data, error } = await storeApi.api.store.emulators.get(); + const { data, error } = await storeApi.api.store.emulators.get({ query: { search: filters.search } }); if (error) throw new Error(JSON.stringify(error.value)); return data; } @@ -42,14 +43,14 @@ export const storeEmulatorDeleteMutation = mutationOptions({ if (error) throw error; } }); -export const storeGamesInfiniteQuery = infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({ +export const storeGamesInfiniteQuery = (filter: GameListFilterType) => infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({ initialPageParam: 0, - queryKey: ['store-games'], + queryKey: ['store-games', filter], getNextPageParam: (lastPage, pages) => lastPage.nextPage, queryFn: async (data) => { const pageParam = data.pageParam as number; - const { data: games, error } = await rommApi.api.romm.games.get({ query: { source: 'store', offset: pageParam * 10, limit: 10 } }); + const { data: games, error } = await rommApi.api.romm.games.get({ query: { ...filter, source: 'store', offset: pageParam * 10, limit: 10 } }); if (error) throw error; return { data: games.games, nextPage: pageParam + 1 }; } diff --git a/src/mainview/scripts/shortcuts.ts b/src/mainview/scripts/shortcuts.ts index c198037..35316b7 100644 --- a/src/mainview/scripts/shortcuts.ts +++ b/src/mainview/scripts/shortcuts.ts @@ -191,7 +191,7 @@ export function useShortcutContext () return { shortcuts: array }; } -export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps: DependencyList) +export function useShortcuts (focusKey: string, build: () => Shortcut[], deps?: DependencyList) { useEffect(() => { @@ -211,6 +211,6 @@ export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps markDirtyThrottled(); }; - }, [...deps, focusKey]); + }, [focusKey, ...deps ?? []]); } \ No newline at end of file diff --git a/src/mainview/types.d.ts b/src/mainview/types.d.ts index 34803b0..9100029 100644 --- a/src/mainview/types.d.ts +++ b/src/mainview/types.d.ts @@ -50,9 +50,15 @@ declare interface FocusParams onFocus?: (focusKey: string, node: HTMLElement, details: Record) => void; } +declare interface InteractParamsArgs +{ + event?: Event, + focusKey?: string; +} + declare interface InteractParams { - onAction?: (e?: Event) => void; + onAction?: (ctx: InteractParamsArgs) => void; } declare interface FilterOption extends FocusParams, InteractParams diff --git a/src/shared/constants.ts b/src/shared/constants.ts index faefce1..5d7b307 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -17,11 +17,10 @@ export const SOCKETS_URL = (host: string) => `ws://${host}:${RPC_PORT}`; export const STORE_VERSION = "^0"; export const DefaultRommStaleTime = 60 * 1000; // A minute -export interface GameMeta +export interface GameMeta extends FocusParams { id: string, onSelect?: () => void, - onFocus?: (details: FocusDetails) => void, title: string, subtitle: string | JSX.Element, previewUrl?: string; @@ -46,7 +45,9 @@ export const LocalSettingsSchema = z.object({ theme: z.enum(['dark', 'light', 'auto']).default('auto'), soundEffects: z.boolean().default(true), soundEffectsVolume: z.number().min(0).max(100).default(50), - hapticsEffects: z.boolean().default(true) + hapticsEffects: z.boolean().default(true), + showRouterDevOptions: z.boolean().default(false), + showQueryDevOptions: z.boolean().default(false), }); export const GameListFilterSchema = z.object({ @@ -56,9 +57,14 @@ export const GameListFilterSchema = z.object({ collection_id: z.coerce.number().optional(), collection_source: z.string().optional(), limit: z.coerce.number().optional(), + search: z.string().optional(), offset: z.coerce.number().optional(), source: z.string().optional(), - orderBy: z.literal(['added', 'activity', 'name']).optional() + localOnly: z.coerce.boolean().optional(), + orderBy: z.literal(['added', 'activity', 'name', 'release']).optional(), + age_ratings: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), + genres: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), + keywords: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), }); export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() }); diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index 077ddd6..ca0fc49 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -57,17 +57,15 @@ declare interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator } -declare interface FrontEndGameTypeDetailed extends FrontEndGameType +declare interface FrontEndGameTypeDetailed extends Exclude { summary: string | null; fs_size_bytes: number | null; missing: boolean; local: boolean; - genres?: string[]; - companies?: string[]; - release_date?: Date; imdb_id?: number; ra_id?: number; + metadata: FrontEndGameMetadataDetailed, emulators?: FrontEndGameTypeDetailedEmulator[], achievements?: { unlocked: number; @@ -162,6 +160,39 @@ declare interface FrontEndGameTypeWithIds extends FrontEndGameType ra_id: number | null; } +declare interface FrontEndFilterSets +{ + age_ratings: Set, + player_counts: Set, + languages: Set, + companies: Set, + genres: Set; +} + +declare interface FrontEndFilterLists +{ + age_ratings: string[], + player_counts: string[], + languages: string[], + companies: string[], + genres: string[]; +} + +declare interface FrontEndGameMetadata +{ + first_release_date: Date | null; +} + +declare interface FrontEndGameMetadataDetailed extends FrontEndGameMetadata +{ + genres: string[], + companies: string[], + game_modes: string[], + age_ratings: string[]; + player_count: string | null; + average_rating: number | null; +} + declare interface FrontEndGameType { platform_display_name: string | null, @@ -173,6 +204,7 @@ declare interface FrontEndGameType path_cover: string | null, last_played: Date | null, updated_at: Date, + metadata: FrontEndGameMetadata, slug: string | null, name: string | null, platform_id: number | null, From c09fbd3dc88891227eda2b9f3bd9ac45621c00ea Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Fri, 17 Apr 2026 21:21:14 +0300 Subject: [PATCH 40/65] fix: Fixed tests feat: Added RClone integration feat: Implemented plugin settings feat: Updated minimal store version test: Fixed tests feat: Moved store and igdb and es-de to their own plugins --- bun.lock | 27 +- drizzle/0002_flowery_rocket_raccoon.sql | 31 ++ drizzle/meta/0002_snapshot.json | 479 ++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 5 +- scripts/dev.ts | 7 +- .../drizzle/es-de/0000_sparkling_banshee.sql | 34 ++ scripts/drizzle/es-de/meta/0000_snapshot.json | 234 ++++++++ scripts/drizzle/es-de/meta/_journal.json | 13 + scripts/generate-es-de-mapping.ts | 18 +- src/bun/api/app.ts | 8 +- src/bun/api/emulatorjs/emulatorjs.ts | 6 +- src/bun/api/games/games.ts | 464 +++++++--------- src/bun/api/games/platforms.ts | 38 +- .../api/games/services/launchGameService.ts | 401 +------------- src/bun/api/games/services/statusService.ts | 177 +++--- src/bun/api/games/services/utils.ts | 115 +--- src/bun/api/hooks/app.ts | 2 + src/bun/api/hooks/emulators.ts | 2 + src/bun/api/hooks/games.ts | 42 +- src/bun/api/hooks/store.ts | 10 + src/bun/api/jobs/install-job.ts | 85 ++- src/bun/api/jobs/jobs.ts | 2 + src/bun/api/jobs/launch-game-job.ts | 26 +- src/bun/api/jobs/reload-plugins-job.ts | 15 + .../com.simeonradivoev.gameflow.cemu/cemu.ts | 6 +- .../package.json | 1 + .../dolphin.ts | 16 +- .../package.json | 1 + .../utils.ts | 8 +- .../package.json | 1 + .../pcsx2.ts | 6 +- .../package.json | 1 + .../ppsspp.ts | 6 +- .../package.json | 1 + .../com.simeonradivoev.gameflow.xemu/xemu.ts | 4 +- .../package.json | 1 + .../xenia.ts | 9 +- .../com.simeonradivoev.gameflow.es/es-de.ts | 520 ++++++++++++++++++ .../package.json | 13 + .../package.json | 13 + .../rclone.ts | 292 ++++++++++ .../com.simeonradivoev.gameflow.igdb/igdb.ts | 83 +++ .../package.json | 13 + .../package.json | 1 + .../com.simeonradivoev.gameflow.romm/romm.ts | 144 +++-- .../package.json | 13 + .../services.ts | 313 +++++++++++ .../store.ts | 312 +++++++++++ src/bun/api/plugins/plugin-manager.ts | 62 ++- src/bun/api/plugins/plugins.ts | 19 +- src/bun/api/plugins/register-plugins.ts | 22 +- src/bun/api/schema/app.ts | 6 +- src/bun/api/settings/services.ts | 18 +- src/bun/api/settings/settings.ts | 52 +- .../api/store/services/emulatorsService.ts | 54 +- src/bun/api/store/services/gamesService.ts | 67 +-- src/bun/api/store/store.ts | 120 ++-- src/bun/api/system.ts | 83 ++- src/bun/api/task-queue.ts | 13 +- src/bun/types/typesc.schema.ts | 36 +- src/mainview/App.tsx | 1 - src/mainview/components/AppCommunication.tsx | 30 +- src/mainview/components/CardElement.tsx | 26 +- src/mainview/components/CardList.tsx | 8 +- src/mainview/components/CollectionList.tsx | 3 +- src/mainview/components/CollectionsDetail.tsx | 6 +- src/mainview/components/Constants.tsx | 16 +- src/mainview/components/FrontEndGameCard.tsx | 28 +- src/mainview/components/GameList.tsx | 32 +- src/mainview/components/Header.tsx | 14 +- .../components/ImageWithFallbacks.tsx | 19 + src/mainview/components/LoadingCardList.tsx | 1 - src/mainview/components/LoadingScreen.tsx | 9 + src/mainview/components/PlatformsList.tsx | 58 +- src/mainview/components/SelectMenu.tsx | 18 +- src/mainview/components/game/ActionButton.tsx | 4 +- .../components/game/ActionButtons.tsx | 46 +- src/mainview/components/game/Details.tsx | 2 +- src/mainview/components/game/MainActions.tsx | 5 +- .../components/options/OptionInput.tsx | 3 +- .../components/options/OptionSpace.tsx | 5 + .../components/store/EmulatorsSection.tsx | 4 +- src/mainview/gen/routeTree.gen.ts | 21 + src/mainview/preload.tsx | 13 +- src/mainview/routes/embedded.$source.$id.tsx | 2 +- src/mainview/routes/game/$source.$id.tsx | 8 +- src/mainview/routes/index.tsx | 10 +- src/mainview/routes/platform.$source.$id.tsx | 68 ++- src/mainview/routes/settings/accounts.tsx | 6 +- src/mainview/routes/settings/emulators.tsx | 4 +- .../routes/settings/plugin.$source.tsx | 158 ++++++ src/mainview/routes/settings/plugins.tsx | 78 ++- src/mainview/routes/settings/route.tsx | 28 +- .../routes/store/details.emulator.$id.tsx | 5 +- src/mainview/routes/store/tab/games.tsx | 38 +- src/mainview/routes/store/tab/index.tsx | 20 +- src/mainview/routes/store/tab/route.tsx | 1 + src/mainview/scripts/queries/plugins.ts | 9 + src/mainview/scripts/queries/romm.ts | 51 +- src/mainview/scripts/queries/settings.ts | 53 +- src/mainview/scripts/queries/store.ts | 2 + src/mainview/scripts/queries/system.ts | 10 + src/shared/constants.ts | 56 +- src/shared/types..d.ts | 32 +- src/tests/game-launching.test.ts | 27 +- src/tests/mock-roms/mock-emulator.exe | 1 + src/tests/mock-roms/mock-rom.iso | 1 + src/tests/preload.ts | 1 + vendors/es-de/emulators.darwin.x64.sqlite | Bin 180224 -> 176128 bytes vendors/es-de/emulators.haiku.x64.sqlite | Bin 135168 -> 131072 bytes vendors/es-de/emulators.linux.arm.sqlite | Bin 184320 -> 180224 bytes vendors/es-de/emulators.linux.x64.sqlite | Bin 221184 -> 217088 bytes vendors/es-de/emulators.win32.x64.sqlite | Bin 212992 -> 212992 bytes vendors/romm/custom-overrides.json | 22 +- 115 files changed, 4139 insertions(+), 1502 deletions(-) create mode 100644 drizzle/0002_flowery_rocket_raccoon.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 scripts/drizzle/es-de/0000_sparkling_banshee.sql create mode 100644 scripts/drizzle/es-de/meta/0000_snapshot.json create mode 100644 scripts/drizzle/es-de/meta/_journal.json create mode 100644 src/bun/api/hooks/store.ts create mode 100644 src/bun/api/jobs/reload-plugins-job.ts create mode 100644 src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts create mode 100644 src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/package.json create mode 100644 src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json create mode 100644 src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts create mode 100644 src/mainview/components/ImageWithFallbacks.tsx create mode 100644 src/mainview/components/LoadingScreen.tsx create mode 100644 src/mainview/routes/settings/plugin.$source.tsx create mode 100644 src/tests/mock-roms/mock-emulator.exe create mode 100644 src/tests/mock-roms/mock-rom.iso diff --git a/bun.lock b/bun.lock index 0b550d6..22e9e88 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", "@jimp/wasm-webp": "^1.6.0", + "@phalcode/ts-igdb-client": "^1.0.26", "cheerio": "^1.2.0", "conf": "^15.0.2", "drizzle-orm": "^0.45.1", @@ -25,6 +26,7 @@ "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", "open": "^11.0.0", + "p-queue": "^9.1.2", "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", @@ -32,7 +34,6 @@ "tapable": "^2.3.0", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", - "ts-igdb-client": "^0.4.2", "unzip-stream": "^0.3.4", "webview-bun": "^2.4.0", "zod": "^4.3.6", @@ -58,8 +59,10 @@ "@types/fs-extra": "^11.0.4", "@types/howler": "^2.2.12", "@types/ini": "^4.1.1", + "@types/json-schema": "^7.0.15", "@types/mustache": "^4.2.6", "@types/node-7z": "^2.1.11", + "@types/rclone.js": "^0.6.3", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", @@ -456,6 +459,10 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + "@phalcode/ts-apicalypse": ["@phalcode/ts-apicalypse@1.0.26", "", { "dependencies": { "axios": "^1.11.0" } }, "sha512-RdqkuunEYu63hRs4tYZ6FLTC17ynC6AJ/YUppRGSIyr6pm5pI/vB1qlEaeUr/f4JJsNmbFwGjnMJdXvoP1LmWA=="], + + "@phalcode/ts-igdb-client": ["@phalcode/ts-igdb-client@1.0.26", "", { "dependencies": { "@phalcode/ts-apicalypse": "^1.0.26", "axios": "^1.11.0" } }, "sha512-ITBazxhafHDBVJFI6THrLOT8OuO4zhD9pOeKQUFJ80soKhBevvbJz3tzkt24fF783Hoqaja8rWmGSwcN04d5gA=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], @@ -634,6 +641,8 @@ "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], + "@types/rclone.js": ["@types/rclone.js@0.6.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-BssKAAVRY//fxGKso8SatyOwiD7X0toDofNnVxZlIXmN7UHrn2UBTxldNAjgUvWA91qJyeEPfKmeJpZVhLugXg=="], + "@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -692,7 +701,7 @@ "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], - "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], + "axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="], "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], @@ -958,7 +967,7 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], - "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], @@ -1314,6 +1323,10 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-queue": ["p-queue@9.1.2", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^7.0.0" } }, "sha512-ktsDOALzTYTWWF1PbkNVg2rOt+HaOaMWJMUnt7T3qf5tvZ1L8dBW3tObzprBcXNMKkwj+yFSLqHso0x+UFcJXw=="], + + "p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], @@ -1390,7 +1403,7 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], "q": ["q@1.5.1", "", {}, "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw=="], @@ -1638,10 +1651,6 @@ "trim-newlines": ["trim-newlines@3.0.1", "", {}, "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw=="], - "ts-apicalypse": ["ts-apicalypse@0.4.2", "", { "dependencies": { "axios": "^1.4.0" } }, "sha512-A02KFDFZHYTft0fTkxr5AERLb//XK/qjvGxrX8uNCwZAvFpLI/4TrOVxbq6kV2caZXWAn8eZZfRzVn1xd/cSMw=="], - - "ts-igdb-client": ["ts-igdb-client@0.4.2", "", { "dependencies": { "axios": "^1.4.0", "ts-apicalypse": "^0.4.2" } }, "sha512-VGQbIyy75GbHW0WGGHBXsdJzuj8wOW/jiWURmQWltpkFjCTMSqqx9gTPSUUcJ0W2kdlWcyMU4TDDHo4N3Fij7A=="], - "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -1868,6 +1877,8 @@ "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "http-proxy/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], "load-json-file/pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], diff --git a/drizzle/0002_flowery_rocket_raccoon.sql b/drizzle/0002_flowery_rocket_raccoon.sql new file mode 100644 index 0000000..0d8fa7e --- /dev/null +++ b/drizzle/0002_flowery_rocket_raccoon.sql @@ -0,0 +1,31 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_games` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `source_id` text, + `source` text, + `igdb_id` integer, + `name` text, + `ra_id` integer, + `path_fs` text, + `main_glob` text, + `last_played` integer, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `metadata` text DEFAULT '{}' NOT NULL, + `slug` text, + `platform_id` integer NOT NULL, + `cover` blob, + `type` text, + `summary` text, + `version` text, + `version_source` text, + `version_system` text, + FOREIGN KEY (`platform_id`) REFERENCES `platforms`(`id`) ON UPDATE cascade ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `__new_games`("id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "main_glob", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", "version", "version_source", "version_system") SELECT "id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "main_glob", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", "version", "version_source", "version_system" FROM `games`;--> statement-breakpoint +DROP TABLE `games`;--> statement-breakpoint +ALTER TABLE `__new_games` RENAME TO `games`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `games_igdb_id_unique` ON `games` (`igdb_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `games_ra_id_unique` ON `games` (`ra_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `games_slug_unique` ON `games` (`slug`); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..fb182f2 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,479 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "40569ae5-facd-4680-bd48-fe70c5abf498", + "prevId": "6dc00b41-64d7-4cb3-ad90-f9e8e35af643", + "tables": { + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections_games": { + "name": "collections_games", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "collections_games_collection_id_collections_id_fk": { + "name": "collections_games_collection_id_collections_id_fk", + "tableFrom": "collections_games", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "collections_games_game_id_games_id_fk": { + "name": "collections_games_game_id_games_id_fk", + "tableFrom": "collections_games", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "games": { + "name": "games", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "igdb_id": { + "name": "igdb_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ra_id": { + "name": "ra_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path_fs": { + "name": "path_fs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "main_glob": { + "name": "main_glob", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_played": { + "name": "last_played", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform_id": { + "name": "platform_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cover": { + "name": "cover", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version_source": { + "name": "version_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version_system": { + "name": "version_system", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "games_igdb_id_unique": { + "name": "games_igdb_id_unique", + "columns": [ + "igdb_id" + ], + "isUnique": true + }, + "games_ra_id_unique": { + "name": "games_ra_id_unique", + "columns": [ + "ra_id" + ], + "isUnique": true + }, + "games_slug_unique": { + "name": "games_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "games_platform_id_platforms_id_fk": { + "name": "games_platform_id_platforms_id_fk", + "tableFrom": "games", + "tableTo": "platforms", + "columnsFrom": [ + "platform_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "platforms": { + "name": "platforms", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "igdb_id": { + "name": "igdb_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "igdb_slug": { + "name": "igdb_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moby_id": { + "name": "moby_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "es_slug": { + "name": "es_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ra_id": { + "name": "ra_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover": { + "name": "cover", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "platforms_igdb_id_unique": { + "name": "platforms_igdb_id_unique", + "columns": [ + "igdb_id" + ], + "isUnique": true + }, + "platforms_igdb_slug_unique": { + "name": "platforms_igdb_slug_unique", + "columns": [ + "igdb_slug" + ], + "isUnique": true + }, + "platforms_moby_id_unique": { + "name": "platforms_moby_id_unique", + "columns": [ + "moby_id" + ], + "isUnique": true + }, + "platforms_es_slug_unique": { + "name": "platforms_es_slug_unique", + "columns": [ + "es_slug" + ], + "isUnique": true + }, + "platforms_ra_id_unique": { + "name": "platforms_ra_id_unique", + "columns": [ + "ra_id" + ], + "isUnique": true + }, + "platforms_slug_unique": { + "name": "platforms_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "screenshots": { + "name": "screenshots", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "game_id": { + "name": "game_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "screenshots_game_id_games_id_fk": { + "name": "screenshots_game_id_games_id_fk", + "tableFrom": "screenshots", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c181729..0df44e3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1772998956867, "tag": "0001_outstanding_silk_fever", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1776111721964, + "tag": "0002_flowery_rocket_raccoon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 3d5f135..79ef69a 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", "@jimp/wasm-webp": "^1.6.0", + "@phalcode/ts-igdb-client": "^1.0.26", "cheerio": "^1.2.0", "conf": "^15.0.2", "drizzle-orm": "^0.45.1", @@ -65,6 +66,7 @@ "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", "open": "^11.0.0", + "p-queue": "^9.1.2", "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", @@ -72,7 +74,6 @@ "tapable": "^2.3.0", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", - "ts-igdb-client": "^0.4.2", "unzip-stream": "^0.3.4", "webview-bun": "^2.4.0", "zod": "^4.3.6" @@ -98,8 +99,10 @@ "@types/fs-extra": "^11.0.4", "@types/howler": "^2.2.12", "@types/ini": "^4.1.1", + "@types/json-schema": "^7.0.15", "@types/mustache": "^4.2.6", "@types/node-7z": "^2.1.11", + "@types/rclone.js": "^0.6.3", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", diff --git a/scripts/dev.ts b/scripts/dev.ts index b7c07f5..b8d3f6d 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -20,7 +20,7 @@ function spawnServer () HEADLESS: "true", }, stdout: "pipe", - stderr: "inherit", + stderr: "pipe", stdin: "pipe", signal: abortController.signal, killSignal: 'SIGUSR1', @@ -40,6 +40,11 @@ function spawnServer () console.log(e); } }); + const rle = createInterface({ input: Readable.fromWeb(s.stderr as any) }); + rle.on('line', e => + { + console.error(e); + }); return s; } diff --git a/scripts/drizzle/es-de/0000_sparkling_banshee.sql b/scripts/drizzle/es-de/0000_sparkling_banshee.sql new file mode 100644 index 0000000..c86dd0e --- /dev/null +++ b/scripts/drizzle/es-de/0000_sparkling_banshee.sql @@ -0,0 +1,34 @@ +CREATE TABLE `commands` ( + `system` text, + `label` text, + `command` text NOT NULL, + FOREIGN KEY (`system`) REFERENCES `systems`(`name`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `emulators` ( + `name` text PRIMARY KEY NOT NULL, + `fullname` text, + `systempath` text DEFAULT (json_array()) NOT NULL, + `staticpath` text DEFAULT (json_array()) NOT NULL, + `corepath` text DEFAULT (json_array()) NOT NULL, + `androidpackage` text DEFAULT (json_array()) NOT NULL, + `winregistrypath` text DEFAULT (json_array()) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `emulators_name_unique` ON `emulators` (`name`);--> statement-breakpoint +CREATE TABLE `systemMappings` ( + `source` text, + `sourceSlug` text, + `sourceId` integer, + `system` text NOT NULL, + FOREIGN KEY (`system`) REFERENCES `systems`(`name`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `systems` ( + `name` text PRIMARY KEY NOT NULL, + `fullname` text, + `path` text, + `extension` text DEFAULT (json_array()) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `systems_name_unique` ON `systems` (`name`); \ No newline at end of file diff --git a/scripts/drizzle/es-de/meta/0000_snapshot.json b/scripts/drizzle/es-de/meta/0000_snapshot.json new file mode 100644 index 0000000..5c40c18 --- /dev/null +++ b/scripts/drizzle/es-de/meta/0000_snapshot.json @@ -0,0 +1,234 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b4ee710f-eaa5-4bbb-9e69-13d490c7142c", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "commands": { + "name": "commands", + "columns": { + "system": { + "name": "system", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "commands_system_systems_name_fk": { + "name": "commands_system_systems_name_fk", + "tableFrom": "commands", + "tableTo": "systems", + "columnsFrom": [ + "system" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "emulators": { + "name": "emulators", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fullname": { + "name": "fullname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "systempath": { + "name": "systempath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "staticpath": { + "name": "staticpath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "corepath": { + "name": "corepath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "androidpackage": { + "name": "androidpackage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "winregistrypath": { + "name": "winregistrypath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + } + }, + "indexes": { + "emulators_name_unique": { + "name": "emulators_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "systemMappings": { + "name": "systemMappings", + "columns": { + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceSlug": { + "name": "sourceSlug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceId": { + "name": "sourceId", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "system": { + "name": "system", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "systemMappings_system_systems_name_fk": { + "name": "systemMappings_system_systems_name_fk", + "tableFrom": "systemMappings", + "tableTo": "systems", + "columnsFrom": [ + "system" + ], + "columnsTo": [ + "name" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "systems": { + "name": "systems", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fullname": { + "name": "fullname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extension": { + "name": "extension", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + } + }, + "indexes": { + "systems_name_unique": { + "name": "systems_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/scripts/drizzle/es-de/meta/_journal.json b/scripts/drizzle/es-de/meta/_journal.json new file mode 100644 index 0000000..65dbc00 --- /dev/null +++ b/scripts/drizzle/es-de/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1776039605377, + "tag": "0000_sparkling_banshee", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/scripts/generate-es-de-mapping.ts b/scripts/generate-es-de-mapping.ts index 115c19f..8e8fa74 100644 --- a/scripts/generate-es-de-mapping.ts +++ b/scripts/generate-es-de-mapping.ts @@ -96,12 +96,18 @@ await Promise.all(platforms.map(async ([platform, arch]) => }); const rommMapping = rommPlatforms.data?.find(p => - p.slug === (customMappings as any)[name] || - p.slug === name || - p.igdb_slug === name || - p.hltb_slug === name || - p.moby_slug === name || - p.display_name === fullname + { + const custom = (customMappings as any)[name]; + if (Array.isArray(custom) && custom.some(m => m === p.slug)) + { + return true; + } + + return p.slug === custom || + p.slug === name || + p.igdb_slug === name || + p.display_name === fullname; + } ); const mappings: { diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index 350607e..e4401bb 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -18,13 +18,13 @@ import EventEmitter from "node:events"; import { appPath } from "../utils"; import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; import { ensureDir } from "fs-extra"; -import UpdateStoreJob from "./jobs/update-store"; import { getStoreFolder } from "./store/services/gamesService"; import { PluginManager } from "./plugins/plugin-manager"; import registerPlugins from "./plugins/register-plugins"; import controls from './controls/controls'; import { RunAPIServer } from "./rpc"; import { RunBunServer } from "../server"; +import ReloadPluginsJob from "./jobs/reload-plugins-job"; export let config: Conf; export let customEmulators: Conf>; @@ -72,7 +72,6 @@ export async function load () console.log("Config Path Located At: ", config.path); console.log("Custom Emulator Paths Located At: ", customEmulators.path); console.log("App Directory is ", process.env.APPDIR); - console.log("Store Directory is ", getStoreFolder()); cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite'); fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json')); @@ -84,14 +83,14 @@ export async function load () emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema }); await reloadDatabase(); plugins = new PluginManager(); - await registerPlugins(plugins); api = await RunAPIServer(); + await registerPlugins(plugins); + taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); controlsHandle = await controls(); if (!process.env.PUBLIC_ACCESS) bunServer = await RunBunServer(); config.onDidChange('downloadPath', () => reloadDatabase()); config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v })); - taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); } export async function cleanup () @@ -120,6 +119,7 @@ export async function reloadDatabase () db = drizzle(sqlite, { schema }); cache = drizzle(cacheSqlite, { schema: cacheSchema }); migrate(db!, { migrationsFolder: appPath("./drizzle") }); + sqlite.run("PRAGMA foreign_keys = ON;"); await cache.run(` CREATE TABLE IF NOT EXISTS item_cache ( key TEXT PRIMARY KEY, diff --git a/src/bun/api/emulatorjs/emulatorjs.ts b/src/bun/api/emulatorjs/emulatorjs.ts index 247ce6a..9e81b46 100644 --- a/src/bun/api/emulatorjs/emulatorjs.ts +++ b/src/bun/api/emulatorjs/emulatorjs.ts @@ -70,13 +70,13 @@ export default new Elysia({ prefix: '/emulatorjs' }) const localGame = await getLocalGame(source, id); if (!localGame) return status("Not Found"); - const changedSaveFiles: SaveFileChange[] = []; + const changedSaveFiles: Record = {}; if (save) { const savesPath = path.join(config.get('downloadPath'), 'saves', "EMULATORJS"); const saveFile = path.join(savesPath, save.name); await Bun.write(saveFile, save); - changedSaveFiles.push({ subPath: save.name, cwd: savesPath }); + changedSaveFiles.gameflow = { subPath: save.name, cwd: savesPath, shared: false }; events.emit('notification', { message: "Save Backed Up", type: "success", icon: "save" }); } await updateLocalLastPlayed(localGame.id); @@ -85,7 +85,7 @@ export default new Elysia({ prefix: '/emulatorjs' }) id, saveFolderPath: path.join(config.get('downloadPath'), "saves", "EMULATORJS"), gameInfo: { platformSlug: localGame?.platform.slug }, - changedSaveFiles: changedSaveFiles, + changedSaveFiles: [], validChangedSaveFiles: changedSaveFiles, command: { id: "EMULATORJS", diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 8e489cf..b7abb35 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -1,26 +1,26 @@ import Elysia, { status } from "elysia"; import { config, db, emulatorsDb, plugins, taskQueue } from "../app"; -import { and, eq, getTableColumns, ilike, inArray, like, sql } from "drizzle-orm"; +import { and, desc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm"; import z from "zod"; import * as schema from "@schema/app"; import fs from "node:fs/promises"; import { GameListFilterSchema, SERVER_URL } from "@shared/constants"; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; -import { convertLocalToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; -import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, validateGameSource } from "./services/statusService"; +import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; +import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService"; import { errorToResponse } from "elysia/adapter/bun/handler"; -import { getEmulatorsForSystem, getRomFilePaths, launchCommand } from "./services/launchGameService"; +import { launchCommand } from "./services/launchGameService"; import { getErrorMessage, SeededRandom } from "@/bun/utils"; import { defaultFormats, defaultPlugins } from 'jimp'; import { createJimp } from "@jimp/core"; import webp from "@jimp/wasm-webp"; import * as emulatorSchema from '@schema/emulators'; -import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService"; -import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService"; +import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService"; import { host } from "@/bun/utils/host"; import { LaunchGameJob } from "../jobs/launch-game-job"; import { cores } from "../emulatorjs/emulatorjs"; +import { findEmulatorPluginIntegration } from "../store/services/emulatorsService"; // A custom jimp that supports webp const Jimp = createJimp({ @@ -58,8 +58,15 @@ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, if (typeof img === 'string') { - const rommFetch = await fetch(img); - return rommFetch; + const res = await fetch(img); + + return new Response(res.body, { + status: res.status, + headers: { + "Content-Type": res.headers.get("Content-Type") ?? "image/jpeg", + "Cache-Control": "public, max-age=86400", + }, + }); } return img; @@ -135,190 +142,144 @@ export default new Elysia() .get('/games', async ({ query, set }) => { const games: FrontEndGameType[] = []; - const filterSets: FrontEndFilterSets = { - age_ratings: new Set(), - player_counts: new Set(), - languages: new Set(), - companies: new Set(), - genres: new Set() - }; - if (query.source === 'store') + const where: any[] = []; + let localGamesSet: Set | undefined; + + if (query.platform_slug) { - const shuffledGames = await getShuffledStoreGames(); - set.headers['x-max-items'] = shuffledGames.length; - const storeGames = await Promise.all(shuffledGames.filter(g => + where.push(eq(schema.platforms.slug, query.platform_slug)); + } else if (query.platform_id && query.platform_source === 'local') + { + where.push(eq(schema.platforms.id, query.platform_id)); + } + else if (query.platform_id && query.platform_source) + { + const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: query.platform_id ? String(query.platform_id) : undefined }); + if (platform) { - if (query.search) - return path.basename(g.path).toLocaleLowerCase().includes(query.search.toLocaleLowerCase()); - return true; - }) - .slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length)) - .map(async (e) => + where.push(eq(schema.platforms.slug, platform?.slug)); + } + } + + if (query.search) + { + where.push(like(schema.games.name, query.search)); + } + + if (query.source) + { + where.push(eq(schema.games.source, query.source)); + } + + const ordering: any[] = []; + + if (query.orderBy) + { + switch (query.orderBy) + { + case 'added': + ordering.push(desc(schema.games.created_at)); + break; + case 'activity': + ordering.push(sql`MAX(COALESCE(${schema.games.created_at}, '1970-01-01'), COALESCE(${schema.games.last_played}, '1970-01-01')) DESC`); + break; + case 'name': + ordering.push(desc(schema.games.name)); + break; + case "release": + ordering.push(sql`json_extract(${schema.games.metadata}, '$.first_release_date') DESC NULLS LAST`); + break; + } + } + + const localGames = await db.select({ + ...getTableColumns(schema.games), + platform: schema.platforms, + screenshotIds: sql`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]), + }) + .from(schema.games) + .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) + .leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)) + .groupBy(schema.games.id) + .orderBy(...ordering) + .where(and(...where)); + + localGamesSet = new Set( + localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`) + .concat(localGames.filter(g => !!g.igdb_id).map(g => `igdb@${g.igdb_id}`)) + ); + + function localGameExistsPredicate (game: { id: FrontEndId, igdb_id?: number | null, ra_id?: number | null; }) + { + if (localGamesSet?.has(`${game.id.source}@${game.id.id}`)) return true; + if (game.igdb_id && localGamesSet?.has(`igdb@${game.igdb_id}`)) return true; + if (game.ra_id && localGamesSet?.has(`ra@${game.ra_id}`)) return true; + return false; + } + + if (query.collection_id) + { + // Collections are just a remote thing for now. + const remoteGames: FrontEndGameTypeWithIds[] = []; + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); + games.push(...remoteGames.map(g => + { + if (localGameExistsPredicate(g)) { - const system = path.dirname(e.path); - const id = path.basename(e.path, path.extname(e.path)); + return convertLocalToFrontend(localGames.find(g => localGameExistsPredicate({ id: { id: g.source_id ?? '', source: g.source ?? '' }, igdb_id: g.igdb_id, ra_id: g.ra_id }))!); + } + else + { + return g; + } + })); - const localGame = await db.select({ - ...getTableColumns(schema.games), - platform: schema.platforms, - screenshotIds: sql`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]), - }) - .from(schema.games) - .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) - .leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)) - .groupBy(schema.games.id) - .where(and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`))); - - if (localGame.length > 0) return convertLocalToFrontend(localGame[0]); - - const storeGame = await getStoreGameFromPath(e.path); - - return convertStoreToFrontend(system, id, storeGame); - })); - games.push(...storeGames.filter(g => g !== undefined)); } else { - const where: any[] = []; - let localGamesSet: Set | undefined; - - if (query.platform_slug) + games.push(...localGames.slice(query.offset, query.limit !== undefined ? ((query.offset ?? 0) + query.limit) : undefined).filter(g => { - where.push(eq(schema.platforms.slug, query.platform_slug)); - } else if (query.platform_id && query.platform_source === 'local') - { - where.push(eq(schema.platforms.id, query.platform_id)); - } - else if (query.platform_id && query.platform_source) - { - const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: String(query.platform_id) }); - if (platform) + if (query.genres && query.genres.length > 0) { - where.push(eq(schema.platforms.slug, platform?.slug)); + if (!g.metadata) return false; + if (!g.metadata.genres) return false; + if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false; } - } - if (query.search) + return true; + }).map(g => { - where.push(like(schema.games.name, query.search)); - } + return convertLocalToFrontend(g); + })); - if (query.source) + if (query.localOnly !== true) { - where.push(eq(schema.games.source, query.source)); - } - - const localGames = await db.select({ - ...getTableColumns(schema.games), - platform: schema.platforms, - screenshotIds: sql`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]), - }) - .from(schema.games) - .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) - .leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)) - .groupBy(schema.games.id) - .where(and(...where)); - - localGamesSet = new Set( - localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`) - .concat(localGames.filter(g => !!g.igdb_id).map(g => `igdb@${g.igdb_id}`)) - ); - - function localGameExistsPredicate (game: { id: FrontEndId, igdb_id?: number | null, ra_id?: number | null; }) - { - if (localGamesSet?.has(`${game.id.source}@${game.id.id}`)) return true; - if (game.igdb_id && localGamesSet?.has(`igdb@${game.igdb_id}`)) return true; - if (game.ra_id && localGamesSet?.has(`ra@${game.ra_id}`)) return true; - return false; - } - - if (query.collection_id) - { - // Collections are just a remote thing for now. const remoteGames: FrontEndGameTypeWithIds[] = []; - await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e)); - games.push(...remoteGames.map(g => + const remoteGameSet = new Set(); + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); + games.push(...remoteGames.filter(g => { if (localGameExistsPredicate(g)) { - return convertLocalToFrontend(localGames.find(g => localGameExistsPredicate({ id: { id: g.source_id ?? '', source: g.source ?? '' }, igdb_id: g.igdb_id, ra_id: g.ra_id }))!); + return false; } - else - { - return g; - } - })); - } else - { - games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).filter(g => - { - if (query.genres && query.genres.length > 0) + if (g.igdb_id) { - if (!g.metadata) return false; - if (!g.metadata.genres) return false; - if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false; + const igdbId = `igdb@${g.igdb_id}`; + if (remoteGameSet.has(igdbId)) return false; + remoteGameSet.add(igdbId); + } + + if (g.ra_id) + { + const raId = `ra@${g.ra_id}`; + if (remoteGameSet.has(raId)) return false; + remoteGameSet.add(raId); } return true; - }).map(g => - { - return convertLocalToFrontend(g); })); - - if (query.localOnly !== true) - { - const remoteGames: FrontEndGameTypeWithIds[] = []; - const remoteGameSet = new Set(); - await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e)); - games.push(...remoteGames.filter(g => - { - if (localGameExistsPredicate(g)) - { - return false; - } - - if (g.igdb_id) - { - const igdbId = `igdb@${g.igdb_id}`; - if (remoteGameSet.has(igdbId)) return false; - remoteGameSet.add(igdbId); - } - - if (g.ra_id) - { - const raId = `ra@${g.ra_id}`; - if (remoteGameSet.has(raId)) return false; - remoteGameSet.add(raId); - } - - return true; - })); - } else - { - await plugins.hooks.games.fetchFilters.promise({ filters: filterSets }).catch(e => console.error(e)); - } - - localGames.map(g => - { - const metadata: any = g.metadata; - if (metadata.genres && Array.isArray(metadata.genres)) - { - metadata.genres.forEach((g: string) => filterSets.genres.add(g)); - } - if (metadata.age_ratings && Array.isArray(metadata.age_ratings)) - { - metadata.age_ratings.forEach((g: string) => filterSets.age_ratings.add(g)); - } - if (metadata.companies && Array.isArray(metadata.companies)) - { - metadata.companies.forEach((g: string) => filterSets.companies.add(g)); - } - if (metadata.player_count) - { - filterSets.player_counts.add(metadata.player_count); - } - }); } } @@ -342,7 +303,37 @@ export default new Elysia() } - const filterLists: FrontEndFilterLists = { + return { games }; + }, { + query: GameListFilterSchema, + }) + .get('/games/filters', async ({ query: { source } }) => + { + const filterSets: FrontEndFilterSets = { + age_ratings: new Set(), + player_counts: new Set(), + languages: new Set(), + companies: new Set(), + genres: new Set() + }; + + let filter: any = undefined; + if (source) filter = eq(schema.games.source, source); + const local_metadata = await db.query.games.findMany({ columns: { metadata: true }, where: filter }); + + local_metadata.forEach(game => + { + game.metadata.age_ratings?.forEach(r => filterSets.age_ratings.add(r)); + game.metadata.genres?.forEach(r => filterSets.genres.add(r)); + game.metadata.companies?.forEach(r => filterSets.companies.add(r)); + + if (game.metadata.player_count) + filterSets.player_counts.add(game.metadata.player_count); + }); + + await plugins.hooks.games.fetchFilters.promise({ filters: filterSets, source }); + + const filters: FrontEndFilterLists = { age_ratings: Array.from(filterSets.age_ratings), player_counts: Array.from(filterSets.player_counts), languages: Array.from(filterSets.languages), @@ -350,34 +341,21 @@ export default new Elysia() genres: Array.from(filterSets.genres) }; - return { games, filters: filterLists }; + return filters; }, { - query: GameListFilterSchema, + query: z.object({ source: z.string().optional() }) }) .get('/rom/:source/:id', async ({ params: { id, source } }) => { - const localGame = await db.query.games.findFirst({ - where: getLocalGameMatch(id, source), - columns: { path_fs: true }, - with: { platform: { columns: { es_slug: true } } } - }); + const filePaths = await plugins.hooks.games.fetchRomFiles.promise({ source, id }); - if (!localGame?.path_fs) + if (!filePaths || filePaths.length <= 0) { - return status("Not Found"); + return status("Not Found", "No Valid Roms Found"); } - const downloadPath = config.get('downloadPath'); - const path_fs = path.join(downloadPath, localGame.path_fs); + return Bun.file(filePaths[0]); - const filesPaths = await getRomFilePaths(path_fs, localGame.platform.es_slug ?? undefined); - - if (filesPaths.length <= 0) - { - throw new Error("No Valid Roms Found"); - } - - return Bun.file(filesPaths[0]); }, { params: z.object({ source: z.string(), id: z.string() }) }) @@ -392,17 +370,12 @@ export default new Elysia() const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) }); if (systemMapping) { - const emulatorNames = await getEmulatorsForSystem(systemMapping.system); - const emulators = await Promise.all(emulatorNames.map(n => getStoreEmulatorPackage(n).then(e => ({ name: n, data: e })))); + const emulatorNames: string[] = []; + await plugins.hooks.emulators.findEmulatorForSystem.promise({ system: systemMapping.system, emulators: emulatorNames }); - sourceData.emulators = await Promise.all(emulators.map(async ({ name, data }) => + sourceData.emulators = (await Promise.all(emulatorNames.map(async name => { - if (data) - { - const systems = await buildStoreFrontendEmulatorSystems(data); - return { ...await convertStoreEmulatorToFrontend(data, 0, systems), store_exists: true }; - } - else if (name === 'EMULATORJS') + if (name === 'EMULATORJS') { return { name: 'EMULATORJS', @@ -424,22 +397,34 @@ export default new Elysia() return system; })), gameCount: 0, - integrations: [] - } satisfies FrontEndGameTypeDetailedEmulator; - } - else - { - return { - name: name, - logo: "", - systems: [], - gameCount: 0, - validSources: [], + source: 'local', integrations: [] } satisfies FrontEndGameTypeDetailedEmulator; } - })); + const foundEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: name }); + + const execPaths: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: name, sources: execPaths }); + const integrations = findEmulatorPluginIntegration(id, execPaths); + + if (foundEmulator) + { + foundEmulator.validSources = execPaths; + foundEmulator.integrations = integrations; + return foundEmulator; + } + + return { + name: name, + logo: "", + source: 'local', + systems: [], + gameCount: 0, + validSources: execPaths, + integrations: integrations + } satisfies FrontEndGameTypeDetailedEmulator; + }))).filter(e => !!e); } } @@ -466,17 +451,18 @@ export default new Elysia() }, { params: z.object({ id: z.string(), source: z.string() }), }) - .post('/game/:source/:id/install', async ({ params: { id, source } }) => + .post('/game/:source/:id/install', async ({ params: { id, source }, query: { downloadId } }) => { if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob)) { - return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source)); + return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, { downloadId })); } else { return status('Not Implemented'); } }, { params: z.object({ id: z.string(), source: z.string() }), + query: z.object({ downloadId: z.string().optional() }), response: z.any() }) .delete('/game/:source/:id/install', async ({ params: { id, source } }) => @@ -501,6 +487,10 @@ export default new Elysia() { return fixSource(source, id); }) + .post('/game/:source/:id/update', async ({ params: { id, source } }) => + { + return update(source, id); + }) .post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) => { const validCommands = await getValidLaunchCommandsForGame(source, id); @@ -559,8 +549,6 @@ export default new Elysia() const emulator = await getStoreEmulatorPackage(id); if (!emulator) return status("Not Found"); const systems = await buildStoreFrontendEmulatorSystems(emulator); - const systemsIdSet = new Set(systems.map(s => s.id)); - const games: FrontEndGameType[] = []; @@ -587,28 +575,6 @@ export default new Elysia() await plugins.hooks.games.fetchRecommendedGamesForEmulator.promise({ emulator, systems, games: remoteGames }); games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`))); - const gamesManifest = await getStoreGameManifest(); - const storeGames = await Promise.all(gamesManifest - .filter(g => systemsIdSet.has(path.dirname(g.path))) - .map(async (e) => - { - const system = path.dirname(e.path); - const id = path.basename(e.path, path.extname(e.path)); - - const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) }); - - if (localGame) - { - return undefined; - } - - const storeGame = await getStoreGameFromPath(e.path); - - return convertStoreToFrontend(system, id, storeGame); - })); - - games.push(...storeGames.filter(g => g !== undefined).slice(0, 3)); - return games; }) .get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) => @@ -619,7 +585,7 @@ export default new Elysia() const sourceCompaniesSet = new Set(sourceData.metadata.companies); const sourceGenresSet = new Set(sourceData.metadata.genres); - const esSystem = sourceData.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug)), columns: { system: true } }) : undefined; + const games: (FrontEndGameType & { metadata?: any; })[] = []; @@ -632,35 +598,7 @@ export default new Elysia() games.push(...localGames.map(g => convertLocalToFrontend(g))); - const shuffledGames = await getShuffledStoreGames(); - const storeGames = await Promise.all(shuffledGames - .filter(g => - { - const system = path.dirname(g.path); - const id = path.basename(g.path, path.extname(g.path)); - if (localGamesSourceSet.has(`store@${system}@${id}`)) - return false; - - if (esSystem) - { - if (path.dirname(g.path) === esSystem.system) return true; - } - - return false; - }) - .map(async (e) => - { - const system = path.dirname(e.path); - const id = path.basename(e.path, path.extname(e.path)); - const storeGame = await getStoreGameFromPath(e.path); - return convertStoreToFrontend(system, id, storeGame); - })); - - if (storeGames) - { - games.push(...storeGames.slice(0, 3)); - } const remoteGames: (FrontEndGameType & { metadata?: any; })[] = []; plugins.hooks.games.fetchRecommendedGamesForGame.promise({ diff --git a/src/bun/api/games/platforms.ts b/src/bun/api/games/platforms.ts index a33e155..22161ae 100644 --- a/src/bun/api/games/platforms.ts +++ b/src/bun/api/games/platforms.ts @@ -1,6 +1,6 @@ import Elysia, { status } from "elysia"; import z from "zod"; -import { and, count, eq, getTableColumns, not } from "drizzle-orm"; +import { and, count, eq, getTableColumns, not, notExists } from "drizzle-orm"; import { db, plugins } from "../app"; import * as schema from "@schema/app"; @@ -93,7 +93,8 @@ export default new Elysia() if (!remotePlatform) return status("Not Found"); return remotePlatform; } - }, { params: z.object({ source: z.string(), id: z.string() }) }).get('/platform/local/:id/cover', async ({ params: { id }, set }) => + }, { params: z.object({ source: z.string(), id: z.string() }) }) + .get('/platform/local/:id/cover', async ({ params: { id }, set }) => { set.headers["cross-origin-resource-policy"] = 'cross-origin'; @@ -112,4 +113,35 @@ export default new Elysia() set.headers["content-type"] = coverBlob.cover_type; } return status(200, coverBlob.cover); - }, { response: { 200: z.instanceof(Buffer), 404: z.any() }, params: z.object({ id: z.coerce.number() }) }); \ No newline at end of file + }, { response: { 200: z.instanceof(Buffer), 404: z.any() }, params: z.object({ id: z.coerce.number() }) }) + .post('/platform/local/:id/update', async ({ params: { id } }) => + { + const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, Number(id)) }); + if (!localPlatform) return status("Not Found"); + + const platformLookup = await plugins.hooks.games.platformLookup.promise({ + slug: localPlatform.slug + }); + let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${localPlatform.slug}.svg`); + if (!platformCover.ok && platformLookup?.url_logo) + { + platformCover = await fetch(platformLookup.url_logo); + } + + await db.update(schema.platforms).set({ + name: platformLookup?.name, + cover: Buffer.from(await platformCover.arrayBuffer()), + cover_type: platformCover.headers.get('content-type'), + }).where(eq(schema.platforms.id, localPlatform.id)); + }) + .delete('/platform/local/:id', async ({ params: { id } }) => + { + const deleted = await db.delete(schema.platforms).where(and(eq(schema.platforms.id, Number(id)), + notExists( + db + .select() + .from(schema.games) + .where(eq(schema.games.platform_id, Number(id))) + ))).returning(); + if (deleted.length <= 0) return status("Not Found"); + }); \ No newline at end of file diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index 4dde81b..1a0dddc 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -1,19 +1,12 @@ import path from 'node:path'; -import { Glob, which } from 'bun'; +import { Glob } from 'bun'; import fs from 'node:fs/promises'; -import { existsSync, readFileSync } from 'node:fs'; -import * as schema from '@schema/emulators'; -import { eq } from 'drizzle-orm'; -import { config, customEmulators, emulatorsDb, taskQueue } from '../../app'; -import os from 'node:os'; -import { cores } from '../../emulatorjs/emulatorjs'; +import { existsSync } from 'node:fs'; +import { config, taskQueue } from '../../app'; import { LaunchGameJob } from '../../jobs/launch-game-job'; import { getStoreEmulatorPackage } from '../../store/services/gamesService'; import { getOrCachedScoopPackage } from '../../store/services/emulatorsService'; -export const varRegex = /%([^%]+)%/g; -export const assignRegex = /(%\w+%)=(\S+) /g; - export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string) { if (taskQueue.hasActiveOfType(LaunchGameJob)) @@ -24,285 +17,6 @@ export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId)); } -/** - * Get the emulators related to the given system - * @param systemSlug the ES-DE slug for the system - */ -export async function getEmulatorsForSystem (systemSlug: string) -{ - const system = await emulatorsDb.query.systems.findFirst({ - with: { commands: true }, - where: eq(schema.systems.name, systemSlug) - }); - - if (!system) - { - throw new Error(`Could not find system '${systemSlug}'`); - } - - const emulators = new Set(); - await Promise.all(system.commands.map(async (command, index) => - { - let cmd = command.command; - - const matches = Array.from(cmd.matchAll(varRegex)); - matches.forEach(([value]) => - { - if (value.startsWith("%EMULATOR_")) - { - const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); - emulators.add(emulatorName); - return; - } - }); - })); - - - - if (cores[systemSlug]) - { - emulators.add('EMULATORJS'); - } - - return Array.from(emulators); -} - -export async function getRomFilePaths (gamePath: string, systemSlug?: string) -{ - if (!existsSync(gamePath)) - { - throw new Error(`Provided rom path is missing: '${gamePath}'`); - } - - const gamePathStat = await fs.stat(gamePath); - const validFiles: string[] = []; - - if (gamePathStat.isDirectory()) - { - if (!systemSlug) throw new Error("Needs system to find valid file"); - - const system = await emulatorsDb.query.systems.findFirst({ - with: { commands: true }, - where: eq(schema.systems.name, systemSlug) - }); - - if (!system) - { - throw new Error(`Could not find system '${systemSlug}'`); - } - - const extensionList = system.extension.join(','); - - for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`))) - { - validFiles.push(file); - } - - if (validFiles.length <= 0) - { - throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`); - } - } else if (systemSlug) - { - const system = await emulatorsDb.query.systems.findFirst({ - with: { commands: true }, - where: eq(schema.systems.name, systemSlug) - }); - - if (!system) - { - throw new Error(`Could not find system '${systemSlug}'`); - } - - if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase()))) - { - validFiles.push(gamePath); - } - else - { - const extensionList = system.extension.join(','); - throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`); - } - } else - { - validFiles.push(gamePath); - } - - return validFiles; -} - -/** - * - * @param data Uses es-de system slug - * @returns - */ -export async function getValidLaunchCommands (data: { - systemSlug: string; - gamePath: string; -}): Promise -{ - - const system = await emulatorsDb.query.systems.findFirst({ - with: { commands: true }, - where: eq(schema.systems.name, data.systemSlug) - }); - - if (!system) - { - throw new Error(`Could not find system '${data.systemSlug}'`); - } - - if (!system.extension || system.extension.length <= 0) - { - throw new Error(`No extensions listed for system '${data.systemSlug}'`); - } - - const downloadPath = config.get('downloadPath'); - const gamePath = path.join(downloadPath, data.gamePath); - - const validFiles: string[] = await getRomFilePaths(gamePath, data.systemSlug); - - function escapeWindowsArg (arg: string): string - { - if (process.platform === 'win32') - { - return `"${arg - .replace(/(\\*)"/g, '$1$1\\"') // escape quotes - .replace(/(\\*)$/, '$1$1') // escape trailing backslashes - }"`; - } else - { - if (arg.includes(' ')) - { - return `"${arg}"`; - } else - { - return arg; - } - } - } - - const formattedCommands = await Promise.all(system.commands - .filter(c => !c.command.includes(`%ENABLESHORTCUTS%`)) - .map(async (command, index) => - { - const label = command.label; - let cmd = command.command; - - let emulator: string | undefined = undefined; - let rom = validFiles[0]; - - if (cmd.includes('%ESCAPESPECIALS%')) - rom = rom.replace(/[&()^=;,]/g, ''); - - - - const staticVars: Record = { - '%ROM%': escapeWindowsArg(rom), - '%ROMRAW%': validFiles[0], - '%ROMRAWWIN%': escapeWindowsArg(validFiles[0].replaceAll('/', '\\')), - '%ESPATH%': escapeWindowsArg(path.dirname(Bun.main)), - '%ROMPATH%': escapeWindowsArg(gamePath), - '%BASENAME%': escapeWindowsArg(path.basename(validFiles[0], path.extname(validFiles[0]))), - '%FILENAME%': escapeWindowsArg(path.basename(validFiles[0])), - '%ESCAPESPECIALS%': "", - '%HIDEWINDOW%': "" - }; - - cmd = cmd.replace(/\%INJECT\%=(?[\w\%.\/\\]+)/g, (_, injectFile: string) => - { - try - { - const resolvedInjectFile = injectFile.replace(varRegex, (a) => - { - return staticVars[a] ?? a; - }); - if (existsSync(resolvedInjectFile)) - { - const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' }); - return rawContents.split('\n').map(v => v.replace('\r', '')).join(' '); - } - - return ''; - } catch (error) - { - return ''; - } - }); - - const matches = Array.from(cmd.matchAll(varRegex)); - const varList = await Promise.all(matches.map(async ([value]) => - { - if (value.startsWith("%EMULATOR_")) - { - const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); - let execs = await findExecsByName(emulatorName); - let validExec = execs.find(e => e.exists); - - emulator = emulatorName; - return [ - [value, validExec ? validExec.binPath : undefined] as [string, string | undefined], - [`%EMUSOURCE%`, validExec?.type] as [string, string | undefined], - ['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined], - ['%EMUDIRRAW%', validExec?.rootPath ?? (validExec ? path.dirname(validExec.binPath) : undefined)] as [string, string | undefined] - ]; - - } - - const key = value[0].substring(1, value.length - 1); - return [[value, process.env[key]] as [string, string | undefined]]; - })); - - const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars }; - let startDir: string | undefined = undefined; - - if ('%STARTDIR%' in vars) - { - delete vars['%STARTDIR%']; - - cmd = cmd.replace(assignRegex, (match, p1, p2) => - { - if (p1 === '%STARTDIR%') - { - startDir = varRegex.test(p2) ? staticVars[p2] : p2; - } - return ""; - }); - } - - // missing variable - const invalid = Object.entries(vars).find(c => c[1] === undefined); - - const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim(); - - return { - id: index, - label: label ?? undefined, - command: formattedCommand, - startDir, - valid: !invalid, emulator, - emulatorSource: vars['%EMUSOURCE%'] as any, - metadata: { - romPath: validFiles[0], - emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1], - emulatorDir: vars['%EMUDIRRAW%'] - } - } satisfies CommandEntry; - })); - - return formattedCommands.filter(c => !!c); -} - -export async function findExecsByName (emulatorName: string) -{ - const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) }); - if (!emulator) - { - throw new Error(`Could not find emulator ${emulatorName}`); - } - return findExecs(emulatorName, emulator); -} - export async function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): Promise { const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id); @@ -355,112 +69,3 @@ export async function findStoreEmulatorExec (id: string, emulator?: { systempath return undefined; } -export async function findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; }) -{ - const execs: EmulatorSourceEntryType[] = []; - - if (customEmulators.has(id)) - { - execs.push({ binPath: customEmulators.get(id), type: 'custom', exists: await fs.exists(customEmulators.get(id)) }); - } - - if (emulator && emulator.systempath.length > 0) - { - const storePath = await findStoreEmulatorExec(id, emulator); - if (storePath) execs.push(storePath); - } - - if (emulator && os.platform() === 'win32') - { - const regValues = emulator.winregistrypath; - if (regValues.length > 0) - { - for (const node of regValues) - { - const registryValue = await readRegistryValue(node); - if (registryValue) - { - execs.push({ binPath: registryValue, type: 'registry', exists: true }); - } - } - - } - } - - if (emulator && emulator.systempath.length > 0) - { - const systemPath = await resolveSystemPath(emulator.systempath); - if (systemPath) - { - execs.push({ binPath: systemPath, type: 'system', exists: true }); - } - } - - if (emulator && emulator.staticpath.length > 0) - { - const staticPath = await resolveStaticPath(emulator.staticpath); - if (staticPath) - { - execs.push({ binPath: staticPath, type: 'static', exists: true }); - } - } - - return execs; -} - -async function readRegistryValue (text: string) -{ - const params = text.split('|'); - const key = path.dirname(params[0]); - const value = path.basename(params[0]); - const bin = params.length > 1 ? params[1] : undefined; - - const proc = Bun.spawn({ - cmd: ["reg", "QUERY", key, "/v", value], - stdout: "pipe", - stderr: "pipe", - }); - - const output = await new Response(proc.stdout).text(); - await proc.exited; - - if (!output.includes(value)) return null; - - const lines = output.split("\n"); - for (const line of lines) - { - if (line.includes(value)) - { - const parts = line.trim().split(/\s{4,}/); - return bin ? path.join(parts[2], bin) : parts[2]; // registry value - } - } - - return null; -} - -async function resolveStaticPath (entries: string[]) -{ - for (const entry of entries) - { - const resolved = entry.replace("~", os.homedir()); - if (await fs.exists(resolved)) - { - return resolved; - } - } - return null; -} - -async function resolveSystemPath (entries: string[]) -{ - for (const entry of entries) - { - try - { - const found = which(entry); - return found; - } catch { } - } - return null; -} \ No newline at end of file diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 53ec3de..82f69d4 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -1,21 +1,17 @@ -import { RPC_URL, } from "@shared/constants"; -import { config, db, emulatorsDb, plugins, taskQueue } from "../../app"; -import { findExecs, getValidLaunchCommands } from "./launchGameService"; -import * as emulatorSchema from '@schema/emulators'; -import { and, eq } from "drizzle-orm"; +import { config, db, plugins, taskQueue } from "../../app"; +import { eq } from "drizzle-orm"; import { getErrorMessage } from "@/bun/utils"; -import { checkFiles, getLocalGameMatch } from "./utils"; +import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils"; import fs from 'node:fs/promises'; -import { getStoreGameFromId } from "../../store/services/gamesService"; -import { cores } from "../../emulatorjs/emulatorjs"; -import { host } from "@/bun/utils/host"; import Elysia from "elysia"; import z from "zod"; import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { LaunchGameJob } from "../../jobs/launch-game-job"; import * as appSchema from "@schema/app"; +import { RPC_URL } from "@/shared/constants"; +import { host } from "@/bun/utils/host"; -class CommandSearchError extends Error +export class CommandSearchError extends Error { constructor(status: GameStatusType, message: string) { @@ -33,7 +29,8 @@ export async function getLocalGame (source: string, id: string) source: true, source_id: true, igdb_id: true, - ra_id: true + ra_id: true, + main_glob: true }, where: getLocalGameMatch(id, source), with: { @@ -44,6 +41,59 @@ export async function getLocalGame (source: string, id: string) return localGame; } +export async function update (source: string, id: string) +{ + const localGame = await getLocalGame(source, id); + if (!localGame) throw new Error("Could not find Local Game"); + if (!localGame.source || !localGame.source_id) throw new Error("Game has not source defined"); + const sourceGame = await getSourceGameDetailed(localGame.source, localGame.source_id, { sourceOnly: true }); + if (!sourceGame) throw new Error("Could not find source game"); + + await db.transaction(async (tx) => + { + await tx.delete(appSchema.screenshots).where(eq(appSchema.screenshots.game_id, localGame.id)); + + const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)]; + if (paths_screenshots.length <= 0 && sourceGame.igdb_id) + { + const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id) }); + if (igdbLookup) + { + paths_screenshots.push(...igdbLookup.screenshotUrls); + } + } + + // pre-fetch screenshots + const screenshots = await Promise.all(paths_screenshots.map(s => fetch(s))); + + if (screenshots.length > 0) + { + await tx.insert(appSchema.screenshots).values(await Promise.all(screenshots.map(async (response) => + { + const screenshot: typeof appSchema.screenshots.$inferInsert = { + game_id: localGame.id, + content: Buffer.from(await response.arrayBuffer()), + type: response.headers.get('content-type') + }; + + return screenshot; + }))); + } + + await tx.update(appSchema.games).set({ + metadata: { + age_ratings: sourceGame.metadata.age_ratings, + genres: sourceGame.metadata.genres, + player_count: sourceGame.metadata.player_count ?? undefined, + companies: sourceGame.metadata.companies, + game_modes: sourceGame.metadata.game_modes, + average_rating: sourceGame.metadata.average_rating ?? undefined, + first_release_date: sourceGame.metadata.first_release_date?.getTime() ?? undefined, + } + }).where(eq(appSchema.games.id, localGame.id)); + }); +} + export async function fixSource (source: string, id: string) { const valid = await validateGameSource(source, id); @@ -94,12 +144,10 @@ export async function validateGameSource (source: string, id: string): Promise<{ if (!localGame) return { valid: true }; if (localGame.source && localGame.source_id) { - // Store should be immutable - if (localGame.source === 'store') return { valid: true, localGame }; - const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id }); if (!sourceGame) return { valid: false, reason: "Source Missing", localGame }; - if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined)) + // Store should be immutable + if (localGame.source !== 'store' && sourceGame.igdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined)) { return { valid: false, reason: "Metadata Missmatch", localGame }; } @@ -115,79 +163,34 @@ export async function updateLocalLastPlayed (id: number) export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined> { - if (source === 'emulator') - { - const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, id) }); - const allExecs = await findExecs(id, esEmulator); - return { - commands: allExecs.map(exec => ({ - command: exec.binPath, - id: exec.type, - emulator: id, - emulatorSource: exec.type, - metadata: { - emulatorBin: exec.binPath, - emulatorDir: exec.rootPath - }, - valid: true - } satisfies CommandEntry)), - gameId: { source: "emulator", id: id } - }; - } const localGame = await getLocalGame(source, id); if (localGame) { - const rommPlatform = localGame.platform.slug; - const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform), eq(emulatorSchema.systemMappings.source, 'romm')) }); + const commands = await plugins.hooks.games.buildLaunchCommands.promise({ + source: localGame.source, + sourceId: localGame.source_id, + id: { source: 'local', id: String(localGame.id) }, + systemSlug: localGame.platform.slug, + gamePath: localGame.path_fs, + mainGlob: localGame.main_glob, + }); - if (esPlatform) + if (commands instanceof Error || !commands) return commands; + + const validCommand = commands.find(c => c.valid); + if (validCommand) { - if (localGame.path_fs) - { - try - { - const commands = await getValidLaunchCommands({ systemSlug: esPlatform.system, gamePath: localGame.path_fs }); - - if (cores[esPlatform.system]) - { - const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`; - commands.push({ - id: 'EMULATORJS', - label: "Emulator JS", - command: `core=${cores[esPlatform.system]}&gameUrl=${encodeURIComponent(gameUrl)}`, - valid: true, - emulator: 'EMULATORJS', - metadata: { - romPath: gameUrl - } - }); - } - - const validCommand = commands.find(c => c.valid); - if (validCommand) - { - return { commands: commands.filter(c => c.valid), gameId: { id: String(localGame.id), source: 'local' }, source: localGame.source ?? source, sourceId: String(localGame.source_id) ?? id }; - } - else - { - return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`); - } - } catch (error) - { - console.error(error); - return new CommandSearchError('error', getErrorMessage(error)); - } - - } else - { - return new CommandSearchError('error', 'Missing Path'); - } + return { + commands: commands.filter(c => c.valid), + gameId: { id: String(localGame.id), source: 'local' }, + source: localGame.source ?? source, + sourceId: String(localGame.source_id) ?? id, + }; } else { - return new CommandSearchError('error', `Missing Platform ${localGame.platform.slug}`); + return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`); } - } return undefined; @@ -239,6 +242,7 @@ export default function buildStatusResponse () } else { + const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source) }); const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id); if (validCommand) { @@ -255,9 +259,9 @@ export default function buildStatusResponse () }); } - } else if (ws.data.params.source === 'store') + } else if (!localGame && ws.data.params.source === 'store') { - const storeGame = await getStoreGameFromId(ws.data.params.id); + /*const storeGame = await getStoreGame(ws.data.params.id); const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); const size = Number(fileResponse.headers.get('content-length')); const stats = await fs.statfs(config.get('downloadPath')); @@ -268,8 +272,10 @@ export default function buildStatusResponse () } else { ws.send({ status: 'install', details: 'Install' }); - } - } else + }*/ + + ws.send({ status: 'install', details: 'Install' }); + } else if (!localGame) { const files = await plugins.hooks.games.fetchDownloads.promise({ source: ws.data.params.source, @@ -302,8 +308,9 @@ export default function buildStatusResponse () ws.send({ status: 'install', details: 'Install' }); } } - - + } else + { + ws.send({ status: 'error', error: "No Way To Launch" }); } } } diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index 955d1e7..b1b6fc2 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -4,10 +4,10 @@ import path from "node:path"; import { config, db, emulatorsDb, plugins } from "../../app"; import { and, eq } from "drizzle-orm"; import * as schema from "@schema/app"; -import { StoreGameType } from "@shared/constants"; -import * as emulatorSchema from "@schema/emulators"; -import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService"; +import { RPC_URL, StoreGameType } from "@shared/constants"; import { hashFile } from "@/bun/utils"; +import { host } from "@/bun/utils/host"; +import secrets from "../../secrets"; export async function calculateSize (installPath: string | null) { @@ -21,6 +21,11 @@ export async function checkInstalled (installPath: string | null) return fs.exists(path.join(config.get('downloadPath'), installPath)); } +export function getScreenshotLocalGameMatch (id: string, source: string) +{ + return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id)); +} + export function getLocalGameMatch (id: string, source: string) { return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id)); @@ -35,7 +40,7 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & { platform_display_name: g.platform?.name ?? null, id: { id: String(g.id), source: 'local' }, updated_at: g.created_at, - path_cover: `/api/romm/game/local/${g.id}/cover`, + path_covers: [`/api/romm/game/local/${g.id}/cover`], source_id: g.source_id, source: g.source, path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`, @@ -67,7 +72,7 @@ export async function convertLocalToFrontendDetailed (g: typeof schema.games.$in platform_display_name: g.platform?.name ?? "Local", id: { id: String(g.id), source: 'local' }, updated_at: g.created_at, - path_cover: `/api/romm/game/local/${g.id}/cover`, + path_covers: [`/api/romm/game/local/${g.id}/cover`], source_id: g.source_id, source: g.source, path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`, @@ -82,6 +87,11 @@ export async function convertLocalToFrontendDetailed (g: typeof schema.games.$in fs_size_bytes: fileSize, missing: !exists, local: true, + ra_id: g.ra_id, + version: g.version, + version_source: g.version_source, + version_system: g.version_system, + igdb_id: g.igdb_id, metadata: { genres: g.metadata.genres ?? [], companies: g.metadata.companies ?? [], @@ -96,74 +106,6 @@ export async function convertLocalToFrontendDetailed (g: typeof schema.games.$in return game; } -export async function convertStoreToFrontend (system: string, id: string, storeGame: StoreGameType): Promise -{ - const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ - where: and(eq(emulatorSchema.systemMappings.system, system), eq(emulatorSchema.systemMappings.source, 'romm')) - }); - - const platformDef = await emulatorsDb.query.systems.findFirst({ - where: eq(emulatorSchema.systems.name, system), - columns: { fullname: true } - }); - - const gameId = `${system}@${id}`; - - const game: FrontEndGameType = { - platform_display_name: platformDef?.fullname ?? system, - path_platform_cover: `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`, - id: { source: 'store', id: gameId }, - source: null, - source_id: null, - path_fs: null, - path_cover: `/api/romm/image?url=${encodeURIComponent(storeGame.pictures.titlescreens?.[0])}`, - last_played: null, - updated_at: new Date(), - slug: null, - name: storeGame.title, - platform_id: null, - platform_slug: rommSystem?.sourceSlug ?? system, - paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [], - metadata: { - first_release_date: null - } - }; - - return game; -} - -export async function convertStoreToFrontendDetailed (system: string, id: string, storeGame: StoreGameType): Promise -{ - let size: number | null = null; - try - { - const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); - size = Number(fileResponse.headers.get('content-length')); - } catch (error) - { - console.error(error); - } - - const detailed: FrontEndGameTypeDetailed = { - ...await convertStoreToFrontend(system, id, storeGame), - summary: storeGame.description, - fs_size_bytes: size, - missing: false, - local: false, - metadata: { - genres: storeGame.tags, - companies: [], - game_modes: [], - age_ratings: [], - player_count: "", - average_rating: null, - first_release_date: null - } - }; - - return detailed; -} - export async function getLocalGameDetailed (match: any) { const localGame = await db.query.games.findFirst({ @@ -182,7 +124,7 @@ export async function getLocalGameDetailed (match: any) return undefined; } -export async function getSourceGameDetailed (source: string, id: string) +export async function getSourceGameDetailed (source: string, id: string, options?: { sourceOnly?: boolean; }) { if (source === 'local') { @@ -194,30 +136,13 @@ export async function getSourceGameDetailed (source: string, id: string) { const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source)); - if (source === 'store') + const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame }); + if (localGame && options?.sourceOnly !== true) { - const gameId = extractStoreGameSourceId(id); - const storeGame = await getStoreGame(gameId.system, gameId.id); - if (!storeGame) return undefined; - const storeFrontendGame = await convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame); - if (localGame) - { - return { ...storeFrontendGame, ...localGame }; - } - return storeFrontendGame; - } else - { - const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame }); - if (remoteGame) - { - return remoteGame; - } else if (localGame) - { - return localGame; - } + return localGame; } - return undefined; + return remoteGame; } } diff --git a/src/bun/api/hooks/app.ts b/src/bun/api/hooks/app.ts index bf592a0..a1f0eec 100644 --- a/src/bun/api/hooks/app.ts +++ b/src/bun/api/hooks/app.ts @@ -1,10 +1,12 @@ import { AuthHooks } from "./auth"; import { EmulatorHooks } from "./emulators"; import { GameHooks } from "./games"; +import { StoreHooks } from "./store"; export class GameflowHooks { games = new GameHooks(); emulators = new EmulatorHooks(); auth = new AuthHooks(); + store = new StoreHooks(); } \ No newline at end of file diff --git a/src/bun/api/hooks/emulators.ts b/src/bun/api/hooks/emulators.ts index 6740b30..4ac51e7 100644 --- a/src/bun/api/hooks/emulators.ts +++ b/src/bun/api/hooks/emulators.ts @@ -22,6 +22,8 @@ export class EmulatorHooks * Triggered when emulator is downloaded or updated */ emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContext], { emulator: string; }>(['ctx']); + findEmulatorSource = new AsyncSeriesHook<[ctx: { emulator: string; sources: EmulatorSourceEntryType[]; }]>(['ctx']); + findEmulatorForSystem = new AsyncSeriesHook<[ctx: { system: string; emulators: string[]; }]>(['ctx']); constructor() { diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index f1f4a6a..f4ae463 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -3,6 +3,14 @@ import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfal export class GameHooks { + buildLaunchCommands = new AsyncSeriesBailHook<[ctx: { + source: string | null; + sourceId: string | null; + id: FrontEndId; + systemSlug: string; + gamePath: string | null, + mainGlob?: string | null, + }], CommandEntry[] | Error | undefined>(['ctx']); /** override the launch command for an emulator * @param ctx.autoValidCommands The auto generated command for example based on the ES-DE listing * @param ctx.emulator The emulator ID if any @@ -20,7 +28,7 @@ export class GameHooks id: FrontEndId; platformSlug?: string; }; - }], { args: string[], savesPath?: string; } | undefined, { emulator: string; }>(['ctx']); + }], { args: string[], savesPath?: SaveSlots; env?: Record; } | undefined, { emulator: string; }>(['ctx']); /** * Is the given emulator for the given command supported * @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects. @@ -37,9 +45,9 @@ export class GameHooks fetchGames = new AsyncSeriesHook<[ctx: { query: GameListFilterType; games: FrontEndGameTypeWithIds[]; - filters: FrontEndFilterSets; }]>(['ctx']); fetchFilters = new AsyncSeriesHook<[ctx: { + source?: string; filters: FrontEndFilterSets; }]>(['ctx']); fetchGame = new AsyncSeriesBailHook<[ctx: { @@ -58,7 +66,12 @@ export class GameHooks fetchDownloads = new AsyncSeriesBailHook<[ctx: { source: string; id: string; + downloadId?: string; }], DownloadInfo | undefined>(['ctx']); + fetchRomFiles = new AsyncSeriesBailHook<[ctx: { + source: string; + id: string; + }], string[] | undefined>(['ctx']); fetchRecommendedGamesForGame = new AsyncSeriesHook<[ctx: { game: FrontEndGameTypeDetailed, games: (FrontEndGameType & { metadata?: any; })[]; @@ -73,28 +86,39 @@ export class GameHooks id: string; }], FrontEndPlatformType | undefined>(['ctx']); platformLookup = new AsyncSeriesBailHook<[ctx: { - source: string; - id: string; - }], { slug: string; } | undefined>(['ctx']); + source?: string; + id?: string; + slug?: string; + }], { + slug: string; + url_logo?: string | null; + name?: string; + family_name?: string; + } | undefined>(['ctx']); + gameLookup = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], { screenshotUrls: string[]; } | undefined>(['ctx']); fetchPlatforms = new AsyncSeriesHook<[ctx: { platforms: FrontEndPlatformType[]; }]>(['ctx']); prePlay = new AsyncSeriesHook<[ctx: { source: string, id: string; - saveFolderPath?: string; + saveFolderSlots: Record; setProgress: (progress: number, state: string) => void, command: CommandEntry; gameInfo: { platformSlug?: string; }; }]>(["ctx"]); + /** + * @param changedSaveFiles Auto detected changed files. This is mainly used to see what changed during gameplay + * @param validChangedSaveFiles This will be final valid changes to be saved using save integrations like rclone + */ postPlay = new AsyncSeriesHook<[ctx: { source: string, id: string; - saveFolderPath?: string; - changedSaveFiles: SaveFileChange[], - validChangedSaveFiles: SaveFileChange[], + saveFolderSlots?: Record; + changedSaveFiles: { subPath: string, cwd: string; }[], + validChangedSaveFiles: Record, command: CommandEntry; gameInfo: { platformSlug?: string; diff --git a/src/bun/api/hooks/store.ts b/src/bun/api/hooks/store.ts new file mode 100644 index 0000000..de889d1 --- /dev/null +++ b/src/bun/api/hooks/store.ts @@ -0,0 +1,10 @@ +import { EmulatorDownloadInfoType } from "@/shared/constants"; +import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; + +export class StoreHooks +{ + fetchFeaturedGames = new AsyncSeriesHook<[ctx: { games: FrontEndGameTypeDetailed[]; }]>(['ctx']); + fetchEmulators = new AsyncSeriesHook<[ctx: { emulators: FrontEndEmulator[]; search?: string; }]>(['ctx']); + fetchEmulator = new AsyncSeriesBailHook<[ctx: { id: string; }], FrontEndEmulatorDetailed | undefined>(['ctx']); + fetchDownload = new AsyncSeriesBailHook<[ctx: { id: string; }], (EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined>(['ctx']); +} \ No newline at end of file diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 0564111..07f4fb6 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -5,7 +5,6 @@ import * as schema from "@schema/app"; import * as emulatorSchema from "@schema/emulators"; import path, { join } from 'node:path'; import { config, db, emulatorsDb, events, plugins } from "../app"; -import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService"; import * as igdb from 'ts-igdb-client'; import secrets from "../secrets"; import { simulateProgress } from "@/bun/utils"; @@ -13,17 +12,16 @@ import { Downloader } from "@/bun/utils/downloader"; import Seven from 'node-7z'; import z from "zod"; import { checkFiles } from "../games/services/utils"; -import { ensureDir, existsSync } from "fs-extra"; +import { ensureDir, move } from "fs-extra"; import { path7za } from "7zip-bin"; -import slugify from 'slugify'; import StreamZip from 'node-stream-zip'; -import { createExtractorFromFile } from 'node-unrar-js'; import { which } from "bun"; interface JobConfig { dryRun?: boolean; dryDownload?: boolean; + downloadId?: string; } export type InstallJobStates = 'download' | 'extract'; @@ -55,34 +53,7 @@ export class InstallJob implements IJob const downloadPath = config.get('downloadPath'); let info: DownloadInfo | undefined; - switch (this.source) - { - case 'store': - const game = await getStoreGameFromId(this.gameId); - const gameId = extractStoreGameSourceId(this.gameId); - info = { - coverUrl: game.pictures.titlescreens[0], - screenshotUrls: game.pictures.screenshots, - files: [{ - url: new URL(game.file), - file_path: `roms/${game.system}`, - file_name: path.basename(decodeURI(game.file)), - size: 0 - }], - slug: this.gameId, - source_id: this.gameId, - name: game.title, - summary: game.description, - system_slug: gameId.system, - path_fs: path.join('roms', gameId.system, slugify(game.title)), - extract_path: '.', - }; - - break; - default: - info = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId }); - break; - } + info = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId }); if (!info) throw new Error(`Could not find downloader for source ${this.source}`); @@ -116,9 +87,10 @@ export class InstallJob implements IJob { let progress = 0; const progressDelta = 1 / downloadedFiles.length; + const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path); + for (const filePath of downloadedFiles) { - const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path); await new Promise(async (resolve, reject) => { let sevenZipPath = process.env.ZIP7_PATH ?? path7za; @@ -176,8 +148,23 @@ export class InstallJob implements IJob throw e; } }); + progress += progressDelta * 100; } + + // check if 1 root folder we need to get rid of + const contents = await fs.readdir(extractPath); + if (contents.length === 1) + { + const stat = await fs.stat(path.join(extractPath, contents[0])); + if (stat.isDirectory()) + { + console.log("Found 1 root folder, using that instead"); + const tmpGameFolder = `${extractPath} (1)`; + await move(path.join(extractPath, contents[0]), tmpGameFolder, { overwrite: true }); + await move(tmpGameFolder, extractPath, { overwrite: true }); + } + } } } @@ -221,7 +208,15 @@ export class InstallJob implements IJob if (!existingPlatform) { // TODO: use something else than the romm demo as CDN - const platformCover = await fetch(`https://demo.romm.app/assets/platforms/${info.system_slug}.svg`); + + const platformLookup = await plugins.hooks.games.platformLookup.promise({ + slug: info.platform?.slug ?? info.system_slug + }); + let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${info.platform?.slug ?? info.system_slug}.svg`); + if (!platformCover.ok && platformLookup?.url_logo) + { + platformCover = await fetch(platformLookup.url_logo); + } if (!esPlatform && !info.platform) { @@ -251,7 +246,7 @@ export class InstallJob implements IJob cover_type: platformCover.headers.get('content-type'), name: info.platform?.name ?? esPlatform?.system.fullname ?? '', family_name: info.platform?.family_name, - es_slug: esPlatform?.system.name ?? undefined + es_slug: esPlatform?.system.name ?? undefined, }; // TODO: add ES slug once I have better way to query ES @@ -278,22 +273,20 @@ export class InstallJob implements IJob name: info.name, cover, cover_type: coverResponse.headers.get('content-type'), - metadata: info.metadata + metadata: info.metadata, + main_glob: info.main_glob, + version: info.version, + version_source: info.version_source, + version_system: info.version_system }; const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id }); - if (info.screenshotUrls.length <= 0 && process.env.TWITCH_CLIENT_ID) + if (info.screenshotUrls.length <= 0 && info.igdb_id) { - const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); - if (access_token) - { - const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token); - - const { data } = await client.request('artworks').pipe(igdb.fields(['game', 'url']), igdb.where('game', '=', info.igdb_id)).execute(); - - info.screenshotUrls.push(...data.filter(s => s.url).map(s => s.url!)); - } + const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(info.igdb_id) }); + if (igdbLookup) return igdbLookup.screenshotUrls; + return []; } // pre-fetch screenshots diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index 7bc92c7..328c04e 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -10,6 +10,7 @@ import { IJob } from "../task-queue"; import { LaunchGameJob } from "./launch-game-job"; import { BiosDownloadJob } from "./bios-download-job"; import { InstallJob } from "./install-job"; +import ReloadPluginsJob from "./reload-plugins-job"; function registerJob< const Path extends string, @@ -107,4 +108,5 @@ export const jobs = new Elysia({ prefix: '/api/jobs' }) .use(registerJob(UpdateStoreJob)) .use(registerJob(BiosDownloadJob)) .use(registerJob(InstallJob)) + .use(registerJob(ReloadPluginsJob)) .use(registerJob(EmulatorDownloadJob)); diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index bcea594..9159002 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -19,8 +19,8 @@ export class LaunchGameJob implements IJob; - saveFolderPath?: string; + changedSaveFiles: Map; + saveSlots: SaveSlots = {}; constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string) { @@ -47,9 +47,8 @@ export class LaunchGameJob implements IJob console.error(e)); } @@ -59,7 +58,7 @@ export class LaunchGameJob implements IJob { console.error(e); - reject(e); + resolve(1); }); game = spawnGame; } else if (this.validCommand.metadata.emulatorBin) { - this.saveFolderPath = commandArgs.savesPath; + this.saveSlots = commandArgs.savesPath ?? {}; await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }); @@ -154,12 +155,15 @@ export class LaunchGameJob implements IJob { diff --git a/src/bun/api/jobs/reload-plugins-job.ts b/src/bun/api/jobs/reload-plugins-job.ts new file mode 100644 index 0000000..4796fc8 --- /dev/null +++ b/src/bun/api/jobs/reload-plugins-job.ts @@ -0,0 +1,15 @@ +import z from "zod"; +import { IJob, JobContext } from "../task-queue"; +import { plugins } from "../app"; + +export default class ReloadPluginsJob implements IJob +{ + static id = "reload-plugins-job" as const; + static dataSchema = z.never(); + group = "reload-plugins"; + + async start (context: JobContext, never, string>) + { + await plugins.reloadAll(context); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts index cc3cfc7..7a0eadc 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts @@ -1,4 +1,4 @@ -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginContextType, PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; import path from 'node:path'; import { config } from "@/bun/api/app"; @@ -7,7 +7,7 @@ export default class CEMUIntegration implements PluginType { emulator = 'CEMU'; - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { @@ -29,7 +29,7 @@ export default class CEMUIntegration implements PluginType args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`); } - return { args, savesPath: savesPath }; + return { args, savesPath: { cemu: { cwd: savesPath } } }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json index bbabba6..9a0c5c6 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json @@ -5,6 +5,7 @@ "description": "CEMU Emulator Integration", "main": "./cemu.ts", "icon": "https://upload.wikimedia.org/wikipedia/commons/9/9e/Cemu_Emulator_Official_Logo.png", + "category": "emulators", "keywords": [ "integration", "emulator", diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index aa993a3..de853f7 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -1,6 +1,6 @@ import { config } from "@/bun/api/app"; -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import path from 'node:path'; import desc from './package.json'; import { ensureDir } from "fs-extra"; @@ -10,7 +10,7 @@ export default class DOLPHINIntegration implements PluginType { emulator = 'DOLPHIN'; - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { @@ -70,14 +70,18 @@ export default class DOLPHINIntegration implements PluginType finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder; } - return { args, savesPath: finalSavesPath }; + return { args, savesPath: { dolphin: { cwd: finalSavesPath } } }; }); - ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) => + ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderSlots, command, gameInfo }) => { - if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath) + if (command.emulator === this.emulator && saveFolderSlots && command.metadata.romPath) { - validChangedSaveFiles.push(...await getSavePaths(command.metadata.romPath, saveFolderPath, command.metadata.emulatorDir)); + validChangedSaveFiles.dolphin = { + cwd: saveFolderSlots.dolphin.cwd, + subPath: await getSavePaths(command.metadata.romPath, saveFolderSlots.dolphin.cwd, command.metadata.emulatorDir), + shared: false + }; } }); } diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json index 146b910..e413d06 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json @@ -5,6 +5,7 @@ "description": "DOLPHIN Emulator Integration", "main": "./dolphin.ts", "icon": "https://upload.wikimedia.org/wikipedia/commons/5/53/Dolphin_Emulator_Logo_Refresh.svg", + "category": "emulators", "keywords": [ "integration", "emulator", diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts index 2514790..8794e80 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts @@ -128,10 +128,10 @@ async function getGCSavePaths (romPath: string, savesPath: string, location: Dol const cardPath = join(savesPath, "GC", region); const glob = new Bun.Glob(`${makerCode}-${gameCode}-*.gci`); - const saves: SaveFileChange[] = []; + const saves: string[] = []; for await (const file of glob.scan(cardPath)) { - saves.push({ subPath: path.join("GC", region, file), cwd: savesPath, shared: false }); + saves.push(path.join("GC", region, file)); } return saves; @@ -145,7 +145,7 @@ export async function getType (romPath: string, bundledEmulatorDir?: string): Pr return isGameCube ? "gamecube" : "wii"; } -export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise +export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise { const location = await findDolphinTool(bundledEmulatorDir); const gameId = await readGameId(romPath, location); @@ -159,6 +159,6 @@ export async function getSavePaths (romPath: string, savesPath: string, bundledE const folder = Buffer.from(gameId.slice(0, 4), "ascii").toString("hex").toUpperCase(); const rootFolder = join(savesPath, "Wii", "title", "00010000", folder); const files = await fs.readdir(rootFolder, { recursive: true }); - return files.map(f => ({ subPath: path.join("Wii", "title", "00010000", f), cwd: savesPath, shared: false })); + return files.map(f => path.join("Wii", "title", "00010000", f)); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json index bab4f08..6b8c725 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json @@ -5,6 +5,7 @@ "description": "PCSX2 Emulator Integration", "main": "./pcsx2.ts", "icon": "https://upload.wikimedia.org/wikipedia/commons/2/2b/PCSX2_logo4.png", + "category": "emulators", "keywords": [ "integration", "emulator", diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index db405a2..605c1b6 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -1,6 +1,6 @@ import { config } from "@/bun/api/app"; -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import defaultConfig from './PCSX2.ini' with { type: 'file' }; import path from 'node:path'; import { ensureDir } from "fs-extra"; @@ -11,7 +11,7 @@ export default class PCSX2Integration implements PluginType { emulator = "PCSX2"; - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { @@ -103,7 +103,7 @@ export default class PCSX2Integration implements PluginType await Bun.write(configPath, ini.stringify(configFile)); - return { args, savesPath: paths.MEMORY_CARDS_PATH }; + return { args, savesPath: { pcsx2: { cwd: paths.MEMORY_CARDS_PATH } } }; } return { args }; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json index f8e00f5..3801e34 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json @@ -5,6 +5,7 @@ "description": "PPSSPP Emulator Integration", "main": "./ppsspp.ts", "icon": "https://www.ppsspp.org/static/img/platform/ppsspp-icon.png", + "category": "emulators", "keywords": [ "integration", "emulator", diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index b6ff93d..5f5dbbb 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -1,4 +1,4 @@ -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; import { config } from "@/bun/api/app"; import configFilePathWin32 from './win32/ppsspp.ini' with { type: 'file' }; @@ -15,7 +15,7 @@ export default class PPSSPPIntegration implements PluginType { emulator = "PPSSPP"; - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => { @@ -114,7 +114,7 @@ export default class PPSSPPIntegration implements PluginType await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); } - return { args, savesPath: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") }; + return { args, savesPath: { ppsspp: { cwd: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") } } }; } return { args }; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json index 3a77c30..937ebc3 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json @@ -5,6 +5,7 @@ "description": "XEMU Emulator Integration", "main": "./xemu.ts", "icon": "https://upload.wikimedia.org/wikipedia/commons/8/8e/Xemu_logo_green.svg", + "category": "emulators", "keywords": [ "integration", "emulator", diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts index 010430c..fe0f65e 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts @@ -1,4 +1,4 @@ -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; import { config } from "@/bun/api/app"; import path from "node:path"; @@ -10,7 +10,7 @@ export default class XEMUIntegration implements PluginType { emulator = 'XEMU'; - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json index a6b3d25..280f14f 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json @@ -5,6 +5,7 @@ "description": "XENIA Emulator Integration", "main": "./xenia.ts", "icon": "https://xenia.jp/images/logo-256x256.png", + "category": "emulators", "keywords": [ "integration", "emulator", diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts index 6a021da..774b918 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts @@ -1,4 +1,4 @@ -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; import { GameflowHooks } from "@/bun/api/hooks/app"; import { config } from "@/bun/api/app"; @@ -68,9 +68,10 @@ export default class XENIAIntegration implements PluginType if (ctx.autoValidCommand.metadata.romPath) { finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath); + return { args, savesPath: { xenia: { cwd: finalSavesPath } } }; } - return { args, savesPath: finalSavesPath }; + return { args }; }; return { args }; @@ -82,7 +83,7 @@ export default class XENIAIntegration implements PluginType return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves"] }; } - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, this.handleEmulatorLaunchSupport); ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulatorEdge }, this.handleEmulatorLaunchSupport); @@ -95,7 +96,7 @@ export default class XENIAIntegration implements PluginType if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath) { const files = await fs.readdir(saveFolderPath, { recursive: true }); - validChangedSaveFiles.push(...files.map(f => ({ subPath: f, cwd: saveFolderPath, shared: false } satisfies SaveFileChange))); + validChangedSaveFiles.gameflow = { cwd: saveFolderPath, subPath: files, shared: false }; } }); } diff --git a/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts new file mode 100644 index 0000000..85b8aa0 --- /dev/null +++ b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts @@ -0,0 +1,520 @@ +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; +import desc from './package.json'; +import { config, customEmulators, db, emulatorsDb } from "@/bun/api/app"; +import * as emulatorSchema from '@schema/emulators'; +import { and, eq } from "drizzle-orm"; +import { cores } from "@/bun/api/emulatorjs/emulatorjs"; +import { RPC_URL } from "@/shared/constants"; +import { host } from "@/bun/utils/host"; +import path from 'node:path'; +import { existsSync, readFileSync } from "node:fs"; +import fs from "node:fs/promises"; +import { findStoreEmulatorExec } from "@/bun/api/games/services/launchGameService"; +import { which } from "bun"; +import os from 'node:os'; +import { getLocalGameMatch } from "@/bun/api/games/services/utils"; + +export default class IgdbIntegration implements PluginType +{ + varRegex = /%([^%]+)%/g; + assignRegex = /(%\w+%)=(\S+) /g; + + /** + * Get the emulators related to the given system + * @param systemSlug the ES-DE slug for the system + */ + async getEmulatorsForSystem (systemSlug: string) + { + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(emulatorSchema.systems.name, systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${systemSlug}'`); + } + + const emulators = new Set(); + await Promise.all(system.commands.map(async (command, index) => + { + let cmd = command.command; + + const matches = Array.from(cmd.matchAll(this.varRegex)); + matches.forEach(([value]) => + { + if (value.startsWith("%EMULATOR_")) + { + const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); + emulators.add(emulatorName); + return; + } + }); + })); + + if (cores[systemSlug]) + { + emulators.add('EMULATORJS'); + } + + return Array.from(emulators); + } + + async findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; }) + { + const execs: EmulatorSourceEntryType[] = []; + + if (customEmulators.has(id)) + { + execs.push({ binPath: customEmulators.get(id), type: 'custom', exists: await fs.exists(customEmulators.get(id)) }); + } + + if (emulator && emulator.systempath.length > 0) + { + const storePath = await findStoreEmulatorExec(id, emulator); + if (storePath) execs.push(storePath); + } + + if (emulator && process.platform === 'win32') + { + const regValues = emulator.winregistrypath; + if (regValues.length > 0) + { + for (const node of regValues) + { + const registryValue = await this.readRegistryValue(node); + if (registryValue) + { + execs.push({ binPath: registryValue, type: 'registry', exists: true }); + } + } + + } + } + + if (emulator && emulator.systempath.length > 0) + { + const systemPath = await this.resolveSystemPath(emulator.systempath); + if (systemPath) + { + execs.push({ binPath: systemPath, type: 'system', exists: true }); + } + } + + if (emulator && emulator.staticpath.length > 0) + { + const staticPath = await this.resolveStaticPath(emulator.staticpath); + if (staticPath) + { + execs.push({ binPath: staticPath, type: 'static', exists: true }); + } + } + + return execs; + } + + async readRegistryValue (text: string) + { + const params = text.split('|'); + const key = path.dirname(params[0]); + const value = path.basename(params[0]); + const bin = params.length > 1 ? params[1] : undefined; + + const proc = Bun.spawn({ + cmd: ["reg", "QUERY", key, "/v", value], + stdout: "pipe", + stderr: "pipe", + }); + + const output = await new Response(proc.stdout).text(); + await proc.exited; + + if (!output.includes(value)) return null; + + const lines = output.split("\n"); + for (const line of lines) + { + if (line.includes(value)) + { + const parts = line.trim().split(/\s{4,}/); + return bin ? path.join(parts[2], bin) : parts[2]; // registry value + } + } + + return null; + } + + async resolveStaticPath (entries: string[]) + { + for (const entry of entries) + { + const resolved = entry.replace("~", os.homedir()); + if (await fs.exists(resolved)) + { + return resolved; + } + } + return null; + } + + async resolveSystemPath (entries: string[]) + { + for (const entry of entries) + { + try + { + const found = which(entry); + return found; + } catch { } + } + return null; + } + + async findExecsByName (emulatorName: string) + { + const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulatorName) }); + if (!emulator) + { + throw new Error(`Could not find emulator ${emulatorName}`); + } + return this.findExecs(emulatorName, emulator); + } + + async getRomFilePaths (gamePath: string, config: { systemSlug?: string; mainGlob?: string | null; }) + { + if (!existsSync(gamePath)) + { + throw new Error(`Provided rom path is missing: '${gamePath}'`); + } + + const gamePathStat = await fs.stat(gamePath); + const validFiles: string[] = []; + + if (gamePathStat.isDirectory()) + { + if (config.mainGlob) + { + const files = await Array.fromAsync(fs.glob(config.mainGlob, { cwd: gamePath })); + if (files.length > 1) + { + throw new Error("Found multiple rom files"); + } else if (files.length === 0) + { + throw new Error("Found no valid roms"); + } + + validFiles.push(path.join(gamePath, files[0])); + } else + { + if (!config.systemSlug) throw new Error("Needs system to find valid file"); + + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(emulatorSchema.systems.name, config.systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${config.systemSlug}'`); + } + + const extensionList = system.extension.join(','); + + for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`))) + { + validFiles.push(file); + } + + if (validFiles.length <= 0) + { + throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`); + } + } + } else if (config.systemSlug) + { + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(emulatorSchema.systems.name, config.systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${config.systemSlug}'`); + } + + if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase()))) + { + validFiles.push(gamePath); + } + else + { + const extensionList = system.extension.join(','); + throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`); + } + } else + { + validFiles.push(gamePath); + } + + return validFiles; + } + + /** + * + * @param data Uses es-de system slug + * @param mainGlob The main file glob supported pattern to search for if game path is a directory + * @returns + */ + async getValidLaunchCommands (data: { + systemSlug: string; + gamePath: string; + mainGlob?: string | null; + }): Promise + { + + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(emulatorSchema.systems.name, data.systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${data.systemSlug}'`); + } + + if (!system.extension || system.extension.length <= 0) + { + throw new Error(`No extensions listed for system '${data.systemSlug}'`); + } + + const downloadPath = config.get('downloadPath'); + const gamePath = path.join(downloadPath, data.gamePath); + + const validFiles: string[] = await this.getRomFilePaths(gamePath, { systemSlug: data.systemSlug, mainGlob: data.mainGlob }); + + function escapeWindowsArg (arg: string): string + { + if (process.platform === 'win32') + { + return `"${arg + .replace(/(\\*)"/g, '$1$1\\"') // escape quotes + .replace(/(\\*)$/, '$1$1') // escape trailing backslashes + }"`; + } else + { + if (arg.includes(' ')) + { + return `"${arg}"`; + } else + { + return arg; + } + } + } + + const formattedCommands = await Promise.all(system.commands + .filter(c => !c.command.includes(`%ENABLESHORTCUTS%`)) + .map(async (command, index) => + { + const label = command.label; + let cmd = command.command; + + let emulator: string | undefined = undefined; + let rom = validFiles[0]; + + if (cmd.includes('%ESCAPESPECIALS%')) + rom = rom.replace(/[&()^=;,]/g, ''); + + + + const staticVars: Record = { + '%ROM%': escapeWindowsArg(rom), + '%ROMRAW%': validFiles[0], + '%ROMRAWWIN%': escapeWindowsArg(validFiles[0].replaceAll('/', '\\')), + '%ESPATH%': escapeWindowsArg(path.dirname(Bun.main)), + '%ROMPATH%': escapeWindowsArg(gamePath), + '%BASENAME%': escapeWindowsArg(path.basename(validFiles[0], path.extname(validFiles[0]))), + '%FILENAME%': escapeWindowsArg(path.basename(validFiles[0])), + '%ESCAPESPECIALS%': "", + '%HIDEWINDOW%': "" + }; + + cmd = cmd.replace(/\%INJECT\%=(?[\w\%.\/\\]+)/g, (_, injectFile: string) => + { + try + { + const resolvedInjectFile = injectFile.replace(this.varRegex, (a) => + { + return staticVars[a] ?? a; + }); + if (existsSync(resolvedInjectFile)) + { + const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' }); + return rawContents.split('\n').map(v => v.replace('\r', '')).join(' '); + } + + return ''; + } catch (error) + { + return ''; + } + }); + + const matches = Array.from(cmd.matchAll(this.varRegex)); + const varList = await Promise.all(matches.map(async ([value]) => + { + if (value.startsWith("%EMULATOR_")) + { + const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); + let execs = await this.findExecsByName(emulatorName); + let validExec = execs.find(e => e.exists); + + emulator = emulatorName; + return [ + [value, validExec ? validExec.binPath : undefined] as [string, string | undefined], + [`%EMUSOURCE%`, validExec?.type] as [string, string | undefined], + ['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined], + ['%EMUDIRRAW%', validExec?.rootPath ?? (validExec ? path.dirname(validExec.binPath) : undefined)] as [string, string | undefined] + ]; + + } + + const key = value[0].substring(1, value.length - 1); + return [[value, process.env[key]] as [string, string | undefined]]; + })); + + const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars }; + let startDir: string | undefined = undefined; + + if ('%STARTDIR%' in vars) + { + delete vars['%STARTDIR%']; + + cmd = cmd.replace(this.assignRegex, (match, p1, p2) => + { + if (p1 === '%STARTDIR%') + { + startDir = this.varRegex.test(p2) ? staticVars[p2] : p2; + } + return ""; + }); + } + + // missing variable + const invalid = Object.entries(vars).find(c => c[1] === undefined); + + const formattedCommand = cmd.replace(this.varRegex, (s) => vars[s] ?? '').trim(); + + return { + id: index, + label: label ?? undefined, + command: formattedCommand, + startDir, + valid: !invalid, emulator, + emulatorSource: vars['%EMUSOURCE%'] as any, + metadata: { + romPath: validFiles[0], + emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1], + emulatorDir: vars['%EMUDIRRAW%'] + } + } satisfies CommandEntry; + })); + + return formattedCommands.filter(c => !!c); + } + + async load (ctx: PluginLoadingContextType) + { + ctx.hooks.emulators.findEmulatorSource.tapPromise(desc.name, async ({ sources, emulator }) => + { + sources.push(...await this.findExecsByName(emulator)); + }); + + ctx.hooks.emulators.findEmulatorForSystem.tapPromise(desc.name, async ({ system, emulators }) => + { + emulators.push(...await this.getEmulatorsForSystem(system)); + }); + + ctx.hooks.games.fetchRomFiles.tapPromise(desc.name, async ({ source, id }) => + { + const localGame = await db.query.games.findFirst({ + where: getLocalGameMatch(id, source), + columns: { path_fs: true, main_glob: true }, + with: { platform: { columns: { es_slug: true } } } + }); + + if (!localGame?.path_fs) + { + return; + } + + const downloadPath = config.get('downloadPath'); + const path_fs = path.join(downloadPath, localGame.path_fs); + + return this.getRomFilePaths(path_fs, { systemSlug: localGame.platform.es_slug ?? undefined, mainGlob: localGame.main_glob }); + }); + + ctx.hooks.games.buildLaunchCommands.tapPromise(desc.name, async ({ systemSlug, source, id, gamePath, mainGlob }) => + { + if (source === 'emulator') + { + const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, id.id) }); + const allExecs = await this.findExecs(id.id, esEmulator); + return allExecs.map(exec => ({ + command: exec.binPath, + id: exec.type, + emulator: id.id, + emulatorSource: exec.type, + metadata: { + emulatorBin: exec.binPath, + emulatorDir: exec.rootPath + }, + valid: true + } satisfies CommandEntry)); + } + + const rommPlatform = systemSlug; + let esSystem: string | undefined = undefined; + const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ + where: and(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform), eq(emulatorSchema.systemMappings.source, 'romm')) + }); + + if (systemMapping) esSystem = systemMapping.system; + + if (!esSystem) + { + const system = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.systems.name, systemSlug), columns: { name: true } }); + if (system) esSystem = system.name; + } + + if (esSystem && gamePath) + { + try + { + const commands = await this.getValidLaunchCommands({ systemSlug: esSystem, gamePath, mainGlob }); + + if (cores[esSystem]) + { + const gameUrl = `${RPC_URL(host)}/api/romm/rom/${id.source}/${id.id}`; + commands.push({ + id: 'EMULATORJS', + label: "Emulator JS", + command: `core=${cores[esSystem]}&gameUrl=${encodeURIComponent(gameUrl)}`, + valid: true, + emulator: 'EMULATORJS', + metadata: { + romPath: gameUrl + } + }); + } + + return commands; + } catch (error) + { + console.error(error); + if (error instanceof Error) return error; + } + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/package.json b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/package.json new file mode 100644 index 0000000..9f4d82d --- /dev/null +++ b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/package.json @@ -0,0 +1,13 @@ +{ + "name": "com.simeonradivoev.gameflow.es", + "displayName": "ES-DE Launcher", + "version": "0.0.1", + "description": "ES-DE Launch Configurations. Used as fallback", + "main": "./es-de.ts", + "icon": "https://impro.usercontent.one/appid/oneComWsb/domain/es-de.org/media/es-de.org/onewebmedia/ES-DE_logo.png", + "category": "launchers", + "keywords": [ + "integration", + "es-de" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json new file mode 100644 index 0000000..20a8525 --- /dev/null +++ b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json @@ -0,0 +1,13 @@ +{ + "name": "com.simeonradivoev.gameflow.rclone", + "displayName": "Rclone Integration", + "version": "0.0.1", + "description": "Rclone integration for syncing saves", + "main": "./rclone.ts", + "icon": "https://forum.rclone.org/uploads/default/original/2X/8/8a14ccd453604987a64820f56c6afa75c229aa17.png", + "category": "saves", + "keywords": [ + "integration", + "rclone" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts new file mode 100644 index 0000000..5c70f3b --- /dev/null +++ b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts @@ -0,0 +1,292 @@ +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; +import desc from './package.json'; +import { config, events } from "@/bun/api/app"; +import path, { dirname } from 'node:path'; +import unzip from 'unzip-stream'; +import { ensureDir } from "fs-extra"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import fs from 'node:fs/promises'; +import { randomUUIDv7, sleep } from "bun"; +import z from "zod"; +import { createInterface } from "node:readline"; +import { redirect } from "elysia"; +import { getErrorMessage } from "@/bun/utils"; +import { id } from "zod/v4/locales"; + +const SettingsSchema = z.object({ + runWebGui: z.boolean() + .default(false) + .describe("Run the Web GUI that can be accessed at http://localhost:5572") + .meta({ title: "Run Web GUI" }), + globalConfig: z.boolean().default(false).describe("Use the Global Config file if already setup"), + webGuiPassword: z.string().optional().readonly().describe("Randomly Generated. Read Only. Username is gameflow"), + remoteName: z.string().default(""), + verboseLog: z.boolean() + .default(false) + .describe("Show detailed log of operation for debugging") + .meta({ $comment: JSON.stringify({ category: "debug" }) }) +}); + +type SettingsType = z.infer; +const loginTokenUrlRegex = /http:\/\/[\w\d:\-@\[\]\.\/?=]+/gm; + +export default class RcloneIntegration implements PluginType +{ + settingsSchema = SettingsSchema; + rclonePath: string | undefined; + server: Bun.Subprocess | undefined; + password: string; + user = "gameflow"; + loginUrl: string | undefined = undefined; + eventsNames = [{ + id: "open-web-gui", + title: "Open Web GUI", + description: "Open Web GUI", + action: "Open" + }, { + id: "refresh", + title: "Refresh Sources", + action: "Refresh" + }]; + + constructor() + { + this.password = randomUUIDv7(); + } + + async onEvent (id: string) + { + switch (id) + { + case "open-web-gui": + return { openTab: this.loginUrl }; + break; + case "refresh": + await this.refresh(); + return { reload: true }; + break; + } + } + + async setup (ctx: PluginLoadingContextType) + { + ctx.zodRegistry.add(SettingsSchema.shape.runWebGui, { requiresRestart: true }); + ctx.zodRegistry.add(SettingsSchema.shape.globalConfig, { requiresRestart: true }); + + const toolsPath = path.join(config.get('downloadPath'), "tools"); + const existingRclones = await Array.fromAsync(fs.glob('**/rclone.exe', { cwd: toolsPath })); + if (existingRclones[0]) + { + this.rclonePath = path.join(toolsPath, existingRclones[0]); + await this.startServer(ctx); + return; + } + + if (await fs.exists(path.join(toolsPath, 'rclone-current-windows-amd64'))) + { + return; + } + + ctx.setProgress(0.5, "Downloading RClone"); + const rcCloseZip = await fetch(`https://downloads.rclone.org/rclone-current-windows-amd64.zip`); + + await ensureDir(toolsPath); + await pipeline(Readable.fromWeb(rcCloseZip.body as any), unzip.Extract({ path: toolsPath })); + const dests = await Array.fromAsync(fs.glob('**/rclone.exe', { cwd: toolsPath })); + if (dests[0]) + { + this.rclonePath = path.join(toolsPath, dests[0]); + await this.startServer(ctx); + return; + } + } + + async refresh () + { + const data = await this.request('/config/listremotes', {}); + z.globalRegistry.add(SettingsSchema.shape.remoteName, { examples: data.remotes, description: "The name of the remote to sync with" }); + } + + async startServer (ctx: PluginLoadingContextType) + { + const args: string[] = []; + if (ctx.config.get('runWebGui')) + { + args.push("--rc-web-gui"); + args.push("--rc-web-gui-no-open-browser"); + } + if (ctx.config.get('')) + { + args.push('-vv'); + } + let env: Record | undefined = undefined; + if (!ctx.config.get('globalConfig')) + { + env = { RCLONE_CONFIG: path.join(config.get('downloadPath'), 'tools', 'config', 'rclone', 'rclone.conf') }; + } + ctx.config.set('webGuiPassword', this.password); + this.server = Bun.spawn([this.rclonePath!, "rcd", '--use-json-log', `--rc-user=${this.user}`, ...args, `--rc-pass=${this.password}`, "--rc-addr", "localhost:5572"], { + stdout: "pipe", + stderr: "pipe", + env + }); + const rl = createInterface({ input: Readable.fromWeb(this.server.stderr as any) }); + rl.on('line', e => + { + const data = JSON.parse(e); + + if (data.level === 'error') + { + console.error(data.msg); + } else + { + console.log(e); + if (loginTokenUrlRegex.test(data.msg)) + { + this.loginUrl = (data.msg as string).match(loginTokenUrlRegex)?.find(e => e); + } + } + + }); + + await new Promise((resolve) => + { + const handleResolve = (line: string) => + { + const data = JSON.parse(line); + if (!loginTokenUrlRegex.test(data.msg)) return; + rl.off('line', handleResolve); + resolve(data); + }; + rl.on('line', handleResolve); + }); + + await this.refresh(); + } + + async request (path: string, body: any) + { + const response = await fetch(`http://localhost:5572${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${Buffer.from(`${this.user}:${this.password}`).toString('base64')}` + }, + body: JSON.stringify(body) + }); + + const data = await response.json(); + if (response.ok) + { + return data; + } else + { + throw new Error(response.statusText, { cause: data }); + } + } + + async cleanup () + { + await this.request('/core/quit', {}).catch(e => + { + this.server?.kill("SIGKILL"); + }); + + await this.server?.exited; + } + + async load (ctx: PluginLoadingContextType) + { + await this.setup(ctx); + + ctx.hooks.games.prePlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, setProgress, saveFolderSlots }) => + { + if (source !== 'store' || !this.rclonePath || !saveFolderSlots) return; + + for await (const [slot, { cwd }] of Object.entries(saveFolderSlots)) + { + + let src: string; + if (ctx.config.get('remoteName')) + { + src = `${ctx.config.get('remoteName')}:gameflow/saves/${source}/${id}/${slot}`; + + const exists = await this.request('/operations/stat', { + fs: `${ctx.config.get('remoteName')}:`, + remote: `gameflow/saves/${source}/${id}/${slot}` + }).catch(e => undefined); + if (!exists || !exists.item) return; + + } else + { + src = path.join(config.get('downloadPath'), 'saves', source, id, slot); + if (!await fs.exists(path.join(config.get('downloadPath'), 'saves', source, id, slot))) return; + } + + setProgress(0.5, "RClone: Syncing Saves"); + + const data = await this.request('/sync/copy', { + srcFs: src, + dstFs: cwd, + createEmptySrcDirs: true, + _config: { + UseJSONLog: true, + LogLevel: "DEBUG", + HumanReadable: true, + Progress: true, + DryRun: true + } + }); + console.log(data); + } + + }); + + ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles }) => + { + if (source !== 'store' || !this.rclonePath) return; + console.log("Save Files", Object.values(validChangedSaveFiles).flatMap(c => Array.isArray(c.subPath) ? c.subPath : [c.subPath]).join(",")); + + await Promise.all(Object.entries(validChangedSaveFiles).map(async ([slot, change]) => + { + let dest: string; + if (ctx.config.get('remoteName')) + { + dest = `${ctx.config.get('remoteName')}:gameflow/saves/${source}/${id}/${slot}`; + } else + { + dest = path.join(config.get('downloadPath'), 'saves', source, id, slot); + } + + const data = await this.request('/sync/sync', { + srcFs: change.cwd, + dstFs: dest, + createEmptySrcDirs: true, + _config: { + UseJSONLog: true, + LogLevel: "DEBUG", + HumanReadable: true, + Progress: true + }, + _filter: { + IncludeRule: Array.isArray(change.subPath) ? change.subPath.map(s => + { + if (change.isGlob) return s; + else s.replaceAll('\\', '/'); + }) : change.isGlob ? change.subPath : change.subPath.replaceAll('\\', '/') + } + }).catch(e => + { + events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' }); + return undefined; + }); + + if (data) + { + events.emit('notification', { message: "RClone: Save Synced", type: 'success', icon: 'save' }); + } + })); + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts new file mode 100644 index 0000000..78d28b3 --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts @@ -0,0 +1,83 @@ +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; +import desc from './package.json'; +import secrets from "@/bun/api/secrets"; +import PQueue from 'p-queue'; +import * as igdb from '@phalcode/ts-igdb-client'; + +export default class IgdbIntegration implements PluginType +{ + queue: PQueue; + + constructor() + { + this.queue = new PQueue({ concurrency: 8, interval: 1000, intervalCap: 4, strict: true }); + } + + async apiCall (subPath: string, query: string) + { + const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); + const headers = new Headers({ + "Client-ID": process.env.TWITCH_CLIENT_ID ?? '', + Authorization: `Bearer ${access_token}`, + Accept: "application/json" + }); + const response = await this.queue.add(() => fetch(`https://api.igdb.com/v4${subPath}`, { + headers: headers, + method: "POST", + body: query + })); + if (response.ok) + { + return response.json() as T; + } + } + + async cleanup () + { + this.queue.clear(); + } + + async load (ctx: PluginLoadingContextType) + { + ctx.hooks.games.gameLookup.tapPromise(desc.name, async ({ source, id }) => + { + if (!process.env.TWITCH_CLIENT_ID) return; + if (source !== 'igdb') return; + + const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); + if (access_token) + { + const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token); + const { data } = await client.request('screenshots').pipe(igdb.fields(['game', 'url', 'image_id']), igdb.where('game', '=', Number(id))).execute(); + return { screenshotUrls: data.filter(s => s.url).map(s => `https://images.igdb.com/igdb/image/upload/t_720p/${s.image_id}.webp`) }; + } + }); + + ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) => + { + let query: string | undefined = undefined; + if (source && id) + { + if (source !== 'igdb') return; + query = `fields name, slug, platform_logo.image_id, platform_logo.url, platform_family.name; where id = ${id};`; + + } + else if (slug) + { + query = `fields name, slug, platform_logo.image_id, platform_logo.url, platform_family.name; where slug = "${slug}";`; + } + + if (query) + { + const data = await this.apiCall<[any]>('/platforms', query); + if (!data || data.length <= 0) return; + return { + slug: data[0].slug, + url_logo: `https://images.igdb.com/igdb/image/upload/t_logo_med/${data[0].platform_logo.image_id}.png`, + name: data[0].name, + family_name: data[0].platform_family?.name + }; + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json new file mode 100644 index 0000000..b1cd2e8 --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json @@ -0,0 +1,13 @@ +{ + "name": "com.simeonradivoev.gameflow.igdb", + "displayName": "IGDB Integration", + "version": "0.0.1", + "description": "IGDB Metadata Integration", + "main": "./igdb.ts", + "icon": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/IGDB_logo.svg/1920px-IGDB_logo.svg.png", + "category": "sources", + "keywords": [ + "integration", + "igdb" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json index 815ddb0..52c2376 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json @@ -5,6 +5,7 @@ "description": "ROMM Server Integration", "main": "./romm.ts", "icon": "https://romm.app/_ipx/q_80/images/blocks/logos/romm.svg", + "category": "sources", "keywords": [ "integration", "romm" diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 6193254..6bfbe2d 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -1,8 +1,8 @@ -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; -import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; +import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, PlatformSchema, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; import { config, events } from "@/bun/api/app"; import path from 'node:path'; import fs from 'node:fs/promises'; @@ -12,9 +12,17 @@ import secrets from "@/bun/api/secrets"; import { getAuthToken } from "@/clients/romm/core/auth.gen"; import { client } from "@/clients/romm/client.gen"; import { validateGameSource } from "@/bun/api/games/services/statusService"; +import z from "zod"; -export default class RommIntegration implements PluginType +const SettingsSchema = z.object({ + savesSync: z.boolean().default(false).describe("Experimental save sync support") +}); + +type SettingsType = z.infer; + +export default class RommIntegration implements PluginType { + settingsSchema = SettingsSchema; isSteamDeck = false; orderByMap: Record = { added: "created_at", @@ -54,7 +62,7 @@ export default class RommIntegration implements PluginType { const game: FrontEndGameType = { id: { id: String(rom.id), source: 'romm' }, - path_cover: `/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`, + path_covers: [`/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`], last_played: rom.rom_user.last_played !== null ? new Date(rom.rom_user.last_played) : null, updated_at: new Date(rom.created_at), metadata: { @@ -83,8 +91,8 @@ export default class RommIntegration implements PluginType fs_size_bytes: rom.fs_size_bytes, local: false, missing: rom.missing_from_fs, - imdb_id: rom.igdb_id ?? undefined, - ra_id: rom.ra_id ?? undefined, + igdb_id: rom.igdb_id, + ra_id: rom.ra_id, metadata: { age_ratings: rom.metadatum.age_ratings, genres: rom.metadatum.genres, @@ -126,15 +134,12 @@ export default class RommIntegration implements PluginType return detailed; } - async setup () + async load (ctx: PluginLoadingContextType) { this.isSteamDeck = isSteamDeckGameMode(); await this.updateClient(); - } - load (ctx: PluginContextType) - { - ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games, filters }) => + ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) => { if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm')) { @@ -146,7 +151,7 @@ export default class RommIntegration implements PluginType limit: query.limit, offset: query.offset, order_by: this.orderByMap[query.orderBy ?? ''], - with_filter_values: true, + with_filter_values: false, genres: query.genres, genres_logic: "all", age_ratings: query.age_ratings, @@ -154,12 +159,6 @@ export default class RommIntegration implements PluginType }, throwOnError: true }); - rommGames.data.filter_values.age_ratings.forEach(r => filters.age_ratings.add(r)); - rommGames.data.filter_values.companies.forEach(r => filters.companies.add(r)); - rommGames.data.filter_values.languages.forEach(r => filters.languages.add(r)); - rommGames.data.filter_values.player_counts.forEach(r => filters.player_counts.add(r)); - rommGames.data.filter_values.genres.forEach(r => filters.genres.add(r)); - games.push(...rommGames.data.items.map(g => { const game: FrontEndGameTypeWithIds = { @@ -172,8 +171,10 @@ export default class RommIntegration implements PluginType } }); - ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters }) => + ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) => { + if (source && source !== 'romm') return; + const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true }); rommFilters.data.age_ratings.forEach(r => filters.age_ratings.add(r)); rommFilters.data.companies.forEach(r => filters.companies.add(r)); @@ -188,7 +189,7 @@ export default class RommIntegration implements PluginType await this.updateClient(); }); - ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id, localGame }) => + ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) => { if (source !== 'romm') return; @@ -196,13 +197,6 @@ export default class RommIntegration implements PluginType if (rom.data) { const romGame = await this.convertRomToFrontendDetailed(rom.data); - if (localGame) - { - return { - ...romGame, - ...localGame, - }; - } return romGame; } @@ -405,10 +399,12 @@ export default class RommIntegration implements PluginType } }); - ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderPath, setProgress }) => + ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderSlots, setProgress }) => { - if (source !== 'romm') return; - if (saveFolderPath) + if (source !== 'romm' || !ctx.config.get('savesSync')) return; + if (!saveFolderSlots) return; + + for await (const [slot, { cwd }] of Object.entries(saveFolderSlots)) { setProgress(0, "saves"); @@ -418,53 +414,38 @@ export default class RommIntegration implements PluginType console.error(saveFiles.error); } else { - for (let i = 0; i < saveFiles.data.slots.length; i++) + const rommSlot = saveFiles.data.slots.find(s => s.slot === 'gameflow' && s.latest.file_name_no_tags === slot); + if (rommSlot) { - const slot = saveFiles.data.slots[i]; - const savePath = path.join(saveFolderPath, slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`); - if (await fs.exists(savePath)) - { - const existingSaveSync = await fs.stat(savePath); - const updatedAtTime = new Date(slot.latest.updated_at).getTime(); - - if (existingSaveSync.mtimeMs > updatedAtTime) - { - console.log("Newer save file", savePath, "Server:", new Date(slot.latest.updated_at), "Local:", existingSaveSync.mtime); - // Newer file - continue; - } else if (updatedAtTime === existingSaveSync.mtimeMs) - { - //TODO: do checksum comparison when that works on romm - console.log("Same save file", savePath); - continue; - } - } - const auth = await this.getAuthToken(); const headers: Record = {}; if (auth) headers['Authorization'] = auth; - const saveResponse = await fetch(`${config.get('rommAddress')}${slot.latest.download_path}`, { headers }); + const saveResponse = await fetch(`${config.get('rommAddress')}${rommSlot.latest.download_path}`, { headers }); if (!saveResponse.ok) { console.error("Error downloading save", saveResponse.statusText); - break; + return; } - await Bun.write(savePath, saveResponse); - console.log("Loaded", savePath); - setProgress((i / saveFiles.data.slots.length) * 100, "saves"); + + const saveArchive = new Bun.Archive(await saveResponse.blob()); + setProgress(50, "saves"); + const count = await saveArchive.extract(cwd); + setProgress(100, "saves"); + console.log("Loaded", count, "save files"); } } - setProgress(1, "saves"); + setProgress(100, "saves"); await Bun.sleep(1000); } }); - ctx.hooks.games.postPlay.tapPromise(desc.name, async ({ source, id, validChangedSaveFiles, saveFolderPath, command }) => + // Should run after emulators decide on saves + ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) => { - if (source !== 'romm') return; + if (source !== 'romm' || !ctx.config.get('savesSync')) return; const sourceValidation = await validateGameSource(source, id); if (!sourceValidation.valid) @@ -473,7 +454,7 @@ export default class RommIntegration implements PluginType return; } - const finalSavePaths = validChangedSaveFiles.filter(f => !f.shared); + /*const finalSavePaths = validChangedSaveFiles.filter(f => !f.shared && !f.isGlob).flatMap(s => Array.isArray(s.subPath) ? s.subPath.map(p => ({ cwd: s.cwd, subPath: p })) : [{ cwd: s.cwd, subPath: s.subPath }]); const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } }); if (saveFiles.error) @@ -494,29 +475,31 @@ export default class RommIntegration implements PluginType if (!finalSavePaths.some(f => f.subPath === subPath)) { // Add newer files to the list, maybe they were changed offscreen. - finalSavePaths.push({ subPath, cwd: saveFolderPath, shared: false }); + finalSavePaths.push({ subPath, cwd: saveFolderPath }); } } } } - } + }*/ + + const finalSavePaths = Object.entries(validChangedSaveFiles).filter(([slot, change]) => !change.isGlob && !change.shared); if (finalSavePaths.length > 0) { - console.log("Files Changed:", finalSavePaths.map(f => f.subPath)?.join(", ")); + console.log("Files Changed:", finalSavePaths.map(([slot, change]) => Array.isArray(change.subPath) ? change.subPath.join(',') : change.subPath)?.join(", ")); - await Promise.all(finalSavePaths.map(async f => + await Promise.all(finalSavePaths.map(async ([slot, change]) => { - const absolutePath = path.join(f.cwd, f.subPath); - if (!await fs.exists(absolutePath)) return; - const stat = await fs.stat(absolutePath); - if (stat.isDirectory()) return; + const savesArray = Array.isArray(change.subPath) ? change.subPath : [change.subPath]; + + // TODO: handle directories + const archive = new Bun.Archive(Object.fromEntries(savesArray.map(s => [s, Bun.file(path.join(change.cwd, s))]))); const data: FormData = new FormData(); - data.append('saveFile', Bun.file(absolutePath), path.basename(f.subPath)); + data.append('saveFile', await archive.blob(), slot); const url = new URL(`${config.get('rommAddress')}/api/saves`); url.searchParams.set('rom_id', id); - url.searchParams.set('slot', path.dirname(f.subPath)); + url.searchParams.set('slot', slot); url.searchParams.set('autocleanup', "true"); url.searchParams.set('autocleanup_limit', "2"); if (command.emulator) @@ -582,11 +565,24 @@ export default class RommIntegration implements PluginType }); - ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id }) => + ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) => { - if (source !== 'romm') return; - const platforms = await this.getAllRommPlatforms(); - return platforms.find(p => p.id === Number(id)); + let platform: PlatformSchema | undefined = undefined; + + if (id && source) + { + if (source !== 'romm') return; + const platforms = await this.getAllRommPlatforms(); + platform = platforms.find(p => p.id === Number(id)); + + } else if (slug) + { + const platforms = await this.getAllRommPlatforms(); + platform = platforms.find(p => p.slug === slug); + } + + if (!platform) return; + return { slug: platform?.slug, url_logo: platform.url_logo, name: platform.display_name, family_name: platform.family_name ?? undefined }; }); ctx.hooks.games.searchGame.tapPromise(desc.name, async ({ source, igdb_id, ra_id }) => diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json new file mode 100644 index 0000000..713f76f --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json @@ -0,0 +1,13 @@ +{ + "name": "com.simeonradivoev.gameflow.store", + "displayName": "Gameflow Store", + "version": "0.0.1", + "description": "The internal gameflow store", + "main": "./store.ts", + "category": "sources", + "canDisable": false, + "keywords": [ + "internal", + "store" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts new file mode 100644 index 0000000..59e0432 --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts @@ -0,0 +1,313 @@ +import { getStoreFolder } from "@/bun/api/store/services/gamesService"; +import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, StoreDownloadType, StoreGameSchema, StoreGameType } from "@/shared/constants"; +import os from 'node:os'; +import path from "node:path"; +import * as appSchema from '@schema/app'; +import * as emulatorSchema from '@schema/emulators'; +import { db, emulatorsDb, plugins } from "@/bun/api/app"; +import { and, eq } from "drizzle-orm"; +import { getOrCached } from "@/bun/api/cache"; +import { Glob } from "bun"; +import { shuffleInPlace } from "@/bun/utils"; +import mustache from "mustache"; +import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; +import fs from "node:fs/promises"; + +export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; }) +{ + const offset = filter?.offset ?? 0; + const limit = Math.min(50, filter?.limit ?? 10); + + const games = await Promise.all(gamesManifest.slice(offset, Math.min(offset + limit, gamesManifest.length)).map((e: any) => + { + return fetch(e.url).then(e => e.json()).then(game => StoreGameSchema.parseAsync(JSON.parse(atob(game.content.replace(/\n/g, ""))))); + })); + + return games; +} + +export async function getStoreGame (id: string) +{ + const file = Bun.file(path.join(getStoreFolder(), 'buckets', 'games', `${id}.json`)); + if (!(await file.exists())) return undefined; + const game = file + .json() + .then(g => StoreGameSchema.parseAsync(g)) + .then(g => ({ ...g, id })); + return game; +} + +function convertStoreMediaToPath (c: string) +{ + if (c.startsWith('http')) + { + return `/api/romm/image?url=${encodeURIComponent(c)}`; + } else + { + return `/api/store/media/${c}`; + } +} + +export async function convertStoreToFrontend (id: string, storeGame: StoreGameType): Promise +{ + const validDownload = getValidDownload(storeGame); + + let platform_slug: string | null = null; + let platform_id: number | null = null; + let platform_display_name: string | null = null; + let path_platform_cover: string | null = null; + + if (validDownload?.system) + { + let system = validDownload.system.split(':')[0]; + if (system === 'win32') system = 'win'; + + const localPlatform = await db.query.platforms.findFirst({ where: eq(appSchema.platforms.slug, system), columns: { id: true, slug: true, name: true } }); + if (localPlatform) + { + platform_id = localPlatform.id; + platform_slug = localPlatform.slug; + path_platform_cover = `/api/romm/platform/local/${localPlatform.id}/cover`; + platform_display_name = localPlatform.name; + } + + if (platform_slug === null) + { + const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ + where: and(eq(emulatorSchema.systemMappings.sourceSlug, system), eq(emulatorSchema.systemMappings.source, 'romm')) + }); + + if (rommSystem?.system) + { + const platformDef = await emulatorsDb.query.systems.findFirst({ + where: eq(emulatorSchema.systems.name, rommSystem?.system), + columns: { fullname: true } + }); + + platform_slug = rommSystem.system; + platform_display_name = platformDef?.fullname ?? null; + path_platform_cover = `/api/romm/image/romm/assets/platforms/${rommSystem.sourceSlug}.svg`; + + } else + { + const platformDef = await emulatorsDb.query.systems.findFirst({ + where: eq(emulatorSchema.systems.name, system), + columns: { fullname: true } + }); + + platform_slug = system; + platform_display_name = platformDef?.fullname ?? null; + } + + platform_slug ??= system; + } + } + + + const game: FrontEndGameType = { + platform_display_name, + path_platform_cover, + id: { source: 'store', id: id }, + source: null, + source_id: null, + path_fs: null, + path_covers: storeGame.covers?.map(convertStoreMediaToPath) ?? [], + last_played: null, + updated_at: new Date(), + slug: id, + name: storeGame.name, + platform_id, + platform_slug, + paths_screenshots: storeGame.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [], + metadata: { + first_release_date: typeof storeGame.first_release_date === 'number' ? new Date(storeGame.first_release_date) : storeGame.first_release_date ?? null + } + }; + + return game; +} + + +export async function convertStoreToFrontendDetailed (id: string, storeGame: StoreGameType): Promise +{ + const validDownload = getValidDownload(storeGame); + let size: number | null = null; + if (validDownload?.url) + { + try + { + const fileResponse = await fetch(validDownload?.url, { method: 'HEAD' }); + size = Number(fileResponse.headers.get('content-length')); + } catch (error) + { + console.error(error); + } + } + + const detailed: FrontEndGameTypeDetailed = { + ...await convertStoreToFrontend(id, storeGame), + summary: storeGame.description, + fs_size_bytes: size, + missing: false, + local: false, + version: storeGame.version, + igdb_id: storeGame.igdb_id ?? null, + ra_id: storeGame.ra_id ?? null, + metadata: { + genres: storeGame.genres ?? [], + companies: storeGame.companies ?? [], + game_modes: [], + age_ratings: [], + player_count: storeGame.player_count ?? null, + average_rating: null, + first_release_date: typeof storeGame.first_release_date === 'number' ? new Date(storeGame.first_release_date) : storeGame.first_release_date ?? null + } + }; + + return detailed; +} + +export function getValidDownload (game: StoreGameType, downloadId?: string) +{ + const downloads = Object.entries(game.downloads).map(([k, d]) => ({ id: k, ...d })); + const supportedDownloads = downloads.filter(d => d.type === 'direct'); + + if (downloadId) + { + return supportedDownloads.find(d => d.id === downloadId); + } else + { + return supportedDownloads.find(d => d.system === `${process.platform}:${process.arch}`) + ?? supportedDownloads.find(d => + { + // Linux supports proton, can run windows games + if (process.platform === 'linux') return d.system === `win32:${process.arch}`; + return false; + }) + // Fallback to emulator platforms + ?? supportedDownloads.find(d => !d.system.includes(':')); + } +} + +export async function getShuffledStoreGames () +{ + return getOrCached('shuffled-store-games', async () => + { + const files = new Glob(path.join(getStoreFolder(), 'buckets', 'games', '*.json')).scan(); + const allGamePaths = await Array.fromAsync(files); + const allStoreGames = await Promise.all(allGamePaths.map(p => Bun.file(p).json().then(g => StoreGameSchema.parseAsync(g)).then(g => ({ ...g, id: path.basename(p, '.json') })))); + shuffleInPlace(allStoreGames, Math.round(new Date().getTime() / 1000 / 60 / 60)); + return allStoreGames; + }, { expireMs: 1000 / 60 / 60 }); +} + +export async function buildFilters (filters: FrontEndFilterSets) +{ + const filtersFile = Bun.file(path.join(getStoreFolder(), 'manifests', 'filters.json')); + if (!await filtersFile.exists()) return; + const storeFilters = await filtersFile.json(); + + storeFilters.genres?.forEach((g: string) => filters.genres.add(g)); + storeFilters.age_ratings?.forEach((g: string) => filters.age_ratings.add(g)); + if (storeFilters.player_count) + filters.player_counts.add(storeFilters.player_count); + storeFilters.companies?.forEach((g: string) => filters.companies.add(g)); +} + +function getAppData () +{ + if (process.platform === "win32") return process.env.APPDATA!; + if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support"); + // linux + return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); +} + +function getLocalAppData () +{ + if (process.platform === "win32") return process.env.LOCALAPPDATA!; + if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Caches"); + // Linux / Unix + return process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"); +} + +export function buildSaves (command: CommandEntry, storeGame: StoreGameType, download?: StoreDownloadType) +{ + let saveFileGlobs: Record | undefined = undefined; + if (download && download.saves) + { + saveFileGlobs = download.saves; + + } else if (storeGame.saves) + { + const platformSaves = storeGame.saves[`${process.platform}:${process.arch}`]; + if (platformSaves) + { + saveFileGlobs = platformSaves; + } + } + + const view = { + GAMEDIR: command.startDir, + HOMEDIR: os.homedir(), + TMPDIR: os.tmpdir(), + APPDATA: getAppData(), + LOCALAPPDATA: getLocalAppData(), + }; + + if (!saveFileGlobs) return; + + return Object.entries(saveFileGlobs).map(([slot, save]) => + { + const cwd = mustache.render(save.cwd, view); + const change: SaveFileChange = { + cwd, + shared: false, + isGlob: true, + subPath: save.globs + }; + return [slot, change] as [string, SaveFileChange]; + }); +} + +export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, systems: EmulatorSystem[]) +{ + const execPaths: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: emulator.name, sources: execPaths }); + + const em: FrontEndEmulator = { + name: emulator.name, + logo: emulator.logo, + systems, + gameCount: 0, + validSources: execPaths, + integrations: [] + }; + + return em; +} + +export async function getExistingStoreEmulatorDownload (emulator: EmulatorPackageType): Promise<(EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined> +{ + const existingPackagePath = `${getEmulatorPath(emulator.name)}.json`; + if (await fs.exists(existingPackagePath)) + { + const existingPackage = await EmulatorDownloadInfoSchema.parseAsync(await Bun.file(existingPackagePath).json()); + const download = await getEmulatorDownload(emulator, existingPackage.type).catch(d => undefined); + if (!download) return { ...existingPackage, hasUpdate: false }; + if (download.info.version) + { + if (existingPackage.version !== download.info.version) return { ...existingPackage, hasUpdate: true }; + } else if (existingPackage.id !== download.info.id) + { + return { ...existingPackage, hasUpdate: true }; + } + + return { ...existingPackage, hasUpdate: false }; + } + + // this should only happen if download info is missing maybe manually deleted or wasn't saved. + return undefined; +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts new file mode 100644 index 0000000..e3dec17 --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts @@ -0,0 +1,312 @@ +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; +import desc from './package.json'; +import path, { basename, dirname } from 'node:path'; +import { StoreDownloadType, StoreGameSchema, StoreGameType } from "@/shared/constants"; +import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; +import { Glob, pathToFileURL } from "bun"; +import { getOrCached } from "@/bun/api/cache"; +import { shuffleInPlace } from "@/bun/utils"; +import { and, eq } from "drizzle-orm"; +import * as emulatorSchema from '@schema/emulators'; + +import { config, db, emulatorsDb, plugins, taskQueue } from "@/bun/api/app"; +import fs from "node:fs/promises"; +import { getSourceGameDetailed } from "@/bun/api/games/services/utils"; +import mustache from "mustache"; +import os from 'node:os'; +import UpdateStoreJob from "@/bun/api/jobs/update-store"; +import { getEmulatorDownload } from "@/bun/api/store/services/emulatorsService"; +import { buildFilters, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownload } from "./services"; + +export default class RommIntegration implements PluginType +{ + async setup (ctx: PluginLoadingContextType) + { + console.log("Store Directory is ", getStoreFolder()); + ctx.setProgress(0, "Updating Store"); + await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); + } + + async load (ctx: PluginLoadingContextType) + { + + ctx.hooks.store.fetchDownload.tapPromise(desc.name, async ({ id }) => + { + const emulatorPackage = await getStoreEmulatorPackage(id); + const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage!); + return downloadInfo; + }); + + ctx.hooks.store.fetchEmulator.tapPromise(desc.name, async ({ id }) => + { + const emulatorPackage = await getStoreEmulatorPackage(id); + if (!emulatorPackage) return undefined; + + const systems = await buildStoreFrontendEmulatorSystems(emulatorPackage); + + const emulatorScreenshotsPath = path.join(getStoreFolder(), "media", "screenshots", id); + const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : []; + const biosDirPath = path.join(config.get('downloadPath'), 'bios', id); + const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : []; + const storeDownloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage); + + const emulator: FrontEndEmulatorDetailed = { + name: emulatorPackage.name, + description: emulatorPackage.description, + source: "store", + systems, + validSources: [], + screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`), + gameCount: 0, + homepage: emulatorPackage.homepage, + downloads: (await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d => + { + const download = await getEmulatorDownload(emulatorPackage, d.type).catch(e => undefined); + return download?.info; + }) ?? [])).filter(d => !!d).map(d => ({ name: d.type, type: d.type, version: d.version })), + logo: emulatorPackage.logo, + biosRequirement: emulatorPackage.bios, + bios: biosFiles, + integrations: [], + storeDownloadInfo: storeDownloadInfo + }; + + return emulator; + }); + + ctx.hooks.store.fetchEmulators.tapPromise(desc.name, async ({ emulators, search }) => + { + const emulatesParsed = await getAllStoreEmulatorPackages(); + emulators.push(...await Promise.all(emulatesParsed + .filter(e => + { + if (!e.os.includes(process.platform as any)) return false; + if (search) + { + if (e.name.toLocaleLowerCase().includes(search) || e.systems.some(s => s.toLocaleLowerCase().includes(search)) || e.keywords?.some(k => k.toLocaleLowerCase().includes(search))) + { + return true; + } + + return false; + } + return true; + }) + .map(async (emulator) => + { + const systems = await buildStoreFrontendEmulatorSystems(emulator); + return convertStoreEmulatorToFrontend(emulator, systems); + }))); + }); + + ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderSlots, command }) => + { + if (source !== 'store') return; + const storeGame = await getStoreGame(id); + const localGame = await getSourceGameDetailed(source, id); + + if (!localGame || !storeGame) return; + if (!localGame.version_source) return; + + const download = storeGame.downloads[localGame.version_source]; + const saves = buildSaves(command, storeGame, download); + + saves?.forEach(([slot, save]) => saveFolderSlots[slot] = { cwd: save.cwd }); + }); + + ctx.hooks.games.postPlay.tapPromise(desc.name, async ({ validChangedSaveFiles, source, id, command }) => + { + if (source !== 'store') return; + const storeGame = await getStoreGame(id); + const localGame = await getSourceGameDetailed(source, id); + + if (!localGame || !storeGame) return; + if (!localGame.version_source) return; + + const download = storeGame.downloads[localGame.version_source]; + + const saves = buildSaves(command, storeGame, download); + saves?.forEach(([key, val]) => validChangedSaveFiles[key] = val); + }); + + ctx.hooks.games.buildLaunchCommands.tapPromise({ name: desc.name, before: 'com.simeonradivoev.gameflow.es' }, async ({ gamePath, source, sourceId, systemSlug, mainGlob }) => + { + if (source !== 'store' || !gamePath || systemSlug !== 'win') return; + const downloadPath = config.get('downloadPath'); + const gamePathAbsolute = path.join(downloadPath, gamePath); + if (!(await fs.exists(gamePathAbsolute))) return; + const gamePathStat = await fs.stat(gamePathAbsolute); + + if (gamePathStat.isDirectory()) + { + const fileGlob = new Glob(mainGlob ?? '**/*.exe'); + for await (const file of fileGlob.scan({ cwd: path.join(downloadPath, gamePath) })) + { + return [{ + startDir: path.join(downloadPath, gamePath, dirname(file)), + command: basename(file), + id: 'store-win', + valid: true, + env: { + XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '') + }, + metadata: { + romPath: path.join(downloadPath, gamePath, file) + } + }]; + } + + } else + { + return [{ + startDir: path.join(downloadPath, dirname(gamePath)), + command: basename(gamePath), + env: { + XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '') + }, + id: 'store-win', + valid: true, + metadata: { + romPath: path.join(downloadPath, gamePath) + } + }]; + } + + }); + + ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) => + { + if (!source || source !== 'store') return; + await buildFilters(filters); + }); + + ctx.hooks.store.fetchFeaturedGames.tapPromise(desc.name, async ({ games }) => + { + const allGames = await getShuffledStoreGames(); + const convertedGames = await Promise.all(allGames.slice(0, 3).map(async g => + { + return convertStoreToFrontendDetailed(g.id, g); + })); + games.push(...convertedGames); + }); + + ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) => + { + if (!query.source || query.source !== 'store') return; + if (query.collection_source || query.collection_id) return; + + const shuffledGames = await getShuffledStoreGames(); + const storeGames = await Promise.all(shuffledGames.filter(g => + { + if (query.search) + return path.basename(g.name).toLocaleLowerCase().includes(query.search.toLocaleLowerCase()); + return true; + }) + .slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length)) + .map(async (e) => + { + const game: FrontEndGameTypeWithIds = { + ...await convertStoreToFrontend(e.id, e), + igdb_id: e.igdb_id ?? null, + ra_id: e.ra_id ?? null + }; + return game; + })); + games.push(...storeGames.filter(g => g !== undefined)); + }); + + ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) => + { + const esSystem = game.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, game.platform_slug)), columns: { system: true } }) : undefined; + + const shuffledGames = await getShuffledStoreGames(); + const storeGames = await Promise.all(shuffledGames + .filter(g => + { + if (esSystem) + { + if (Object.values(g.downloads).some(d => d.system === esSystem.system)) return true; + } + + return false; + }) + .map(async (e) => + { + return convertStoreToFrontend(e.id, e); + })); + + if (storeGames) + { + games.push(...storeGames.slice(0, 3)); + } + }); + + ctx.hooks.games.fetchRecommendedGamesForEmulator.tapPromise(desc.name, async ({ emulator, games, systems }) => + { + const systemsIdSet = new Set(systems.map(s => s.id)); + const gamesManifest = await getShuffledStoreGames(); + const storeGames = await Promise.all(gamesManifest + .filter(g => Object.values(g.downloads).some(d => systemsIdSet.has(d.system))) + .map(async (e) => + { + + return convertStoreToFrontend(e.id, e); + })); + + games.push(...storeGames.filter(g => g !== undefined).slice(0, 3)); + }); + + ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) => + { + if (source !== 'store') return; + const storeGame = await getStoreGame(id); + if (storeGame) + { + return convertStoreToFrontendDetailed(id, storeGame); + } + }); + + ctx.hooks.games.fetchDownloads.tapPromise(desc.name, async ({ source, id, downloadId }) => + { + if (source !== 'store') return; + const game = await getStoreGame(id); + if (!game) throw new Error("Missing Store Game"); + + const validDownload = getValidDownload(game, downloadId); + + if (validDownload) + { + let system = validDownload.system.split(":")[0]; + if (system === 'win32') system = 'win'; + + const info: DownloadInfo = { + coverUrl: game.covers?.[0] ? game.covers[0].startsWith('http') ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "", + screenshotUrls: game.screenshots ?? [], + files: [{ + url: new URL(validDownload.url), + file_path: `roms/${system}`, + file_name: path.basename(decodeURI(validDownload.url)), + size: 0 + }], + slug: id, + source_id: id, + name: game.name, + summary: game.description, + system_slug: system, + path_fs: path.join('roms', system, game.id), + extract_path: '.', + main_glob: validDownload.main, + version: game.version, + version_system: validDownload.system, + version_source: validDownload.id, + platform: { + slug: system, + name: system + } + }; + + return info; + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/plugin-manager.ts b/src/bun/api/plugins/plugin-manager.ts index 90392f1..e22b2c2 100644 --- a/src/bun/api/plugins/plugin-manager.ts +++ b/src/bun/api/plugins/plugin-manager.ts @@ -1,6 +1,15 @@ import { GameflowHooks } from "../hooks/app"; -import { PluginContextType, PluginDescriptionType, PluginType } from "../../types/typesc.schema"; +import { PluginDescriptionType, PluginLoadingContextType, PluginType } from "../../types/typesc.schema"; import { config } from "../app"; +import Conf from "conf"; +import projectPackage from '~/package.json'; +import z from "zod"; +import { EventEmitter } from "node:stream"; + +export const pluginZodRegistry = z.registry<{ + requiresRestart?: boolean; + readOnly?: boolean; +}>(); export class PluginManager { @@ -11,10 +20,11 @@ export class PluginManager plugin: PluginType; description: PluginDescriptionType, source: PluginSourceType; + config?: Conf; }> = {}; - async register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType) + register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType) { try { @@ -24,15 +34,29 @@ export class PluginManager } else { - if (plugin.setup) await plugin.setup(); + let pluginConfig: Conf | undefined = undefined; + if (plugin.settingsSchema) + { + pluginConfig = new Conf({ + projectName: projectPackage.name, + configName: description.name, + projectSuffix: 'bun', + cwd: process.env.CONFIG_CWD, + schema: Object.fromEntries(Object.entries(plugin.settingsSchema.shape).map(([key, schema]) => [key, (schema as z.ZodObject).toJSONSchema() as any])) as any, + defaults: plugin.settingsSchema.parse({}), + migrations: plugin.settingsMigrations as any, + projectVersion: description.version + }); + } + this.plugins[description.name] = { enabled: !config.get('disabledPlugins').includes(description.name), loaded: false, plugin: plugin, source: source, - description: description + description: description, + config: pluginConfig }; - this.reload(description.name); console.log("Plugin", description.name, "registered"); } @@ -44,24 +68,29 @@ export class PluginManager }; } - private reload (name: string) + private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; }) { const plugin = this.plugins[name]; if (plugin) { - const ctx: PluginContextType = { hooks: this.hooks }; + const ctx: PluginLoadingContextType = { + hooks: this.hooks, + setProgress: reloadCtx.setProgress.bind(reloadCtx), + config: plugin.config as any, + zodRegistry: pluginZodRegistry + }; if (plugin.loaded) { - plugin.plugin.onBeforeReload?.(ctx); + await plugin.plugin.cleanup?.(); plugin.loaded = false; } try { - if (plugin.enabled) + if (plugin.enabled || plugin.description.canDisable === false) { - plugin.plugin.load(ctx); + await plugin.plugin.load(ctx); plugin.loaded = true; } } catch (error) @@ -72,10 +101,14 @@ export class PluginManager } } - reloadAll () + async reloadAll (ctx: { setProgress: (progress: number, state: string) => void; }) { this.hooks = new GameflowHooks(); - Object.keys(this.plugins).forEach(id => this.reload(id)); + for await (const id of Object.keys(this.plugins)) + { + ctx.setProgress(0, `Loading ${id}`); + await this.reload(id, ctx); + } } async cleanup () @@ -84,7 +117,10 @@ export class PluginManager { try { - await p.plugin.cleanup!(); + if (p.loaded) + { + await p.plugin.cleanup!(); + } } catch (error) { console.log("Error for plugin", p.description.name, "while cleaning up"); diff --git a/src/bun/api/plugins/plugins.ts b/src/bun/api/plugins/plugins.ts index e276f92..6a2dedc 100644 --- a/src/bun/api/plugins/plugins.ts +++ b/src/bun/api/plugins/plugins.ts @@ -1,7 +1,8 @@ import Elysia, { status } from "elysia"; -import { plugins } from "../app"; +import { plugins, taskQueue } from "../app"; import z from "zod"; import { toggleElementInConfig } from "@/bun/utils"; +import ReloadPluginsJob from "../jobs/reload-plugins-job"; export default new Elysia({ prefix: '/plugins' }) .get('/', async () => @@ -15,19 +16,31 @@ export default new Elysia({ prefix: '/plugins' }) description: p.description.description, source: p.source, version: p.description.version, - icon: p.description.icon + canDisable: p.description.canDisable ?? true, + icon: p.description.icon, + category: p.description.category, + hasSettings: !!p.config }; return plugin; }); }) + .get('/:id', async ({ params: { id } }) => + { + const plugin = plugins.plugins[id]; + return plugin.description; + }) .post('/:id', async ({ params: { id }, body: { enabled } }) => { const plugin = plugins.plugins[id]; if (plugin) { + if (plugin.description.canDisable === false) + { + return status("Forbidden"); + } plugin.enabled = enabled; toggleElementInConfig('disabledPlugins', plugin.description.name, enabled); - plugins.reloadAll(); + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); } else { return status("Not Found"); diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts index 3c49311..5f542ae 100644 --- a/src/bun/api/plugins/register-plugins.ts +++ b/src/bun/api/plugins/register-plugins.ts @@ -7,11 +7,14 @@ import cemu from './builtin/emulators/com.simeonradivoev.gameflow.cemu/package.j import xenia from './builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json'; import xemu from './builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json'; import romm from './builtin/sources/com.simeonradivoev.gameflow.romm/package.json'; +import igdb from './builtin/sources/com.simeonradivoev.gameflow.igdb/package.json'; +import store from './builtin/sources/com.simeonradivoev.gameflow.store/package.json'; +import es from './builtin/launchers/com.simeonradivoev.gameflow.es/package.json'; +import rclone from './builtin/other/com.simeonradivoev.gameflow.rclone/package.json'; import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema"; export default async function register (pluginManager: PluginManager) { - const plugins: (PluginDescriptionType & { main: string; load: () => Promise; })[] = [ { ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') }, { ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') }, @@ -20,9 +23,24 @@ export default async function register (pluginManager: PluginManager) { ...xenia, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia') }, { ...xemu, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu') }, { ...romm, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.romm/romm') }, + { ...igdb, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.igdb/igdb') }, + { ...es, load: () => import('./builtin/launchers/com.simeonradivoev.gameflow.es/es-de') }, + { ...store, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.store/store') }, + { ...rclone, load: () => import('./builtin/other/com.simeonradivoev.gameflow.rclone/rclone') }, ]; - await Promise.all(plugins.map(async (pluginPackage) => + await Promise.all(plugins.filter(p => + { + if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(p.name)) + { + return false; + } + if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(p.name)) + { + return false; + } + return true; + }).map(async (pluginPackage) => { const file = await pluginPackage.load(); if (file.default && typeof file.default === 'function') diff --git a/src/bun/api/schema/app.ts b/src/bun/api/schema/app.ts index 35c9c5a..2226b20 100644 --- a/src/bun/api/schema/app.ts +++ b/src/bun/api/schema/app.ts @@ -9,6 +9,7 @@ export const games = sqliteTable('games', { name: text("name"), ra_id: integer('ra_id').unique(), path_fs: text("path_fs"), + main_glob: text("main_glob"), last_played: integer("last_played", { mode: 'timestamp' }), created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(), metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type<{ @@ -24,7 +25,10 @@ export const games = sqliteTable('games', { platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(), cover: blob("cover", { mode: 'buffer' }), cover_type: text('type'), - summary: text("summary") + summary: text("summary"), + version: text('version'), + version_source: text("version_source"), + version_system: text("version_system"), }); export const gamesRelations = relations(games, ({ many, one }) => ({ diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index 7b7a89e..ea76797 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -2,10 +2,9 @@ import * as appSchema from '@schema/app'; import * as emulatorSchema from "@schema/emulators"; import { eq, inArray } from 'drizzle-orm'; -import { db, emulatorsDb } from '../app'; +import { db, emulatorsDb, plugins } from '../app'; import { cores } from '../emulatorjs/emulatorjs'; import { SERVER_URL } from '@/shared/constants'; -import { findExecsByName } from '../games/services/launchGameService'; import { host } from '@/bun/utils/host'; import { findEmulatorPluginIntegration } from '../store/services/emulatorsService'; @@ -54,7 +53,18 @@ export async function getRelevantEmulators () const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator); const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) => { - const execPaths = await findExecsByName(emulator); + const execPaths: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator, sources: execPaths }); + const integrations = findEmulatorPluginIntegration(emulator, execPaths); + + const storeEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: emulator }); + + if (storeEmulator) + { + storeEmulator.validSources = execPaths; + storeEmulator.integrations = integrations; + return storeEmulator; + } let platform: number | null | undefined = null; const validSystemSlug = system_slug.find(s => s.system); @@ -75,7 +85,7 @@ export async function getRelevantEmulators () gameCount: 0, isCritical: false, validSources: execPaths, - integrations: findEmulatorPluginIntegration(emulator, execPaths) + integrations }; return em; diff --git a/src/bun/api/settings/settings.ts b/src/bun/api/settings/settings.ts index dda53b9..c315701 100644 --- a/src/bun/api/settings/settings.ts +++ b/src/bun/api/settings/settings.ts @@ -1,12 +1,15 @@ import z from "zod"; import { SettingsSchema } from "@shared/constants"; import Elysia, { status } from "elysia"; -import { config, customEmulators, taskQueue } from "../app"; +import { config, customEmulators, plugins, taskQueue } from "../app"; import fs from 'node:fs/promises'; import { existsSync } from "node:fs"; import { InstallJob } from "../jobs/install-job"; import { move } from "fs-extra"; import { getRelevantEmulators } from "./services"; +import type { JSONSchema7 } from "json-schema"; +import ReloadPluginsJob from "../jobs/reload-plugins-job"; +import { pluginZodRegistry } from "../plugins/plugin-manager"; export const settings = new Elysia({ prefix: '/api/settings' }) .get('/emulators/automatic', async () => @@ -77,18 +80,59 @@ export const settings = new Elysia({ prefix: '/api/settings' }) drive: z.string().optional() }) }) - .get("/:id", async ({ params: { id } }) => + .get("local/:id", async ({ params: { id } }) => { const value = config.get(id); return { value: value }; }, { params: z.object({ id: z.keyof(SettingsSchema) }), - }).post('/:id', + }).post('local/:id', async ({ params: { id }, body: { value }, }) => { config.set(id, value); }, { params: z.object({ id: z.keyof(SettingsSchema) }), body: z.object({ value: z.any() }), - }); + }) + .get('/definitions/:source', async ({ params: { source } }) => + { + return plugins.plugins[source].plugin.settingsSchema?.toJSONSchema() as JSONSchema7; + }) + .get('/actions/:source', async ({ params: { source } }) => + { + const plugin = plugins.plugins[source]?.plugin; + if (!plugin.eventsNames) return []; + return plugin.eventsNames; + }) + .post('/actions/:source/:id', async ({ params: { source, id } }) => + { + return await plugins.plugins[source]?.plugin.onEvent?.(id); + }) + .get('/:source/:id', async ({ params: { source, id } }) => + { + return { value: plugins.plugins[source].config?.get(id) }; + }) + .put('/:source/:id', async ({ params: { source, id }, body: { value } }) => + { + const plugin = plugins.plugins[source]; + if (!plugin.config) return status("Not Found", "Plugin has no config"); + const settingSchema = plugin.plugin.settingsSchema?.shape[id] as z.ZodObject; + if (!settingSchema) return status("Not Found", "Could not find setting"); + const meta = pluginZodRegistry.get(settingSchema); + + if (meta?.readOnly) + { + return; + } + + plugin.config?.set(id, value); + + if (meta?.requiresRestart) + { + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); + } + }, + { + body: z.object({ value: z.any() }) + }); diff --git a/src/bun/api/store/services/emulatorsService.ts b/src/bun/api/store/services/emulatorsService.ts index 1682ecb..d29a1be 100644 --- a/src/bun/api/store/services/emulatorsService.ts +++ b/src/bun/api/store/services/emulatorsService.ts @@ -1,34 +1,7 @@ -import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants"; -import { config, emulatorsDb, plugins } from "../../app"; -import * as emulatorSchema from '@schema/emulators'; -import { findExecs } from "../../games/services/launchGameService"; -import { eq } from "drizzle-orm"; +import { EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants"; +import { config, plugins } from "../../app"; import { getOrCached, getOrCachedGithubRelease } from "../../cache"; import path from "node:path"; -import fs from "node:fs/promises"; - -export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: EmulatorSystem[]) -{ - const execPaths: EmulatorSourceEntryType[] = []; - const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) }); - - if (esEmulator) - { - const allExecs = await findExecs(emulator.name, esEmulator); - execPaths.push(...allExecs); - } - - const em: FrontEndEmulator = { - name: emulator.name, - logo: emulator.logo, - systems, - gameCount, - validSources: execPaths, - integrations: findEmulatorPluginIntegration(emulator.name, execPaths) - }; - - return em; -} export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]): EmulatorSupport[] { @@ -52,29 +25,6 @@ export function getEmulatorPath (emulator: string) return path.join(config.get('downloadPath'), "emulators", emulator); } -export async function getExistingStoreEmulatorDownload (emulator: EmulatorPackageType): Promise<(EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined> -{ - const existingPackagePath = `${getEmulatorPath(emulator.name)}.json`; - if (await fs.exists(existingPackagePath)) - { - const existingPackage = await EmulatorDownloadInfoSchema.parseAsync(await Bun.file(existingPackagePath).json()); - const download = await getEmulatorDownload(emulator, existingPackage.type).catch(d => undefined); - if (!download) return { ...existingPackage, hasUpdate: false }; - if (download.info.version) - { - if (existingPackage.version !== download.info.version) return { ...existingPackage, hasUpdate: true }; - } else if (existingPackage.id !== download.info.id) - { - return { ...existingPackage, hasUpdate: true }; - } - - return { ...existingPackage, hasUpdate: false }; - } - - // this should only happen if download info is missing maybe manually deleted or wasn't saved. - return undefined; -} - export async function getEmulatorDownload (emulator: EmulatorPackageType, source: string) { if (!emulator.downloads) throw new Error("Emulator has no downloads"); diff --git a/src/bun/api/store/services/gamesService.ts b/src/bun/api/store/services/gamesService.ts index 99aa15a..17ee6ec 100644 --- a/src/bun/api/store/services/gamesService.ts +++ b/src/bun/api/store/services/gamesService.ts @@ -6,74 +6,9 @@ import path from "node:path"; import fs from 'node:fs/promises'; import * as emulatorSchema from '@schema/emulators'; import { shuffleInPlace } from "@/bun/utils"; +import { Glob } from "bun"; -export async function getShuffledStoreGames () -{ - return getOrCached('shuffled-store-games', async () => - { - const gamesManifest = await getStoreGameManifest(); - const allStoreGames = gamesManifest.filter(g => g.type === 'blob'); - shuffleInPlace(allStoreGames, Math.round(new Date().getTime() / 1000 / 60 / 60)); - return allStoreGames; - }, { expireMs: 1000 / 60 / 60 }); -} -export async function getStoreGameManifest () -{ - return getOrCached(CACHE_KEYS.STORE_GAME_MANIFEST, async () => - { - const store = await fetch('https://api.github.com/repos/dragoonDorise/EmuDeck/git/trees/50261b66d69c1758efa28c6d7c54e45259a0c9c5?recursive=true').then(r => r.json()).then(data => GithubManifestSchema.parseAsync(data)); - - return store.tree.filter((e: any) => - { - if (e.type === 'blob' && e.path !== "featured.json") - { - return true; - } - return false; - }); - }); -} - -export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; }) -{ - const offset = filter?.offset ?? 0; - const limit = Math.min(50, filter?.limit ?? 10); - - const games = await Promise.all(gamesManifest.slice(offset, Math.min(offset + limit, gamesManifest.length)).map((e: any) => - { - return fetch(e.url).then(e => e.json()).then(game => StoreGameSchema.parseAsync(JSON.parse(atob(game.content.replace(/\n/g, ""))))); - })); - - return games; -} - -export function extractStoreGameSourceId (id: string) -{ - const gameId = id.split('@'); - if (gameId.length !== 2) - throw new Error("Store ID should include platform and name with @ separator"); - return { system: gameId[0], id: gameId[1] }; -} - -export function getStoreGameFromId (id: string) -{ - const data = extractStoreGameSourceId(id); - return getStoreGame(data.system, data.id); -} - -export async function getStoreGame (system: string, id: string) -{ - return getStoreGameFromPath(`${system}/${encodeURIComponent(id)}.json`); -} - -export async function getStoreGameFromPath (path: string) -{ - const game = await getOrCached(CACHE_KEYS.STORE_GAME(path), () => fetch(`https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/${path}`) - .then(e => e.json()) - .then(g => StoreGameSchema.parseAsync(g))); - return game; -} export function getStoreRootFolder () { diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 5145232..b15f5b6 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -1,19 +1,18 @@ import Elysia, { status } from "elysia"; -import { config, db, taskQueue } from "../app"; +import { config, db, plugins, taskQueue } from "../app"; import path from "node:path"; import fs from 'node:fs/promises'; -import { EmulatorDownloadInfoSchema, StoreGameSchema } from "@/shared/constants"; -import { findExecsByName } from "../games/services/launchGameService"; +import { EmulatorDownloadInfoSchema } from "@/shared/constants"; import * as appSchema from '@schema/app'; import z from "zod"; -import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; +import { convertLocalToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; import { getPlatformsApiPlatformsGet } from "@/clients/romm"; import { CACHE_KEYS, getOrCached } from "../cache"; -import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "./services/gamesService"; +import { getStoreFolder } from "./services/gamesService"; import { EmulatorDownloadJob } from "../jobs/emulator-download-job"; -import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration, getEmulatorDownload, getExistingStoreEmulatorDownload } from "./services/emulatorsService"; import { BiosDownloadJob } from "../jobs/bios-download-job"; +import { findEmulatorPluginIntegration } from "./services/emulatorsService"; export const store = new Elysia({ prefix: '/api/store' }) .get('/emulators', async ({ query }) => @@ -23,42 +22,32 @@ export const store = new Elysia({ prefix: '/api/store' }) console.error(e); return undefined; }); - const emulatesParsed = await getAllStoreEmulatorPackages(); - let frontEndEmulators = await Promise.all(emulatesParsed - .filter(e => + + + let frontEndEmulators: FrontEndEmulator[] = []; + await plugins.hooks.store.fetchEmulators.promise({ emulators: frontEndEmulators, search: query.search }); + + await Promise.all(frontEndEmulators.map(async e => + { + const gameCounts = e.systems.map((s) => { - if (!e.os.includes(process.platform as any)) return false; - if (query.search) + const romPlatform = rommPlatforms?.find(p => p.slug === (s.romm_slug ?? s.id)); + if (romPlatform) { - const lowerCaseSearch = query.search.toLocaleLowerCase(); - - if (e.name.toLocaleLowerCase().includes(lowerCaseSearch) || e.systems.some(s => s.toLocaleLowerCase().includes(lowerCaseSearch)) || e.keywords?.some(k => k.toLocaleLowerCase().includes(lowerCaseSearch))) - { - return true; - } - - return false; + return romPlatform.rom_count; } - return true; - }) - .map(async (emulator) => - { - const systems = await buildStoreFrontendEmulatorSystems(emulator); - const gameCounts = await Promise.all(systems.map(async (s) => - { - const romPlatform = rommPlatforms?.find(p => p.slug === (s.romm_slug ?? s.id)); - if (romPlatform) - { - return romPlatform.rom_count; - } - return 0; + return 0; - })); + }); - const gameCount = gameCounts.reduce((a, c) => a + c); - return convertStoreEmulatorToFrontend(emulator, gameCount, systems); - })); + const execPaths: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: e.name, sources: execPaths }); + const integrations = findEmulatorPluginIntegration(e.name, execPaths); + + e.gameCount = gameCounts.reduce((a, c) => a + c); + e.integrations = integrations; + })); if (query.missing) { @@ -98,25 +87,31 @@ export const store = new Elysia({ prefix: '/api/store' }) }) .get('/games/featured', async () => { - const response = await fetch('https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/featured.json'); - const games = await z.object({ featured: z.array(StoreGameSchema) }).parseAsync(await response.json()); - return Promise.all(games.featured.map(async g => + const games: FrontEndGameTypeDetailed[] = []; + await plugins.hooks.store.fetchFeaturedGames.promise({ games }); + + return Promise.all(games.map(async g => { - const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(`${g.system}@${g.title}`, 'store') }); + const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(g.id.id, g.id.source) }); if (localGame) return convertLocalToFrontendDetailed(localGame); - return convertStoreToFrontendDetailed(g.system, g.title, g); + return g; })); }) .get('/stats', async () => { - const emulatesParsed = await getAllStoreEmulatorPackages(); - const storeEmulatorCount = emulatesParsed.filter(e => e.os.includes(process.platform as any)).length; + let frontEndEmulators: FrontEndEmulator[] = []; + await plugins.hooks.store.fetchEmulators.promise({ emulators: frontEndEmulators }); + const storeEmulatorCount = frontEndEmulators.length; const gameCount = await db.$count(appSchema.games); return { storeEmulatorCount, gameCount }; }) + .get('/media/*', async ({ params }) => + { + return Bun.file(path.join(getStoreFolder(), params["*"])); + }) .get('/screenshot/emulator/:id/:name', async ({ params: { id, name } }) => { return Bun.file(path.join(getStoreFolder(), "media", "screenshots", id, name)); @@ -124,49 +119,14 @@ export const store = new Elysia({ prefix: '/api/store' }) { params: z.object({ id: z.string(), name: z.string() }) }) .get('/emulator/:id/update', async ({ params: { id } }) => { - const emulatorPackage = await getStoreEmulatorPackage(id); - const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage!); - return downloadInfo; + return plugins.hooks.store.fetchDownload.promise({ id }); }, { response: z.union([z.intersection(EmulatorDownloadInfoSchema, z.object({ hasUpdate: z.boolean() })), z.undefined()]) }) .get('/emulator/:id', async ({ params: { id } }) => { - const emulatorPackage = await getStoreEmulatorPackage(id); - if (!emulatorPackage) return status("Not Found"); - - const systems = await buildStoreFrontendEmulatorSystems(emulatorPackage); - - const execPaths = await findExecsByName(emulatorPackage.name); - - const emulatorScreenshotsPath = path.join(getStoreFolder(), "media", "screenshots", id); - const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : []; - const biosDirPath = path.join(config.get('downloadPath'), 'bios', id); - const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : []; - const storeDownloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage); - - const emulator: FrontEndEmulatorDetailed = { - name: emulatorPackage.name, - description: emulatorPackage.description, - systems, - validSources: execPaths, - screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`), - gameCount: 0, - homepage: emulatorPackage.homepage, - downloads: (await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d => - { - const download = await getEmulatorDownload(emulatorPackage, d.type).catch(e => undefined); - return download?.info; - }) ?? [])).filter(d => !!d).map(d => ({ name: d.type, type: d.type, version: d.version })), - logo: emulatorPackage.logo, - biosRequirement: emulatorPackage.bios, - bios: biosFiles, - integrations: findEmulatorPluginIntegration(emulatorPackage.name, execPaths), - storeDownloadInfo: storeDownloadInfo - }; - - return emulator; + return plugins.hooks.store.fetchEmulator.promise({ id }); }, { params: z.object({ id: z.string() }) }) .post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) => { diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 720a10b..5292b85 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -2,7 +2,7 @@ import Elysia from "elysia"; import open from 'open'; import z from "zod"; import os from 'node:os'; -import { cachePath, config, events } from "./app"; +import { cachePath, config, events, taskQueue } from "./app"; import { isSteamDeck, openExternal } from "../utils"; import fs from 'node:fs/promises'; import buildNotificationsStream from "./notifications"; @@ -12,6 +12,22 @@ import { getDevices, getDevicesCurated } from "./drives"; import getFolderSize from "get-folder-size"; import si from 'systeminformation'; import { getStoreFolder } from "./store/services/gamesService"; +import ReloadPluginsJob from "./jobs/reload-plugins-job"; +import { semver } from "bun"; +import packageDef from '~/package.json'; + +async function checkUpdate () +{ + const latest = await fetch('https://api.github.com/repos/simeonradivoev/gameflow-deck/releases/latest'); + if (latest.ok) + { + const data = await latest.json(); + const hasUpdate = semver.order(data.tag_name, packageDef.version); + return hasUpdate; + } + + return 0; +} export const system = new Elysia({ prefix: '/api/system' }) .post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) => @@ -60,29 +76,64 @@ export const system = new Elysia({ prefix: '/api/system' }) set.headers["cache-control"] = 'no-cache'; set.headers['connection'] = 'keep-alive'; return new Response(buildNotificationsStream()); + }) + .get('/notifications/all', ({ }) => + { + }) .ws('/info/system', { response: z.discriminatedUnion('type', [ z.object({ type: z.literal('info'), data: SystemInfoSchema }), - z.object({ type: z.literal('focus') }) + z.object({ type: z.literal('focus') }), + z.object({ type: z.literal('loading'), progress: z.number(), state: z.string().optional() }), + z.object({ type: z.literal('loaded') }), ]), async open (ws) { - const battery = await si.battery(); - const wifi = await si.wifiConnections(); - const bluetooth = await si.bluetoothDevices(); - ws.send({ - type: 'info', - data: { - battery: battery, - wifiConnections: wifi, - bluetoothDevices: bluetooth - } - }, true); + const existingLoading = taskQueue.findJob(ReloadPluginsJob.id, ReloadPluginsJob); + if (existingLoading) ws.send({ type: 'loading', progress: existingLoading.progress, state: existingLoading.state }); + else ws.send({ type: 'loaded' }); + + const startInfo = async () => + { + const battery = await si.battery(); + const wifi = await si.wifiConnections(); + const bluetooth = await si.bluetoothDevices(); + ws.send({ + type: 'info', + data: { + battery: battery, + wifiConnections: wifi, + bluetoothDevices: bluetooth + } + }, true); + }; + startInfo(); const handleFocus = () => ws.send({ type: 'focus' }); events.on('focus', handleFocus); - (ws.data as any).dispose = [() => events.removeListener('focus', handleFocus)]; + const dispose: (() => void)[] = []; + + dispose.push(taskQueue.on('progress', e => + { + if (e.id !== ReloadPluginsJob.id) return; + ws.send({ type: "loading", progress: e.progress, state: e.state }); + })); + dispose.push(taskQueue.on('started', e => + { + if (e.id !== ReloadPluginsJob.id) return; + ws.send({ type: "loading", progress: 0 }); + })); + dispose.push(taskQueue.on('ended', e => + { + if (e.id !== ReloadPluginsJob.id) return; + ws.send({ type: "loaded" }); + })); + + (ws.data as any).dispose = [...dispose, () => + { + events.removeListener('focus', handleFocus); + }]; (ws.data as any).observer = setInterval(async () => { const battery = await si.battery(); @@ -209,4 +260,8 @@ export const system = new Elysia({ prefix: '/api/system' }) await openExternal(url); }, { body: z.object({ url: z.string() }) + }) + .get('/update', async () => + { + return checkUpdate(); }); \ No newline at end of file diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index 308f217..34459ed 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -1,7 +1,8 @@ +import { and } from 'drizzle-orm'; import EventEmitter from 'node:events'; -import z from 'zod'; +import z, { any } from 'zod'; export class TaskQueue { @@ -121,29 +122,29 @@ export interface EventsList queued: [e: BaseEvent]; } -interface BaseEvent +export interface BaseEvent { id: string; job: IPublicJob; } -interface ErrorEvent extends BaseEvent +export interface ErrorEvent extends BaseEvent { error: unknown; } -interface AbortEvent extends BaseEvent +export interface AbortEvent extends BaseEvent { reason?: any; } -interface ProgressEvent extends BaseEvent +export interface ProgressEvent extends BaseEvent { progress: number; state?: string; } -interface CompletedEvent extends BaseEvent +export interface CompletedEvent extends BaseEvent { } diff --git a/src/bun/types/typesc.schema.ts b/src/bun/types/typesc.schema.ts index aafc779..c87a6ef 100644 --- a/src/bun/types/typesc.schema.ts +++ b/src/bun/types/typesc.schema.ts @@ -1,28 +1,52 @@ import z from "zod"; import { GameflowHooks } from "../api/hooks/app"; +import Conf from "conf"; +import { $ZodRegistry } from "zod/v4/core"; +import EventEmitter from "node:events"; export const PluginContextSchema = z.object({ hooks: z.instanceof(GameflowHooks) }); +export const PluginLoadingContextSchema = z.object({ + setProgress: z.function().input([z.number(), z.string()]).output(z.void()), + config: z.instanceof(Conf), + zodRegistry: z.instanceof($ZodRegistry) +}).extend(PluginContextSchema.shape); + export const PluginDescriptionSchema = z.object({ name: z.string(), displayName: z.string(), version: z.string(), description: z.string(), icon: z.url().optional(), - keywords: z.array(z.string()).optional() + keywords: z.array(z.string()).optional(), + category: z.string().default("other"), + canDisable: z.boolean().default(true).optional() }); export const PluginSchema = z.object({ - setup: z.function().output(z.promise(z.void())).optional(), - load: z.function().input([PluginContextSchema]).output(z.void()), - onBeforeReload: z.function().input([PluginContextSchema]).output(z.void()).optional(), - cleanup: z.function().output(z.promise(z.void())).optional() + load: z.function().input([PluginLoadingContextSchema]).output(z.promise(z.void())), + cleanup: z.function().output(z.promise(z.void())).optional(), + settingsSchema: z.instanceof(z.ZodObject).optional(), + settingsMigrations: z.record(z.string(), z.function().input([z.instanceof(Conf)]).output(z.void())).optional(), + eventsNames: z.object({ + id: z.string(), + title: z.string().optional(), + description: z.string().optional(), + action: z.string() + }).array().optional(), + onEvent: z.function().input([z.string()]).output(z.any()).optional() }); -export type PluginType = z.infer; +export type PluginType = Record> = Omit, "load" | 'settingsMigrations'> & { + load: (ctx: PluginLoadingContextType) => Promise; + settingsMigrations?: Record) => void>; +}; export type PluginContextType = z.infer; +export type PluginLoadingContextType = Record> = z.infer & { + config: Conf; +}; export type PluginDescriptionType = z.infer; export const ActiveGameSchema = z.object({ diff --git a/src/mainview/App.tsx b/src/mainview/App.tsx index 76dcda3..605f15d 100644 --- a/src/mainview/App.tsx +++ b/src/mainview/App.tsx @@ -9,7 +9,6 @@ export const focusQueue: string[] = []; export default function App (data: { children: any; }) { - useEffect(() => { const focusMap = new Map(); diff --git a/src/mainview/components/AppCommunication.tsx b/src/mainview/components/AppCommunication.tsx index f0ba0a3..b30ef41 100644 --- a/src/mainview/components/AppCommunication.tsx +++ b/src/mainview/components/AppCommunication.tsx @@ -1,12 +1,16 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { SystemInfoContext } from "../scripts/contexts"; import { systemApi } from "../scripts/clientApi"; import { SystemInfoType } from "@/shared/constants"; +import LoadingScreen from "./LoadingScreen"; export default function AppCommunication (data: { children: any; }) { - const [systemInfo, setSystemInfo] = useState(); + const [loadingInfo, setLoadingInfo] = useState(undefined); + const [loading, setLoading] = useState(true); + const loadingProgressBarRef = useRef(null); + useEffect(() => { const sub = systemApi.api.system.info.system.subscribe(); @@ -20,14 +24,32 @@ export default function AppCommunication (data: { children: any; }) case "focus": window.focus(); break; + case "loading": + setLoadingInfo(data.state); + if (loadingProgressBarRef.current) + loadingProgressBarRef.current.value = data.progress; + setLoading(true); + break; + case "loaded": + setLoading(false); + break; } - }); document.documentElement.dataset.loaded = "true"; }, []); return - {data.children} + {loading ? + +
    +
    + + {loadingInfo} +
    + +
    +
    + : data.children}
    ; } \ No newline at end of file diff --git a/src/mainview/components/CardElement.tsx b/src/mainview/components/CardElement.tsx index fdbee68..6960315 100644 --- a/src/mainview/components/CardElement.tsx +++ b/src/mainview/components/CardElement.tsx @@ -4,6 +4,7 @@ import { JSX } from "react"; import { twMerge } from "tailwind-merge"; import useActiveControl from "../scripts/gamepads"; import { oneShot } from "../scripts/audio/audio"; +import ImageWithFallbacks from "./ImageWithFallbacks"; export function GameCardSkeleton () { @@ -21,8 +22,8 @@ export function GameCardSkeleton () export interface GameCardParams extends FocusParams { title: string; - subtitle: string | JSX.Element; - preview?: string | JSX.Element | ((p: { focused: boolean; }) => JSX.Element); + subtitle?: string | JSX.Element; + preview?: string | JSX.Element | URL[] | ((p: { focused: boolean; }) => JSX.Element); srcset?: string; focusKey: string; index: number; @@ -49,6 +50,21 @@ export default function CardElement (data: GameCardParams & InteractParams) }); const { isPointer } = useActiveControl(); + let preview: any = undefined; + if (typeof data.preview === "string") + { + preview = ; + } else if (Array.isArray(data.preview)) + { + preview = ; + } else if (typeof data.preview === 'function') + { + preview = data.preview({ focused }); + } else + { + preview = data.preview; + } + return (
  • - {typeof data.preview === "string" ? ( - - ) : ( - typeof data.preview === 'function' ? data.preview({ focused }) : data.preview - )} + {preview}
    diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index 671018f..23ed5e7 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -20,9 +20,9 @@ export interface GameMetaExtra extends GameMeta function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusParams & InteractParams) { let preview: GameCardParams['preview'] = data.game.preview; - if (!preview && data.game.previewUrl) + if (!preview && data.game.previewUrls) { - preview = data.game.previewUrl; + preview = data.game.previewUrls; } const handleAction = (ctx: InteractParamsArgs) => @@ -40,7 +40,7 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara focusKey={data.game.focusKey} data-index={data.i} title={data.game.title} - subtitle={data.game.subtitle ?? ""} + subtitle={data.game.subtitle} srcset={data.game.previewSrcset} onFocus={(focusKey, node, details) => { @@ -69,8 +69,6 @@ export function CardList (data: { { const { ref, focusKey } = useFocusable({ focusKey: data.id, - forceFocus: true, - autoRestoreFocus: true, focusable: data.games.length > 0, preferredChildFocusKey: data.focus }); diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index 121be98..c04db7f 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -37,7 +37,6 @@ export default function CollectionList (data: { id: `${g.id.source}@${g.id.id}`, title: g.name, focusKey: `collection-${g.id}`, - subtitle: "", previewUrl: `${RPC_URL(__HOST__)}${g.path_platform_cover}`, badges: [ @@ -46,7 +45,7 @@ export default function CollectionList (data: { ], } satisfies GameMetaExtra))} onSelectGame={data.onSelect ? data.onSelect : handleDefaultSelect} - onGameFocus={(id, node, details) => + onFocus={(id, node, details) => { data.setBackground( `https://picsum.photos/id/${10 + (id ?? 0)}/100/100.webp?blur=10`, diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index 0b9e0e1..8108149 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -10,7 +10,7 @@ import { GameListFilterSchema, GameListFilterType } from '@/shared/constants'; import { HandleGoBack } from '../scripts/utils'; import LoadingCardList from './LoadingCardList'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { gameQuery } from '../scripts/queries/romm'; +import { gameFiltersQuery, gameQuery } from '../scripts/queries/romm'; import { useNavigate, useRouter } from '@tanstack/react-router'; import SelectMenu from './SelectMenu'; import { RoundButton } from './RoundButton'; @@ -41,7 +41,6 @@ export interface CollectionsDetailParams export function CollectionsDetail (data: CollectionsDetailParams) { const router = useRouter(); - const [filterValues, setFilterValues] = useState(); const queryClient = useQueryClient(); const finalFilter = { ...data.localFilter, ...data.filters }; const focusKey = `game-list-${data.id}`; @@ -50,6 +49,8 @@ export function CollectionsDetail (data: CollectionsDetailParams) preferredChildFocusKey: `${focusKey}-list` }); + const { data: filterValues } = useQuery(gameFiltersQuery({ source: data.filters?.source })); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]); const handleScroll: FocusParams['onFocus'] = (cardId, node, details) => @@ -79,7 +80,6 @@ export function CollectionsDetail (data: CollectionsDetailParams) = { store: , local: , romm: +}; + +export const pluginCategoryIcons: Record = { + saves: , + sources: , + launchers: , + emulators: +}; + +export const pluginCategoryPriorities: Record = { + saves: 100, + sources: 90, + launchers: 80, + emulators: 60 }; \ No newline at end of file diff --git a/src/mainview/components/FrontEndGameCard.tsx b/src/mainview/components/FrontEndGameCard.tsx index 39bd6ab..3d8ea25 100644 --- a/src/mainview/components/FrontEndGameCard.tsx +++ b/src/mainview/components/FrontEndGameCard.tsx @@ -13,16 +13,24 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); }; - const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`); - platformUrl.searchParams.set('width', "64"); - const subtitle =
    - {!!data.game.path_platform_cover && } -

    {data.game.platform_display_name}

    -
    ; + let subtitle: any = undefined; + if (data.game.path_platform_cover) + { + const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`); + platformUrl.searchParams.set('width', "64"); + subtitle =
    + {!!data.game.path_platform_cover && } +

    {data.game.platform_display_name}

    +
    ; + } - const previewUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_cover}`); - previewUrl.searchParams.delete('ts'); - previewUrl.searchParams.set('width', "640"); + const previewUrls = data.game.path_covers.map(c => + { + const url = new URL(`${RPC_URL(__HOST__)}${c}`); + url.searchParams.delete('ts'); + url.searchParams.set('width', "640"); + return url; + }); const badges: JSX.Element[] = []; @@ -53,7 +61,7 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG badges={badges} onFocus={data.onFocus} onAction={(e) => data.onAction ? data.onAction(e) : handleDefaultSelect(data.game.id, data.game.source, data.game.source_id)} - preview={previewUrl.href} + preview={previewUrls} title={data.game.name ?? ""} subtitle={subtitle} focusKey={FOCUS_KEYS.GAME_CARD(data.game.id)} diff --git a/src/mainview/components/GameList.tsx b/src/mainview/components/GameList.tsx index b94a45d..73caa98 100644 --- a/src/mainview/components/GameList.tsx +++ b/src/mainview/components/GameList.tsx @@ -1,4 +1,4 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { GameMetaExtra, CardList } from "./CardList"; import { DefaultRommStaleTime, GameListFilterType, RPC_URL } from "@shared/constants"; import { useNavigate } from "@tanstack/react-router"; @@ -19,7 +19,6 @@ export interface GameListParams extends FocusParams className?: string; finalElement?: JSX.Element; saveChildFocus?: "session" | "local"; - setFilterValues?: (filters: FrontEndFilterLists) => void; } export function GameList (data: GameListParams) @@ -37,7 +36,7 @@ export function GameList (data: GameListParams) try { const screenshotUrl = game.paths_screenshots && game.paths_screenshots.length > 0 ? new URL(`${RPC_URL(__HOST__)}${game.paths_screenshots[new Date().getMinutes() % game.paths_screenshots.length]}`) : undefined; - const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_cover}`); + const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_covers[0]}`); const previewUrl = blur ? coverUrl : (screenshotUrl ?? coverUrl); previewUrl.searchParams.delete('ts'); data.setBackground?.(previewUrl.href) ?? backgroundContext.setBackground(previewUrl.href); @@ -48,11 +47,6 @@ export function GameList (data: GameListParams) } }; - useEffect(() => - { - data.setFilterValues?.(games.data.filters); - }, [games.data.filters]); - function handleDefaultSelect (g: FrontEndGameType) { navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source } }); @@ -79,23 +73,31 @@ export function GameList (data: GameListParams) badges.push(); } - const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`); - previewUrl.searchParams.delete('ts'); + const previewUrls = g.path_covers.map(c => + { + const url = new URL(`${RPC_URL(__HOST__)}${c}`); + url.searchParams.delete('ts'); + return url; + }); - const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); - platformUrl.searchParams.set('width', "64"); + let platformUrl: URL | undefined = undefined; + if (g.path_platform_cover) + { + platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); + platformUrl.searchParams.set('width', "64"); + } return { id: `${g.id.source}@${g.id.id}`, - focusKey: g.slug ?? `game-${g.id}`, + focusKey: `${data.id}-${g.id.source}@${g.id.id}`, title: g.name ?? "", subtitle: (
    - {!!g.path_platform_cover && } +

    {g.platform_display_name}

    ), - previewUrl: previewUrl.href, + previewUrls: previewUrls, badges: badges, onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g), onFocus: () => handleFocus(g.id, g.source, g.source_id) diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index 3a2e8dd..803e9c7 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -13,6 +13,7 @@ import BatteryWarning, Bell, Bluetooth, + CircleFadingArrowUp, Clock, Settings, Wifi, @@ -31,6 +32,7 @@ import { twitchLoginVerificationQuery } from "../scripts/queries/settings"; import { SystemInfoContext } from "../scripts/contexts"; import { useRouter } from "@tanstack/react-router"; import { oneShot } from "../scripts/audio/audio"; +import { hasUpdateQuery } from "../scripts/queries/system"; function HeaderAvatar (data: { id: string; @@ -83,6 +85,14 @@ export interface HeaderAccount action?: () => void; } +function UpdateStatus () +{ + const hasUnread = false; + return
    + +
    ; +} + function NotificationStatus () { const hasUnread = false; @@ -249,13 +259,15 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; }) { const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' }); + const { data: hasUpdate } = useQuery(hasUpdateQuery); return
    -
    +
    + {!!hasUpdate && hasUpdate >= 1 && }
    {!!data.buttons &&
    } diff --git a/src/mainview/components/ImageWithFallbacks.tsx b/src/mainview/components/ImageWithFallbacks.tsx new file mode 100644 index 0000000..7954da3 --- /dev/null +++ b/src/mainview/components/ImageWithFallbacks.tsx @@ -0,0 +1,19 @@ +export default function ImageWithFallbacks (data: { + src: URL[]; + draggable?: boolean; + className?: string; +}) +{ + const handleError = (e: React.SyntheticEvent) => + { + const img = e.currentTarget; + const nextIndex = Number(img.dataset.index) + 1; + + if (nextIndex < data.src.length) + { + img.dataset.index = String(nextIndex); + img.src = data.src[nextIndex].href; + } + }; + return ; +} \ No newline at end of file diff --git a/src/mainview/components/LoadingCardList.tsx b/src/mainview/components/LoadingCardList.tsx index e25dc26..d811d55 100644 --- a/src/mainview/components/LoadingCardList.tsx +++ b/src/mainview/components/LoadingCardList.tsx @@ -17,7 +17,6 @@ export default function LoadingCardList (data: { id: string, placeholderCount: n ref={ref} title="Games" id={`card-list-placeholder`} - save-child-focus="session" className={twMerge("items-center justify-center-safe h-full", data.grid ? "grid h-fit sm:gap-2 md:gap-5 auto-rows-min grid-cols-[repeat(auto-fill,var(--game-card-width))]" : 'landscape:grid landscape:grid-flow-col landscape:auto-cols-min auto-rows-[1fr] sm:gap-2 md:gap-4 portrait:grid portrait:auto-rows-min portrait:grid-cols-[repeat(auto-fill,var(--game-card-width))] *:portrait:aspect-8/10 *:landscape:aspect-8/12 sm:landscape:max-h-84 md:max-h-128!', diff --git a/src/mainview/components/LoadingScreen.tsx b/src/mainview/components/LoadingScreen.tsx new file mode 100644 index 0000000..f92cc98 --- /dev/null +++ b/src/mainview/components/LoadingScreen.tsx @@ -0,0 +1,9 @@ +export default function LoadingScreen (data: { children?: any; }) +{ + return
    +
    +
    +
    + {data.children} +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/PlatformsList.tsx b/src/mainview/components/PlatformsList.tsx index 039e20a..2efcfbd 100644 --- a/src/mainview/components/PlatformsList.tsx +++ b/src/mainview/components/PlatformsList.tsx @@ -3,10 +3,36 @@ import { useNavigate } from "@tanstack/react-router"; import { DefaultRommStaleTime, RPC_URL } from "@shared/constants"; import { CardList, GameMetaExtra } from "./CardList"; import { rommApi } from "../scripts/clientApi"; -import { JSX, useMemo } from "react"; -import { HardDrive } from "lucide-react"; +import { JSX, useMemo, useState } from "react"; +import { Gamepad2, HardDrive } from "lucide-react"; import { mobileCheck } from "../scripts/utils"; import { twMerge } from "tailwind-merge"; +import placeholder from '../assets/256x256.png?url'; + +function Preview (data: { index: number, pathCover: string | null; }) +{ + const coverUrl = new URL(`${RPC_URL(__HOST__)}${data.pathCover}`); + coverUrl.searchParams.set('width', "320"); + const isMobile = mobileCheck(); + return
    + e.currentTarget.src = placeholder} + src={coverUrl.href} + > + +
    ; +} export function PlatformsList (data: { id: string, @@ -17,7 +43,7 @@ export function PlatformsList (data: { saveChildFocus?: "session" | "local"; } & FocusParams) { - const isMobile = mobileCheck(); + const navigate = useNavigate(); const { data: platforms } = useSuspenseQuery( { @@ -44,37 +70,19 @@ export function PlatformsList (data: { badges.push({g.game_count}); if (g.hasLocal) badges.push(); - const coverUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`); - coverUrl.searchParams.set('width', "320"); + const entry: GameMetaExtra = { id: g.slug, focusKey: g.slug, title: g.name, - subtitle: g.family_name ?? "", - previewUrl: "", + subtitle: g.family_name ?? undefined, + previewUrls: "", badges, onFocus: () => data.setBackground( g.paths_screenshots.length > 0 ? `${RPC_URL(__HOST__)}${g.paths_screenshots[new Date().getMinutes() % g.paths_screenshots.length]}` : `${RPC_URL(__HOST__)}/api/romm/image?url=https://picsum.photos/id/${10 + i}/1280/720.webp`, ), onSelect: () => data.onSelect ? data.onSelect(g.id.source, g.id.id) : handleDefaultSelect(g.id.source, g.id.id), - preview: - () =>
    - -
    - , + preview: () => }; return entry; }), [platforms]); diff --git a/src/mainview/components/SelectMenu.tsx b/src/mainview/components/SelectMenu.tsx index 4ad4878..4caf3a6 100644 --- a/src/mainview/components/SelectMenu.tsx +++ b/src/mainview/components/SelectMenu.tsx @@ -2,7 +2,7 @@ import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { MatchRoute, useMatch, useMatchRoute, useNavigate, useRouterState } from "@tanstack/react-router"; import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; -import { DoorOpen, Gamepad2, RefreshCcw, Settings, Store } from "lucide-react"; +import { DoorOpen, Gamepad2, Puzzle, RefreshCcw, Settings, Store } from "lucide-react"; import { systemApi } from "../scripts/clientApi"; import { FOCUS_KEYS } from "../scripts/types"; @@ -54,12 +54,24 @@ export default function SelectMenu (data: { rootFocusKey: string; }) action (ctx) { setOpen(false); - navigate({ to: "/settings/accounts" }); + navigate({ to: "/settings/interface" }); }, - selected: !!matchRoute({ to: '/settings/accounts' }), + selected: !!matchRoute({ to: '/settings' }) && !matchRoute({ to: '/settings/plugins' }) && !matchRoute({ to: '/settings/plugin/$source' }), type: "accent", id: "settings-m" }, + { + content: "Plugins", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/settings/plugins" }); + }, + selected: !!matchRoute({ to: '/settings/plugins' }) && !matchRoute({ to: '/settings/plugin/$source' }), + type: "accent", + id: "plugins-m" + }, { content: "Reload", icon: , diff --git a/src/mainview/components/game/ActionButton.tsx b/src/mainview/components/game/ActionButton.tsx index e950a67..7a6db5e 100644 --- a/src/mainview/components/game/ActionButton.tsx +++ b/src/mainview/components/game/ActionButton.tsx @@ -12,7 +12,7 @@ export default function ActionButton (data: { square?: boolean, onFocus?: () => void; tooltip?: string, - tooltip_type?: 'accent' | 'error'; + tooltipType?: 'accent' | 'error'; disabled?: boolean; } & InteractParams) { @@ -30,7 +30,7 @@ export default function ActionButton (data: { ref={ref} onClick={e => data.onAction?.({ event: e.nativeEvent, focusKey })} data-tooltip={data.tooltip} - data-tooltip-type={data.tooltip_type} + data-tooltip-type={data.tooltipType} className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30 active:bg-base-100 active:transition-none active:text-base-content", "hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md:size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}> {data.icon} diff --git a/src/mainview/components/game/ActionButtons.tsx b/src/mainview/components/game/ActionButtons.tsx index 8730779..446de01 100644 --- a/src/mainview/components/game/ActionButtons.tsx +++ b/src/mainview/components/game/ActionButtons.tsx @@ -1,10 +1,10 @@ -import { deleteGameMutation, fixSourceMutation, gameInvalidationQuery, validateSourceQuery } from "@/mainview/scripts/queries/romm"; +import { deleteGameMutation, fixSourceMutation, gameInvalidationQuery, updateSourceMutation, validateSourceQuery } from "@/mainview/scripts/queries/romm"; import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { useMutation, useQuery } from "@tanstack/react-query"; import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; import { getErrorMessage } from "react-error-boundary"; import toast from "react-hot-toast"; -import { Hammer, Settings, Trash, Trophy } from "lucide-react"; +import { Hammer, RefreshCcw, Settings, Trash, Trophy } from "lucide-react"; import MainActions from "./MainActions"; import ActionButton from "./ActionButton"; import { useLocalStorage } from "usehooks-ts"; @@ -34,7 +34,8 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots'); const fixMutation = useMutation({ - ...fixSourceMutation, onSuccess (data, variables, onMutateResult, context) + ...fixSourceMutation, + onSuccess (data, variables, onMutateResult, context) { if (onMutateResult) toast.success("Updated Source"); context.client.invalidateQueries(gameInvalidationQuery(variables.id, variables.source)).then(() => router.history.back()); @@ -44,6 +45,18 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, toast.error(getErrorMessage(error) ?? "Error While Trying To Fix"); } }); + const updateMutation = useMutation({ + ...updateSourceMutation, + onSuccess (data, variables, onMutateResult, context) + { + if (onMutateResult) toast.success("Updated Source"); + context.client.invalidateQueries(gameInvalidationQuery(variables.id, variables.source)); + }, + onError (error) + { + toast.error(getErrorMessage(error) ?? "Error While Trying To Update"); + } + }); const { data: validation } = useQuery(validateSourceQuery(data.source, data.id)); const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: 'actions', forceFocus: true, trackChildren: true, preferredChildFocusKey: 'mainAction' }); const router = useRouter(); @@ -62,7 +75,7 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, useBlocker({ shouldBlockFn: () => { - return deleteMutation.isPending || fixMutation.isPending; + return deleteMutation.isPending || fixMutation.isPending || updateMutation.isPending; } }); @@ -85,15 +98,34 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, { contextOptions.push({ id: "fix_source", - action (ctx) + async action (ctx) { - if (data.game) - fixMutation.mutate({ source: data.game.id.source, id: data.game.id.id }); + if (!data.game) return; + await fixMutation.mutateAsync({ source: data.game.id.source, id: data.game.id.id }); + ctx.close(); + router.navigate({ replace: true }); }, icon: fixMutation.isPending ? : , content: "Try Fix Source", type: "warning" }); + } else if (data.game?.id.source === 'local') + { + contextOptions.push({ + id: 'update_source', + async action (ctx) + { + if (data.game) + { + await updateMutation.mutateAsync({ source: data.game.id.source, id: data.game.id.id }); + ctx.close(); + router.navigate({ replace: true }); + } + }, + icon: updateMutation.isPending ? : , + content: "Update Metadata", + type: "primary" + }); } const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: , canClose: !deleteMutation.isPending }); diff --git a/src/mainview/components/game/Details.tsx b/src/mainview/components/game/Details.tsx index c4166a5..ce7add0 100644 --- a/src/mainview/components/game/Details.tsx +++ b/src/mainview/components/game/Details.tsx @@ -40,7 +40,7 @@ export default function Details (data: { const platformCoverImg = data.game?.path_platform_cover ? new URL(`${RPC_URL(__HOST__)}${data.game?.path_platform_cover}`) : undefined; if (platformCoverImg) platformCoverImg.searchParams.set("width", "64"); - const gameCoverImg = data.game?.path_cover ? `${RPC_URL(__HOST__)}${data.game?.path_cover}` : undefined; + const gameCoverImg = data.game?.path_covers ? `${RPC_URL(__HOST__)}${data.game?.path_covers[0]}` : undefined; let fileSizeIcon: JSX.Element | undefined; if (!data.game) diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index d4067ee..681afd2 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -69,7 +69,6 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so { const errorMessage = getErrorMessage(e.data.error); if (!errorMessage) return; - toast.error(errorMessage); setError(errorMessage); } }); @@ -137,7 +136,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so mainButton = { @@ -169,7 +168,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so { case 'present': case 'install': - installMut.mutate(); + installMut.mutate({}); break; } }} diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index 31c1d27..08a6261 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -19,6 +19,7 @@ export function OptionInput (data: { step?: number; defaultValue?: string | boolean | number; autocomplete?: HTMLInputAutoCompleteAttribute; + compact?: boolean; onBlur?: FocusEventHandler; onChange?: (value: string | number | boolean) => void; }) @@ -121,7 +122,7 @@ export function OptionInput (data: { }; return ( -
    @@ -59,22 +64,23 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; }) {game ?
    - { e.currentTarget.dataset.loaded = "true"; e.currentTarget.classList.toggle('scale-110', false); }} - /> + > + {previewUrls?.map((u, i) => )} +
    - {!!data.games && } + {!!data.games && }

    {game.name}

    @@ -133,7 +139,7 @@ export function RouteComponent ()
    storeContext.showDetails('emulator', 'store', id, focus)} + onSelect={(em, focus) => storeContext.showDetails('emulator', em.source, em.name, focus)} onFocus={scrollIntoViewHandler({ block: 'end' })} emulators={recommendedEmulators} />
    diff --git a/src/mainview/routes/store/tab/route.tsx b/src/mainview/routes/store/tab/route.tsx index f459099..d771fa6 100644 --- a/src/mainview/routes/store/tab/route.tsx +++ b/src/mainview/routes/store/tab/route.tsx @@ -104,6 +104,7 @@ function RouteComponent () { if (type === 'emulator') { + if (source === 'local') return; router.navigate({ to: '/store/details/emulator/$id', params: { id } }); } else if (type === 'game') diff --git a/src/mainview/scripts/queries/plugins.ts b/src/mainview/scripts/queries/plugins.ts index 6be97dc..323b168 100644 --- a/src/mainview/scripts/queries/plugins.ts +++ b/src/mainview/scripts/queries/plugins.ts @@ -11,6 +11,15 @@ export const getAllPluginsQuery = queryOptions({ } }); +export const getPluginDetailsQuery = (source: string) => queryOptions({ + queryKey: ['plugins', source], queryFn: async () => + { + const { data, error } = await pluginsApi.plugins({ id: source }).get(); + if (error) throw error; + return data; + } +}); + export const enablePluginMutation = mutationOptions({ mutationKey: ['plugin', 'enable'], mutationFn: async (vars: { id: string, enabled: boolean; }) => diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts index 3f73bcf..ded28d1 100644 --- a/src/mainview/scripts/queries/romm.ts +++ b/src/mainview/scripts/queries/romm.ts @@ -1,6 +1,6 @@ import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants"; import { rommApi, settingsApi } from "../clientApi"; -import { mutationOptions, QueryFilters, queryOptions, useMutation } from "@tanstack/react-query"; +import { InvalidateQueryFilters, mutationOptions, QueryFilters, queryOptions, useMutation } from "@tanstack/react-query"; import z from "zod"; import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen"; @@ -72,8 +72,8 @@ export const rommLoggedInQuery = queryOptions({ return data; } }); -export const rommHostnameQuery = queryOptions({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) }); -export const rommUsernameQuery = queryOptions({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) }); +export const rommHostnameQuery = queryOptions({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ source: 'local' })({ id: 'rommAddress' }).get().then(d => d.data?.value as string) }); +export const rommUsernameQuery = queryOptions({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ source: 'local' })({ id: 'rommUser' }).get().then(d => d.data?.value as string) }); export const deleteGameMutation = (id: FrontEndId) => mutationOptions({ mutationKey: ['delete', id], mutationFn: () => rommApi.api.romm.game({ source: id.source })({ id: id.id }).delete() @@ -107,9 +107,9 @@ export const platformQuery = (source: string, id: string) => queryOptions({ }); export const installMutation = (source: string, id: string) => mutationOptions({ mutationKey: ['install', source, id], - mutationFn: async () => + mutationFn: async (init: { downloadId?: string; }) => { - const { data, error } = await rommApi.api.romm.game({ source })({ id }).install.post(); + const { data, error } = await rommApi.api.romm.game({ source })({ id }).install.post({ query: { downloadId: init.downloadId } }); if (error) throw error; return data; } @@ -170,4 +170,45 @@ export const fixSourceMutation = mutationOptions({ if (error) throw error; return data; } +}); +export const updateSourceMutation = mutationOptions({ + mutationKey: ['game', "update_source"], mutationFn: async ({ source, id }: { source: string, id: string; }) => + { + const { data, error } = await rommApi.api.romm.game({ source })({ id }).update.post(); + if (error) throw error; + return data; + } +}); +export const updatePlatformMutation = (id: string) => mutationOptions({ + mutationKey: ['platform', 'local', 'update', id], + mutationFn: async () => + { + const { data, error } = await rommApi.api.romm.platform.local({ id }).update.post(); + if (error) throw error; + return data; + } +}); +export const deletePlatformMutation = (id: string) => mutationOptions({ + mutationKey: ['platform', 'local', 'delete', id], + mutationFn: async () => + { + const { data, error } = await rommApi.api.romm.platform.local({ id }).delete(); + if (error) throw error; + return data; + } +}); +export const localPlatformFilter = (id: string) => ({ + predicate (query) + { + return query.queryKey.includes('platform') && ((query.queryKey.includes('local') && query.queryKey.includes(id)) || query.queryKey.includes('all')); + }, +} satisfies InvalidateQueryFilters as InvalidateQueryFilters); + +export const gameFiltersQuery = (filters: { source?: string; }) => queryOptions({ + queryKey: ['game', 'filters', filters], queryFn: async () => + { + const { data, error } = await rommApi.api.romm.games.filters.get({ query: { source: filters.source } }); + if (error) throw error; + return data; + } }); \ No newline at end of file diff --git a/src/mainview/scripts/queries/settings.ts b/src/mainview/scripts/queries/settings.ts index 186b224..5b99ace 100644 --- a/src/mainview/scripts/queries/settings.ts +++ b/src/mainview/scripts/queries/settings.ts @@ -113,7 +113,7 @@ export const setSettingMutation = (id?: string) => mutationOptions({ mutationKey: ["setting", id], mutationFn: async (value: any) => { - const response = await settingsApi.api.settings({ id: id! }).post({ value }); + const response = await settingsApi.api.settings.local({ id: id! }).post({ value }); if (response.error) throw response.error; return response.data; } @@ -123,9 +123,58 @@ export const getSettingQuery = (id: string | undefined) => queryOptions({ queryKey: ["setting", id], queryFn: async () => { - const { data: value, error } = await settingsApi.api.settings({ id: id! }).get(); + const { data: value, error } = await settingsApi.api.settings.local({ id: id! }).get(); if (error) throw error; return value.value; }, +}); +export const getPluginSettingsDefinitionQuery = (source: string) => queryOptions({ + queryKey: ['settings', source, 'definitions'], + queryFn: async () => + { + const { data: value, error } = await settingsApi.api.settings.definitions({ source }).get(); + if (error) throw error; + + return value; + } +}); +export const getPluginSettingQuery = (source: string, id: string) => queryOptions({ + queryKey: ["setting", source, id], + queryFn: async () => + { + const { data, error } = await settingsApi.api.settings({ source })({ id }).get(); + if (error) throw error; + + return data; + }, +}); +export const setPluginSettingMutation = (source: string, id: string) => mutationOptions({ + mutationKey: ["setting", source, id], + mutationFn: async (value: any) => + { + const { data, error } = await settingsApi.api.settings({ source })({ id }).put({ value }); + if (error) throw error; + + return data; + }, +}); +export const getPluginActionsQuery = (source: string) => queryOptions({ + queryKey: ['plugin', source, 'actions'], queryFn: async () => + { + const { data, error } = await settingsApi.api.settings.actions({ source }).get(); + if (error) throw error; + + return data; + } +}); +export const pluginActionMutation = (source: string, id: string) => mutationOptions({ + mutationKey: ["plugin", source, "action"], + mutationFn: async () => + { + const { data, error, response } = await settingsApi.api.settings.actions({ source })({ id }).post(); + if (error) throw error; + + return { data: data as any, response }; + }, }); \ No newline at end of file diff --git a/src/mainview/scripts/queries/store.ts b/src/mainview/scripts/queries/store.ts index 648f9ef..9e506a9 100644 --- a/src/mainview/scripts/queries/store.ts +++ b/src/mainview/scripts/queries/store.ts @@ -43,6 +43,7 @@ export const storeEmulatorDeleteMutation = mutationOptions({ if (error) throw error; } }); + export const storeGamesInfiniteQuery = (filter: GameListFilterType) => infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({ initialPageParam: 0, queryKey: ['store-games', filter], @@ -55,6 +56,7 @@ export const storeGamesInfiniteQuery = (filter: GameListFilterType) => infiniteQ return { data: games.games, nextPage: pageParam + 1 }; } }); + export const storeGetStatsQuery = queryOptions({ queryKey: ['store', 'stats'], queryFn: async () => { diff --git a/src/mainview/scripts/queries/system.ts b/src/mainview/scripts/queries/system.ts index 853e3a9..b46e4ea 100644 --- a/src/mainview/scripts/queries/system.ts +++ b/src/mainview/scripts/queries/system.ts @@ -46,4 +46,14 @@ export const closeMutation = mutationOptions({ const { error } = await systemApi.api.system.exit.post(); if (error) throw error; } +}); +export const hasUpdateQuery = queryOptions({ + queryKey: ['update'], + queryFn: async () => + { + const { data, error } = await systemApi.api.system.update.get(); + if (error) throw error; + return data; + }, + staleTime: 1000 * 60 * 30 }); \ No newline at end of file diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5d7b307..5523dac 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,6 +1,5 @@ -import { FocusDetails } from '@noriginmedia/norigin-spatial-navigation'; import { JSX } from 'react'; import * as z from 'zod'; @@ -14,7 +13,6 @@ export const RPC_PORT = 8787; export const RPC_URL = (host: string) => `http://${host}:${RPC_PORT}`; export const EMULATORJS_URL = (host: string) => `http://${host}:${EMULATORJS_PORT}`; export const SOCKETS_URL = (host: string) => `ws://${host}:${RPC_PORT}`; -export const STORE_VERSION = "^0"; export const DefaultRommStaleTime = 60 * 1000; // A minute export interface GameMeta extends FocusParams @@ -22,8 +20,8 @@ export interface GameMeta extends FocusParams id: string, onSelect?: () => void, title: string, - subtitle: string | JSX.Element, - previewUrl?: string; + subtitle?: string | JSX.Element, + previewUrls?: string | URL[]; previewSrcset?: string; }; @@ -88,17 +86,46 @@ export const GithubManifestSchema = z.object({ })) }); -export const StoreGameSchema = z.object({ - system: z.string(), - title: z.string(), - url: z.string().optional(), - file: z.url(), - description: z.string(), - pictures: z.object({ - screenshots: z.array(z.string()), - titlescreens: z.array(z.string()) +export const StoreGameSaveSchema = z.object({ + cwd: z.string(), + globs: z.string().array() +}); + +export const StoreDownloadSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('direct'), + url: z.url(), + name: z.string().optional(), + system: z.string(), + main: z.string().optional(), + saves: z.record(z.string(), StoreGameSaveSchema).optional() }), - tags: z.array(z.string()) + z.object({ + type: z.literal("itch"), + path: z.string(), + name: z.string().optional(), + system: z.string(), + saves: z.record(z.string(), StoreGameSaveSchema).optional() + }) +]); + +export const StoreGameSchema = z.object({ + name: z.string(), + description: z.string(), + version: z.string(), + homepage: z.string().optional(), + keywords: z.string().array().optional(), + genres: z.string().array().optional(), + companies: z.string().array().optional(), + screenshots: z.string().array().optional(), + covers: z.string().array().optional(), + igdb_id: z.number().optional(), + ra_id: z.number().optional(), + sgdb_id: z.number().optional(), + first_release_date: z.union([z.number(), z.date()]).optional(), + player_count: z.string().optional(), + saves: z.record(z.string(), z.record(z.string(), StoreGameSaveSchema)).optional(), + downloads: z.record(z.string(), StoreDownloadSchema) }); export const EmulatorPackageSchema = z.object({ @@ -175,6 +202,7 @@ export const EmulatorDownloadInfoSchema = z.object({ export type EmulatorPackageType = z.infer; export type StoreGameType = z.infer; +export type StoreDownloadType = z.infer; export type SettingsType = z.infer; export type LocalSettingsType = z.infer; export const PlatformSchema = z.object({ slug: z.string() }); diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index ca0fc49..7d2ca5f 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -11,6 +11,7 @@ declare interface EmulatorSourceEntryType declare interface FrontEndEmulator { name: string; + source: string; logo: string; systems: EmulatorSystem[]; description?: string; @@ -57,14 +58,15 @@ declare interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator } -declare interface FrontEndGameTypeDetailed extends Exclude +declare interface FrontEndGameTypeDetailed extends Exclude { summary: string | null; fs_size_bytes: number | null; missing: boolean; local: boolean; - imdb_id?: number; - ra_id?: number; + version?: string | null; + version_system?: string | null; + version_source?: string | null; metadata: FrontEndGameMetadataDetailed, emulators?: FrontEndGameTypeDetailedEmulator[], achievements?: { @@ -118,6 +120,8 @@ declare interface CommandEntry label?: string; /** Compiled command to be executed */ command: string; + /** Environment variables */ + env?: Record, /** The path the spawned process will start at */ startDir?: string; /** Is the command valid, for example does the executable exists */ @@ -201,7 +205,7 @@ declare interface FrontEndGameType source: string | null, source_id: string | null, path_fs: string | null, - path_cover: string | null, + path_covers: string[], last_played: Date | null, updated_at: Date, metadata: FrontEndGameMetadata, @@ -231,8 +235,11 @@ declare interface FrontendPlugin name: string; displayName: string; description: string; + category: string; enabled: boolean; + canDisable: boolean; source: PluginSourceType; + hasSettings: boolean; version: string; icon?: string; } @@ -250,6 +257,7 @@ declare interface DownloadInfo platform?: DownloadPlatform; slug?: string; path_fs?: string; + main_glob?: string; summary?: string; name: string; last_played?: Date; @@ -261,6 +269,9 @@ declare interface DownloadInfo metadata?: any; files: DownloadFileEntry[]; auth?: string; + version?: string; + version_source?: string; + version_system?: string; } declare interface DownloadPlatform @@ -315,9 +326,18 @@ declare interface EmulatorSupport capabilities?: EmulatorCapabilities[]; } -declare interface SaveFileChange +declare interface AutoSaveChange { subPath: string; cwd: string; +} + +declare interface SaveFileChange +{ + subPath: string | string[]; + isGlob?: true; + cwd: string; shared: boolean; -} \ No newline at end of file +} + +declare type SaveSlots = Record; \ No newline at end of file diff --git a/src/tests/game-launching.test.ts b/src/tests/game-launching.test.ts index 3ac7d8a..9a0f960 100644 --- a/src/tests/game-launching.test.ts +++ b/src/tests/game-launching.test.ts @@ -1,24 +1,35 @@ -import { expect, test } from 'bun:test'; +import { expect, test, beforeEach, describe } from 'bun:test'; import path, { resolve } from 'node:path'; import * as app from '@/bun/api/app'; +import * as appSchema from '@/bun/api/schema/app'; +import { } from 'node:test'; test("uses custom emulator", async () => { app.customEmulators.set('PCSX2', resolve("./src/tests/mock-roms/mock-emulator.exe")); - - const { getValidLaunchCommands: getLaunchCommands } = await import('@/bun/api/games/services/launchGameService'); - const commands = await getLaunchCommands({ - systemSlug: 'ps2', - gamePath: './mock-rom.iso' - }); + const mockPlatform: typeof appSchema.platforms.$inferInsert = { + name: 'Test', + slug: 'ps2', + }; + await app.db.insert(appSchema.platforms).values(mockPlatform); + const mockGame: typeof appSchema.games.$inferInsert = { + platform_id: 1, + path_fs: './mock-rom.iso' + }; + await app.db.insert(appSchema.games).values(mockGame); await Bun.write(path.join(app.config.get('downloadPath'), 'mock-rom.iso'), "This is a mock Rom"); await Bun.write(path.join(app.config.get('downloadPath'), 'mock-emulator.exe'), "This is a mock Emulator"); + const { getValidLaunchCommandsForGame } = await import('@/bun/api/games/services/statusService'); + const commands = await getValidLaunchCommandsForGame('local', '1'); + expect(commands) .toSatisfy((d) => { - const validCommand = d.find(c => + if (d instanceof Error) return false; + if (!d) return false; + const validCommand = d.commands.find(c => c?.command.includes("mock-rom.iso") && c.command.includes("mock-emulator.exe") ); diff --git a/src/tests/mock-roms/mock-emulator.exe b/src/tests/mock-roms/mock-emulator.exe new file mode 100644 index 0000000..8a4beb5 --- /dev/null +++ b/src/tests/mock-roms/mock-emulator.exe @@ -0,0 +1 @@ +This is a mock Emulator \ No newline at end of file diff --git a/src/tests/mock-roms/mock-rom.iso b/src/tests/mock-roms/mock-rom.iso new file mode 100644 index 0000000..802b307 --- /dev/null +++ b/src/tests/mock-roms/mock-rom.iso @@ -0,0 +1 @@ +This is a mock Rom \ No newline at end of file diff --git a/src/tests/preload.ts b/src/tests/preload.ts index be6b38d..e9a17b9 100644 --- a/src/tests/preload.ts +++ b/src/tests/preload.ts @@ -20,6 +20,7 @@ beforeAll(async () => process.env.CUSTOM_STORE_PATH = resolve('./src/tests/mock-store'); process.env.CONFIG_CWD = resolve('./src/tests/mock-config'); process.env.DEFAULT_DOWNLOAD_PATH = resolve('./src/tests/mock-roms'); + process.env.PLUGIN_BLACKLIST = 'com.simeonradivoev.gameflow.rclone'; }); async function FileCleanup () diff --git a/vendors/es-de/emulators.darwin.x64.sqlite b/vendors/es-de/emulators.darwin.x64.sqlite index b669d1970556cd03ae1c00c4bc1efc99fe9388f9..ca00b69b85930a87b9177d272d2105de2d765bc0 100644 GIT binary patch delta 22237 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≥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%0AR^;)NRh0Br6?)4&ZY1{h z78Mt5-dtGeE-r#=!)5nM25c{gKdHo@u<*~t1Fga)mR0FFMC-+uXlJs-e~JGPe-OVG zKNCL`-w|IIUlM*ToXel!=NS$gTJ_)SAJZ4=?$+(mmFjryJ=!kqG|eA1k85t#G;+te z_qay&x$NKBO$>owU^}#_Euk##b)K1H)?c_hLQ!8p*_E&rY^F!IWsc7xyZhr)8#G8tfhqd=9^tle>_PSdc00R~tzmOmJ@Y;D26I1ih`Ef}%M37UnWaoIlLSA)hwwDq0wD;( zMp#aZi~m`rG%iyGwgLBu&$Fe+KQ`v+9Ue`R9GuMtwn2Z-P71fvn^8*TEGotp461Yz zMPx|!NPQp+6>MuD6GivDK$mG138=D39x{hq$;7!-VBLaa<@JvQb%;wstBZA76LpR% zi~{X|Rp|h}y8v;##M3IQ!Ju|Af@zW=vOAa#`p8v5XPSx!3~*3;t|?Dquxc1as~ zIhZ;wX*gRZxM*=m%2#OX<4kE1hg6ko%fN6?f*L29CAHGAU@$k1a!4#^bAhwJueZlN z><;wj2ZyVb{{)9qB?f6KC+&2c<{lnLrr@wm0@_Mvyp>|?ofJt$N5GjFfyNlYF|O1@ zTOBVeHAYI^qf|qi94Eyn;%?erXD~Q3jyRJuQB1%+HHNvra4I~;3n)Xj&<4CG%N0jX zj0-owa-z+GelHRbqjUbI<@vY?Yr9NwEv?$q`gMF zTf14?rmfb_(WYy8%?ZsPH1}z)(S$WyHPxCqniTFN_dfS9cQbcB=j9f1nH;F!Ro|<= zUVXm$4D~kka`n`eY9sqk_D}4~?8EGhY>=&Gi`g`mVSZrVVV-1;Ft;)h^by;bHB24) zh#W?Mf5M;OWq1g#fiU=R@Fx8^itP*70J+*HCMSwBj;)v&co%_S<4_7E@f3 zTu9BIEpGnGDKc3upjA)!FV$ldEy#1I6+>S&OhL1p&)KYCjn&~qoG8ym6!k9S*p=WI z{w(sWN#2FFBuSn**}JqM(k#zVilc@V!798&dAd>@HK+*UkC*de?q<6JO;ezN+l7!# z&gEd1lHhC)9R08&yIh5NG3|7SPQzl-ZoW%;h*d}L_89i&2$Lr##B-u$v%TC)891JcRbO*MTSmZQ1UWy3u+upRddIPggKi9`0{PLXDotU^l2edLWv8tnLrkB?EbXGru*65LzRzQk zcG3=65*^Cy^CU}SG_tWM&B0vAGt%SLnEE{F(hlOQfQ(h6w6B(6(AeiONFxdmaGPoQ z!?X#CA?WMzq~Ik=LD~i-%tR1>7AZg*k%%&vBQ8nWPClxDG>iQw0T$aHB7xv~<4tB*W@kMYg1iUp+ck~dZ{hmc9?*U|bCf%qyqj(uKtz%H$&wN99v z8F7kJ>LlM(feY3UelBF!tfpmSZefcvn57N{6>y7jlwDed=9FlvZM445|?rq8PK(nwY%Jy}=T^M5!e)c?jaqDmByc5+bEK zdW(W>!cH+0v!}WivAopCS@IN7*@|GQ)S##yZR=(?W*+`QPwZ)zR*?7Sfx}r(ioYVQ@Ty|-}S zjAtlVLN7rop)JGUL`Ol3L<68`tb=edy#%Q!#-gR5MJha%r3i6Ff>gj+<|xHzC~TJI zP@ZVIj4IQ#)xsRF<|U9wi&C9GmR-mweW-RvGBZbM7UZwOYjL@LXD6o z*ac>s|CaxNf0q9ZPx!NWKi|nO;%D(r!@mq~8J;lQW;kHjVdyt>8LACa3`YHr`Y-fv z=zpibO@D!YOy8q#)i2N&=&icH>ps#wuX{julkQU8**cG|RX0zUtF!7|a3Xow4hco(@6jH2rHU=rdks+@$&=+uT9uq_B6M7UJ9>t$E)J29i zfK6N%lfgPfW{1|2OE;jE=_C(qKo7NsytM(Qj#Hb>pkS%~7#yN@#y#5K>-8svI#8m) zrUZ@{jaY+tvxLxe$FzmQ^hye~PZEL^6lo2$QH`C*49cRcE0r>-#!^_Mw`ykyZTJk8 zO^Fk+Q-ORRjkJ8Iz(^KCEwl@$uj(k+tbhSGOF?_6iLCF&0ID$&q0>;)(;K~~gc`_& z-H_^7L0dBsmgPms_E0@}9N!*ajz(VPNCc|!*G(bCbfdpXj=o?DDVEz2aO-u$RC!;YW_2Yv~1MXkes;>!0SQ%DT)rxe`4~f_90hV zXfZJ@Mvq-XW-UhNvj{tOs`I%hNEe?+2UA1Uqm7}3|L=An=jj5k6dfz&f8d`wU z#wMJ->gccIBztH+xeE)2dE~jp7~0My#}-3IUKJg9v<@k8K69v2K?U4-aa2mEg3PJK z0Y}puOR=J)=shJ=M!G4vbn<-UN6F4m3AwlyEnhLYvlh~G6f=yYAFI(6Dx~${=*Q~I z4i%7ZYEhu)5X%zC&djHSk&}p3RR^(H4Es@_y*sR-*<|GsuvN}dmJ4an8*~rSd~C>z z|52Xykdkjs#5v326lZ7#xo!!#dZuFwl*QAdagrFyqhXvP0z*OV5T>J{X$f^i5Pz0X zF4d7l1k#JRq|j742op}N0FkMo9O9~jvqVv;dKOh%QhA_Gtz}){q%H6Cd5u{QRQAp9Gh)MUL&z@gqPd>3eBj9+8W;)Ymu{qw(iC|o%`cQSgg8YZSmiO= zn#4kaR>3CAJ7{|*77}(U=xlkEBY#~E*^E3wQtB~Zl80&axFXJOCluOauqV7@;{AIb6n>283O72D`*bSLIrinhpG6F$)&z~b>rlKT^mPQlTe zQQi`NF+^dz+(#a7K<~4ecIK1>;(Y&**Vp3@m}QzSGMG82-i)D=Wy~4v(fRQ8VQxQD zxm#t-9Ahhk3PQQrCIHLDW!b_AlB*YOx{8{Anlf-ce<&?YV^iODm6Om?lol+WY$2h_%c!}~_r8w$~ z5yT%achWYXMzRXDMu9MQLdYzyo@h^RFxiLcpNw%!%rqc~Ka0F-QhOF7E=g{m+#YQj zVpHWd($I_=fniF_`o=?%e5EK}ZjH4?M0Q?QLKC!9NeCs&&5H0*16f|$+Y2}N=p#vP zqT-?G356A;Rc@T5BQ*-vCO1Uqi+tJ)mhly_q2Hwt&2s%jSIbM)h!kavTjE_UFGY++ zUN)(#ix8J2FP+>~2O`Zf#x1ej2|+zxb{T_|Q+hp4Bd~m>SYED;b=!oDyu3Iz1q?zK zxrUac#EB)n!CW8S%<>}IFhzk7q^Kmhnzk&FDlDW(tBkAXr$&jX6l;+e&=Sqc1}MT_ zigd{H$%Z0uW-56lDxZa71$i!QiY?$)%FC3i2r0rfxk~aN@{SuZXgR3^f%LMCSp3v)N5K{Dl(;-RL0M7AL4G~O_+L&8rK<@81sz|;YZ<^@T%~L zaI3Ii2ny?kCBiHrO#uEF|2F?S{!ac8{r8(~au-bZtt)Vb=bn{X%ZPV*(E8@FpN)(mM{G>bK*nhcGW`wRCW_X_tIcbL0|+rxEp zOF7I))qhd{N&SlYA@wcl3)DMjt9#XL>I!wbnq`l%udw&BH?iliTiKOt1?$4yh`%x) zGSA~~#C6Ph%(lCw8er4IKC;aRuJJHhz*wBWYpk+}UPSB9p`0sK`#l`Km~i zm)gYW7B+=<(3>Spjbb#;r!c*QFtv#^x^JRjYPe5XZb_j^G=>}EexJ7}_OypL6PF(s zL&IKd2b)+dWyZ2%94X;mveFNBChQ?w{oq^??x8NqNo@&b$;Z-StYX+r>qXN#C5A~2 zE30VAzA#0VlotxIr#;+FUh!kFy>U{-s-uTu3@PCaq;VxS4b!mLI&F+n7kj3LyU5;^ zXjzne46DtK5t71a?_y~dMe+1z33n!jpThJ?4zHoLClp9^^dLoB!>cE&P!h!^g`>$E zwX762(wil`iklLj0E(cO5N?l6fHI2d;Wjd_4RgblWL+D$Qd{YiCl=z?jg?p8YIaJv zg{ z=J0Y_U1I%t?HCH(GAdsa>(8ne$57~&lGb)eDMlOSQdyL=ZHqfFlurxc!%MJQ1{XV3 z?~N51!iwdx1l)_piZa5B$rbI8g}Kv%?T}fyh}M=JUxS&l!|&_$27)`>K1aBk4r+EH zlc<=WWrP;4YisOb#mxUW!ER==W*x>h zrNcpEi1T77F|4eB;nu+B7$zxPObR=&7Zdw_JNo-a=#>Y6B%nIX55`_p3FEKomIuT__ z4MF@_!ZYZMC9x@Djt(oQw(!g81XZ%F-Fe6=sC& z;i;UBEM5)v^*M>`X*4+4Kj4ktQ^HfIBu=a$sNRngvT26mpt=^t%#1!v;rPDbSQL>F z&LsD(M$yS2Z?1;Sbk`*LYZ&bh43D@6aQWU5PA8T%fOAXo*PuH~BQ0z2H3u151IbGF|xEcUR4l})&WNax4! zDPa?xvI&9S9V4WMjR}E{p>ktSdsraXcY-5V*`z~h-W(%{VI^NfVV;v<)sy#;bsY1w zSQb383-^k~qIWT@r3!(o3aYoG7*kk7M--P8TB8VCm`l7mymQE#KNh{Ggw_8h0*9gm zQ&`E_u*{)2C&Q&up`dDv?kg&{gm4Ei79LQTUdf>yC{q*uiGA!$ zinfME>10m?2nw?!GYMf&@g#?9XLBeK{_wks!WO&LIDK~xOEh? zhcMTR$z5>gU{Al>7ki|JFx`vU#TcqK_Oyrmq@W8@(?Y()3hgSdA^l_QE#y%2q7gtn5yU67d^>ZfB#52VuSY-BP52GCEVz+O$EDr%H1eIPx>zNCE=I(4q7Kj9>^99b>5QLYbPNAIto;9T zSo!{SyWuvbIad`?iNm5`I>Y$A@u;y)xKt4MgZw^zfNwS2gWD8qbXV!Bw2x?ewHD2z zxWllBdx7g#A5)*B&SQ_Ve&!cEK(qi}06%C{kpofoPJX!%A36 zo|mB;>d4FxxN%Eze+Wi2wOlT8k^>=dUj8GjV1kQD&Dk)luTk3^)q}WE9C@N5lWAK- zK0F)Rp_-JO1MT{SYFo`VN+dTv11m6O`r|pU#(;Z=1yYNG&ORU9WaxgdFx~UWUFSkK zE6trts{Ra*XsWo(cB!8%{4;t%`!^tAQg_ui@PG!Rp%%$YuKx!8Vc=WX&7_qR^FDNz zWn}g~)PPb_zYl}b60&6<^x{hLOZ(s)C?Xx_!J|+}tmlK|Dxk;OxA}YfPdi#J>E@8T z&xi9MpUmG67r|`u;(i>^S)}X&*tdyl)oj&8D)DAYo&PdHbY z%YVjS#8(par&*hd+T%R^o?<;ZxWTesaa9 z(8?_Lk-H*L4};{#2yE01sI3(qU*8bf{x(#QZzG^*)^8&>UW#X!w_bi3TnPQ-qRSB2 zLcYBW1+kB8yc|};W^&)@$ZHE!gbPRZhP`v=jdk>A{h4c|cKUIiVno?Ltttm3=S_eeY* z@^#P&tz_^z=(_v{ zP;Z)}A}aAxajNMJ(}-z?Nss3o$As?$A`A#-{vLjn;keZkG1A z_Dbzi&2i0RxUaaF`;ObBjKEz!PMz z-GLTUOQs%1yoT&OjAyzya{FQE)T=p1fz&-TT10O61FFK%ooJ(2vj0x-8W^=571$pb z4pOVhx+Ea3yTHZQODeUk!aL;IJf`TKK>rv0?q|a|x%n>Gz%=h6zKgLbyUAl0qu!lK z<{UtUM43ak^14dAPV5%-rt3`$j6dV4+WEq#!qfbZ{4IE5)@8WR!0Lm#k97xhUAVVB zsBPB#tm#A7lC6GJy@36M-OrXWA29bY7TB(O{eUE?r8KFToV*98=l&xgX_~m~2DHdG z9D!7_`d*OnYo_d`D8f$uelLFE&_H(H2eZ)A+pi%G$Qa2a2tc2x97d7|b^^APUuEF!jtP_C;<*+Xyu7LsQlf^~)kYJ15% zl(Rnf<}J7OzArxhF0ADJoGR!&L#66K~GRcUU&p8TP0cjC|rOR@zY0f63R*G zV^EHsXWL_FpGwJ-kHK^3dCq?v>nSF;KMoyizf?qa+yL8*h3d@OcvfCTT5m#ATR^IA zM2((9#%@H5pHKdHBii`cWXVlf*er6@O-P$b-nj|7>HK!*sWz#^9GIUHjzIsP0pqvkAo9*5dP$n)rRddRdF@O3xY z^8&uGiG2J5n(1yb|3&a%?0dtD@DAH9ZHQW5VG$@#=+O87M5@(yqz zumH~rk{{lL8GHjCyS1Y4qloWchgyA|oT9c=-~mM{=9Q+NsHrHBZE8!w0&?SRFpFIw zCll9O(1;dr>sx3KlgOj=VIk(XVHKX1^}dbvP$Wm*Mt_aRV&6eqX(ax4AYc+W7e;(| z&Q3W4NPajDYt<4b^Q7^QaF%|VY@iXs=3XMb3Y7wG#zZS`k@e*8yBJT%I`YfA_&K1g zC7tgp^K4Oa_D_%XJqF7pWg=!FftI$hj2nIHOeaD zega-pOWD$Rq^8^v`K0tbM%oh@EPI`ib|dCyMmiHAN95=72iO(T8RWbtp;5n!vpOUZ zkB^Y;pTIJ9t+bQOcnZ4oV>B8?Q7oad*nq**J5QnTj>d(zBq6*eW#^z!HKG#l5x3xe z!DFVa80#G~-e5f2m?7LDwDQOK+tG`^VA!UAQ~!{DmF`pBxw<*pPjK;mvF0h9=q$9W zXL3_GPJJ^@b~gJiyPI|4WV@MJ@D>K7b5!rCF2M>;{Y#VUse8knHu9HG!KPiVgkWA} zfYVMH;AKkx5V5qju48bJ+;}CLiSs_gt^1|qmd{X5>&VH^P-T{orq5AHYsrnDLmAq} zwF1I&j-fxVB3B%PwR~kf3@bh*3}f(1g_^Hmm7!b_R1C*r#23DTRDBs|jmBt7h{o=g zOG)L|Fpd`Ufv*9_M=mChUJWj`UM?cOFeVvtA$c$iodSMmV9%E9%9(JIeH>cYL3s`t zJPUk`oKN063p`m4c{Yve=!x*tLIVjuE+FD}5W#V}`8(J^#drp};tx0v(@EB==&JI_ zj#p7frje&#MeCAF(qDrv%~Z}w!@loc!;k{QzSVe|?yJ|J&pAbHQ5E!^{!Q#sIhzc; z4$E~}QLXhTTFW-bndHmYp-G>ibRUl#lkIOoJ-bqNk@McbDM=?Eyn(vzq|;4Ro9Z&( z3h>9rX8mBrW%vc_#JLr6$!5k`-Fah;}7%8 z4L=xeH7wMBrYHJp-PgJS9jCoiTcvqVbH1jU`-I!VW#Xx{2KEH|7W*K36}y0W6F)}D zLg&)}`5>wuJs_>Tf8h^sriSg5H&fX!%)_nyF5zK8sB0z2R) z?f=4I+eB{v7aEgp^5efy2se_ppOA9{dGIGJc0J)v;s|z;^(QeaTSuNei7%`rj-Sym zcaq&dqdi?iuKgLE;%f56&*%g?NZl_ebF0X&e}T2EQ$|bt5uOZcBX@oTCFv_^)QJZ) zPMhjfxs{y!2-4D9q8!DiadgSeq~v4hV$VsmD~w8^H1ZJN^w)xRv_2l zj(s#ZzV=TjC@aWUe}eSO_kwzw#ju}d^J(~DQ@`;e<1@xX#`BCh!XJcX{8M}-pKdsb X3E_VHs=dc>e=5&>sV4V)5BvWY>64_U delta 17133 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 Pb|FfASK;IIHvazsx+m)I delta 22660 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 NC;7X7as&4C{{W7cV|@Ss diff --git a/vendors/es-de/emulators.linux.x64.sqlite b/vendors/es-de/emulators.linux.x64.sqlite index 56fe557d022273b00e645faf985e8720d790e1ab..f08e28f2f23882ab1729e8594549aa3be6aa3e81 100644 GIT binary patch delta 25149 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 diff --git a/vendors/es-de/emulators.win32.x64.sqlite b/vendors/es-de/emulators.win32.x64.sqlite index 02a6f031f4b3d8a79dc37afb4fbb9febb1a0fe6f..f9eb11d524fd6154973087fb182a200f0b6ba483 100644 GIT binary patch delta 25125 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%0A%l^R z_MnQvOv3Eb^W-2bM<=9*vM5+jQFr{AkTOTS6qp)b|j z^%mWqx=(el>W=BI*PX2!&^7A{b)5Da?Q7ZxwYO?P>ql>5jkZjisZG*On6yOmx#o4v zG0j2E)tbGUU7B7^lV*`7UBjbK@fLrKzm30y7kC$6!sqd3_1Ee*)W_5}ssD=pgjd~) z-b9+(r213!h3Z|^G28{WSG7&mt7=hIsOGBl%2Ub@l`kmoSKg|;O1WLxt87v(R?bz% zC{>D&(W5w|*gv5NC^jk9D(V&U6|oA1{9E~Z^5^9D$*+(L=vvgtv*iZvGwx09Dee~T z0`x1^aHZT_E{0Rkf6*uCt#lvVO4rckbT)3L`JKE-9>lFQ-PoN5zoaB=!%p7o?s2-h zGW@70N~omO-9sK%uiZO}OVbl}oVi~2$cWn>99uHxb!|!auf@zkeScc!|IUj!BmHa6 zoC+%unBnh`>S9yj|KWAnxn5V*7Oj7Eh%rm>)mVQ!%4bee1zr}`+%h10ye#y~>Tg42 zP#1(iQQR0`uFOQ=QTncedkOXT9do9)L-Gt?^`2UtZ_tseda;SfT#AEQN z{|RE-v}|g>4n~d9Vnb#b!(Y-5lYc3eAni$dH4HQQmoRZH@z;=7HH1!>fAN$!#U%b= z)RU>4DQ(z=ut@(x_~r=`6^}Y4H7GqgI!s0TQCEa|d&s*5!<_Si4~u_3c%CHIn0!|8RBv~Ov3YP6V&hO1#1R`~*C8*? zgAr$6Fhi0*2VQuRq)bSjFn08kAUwjKHKja;yhF3_GWwbL4^A0O;-A^i9O7x+Psh>` z8tKnGbHd~!Fv34~N_Suqi62V#qb8Uiw2X&f=qVC2A!z|@|6)XI{j=F1V*4{NW%j4D z_J=w@-)_&z9`fcJ{md~AlZ5r;oBe5Lki_kYMQEgd=9whrA~4R6sv(_9>MS@Qpn94Z zCeTw(4^cHeOmtb9k)GA;?~B7>RX8T2*2&L`f}B%D2;yfe5dNeM&+9PR>lWY z4CqE!q(A!1+OX?#WBnG``!umkvc)^1iuE|D{5f`47u#ZxCzwmbr1Ksy|RmkT*XCkexA8s zQXJ<@Ji|u%)r^*@h|WR=jPk3H5<0ynQg$?$SBVzKL@)}#0PES!;#aU{TA276bYnvi z#xA{Ij&w?!CtqOcct3}9kT9J^({%0}aup$&=vhybc^aBwBmE>y=7bdQEN0LszwE#0 zd_>CZn?O3lWEK^zV?c`!o$fQpEJ8AiiuAsnVKU!gX`N5njA$8lX0fEvx1F(^Q58|6$o~>SP z$ffsfVJ5ZY4i347Tw32KqX->A*||e5jc-h^c@?WHaiPI>_7B)SUUFav ze?)wAI%?rJy}S?m>(gh8|WQbGuJ>V9pC+B77TJ8O*nOEdRB~!PUE^q< zjN5Z3>|VCwh~l}+?HO{q>^+4TvXM=OGUUekdZk^YY)VbN6h=n-dKiyPIXgLFgw@y0 zhGJUl7l$!%KJ@FQJ;j~l4Av}BQ5QzX`nsg*7;A@M_S`Ts!slWGgEm6%G^kSo4Lhx2 zP?XOJn~#%d>js6z8k7ZtJuDE!ZWiBqxav51(P*|=iIwRl37#z7qkL$f*<@B}V%eu& z()-r1N^t%sO4%ZOXo!VEIT)&n#LMVgJtY(|iGL=GctS3cdwf_ymwjlQz!HWuU~#N;c5gC~eNqvXt1*$@}uTP_tK^>RTzk07hh z0biaVw)SPA78($!^)V}qwUA0BqWxtKicnl7NMlO{5kAQRlf*kF87R^xnP5{gU0PBw z`xe6b=TY0C4TeRqiZQF3;90F!UH!8WtF48BF?L^snge*6-8%^#kaVmE!`B8XdAHba(15({0hU=@#f>wC`x2 z)!wY#tKF(yr!7ZU%%u5QGx4eBP0fRvYc%I-yqfizrJ5{_RYUpD`Iq?n_-pxdacO4* zU%_YbiM&qzgZeG?Q|f!v`_U2WRX3;$)NyL9>U-5m)f1{aa8bvn8c@}!=BNzH@0Bkr z|DimfJV)tKwxR=;rA$z26hA6HP&}`AP;r~$QiXs%*eb>H358t|r%=nkm%kx@Sbn|y zuktau3!Sieay|Dg_ZlwlT+E$=ry9DrTCRvo<1Dzi^F95LK1Q#n=hIQzN{c9JTPh=; zk(bHCE69!s>|mllZUE9=AyH&IEO>>)F51Qh z7o}oKC|6`)ToS-cmot!--AsWo#=z1DvoyP+0$XA4E4b#eg;h7Ls3RFNGJuiXkp5$7 zJ-e9#BT^}B603_(a%Uk zv%EeCHv~3GfMI8T7_gB!C6b07%c&j9(Fahogo3qWIb%5yfnG+~lzMI-%ZUv1FrucF zv+Vd-PE??qS#pwg4iS_yJ|@pG2i%bI8qS<9tXNXZC&zL$0ZIC!T3$AmlNi7_Zwg6a z!?x8k*ySGfZgmb8Pyb8^V8}N`mcsPbG<5n`Oke{eF(n*FoiUBF1=h16PU&~T-LE0% ztb=D>BgtefeEu3qik4JCiq~tOejzf@0m-kEs7j^-g7H>nxP zs0ffVrH<^p6i)-s?ufDzx*xqSwMMg`Cdw^9p~yn)Ma z7~Zu8ZBfM8rLuGZNlPG?N3#48f$5+wrWJS@1E?ZG`U#WxX9`IAVM<@tAS^P#w8Lo; z!4w2W1S(iJOD+^9+Z*v_3^45w)R&mVKW(6l^#tn62uxW6rKs40Vf8KEfxdn>IUIai z0wql4ON*32NbnvVSPq$Ql9;&;X3a@cCWx35d>8_f(!g0V76c>)mV)O^;;ao_!OS3KqgAZ(^ii0q}2;($m@O=H?H(%+J*|Q4DLXU-3Oy`=EYQD=;g@-=s%crgGlY)` z*kQ@rB(@zxwHRq)67kFei~%WNi-Ng3gfIr?gjjiA2x1LnV5@_JIJym&yvE3>;L{SA z4F|FMZWgX(OCYj0f(S!k7ApipHx1#OX{_g@;T{Fb z=m5ItA^kPdlT*O1sDKr+-yxQmfaI7@D=-Ob3s_+7J0z9{qG0?T61N~A`QJE$li16; zfaHLqi>78tYrr(c2;T95&OYa0=n)f$fD`W!TX(=XHCEoOeIu@3nJ@UX1PoIh7lZ`w z(E;gn-;^@r_G}6x*xA0*hvwxV!Wz&*?Yks~1~ky~E=h_CNN4)k@P81@Q4~#t0d{t8dUpr+;2J4h8`|9wV5j&_-`#Q9Y1rg|9P-{HDJugU>vN3r$(}zA zZ4OYzX{p1`rKdrY0tB@XmDH33?m@{sCfharJ0T#0>)#_u6BDd%)HxwTTz2hrov*3WDrEfbYx zm`w4H!%asqxH$&T9mS>Ft?RNA44jM7d-X4t4WoJkGalP6Wie^ig5W?zb%hso9-1~o;dPQ2io)%jTAw-NH4V{n< zmO$0n*~3%Cq0JMLWg$@LVf0Ju+$hTirXW3Rd{8Z?OugL=CywFX2lR$R;wZSF5WGkG zyDoVYWsJ1IJ#EOP2eDRvC*(XzY>jB*#GMw!cAQTC1Dq~S?i2fD&=wxJh4pRmNh#ZMYw@FkKEJ*l6PBiqJ` za@Z9)se=PAp-!IEGG>~VBQi0F)=Uj1OK)X2(e2fcSZ;jGbuZR6!ax@424f?0OMStrIHY63Uk&O#K~U0DTHC2 z&&`$H3##`?a^Z`b%QegRZ*fcAGG6}wi;q0KBVUm!+bT02FggrZp&PqXcU-qw`@QxO z?E=lmnsLo6{$Kn>{37+o>PVGGxnJ>%VzPR*F-HVGn5@_#Dw3cqFf@^*x{iG7~KasVh z0yh0bytqmGy`RVcF5uMuOjh9n&UHWI+a>Tlrs(G7(D49`1@ZuG!*v_{?Ip|Lg$L-h zxWxa(L(~QrJV@_DI}brNa6Ux;g2o=gU(mKJQdn{X(uK#4;6O50Lsu-9$xbauAloY`yzmi5$2n&8An@9oN@Ed6+^WnGOuu=KY`X91Jm#4603AJ6$q256^ z>{HPVkn}r=)aEMiL`d!4DbO<_mT>XhN?g$xd51JXHa=nkXLY zf~ZJZgQp0bk#s$NwcuzZtsrLL&9o6W)vYm8Co#bhGj-v2OJ+n-r_!iMV?_}QjSCjF zNie{FqUa`FiJ+HT3tI=A!<+Kp=yP-dw@T2#1+UUpt)P`#bDDyf7bEF>U6r80ytoOG z+`=kpjMi4)9Np15>=HEkM$%Wbq7ErYLZ zbRAj-b+L3c8HJl-X`2RZfh?hlRUKs8O>s1Wcwt8zZPj3`BT2AGII<6?=7!CX8IS!t z1bgCXEAPS82tki%iM64Z(8f!aQTc>j=9OWHY^70SxWI6oqW+{D&9~4(8O5|V4uSD*Bp7U{YF|2fu_98*AlZ(g- z*|)O8SoDAYa|mVfxTO_?PR{@;qw;=8-9z)aLZK9Xoo?GM#zKvU&fG`kPDTs&;wM+fzMx|?Oc_R4f(TaHOYcqvvH{A!P~QG1F=J91}!C- zusMUSA#>qG2HimBK-L_(1<#Y*Fo*UVX3LY1pV6r38t&WT4)b(6teuNpFbnRPiyV^% zicGqk%!JBJEISqcmPywsXDINvvK@BVX$+ertt17u+UZ7ZvOFPJpsNcUv*{wPQAmQh z^N^eHT*^GUmL$Nt^QhMluSl3Hq_Ub_T^omhyg^rUr9vD$m_{965!%)NxN_qW-+oiP#m2e;h*{K44 zNx|loL+cD2`Ua|OLm6?o(T z>VT>g47cp1RbjRWdT&oBWF4cWT%#`rH;L0_$ zk#qRWkg%SXplWfhr>oQ^>|VFG+r_%q!7cJdz|bS4fvKpoub zpuJ+RiecADs(?pIsRmWk1QeFj4jt~WwqmO{k2*KGCF6(d5O%`8N*V>Ef>u&t2UJ(k zja1kUhbw456}CZICGzAr%d-}}&95tIJL;6GDtb2RlozY$e$*+0D`+PkO?z<#?M5w9 zRE^9(4Ew98n`{PJL;F#8cxtGNP5xCE$c$jzVAL6IGc@Vn(O;p@(!H*`SXZI_QhS58 zP4lJZ0yH8X=lAdn(2NjpRpV*2H=k85R&18PDj&cdVb^m-^b2|mZ9;qVK^*^?vJYez zpq0a|#C4^njR-G+8#kh?TMnOXq|4AYoVy7-X&Lx7A#f=?z6rA|0b4&Z=wjI1PnR)? zU+5a?v7@+?CeT+Vj^{Sw!q6Z6Nc;lWG(dZ`#R_YRkPyt3W9Aw;x338D265yIVgDe# z0_A;?hdzZ8-Zvkq zYeK3p{MJkhP}Yw&W3hAL-DbqifyFJz%^7fI3)%s*LA{FBYSZPm!dB*D4?2VLzSTDi zu3UvBroqoF!%Vp?rzw;{k~uBDR5-g8+dKo_YsC>wf$BCSCmAkiLvoUUYsZ%pVQo8B znE(&ABRTO9u^P#VgNoI(n<@E*S+Y@?agE^w9&4Jfe_MaHK0$ZCu15QwcD3dk9JnO@ zAG}@t2rfxcJYCd*=ZSVH?pCZ<%*M%E#XZbr)2nF}IYq8Q>t83kQ#QI!Na2cn8{yPA zU4Yxq^R^+U_rhPu-SPcGC07dRToHzPAo;ISX5|7Ct-+Sz!$nPtYgjg3{LkuWX>_ zpr~Ez#9ZxgyAx|}15KwSXj?jwgIB==o!DzFV0NKsZwA3d{b({Px{xy(VYmx{E8+bv zoB$0_;>HoGhuhtlr4DktF-t96-HlmlK;MH|s$r;y_MzwVRuA@X70m0!wpGG8y>t-@ z%g1}^8bi6{8-y47g97%ieKZ-ppga5MH5XBN%}kj`X8gM`+HkF5gCRoSqI*~u&_1Ts zX*Tmu@g3?v)z_()sy;%^sZrjpY*2iqxJ0o8w^trRXDt>TgoX4OT8~GtZYKtrXP=P7 z3vsx^CZ$^FfxYKYD}3-5>Qm|!DYaq1u5+nRr&Hibb>bXmAI}L&j#9rC)}BYpwHk%3 zOvsW@;EAS#+@PO_r1R+zQN!NzvGpqWgQb+S>1yiR3Qb;=g1#+q)Qd;he50^&gkDB{BcK?ihf&r&G)j-4tlP1LE<{;(?-qJ7 z%DTF(=y-ZSJtmnMZDTmi2H^5BIzsxvI8OJHO>oUPZMozcDz8hE-7GV@3{Mya4AJ^w z-Fv#-x|!Nz+HUKl>3p7izDhT_|$?aV5@^4B)Rq{WcrcU4=xa!`D~g=*EfZzN{l*f*cZN$03-!0+!Ankem#z){ z)>)dTG+o%o=kUwaAF0n%=c!&)jjK|We^=I{V{)-#uKcLHmHVB$j&smY>0fCSxu2|) z{UHHJp0~-GXy!1svFm!mJ8@VUJ2!xsN*e z3WXURKK9mMZ>6>PRyk-d#J9?z>q2ZpDLir^I@=|n7I6@lLyt&zpi=!vM5bB>l@}p! zDcpDw>boT{|6&}I#qh?(w1ZzHx8!-)*8#=nm!ygOVrx|Ms-VBL%D+(ZF(qX3l3K3T zzW`2Nf?QM#cEEy)-~m7{r4XVor5*YL$z;M0@s$sIx*+8T$%?SSse>GpC{-=6O9j7{^ zTBZCA1!RjdLUCBJMv*LkOMVp&_Br%C5+GT!FJyc62}N8-fI{M7w0#KdIE)5_3?4pA zn^pb^MMAy55K`_#iJ5aR{WtZW1@ZUM->82l*zTv#a;1K>rB0DD>fa9gPLT!FzYUI` zBBc~V_oiQ1dJJlQA#K>4@*Ep0bPjjQCRyhgO8y7*~j23-E@gM z7=Q44Vdxr^RsLak_8Oc>n_>0=43rGP-UFEOz?TP57Y#z&waASFaPPIWLxcO%P)6cF z=fMXzU~g}N!N1XMWF!3hZx|E6KzWc2Fw%j3Kexf(3s+o+JlO+(Tu0k@+ysD*KVy@l zFJMfo*YAdd*JB-B5Pt*K;es7EAh&eF8#f@wW6V3q@wq__cEydz@f+Z_8?mDG;fiv@ z6|IByf2W-q+}0JOAeV7sJ?iPWn`kRDJl5G|m&uGbVKm%icof}6js6&(pH9_%q&rtv zh?e7C%{!WU{vkd|4QjjUK2@r9Fxr12g6aShBZ%9US*e?3h{iM zWGLb(z92{E1Tx|Fr)i!V*GH{_9?`cRrDa@JU=HLwLvz%)dl`KY1WPVREn5gh9gHHC zJWCg&NL~6YngeMdK8td0Cj9s;O5Idgah&F(K%F>Fn{+91)BKX2&L)i5EQjRBFrxYM zacV{Zt9=gNOoH{#VWc4u?tTu#7zyylb0`zzq2&a+FmZ6%30jK+_LCFH#5O2<9v?CA zH+*oLakcU0J27w?4T%SFC@t`pgJ{=Bf$1Oga=Bm#nBf&2-K)oS#kee?CwTe`_T539 z@S>h38*qU!F2h$CIu!;7GPo5`X+WoSH7+pT{tx6sBWMqy$!CC;Lm02p!`~0lcC}7! z&S}D9UO{KL4i^X=chLr;Mjn^b7dozmA7urSi_N_6E;q0E{`j04IPAKCjBC`Ll>WhUxv@Mtp z(IV|YSCHwJ1XjTZC+RYMiy{uqrtrHMdtZSA&)iZ~YHX?Pd?cqydX9cYB= zcWJL`B{Fh#k4wBGC!bpsXn=3uMens9vfo2fybdFZ%&{FE`NmPvB3Q! z7;RXA;s~dJV=nedDbbW41J+~Ih_Qh5V;Gbu zgDuBsgSwP)0xL;!Q5pj!pnMd0A4B_(qTq4BkwFeXV>7+nWv7JVlpXl1-J3=g5p#;qJi-{q?vn z@LXMy_M~>L<_pbP8a;m@U!Xp$?o^xcEL8=Hu^=vdoLvzF7(7>47QoU8ez4IAu<_2+7-oY=ZM;E{i-PnT+ z(+v|}(oAUklH$&QQ5gObb!q_h97b5dW8@Y@O@~)I0$#aUDi5UMHU)-Z_$xHa1DoOf zuP{O#7=o;?X_Fpz)G>=c_*sIW-ue1#YU0`hgK+o-jD8GAj!FnWgwb^*tsnNhh=%JX z`13{dR5rrEOPK0|cVD7sp)0!fWt3+1fgV_zfW02*hP?@NEyay*UnS57XrK#jPNdh+ zfD5XV=-qN5Hqd$T33@rVKH!9PZ_xD{nE9D0+b=U-W~?)OZMfdx#nHT3XzHJ% zOV=LJc4@VE{B8{jp%XlQdrAGK`g(P<+M;>@kJ6cxZz%UDmnhy*T&Ac;2W~m{6}JLE zB)tNcZAx+Q_wN%HaqW}&P+vsb4ONqQxWP~0+0OOaQ6PE9pzsU2#<_6&FUXQP@COFY zkQeXzm3lBvF_{Io7GWfMavm%w#z^#}9o{KMyK(1aCd~Sc?m|QB#^309+BrB&@yl=C z(}nU(&M}z*JO6_kW;PuC58a?n$HCi*xq>t8g}bSNub7<0<`yoFq2O{LkEX$czax*L ziu)ZWOe)m;fiiLiT>S@aSEMjm00-l7cWK>ZG8FxZ@*#;$XWYUpO=qrTG7%p7la?Sp z0Y=_O3nm^Oe;?IZ9Hf4LG9VVlKETp#aFC^9K=~os&{o*+AzF#ia34!q!2C}Ptwh0& ze_~A34Db9C>4=2Eet5d(C7i~?H^_kWD5 zE;{)2V^nQgDE$|jz#915zi?Q1`08J@D`c`DQR}`}x%v?Y>pL0$0q=#UZ~6K7#u6 zEAkK0F8fh-$vz>UTQli|CLOm94Xc0XI2S6LXg$}4%EqJT7NWAbMb8Z=*Cm6u^DP1!;rDOR`dbdv1%ic@q5)j4kM+P@UO{s~971ZMw?BflKR*~0~p`s#~W0MSGie zhc*g#bo((Z_B=nzXR6;--=c0*{h_*Bm8`s0xj^xhqF-T?za;PDev%yd-!Q~bMZUmq z;xwR(d0?Ng^zhw@Ts674hkL-Va<>}eyIVs$Q@|9D0p5bO)IfKupnNTL(A`S-%UarM z*sV~+r3kig#yg*<4cyRfIXKqQ&4%5a0>AN_8b-1e8+GY!Dn%3FXck?{HS8wPPq;?r z=n!r-ot%I;%3+tD1s71RTQj+nsZ@5-H7H@%a-2moxkF*j@No<%8G%o5+%h`39n^BJ zf=+IOm2zaQakyTN>^TO1$hlmMA?GN#T8tr2D7Z?DAwQ+y+A)SaL&?=+TxD2^{5%Y= zDY+u_Y>Xj=hXmZCG+hi9bk<%jtg^#iyiVnS7}{7E^4v9^u!6Y?Rs zn%mDUr$3{s(M4{To!Tc{2P?9;d-Ou?Zj{kkzQt@KpwxkfUx?s7)=Tc|YcgYp;U&Xr zgG_(Bz5^qxS=v{$mud?%|I$?QkMNr?4)nG9UUjGHN7V(Y1y$ByI~3_CCF;2+ jI4_q Date: Sat, 18 Apr 2026 10:47:38 +0300 Subject: [PATCH 41/65] refactor: removed store version constant --- src/bun/api/jobs/update-store.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bun/api/jobs/update-store.ts b/src/bun/api/jobs/update-store.ts index 85d8425..b051f07 100644 --- a/src/bun/api/jobs/update-store.ts +++ b/src/bun/api/jobs/update-store.ts @@ -1,7 +1,6 @@ import { ensureDir } from "fs-extra"; import { IJob, JobContext } from "../task-queue"; import { getStoreRootFolder } from "../store/services/gamesService"; -import { STORE_VERSION } from "@/shared/constants"; import { tmpdir } from "node:os"; import path from "node:path"; import z from "zod"; @@ -18,7 +17,7 @@ export default class UpdateStoreJob implements IJob { 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 ?? STORE_VERSION; + this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0"; } async start (context: JobContext) From 6aacec2c0de253a71599e261e07aff53055cdb1e Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Mon, 20 Apr 2026 02:14:37 +0300 Subject: [PATCH 42/65] fix: Fixed a bunch of issues on linux fix: Removed archive when unzipping with stream zip fallback --- src/bun/api/games/games.ts | 4 +- src/bun/api/games/services/statusService.ts | 19 ++++---- src/bun/api/hooks/games.ts | 2 +- src/bun/api/jobs/install-job.ts | 12 ++++- src/bun/api/jobs/launch-game-job.ts | 3 +- .../rclone.ts | 42 ++++++++++++------ .../com.simeonradivoev.gameflow.romm/romm.ts | 32 ++++++++++++-- .../services.ts | 44 +++++++++++-------- .../store.ts | 33 +++++++------- src/bun/api/store/store.ts | 9 +++- src/bun/utils/get-browser.ts | 27 ++++++++---- src/mainview/components/CardList.tsx | 2 +- src/mainview/components/ContextDialog.tsx | 2 +- src/mainview/components/game/MainActions.tsx | 34 ++++++++++++-- .../components/store/GamesSection.tsx | 2 +- src/mainview/routes/game/$source.$id.tsx | 2 +- src/mainview/routes/index.tsx | 2 +- .../routes/settings/plugin.$source.tsx | 2 +- .../routes/store/details.emulator.$id.tsx | 35 +++++++++++++++ src/mainview/scripts/queries/romm.ts | 2 +- src/shared/constants.ts | 6 +++ src/shared/types..d.ts | 3 ++ 22 files changed, 236 insertions(+), 83 deletions(-) diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index b7abb35..bf64aaf 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -451,7 +451,7 @@ export default new Elysia() }, { params: z.object({ id: z.string(), source: z.string() }), }) - .post('/game/:source/:id/install', async ({ params: { id, source }, query: { downloadId } }) => + .post('/game/:source/:id/install', async ({ params: { id, source }, body: { downloadId } }) => { if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob)) { @@ -462,7 +462,7 @@ export default new Elysia() } }, { params: z.object({ id: z.string(), source: z.string() }), - query: z.object({ downloadId: z.string().optional() }), + body: z.object({ downloadId: z.string().optional() }), response: z.any() }) .delete('/game/:source/:id/install', async ({ params: { id, source } }) => diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 82f69d4..3c46f23 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -4,11 +4,11 @@ import { getErrorMessage } from "@/bun/utils"; import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils"; import fs from 'node:fs/promises'; import Elysia from "elysia"; -import z from "zod"; +import z, { string } from "zod"; import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { LaunchGameJob } from "../../jobs/launch-game-job"; import * as appSchema from "@schema/app"; -import { RPC_URL } from "@/shared/constants"; +import { DownloadSourceSchema, RPC_URL } from "@/shared/constants"; import { host } from "@/bun/utils/host"; export class CommandSearchError extends Error @@ -205,7 +205,7 @@ export default function buildStatusResponse () z.object({ status: z.literal('refresh'), localId: z.number().optional() }), z.object({ status: z.literal(['queued']) }), z.object({ status: z.literal('playing'), details: z.string() }), - z.object({ status: z.literal('install'), details: z.string() }), + z.object({ status: z.literal('install'), details: z.string(), sources: DownloadSourceSchema.array() }), z.object({ status: z.literal('present'), details: z.string() }), z.object({ status: z.literal(['download', 'extract']), progress: z.number() }), ]), @@ -261,6 +261,8 @@ export default function buildStatusResponse () } else if (!localGame && ws.data.params.source === 'store') { + const downloads = await plugins.hooks.games.fetchDownloads.promise({ source: ws.data.params.source, id: ws.data.params.id }); + const sources = downloads?.map(d => ({ id: d.id, name: d.id })) ?? []; /*const storeGame = await getStoreGame(ws.data.params.id); const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); const size = Number(fileResponse.headers.get('content-length')); @@ -274,19 +276,20 @@ export default function buildStatusResponse () ws.send({ status: 'install', details: 'Install' }); }*/ - ws.send({ status: 'install', details: 'Install' }); + ws.send({ status: 'install', details: 'Install', sources }); } else if (!localGame) { const files = await plugins.hooks.games.fetchDownloads.promise({ source: ws.data.params.source, id: ws.data.params.id }); + const sources = files?.map(d => ({ id: d.id, name: d.id })) ?? []; let filesChecked: LocalDownloadFileEntry[] | undefined; - if (files) + if (files && files.length) { - filesChecked = await checkFiles(files.files, !!files.extract_path); + filesChecked = await checkFiles(files[0].files, !!files[0].extract_path); } if (filesChecked && !filesChecked.some(f => f.exists === false || f.matches === false)) @@ -301,11 +304,11 @@ export default function buildStatusResponse () ws.send({ status: 'error', error: "Not Enough Free Space" }); } else if (filesChecked?.some(f => f.exists === true && f.matches === false)) { - ws.send({ status: 'install', details: 'Some Files Present, Install' }); + ws.send({ status: 'install', details: 'Some Files Present, Install', sources }); } else { - ws.send({ status: 'install', details: 'Install' }); + ws.send({ status: 'install', details: 'Install', sources }); } } } else diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index f4ae463..aa715ca 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -67,7 +67,7 @@ export class GameHooks source: string; id: string; downloadId?: string; - }], DownloadInfo | undefined>(['ctx']); + }], DownloadInfo[] | undefined>(['ctx']); fetchRomFiles = new AsyncSeriesBailHook<[ctx: { source: string; id: string; diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 07f4fb6..166af46 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -53,7 +53,8 @@ export class InstallJob implements IJob const downloadPath = config.get('downloadPath'); let info: DownloadInfo | undefined; - info = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId }); + const allDownloads = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId }); + info = allDownloads?.[0]; if (!info) throw new Error(`Could not find downloader for source ${this.source}`); @@ -137,12 +138,21 @@ export class InstallJob implements IJob { if (filePath.endsWith('.zip')) { + cx.setProgress(0, "extract"); 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; diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 9159002..1a430e5 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -120,7 +120,7 @@ export class LaunchGameJob implements IJob ctx.zodRegistry.add(SettingsSchema.shape.globalConfig, { requiresRestart: true }); const toolsPath = path.join(config.get('downloadPath'), "tools"); - const existingRclones = await Array.fromAsync(fs.glob('**/rclone.exe', { cwd: toolsPath })); + await ensureDir(toolsPath); + const binaryMap: Record = { + win32: '**/rclone.exe', + linux: '**/rclone', + darwin: '**/rclone' + }; + const existingRclones = await Array.fromAsync(fs.glob(binaryMap[process.platform], { cwd: toolsPath })); if (existingRclones[0]) { this.rclonePath = path.join(toolsPath, existingRclones[0]); @@ -83,13 +86,19 @@ export default class RcloneIntegration implements PluginType return; } - if (await fs.exists(path.join(toolsPath, 'rclone-current-windows-amd64'))) - { - return; - } - ctx.setProgress(0.5, "Downloading RClone"); - const rcCloseZip = await fetch(`https://downloads.rclone.org/rclone-current-windows-amd64.zip`); + const platformMap: Record = { + linux: "linux", + win32: "windows", + darwin: "osx" + }; + const archMap: Record = { + x64: "amd64", + arm64: "arm64" + }; + const downloadUrl = `https://downloads.rclone.org/rclone-current-${platformMap[process.platform]}-${archMap[process.arch]}.zip`; + console.log("Starting Download", downloadUrl); + const rcCloseZip = await fetch(downloadUrl); await ensureDir(toolsPath); await pipeline(Readable.fromWeb(rcCloseZip.body as any), unzip.Extract({ path: toolsPath })); @@ -97,6 +106,7 @@ export default class RcloneIntegration implements PluginType if (dests[0]) { this.rclonePath = path.join(toolsPath, dests[0]); + await fs.chmod(this.rclonePath, 0o755); await this.startServer(ctx); return; } @@ -139,7 +149,12 @@ export default class RcloneIntegration implements PluginType if (data.level === 'error') { console.error(data.msg); - } else + } else if (data.level === 'critical') + { + console.error(data.msg); + } + + else { console.log(e); if (loginTokenUrlRegex.test(data.msg)) @@ -150,7 +165,7 @@ export default class RcloneIntegration implements PluginType }); - await new Promise((resolve) => + await new Promise((resolve, reject) => { const handleResolve = (line: string) => { @@ -160,6 +175,7 @@ export default class RcloneIntegration implements PluginType resolve(data); }; rl.on('line', handleResolve); + setTimeout(() => { reject("Timeout"); }, 5000); }); await this.refresh(); diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 6bfbe2d..c0d754e 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -31,6 +31,12 @@ export default class RommIntegration implements PluginType release: "metadatum.first_release_date" }; + async checkRemote () + { + if (!config.has('rommAddress')) return false; + return true; + } + async updateClient () { client.setConfig({ @@ -141,6 +147,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) => { + if (!await this.checkRemote()) return; if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm')) { @@ -173,6 +180,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) => { + if (!await this.checkRemote()) return; if (source && source !== 'romm') return; const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true }); @@ -185,12 +193,14 @@ export default class RommIntegration implements PluginType ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) => { + if (!await this.checkRemote()) return; if (service !== 'romm') return; await this.updateClient(); }); ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) => { + if (!await this.checkRemote()) return; if (source !== 'romm') return; const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } }); @@ -205,6 +215,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchDownloads.tapPromise(desc.name, async ({ source, id }) => { + if (!await this.checkRemote()) return; if (source !== 'romm') return; const rom = (await getRomApiRomsIdGet({ path: { id: Number(id) }, throwOnError: true })).data; @@ -260,12 +271,13 @@ export default class RommIntegration implements PluginType extract_path }; - return info; + return [info]; }); ctx.hooks.emulators.fetchBiosDownload.tapPromise(desc.name, async ({ systems, biosFolder }) => { + if (!await this.checkRemote()) return; const files: DownloadFileEntry[] = []; const allRommPlatforms = await this.getAllRommPlatforms(); @@ -296,6 +308,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) => { + if (!await this.checkRemote()) return; const rommPlatforms = await this.getAllRommPlatforms(); if (rommPlatforms) { @@ -313,7 +326,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchRecommendedGamesForEmulator.tapPromise(desc.name, async ({ emulator, games, systems }) => { - + if (!await this.checkRemote()) return; const rommPlatforms = await this.getAllRommPlatforms(); const systemsRommSlugSet = new Set(systems.filter(s => s.romm_slug).map(s => s.romm_slug!)); if (rommPlatforms) @@ -343,6 +356,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchPlatform.tapPromise(desc.name, async ({ source, id }) => { + if (!await this.checkRemote()) return; if (source !== 'romm') return; const { data: rommPlatform } = await getPlatformApiPlatformsIdGet({ path: { id: Number(id) } }); if (rommPlatform) @@ -365,7 +379,13 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchPlatforms.tapPromise(desc.name, async ({ platforms }) => { - const rommPlatforms = await this.getAllRommPlatforms(); + if (!await this.checkRemote()) return; + const rommPlatforms = await this.getAllRommPlatforms().catch(e => + { + console.error(e); + return undefined; + }); + if (rommPlatforms) { const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p => @@ -401,6 +421,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderSlots, setProgress }) => { + if (!await this.checkRemote()) return; if (source !== 'romm' || !ctx.config.get('savesSync')) return; if (!saveFolderSlots) return; @@ -445,6 +466,7 @@ export default class RommIntegration implements PluginType // Should run after emulators decide on saves ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) => { + if (!await this.checkRemote()) return; if (source !== 'romm' || !ctx.config.get('savesSync')) return; const sourceValidation = await validateGameSource(source, id); @@ -529,6 +551,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchCollections.tapPromise(desc.name, async ({ collections }) => { + if (!await this.checkRemote()) return; const rommCollections = await getCollectionsApiCollectionsGet(); if (rommCollections.response.ok && rommCollections.data) { @@ -549,6 +572,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchCollection.tapPromise(desc.name, async ({ source, id }) => { + if (!await this.checkRemote()) return; if (source !== 'romm') return; const collection = await getCollectionApiCollectionsIdGet({ path: { id: Number(id) } }); if (collection.data) @@ -567,6 +591,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) => { + if (!await this.checkRemote()) return; let platform: PlatformSchema | undefined = undefined; if (id && source) @@ -587,6 +612,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.searchGame.tapPromise(desc.name, async ({ source, igdb_id, ra_id }) => { + if (!await this.checkRemote()) return; if (source !== 'romm') return; const roms = await getRomByMetadataProviderApiRomsByMetadataProviderGet({ query: { igdb_id, ra_id } }); if (roms.error) throw roms.error; diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts index 59e0432..852284a 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts @@ -50,16 +50,16 @@ function convertStoreMediaToPath (c: string) export async function convertStoreToFrontend (id: string, storeGame: StoreGameType): Promise { - const validDownload = getValidDownload(storeGame); + const validDownloads = getValidDownloads(storeGame); let platform_slug: string | null = null; let platform_id: number | null = null; let platform_display_name: string | null = null; let path_platform_cover: string | null = null; - if (validDownload?.system) + if (validDownloads.length > 0 && validDownloads[0].system) { - let system = validDownload.system.split(':')[0]; + let system = validDownloads[0].system.split(':')[0]; if (system === 'win32') system = 'win'; const localPlatform = await db.query.platforms.findFirst({ where: eq(appSchema.platforms.slug, system), columns: { id: true, slug: true, name: true } }); @@ -130,13 +130,13 @@ export async function convertStoreToFrontend (id: string, storeGame: StoreGameTy export async function convertStoreToFrontendDetailed (id: string, storeGame: StoreGameType): Promise { - const validDownload = getValidDownload(storeGame); + const validDownloads = getValidDownloads(storeGame); let size: number | null = null; - if (validDownload?.url) + if (validDownloads.length > 0 && validDownloads[0].url) { try { - const fileResponse = await fetch(validDownload?.url, { method: 'HEAD' }); + const fileResponse = await fetch(validDownloads[0]?.url, { method: 'HEAD' }); size = Number(fileResponse.headers.get('content-length')); } catch (error) { @@ -167,25 +167,32 @@ export async function convertStoreToFrontendDetailed (id: string, storeGame: Sto return detailed; } -export function getValidDownload (game: StoreGameType, downloadId?: string) +export function getValidDownloads (game: StoreGameType, downloadId?: string) { const downloads = Object.entries(game.downloads).map(([k, d]) => ({ id: k, ...d })); const supportedDownloads = downloads.filter(d => d.type === 'direct'); if (downloadId) { - return supportedDownloads.find(d => d.id === downloadId); + return supportedDownloads.filter(d => d.id === downloadId); } else { - return supportedDownloads.find(d => d.system === `${process.platform}:${process.arch}`) - ?? supportedDownloads.find(d => - { - // Linux supports proton, can run windows games - if (process.platform === 'linux') return d.system === `win32:${process.arch}`; - return false; - }) - // Fallback to emulator platforms - ?? supportedDownloads.find(d => !d.system.includes(':')); + return supportedDownloads.filter(d => + { + if (d.system === `${process.platform}:${process.arch}`) return true; + + // TODO: Add linux proton support + //if (process.platform === 'linux' && d.system === `win32:${process.arch}`) return true; + + // emulator fallback + return !d.system.includes(':'); + }).toSorted((a, b) => + { + const bScore = b.system.includes(':') ? 0 : 1; + const aScore = a.system.includes(':') ? 0 : 1; + + return bScore - aScore; + }); } } @@ -283,7 +290,8 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT systems, gameCount: 0, validSources: execPaths, - integrations: [] + integrations: [], + source: "store" }; return em; diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts index e3dec17..59e3b26 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts @@ -1,22 +1,17 @@ import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; import path, { basename, dirname } from 'node:path'; -import { StoreDownloadType, StoreGameSchema, StoreGameType } from "@/shared/constants"; import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; import { Glob, pathToFileURL } from "bun"; -import { getOrCached } from "@/bun/api/cache"; -import { shuffleInPlace } from "@/bun/utils"; import { and, eq } from "drizzle-orm"; import * as emulatorSchema from '@schema/emulators'; -import { config, db, emulatorsDb, plugins, taskQueue } from "@/bun/api/app"; +import { config, emulatorsDb, taskQueue } from "@/bun/api/app"; import fs from "node:fs/promises"; import { getSourceGameDetailed } from "@/bun/api/games/services/utils"; -import mustache from "mustache"; -import os from 'node:os'; import UpdateStoreJob from "@/bun/api/jobs/update-store"; import { getEmulatorDownload } from "@/bun/api/store/services/emulatorsService"; -import { buildFilters, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownload } from "./services"; +import { buildFilters, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services"; export default class RommIntegration implements PluginType { @@ -29,11 +24,13 @@ export default class RommIntegration implements PluginType async load (ctx: PluginLoadingContextType) { + await this.setup(ctx); ctx.hooks.store.fetchDownload.tapPromise(desc.name, async ({ id }) => { const emulatorPackage = await getStoreEmulatorPackage(id); - const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage!); + if (!emulatorPackage) return; + const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage); return downloadInfo; }); @@ -131,7 +128,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.buildLaunchCommands.tapPromise({ name: desc.name, before: 'com.simeonradivoev.gameflow.es' }, async ({ gamePath, source, sourceId, systemSlug, mainGlob }) => { - if (source !== 'store' || !gamePath || systemSlug !== 'win') return; + if (source !== 'store' || !gamePath) return; const downloadPath = config.get('downloadPath'); const gamePathAbsolute = path.join(downloadPath, gamePath); if (!(await fs.exists(gamePathAbsolute))) return; @@ -139,13 +136,15 @@ export default class RommIntegration implements PluginType if (gamePathStat.isDirectory()) { + if (!mainGlob && systemSlug !== 'win') return; const fileGlob = new Glob(mainGlob ?? '**/*.exe'); for await (const file of fileGlob.scan({ cwd: path.join(downloadPath, gamePath) })) { return [{ startDir: path.join(downloadPath, gamePath, dirname(file)), - command: basename(file), - id: 'store-win', + command: `./${basename(file)}`, + id: `store-${process.platform}`, + shell: false, valid: true, env: { XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '') @@ -160,12 +159,13 @@ export default class RommIntegration implements PluginType { return [{ startDir: path.join(downloadPath, dirname(gamePath)), - command: basename(gamePath), + command: `./${basename(gamePath)}`, env: { XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '') }, - id: 'store-win', + id: `store-${process.platform}`, valid: true, + shell: false, metadata: { romPath: path.join(downloadPath, gamePath) } @@ -272,14 +272,15 @@ export default class RommIntegration implements PluginType const game = await getStoreGame(id); if (!game) throw new Error("Missing Store Game"); - const validDownload = getValidDownload(game, downloadId); + const validDownloads = getValidDownloads(game, downloadId); - if (validDownload) + return validDownloads.map(validDownload => { let system = validDownload.system.split(":")[0]; if (system === 'win32') system = 'win'; const info: DownloadInfo = { + id: validDownload.id, coverUrl: game.covers?.[0] ? game.covers[0].startsWith('http') ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "", screenshotUrls: game.screenshots ?? [], files: [{ @@ -306,7 +307,7 @@ export default class RommIntegration implements PluginType }; return info; - } + }); }); } } \ No newline at end of file diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index b15f5b6..e70da91 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -126,7 +126,14 @@ export const store = new Elysia({ prefix: '/api/store' }) }) .get('/emulator/:id', async ({ params: { id } }) => { - return plugins.hooks.store.fetchEmulator.promise({ id }); + const emulator = await plugins.hooks.store.fetchEmulator.promise({ id }); + if (!emulator) return status("Not Found"); + const sources: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: emulator.name, sources }); + const integrations = findEmulatorPluginIntegration(emulator.name, sources); + emulator.validSources = sources; + emulator.integrations = integrations; + return emulator; }, { params: z.object({ id: z.string() }) }) .post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) => { diff --git a/src/bun/utils/get-browser.ts b/src/bun/utils/get-browser.ts index ffcf07c..c489dcc 100644 --- a/src/bun/utils/get-browser.ts +++ b/src/bun/utils/get-browser.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "bun"; +import { Glob, spawnSync } from "bun"; import { platform } from "node:os"; import { RunBrowserType } from "./browser-spawner"; import path from 'node:path'; @@ -48,12 +48,17 @@ const ARCH_MAP: Record> = { }; /** The expected binary path per platform after extraction */ -function getBundledBinaryPath (outDir: string, version: string, platform: string, arch: string): string +async function getBundledBinaryPath (outDir: string, version: string, platform: string, arch: string): Promise { - const subFolder = `ungoogled-chromium_${version}_${PLATFORM_MAP[platform]}_${ARCH_MAP[platform][arch]}`; - if (platform === "linux") return path.join(outDir, subFolder, "chrome"); - if (platform === "darwin") return path.join(outDir, "Chromium.app"); - return path.join(outDir, subFolder, "chrome.exe"); + let glob: Glob | undefined = undefined; + if (platform === "linux") glob = new Glob(`**/chrome`); + else if (platform === "darwin") glob = new Glob(`**/Chromium.app`); + else glob = new Glob(`**/chrome.exe`); + + for await (const bin of glob.scan({ cwd: outDir })) + { + return path.join(outDir, bin); + } } /** @@ -101,10 +106,14 @@ export async function getBrowserPath (config?: BrowserPriorityConfig): Promise { data.game.onFocus?.(focusKey, node, details); - data.onFocus?.(focusKey, node, details); + data.onFocus?.(focusKey, node, { ...details, id: data.game.id }); }} onAction={handleAction} preview={preview} diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index b0acd8f..0511b6f 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -18,7 +18,7 @@ export function ContextList (data: { { const context = useContext(ContextDialogContext); return
      - {data.options?.map(o => )} + {data.options?.map((o, i) => )} {data.showCloseButton !== false &&
      } {data.showCloseButton !== false && } action={() => context.close()} id="close-context-dialog" content="Close" />}
    ; diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index 681afd2..c70369a 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -5,10 +5,11 @@ import { getErrorMessage } from "react-error-boundary"; import toast from "react-hot-toast"; import { useLocalStorage } from "usehooks-ts"; import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; -import { Clock, 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 ActionButton from "./ActionButton"; import { useRouter } from "@tanstack/react-router"; +import { DownloadSourceType } from "@/shared/constants"; export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; }) { @@ -29,6 +30,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so const [status, setStatus] = useState(undefined); const [error, setError] = useState(undefined); const [details, setDetails] = useState(undefined); + const [installSources, setInstallSources] = useState(undefined); const [commands, setCommands] = useState(undefined); const [preferredCommand, setPreferredCommand] = useLocalStorage(`${data.game?.source ?? data.game?.id.source}-${data.game?.source_id ?? data.game?.id.id}-preferred-command`, undefined); const queryClient = useQueryClient(); @@ -51,6 +53,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so setProgress((e.data as any).progress); setDetails((e.data as any).details); setCommands((e.data as any).commands); + setInstallSources((e.data as any).sources); if (e.data.status === 'refresh') { @@ -154,7 +157,11 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so let icon = ; if (status === 'install') { - icon = ; + if (installSources && installSources.length > 1) + icon = ; + else + icon = ; + } else if (status === 'present') { icon = ; @@ -168,7 +175,14 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so { case 'present': case 'install': - installMut.mutate({}); + if (installSources && installSources.length > 1) + { + showInstallSource(true, 'mainAction'); + } else + { + installMut.mutate({}); + } + break; } }} @@ -211,6 +225,19 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so }]} /> }); + const { dialog: installSourcesDialog, setOpen: showInstallSource } = useContextDialog('install-source-dialog', { + content: ({ + content: s.name, + action (ctx) + { + installMut.mutate({ downloadId: s.id }); + ctx.close(); + }, + type: 'primary', + id: s.id + } satisfies DialogEntry)) ?? []} /> + }); + return
    {mainButton}
    @@ -222,6 +249,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
    } + {installSourcesDialog} {installOptionsDialog} {allCommandDialog}
    ; diff --git a/src/mainview/components/store/GamesSection.tsx b/src/mainview/components/store/GamesSection.tsx index 843e8e7..0a1f4cb 100644 --- a/src/mainview/components/store/GamesSection.tsx +++ b/src/mainview/components/store/GamesSection.tsx @@ -44,7 +44,7 @@ export function GamesSection (data: { {data.games?.map((g, i) => data.onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id))} onFocus={(key, node, details) => scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' })} diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 24c8126..103d90f 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -155,7 +155,7 @@ function RouteComponent () useOnNavigateBack((s) => s.sound = 'returnDetails'); - const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists)); + const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists) || e.source === 'store'); const { ref: intersct } = useIntersectionObserver({ onChange: (isIntersecting, entry) => diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 2e20385..12d7411 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -156,7 +156,7 @@ function HomeList (data: { saveChildFocus="session" onFocus={(l, n, d) => { - const [source, id] = l.split('@', 1); + const [source, id] = d.id?.split('@', 2); queryClient.prefetchQuery(gameQuery(source, id)); handleNodeFocus(l, n, d); }} diff --git a/src/mainview/routes/settings/plugin.$source.tsx b/src/mainview/routes/settings/plugin.$source.tsx index e6c1d3e..ecd46af 100644 --- a/src/mainview/routes/settings/plugin.$source.tsx +++ b/src/mainview/routes/settings/plugin.$source.tsx @@ -114,7 +114,7 @@ function Settings () return "settings"; })).map(([cat, data]) => { - return
    + return
    {cat !== "settings" ? cat : <> Settings}
    {data?.map(([key, prop]) => { diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index c93eab4..5b3beeb 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -357,6 +357,41 @@ export function RouteComponent () const stats: StatEntry[] = []; + if (emulator) + { + if (emulator.keywords) + stats.push({ label: "Tags", content: emulator.keywords }); + if (emulator.storeDownloadInfo) + stats.push({ label: "Version", content: `${emulator.storeDownloadInfo.version ?? "Unknown"} (${emulator.storeDownloadInfo.type})` }); + stats.push({ label: "Systems", content: emulator.systems.map(s => s.name) }); + stats.push(...emulator.validSources.flatMap(s => [{ + label: "Source", content:
    +
    +
    {emulatorStatusIcons[s.type]}{s.type}
    +
    {s.binPath}
    +
    + {emulator.integrations.some(i => i.source?.type === s.type) &&
    } + {emulator.integrations.filter(i => i.source?.type === s.type).map(i => + { + return
    +
    + +
    {i.id}
    +
    +
    + {i.capabilities?.map(c => <>
    {capabilityIconMap[c]}{c}
    )} +
    +
    ; + })} +
    + }])); + if (emulator.bios) + stats.push({ + label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios :
    Missing
    + }); + + } + return ( diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts index ded28d1..6b185c3 100644 --- a/src/mainview/scripts/queries/romm.ts +++ b/src/mainview/scripts/queries/romm.ts @@ -109,7 +109,7 @@ export const installMutation = (source: string, id: string) => mutationOptions({ mutationKey: ['install', source, id], mutationFn: async (init: { downloadId?: string; }) => { - const { data, error } = await rommApi.api.romm.game({ source })({ id }).install.post({ query: { downloadId: init.downloadId } }); + const { data, error } = await rommApi.api.romm.game({ source })({ id }).install.post({ downloadId: init.downloadId }); if (error) throw error; return data; } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5523dac..74670f8 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -65,6 +65,11 @@ export const GameListFilterSchema = z.object({ keywords: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), }); +export const DownloadSourceSchema = z.object({ + id: z.string(), + name: z.string() +}); + export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() }); export type GameListFilterType = z.infer; @@ -208,3 +213,4 @@ export type LocalSettingsType = z.infer; export const PlatformSchema = z.object({ slug: z.string() }); export type SystemInfoType = z.infer; export type EmulatorDownloadInfoType = z.infer; +export type DownloadSourceType = z.infer; diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index 7d2ca5f..c2396ba 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -126,6 +126,8 @@ declare interface CommandEntry startDir?: string; /** Is the command valid, for example does the executable exists */ valid: boolean; + /** Run the command as shell. Defaults is true */ + shell?: boolean; /** For what emulator is the command */ emulator?: string; /** Where the emulator came from */ @@ -252,6 +254,7 @@ declare type KeysWithValueAssignableTo = { declare interface DownloadInfo { + id: string; screenshotUrls: string[]; coverUrl: string; platform?: DownloadPlatform; From 7bd0ebdcca1843076911547ec1098cbaae9e2414 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Tue, 21 Apr 2026 23:21:50 +0300 Subject: [PATCH 43/65] fix: logins now refresh on plugins load feat: Added tar archive support fix: Downloaded games and emulator execute permission now updated fix: Fixed rclone for linux fix: on screen keyaboard only now shows up when using a gamepad or touch --- README.md | 5 +- drizzle/0002_flowery_rocket_raccoon.sql | 2 +- src/bun/api/app.ts | 1 + src/bun/api/auth.ts | 207 +++++++++--------- src/bun/api/cache.ts | 7 +- src/bun/api/hooks/games.ts | 6 + src/bun/api/jobs/emulator-download-job.ts | 35 ++- src/bun/api/jobs/install-job.ts | 10 + src/bun/api/jobs/launch-game-job.ts | 105 ++++++--- .../com.simeonradivoev.gameflow.es/es-de.ts | 2 +- .../package.json | 1 + .../rclone.ts | 17 +- .../com.simeonradivoev.gameflow.igdb/igdb.ts | 3 + .../com.simeonradivoev.gameflow.romm/romm.ts | 4 +- .../services.ts | 45 +++- .../store.ts | 103 +++++---- src/bun/api/plugins/plugins.ts | 2 +- src/bun/api/store/services/gamesService.ts | 6 +- src/bun/api/store/store.ts | 17 +- src/bun/api/system.ts | 18 +- src/bun/types/typesc.schema.ts | 7 +- src/mainview/components/CardList.tsx | 2 +- src/mainview/components/FilePicker.tsx | 8 +- src/mainview/components/GameList.tsx | 24 +- src/mainview/components/Header.tsx | 4 +- src/mainview/components/HeaderSearchField.tsx | 9 +- src/mainview/components/LoadMoreButton.tsx | 6 +- .../components/options/OptionInput.tsx | 16 +- src/mainview/routes/index.tsx | 41 +++- src/mainview/routes/launcher.$source.$id.tsx | 3 +- src/mainview/routes/settings/accounts.tsx | 11 +- .../routes/settings/plugin.$source.tsx | 2 +- src/mainview/routes/store/tab/games.tsx | 1 + src/mainview/routes/store/tab/index.tsx | 4 +- src/mainview/scripts/queries/romm.ts | 24 +- src/mainview/scripts/queries/settings.ts | 13 +- src/mainview/scripts/utils.ts | 20 +- src/shared/constants.ts | 5 +- src/shared/types..d.ts | 2 +- 39 files changed, 523 insertions(+), 275 deletions(-) diff --git a/README.md b/README.md index e3b2c46..26befdf 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A Cross-Platform open source Retro gaming frontend designed for handheld and con Focused on building a simple user experience and intuitive UI as a curated community driven experience. > [!WARNING] -> This app is actively in development, it doesn't have most of its major features implemented yet. +> This app is actively in development, it is contantly chaning and improving. > It will have an opinionated design and will be used as an experiment in discovering a good UX. ## Features @@ -13,6 +13,8 @@ Focused on building a simple user experience and intuitive UI as a curated commu - **[ROMM](https://github.com/rommapp/romm)** - download, sync and update roms and platforms. - **[Emulator JS](https://github.com/EmulatorJS/EmulatorJS)** - play your games with emulator js right within the app. Uses RetroArch cores. +- **[RClone](https://github.com/rclone/rclone)** - sync saves between devices or cloud. +- **[UMU](https://github.com/Open-Wine-Components/umu-launcher)** - UMU Launcher for playing windows games on linux without needing steam. (Only used for store games for now) ### Store @@ -32,6 +34,7 @@ Focused on building a simple user experience and intuitive UI as a curated commu - **Automatic Emulator Discovery** - Using the configs of the excellent ES-DE to discover installed emulators and launch games. - Easy fallback configuration with built in file browser. - **Responsive Layout** - Optimized mainly for the steam deck with responsive layout support and dynamic switching of inputs. +- **Cloud/Device Save Sync** - For supported games and emulators. ## Screenshots diff --git a/drizzle/0002_flowery_rocket_raccoon.sql b/drizzle/0002_flowery_rocket_raccoon.sql index 0d8fa7e..5f7942e 100644 --- a/drizzle/0002_flowery_rocket_raccoon.sql +++ b/drizzle/0002_flowery_rocket_raccoon.sql @@ -22,7 +22,7 @@ CREATE TABLE `__new_games` ( FOREIGN KEY (`platform_id`) REFERENCES `platforms`(`id`) ON UPDATE cascade ON DELETE no action ); --> statement-breakpoint -INSERT INTO `__new_games`("id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "main_glob", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", "version", "version_source", "version_system") SELECT "id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "main_glob", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", "version", "version_source", "version_system" FROM `games`;--> statement-breakpoint +INSERT INTO `__new_games`("id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "main_glob", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", "version", "version_source", "version_system") SELECT "id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", NULL, "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", NULL, NULL, NULL FROM `games`;--> statement-breakpoint DROP TABLE `games`;--> statement-breakpoint ALTER TABLE `__new_games` RENAME TO `games`;--> statement-breakpoint PRAGMA foreign_keys=ON;--> statement-breakpoint diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index e4401bb..f54671c 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -72,6 +72,7 @@ export async function load () console.log("Config Path Located At: ", config.path); console.log("Custom Emulator Paths Located At: ", customEmulators.path); console.log("App Directory is ", process.env.APPDIR); + console.log("Cache Path is ", cachePath); cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite'); fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json')); diff --git a/src/bun/api/auth.ts b/src/bun/api/auth.ts index a0740bc..47ea019 100644 --- a/src/bun/api/auth.ts +++ b/src/bun/api/auth.ts @@ -46,67 +46,7 @@ export default new Elysia() return status(res.status, res.statusText); }) - .get('/login/twitch', async () => - { - const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); - if (!access_token) - { - return status('Not Found', "Not Logged In"); - } - - const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${access_token}` } }); - if (res.ok) - { - return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; }; - } - - if (!process.env.TWITCH_CLIENT_ID) - { - return status("Not Found", "Twitch Client ID not set"); - } - - const refresh_token = await secrets.get({ service: 'gamflow_twitch', name: "refresh_token" }); - if (!refresh_token) - { - return status("Not Found", "Refresh Token Not Found"); - } - - // refresh token - const refreshResponse = await fetch('https://id.twitch.tv/oauth2/token', { - method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ - client_id: process.env.TWITCH_CLIENT_ID, - access_token, - grant_type: "refresh_token", - refresh_token - }) - }); - - if (refreshResponse.ok) - { - const data: { - access_token: string, - refresh_token: string, - token_type: string; - expires_in: number; - } = await refreshResponse.json(); - - await secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token }); - await secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token }); - await secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() }); - - await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' }); - - events.emit('notification', { message: "Twitch Refresh Successful", type: 'success' }); - - const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${data.access_token}` } }); - if (res.ok) - { - return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; }; - } - } - - return status(400, res.statusText); - }) + .get('/login/twitch', checkLoginAndRefreshTwitch) .post('/login/romm/qr', async () => { if (taskQueue.hasActiveOfType(LoginJob)) @@ -123,47 +63,7 @@ export default new Elysia() return data.data as UserSchema; }) .post('/login/romm', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) }) - .get('/login/romm', async () => - { - const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' }); - if (!access_token) - { - return { hasLogin: false }; - } - - const expires_in = await secrets.get({ service: 'gameflow', name: "romm_expires_in" }); - if (expires_in) - { - const date = new Date(expires_in); - if (date > new Date()) - { - return { hasLogin: true }; - } - } - - const refresh_token = await secrets.get({ service: 'gameflow', name: "romm_refresh_token" }); - if (!refresh_token) - { - return { hasLogin: false }; - } - - const refreshResponse = await tokenApiTokenPost({ body: { grant_type: "refresh_token", refresh_token: refresh_token } }); - - if (refreshResponse.response.ok && refreshResponse.data) - { - await secrets.set({ service: 'gameflow', name: 'romm_access_token', value: refreshResponse.data.access_token }); - if (refreshResponse.data.refresh_token) - await secrets.set({ service: 'gameflow', name: 'romm_refresh_token', value: refreshResponse.data.refresh_token }); - await secrets.set({ service: 'gameflow', name: 'romm_expires_in', value: new Date(new Date().getTime() + refreshResponse.data.expires * 1000).toString() }); - - await plugins.hooks.auth.loginComplete.promise({ service: 'romm' }); - - events.emit('notification', { message: "Romm Refresh Successful", type: 'success' }); - return { hasLogin: true }; - } - - return status(refreshResponse.response.status, refreshResponse.response.statusText) as any; - }, + .get('/login/romm', checkLoginAndRefreshRomm, { response: z.object({ hasLogin: z.boolean() }) }) .post('/logout/romm', async () => { @@ -174,6 +74,109 @@ export default new Elysia() }, { response: z.any() }); +export async function checkLoginAndRefreshTwitch () +{ + const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); + if (!access_token) + { + return status('Not Found', "Not Logged In"); + } + + const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${access_token}` } }); + if (res.ok) + { + return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; }; + } + + if (!process.env.TWITCH_CLIENT_ID) + { + return status("Not Found", "Twitch Client ID not set"); + } + + const refresh_token = await secrets.get({ service: 'gamflow_twitch', name: "refresh_token" }); + if (!refresh_token) + { + return status("Not Found", "Refresh Token Not Found"); + } + + // refresh token + const refreshResponse = await fetch('https://id.twitch.tv/oauth2/token', { + method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ + client_id: process.env.TWITCH_CLIENT_ID, + access_token, + grant_type: "refresh_token", + refresh_token + }) + }); + + if (refreshResponse.ok) + { + const data: { + access_token: string, + refresh_token: string, + token_type: string; + expires_in: number; + } = await refreshResponse.json(); + + await secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token }); + await secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token }); + await secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() }); + + await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' }); + + events.emit('notification', { message: "Twitch Refresh Successful", type: 'success' }); + + const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${data.access_token}` } }); + if (res.ok) + { + return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; }; + } + } + + return status(400, res.statusText); +} + +export async function checkLoginAndRefreshRomm () +{ + const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' }); + if (!access_token) + { + return { hasLogin: false }; + } + + const expires_in = await secrets.get({ service: 'gameflow', name: "romm_expires_in" }); + if (expires_in) + { + const date = new Date(expires_in); + if (date > new Date()) + { + return { hasLogin: true }; + } + } + + const refresh_token = await secrets.get({ service: 'gameflow', name: "romm_refresh_token" }); + if (!refresh_token) + { + return { hasLogin: false }; + } + + const refreshResponse = await tokenApiTokenPost({ body: { grant_type: "refresh_token", refresh_token: refresh_token } }); + + if (refreshResponse.response.ok && refreshResponse.data) + { + await secrets.set({ service: 'gameflow', name: 'romm_access_token', value: refreshResponse.data.access_token }); + if (refreshResponse.data.refresh_token) + await secrets.set({ service: 'gameflow', name: 'romm_refresh_token', value: refreshResponse.data.refresh_token }); + await secrets.set({ service: 'gameflow', name: 'romm_expires_in', value: new Date(new Date().getTime() + refreshResponse.data.expires * 1000).toString() }); + + await plugins.hooks.auth.loginComplete.promise({ service: 'romm' }); + + events.emit('notification', { message: "Romm Refresh Successful", type: 'success' }); + return { hasLogin: true }; + } + + return status(refreshResponse.response.status, refreshResponse.response.statusText) as any; +} export async function tryLoginAndSave ({ host, username, password }: { host: string, username: string, password: string; }) { diff --git a/src/bun/api/cache.ts b/src/bun/api/cache.ts index 6aa465a..ff84b4d 100644 --- a/src/bun/api/cache.ts +++ b/src/bun/api/cache.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { cache } from "./app"; import cacheSchema from "@schema/cache"; import { GithubReleaseSchema } from "@/shared/constants"; +import PQueue from "p-queue"; export const CACHE_KEYS = { ROM_PLATFORMS: 'rom-platforms', @@ -9,6 +10,8 @@ export const CACHE_KEYS = { STORE_GAME_MANIFEST: 'store-game-manifest' } as const; +export const githubRequestQueue = new PQueue({ intervalCap: 10, interval: 1000 * 60 * 10, strict: true }); + export async function getOrCached (key: string, getter: () => Promise, options?: { expireMs?: number; }): Promise { const cached = await cache.query.item_cache.findFirst({ where: eq(cacheSchema.item_cache.key, key) }); @@ -37,10 +40,10 @@ export async function getOrCached (key: string, getter: () => Promise, opt export async function getOrCachedGithubRelease (path: string) { - return getOrCached(`github-release-${path}`, async () => + return getOrCached(`github-release-${path}`, async () => githubRequestQueue.add(async () => { const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, { method: "GET" }); if (!response.ok) throw new Error(response.statusText); return GithubReleaseSchema.parseAsync(await response.json()); - }); + }), { expireMs: 1000 * 60 * 60 }); } \ No newline at end of file diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index aa715ca..fb94e71 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -124,6 +124,12 @@ export class GameHooks platformSlug?: string; }; }]>(["ctx"]); + postInstall = new AsyncSeriesHook<[ctx: { + source: string, + id: string; + files: string[]; + info: DownloadInfo; + }]>(['ctx']); fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']); fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']); diff --git a/src/bun/api/jobs/emulator-download-job.ts b/src/bun/api/jobs/emulator-download-job.ts index 74e13d2..0da918e 100644 --- a/src/bun/api/jobs/emulator-download-job.ts +++ b/src/bun/api/jobs/emulator-download-job.ts @@ -11,6 +11,7 @@ import { ensureDir, move } from "fs-extra"; import { simulateProgress } from "@/bun/utils"; import { path7za } from "7zip-bin"; import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService"; +import { $ } from "bun"; type EmulatorDownloadStates = "download" | "extract"; @@ -61,7 +62,7 @@ export class EmulatorDownloadJob implements IJob + if (destinationPath.endsWith('.tar')) { - const seven = Seven.extractFull(destinationPath, emulatorsFolder, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true, noRootDuplication: true }); - seven.on('progress', p => context.setProgress(p.percent, "extract")); - seven.on('error', e => reject(e)); - seven.on('end', () => resolve(true)); - }); - await fs.rm(destinationPath, { recursive: true }); + context.setProgress(0, "extract"); + await ensureDir(emulatorsFolder); + await $`tar -xf ${destinationPath} -C ${emulatorsFolder}`; + await fs.rm(destinationPath, { recursive: true }); + } else + { + await new Promise((resolve, reject) => + { + const seven = Seven.extractFull(destinationPath, emulatorsFolder, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true, noRootDuplication: true }); + seven.on('progress', p => context.setProgress(p.percent, "extract")); + seven.on('error', e => reject(e)); + seven.on('end', () => resolve(true)); + }); + await fs.rm(destinationPath, { recursive: true }); + } // check if 1 root folder we need to get rid of const contents = await fs.readdir(emulatorsFolder); @@ -106,15 +116,18 @@ export class EmulatorDownloadJob implements IJob e.type === 'store')?.binPath ?? emulatorsFolder, info, update: this.isUpdate }); - - await Bun.write(`${emulatorsFolder}.json`, JSON.stringify(info, null, 3)); } } diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 166af46..9a2b8b8 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -59,6 +59,7 @@ export class InstallJob implements IJob if (!info) throw new Error(`Could not find downloader for source ${this.source}`); const files = await checkFiles(info.files, !!info.extract_path); + const finalFiles: string[] = []; if (this.config?.dryRun !== true) { @@ -84,6 +85,7 @@ export class InstallJob implements IJob { return; } + if (info.extract_path && downloadedFiles) { let progress = 0; @@ -139,6 +141,7 @@ export class InstallJob implements IJob 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 }); @@ -175,6 +178,12 @@ export class InstallJob implements IJob await move(tmpGameFolder, extractPath, { overwrite: true }); } } + + finalFiles.push(extractPath); + + } else + { + finalFiles.push(...downloadedFiles); } } @@ -323,6 +332,7 @@ export class InstallJob implements IJob await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal); } + await plugins.hooks.games.postInstall.promise({ source: this.source, id: this.gameId, files: finalFiles, info }); events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 }); } diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 1a430e5..139d8af 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -5,9 +5,9 @@ import { db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; import { eq } from "drizzle-orm"; import { spawn } from 'node:child_process'; -import { watch } from "node:fs"; import fs from "node:fs/promises"; import { updateLocalLastPlayed } from "../games/services/statusService"; +import { getErrorMessage } from "@/bun/utils"; export class LaunchGameJob implements IJob, string> { @@ -42,15 +42,24 @@ export class LaunchGameJob implements IJob console.error(e)); + await new Promise(async (resolve) => + { + await plugins.hooks.games.postPlay.promise( + { + source, + id, + command: this.validCommand, + changedSaveFiles: Array.from(this.changedSaveFiles.values()), + validChangedSaveFiles: {}, + gameInfo + }).catch(e => + { + console.error(e); + events.emit('notification', { message: getErrorMessage(e), type: 'error' }); + }).then(() => resolve(false)); + const timeoutHandler = () => resolve(false); + setTimeout(timeoutHandler, 5000); + }); } prePlay (setProgress: (progress: number, state: string) => void, gameInfo: { platformSlug?: string; }) @@ -118,31 +127,58 @@ export class LaunchGameJob implements IJob reject(e)); - // ES-DE commands require shell execution. Some emulators fail otherwise. - const spawnGame = spawn(this.validCommand.command, { - shell: this.validCommand.shell ?? true, - cwd: this.validCommand.startDir, - signal: context.abortSignal, - env: { - ...process.env, - ...this.validCommand.env - }, - }); - - context.setProgress(0, "playing"); - - spawnGame.stdout.on('data', data => console.log(data)); - spawnGame.on('close', (code) => + if (Array.isArray(this.validCommand.command)) { - resolve(code); - }); - spawnGame.on('error', e => - { - console.error(e); - resolve(1); - }); + const bunGame = Bun.spawn(this.validCommand.command, { + cwd: this.validCommand.startDir, + signal: context.abortSignal, + env: { + ...process.env, + ...this.validCommand.env + } + }); + + context.setProgress(0, "playing"); + + bunGame.exited.then(e => + { + resolve(true); + }).catch(e => + { + console.error(e); + reject(e); + }); + + game = bunGame; + } else + { + // ES-DE commands require shell execution. Some emulators fail otherwise. + const spawnGame = spawn(this.validCommand.command, { + shell: this.validCommand.shell ?? true, + cwd: this.validCommand.startDir, + signal: context.abortSignal, + env: { + ...process.env, + ...this.validCommand.env + }, + }); + + context.setProgress(0, "playing"); + + spawnGame.stdout.on('data', data => console.log(data)); + spawnGame.on('close', (code) => + { + resolve(code); + }); + spawnGame.on('error', e => + { + console.error(e); + resolve(1); + }); + + game = spawnGame; + } - game = spawnGame; } else if (this.validCommand.metadata.emulatorBin) { @@ -151,7 +187,6 @@ export class LaunchGameJob implements IJob; @@ -75,8 +77,8 @@ export default class RcloneIntegration implements PluginType await ensureDir(toolsPath); const binaryMap: Record = { win32: '**/rclone.exe', - linux: '**/rclone', - darwin: '**/rclone' + linux: 'rclone-*/rclone', + darwin: 'rclone-*/rclone' }; const existingRclones = await Array.fromAsync(fs.glob(binaryMap[process.platform], { cwd: toolsPath })); if (existingRclones[0]) @@ -102,7 +104,7 @@ export default class RcloneIntegration implements PluginType await ensureDir(toolsPath); await pipeline(Readable.fromWeb(rcCloseZip.body as any), unzip.Extract({ path: toolsPath })); - const dests = await Array.fromAsync(fs.glob('**/rclone.exe', { cwd: toolsPath })); + const dests = await Array.fromAsync(fs.glob(binaryMap[process.platform], { cwd: toolsPath })); if (dests[0]) { this.rclonePath = path.join(toolsPath, dests[0]); @@ -218,7 +220,7 @@ export default class RcloneIntegration implements PluginType ctx.hooks.games.prePlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, setProgress, saveFolderSlots }) => { - if (source !== 'store' || !this.rclonePath || !saveFolderSlots) return; + if (source !== 'store' || !this.rclonePath || !saveFolderSlots || !ctx.config.get('importSaves')) return; for await (const [slot, { cwd }] of Object.entries(saveFolderSlots)) { @@ -250,8 +252,7 @@ export default class RcloneIntegration implements PluginType UseJSONLog: true, LogLevel: "DEBUG", HumanReadable: true, - Progress: true, - DryRun: true + Progress: true } }); console.log(data); @@ -261,7 +262,7 @@ export default class RcloneIntegration implements PluginType ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles }) => { - if (source !== 'store' || !this.rclonePath) return; + if (source !== 'store' || !this.rclonePath || !ctx.config.get('exportSaves')) return; console.log("Save Files", Object.values(validChangedSaveFiles).flatMap(c => Array.isArray(c.subPath) ? c.subPath : [c.subPath]).join(",")); await Promise.all(Object.entries(validChangedSaveFiles).map(async ([slot, change]) => diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts index 78d28b3..ba7bfed 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts @@ -3,6 +3,7 @@ import desc from './package.json'; import secrets from "@/bun/api/secrets"; import PQueue from 'p-queue'; import * as igdb from '@phalcode/ts-igdb-client'; +import { checkLoginAndRefreshTwitch } from "@/bun/api/auth"; export default class IgdbIntegration implements PluginType { @@ -39,6 +40,8 @@ export default class IgdbIntegration implements PluginType async load (ctx: PluginLoadingContextType) { + await checkLoginAndRefreshTwitch(); + ctx.hooks.games.gameLookup.tapPromise(desc.name, async ({ source, id }) => { if (!process.env.TWITCH_CLIENT_ID) return; diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index c0d754e..ccc0c29 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -13,6 +13,7 @@ import { getAuthToken } from "@/clients/romm/core/auth.gen"; import { client } from "@/clients/romm/client.gen"; import { validateGameSource } from "@/bun/api/games/services/statusService"; import z from "zod"; +import { checkLoginAndRefreshRomm } from "@/bun/api/auth"; const SettingsSchema = z.object({ savesSync: z.boolean().default(false).describe("Experimental save sync support") @@ -143,6 +144,8 @@ export default class RommIntegration implements PluginType async load (ctx: PluginLoadingContextType) { this.isSteamDeck = isSteamDeckGameMode(); + ctx.setProgress(0, "Logging Into Romm"); + await checkLoginAndRefreshRomm(); await this.updateClient(); ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) => @@ -150,7 +153,6 @@ export default class RommIntegration implements PluginType if (!await this.checkRemote()) return; if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm')) { - const rommGames = await getRomsApiRomsGet({ query: { platform_ids: query.platform_id ? [query.platform_id] : undefined, diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts index 852284a..1d92b59 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from "node:path"; import * as appSchema from '@schema/app'; import * as emulatorSchema from '@schema/emulators'; -import { db, emulatorsDb, plugins } from "@/bun/api/app"; +import { config, db, emulatorsDb, plugins } from "@/bun/api/app"; import { and, eq } from "drizzle-orm"; import { getOrCached } from "@/bun/api/cache"; import { Glob } from "bun"; @@ -318,4 +318,47 @@ export async function getExistingStoreEmulatorDownload (emulator: EmulatorPackag // this should only happen if download info is missing maybe manually deleted or wasn't saved. return undefined; +} + +export async function buildLaunchCommand (ctx: { gamePath: string; systemSlug: string; mainGlob?: string | null; }): Promise +{ + if (ctx.systemSlug !== 'win' && ctx.systemSlug !== 'linux' && ctx.systemSlug !== 'mac') return; + const downloadPath = config.get('downloadPath'); + const gamePathAbsolute = path.join(downloadPath, ctx.gamePath); + if (!(await fs.exists(gamePathAbsolute))) return; + const gamePathStat = await fs.stat(gamePathAbsolute); + + if (gamePathStat.isDirectory()) + { + let mainGlob = ctx.mainGlob; + if (!mainGlob && ctx.systemSlug === 'win') mainGlob = '**/*.exe'; + if (!mainGlob) return; + const fileGlob = new Glob(mainGlob); + for await (const file of fileGlob.scan({ cwd: path.join(downloadPath, ctx.gamePath) })) + { + return { + startDir: path.join(downloadPath, ctx.gamePath, path.dirname(file)), + command: [`./${path.basename(file)}`], + id: `store-${process.platform}`, + shell: false, + valid: true, + metadata: { + romPath: path.join(downloadPath, ctx.gamePath, file) + } + }; + } + + } else + { + return { + startDir: path.join(downloadPath, path.dirname(ctx.gamePath)), + command: [`./${path.basename(ctx.gamePath)}`], + id: `store-${process.platform}`, + valid: true, + shell: false, + metadata: { + romPath: path.join(downloadPath, ctx.gamePath), + } + }; + } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts index 59e3b26..c57330e 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts @@ -10,11 +10,24 @@ import { config, emulatorsDb, taskQueue } from "@/bun/api/app"; import fs from "node:fs/promises"; import { getSourceGameDetailed } from "@/bun/api/games/services/utils"; import UpdateStoreJob from "@/bun/api/jobs/update-store"; -import { getEmulatorDownload } from "@/bun/api/store/services/emulatorsService"; -import { buildFilters, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services"; +import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; +import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services"; +import { path7za } from "7zip-bin"; export default class RommIntegration implements PluginType { + eventsNames = [{ id: 'updateStore', title: "Update Store", description: "Update the Store Manifest", action: "Update" }]; + + async onEvent (e: string) + { + switch (e) + { + case 'updateStore': + await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); + return { reload: true }; + } + } + async setup (ctx: PluginLoadingContextType) { console.log("Store Directory is ", getStoreFolder()); @@ -126,52 +139,52 @@ export default class RommIntegration implements PluginType saves?.forEach(([key, val]) => validChangedSaveFiles[key] = val); }); + ctx.hooks.emulators.findEmulatorSource.tapPromise(desc.name, async ({ emulator, sources }) => + { + const emulatorPackage = await getStoreEmulatorPackage(emulator); + if (!emulatorPackage) return undefined; + const storeDownloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage); + if (!storeDownloadInfo) return; + const emulatorPath = getEmulatorPath(emulator); + if (!await fs.exists(emulatorPath)) return; + const validDownload = emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].find(d => d.type === storeDownloadInfo?.type); + if (!validDownload || !validDownload.bin) return; + const glob = new Glob(validDownload.bin); + const files = await Array.fromAsync(glob.scan({ cwd: emulatorPath })); + if (files.length > 0) + { + sources.push({ binPath: path.join(emulatorPath, files[0]), exists: true, rootPath: emulatorPath, type: 'store' }); + } + }); + + ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: 'UMU' }, async ({ path: emulatorPath }) => + { + const pathStat = await fs.stat(emulatorPath); + if (pathStat.isFile()) + { + await fs.chmod(emulatorPath, 0o755); + } + }); + + ctx.hooks.games.postInstall.tapPromise(desc.name, async ({ source, id, files, info }) => + { + if (source !== 'store') return; + if (files.length === 1) + { + const command = await buildLaunchCommand({ gamePath: files[0], systemSlug: info.system_slug, mainGlob: info.main_glob }); + if (command && command.metadata.romPath) + { + await fs.chmod(command.metadata.romPath, 0o755); + } + } + }); + ctx.hooks.games.buildLaunchCommands.tapPromise({ name: desc.name, before: 'com.simeonradivoev.gameflow.es' }, async ({ gamePath, source, sourceId, systemSlug, mainGlob }) => { if (source !== 'store' || !gamePath) return; - const downloadPath = config.get('downloadPath'); - const gamePathAbsolute = path.join(downloadPath, gamePath); - if (!(await fs.exists(gamePathAbsolute))) return; - const gamePathStat = await fs.stat(gamePathAbsolute); - - if (gamePathStat.isDirectory()) - { - if (!mainGlob && systemSlug !== 'win') return; - const fileGlob = new Glob(mainGlob ?? '**/*.exe'); - for await (const file of fileGlob.scan({ cwd: path.join(downloadPath, gamePath) })) - { - return [{ - startDir: path.join(downloadPath, gamePath, dirname(file)), - command: `./${basename(file)}`, - id: `store-${process.platform}`, - shell: false, - valid: true, - env: { - XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '') - }, - metadata: { - romPath: path.join(downloadPath, gamePath, file) - } - }]; - } - - } else - { - return [{ - startDir: path.join(downloadPath, dirname(gamePath)), - command: `./${basename(gamePath)}`, - env: { - XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '') - }, - id: `store-${process.platform}`, - valid: true, - shell: false, - metadata: { - romPath: path.join(downloadPath, gamePath) - } - }]; - } - + const command = await buildLaunchCommand({ gamePath, systemSlug, mainGlob }); + if (!command) return; + return [command]; }); ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) => diff --git a/src/bun/api/plugins/plugins.ts b/src/bun/api/plugins/plugins.ts index 6a2dedc..a898036 100644 --- a/src/bun/api/plugins/plugins.ts +++ b/src/bun/api/plugins/plugins.ts @@ -19,7 +19,7 @@ export default new Elysia({ prefix: '/plugins' }) canDisable: p.description.canDisable ?? true, icon: p.description.icon, category: p.description.category, - hasSettings: !!p.config + hasSettings: !!p.config || !!p.plugin.eventsNames }; return plugin; }); diff --git a/src/bun/api/store/services/gamesService.ts b/src/bun/api/store/services/gamesService.ts index 17ee6ec..2e0d675 100644 --- a/src/bun/api/store/services/gamesService.ts +++ b/src/bun/api/store/services/gamesService.ts @@ -1,6 +1,6 @@ import { EmulatorPackageSchema, EmulatorPackageType, GithubManifestSchema, StoreGameSchema } from "@/shared/constants"; import { CACHE_KEYS, getOrCached } from "../../cache"; -import { and, eq } from "drizzle-orm"; +import { and, eq, or } from "drizzle-orm"; import { config, emulatorsDb } from '../../app'; import path from "node:path"; import fs from 'node:fs/promises'; @@ -46,10 +46,10 @@ export async function buildStoreFrontendEmulatorSystems (emulator: EmulatorPacka const systems = await Promise.all(emulator.systems.map(async system => { const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ - where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system)) + where: or(and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system)), and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, system))) }); - const esSystem = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.emulators.name, system), columns: { fullname: true } }); + const esSystem = await emulatorsDb.query.systems.findFirst({ where: or(eq(emulatorSchema.emulators.name, system), eq(emulatorSchema.emulators.name, rommSystem?.system ?? '')), columns: { fullname: true } }); let icon: string = `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`; diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index e70da91..1c4323a 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -12,7 +12,7 @@ import { CACHE_KEYS, getOrCached } from "../cache"; import { getStoreFolder } from "./services/gamesService"; import { EmulatorDownloadJob } from "../jobs/emulator-download-job"; import { BiosDownloadJob } from "../jobs/bios-download-job"; -import { findEmulatorPluginIntegration } from "./services/emulatorsService"; +import { findEmulatorPluginIntegration, getEmulatorPath } from "./services/emulatorsService"; export const store = new Elysia({ prefix: '/api/store' }) .get('/emulators', async ({ query }) => @@ -148,13 +148,22 @@ export const store = new Elysia({ prefix: '/api/store' }) }) .delete('/emulator/:id', async ({ params: { id } }) => { - const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id); + const storeEmulatorFolder = getEmulatorPath(id); + const existingPackagePath = `${storeEmulatorFolder}.json`; + let hadDelete = false; + if (await fs.exists(existingPackagePath)) + { + await fs.rm(existingPackagePath); + hadDelete = true; + } + if (await fs.exists(storeEmulatorFolder)) { fs.rm(storeEmulatorFolder, { recursive: true }); - return status("OK"); + hadDelete = true; } - return status("Not Found"); + + return hadDelete ? status("OK") : status("Not Found"); }) .post('/download/bios/:id', async ({ params: { id } }) => { diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 5292b85..927c00d 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -15,18 +15,22 @@ import { getStoreFolder } from "./store/services/gamesService"; import ReloadPluginsJob from "./jobs/reload-plugins-job"; import { semver } from "bun"; import packageDef from '~/package.json'; +import { getOrCached, githubRequestQueue } from "./cache"; async function checkUpdate () { - const latest = await fetch('https://api.github.com/repos/simeonradivoev/gameflow-deck/releases/latest'); - if (latest.ok) + return getOrCached('check-for-update', async () => githubRequestQueue.add(async () => { - const data = await latest.json(); - const hasUpdate = semver.order(data.tag_name, packageDef.version); - return hasUpdate; - } + const latest = await fetch('https://api.github.com/repos/simeonradivoev/gameflow-deck/releases/latest'); + if (latest.ok) + { + const data = await latest.json(); + const hasUpdate = semver.order(data.tag_name, packageDef.version); + return hasUpdate; + } - return 0; + return 0; + }), { expireMs: 1000 * 60 * 60 }); } export const system = new Elysia({ prefix: '/api/system' }) diff --git a/src/bun/types/typesc.schema.ts b/src/bun/types/typesc.schema.ts index c87a6ef..56bf90c 100644 --- a/src/bun/types/typesc.schema.ts +++ b/src/bun/types/typesc.schema.ts @@ -36,7 +36,10 @@ export const PluginSchema = z.object({ description: z.string().optional(), action: z.string() }).array().optional(), - onEvent: z.function().input([z.string()]).output(z.any()).optional() + onEvent: z.function().input([z.string()]).output(z.object({ + openTab: z.string().optional(), + reload: z.boolean().optional() + }).or(z.record(z.string(), z.any()))).optional() }); export type PluginType = Record> = Omit, "load" | 'settingsMigrations'> & { @@ -55,6 +58,6 @@ export const ActiveGameSchema = z.object({ source: z.string().optional(), sourceId: z.string().optional(), name: z.string(), - command: z.object({ command: z.string(), startDir: z.string().optional() }) + command: z.object({ command: z.string().or(z.string().array()), startDir: z.string().optional() }) }); export type ActiveGameType = z.infer; \ No newline at end of file diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index ca2e27d..5de871d 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -63,7 +63,7 @@ export function CardList (data: { onSelectGame?: (id: string) => void; focus?: string; className?: string; - finalElement?: JSX.Element; + finalElement?: JSX.Element | JSX.Element[]; saveChildFocus?: 'session' | 'local'; } & FocusParams) { diff --git a/src/mainview/components/FilePicker.tsx b/src/mainview/components/FilePicker.tsx index 689900d..6788952 100644 --- a/src/mainview/components/FilePicker.tsx +++ b/src/mainview/components/FilePicker.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { ContextList, DialogEntry } from "./ContextDialog"; import { systemApi } from "../scripts/clientApi"; -import { useContext, useRef, useState } from "react"; +import { FocusEventHandler, useContext, useRef, useState } from "react"; import path from "pathe"; import { Check, File, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; @@ -15,6 +15,7 @@ import toast from "react-hot-toast"; import { FilePickerContext } from "../scripts/contexts"; import useActiveControl from "../scripts/gamepads"; import { createFolderMutation, drivesQuery, filesQuery } from "@queries/system"; +import { showKeyboardHandler } from "../scripts/utils"; function List (data: { id: string, @@ -87,15 +88,16 @@ function List (data: { function NewFolderInput (data: { id: string, name: string | undefined, setName: (name: string) => void; className?: string; }) { const inputRef = useRef(null); + const { control } = useActiveControl(); const { ref, focused, focusSelf } = useFocusable({ focusKey: data.id, onEnterPress: () => inputRef.current?.focus(), onBlur: () => inputRef.current?.blur(), }); - const handleFocus = () => + const handleFocus: FocusEventHandler = (e) => { focusSelf(); - systemApi.api.system.show_keyboard.post(); + showKeyboardHandler(control as any, e.target); }; return
    void; focus?: string; className?: string; - finalElement?: JSX.Element; + finalElement?: JSX.Element | JSX.Element[]; + emptyElement?: JSX.Element | JSX.Element[]; saveChildFocus?: "session" | "local"; } @@ -52,6 +53,25 @@ export function GameList (data: GameListParams) navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source } }); }; + const finalElement: JSX.Element[] = []; + if (!games.isFetching && !!games.data && games.data.games.length <= 0) + { + if (Array.isArray(data.emptyElement)) + { + finalElement.push(...data.emptyElement); + } else if (data.emptyElement) + { + finalElement.push(data.emptyElement); + } + } + if (Array.isArray(data.finalElement)) + { + finalElement.push(...data.finalElement); + } else if (data.finalElement) + { + finalElement.push(data.finalElement); + } + return ( <> 0 ?
    {systemContext.wifiConnections.map(w => { - const className = "w-6 h-6"; + const className = "w-10 h-10"; let icon = ; if (w.signalLevel >= -60) icon = ; @@ -164,7 +164,7 @@ function WiFiStatus () else if (w.signalLevel >= -90) icon = ; - return
    + return
    {icon}
    ; })} diff --git a/src/mainview/components/HeaderSearchField.tsx b/src/mainview/components/HeaderSearchField.tsx index 3845987..50befd9 100644 --- a/src/mainview/components/HeaderSearchField.tsx +++ b/src/mainview/components/HeaderSearchField.tsx @@ -1,10 +1,13 @@ import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import { Ref, RefObject, useEffect, useRef, useState } from "react"; +import { FocusEventHandler, Ref, RefObject, useEffect, useRef, useState } from "react"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { oneShot } from "../scripts/audio/audio"; import { Search } from "lucide-react"; import { RoundButton } from "./RoundButton"; import { useEventListener } from "usehooks-ts"; +import { systemApi } from "../scripts/clientApi"; +import { showKeyboardHandler } from "../scripts/utils"; +import useActiveControl from "../scripts/gamepads"; function SearchInput (data: { id: string; @@ -16,6 +19,7 @@ function SearchInput (data: { onSubmit: (search: string | undefined) => void; } & FocusParams) { + const { control } = useActiveControl(); const { ref, focusKey } = useFocusable({ onBlur: () => inputRef.current?.blur(), onFocus: (l, p, d) => @@ -59,6 +63,8 @@ function SearchInput (data: { data.onSubmit?.(undefined); }, inputRef as any); + const handlInputFocus: FocusEventHandler = e => showKeyboardHandler(control as any, e.target); + return
    ; + }} className='flex data-[hidden=true]:invisible bg-base-100 game-card focusable focusable-accent focusable-hover text-2xl justify-center items-center cursor-pointer' data-hidden={data.hidden} onClick={e => handleAction(e.nativeEvent)} id='load-more-btn'>{data.isFetching ? : "Load More"}
    ; } \ No newline at end of file diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index 08a6261..332d659 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -6,6 +6,8 @@ import { systemApi } from "../../scripts/clientApi"; import { CheckIcon, X } from "lucide-react"; import { oneShot } from "@/mainview/scripts/audio/audio"; import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; +import { showKeyboardHandler } from "@/mainview/scripts/utils"; +import useActiveControl from "@/mainview/scripts/gamepads"; export function OptionInput (data: { name: string; @@ -35,6 +37,7 @@ export function OptionInput (data: { } oneShot('click'); }; + const { control } = useActiveControl(); const [inputFocused, setInputFocused] = useState(false); const inputRef = useRef(null); const { ref, focusKey } = useFocusable({ @@ -99,20 +102,11 @@ export function OptionInput (data: { return shortcuts; }, [inputFocused, data.type]); - const handleInputFocus = () => + const handleInputFocus: FocusEventHandler = (e) => { option.focus(); setInputFocused(true); - if (inputRef.current) - { - var rect = inputRef.current?.getBoundingClientRect(); - systemApi.api.system.show_keyboard.post({ - XPosition: rect.x, - YPosition: rect.y, - Width: rect.width, - Height: rect.height - }); - } + showKeyboardHandler(control as any, e.target); }; const handleInputBlur = (e: any) => diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 12d7411..e2e6844 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -10,6 +10,9 @@ import OctagonAlert, Maximize, Store, + LayoutGrid, + PlusCircle, + Plus, } from "lucide-react"; import { @@ -39,7 +42,7 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/ import z from "zod"; import CollectionList from "../components/CollectionList"; import { zodValidator } from '@tanstack/zod-adapter'; -import { mobileCheck, useDragScroll } from "../scripts/utils"; +import { mobileCheck, scrollIntoNearestParent, scrollIntoViewHandler, useDragScroll } from "../scripts/utils"; import { AnimatedBackgroundContext } from "../scripts/contexts"; import Carousel from "../components/Carousel"; import { closeMutation } from "@queries/system"; @@ -48,6 +51,7 @@ import { oneShot } from "../scripts/audio/audio"; import { FloatingShortcuts } from "../components/Shortcuts"; import SelectMenu from "../components/SelectMenu"; import HeaderSearchField from "../components/HeaderSearchField"; +import CardElement from "../components/CardElement"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -91,6 +95,35 @@ function HomeListError (data: { focused: boolean; })
    ; } +function Preview (data: { index: number; children?: any; }) +{ + const isMobile = mobileCheck(); + return
    + {data.children} +
    ; +} + +function GetStoreGamesCard () +{ + const router = useRouter(); + const handleNavigate = () => + { + router.navigate({ to: '/store/tab/games' }); + }; + return ]} onAction={handleNavigate} title="Gameflow Store" subtitle="Get Free Games" preview={} focusKey='store-games-btn' index={0} id="store-games-btn" />; +} + function ShowAllGamesCard () { const router = useRouter(); @@ -98,8 +131,7 @@ function ShowAllGamesCard () { router.navigate({ to: '/games' }); }; - const { ref } = useFocusable({ focusKey: 'all-games-btn', onEnterPress: handleNavigate }); - return
    All Games
    ; + return } focusKey='all-games-btn' index={0} id="all-games-btn" />; } function HomeList (data: { @@ -165,7 +197,8 @@ function HomeList (data: { id="games-list" setBackground={bg.setBackground} filters={{ limit: 12, orderBy: 'activity' }} - finalElement={} + finalElement={[, ]} + emptyElement={[]} /> ; diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index bba950b..4254395 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -10,7 +10,8 @@ import { useRef } from 'react'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, staticData: { - enterSound: 'launch' + enterSound: 'launch', + missNavSound: false }, }); diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index a2b87b9..0e63a80 100644 --- a/src/mainview/routes/settings/accounts.tsx +++ b/src/mainview/routes/settings/accounts.tsx @@ -24,7 +24,7 @@ import { useJobStatus } from "@/mainview/scripts/utils"; import { useInterval } from "usehooks-ts"; import { TwitchIcon } from "@/mainview/scripts/brandIcons"; import { twitchLoginMutation, twitchLoginVerificationQuery, twitchLogoutMutation } from "@queries/settings"; -import { rommGetOptionsQuery, rommLoggedInQuery, rommHostnameQuery, rommLoginMutation, rommLogoutMutation, rommQrLoginMutation, rommUsernameQuery, rommUserQuery } from "@queries/romm"; +import { rommGetOptionsQuery, rommLoggedInQuery, rommHostnameQuery, rommLoginMutation, rommLogoutMutation, rommQrLoginMutation, rommUsernameQuery, rommUserQuery, invalidateLogin } from "@queries/romm"; import { systemApi } from "@/mainview/scripts/clientApi"; export const Route = createFileRoute("/settings/accounts")({ @@ -59,10 +59,7 @@ function TwitchLogin () { const loginStatus = useQuery(twitchLoginVerificationQuery); - const loginMutation = useMutation({ - ...twitchLoginMutation, - onSuccess: () => loginStatus.refetch() - }); + const loginMutation = useMutation(twitchLoginMutation); const logoutMutation = useMutation({ ...twitchLogoutMutation, onSuccess: () => loginStatus.refetch() }); @@ -100,8 +97,8 @@ function LoginControls (data: {}) ...rommLogoutMutation, onSuccess: async (d, v, r, c) => { - user.refetch(); - await c.client.invalidateQueries({ queryKey: ["romm", "auth"] }); + await user.refetch(); + await invalidateLogin(c.client); await router.navigate({ replace: true }); } }); diff --git a/src/mainview/routes/settings/plugin.$source.tsx b/src/mainview/routes/settings/plugin.$source.tsx index ecd46af..78e0623 100644 --- a/src/mainview/routes/settings/plugin.$source.tsx +++ b/src/mainview/routes/settings/plugin.$source.tsx @@ -42,7 +42,7 @@ function PluginAction (data: { id: string, title: string | undefined, descriptio
    {data.title ?? data.id}
    {data.description}
    }> - + ; } diff --git a/src/mainview/routes/store/tab/games.tsx b/src/mainview/routes/store/tab/games.tsx index d402367..febe997 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -71,6 +71,7 @@ function RouteComponent ()
    diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts index 6b185c3..7ebf4db 100644 --- a/src/mainview/scripts/queries/romm.ts +++ b/src/mainview/scripts/queries/romm.ts @@ -1,6 +1,6 @@ import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants"; import { rommApi, settingsApi } from "../clientApi"; -import { InvalidateQueryFilters, mutationOptions, QueryFilters, queryOptions, useMutation } from "@tanstack/react-query"; +import { InvalidateQueryFilters, mutationOptions, QueryClient, QueryFilters, queryOptions, useMutation } from "@tanstack/react-query"; import z from "zod"; import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen"; @@ -23,6 +23,20 @@ export const gameQuery = (source: string, id: string) => queryOptions({ }, }); export const rommLogoutMutation = mutationOptions({ mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.romm.post() }); +export const invalidateLogin = (client: QueryClient) => +{ + return client.invalidateQueries({ + predicate (query) + { + return query.queryKey.includes('auth') + || query.queryKey.includes('games') + || query.queryKey.includes('game') + || query.queryKey.includes('platform') + || query.queryKey.includes('platforms') + || query.queryKey.includes('collections'); + }, + }); +}; export const rommQrLoginMutation = mutationOptions({ mutationKey: ['login', 'qr', 'cancel'], mutationFn: async () => @@ -30,7 +44,11 @@ export const rommQrLoginMutation = mutationOptions({ const { data, error } = await rommApi.api.romm.login.romm.qr.post(); if (error) throw error; return data; - } + }, + onSuccess: (d, v, r, c) => + { + invalidateLogin(c.client); + }, }); export const rommLoginMutation = mutationOptions({ mutationKey: ["romm", "login"], @@ -41,7 +59,7 @@ export const rommLoginMutation = mutationOptions({ }, onSuccess: (d, v, r, c) => { - c.client.invalidateQueries({ queryKey: ['romm', 'auth'] }); + invalidateLogin(c.client); }, onError: (e) => { diff --git a/src/mainview/scripts/queries/settings.ts b/src/mainview/scripts/queries/settings.ts index 5b99ace..e0f605e 100644 --- a/src/mainview/scripts/queries/settings.ts +++ b/src/mainview/scripts/queries/settings.ts @@ -2,6 +2,7 @@ import { mutationOptions, queryOptions } from "@tanstack/react-query"; import { getErrorMessage } from "react-error-boundary"; import toast from "react-hot-toast"; import { rommApi, settingsApi } from "../clientApi"; +import { invalidateLogin } from "./romm"; export const changeDownloadsMutation = mutationOptions({ mutationKey: ["setting", "downloads"], @@ -29,21 +30,25 @@ export const autoEmulatorsQuery = queryOptions({ } }); export const twitchLogoutMutation = mutationOptions({ - mutationKey: ['twitch', 'logout'], + mutationKey: ['twitch', 'auth', 'logout'], mutationFn: () => { return rommApi.api.romm.logout.twitch.post(); } }); export const twitchLoginMutation = mutationOptions({ - mutationKey: ['twitch', 'login'], + mutationKey: ['twitch', 'auth', 'login'], mutationFn: (openInBrowser: boolean) => { return rommApi.api.romm.login.twitch.post({ openInBrowser }); - } + }, + onSuccess (data, variables, onMutateResult, context) + { + invalidateLogin(context.client); + }, }); export const twitchLoginVerificationQuery = queryOptions({ - queryKey: ['twitch', 'login', 'status'], + queryKey: ['twitch', 'login', 'status', 'auth'], retry (failureCount, error) { if ((error as any).status === 404) diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index 428be6e..9164617 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -1,7 +1,7 @@ import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants"; -import { DependencyList, RefObject, useEffect, useRef, useState } from "react"; +import { DependencyList, FocusEventHandler, RefObject, useEffect, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; -import { jobsApi } from "./clientApi"; +import { jobsApi, systemApi } from "./clientApi"; import { JobsAPIType } from "@/bun/api/rpc"; import { AnyRouter, useRouter } from "@tanstack/react-router"; import { soundMap } from "./audio/audioConstants"; @@ -368,4 +368,18 @@ export function useOnNavigateBack (callback: (state: { sound?: keyof typeof soun return unsub; }, [router]); -} \ No newline at end of file +} + +export function showKeyboardHandler (activeControl: string, node?: HTMLInputElement) +{ + if (node && node.type !== 'checkbox' && (activeControl === 'gamepad' || activeControl === 'touch')) + { + var rect = node.getBoundingClientRect(); + systemApi.api.system.show_keyboard.post({ + XPosition: rect.x, + YPosition: rect.y, + Width: rect.width, + Height: rect.height + }); + } +}; \ No newline at end of file diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 74670f8..e21bb54 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -145,15 +145,18 @@ export const EmulatorPackageSchema = z.object({ z.object({ type: z.literal(['github', 'gitlab']), pattern: z.string(), - path: z.string() + path: z.string(), + bin: z.string().optional() }), z.object({ type: z.literal('direct'), url: z.url(), + bin: z.string().optional() }), z.object({ type: z.literal('scoop'), url: z.url(), + bin: z.string().optional() }) ]))).optional(), systems: z.array(z.string()), diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index c2396ba..2a03104 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -119,7 +119,7 @@ declare interface CommandEntry /** The front end label for the command. Mainly gotten from ES-DE list */ label?: string; /** Compiled command to be executed */ - command: string; + command: string | string[]; /** Environment variables */ env?: Record, /** The path the spawned process will start at */ From 701f88213693589db07e1da270c85d90635c96e9 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Wed, 22 Apr 2026 18:31:32 +0300 Subject: [PATCH 44/65] Added nw.js launch options --- .../rclone.ts | 3 +- src/bun/browser.ts | 25 ++++++++++++ src/bun/webview/linux.ts | 39 +++---------------- src/mainview/gen/static-icon-assets.gen.ts | 2 +- tsconfig.json | 1 + 5 files changed, 34 insertions(+), 36 deletions(-) diff --git a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts index 4b8c59c..ba31a9b 100644 --- a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts +++ b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts @@ -250,8 +250,7 @@ export default class RcloneIntegration implements PluginType UseJSONLog: true, LogLevel: "DEBUG", HumanReadable: true, - Progress: true, - DryRun: true + Progress: true } }); console.log(data); diff --git a/src/bun/browser.ts b/src/bun/browser.ts index 79c01b8..c86dfef 100644 --- a/src/bun/browser.ts +++ b/src/bun/browser.ts @@ -3,6 +3,9 @@ import { BrowserParams, BuildParams } from './utils/browser-params'; import os from 'node:os'; import { EventEmitter } from 'node:stream'; import { dlopen, FFIType, Pointer } from "bun:ffi"; +import { SERVER_URL } from '@/shared/constants'; +import { host } from './utils/host'; +import fs from 'node:fs/promises'; export default async function init (events: EventEmitter, forceBrowser: boolean, params: BrowserParams) { @@ -19,6 +22,8 @@ export default async function init (events: EventEmitter, forceBrowser: boolean, await runBrowser(events, params); } } + + await runNW(events, params); } function focusWindow (id: Pointer) @@ -44,8 +49,28 @@ function focusWindow (id: Pointer) } } +async function runNW (events: EventEmitter, params: BrowserParams) +{ + const path = process.platform === 'win32' ? './bin/nw/nw.exe' : './bin/nw/nw'; + if (!await fs.exists(path)) + { + console.error("Could not find NW.js"); + return; + } + const signalHandler = new AbortController(); + events.on('exitapp', () => signalHandler.abort()); + const args = [path, `--url=${SERVER_URL(host)}`]; + if (process.env.NODE_ENV !== 'development') args.push("--disable-devtools"); + const nwProcess = Bun.spawn(args, { signal: signalHandler.signal }); + await nwProcess.exited; +} + async function runWebview (events: EventEmitter, params: BrowserParams) { + if (process.platform !== 'win32') + { + throw new Error("Webview only supported on windows"); + } const webviewPath = process.env.IS_BINARY ? `./webview/${os.platform()}` : new URL(`./webview/${os.platform()}`, import.meta.url).href; console.log("Launching Webview Worker at: ", webviewPath); const config: Record = {}; diff --git a/src/bun/webview/linux.ts b/src/bun/webview/linux.ts index c53ccca..6683249 100644 --- a/src/bun/webview/linux.ts +++ b/src/bun/webview/linux.ts @@ -1,36 +1,9 @@ import { Size, SizeHint, Webview } from 'webview-bun'; import webviewWorkerBase from "./base"; -if (process.env.FLATPAK_BUILD === "true") -{ - let webview: Bun.Subprocess | undefined = undefined; - let hostUrl: string | undefined = undefined; - webviewWorkerBase({ - navigate: (url) => - { - hostUrl = url; - - }, destroy: () => webview?.kill(), run: () => - { - webview = Bun.spawn(["webview", hostUrl ?? ''], { - stdout: "inherit", - stderr: "inherit", - env: { - ...process.env, - }, - onExit () - { - postMessage({ data: 'destroyed' }); - } - }); - } - }); -} else -{ - console.log("Launching Webview"); - let size: Size | undefined = undefined; - if (process.env.WINDOW_WIDTH && process.env.WINDOW_HEIGHT) - size = { width: Number(process.env.WINDOW_WIDTH), height: Number(process.env.WINDOW_HEIGHT), hint: SizeHint.NONE }; - const webview = new Webview(process.env.NODE_ENV === 'development', size); - webviewWorkerBase(webview); -} \ No newline at end of file +console.log("Launching Webview"); +let size: Size | undefined = undefined; +if (process.env.WINDOW_WIDTH && process.env.WINDOW_HEIGHT) + size = { width: Number(process.env.WINDOW_WIDTH), height: Number(process.env.WINDOW_HEIGHT), hint: SizeHint.NONE }; +const webview = new Webview(process.env.NODE_ENV === 'development', size); +webviewWorkerBase(webview); \ No newline at end of file diff --git a/src/mainview/gen/static-icon-assets.gen.ts b/src/mainview/gen/static-icon-assets.gen.ts index 1d1a4aa..cb3fe1b 100644 --- a/src/mainview/gen/static-icon-assets.gen.ts +++ b/src/mainview/gen/static-icon-assets.gen.ts @@ -464,7 +464,7 @@ const assets = new Set([ ]); // Store basePath resolved from Vite config -const BASE_PATH = "/"; +const BASE_PATH = "./"; /** diff --git a/tsconfig.json b/tsconfig.json index 49e40c5..7fb79f1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -41,6 +41,7 @@ }, "include": [ "src", + "scripts", "vite.config.ts", "vite-env-override.d.ts" ] From 813785f4f3d292a87cc4a6b86dc152c43572d2c8 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 26 Apr 2026 03:26:15 +0300 Subject: [PATCH 45/65] feat: Bundled NW.js with appimages feat: Implemented self update feat: Added rclone saves for emulators fix: Fixed auto focus in builds feat: Added helper cards on empty library --- .config/appimage/AppRun | 2 + ...m.simeonradivoev.gameflow-deck.appdata.xml | 53 +++ .../com.simeonradivoev.gameflow-deck.desktop | 10 + .../com.simeonradivoev.gameflow-deck.desktop | 2 +- .../com.simeonradivoev.gameflow-deck.json | 53 +-- .config/flatpak/webview/CMakeLists.txt | 35 -- .config/flatpak/webview/main.cpp | 14 - .github/workflows/build.yml | 4 +- .gitignore | 3 +- .vscode/settings.json | 9 +- package.json | 5 +- scripts/build-appimage.ts | 64 ++-- scripts/dev.ts | 78 +++-- scripts/download-nw.ts | 54 +++ src/bun/api/app.ts | 8 +- src/bun/api/cache.ts | 20 +- src/bun/api/jobs/launch-game-job.ts | 65 ++-- src/bun/api/jobs/self-update-job.ts | 118 +++++++ .../com.simeonradivoev.gameflow.cemu/cemu.ts | 13 +- .../dolphin.ts | 19 +- .../pcsx2.ts | 14 +- .../ppsspp.ts | 31 +- .../xenia.ts | 13 +- .../rclone.ts | 326 +++++++++++++----- .../com.simeonradivoev.gameflow.romm/romm.ts | 4 +- src/bun/api/plugins/plugin-manager.ts | 6 +- src/bun/api/system.ts | 55 +-- src/bun/api/task-queue.ts | 13 +- src/bun/browser.ts | 94 +++-- src/bun/index.ts | 40 ++- src/bun/types/types.d.ts | 10 + src/bun/utils.ts | 6 + src/bun/utils/browser-params.ts | 9 + src/bun/utils/downloader.ts | 29 +- src/bun/utils/update-gameflow-linux.sh | 6 + src/bun/utils/update-gameflow-windows.bat | 6 + .../components/AnimatedBackground.tsx | 21 +- src/mainview/components/AppCommunication.tsx | 4 + src/mainview/components/AutoFocus.tsx | 12 +- src/mainview/components/CardList.tsx | 4 +- src/mainview/components/Header.tsx | 29 +- .../components/ImageWithFallbacks.tsx | 15 +- src/mainview/components/backgrounds/dots.tsx | 5 +- src/mainview/components/game/MainActions.tsx | 17 +- src/mainview/gen/static-icon-assets.gen.ts | 2 +- src/mainview/index.css | 2 +- src/mainview/routes/game/$source.$id.tsx | 2 +- src/mainview/routes/index.tsx | 43 ++- src/mainview/routes/launcher.$source.$id.tsx | 6 +- src/mainview/routes/settings/about.tsx | 142 +++++--- .../routes/settings/plugin.$source.tsx | 40 ++- src/mainview/routes/settings/plugins.tsx | 4 +- src/mainview/routes/store/tab/games.tsx | 8 +- src/mainview/routes/store/tab/index.tsx | 14 +- src/mainview/scripts/queries/system.ts | 20 ++ src/mainview/scripts/utils.ts | 4 +- src/mainview/types.d.ts | 1 + src/shared/types..d.ts | 1 + vite.config.ts | 3 +- 59 files changed, 1210 insertions(+), 480 deletions(-) create mode 100644 .config/appimage/AppRun create mode 100644 .config/appimage/com.simeonradivoev.gameflow-deck.appdata.xml create mode 100644 .config/appimage/com.simeonradivoev.gameflow-deck.desktop delete mode 100644 .config/flatpak/webview/CMakeLists.txt delete mode 100644 .config/flatpak/webview/main.cpp create mode 100644 scripts/download-nw.ts create mode 100644 src/bun/api/jobs/self-update-job.ts create mode 100644 src/bun/utils/update-gameflow-linux.sh create mode 100644 src/bun/utils/update-gameflow-windows.bat diff --git a/.config/appimage/AppRun b/.config/appimage/AppRun new file mode 100644 index 0000000..7320ecc --- /dev/null +++ b/.config/appimage/AppRun @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$APPDIR/usr/bin/{{BINARY_NAME}}" "$@" \ No newline at end of file diff --git a/.config/appimage/com.simeonradivoev.gameflow-deck.appdata.xml b/.config/appimage/com.simeonradivoev.gameflow-deck.appdata.xml new file mode 100644 index 0000000..1d4cc0f --- /dev/null +++ b/.config/appimage/com.simeonradivoev.gameflow-deck.appdata.xml @@ -0,0 +1,53 @@ + + + {{APP_ID}} + CC0-1.0 + {{LICENSE}} + {{APP_NAME}} + Retro gaming frontend designed for handheld and controllers + + Simeon Radivoev + + +

    A Cross-Platform Retro gaming frontend designed for handheld and controllers. Focused on building a simple user experience and intuitive UI.

    +
    + + Game + + + always + + {{APP_ID}}.desktop + https://github.com/simeonradivoev/gameflow-deck + https://github.com/simeonradivoev/gameflow-deck/issues + https://github.com/sponsors/simeonradivoev + + + https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/yObFD2LySH.jpg + + +
  • Game DetailsThe Settings PanelEmulator DetailsGameflow Store
    - - - - - - {/* row 2 */} - - - - - - - - - - - - - {/* row 3 */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + const { ref, focusKey } = useFocusable({ focusKey: 'about-section' }); + const { data: hasUpdate, refetch: refetchHasUpdate } = useQuery(hasUpdateQuery); + const update = useMutation(updateMutation); + const forceCheckUpdate = useMutation({ + ...checkUpdateMutation, + onSuccess (data, variables, onMutateResult, context) + { + refetchHasUpdate(); + }, + }); + + return
    Agent{navigator.userAgent}
    Platform{navigator.platform}
    Resolution{screen.width}x{screen.height}
    Window{window.innerWidth}x{window.innerHeight}
    User{systemInfo?.data?.user}
    Architecture{systemInfo?.data?.arch}
    System{systemInfo?.data?.platform}
    Hostname{systemInfo?.data?.hostname}
    Machine{systemInfo?.data?.machine}
    SizesCache: {prettyBytes(systemInfo?.data?.cacheSize ?? 0)}, Store: {prettyBytes(systemInfo?.data?.storeSize ?? 0)}
    Source{systemInfo?.data?.source}
    Steam Deck{systemInfo?.data?.steamDeck ?? 'false'}
    + + + + + + + + + + + + + + + + {/* row 2 */} + + + + + + + + + + + + + {/* row 3 */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Version{systemInfo?.data?.version}
    Update + { + hasUpdate && hasUpdate.hasUpdate > 0 ? + : + + } + {} +
    Agent{navigator.userAgent}
    Platform{navigator.platform}
    Resolution{screen.width}x{screen.height}
    Window{window.innerWidth}x{window.innerHeight}
    User{systemInfo?.data?.user}
    Architecture{systemInfo?.data?.arch}
    System{systemInfo?.data?.platform}
    Hostname{systemInfo?.data?.hostname}
    Machine{systemInfo?.data?.machine}
    SizesCache: {prettyBytes(systemInfo?.data?.cacheSize ?? 0)}, Store: {prettyBytes(systemInfo?.data?.storeSize ?? 0)}
    Source{systemInfo?.data?.source}
    Steam Deck{systemInfo?.data?.steamDeck ?? 'false'}
    ; } diff --git a/src/mainview/routes/settings/plugin.$source.tsx b/src/mainview/routes/settings/plugin.$source.tsx index 78e0623..eedeada 100644 --- a/src/mainview/routes/settings/plugin.$source.tsx +++ b/src/mainview/routes/settings/plugin.$source.tsx @@ -1,4 +1,5 @@ import { AutoFocus } from '@/mainview/components/AutoFocus'; +import DotsLoading from '@/mainview/components/backgrounds/dots'; import { Button } from '@/mainview/components/options/Button'; import { OptionDropdown } from '@/mainview/components/options/OptionDropdown'; import { OptionInput } from '@/mainview/components/options/OptionInput'; @@ -7,16 +8,34 @@ import { RoundButton } from '@/mainview/components/RoundButton'; import { getAllPluginsQuery, getPluginDetailsQuery } from '@/mainview/scripts/queries/plugins'; import { getPluginActionsQuery, getPluginSettingQuery, getPluginSettingsDefinitionQuery, pluginActionMutation, setPluginSettingMutation } from '@/mainview/scripts/queries/settings'; import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { scrollIntoViewHandler } from '@/mainview/scripts/utils'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { JSONSchema7 } from 'json-schema'; import { ArrowLeft, CirclePlay, Play, Settings2, SettingsIcon } from 'lucide-react'; import toast from 'react-hot-toast'; export const Route = createFileRoute('/settings/plugin/$source')({ component: RouteComponent, + pendingComponent: Loading, + async loader (ctx) + { + const definitions = await ctx.context.queryClient.fetchQuery(getPluginSettingsDefinitionQuery(ctx.params.source)); + const actions = await ctx.context.queryClient.fetchQuery(getPluginActionsQuery(ctx.params.source)); + await new Promise(resolve => setTimeout(resolve, 1000)); + return { definitions, actions }; + }, }); +function Loading () +{ + const { ref, focusSelf } = useFocusable({ focusKey: 'plugins' }); + return <> + + + ; +} + function PluginAction (data: { id: string, title: string | undefined, description: string | undefined; action: string; reload: () => void; }) { const { source } = Route.useParams(); @@ -91,15 +110,19 @@ function PluginOption (data: { name: string, title?: string, prop: JSONSchema7; function Settings () { + const { definitions, actions } = Route.useLoaderData(); const { source } = Route.useParams(); - const { data: definitions, refetch: refetchDefinitions } = useQuery(getPluginSettingsDefinitionQuery(source)); - const { data: actions, refetch: referchActions } = useQuery(getPluginActionsQuery(source)); + const queryClient = useQueryClient(); + const handleReload = () => { - referchActions(); - refetchDefinitions(); + queryClient.refetchQueries(getPluginSettingsDefinitionQuery(source)); + queryClient.refetchQueries(getPluginActionsQuery(source)); }; - const { ref, focusKey } = useFocusable({ focusKey: 'plugin-settings' }); + const { ref, focusKey } = useFocusable({ + focusKey: 'plugin-settings', + focusable: (definitions?.properties && Object.keys(definitions?.properties).length > 0) || actions.length > 0 + }); return
    {!!definitions?.properties && Object.entries(Object.groupBy(Object.entries(definitions?.properties) @@ -142,16 +165,19 @@ function RouteComponent () return
    - +
    + {data?.displayName}
      {data?.keywords?.map((k, i) =>
    • {k}
    • )}
    {data?.description}
    + +
    ; diff --git a/src/mainview/routes/settings/plugins.tsx b/src/mainview/routes/settings/plugins.tsx index 2e12c78..37240e9 100644 --- a/src/mainview/routes/settings/plugins.tsx +++ b/src/mainview/routes/settings/plugins.tsx @@ -1,3 +1,4 @@ +import { AutoFocus } from '@/mainview/components/AutoFocus'; import { pluginCategoryIcons, pluginCategoryPriorities } from '@/mainview/components/Constants'; import { Button } from '@/mainview/components/options/Button'; import { OptionInput } from '@/mainview/components/options/OptionInput'; @@ -62,7 +63,7 @@ function Plugin (data: { function RouteComponent () { const { data: plugins, refetch: refetchPlugins } = useQuery(getAllPluginsQuery); - const { ref, focusKey } = useFocusable({ focusKey: 'plugins' }); + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'plugins' }); const pluginMutation = useMutation({ ...enablePluginMutation, onSuccess (data, variables, onMutateResult, context) { @@ -84,6 +85,7 @@ function RouteComponent ()
    ; })} +
    ; } diff --git a/src/mainview/routes/store/tab/games.tsx b/src/mainview/routes/store/tab/games.tsx index febe997..edf0864 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -30,7 +30,7 @@ function RouteComponent () const { focus } = Route.useSearch(); const [search] = useSessionStorage(`${Route.to}-search`, undefined); const navigator = useNavigate(); - const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus }); + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus ?? 'store-games' }); const [filter, setFilter] = useSessionStorage('store-games-filters', {}); const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery(filter)); const { data: gameFilters } = useQuery(gameFiltersQuery({ source: 'store' })); @@ -80,7 +80,8 @@ function RouteComponent () if (isFetchingNextPage || isFetching) return; fetchNextPage(); - }} />} games={data?.pages.flatMap((page) => page.data.map((g) => + }} />} + games={data?.pages.flatMap((page) => page.data.map((g) => { const badges: JSX.Element[] = []; if (g.id.source === 'local') @@ -119,7 +120,8 @@ function RouteComponent () onFocus: (k, n, d) => handleFocus(k, n, d) } satisfies GameMetaExtra as GameMetaExtra; }) - ) ?? []} id={'store-games'} /> + ) ?? []} + id={'store-games'} />
    diff --git a/src/mainview/routes/store/tab/index.tsx b/src/mainview/routes/store/tab/index.tsx index 2992f88..178eea0 100644 --- a/src/mainview/routes/store/tab/index.tsx +++ b/src/mainview/routes/store/tab/index.tsx @@ -15,6 +15,7 @@ import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import { useQuery } from '@tanstack/react-query'; import { autoEmulatorsQuery } from '@queries/settings'; import { storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@queries/store'; +import ImageWithFallbacks from '@/mainview/components/ImageWithFallbacks'; export const Route = createFileRoute('/store/tab/')({ component: RouteComponent @@ -64,16 +65,7 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; }) {game ?
    - - { - e.currentTarget.dataset.loaded = "true"; - e.currentTarget.classList.toggle('scale-110', false); - }} - > - {previewUrls?.map((u, i) => )} - +
    @@ -89,7 +81,7 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
    - +
    :
    } diff --git a/src/mainview/scripts/queries/system.ts b/src/mainview/scripts/queries/system.ts index b46e4ea..ffc503f 100644 --- a/src/mainview/scripts/queries/system.ts +++ b/src/mainview/scripts/queries/system.ts @@ -56,4 +56,24 @@ export const hasUpdateQuery = queryOptions({ return data; }, staleTime: 1000 * 60 * 30 +}); + +export const checkUpdateMutation = mutationOptions({ + mutationKey: ['update', 'check'], + mutationFn: async () => + { + const { data, error } = await systemApi.api.system.update.check.post(); + if (error) throw error; + return data; + }, +}); + +export const updateMutation = mutationOptions({ + mutationKey: ['update'], + mutationFn: async () => + { + const { data, error } = await systemApi.api.system.update.post(); + if (error) throw error; + return data; + }, }); \ No newline at end of file diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index 9164617..37b2da1 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -272,6 +272,7 @@ export function useJobStatus, "completed" | "ended", 'data'>) => void; onCompleted?: (data: ExtractField, "completed" | "ended", 'data'>) => void; onError?: (error: string) => void; + onClosed?: () => void; }, deps?: DependencyList ) @@ -289,6 +290,7 @@ export function useJobStatus init?.onClosed?.()); sub.subscribe(({ data }) => { switch (data.type) @@ -326,7 +328,7 @@ export function useJobStatus; declare module "@emulators" { const data: Record; diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index 2a03104..b715875 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -341,6 +341,7 @@ declare interface SaveFileChange isGlob?: true; cwd: string; shared: boolean; + fixedSize?: boolean; } declare type SaveSlots = Record; \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 7029442..b396eae 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -102,7 +102,8 @@ export default defineConfig(({ command }) => }, define: { __HOST__: JSON.stringify(host), - __PUBLIC__: process.env.PUBLIC_ACCESS ? true : false + __PUBLIC__: process.env.PUBLIC_ACCESS ? true : false, + __FLATPAK__: process.env.FLATPAK_BUILD ? true : false } }; }); \ No newline at end of file From cf84f40a174b8f242ca58fb6fe02eefab46ff442 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 26 Apr 2026 14:56:54 +0300 Subject: [PATCH 46/65] feat: added update notes and moved update to own tab feat: added update info for emulators --- .gitignore | 3 +- bun.lock | 168 +++++++++++++++++- package.json | 2 + scripts/dev.ts | 14 +- src/bun/api/system.ts | 7 +- src/bun/index.ts | 1 - src/mainview/components/Header.tsx | 2 +- src/mainview/gen/routeTree.gen.ts | 21 +++ src/mainview/index.css | 1 + src/mainview/routes/settings/about.tsx | 21 +-- src/mainview/routes/settings/route.tsx | 7 + src/mainview/routes/settings/update.tsx | 75 ++++++++ .../routes/store/details.emulator.$id.tsx | 28 ++- src/shared/types..d.ts | 2 +- 14 files changed, 318 insertions(+), 34 deletions(-) create mode 100644 src/mainview/routes/settings/update.tsx diff --git a/.gitignore b/.gitignore index f21beae..e7e5c74 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,5 @@ gameflow-deck.code-workspace src/tests/mock-roms/db.sqlite src/tests/mock-config bin -.config/flatpak/repo \ No newline at end of file +.config/flatpak/repo +xenia.log \ No newline at end of file diff --git a/bun.lock b/bun.lock index 22e9e88..884980a 100644 --- a/bun.lock +++ b/bun.lock @@ -44,6 +44,7 @@ "@emulatorjs/emulatorjs": "^4.2.3", "@hey-api/openapi-ts": "^0.91.0", "@noriginmedia/norigin-spatial-navigation": "^2.3.0", + "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-form": "^1.28.0", "@tanstack/react-query": "^5.90.20", @@ -87,6 +88,7 @@ "react-dom": "^19.2.4", "react-error-boundary": "^6.1.0", "react-hot-toast": "^2.6.0", + "react-markdown": "^10.1.0", "react-qr-code": "^2.0.18", "sass-embedded": "^1.97.3", "standard-version": "^9.5.0", @@ -549,6 +551,8 @@ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="], @@ -619,10 +623,16 @@ "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/fs-extra": ["@types/fs-extra@11.0.4", "", { "dependencies": { "@types/jsonfile": "*", "@types/node": "*" } }, "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/howler": ["@types/howler@2.2.12", "", {}, "sha512-hy769UICzOSdK0Kn1FBk4gN+lswcj1EKRkmiDtMkUGvFfYJzgaDXmVXkSShS2m89ERAatGIPnTUlp2HhfkVo5g=="], "@types/ini": ["@types/ini@4.1.1", "", {}, "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg=="], @@ -631,8 +641,12 @@ "@types/jsonfile": ["@types/jsonfile@6.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + "@types/minimist": ["@types/minimist@1.2.5", "", {}, "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag=="], + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/mustache": ["@types/mustache@4.2.6", "", {}, "sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw=="], "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], @@ -647,8 +661,12 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/unzip-stream": ["@types/unzip-stream@0.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-ud0vtsNRF+joUCyvNMyo0j5DKX2Lh/im+xVgRzBEsfHhQYZ+i4fKTveova9XxLzt6Jl6G0e/0mM4aC0gqZYSnA=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], "JSONStream": ["JSONStream@1.3.5", "", { "dependencies": { "jsonparse": "^1.2.0", "through": ">=2.2.7 <3" }, "bin": { "JSONStream": "./bin.js" } }, "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ=="], @@ -707,6 +725,8 @@ "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -753,10 +773,20 @@ "caniuse-lite": ["caniuse-lite@1.0.30001765", "", {}, "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], @@ -785,6 +815,8 @@ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], "compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="], @@ -857,6 +889,8 @@ "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -879,6 +913,8 @@ "decamelize-keys": ["decamelize-keys@1.1.1", "", { "dependencies": { "decamelize": "^1.1.0", "map-obj": "^1.0.0" } }, "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="], "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], @@ -889,6 +925,8 @@ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], @@ -897,6 +935,8 @@ "detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], @@ -965,6 +1005,8 @@ "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], @@ -977,6 +1019,8 @@ "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "eyes": ["eyes@0.1.8", "", {}, "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ=="], "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], @@ -1067,6 +1111,10 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], @@ -1075,6 +1123,8 @@ "html-encoding-sniffer": ["html-encoding-sniffer@3.0.0", "", { "dependencies": { "whatwg-encoding": "^2.0.0" } }, "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA=="], + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], "http-proxy": ["http-proxy@1.18.1", "", { "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", "requires-port": "^1.0.0" } }, "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ=="], @@ -1097,12 +1147,20 @@ "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -1111,6 +1169,8 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], @@ -1119,7 +1179,7 @@ "is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], - "is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-text-path": ["is-text-path@1.0.1", "", { "dependencies": { "text-extensions": "^1.0.0" } }, "sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w=="], @@ -1213,6 +1273,8 @@ "lodash.negate": ["lodash.negate@3.0.2", "", {}, "sha512-JGJYYVslKYC0tRMm/7igfdHulCjoXjoganRNWM8AgS+RXfOvFnPkOveDhPI65F9aAypCX9QEEQoBqWf7Q6uAeA=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -1225,6 +1287,22 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="], "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], @@ -1233,6 +1311,48 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], @@ -1337,6 +1457,8 @@ "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], @@ -1383,6 +1505,8 @@ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], "preact": ["preact@10.11.3", "", {}, "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg=="], @@ -1403,6 +1527,8 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], "q": ["q@1.5.1", "", {}, "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw=="], @@ -1427,6 +1553,8 @@ "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + "react-qr-code": ["react-qr-code@2.0.18", "", { "dependencies": { "prop-types": "^15.8.1", "qr.js": "0.0.0" }, "peerDependencies": { "react": "*" } }, "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], @@ -1445,6 +1573,10 @@ "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1555,6 +1687,8 @@ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "spdx-correct": ["spdx-correct@3.2.0", "", { "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA=="], "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], @@ -1577,6 +1711,8 @@ "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "stringify-package": ["stringify-package@1.0.1", "", {}, "sha512-sa4DUQsYciMP1xhKWGuFM04fB0LG/9DlluZoSVywUMRNvzid6XucHK0/90xGxRoHrAaROrcHK1aPKaijCtSrhg=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1593,6 +1729,10 @@ "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -1649,8 +1789,12 @@ "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "trim-newlines": ["trim-newlines@3.0.1", "", {}, "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw=="], + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -1673,8 +1817,20 @@ "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + "union": ["union@0.5.0", "", { "dependencies": { "qs": "^6.4.0" } }, "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA=="], + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], @@ -1699,6 +1855,10 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vite-plugin-svg-icons-ng": ["vite-plugin-svg-icons-ng@1.5.2", "", { "dependencies": { "fast-glob": "^3.3.3", "fs-extra": "^11.3.2", "node-html-parser": "^7.0.1", "svgo": "^3.3.2" }, "peerDependencies": { "vite": ">=5.0.0" } }, "sha512-A68obs8XDT+q8q8dKyjrT/v0qw8h5pEBKXJ27aUXjARYeJ6MNvhIhRLLiUwnSrbn/B4TBF4UVaWRXKftAqP7+A=="], @@ -1757,6 +1917,8 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1891,10 +2053,14 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "minimist-options/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="], + "nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="], "optimist/minimist": ["minimist@0.0.10", "", {}, "sha512-iotkTvxc+TwOm5Ieim8VnSNvCDjCK9S8G3scJ50ZthspSxa7jx50jkhYduuAtAjvfDUwSgOwf8+If99AlOEhyw=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], diff --git a/package.json b/package.json index fc31668..57669af 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "@emulatorjs/emulatorjs": "^4.2.3", "@hey-api/openapi-ts": "^0.91.0", "@noriginmedia/norigin-spatial-navigation": "^2.3.0", + "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-form": "^1.28.0", "@tanstack/react-query": "^5.90.20", @@ -128,6 +129,7 @@ "react-dom": "^19.2.4", "react-error-boundary": "^6.1.0", "react-hot-toast": "^2.6.0", + "react-markdown": "^10.1.0", "react-qr-code": "^2.0.18", "sass-embedded": "^1.97.3", "standard-version": "^9.5.0", diff --git a/scripts/dev.ts b/scripts/dev.ts index 14e3f40..c619171 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -3,8 +3,10 @@ import browser from '../src/bun/browser'; import { tmpdir } from "os"; import path from "path"; import { watch } from "fs"; +import { sleep } from "bun"; const events = new EventEmitter(); const abortController = new AbortController(); +let restarting = false; process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=9222"; process.env.NODE_ENV = "development"; @@ -33,7 +35,7 @@ function spawnServer () }, onExit (subprocess, exitCode, signalCode) { - if (exitCode !== 3) + if (!restarting) { console.log("Existing Dev With", exitCode); process.exit(); @@ -63,18 +65,22 @@ async function restart () { if (server) { - server.kill("SIGUSR1"); + restarting = true; + server.kill(); await server.exited; server = undefined; - console.log("Server Restarted"); + console.log("Old Server stopped"); } server = spawnServer(); - console.log("Server Restarted"); + await sleep(1000); + console.log("New Server started"); + restarting = false; } watch("./src/bun", { recursive: true }, (event, filename) => { + if (restarting) return; console.log(`[watcher] ${event}: ${filename} — restarting...`); restart(); }); diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index ccc3fb9..a706563 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -20,9 +20,12 @@ import SelfUpdateJob from "./jobs/self-update-job"; async function checkUpdate (force?: boolean) { const latest = await getOrCachedGithubRelease('simeonradivoev/gameflow-deck', force); - if (!latest || !latest.tag_name) return { hasUpdate: 0, version: getAppVersion() }; + if (!latest || !latest.tag_name) return { + hasUpdate: 0, + version: getAppVersion() + }; const hasUpdate = semver.order(latest.tag_name, getAppVersion()); - return { hasUpdate, version: latest.tag_name }; + return { hasUpdate, version: latest.tag_name, info: latest.body }; } export const system = new Elysia({ prefix: '/api/system' }) diff --git a/src/bun/index.ts b/src/bun/index.ts index a2ba31d..7ad5803 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -25,7 +25,6 @@ async function shutdown (code: number) process.on("SIGINT", () => shutdown(0)); process.on("SIGTERM", () => shutdown(0)); -process.on('SIGUSR1', () => shutdown(3)); if (process.env.HEADLESS) { diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index cf36fe0..b9e29d9 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -89,7 +89,7 @@ function UpdateStatus () { const handleSelect = () => { - navigate({ to: '/settings/about' }); + navigate({ to: '/settings/update' }); }; const hasUnread = false; const navigate = useNavigate(); diff --git a/src/mainview/gen/routeTree.gen.ts b/src/mainview/gen/routeTree.gen.ts index 42c25f0..4d647d1 100644 --- a/src/mainview/gen/routeTree.gen.ts +++ b/src/mainview/gen/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './../routes/__root' import { Route as GamesRouteImport } from './../routes/games' import { Route as SettingsRouteRouteImport } from './../routes/settings/route' import { Route as IndexRouteImport } from './../routes/index' +import { Route as SettingsUpdateRouteImport } from './../routes/settings/update' import { Route as SettingsPluginsRouteImport } from './../routes/settings/plugins' import { Route as SettingsInterfaceRouteImport } from './../routes/settings/interface' import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emulators' @@ -45,6 +46,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const SettingsUpdateRoute = SettingsUpdateRouteImport.update({ + id: '/update', + path: '/update', + getParentRoute: () => SettingsRouteRoute, +} as any) const SettingsPluginsRoute = SettingsPluginsRouteImport.update({ id: '/plugins', path: '/plugins', @@ -142,6 +148,7 @@ export interface FileRoutesByFullPath { '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute '/settings/plugins': typeof SettingsPluginsRoute + '/settings/update': typeof SettingsUpdateRoute '/collection/$source/$id': typeof CollectionSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute @@ -163,6 +170,7 @@ export interface FileRoutesByTo { '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute '/settings/plugins': typeof SettingsPluginsRoute + '/settings/update': typeof SettingsUpdateRoute '/collection/$source/$id': typeof CollectionSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute @@ -186,6 +194,7 @@ export interface FileRoutesById { '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute '/settings/plugins': typeof SettingsPluginsRoute + '/settings/update': typeof SettingsUpdateRoute '/collection/$source/$id': typeof CollectionSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute @@ -210,6 +219,7 @@ export interface FileRouteTypes { | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/settings/update' | '/collection/$source/$id' | '/embedded/$source/$id' | '/game/$source/$id' @@ -231,6 +241,7 @@ export interface FileRouteTypes { | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/settings/update' | '/collection/$source/$id' | '/embedded/$source/$id' | '/game/$source/$id' @@ -253,6 +264,7 @@ export interface FileRouteTypes { | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/settings/update' | '/collection/$source/$id' | '/embedded/$source/$id' | '/game/$source/$id' @@ -301,6 +313,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/settings/update': { + id: '/settings/update' + path: '/update' + fullPath: '/settings/update' + preLoaderRoute: typeof SettingsUpdateRouteImport + parentRoute: typeof SettingsRouteRoute + } '/settings/plugins': { id: '/settings/plugins' path: '/plugins' @@ -430,6 +449,7 @@ interface SettingsRouteRouteChildren { SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute SettingsInterfaceRoute: typeof SettingsInterfaceRoute SettingsPluginsRoute: typeof SettingsPluginsRoute + SettingsUpdateRoute: typeof SettingsUpdateRoute SettingsPluginSourceRoute: typeof SettingsPluginSourceRoute } @@ -440,6 +460,7 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = { SettingsEmulatorsRoute: SettingsEmulatorsRoute, SettingsInterfaceRoute: SettingsInterfaceRoute, SettingsPluginsRoute: SettingsPluginsRoute, + SettingsUpdateRoute: SettingsUpdateRoute, SettingsPluginSourceRoute: SettingsPluginSourceRoute, } diff --git a/src/mainview/index.css b/src/mainview/index.css index 532b330..4c82b71 100644 --- a/src/mainview/index.css +++ b/src/mainview/index.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @import 'animate.css'; @plugin "daisyui"; +@plugin "@tailwindcss/typography"; @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); @custom-variant light (&:where([data-theme=light], [data-theme=light] *)); diff --git a/src/mainview/routes/settings/about.tsx b/src/mainview/routes/settings/about.tsx index 3351ff5..b6db34f 100644 --- a/src/mainview/routes/settings/about.tsx +++ b/src/mainview/routes/settings/about.tsx @@ -16,15 +16,7 @@ function RouteComponent () { const { data: systemInfo } = useQuery(systemInfoQuery); const { ref, focusKey } = useFocusable({ focusKey: 'about-section' }); - const { data: hasUpdate, refetch: refetchHasUpdate } = useQuery(hasUpdateQuery); - const update = useMutation(updateMutation); - const forceCheckUpdate = useMutation({ - ...checkUpdateMutation, - onSuccess (data, variables, onMutateResult, context) - { - refetchHasUpdate(); - }, - }); + return @@ -34,17 +26,6 @@ function RouteComponent () - - - - diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index 11c213e..5df28ac 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -23,6 +23,7 @@ import Joystick, MonitorCog, Puzzle, + RefreshCcw, } from "lucide-react"; import { JSX, useMemo } from "react"; import { twMerge } from "tailwind-merge"; @@ -166,6 +167,12 @@ function SettingsMenu (data: {}) label="Directories" icon={} /> + } + /> + + + ; +} + +function RouteComponent () +{ + const { data } = Route.useLoaderData(); + const navigate = useNavigate(); + const update = useMutation(updateMutation); + const forceCheckUpdate = useMutation({ + ...checkUpdateMutation, + onSuccess (data, variables, onMutateResult, context) + { + context.client.invalidateQueries(hasUpdateQuery); + navigate({ to: '/settings/update', replace: true }); + }, + }); + const { ref, focusKey } = useFocusable({ focusKey: 'updates' }); + return
    + +

    Version: {data.version}

    +
    + { + data.hasUpdate > 0 ? + : + + } + {} +
    +
    Version Info
    +
    + {children}; + }, + }} >{data.info} +
    +
    +
    ; +} diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 5b3beeb..0d6f6af 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -1,4 +1,4 @@ -import { useRef } from "react"; +import { useRef, useState } from "react"; import { useFocusable, @@ -27,6 +27,8 @@ import { deleteBiosMutation, downloadBiosMutation, installEmulatorMutation, stor import { gamesRecommendedBasedOnEmulatorQuery } from "@queries/romm"; import FocusTooltip from "@/mainview/components/FocusTooltip"; import { AutoFocus } from "@/mainview/components/AutoFocus"; +import { FilterUI } from "@/mainview/components/Filters"; +import Markdown from "react-markdown"; export const Route = createFileRoute('/store/details/emulator/$id')({ component: RouteComponent, @@ -330,6 +332,16 @@ const capabilityIconMap: Record = { batch: }; +function InfoTabs (data: { tabs: Record, selectTab: (v: string) => void; }) +{ + const { ref, focusKey } = useFocusable({ focusKey: 'emulator-info-tabs-section', onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'start' })(focusKey, ref.current, d) }); + return
    + + data.selectTab(v)} /> + +
    ; +} + export function RouteComponent () { const { id } = Route.useParams(); @@ -343,6 +355,7 @@ export function RouteComponent () const { data: emulator, isPending: isEmulatorPending } = useQuery(storeEmulatorDetailsQuery(id)); const { data: recommendedEmulators } = useQuery(storeEmulatorsRecommendedQuery(id)); const { data: recommendedGames } = useQuery(gamesRecommendedBasedOnEmulatorQuery(id)); + const [infoTab, setInfoTab] = useState("stats"); useShortcuts(focusKey, () => [{ label: "Return", @@ -389,7 +402,15 @@ export function RouteComponent () stats.push({ label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios :
    Missing
    }); + } + const infoTabs: Record = { + stats: { label: "Stats", selected: infoTab === 'stats', icon: }, + }; + + if (emulator?.storeDownloadInfo?.hasUpdate) + { + infoTabs.update = { label: "Update", icon: , selected: infoTab === 'update' }; } return ( @@ -411,8 +432,9 @@ export function RouteComponent ()
    -
    Stats
    - + + {infoTab === 'stats' && } + {infoTab === 'update' && {emulator?.storeDownloadInfo?.description}} {recommendedEmulators &&
    Date: Sun, 26 Apr 2026 15:46:03 +0300 Subject: [PATCH 47/65] fix: Made self update work on windows --- src/bun/api/jobs/self-update-job.ts | 10 +++++++--- src/bun/utils/update-gameflow-linux.sh | 3 +++ src/bun/utils/update-gameflow-windows.bat | 15 ++++++++++++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/bun/api/jobs/self-update-job.ts b/src/bun/api/jobs/self-update-job.ts index 38d57c0..f965311 100644 --- a/src/bun/api/jobs/self-update-job.ts +++ b/src/bun/api/jobs/self-update-job.ts @@ -48,6 +48,10 @@ export default class SelfUpdateJob implements IJob { case "win32": validAsset = data.assets.find((e: any) => new Bun.Glob(`Gameflow-${process.platform}-${process.arch}.zip`).match(e.name)); + if (!validAsset) + { + validAsset = data.assets.find((e: any) => new Bun.Glob(`Gameflow-*.zip`).match(e.name)); + } break; case "linux": validAsset = data.assets.find((e: any) => new Bun.Glob(`Gameflow-${process.platform}-${process.arch}.AppImage`).match(e.name)); @@ -100,13 +104,13 @@ export default class SelfUpdateJob implements IJob const batPath = path.join(os.tmpdir(), "update-gameflow.bat"); await Bun.write(batPath, mustache.render(winUpdateScript, { tempFile: winDownloads[0], - extractDir: path.dirname(process.execPath), + installDir: path.dirname(process.execPath), + extractDir: path.join(os.tmpdir(), 'gameflow-update-extract'), exePath: `${pkg.bin}.exe` })); context.setProgress(0, "Restarting App To Update"); - await cleanup(); events.emit('exitapp'); - Bun.spawn(["cmd", "/c", batPath], { detached: true }); + Bun.spawn(["cmd", "/c", "start", "cmd", "/c", batPath], { detached: true }); process.exit(0); } diff --git a/src/bun/utils/update-gameflow-linux.sh b/src/bun/utils/update-gameflow-linux.sh index 1bd1c81..9fc68cf 100644 --- a/src/bun/utils/update-gameflow-linux.sh +++ b/src/bun/utils/update-gameflow-linux.sh @@ -1,6 +1,9 @@ #!/bin/bash +echo "Waiting for app to close..." sleep 2 +echo "Installing update..." mv "{{{tempFile}}}" "{{{appImagePath}}}" chmod +x "{{{appImagePath}}}" +echo "Done! Restarting..." "{{{appImagePath}}}" & rm -- "$0" \ No newline at end of file diff --git a/src/bun/utils/update-gameflow-windows.bat b/src/bun/utils/update-gameflow-windows.bat index 11dc3c8..67b8ee8 100644 --- a/src/bun/utils/update-gameflow-windows.bat +++ b/src/bun/utils/update-gameflow-windows.bat @@ -1,6 +1,19 @@ @echo off +echo Waiting for app to close... timeout /t 2 /nobreak -powershell -Command "Expand-Archive -Force '{{{tempFile}}}' '{{{installDir}}}'" +echo. +if exist "{{{extractDir}}}" ( + echo Cleaning up previous update files... + rmdir /S /Q "{{{extractDir}}}" +) +echo Extracting update... +mkdir "{{{extractDir}}}" +tar -xf "{{{tempFile}}}" -C "{{{extractDir}}}" +echo Installing update... +for /d %%i in ("{{{extractDir}}}\*") do xcopy /E /Y /I "%%i\*" "{{{installDir}}}\" >nul +echo Cleaning up... +rmdir /S /Q "{{{extractDir}}}" del "{{{tempFile}}}" +echo Done! Restarting... start "" /D "{{{installDir}}}" "{{{exePath}}}" del "%~f0" \ No newline at end of file From 1653e494655bbe024ef1abe70faf8c0f7557d210 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 26 Apr 2026 15:46:22 +0300 Subject: [PATCH 48/65] chore(release): 1.4.0 --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ package.json | 4 ++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e45981..ccf01db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ 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.4.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.3.0...v1.4.0) (2026-04-26) + + +### Features + +* Added more ways to detect duplicates ([05fafce](https://github.com/simeonradivoev/gameflow-deck/commit/05fafced07c853deb656d7c17d05184c42ee507c)) +* added update notes and moved update to own tab ([cf84f40](https://github.com/simeonradivoev/gameflow-deck/commit/cf84f40a174b8f242ca58fb6fe02eefab46ff442)) +* Added way to update the local games from romm when IDs change based on IGDB or Retro Achievement ID ([4806f34](https://github.com/simeonradivoev/gameflow-deck/commit/4806f3487a577ab8e7c66907e5b640d95ab8a46c)), closes [#2](https://github.com/simeonradivoev/gameflow-deck/issues/2) +* Bundled NW.js with appimages ([813785f](https://github.com/simeonradivoev/gameflow-deck/commit/813785f4f3d292a87cc4a6b86dc152c43572d2c8)) +* Implemented audio effects ([edbc390](https://github.com/simeonradivoev/gameflow-deck/commit/edbc390d144bf44da35d0f5383ec36eb25c34d1b)) +* Implemented dolphin integration ([a69147a](https://github.com/simeonradivoev/gameflow-deck/commit/a69147a4f73cf626b92622a8ee22b54f538d41a9)) +* Implemented emulator launching ([09b8b9c](https://github.com/simeonradivoev/gameflow-deck/commit/09b8b9c6f850cea3b897308925faf9be02cefa1a)), closes [#1](https://github.com/simeonradivoev/gameflow-deck/issues/1) +* Implemented emulator versions and updating ([34db717](https://github.com/simeonradivoev/gameflow-deck/commit/34db717ec5cbcf8b1ae54fbda33bf9a78f01bd17)) +* Implemented filtering and searching ([444d8c4](https://github.com/simeonradivoev/gameflow-deck/commit/444d8c4c278c6032b37f44a884cb6d7bf0b54c85)) +* implemented haptics ([54dd925](https://github.com/simeonradivoev/gameflow-deck/commit/54dd9256e361877d0950a84061d9402616706352)) +* Implemented romm saves for dolphin and xenia ([7948bd2](https://github.com/simeonradivoev/gameflow-deck/commit/7948bd24fabfc01b7be358f06fcd58c8795826c7)) + + +### Bug Fixes + +* Fixed a bunch of issues on linux ([6aacec2](https://github.com/simeonradivoev/gameflow-deck/commit/6aacec2c0de253a71599e261e07aff53055cdb1e)) +* Fixed emulator details buttons not showing ([04d5856](https://github.com/simeonradivoev/gameflow-deck/commit/04d5856f7d71c944c82877d2a1457facea4b6d31)) +* Fixed tests ([c09fbd3](https://github.com/simeonradivoev/gameflow-deck/commit/c09fbd3dc88891227eda2b9f3bd9ac45621c00ea)) +* logins now refresh on plugins load ([7bd0ebd](https://github.com/simeonradivoev/gameflow-deck/commit/7bd0ebdcca1843076911547ec1098cbaae9e2414)) +* Made self update work on windows ([ae196e1](https://github.com/simeonradivoev/gameflow-deck/commit/ae196e11d616b9813dba11f64e7c844077686db8)) +* Made store downloads extract in their own folder ([764691f](https://github.com/simeonradivoev/gameflow-deck/commit/764691fc8610fafebc93a69ca24f74bcac42a898)) + ## [1.3.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.2.1...v1.3.0) (2026-03-31) diff --git a/package.json b/package.json index 57669af..4925d6c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "com.simeonradivoev.gameflow-deck", "displayName": "Gameflow", - "version": "1.3.0", + "version": "1.4.0", "description": "Game Launcher", "icon": "./src/mainview/assets/icon.svg", "main": "./src/bun/index.ts", @@ -143,4 +143,4 @@ "vite-static-assets-plugin": "^1.2.2", "vite-tsconfig-paths": "^6.1.1" } -} \ No newline at end of file +} From c23521bf94960794bcd6f72166542a00dd9e9323 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 26 Apr 2026 16:25:12 +0300 Subject: [PATCH 49/65] docs: Updated screenshots and readme --- .github/screenshots/3nhuKCK6E3.jpg | 3 --- .github/screenshots/3nhuKCK6E3.png | 3 +++ .github/screenshots/6wz3gW8c2h.png | 3 +++ .github/screenshots/EWPHmIBEE5.png | 4 ++-- .github/screenshots/GL7SkQbHIY.png | 4 ++-- .github/screenshots/MMeJxl4IXr.png | 3 +++ .github/screenshots/Pkazk0RufB.png | 4 ++-- .github/screenshots/rBY2mgTLy0.png | 3 +++ .github/screenshots/xNj7scPEDQ.png | 4 ++-- .github/screenshots/yObFD2LySH.jpg | 4 ++-- .github/screenshots/zEQxtzhPGx.png | 3 +++ README.md | 30 +++++++++++++++++++----------- 12 files changed, 44 insertions(+), 24 deletions(-) delete mode 100644 .github/screenshots/3nhuKCK6E3.jpg create mode 100644 .github/screenshots/3nhuKCK6E3.png create mode 100644 .github/screenshots/6wz3gW8c2h.png create mode 100644 .github/screenshots/MMeJxl4IXr.png create mode 100644 .github/screenshots/rBY2mgTLy0.png create mode 100644 .github/screenshots/zEQxtzhPGx.png diff --git a/.github/screenshots/3nhuKCK6E3.jpg b/.github/screenshots/3nhuKCK6E3.jpg deleted file mode 100644 index 7f70d9c..0000000 --- a/.github/screenshots/3nhuKCK6E3.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a33280e455034d34c0b2dfa111cff8fe179f97c2a39f7cd0c99b71b1957eda4f -size 1070602 diff --git a/.github/screenshots/3nhuKCK6E3.png b/.github/screenshots/3nhuKCK6E3.png new file mode 100644 index 0000000..5feae4b --- /dev/null +++ b/.github/screenshots/3nhuKCK6E3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5be6551346cd1067ab4bbb172828a801c4e26f1c9cac9ce35e1e712356df05bb +size 1970162 diff --git a/.github/screenshots/6wz3gW8c2h.png b/.github/screenshots/6wz3gW8c2h.png new file mode 100644 index 0000000..6ebc8de --- /dev/null +++ b/.github/screenshots/6wz3gW8c2h.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06131801cba199fa267b532a2f26b841d2b856cdd1bb9428e08879981e7b36c8 +size 1932256 diff --git a/.github/screenshots/EWPHmIBEE5.png b/.github/screenshots/EWPHmIBEE5.png index 6fd9f40..90e6416 100644 --- a/.github/screenshots/EWPHmIBEE5.png +++ b/.github/screenshots/EWPHmIBEE5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a03b0cd56b78f51f8bc4c304b8b14fa611748b9efa192ef26283e732beff90c1 -size 643600 +oid sha256:0eeee2b3d31fbb4ea49bb38a2634088fa0a54d6ce5d41f7bf39f419d518b802e +size 850435 diff --git a/.github/screenshots/GL7SkQbHIY.png b/.github/screenshots/GL7SkQbHIY.png index 2cdbe12..28544a3 100644 --- a/.github/screenshots/GL7SkQbHIY.png +++ b/.github/screenshots/GL7SkQbHIY.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bf2d692f8ccf3a1c5f9addf26052a34a74c332474fbd8c5bbc7923208407a748 -size 86214 +oid sha256:a22580330264a0ad2d4a6f758ad26c18ed9a0a17cbe1254dbbad01e959b205f8 +size 110988 diff --git a/.github/screenshots/MMeJxl4IXr.png b/.github/screenshots/MMeJxl4IXr.png new file mode 100644 index 0000000..f8cd130 --- /dev/null +++ b/.github/screenshots/MMeJxl4IXr.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ba59fccc691e8f43d4eff532d88edfab411d9abb68d0566fad1c167b7b17bdd +size 183846 diff --git a/.github/screenshots/Pkazk0RufB.png b/.github/screenshots/Pkazk0RufB.png index ceced8a..3025b0d 100644 --- a/.github/screenshots/Pkazk0RufB.png +++ b/.github/screenshots/Pkazk0RufB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2dd9859c9495af93872534a913a78597d235f8fb723fe685aa1aeab9283e028b -size 1986843 +oid sha256:9db331ad2d2cf2fb2525560baf5970f8faa6c8ff6ae885d9eedd65560af8fffd +size 2035855 diff --git a/.github/screenshots/rBY2mgTLy0.png b/.github/screenshots/rBY2mgTLy0.png new file mode 100644 index 0000000..653ddab --- /dev/null +++ b/.github/screenshots/rBY2mgTLy0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eca786ab7f525bcfe663f82995fc9e91234ddd5df726528b0926eaf32ed8ad8e +size 110966 diff --git a/.github/screenshots/xNj7scPEDQ.png b/.github/screenshots/xNj7scPEDQ.png index d50d6aa..4813a47 100644 --- a/.github/screenshots/xNj7scPEDQ.png +++ b/.github/screenshots/xNj7scPEDQ.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a234b8d4624ccfd677c698c1e33eb7c0b757dc13f1403fd8bc6d37ed9e6ff02 -size 1673960 +oid sha256:c26e4b9c7f690c49f9625ea2b8c2a82f03a24a0236c53dc02c158f7222c2519d +size 1805877 diff --git a/.github/screenshots/yObFD2LySH.jpg b/.github/screenshots/yObFD2LySH.jpg index 00d761f..f540a83 100644 --- a/.github/screenshots/yObFD2LySH.jpg +++ b/.github/screenshots/yObFD2LySH.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:46e473f90661400fec49d87a972a3324cd4fb18f5b8c670aa5b606462f98fbfe -size 1194459 +oid sha256:2f813f98ae41c6d383dcc5e9d6ea693b6701dddc5a03f73cbd1ed990b8532710 +size 1274712 diff --git a/.github/screenshots/zEQxtzhPGx.png b/.github/screenshots/zEQxtzhPGx.png new file mode 100644 index 0000000..1b18477 --- /dev/null +++ b/.github/screenshots/zEQxtzhPGx.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e3bf4df252866dd15f3c1c6efcfc648fbd9d320e47cf2d64551137497d229d6 +size 1324186 diff --git a/README.md b/README.md index 26befdf..c08de2d 100644 --- a/README.md +++ b/README.md @@ -13,37 +13,41 @@ Focused on building a simple user experience and intuitive UI as a curated commu - **[ROMM](https://github.com/rommapp/romm)** - download, sync and update roms and platforms. - **[Emulator JS](https://github.com/EmulatorJS/EmulatorJS)** - play your games with emulator js right within the app. Uses RetroArch cores. -- **[RClone](https://github.com/rclone/rclone)** - sync saves between devices or cloud. +- **[RClone](https://github.com/rclone/rclone)** - sync saves between devices or cloud. Some Emulators and store games support it. - **[UMU](https://github.com/Open-Wine-Components/umu-launcher)** - UMU Launcher for playing windows games on linux without needing steam. (Only used for store games for now) ### Store -- **Emulators** - (WIP) Download and install emulators and automatically configure them +- **Emulators** - (WIP) Download and install emulators and automatically configure them from a list of supported in the store. Some even come with advanced features like cloud saves. - **Free Curated Games** - Download free curreted games and homebrew roms without ever leaving the app ### Others - **Cross Platform** - Can run on multiple platforms. Built with web technologies and bun backend. - **Steam Deck Support** - Extensively tested with the steam deck. It can use flatpak installed browsers. -- **Lightweight** - It uses the existing system browser to launch the front end, so no need to include a whole web browser. +- **Lightweight** - It uses the window's webview as a frontend, reducing build size and ram usage. - On Windows it first uses webview2 then your browser - - On linux it uses WebKitGTK or a browser even from flatpak + - On linux it does ship with NW.js to work on most distros. A big one is the steam deck missing WebKitGTK. - Not tested on Mac yet - **Great for Controllers** - The UI is inspired by the switch and works great with joysticks and dpads. - **Automatic Downloads** - Downloads roms from ROMM automatically -- **Automatic Emulator Discovery** - Using the configs of the excellent ES-DE to discover installed emulators and launch games. +- **Automatic Emulator Discovery** - Using the configs of the excellent ES-DE to discover installed emulators and launch roms. You can bring your existing configurations. - Easy fallback configuration with built in file browser. - **Responsive Layout** - Optimized mainly for the steam deck with responsive layout support and dynamic switching of inputs. - **Cloud/Device Save Sync** - For supported games and emulators. +- **Dark and Light** - Dark and light themes for your preference. ## Screenshots - - - - - - + + + + + + + + + ## Goals @@ -51,6 +55,7 @@ Focused on building a simple user experience and intuitive UI as a curated commu - I plan to add a free store where you can download all your needed emulators, the goal is to not have to leave the UI for anything. - I really want to add matrix chat support in the app for engaging with your favorite community. Having access to so many nodejs libraries would make it quite straight forward. - I'm sick of closed source and private store fronts, and want a way to share community currated free experiences. I'm also sick of the profit driven nature of games and promotions. +- Being self contained, I want to avoid writing as little as possible to system and contain and manage settings in a custom changeable directory. This was mainly a side-effect of having the low storage steam deck and always running out of space on my internal hard drive. ## Development @@ -81,6 +86,9 @@ Focused on building a simple user experience and intuitive UI as a curated commu - `bun run openapi-ts` generated the openapi client calls from romm's API - `bun run package:windows` builds an package to be distributed on windows - `bun run package:linux` builds an AppImage to be distributed on linux + - `bun run test` run tests + - `bun run download:chromium` downloads degoogled chromium to use as the frontend + - `bun run download:nwjs` downloads NW.js to use as a frontend. ### Tech Stack From 79b627ed3107f09d63f77d6ece030b610ea75909 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Mon, 27 Apr 2026 01:20:54 +0300 Subject: [PATCH 50/65] docs: Updated readme and added gif screenshot --- .../iunZbvYEGp-ezgif.com-optimize.gif | 3 +++ README.md | 18 +++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 .github/screenshots/iunZbvYEGp-ezgif.com-optimize.gif diff --git a/.github/screenshots/iunZbvYEGp-ezgif.com-optimize.gif b/.github/screenshots/iunZbvYEGp-ezgif.com-optimize.gif new file mode 100644 index 0000000..bd842a9 --- /dev/null +++ b/.github/screenshots/iunZbvYEGp-ezgif.com-optimize.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22e018fb97f05c24294fd3b8fe088ca755760b81d3b46dd9f04b5f52f98f34da +size 2693240 diff --git a/README.md b/README.md index c08de2d..a21e662 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A Cross-Platform open source Retro gaming frontend designed for handheld and con Focused on building a simple user experience and intuitive UI as a curated community driven experience. > [!WARNING] -> This app is actively in development, it is contantly chaning 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. ## Features @@ -19,7 +19,7 @@ Focused on building a simple user experience and intuitive UI as a curated commu ### Store - **Emulators** - (WIP) Download and install emulators and automatically configure them from a list of supported in the store. Some even come with advanced features like cloud saves. -- **Free Curated Games** - Download free curreted games and homebrew roms without ever leaving the app +- **Free Curated Games** - Download free curated games and homebrew roms without ever leaving the app ### Others @@ -48,15 +48,27 @@ Focused on building a simple user experience and intuitive UI as a curated commu + ## Goals - I want to build an open and free platform where you can play and discover new hidden gems from the past. - I plan to add a free store where you can download all your needed emulators, the goal is to not have to leave the UI for anything. - I really want to add matrix chat support in the app for engaging with your favorite community. Having access to so many nodejs libraries would make it quite straight forward. -- I'm sick of closed source and private store fronts, and want a way to share community currated free experiences. I'm also sick of the profit driven nature of games and promotions. +- I'm sick of closed source and private store fronts, and want a way to share community curated free experiences. I'm also sick of the profit driven nature of games and promotions. - Being self contained, I want to avoid writing as little as possible to system and contain and manage settings in a custom changeable directory. This was mainly a side-effect of having the low storage steam deck and always running out of space on my internal hard drive. +## Usage + +There are currently 2 ways of getting games. One is logging in through romm and importing your games from there. The other is the store (it's a bit limited right now). I might add local import of roms since IGDB login is already implemented. + +The app created a default folder in your home folder. You can move it. It stores everything there. From downloaded roms, emulators and configs. + +## Existing Setups + +The game should work pretty well with existing emulators one has installed. It uses the ES-DE config to find installed emulators. Only downside is more advanced integrations won't work, as they are mainly used for store emulators where the app has more control over, plus I don't want to mess up existing setups. +But given it's an existing setup, say from emudeck it won't matter much as it's already configured say for the steam deck. + ## Development 1. Install dependencies: From e54a6ac8f042d760b2b57ee254f76502354eaea9 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Mon, 27 Apr 2026 20:08:10 +0300 Subject: [PATCH 51/65] docs: Added some more promo images --- .github/screenshots/3d screenshot.png | 3 +++ .github/screenshots/mockup-1777308293568.png | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 .github/screenshots/3d screenshot.png create mode 100644 .github/screenshots/mockup-1777308293568.png diff --git a/.github/screenshots/3d screenshot.png b/.github/screenshots/3d screenshot.png new file mode 100644 index 0000000..6510ea1 --- /dev/null +++ b/.github/screenshots/3d screenshot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b246781b29fd3ef53ace2639229cb2149b791bf82977cb481f3b4f343f8776d +size 246650 diff --git a/.github/screenshots/mockup-1777308293568.png b/.github/screenshots/mockup-1777308293568.png new file mode 100644 index 0000000..93558db --- /dev/null +++ b/.github/screenshots/mockup-1777308293568.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e478586834f41ec41e85ab76bec7ca244c2521446a40442ba3de0ea97888fa17 +size 2007401 From 06b7e4074da23afdec3b2ff97f84a9e1486944d2 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Mon, 4 May 2026 14:59:43 +0300 Subject: [PATCH 52/65] feat: Implemented local game import (with a wizard) feat: Implemented a radial virtual gamepad keyboard. fix: Fixed shortcuts for file explorer --- README.md | 4 +- bun.lock | 10 +- package.json | 2 +- src/bun/api/games/games.ts | 34 +- src/bun/api/games/platforms.ts | 49 +- src/bun/api/games/services/statusService.ts | 66 ++- src/bun/api/games/services/utils.ts | 305 ++++++++++- src/bun/api/hooks/games.ts | 9 +- src/bun/api/jobs/import-job.ts | 96 ++++ src/bun/api/jobs/install-job.ts | 184 ++----- .../com.simeonradivoev.gameflow.es/es-de.ts | 4 +- .../package.json | 2 +- .../com.simeonradivoev.gameflow.igdb/igdb.ts | 48 +- .../package.json | 2 +- .../com.simeonradivoev.gameflow.romm/romm.ts | 2 + .../store.ts | 5 +- src/bun/api/schema/app.ts | 10 +- src/bun/api/settings/services.ts | 20 +- src/bun/api/system.ts | 8 +- src/bun/api/task-queue.ts | 39 +- src/mainview/assets/sounds.json | 38 +- src/mainview/assets/sounds.ogg | 4 +- src/mainview/components/AppCommunication.tsx | 2 + src/mainview/components/CollectionList.tsx | 5 +- src/mainview/components/CollectionsDetail.tsx | 15 +- src/mainview/components/ContextDialog.tsx | 4 +- src/mainview/components/FilePicker.tsx | 7 +- src/mainview/components/GamepadKeyboard.tsx | 509 ++++++++++++++++++ src/mainview/components/HeaderSearchField.tsx | 14 +- src/mainview/components/SelectMenu.tsx | 5 +- src/mainview/components/ShortcutPrompt.tsx | 7 +- src/mainview/components/Shortcuts.tsx | 48 +- src/mainview/components/SvgIcon.tsx | 5 +- .../components/game/ActionButtons.tsx | 33 +- src/mainview/components/game/GameLookup.tsx | 80 +++ src/mainview/components/game/MainActions.tsx | 84 ++- .../options/DownloadDirectoryOption.tsx | 9 +- .../components/options/LocalOption.tsx | 30 +- .../components/options/OptionInput.tsx | 3 - .../components/options/OptionSpace.tsx | 3 +- .../components/options/PathSettingsOption.tsx | 22 +- .../components/options/SettingsAppForm.tsx | 9 +- .../components/options/SettingsOption.tsx | 6 +- .../store/MissingEmulatorsSection.tsx | 17 +- src/mainview/gen/routeTree.gen.ts | 42 ++ src/mainview/routes/game/$source.$id.tsx | 3 + src/mainview/routes/game/add.tsx | 396 ++++++++++++++ .../routes/game/update.$source.$id.tsx | 61 +++ src/mainview/routes/games.tsx | 16 +- src/mainview/routes/platform.$source.$id.tsx | 14 +- src/mainview/routes/settings/emulators.tsx | 23 +- src/mainview/routes/settings/interface.tsx | 15 +- src/mainview/routes/store/tab/index.tsx | 2 +- src/mainview/routes/store/tab/route.tsx | 8 +- src/mainview/scripts/audio/audio.ts | 12 +- src/mainview/scripts/audio/audioConstants.ts | 17 +- src/mainview/scripts/brandIcons.tsx | 4 + src/mainview/scripts/gamepads.ts | 5 + src/mainview/scripts/queries/romm.ts | 73 ++- src/mainview/scripts/types.ts | 1 + src/mainview/scripts/utils.ts | 2 +- src/shared/constants.ts | 31 +- src/shared/types..d.ts | 40 ++ src/sounds/UI_Single_Set 5_01.wav | 3 + src/sounds/UI_Single_Set 5_03.wav | 3 + src/sounds/UI_Single_Set 5_04.wav | 3 + 66 files changed, 2216 insertions(+), 416 deletions(-) create mode 100644 src/bun/api/jobs/import-job.ts create mode 100644 src/mainview/components/GamepadKeyboard.tsx create mode 100644 src/mainview/components/game/GameLookup.tsx create mode 100644 src/mainview/routes/game/add.tsx create mode 100644 src/mainview/routes/game/update.$source.$id.tsx create mode 100644 src/sounds/UI_Single_Set 5_01.wav create mode 100644 src/sounds/UI_Single_Set 5_03.wav create mode 100644 src/sounds/UI_Single_Set 5_04.wav diff --git a/README.md b/README.md index a21e662..ceed36a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Focused on building a simple user experience and intuitive UI as a curated commu ### Integrations - **[ROMM](https://github.com/rommapp/romm)** - download, sync and update roms and platforms. + - Show Achievements and sync playtime. + - Experimental save syncing - **[Emulator JS](https://github.com/EmulatorJS/EmulatorJS)** - play your games with emulator js right within the app. Uses RetroArch cores. - **[RClone](https://github.com/rclone/rclone)** - sync saves between devices or cloud. Some Emulators and store games support it. - **[UMU](https://github.com/Open-Wine-Components/umu-launcher)** - UMU Launcher for playing windows games on linux without needing steam. (Only used for store games for now) @@ -39,7 +41,7 @@ Focused on building a simple user experience and intuitive UI as a curated commu ## Screenshots - + diff --git a/bun.lock b/bun.lock index 884980a..217584d 100644 --- a/bun.lock +++ b/bun.lock @@ -43,7 +43,7 @@ "@ap0nia/eden-tanstack-query": "^1.0.0-next.22", "@emulatorjs/emulatorjs": "^4.2.3", "@hey-api/openapi-ts": "^0.91.0", - "@noriginmedia/norigin-spatial-navigation": "^2.3.0", + "@noriginmedia/norigin-spatial-navigation": "^3.1.0", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-form": "^1.28.0", @@ -429,7 +429,11 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@noriginmedia/norigin-spatial-navigation": ["@noriginmedia/norigin-spatial-navigation@2.3.0", "", { "dependencies": { "lodash": "^4.17.21" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-gR//N45NnKz1h0/AVknkfg7QnNATETdgXUUD3EKPxuQPyhk7NhsphODzRamyvjYaxsU6VbY/szcUlzBWWBkNMw=="], + "@noriginmedia/norigin-spatial-navigation": ["@noriginmedia/norigin-spatial-navigation@3.1.0", "", { "dependencies": { "@noriginmedia/norigin-spatial-navigation-core": "^3.1.0", "@noriginmedia/norigin-spatial-navigation-react": "^3.1.0" } }, "sha512-KPge4ocpDFde7cpZ2aqrPrKmxOxkue983NsfpmE/vX4k2l+Ik8UkucCWGqkcy81TXkEyRhdsYwFTRePNB5qUCg=="], + + "@noriginmedia/norigin-spatial-navigation-core": ["@noriginmedia/norigin-spatial-navigation-core@3.1.0", "", { "dependencies": { "lodash-es": "^4.17.21" } }, "sha512-AFxJHurTqy+I3NLnaXsLUBa9FZjUryMNFEdLpPrITSqDjk525aINeLMOK1PN7WTiK5xpHL0pbpw0+uVOfWgp4w=="], + + "@noriginmedia/norigin-spatial-navigation-react": ["@noriginmedia/norigin-spatial-navigation-react@3.1.0", "", { "dependencies": { "@noriginmedia/norigin-spatial-navigation-core": "^3.1.0", "lodash-es": "^4.17.21" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-F2PIqzTnlYbbc+oRdIQfBf7e1VcA1uhyjze4uOal8FHI8tZs1U8nomH84+2KcM6G3EM/XGexgQsPy5f5dtrmUA=="], "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], @@ -1259,6 +1263,8 @@ "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], "lodash.defaultsdeep": ["lodash.defaultsdeep@4.6.1", "", {}, "sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA=="], diff --git a/package.json b/package.json index 4925d6c..8abf0dd 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "@ap0nia/eden-tanstack-query": "^1.0.0-next.22", "@emulatorjs/emulatorjs": "^4.2.3", "@hey-api/openapi-ts": "^0.91.0", - "@noriginmedia/norigin-spatial-navigation": "^2.3.0", + "@noriginmedia/norigin-spatial-navigation": "^3.1.0", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-form": "^1.28.0", diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index bf64aaf..604932c 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -8,7 +8,7 @@ import { GameListFilterSchema, SERVER_URL } from "@shared/constants"; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; -import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService"; +import buildStatusResponse, { customUpdate, fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService"; import { errorToResponse } from "elysia/adapter/bun/handler"; import { launchCommand } from "./services/launchGameService"; import { getErrorMessage, SeededRandom } from "@/bun/utils"; @@ -21,6 +21,7 @@ import { host } from "@/bun/utils/host"; import { LaunchGameJob } from "../jobs/launch-game-job"; import { cores } from "../emulatorjs/emulatorjs"; import { findEmulatorPluginIntegration } from "../store/services/emulatorsService"; +import { ImportJob } from "../jobs/import-job"; // A custom jimp that supports webp const Jimp = createJimp({ @@ -491,6 +492,24 @@ export default new Elysia() { return update(source, id); }) + .post('/game/:source/:id/update', async ({ params: { id, source }, body }) => + { + return customUpdate(source, id, body.source, body.id); + }, { body: z.object({ source: z.string(), id: z.string() }) }) + .get('/lookup', async ({ query: { search } }) => + { + const matches: GameLookup[] = []; + await plugins.hooks.games.gameLookup.promise({ search, matches }); + return matches; + }, { + query: z.object({ search: z.string() }) + }) + .get('/lookup/:source/:id', async ({ params: { source, id } }) => + { + const matches: GameLookup[] = []; + await plugins.hooks.games.gameLookup.promise({ source, id, matches }); + return matches; + }) .post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) => { const validCommands = await getValidLaunchCommandsForGame(source, id); @@ -651,4 +670,17 @@ export default new Elysia() rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank); return rankedGames.map(g => g.game).slice(0, 10); + }) + .post('/add/custom', async ({ body: { source, id, platformId, gamePath } }) => + { + if (taskQueue.hasActiveOfType(ImportJob)) return status("Conflict", "Import Job Already Running"); + const data = await taskQueue.enqueue(ImportJob.id, new ImportJob(source, id, gamePath, platformId), true); + return { source: 'local', id: data.localId }; + }, { + body: z.object({ + source: z.string(), + id: z.string(), + gamePath: z.string(), + platformId: z.number() + }) }); \ No newline at end of file diff --git a/src/bun/api/games/platforms.ts b/src/bun/api/games/platforms.ts index 22161ae..d1509a0 100644 --- a/src/bun/api/games/platforms.ts +++ b/src/bun/api/games/platforms.ts @@ -1,8 +1,9 @@ import Elysia, { status } from "elysia"; import z from "zod"; -import { and, count, eq, getTableColumns, not, notExists } from "drizzle-orm"; -import { db, plugins } from "../app"; +import { and, count, eq, getTableColumns, not, notExists, or } from "drizzle-orm"; +import { config, db, plugins } from "../app"; import * as schema from "@schema/app"; +import { findPlatform } from "./services/utils"; export default new Elysia() .get('/platforms', async () => @@ -91,7 +92,8 @@ export default new Elysia() { const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id }); if (!remotePlatform) return status("Not Found"); - return remotePlatform; + const local = await db.query.platforms.findFirst({ where: or(eq(schema.platforms.slug, remotePlatform?.slug), eq(schema.platforms.name, remotePlatform?.name)) }); + return { ...remotePlatform, hasLocal: !!local }; } }, { params: z.object({ source: z.string(), id: z.string() }) }) .get('/platform/local/:id/cover', async ({ params: { id }, set }) => @@ -114,15 +116,31 @@ export default new Elysia() } return status(200, coverBlob.cover); }, { response: { 200: z.instanceof(Buffer), 404: z.any() }, params: z.object({ id: z.coerce.number() }) }) - .post('/platform/local/:id/update', async ({ params: { id } }) => + .post('/platform/:source/:id/update', async ({ params: { source, id } }) => { - const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, Number(id)) }); + const where: any[] = []; + if (source === 'local') + { + where.push(eq(schema.platforms.id, Number(id))); + } else + { + const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id }); + if (remotePlatform) + { + where.push(eq(schema.platforms.slug, remotePlatform.slug)); + } + } + + const localPlatform = await db.query.platforms.findFirst({ + where: or(...where) + + }); if (!localPlatform) return status("Not Found"); const platformLookup = await plugins.hooks.games.platformLookup.promise({ slug: localPlatform.slug }); - let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${localPlatform.slug}.svg`); + let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${localPlatform.slug}.svg`); if (!platformCover.ok && platformLookup?.url_logo) { platformCover = await fetch(platformLookup.url_logo); @@ -144,4 +162,23 @@ export default new Elysia() .where(eq(schema.games.platform_id, Number(id))) ))).returning(); if (deleted.length <= 0) return status("Not Found"); + }) + .get('/platform/lookup/match/:source/:id', async ({ params: { source, id } }) => + { + const platformLookup = await plugins.hooks.games.platformLookup.promise({ source, id }); + if (!platformLookup) return status("Not Found"); + const match = await findPlatform({ + system_slug: platformLookup.slug, + platform: { + source_slug: platformLookup.slug, + source_id: Number(id), + source: source, + name: platformLookup.name + } + }); + return { details: platformLookup, match }; + }, { + detail: { + description: "Find matches of remote platform lookups. Returns the operations for each platform if it were to be imported. If platform locally exists. Will a new local platform be created from say romm. Unknown is returned if no match is found." + } }); \ No newline at end of file diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 3c46f23..b3a1691 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -4,7 +4,7 @@ import { getErrorMessage } from "@/bun/utils"; import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils"; import fs from 'node:fs/promises'; import Elysia from "elysia"; -import z, { string } from "zod"; +import z from "zod"; import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { LaunchGameJob } from "../../jobs/launch-game-job"; import * as appSchema from "@schema/app"; @@ -41,6 +41,63 @@ export async function getLocalGame (source: string, id: string) return localGame; } +/** Update local game's metadata from custom source, not the actual source of the game. Say from metadata providers like IGDB */ +export async function customUpdate (source: string, id: string, destination: string, destinationId: string) +{ + const localGame = await getLocalGame(source, id); + if (!localGame) throw new Error("Could not find Local Game"); + + const matches: GameLookup[] = []; + await plugins.hooks.games.gameLookup.promise({ source: destination, id: destinationId, matches }); + if (matches.length <= 0) throw new Error("Could not find destination"); + const match = matches[0]; + + await db.transaction(async (tx) => + { + await tx.delete(appSchema.screenshots).where(eq(appSchema.screenshots.game_id, localGame.id)); + + // pre-fetch screenshots + const screenshots = await Promise.all(match.screenshotUrls.map(s => fetch(s))); + + if (screenshots.length > 0) + { + await tx.insert(appSchema.screenshots).values(await Promise.all(screenshots.map(async (response) => + { + const screenshot: typeof appSchema.screenshots.$inferInsert = { + game_id: localGame.id, + content: Buffer.from(await response.arrayBuffer()), + type: response.headers.get('content-type') + }; + + return screenshot; + }))); + } + + let cover: Buffer | undefined = undefined; + if (match.coverUrl) + { + const coverResponse = await fetch(match.coverUrl); + if (coverResponse.ok) + { + cover = Buffer.from(await coverResponse.arrayBuffer()); + } + } + + await tx.update(appSchema.games).set({ + cover, + metadata: { + age_ratings: match.age_ratings, + genres: match.genres, + player_count: match.player_count ?? undefined, + companies: match.companies, + game_modes: match.game_modes, + average_rating: match.average_rating ?? undefined, + first_release_date: match.first_release_date, + } + }).where(eq(appSchema.games.id, localGame.id)); + }); +} + export async function update (source: string, id: string) { const localGame = await getLocalGame(source, id); @@ -56,10 +113,11 @@ export async function update (source: string, id: string) const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)]; if (paths_screenshots.length <= 0 && sourceGame.igdb_id) { - const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id) }); - if (igdbLookup) + const matches: GameLookup[] = []; + await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id), matches }); + if (matches.length > 0) { - paths_screenshots.push(...igdbLookup.screenshotUrls); + paths_screenshots.push(...matches[0].screenshotUrls); } } diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index b1b6fc2..833fbdd 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -2,23 +2,25 @@ import getFolderSize from "get-folder-size"; import fs from "node:fs/promises"; import path from "node:path"; import { config, db, emulatorsDb, plugins } from "../../app"; -import { and, eq } from "drizzle-orm"; +import { and, eq, or } from "drizzle-orm"; import * as schema from "@schema/app"; -import { RPC_URL, StoreGameType } from "@shared/constants"; +import { RPC_URL } from "@shared/constants"; import { hashFile } from "@/bun/utils"; import { host } from "@/bun/utils/host"; -import secrets from "../../secrets"; +import * as emulatorSchema from "@schema/emulators"; export async function calculateSize (installPath: string | null) { if (!installPath) return null; - return (await getFolderSize(path.join(config.get('downloadPath'), installPath))).size; + const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath); + return (await getFolderSize(finalPath)).size; } export async function checkInstalled (installPath: string | null) { if (!installPath) return false; - return fs.exists(path.join(config.get('downloadPath'), installPath)); + const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath); + return fs.exists(finalPath); } export function getScreenshotLocalGameMatch (id: string, source: string) @@ -171,4 +173,297 @@ export async function checkFiles (files: DownloadFileEntry[], isArchive: boolean } return { ...f, exists: false, matches: false } satisfies LocalDownloadFileEntry; })); +} + +export async function findPlatform (info: { + system_slug: string; platform: { + igdb_id?: number; + igdb_slug?: string; + ra_id?: number; + moby_id?: number; + source: string; + source_id?: number; + source_slug?: string; + family_name?: string; + name?: string; + } | undefined; +}): + Promise<{ + type: string | null; + slug?: string | null; + name?: string | null; + family_name?: string | null; + es_slug?: string | null; + coverUrl?: string | null; + }> +{ + // Search for existing platform + const platformSearch = [eq(schema.platforms.slug, info.system_slug)]; + const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, info.system_slug)]; + + if (info.platform) + { + if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id)); + if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug)); + if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id)); + if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id)); + + esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, info.platform.source)); + if (info.platform.source_slug) + { + esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.source_slug)); + } else if (info.platform.source_id) + { + esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceId, info.platform.source_id)); + } else + { + throw new Error("Must Provide at least one source id or slug"); + } + } + + const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ + with: { system: true }, + where: and(...esPlatformSearch) + }); + + if (esPlatform) + platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name)); + + let existingPlatform = await db.query.platforms.findFirst({ where: or(...platformSearch) }); + + if (!existingPlatform) + { + // TODO: use something else than the romm demo as CDN + + const platformLookup = await plugins.hooks.games.platformLookup.promise({ + slug: info.platform?.source_slug ?? info.system_slug + }); + let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${info.platform?.source_slug ?? info.system_slug}.svg`, { method: "HEAD" }); + if (!platformCover.ok && platformLookup?.url_logo) + { + platformCover = await fetch(platformLookup.url_logo, { method: "HEAD" }); + } + + if (!esPlatform && !info.platform) + { + // go to unknown platform + existingPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") }); + + if (existingPlatform) + { + return { + type: "existing", + slug: existingPlatform.slug, + name: existingPlatform.name, + family_name: existingPlatform.family_name, + es_slug: existingPlatform.es_slug, + coverUrl: `${RPC_URL(host)}/api/romm/platform/local/${existingPlatform.id}/cover` + }; + } else + { + return { type: "unknown" }; + } + } else + { + return { + type: "new", + slug: info.platform?.source_slug ?? esPlatform?.system.name ?? '', + name: info.platform?.name ?? esPlatform?.system.fullname ?? '', + family_name: info.platform?.family_name, + es_slug: esPlatform?.system.name ?? undefined, + coverUrl: platformCover.url + }; + } + + } else + { + return { + type: "existing", + slug: existingPlatform.slug, + name: existingPlatform.name, + family_name: existingPlatform.family_name, + es_slug: existingPlatform.es_slug, + coverUrl: `${RPC_URL(host)}/api/romm/platform/local/${existingPlatform.id}/cover` + }; + } +} + +export async function createLocalGame (info: { + name: string; + system_slug: string | undefined; + source: string | undefined; + source_id: string | undefined; + slug: string | null | undefined; + path_fs: string | null | undefined; + summary: string | null | undefined; + igdb_id: number | undefined; + ra_id: number | undefined; + main_glob: string | undefined; + cover: Buffer | undefined; + coverType: string | null | undefined; + version: string | undefined; + version_source: string | undefined; + screenshotUrls: string[]; + version_system: string | undefined; + last_played?: Date; + metadata: LocalGameMetadata | undefined, + platform: { + igdb_id?: number; + igdb_slug?: string; + ra_id?: number; + moby_id?: number; + source: string; + source_id?: number; + source_slug?: string; + family_name?: string; + name?: string; + } | undefined; +}) +{ + const id = await db.transaction(async (tx) => + { + // Search for existing platform + const platformSearch = []; + const esPlatformSearch = []; + if (info.system_slug) + { + platformSearch.push(eq(schema.platforms.slug, info.system_slug)); + esPlatformSearch.push(eq(emulatorSchema.systemMappings.system, info.system_slug)); + } + + if (info.platform) + { + if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id)); + if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug)); + if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id)); + if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id)); + + esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, info.platform.source)); + if (info.platform.source_slug) + { + esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.source_slug)); + } else if (info.platform.source_id) + { + esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceId, info.platform.source_id)); + } else + { + throw new Error("Must Provide at least one source id or slug"); + } + } + + const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ + with: { system: true }, + where: and(...esPlatformSearch) + }); + + if (esPlatform) + platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name)); + + let existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) }); + let platformId: number; + if (!existingPlatform) + { + // TODO: use something else than the romm demo as CDN + + const platformLookup = await plugins.hooks.games.platformLookup.promise({ + slug: info.platform?.source_slug ?? info.system_slug + }); + let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${info.platform?.source_slug ?? info.system_slug}.svg`); + if (!platformCover.ok && platformLookup?.url_logo) + { + platformCover = await fetch(platformLookup.url_logo); + } + + if (!esPlatform && !info.platform) + { + // go to unknown platform + existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") }); + + if (existingPlatform) + { + platformId = existingPlatform.id; + } else + { + const [{ id }] = await tx.insert(schema.platforms).values({ + slug: 'unknown', + name: "Unknown" + }).returning({ id: schema.platforms.id }); + platformId = id; + } + } else + { + // Create new local platform + const platform: typeof schema.platforms.$inferInsert = { + slug: info.platform?.source_slug ?? esPlatform?.system.name ?? '', + igdb_id: info.platform?.igdb_id, + igdb_slug: info.platform?.igdb_slug, + ra_id: info.platform?.ra_id, + cover: Buffer.from(await platformCover.arrayBuffer()), + cover_type: platformCover.headers.get('content-type'), + name: info.platform?.name ?? esPlatform?.system.fullname ?? '', + family_name: info.platform?.family_name, + es_slug: esPlatform?.system.name ?? undefined, + }; + + // TODO: add ES slug once I have better way to query ES + const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id }); + platformId = id; + } + + } else + { + platformId = existingPlatform.id; + } + + // create the rom + const game: typeof schema.games.$inferInsert = { + source_id: info.source_id, + source: info.source, + slug: info.slug, + path_fs: info.path_fs, + last_played: info.last_played, + platform_id: platformId, + igdb_id: info.igdb_id, + ra_id: info.ra_id, + summary: info.summary, + name: info.name, + cover: info.cover, + cover_type: info.coverType, + metadata: info.metadata, + main_glob: info.main_glob, + version: info.version, + version_source: info.version_source, + version_system: info.version_system + }; + + const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id }); + + if (info.screenshotUrls.length <= 0 && info.igdb_id) + { + const matches: GameLookup[] = []; + await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(info.igdb_id), matches }); + info.screenshotUrls.push(...matches[0].screenshotUrls); + } + + // pre-fetch screenshots + const screenshots = await Promise.all(info.screenshotUrls.map(s => fetch(s))); + + if (screenshots.length > 0) + { + await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) => + { + const screenshot: typeof schema.screenshots.$inferInsert = { + game_id: id, + content: Buffer.from(await response.arrayBuffer()), + type: response.headers.get('content-type') + }; + + return screenshot; + }))); + } + + return id; + }); + + return id; } \ No newline at end of file diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index fb94e71..b08607c 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -1,5 +1,5 @@ import { EmulatorPackageType, GameListFilterType } from '@/shared/constants'; -import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable'; +import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook } from 'tapable'; export class GameHooks { @@ -95,7 +95,12 @@ export class GameHooks name?: string; family_name?: string; } | undefined>(['ctx']); - gameLookup = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], { screenshotUrls: string[]; } | undefined>(['ctx']); + gameLookup = new AsyncSeriesHook<[ctx: { + source?: string, + id?: string; + search?: string; + matches: GameLookup[]; + }]>(['ctx']); fetchPlatforms = new AsyncSeriesHook<[ctx: { platforms: FrontEndPlatformType[]; }]>(['ctx']); diff --git a/src/bun/api/jobs/import-job.ts b/src/bun/api/jobs/import-job.ts new file mode 100644 index 0000000..91e118b --- /dev/null +++ b/src/bun/api/jobs/import-job.ts @@ -0,0 +1,96 @@ +import { eq, or } from "drizzle-orm"; +import { db, plugins } from "../app"; +import { createLocalGame } from "../games/services/utils"; +import { IJob, JobContext } from "../task-queue"; +import * as schema from "@schema/app"; +import z from "zod"; + +export class ImportJob implements IJob, string> +{ + static id = "import-job" as const; + static dataSchema = z.object({ localId: z.number().nullable() }); + group?: 'import-job'; + gamePath: string; + source: string; + id: string; + platformId: number; + localId: number | null = null; + + constructor(source: string, id: string, gamePath: string, platformId: number) + { + this.gamePath = gamePath; + this.source = source; + this.id = id; + this.platformId = platformId; + } + + exposeData (): z.infer + { + return { localId: this.localId }; + } + + async start (context: JobContext, string>, z.infer, string>): Promise + { + const matches: GameLookup[] = []; + await plugins.hooks.games.gameLookup.promise({ source: this.source, id: this.id, matches }); + if (matches.length <= 0) throw Error("Could not Find Game"); + const match = matches[0]; + + let cover: Buffer | undefined = undefined; + let coverType: string | undefined = undefined; + if (match.coverUrl) + { + const coverResponse = await fetch(match.coverUrl); + if (coverResponse.ok) + { + cover = Buffer.from(await coverResponse.arrayBuffer()); + coverType = coverResponse.headers.get('content-type') ?? undefined; + } + } + + const localSearchFilters: any[] = []; + if (match.igdb_id) localSearchFilters.push(eq(schema.games.igdb_id, match.igdb_id)); + if (match.slug) localSearchFilters.push(eq(schema.games.slug, match.slug)); + localSearchFilters.push(eq(schema.games.name, match.name)); + localSearchFilters.push(eq(schema.games.path_fs, this.gamePath)); + const existingLocalGame = await db.query.games.findFirst({ where: or(...localSearchFilters) }); + + if (existingLocalGame) throw new Error("Game Already Exists"); + + const platformMatch = match.platforms.find(p => p.id === this.platformId); + + this.localId = await createLocalGame({ + name: match.name, + system_slug: platformMatch?.slug, + source: undefined, + source_id: undefined, + slug: match.slug, + path_fs: this.gamePath, + summary: match.summary, + igdb_id: match.igdb_id, + ra_id: undefined, + main_glob: undefined, + cover, + coverType, + version: undefined, + version_source: undefined, + screenshotUrls: match.screenshotUrls, + version_system: undefined, + platform: platformMatch ? { + source_slug: platformMatch.slug, + source_id: platformMatch.id, + source: this.source, + name: platformMatch.displayName + } : undefined, + metadata: { + game_modes: match.game_modes, + companies: match.companies, + first_release_date: match.first_release_date ?? undefined, + player_count: match.player_count, + age_ratings: match.age_ratings, + average_rating: match.average_rating, + genres: match.genres, + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 9a2b8b8..1b48394 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -1,17 +1,12 @@ import { IJob, JobContext } from "../task-queue"; -import { and, eq, or } from 'drizzle-orm'; import fs from 'node:fs/promises'; -import * as schema from "@schema/app"; -import * as emulatorSchema from "@schema/emulators"; -import path, { join } from 'node:path'; -import { config, db, emulatorsDb, events, plugins } from "../app"; -import * as igdb from 'ts-igdb-client'; -import secrets from "../secrets"; +import path from 'node:path'; +import { config, events, plugins } from "../app"; import { simulateProgress } from "@/bun/utils"; import { Downloader } from "@/bun/utils/downloader"; import Seven from 'node-7z'; import z from "zod"; -import { checkFiles } from "../games/services/utils"; +import { checkFiles, createLocalGame } from "../games/services/utils"; import { ensureDir, move } from "fs-extra"; import { path7za } from "7zip-bin"; import StreamZip from 'node-stream-zip'; @@ -37,6 +32,7 @@ export class InstallJob implements IJob // The local game ID of newly created entry, if successful public localGameId?: number; public group = InstallJob.id; + public localPath?: string; constructor(id: string, source: string, config?: JobConfig) { @@ -51,18 +47,19 @@ export class InstallJob implements IJob await fs.mkdir(config.get('downloadPath'), { recursive: true }); const downloadPath = config.get('downloadPath'); - let info: DownloadInfo | undefined; - - const allDownloads = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId }); - info = allDownloads?.[0]; - - if (!info) throw new Error(`Could not find downloader for source ${this.source}`); - - const files = await checkFiles(info.files, !!info.extract_path); const finalFiles: string[] = []; + let info: DownloadInfo | undefined; if (this.config?.dryRun !== true) { + const allDownloads = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId }); + info = allDownloads?.[0]; + + if (!info) throw new Error(`Could not find downloader for source ${this.source}`); + + const files = await checkFiles(info.files, !!info.extract_path); + + if (this.config?.dryDownload !== true && files.some(f => !f.exists || !f.matches)) { const headers: Record = {}; @@ -197,143 +194,32 @@ export class InstallJob implements IJob if (cx.abortSignal.aborted) return; - await db.transaction(async (tx) => - { - // Search for existing platform - const platformSearch = [eq(schema.platforms.slug, info.system_slug)]; - const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, info.system_slug)]; - - if (info.platform) - { - if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id)); - if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug)); - if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id)); - if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id)); - - esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, 'romm')); - esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.slug)); - } - - const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ - with: { system: true }, - where: and(...esPlatformSearch) - }); - - if (esPlatform) - platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name)); - - let existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) }); - let platformId: number; - if (!existingPlatform) - { - // TODO: use something else than the romm demo as CDN - - const platformLookup = await plugins.hooks.games.platformLookup.promise({ - slug: info.platform?.slug ?? info.system_slug - }); - let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${info.platform?.slug ?? info.system_slug}.svg`); - if (!platformCover.ok && platformLookup?.url_logo) - { - platformCover = await fetch(platformLookup.url_logo); - } - - if (!esPlatform && !info.platform) - { - // go to unknown platform - existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") }); - - if (existingPlatform) - { - platformId = existingPlatform.id; - } else - { - const [{ id }] = await tx.insert(schema.platforms).values({ - slug: 'unknown', - name: "Unknown" - }).returning({ id: schema.platforms.id }); - platformId = id; - } - } else - { - // Create new local platform - const platform: typeof schema.platforms.$inferInsert = { - slug: info.platform?.slug ?? esPlatform?.system.name ?? '', - igdb_id: info.platform?.igdb_id, - igdb_slug: info.platform?.igdb_slug, - ra_id: info.platform?.ra_id, - cover: Buffer.from(await platformCover.arrayBuffer()), - cover_type: platformCover.headers.get('content-type'), - name: info.platform?.name ?? esPlatform?.system.fullname ?? '', - family_name: info.platform?.family_name, - es_slug: esPlatform?.system.name ?? undefined, - }; - - // TODO: add ES slug once I have better way to query ES - const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id }); - platformId = id; - } - - } else - { - platformId = existingPlatform.id; - } - - // create the rom - const game: typeof schema.games.$inferInsert = { - source_id: info.source_id, - source: this.source, - slug: info.slug, - path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined), - last_played: info.last_played, - platform_id: platformId, - igdb_id: info.igdb_id, - ra_id: info.ra_id, - summary: info.summary, - name: info.name, - cover, - cover_type: coverResponse.headers.get('content-type'), - metadata: info.metadata, - main_glob: info.main_glob, - version: info.version, - version_source: info.version_source, - version_system: info.version_system - }; - - const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id }); - - if (info.screenshotUrls.length <= 0 && info.igdb_id) - { - const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(info.igdb_id) }); - if (igdbLookup) return igdbLookup.screenshotUrls; - return []; - } - - // pre-fetch screenshots - const screenshots = await Promise.all(info.screenshotUrls.map(s => fetch(s))); - - if (screenshots.length > 0) - { - await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) => - { - const screenshot: typeof schema.screenshots.$inferInsert = { - game_id: id, - content: Buffer.from(await response.arrayBuffer()), - type: response.headers.get('content-type') - }; - - return screenshot; - }))); - } - - this.localGameId = id; + this.localGameId = await createLocalGame({ + cover, + coverType: coverResponse.headers.get('content-type'), + system_slug: info.system_slug, + source_id: info.source_id, + source: this.source, + slug: info.slug, + path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined), + summary: info.summary, + igdb_id: info.igdb_id, + ra_id: info.ra_id, + name: info.name, + main_glob: info.main_glob, + version: info.version, + version_source: info.version_source, + screenshotUrls: info.screenshotUrls, + version_system: info.version_system, + metadata: info.metadata, + platform: info.platform }); + + if (this.source && this.gameId) await plugins.hooks.games.postInstall.promise({ source: this.source, id: this.gameId, files: finalFiles, info }); + events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 }); } else { await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal); } - - await plugins.hooks.games.postInstall.promise({ source: this.source, id: this.gameId, files: finalFiles, info }); - - events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts index 5f10e24..7108dcf 100644 --- a/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts +++ b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts @@ -288,7 +288,7 @@ export default class IgdbIntegration implements PluginType } const downloadPath = config.get('downloadPath'); - const gamePath = path.join(downloadPath, data.gamePath); + const gamePath = path.isAbsolute(data.gamePath) ? data.gamePath : path.join(downloadPath, data.gamePath); const validFiles: string[] = await this.getRomFilePaths(gamePath, { systemSlug: data.systemSlug, mainGlob: data.mainGlob }); @@ -449,7 +449,7 @@ export default class IgdbIntegration implements PluginType } const downloadPath = config.get('downloadPath'); - const path_fs = path.join(downloadPath, localGame.path_fs); + const path_fs = path.isAbsolute(localGame.path_fs) ? localGame.path_fs : path.join(downloadPath, localGame.path_fs); return this.getRomFilePaths(path_fs, { systemSlug: localGame.platform.es_slug ?? undefined, mainGlob: localGame.main_glob }); }); diff --git a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json index 20a8525..2c42339 100644 --- a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json +++ b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "description": "Rclone integration for syncing saves", "main": "./rclone.ts", - "icon": "https://forum.rclone.org/uploads/default/original/2X/8/8a14ccd453604987a64820f56c6afa75c229aa17.png", + "icon": "data:image/svg+xml,%3Csvg%20role%3D%22img%22%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3ERclone%3C%2Ftitle%3E%3Cpath%20d%3D%22M11.842.6258C9.3647.6813%206.9754%201.9906%205.646%204.2933c-.7593%201.3144-1.0647%202.7662-.966%204.1745a7.99%207.99%200%200%201%202.6568-.4541l1.4705-.0013c-.0093-.5594.1245-1.1284.4245-1.6482.8827-1.5284%202.837-2.0522%204.3654-1.1695%201.5284.8824%202.0519%202.8366%201.1695%204.365l-1.4782%202.5647%201.1955%202.0714%202.3914-.0004%201.4775-2.5655c2.0262-3.5088.8239-7.9959-2.6853-10.0217C14.4614.9118%2013.1396.5967%2011.842.6258m-1.5451%208.073-2.9605.0029C3.2844%208.7017%200%2011.9867%200%2016.0383c0%204.052%203.2844%207.3367%207.3364%207.3367%201.5174%200%202.9267-.4609%204.0967-1.2497a8%208%200%200%201-1.72-2.0748l-.7368-1.273c-.4799.288-1.0392.4565-1.6395.4565-1.765%200-3.1958-1.4307-3.1958-3.1958%200-1.7647%201.4307-3.1954%203.1958-3.1954l2.96-.0022%201.1962-2.0708zm9.587.7475a7.99%207.99%200%200%201-.935%202.5278l-.7344%201.2745c.4892.2717.915.6719%201.2153%201.192.8823%201.528.3585%203.4826-1.1699%204.365-1.528.8823-3.4828.3588-4.3651-1.1696l-1.482-2.5628h-2.3915L8.8256%2017.144l1.483%202.5626c2.0262%203.5091%206.513%204.7112%2010.022%202.685%203.5089-2.0257%204.7112-6.5125%202.6853-10.0216-.7588-1.3144-1.863-2.3052-3.132-2.9237%22%20%2F%3E%3C%2Fsvg%3E", "category": "saves", "keywords": [ "integration", diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts index ba7bfed..71fa313 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts @@ -42,17 +42,53 @@ export default class IgdbIntegration implements PluginType { await checkLoginAndRefreshTwitch(); - ctx.hooks.games.gameLookup.tapPromise(desc.name, async ({ source, id }) => + ctx.hooks.games.gameLookup.tapPromise(desc.name, async ({ source, id, search, matches }) => { if (!process.env.TWITCH_CLIENT_ID) return; - if (source !== 'igdb') return; - const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); - if (access_token) + if (!access_token) + { + return; + } + + if ((source === 'igdb' && id) || search) { const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token); - const { data } = await client.request('screenshots').pipe(igdb.fields(['game', 'url', 'image_id']), igdb.where('game', '=', Number(id))).execute(); - return { screenshotUrls: data.filter(s => s.url).map(s => `https://images.igdb.com/igdb/image/upload/t_720p/${s.image_id}.webp`) }; + + const { data: games } = await this.queue.add(() => client.request('games') + .pipe(...(search ? [igdb.search(search)] : []), + igdb.fields(['id', 'name', 'summary', 'screenshots.image_id', 'slug', 'first_release_date', 'rating', 'genres.name', 'involved_companies.company.name', 'keywords.name', 'game_modes.name', 'cover.image_id', 'age_ratings.rating_category.rating', 'platforms.name', 'platforms.abbreviation', 'platforms.slug']), + ...(source === 'igdb' && id ? [igdb.where('id', '=', Number(id))] : []), + igdb.limit(10)).execute()); + + matches.push(...games.filter(g => !!g.name) + .map(g => + { + const lookup: GameLookup = { + source: 'igdb', + id: String(g.id), + coverUrl: g.cover ? `https://images.igdb.com/igdb/image/upload/t_720p/${g.cover.image_id}.webp` : undefined, + screenshotUrls: g.screenshots?.map(s => `https://images.igdb.com/igdb/image/upload/t_720p/${s.image_id}.webp`) ?? [], + name: g.name!, + summary: g.summary, + genres: g.genres?.map(g => g.name!) ?? [], + companies: g.involved_companies?.filter(c => c.company?.name).map(c => c.company?.name!) ?? [], + game_modes: g.game_modes?.map(m => m.name!) ?? [], + age_ratings: g.age_ratings?.map(r => r.rating_category?.rating!) ?? [], + player_count: undefined, + // UNIX date, needs to be converted + first_release_date: g.first_release_date ? g.first_release_date * 1000 : undefined, + average_rating: g.rating ?? undefined, + keywords: g.keywords?.map(k => k.name!) ?? [], + igdb_id: g.id, + platforms: g.platforms?.map(p => ({ id: p.id!, name: p.abbreviation, displayName: p.name!, slug: p.slug! })) ?? [], + slug: g.slug + }; + + return lookup; + })); + + return; } }); diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json index b1cd2e8..55939bb 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "description": "IGDB Metadata Integration", "main": "./igdb.ts", - "icon": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/IGDB_logo.svg/1920px-IGDB_logo.svg.png", + "icon": "data:image/svg+xml,%3Csvg%20role%3D%22img%22%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EIGDB%3C%2Ftitle%3E%3Cpath%20d%3D%22M24%206.228c-8%20.002-16%200-24%200v11.543a88.875%2088.875%200%200%201%202.271-.333%2074.051%2074.051%200%200%201%2017.038-.28c1.57.153%203.134.363%204.69.614V6.228zm-.706.707v10.013a74.747%2074.747%200%200%200-22.588%200V6.934h22.588ZM7.729%208.84a2.624%202.624%200%200%200-1.857.72%202.55%202.55%200%200%200-.73%201.33c-.098.5-.063%201.03.112%201.51.177.488.515.917.954%201.196.547.354%201.224.472%201.865.401a3.242%203.242%200%200%200%201.786-.777c-.003-.724.002-1.449-.002-2.173-.725.004-1.45-.002-2.174.003.003.317%200%20.634.001.951h1.105c.002.236%200%20.473.002.71-.268.196-.603.286-.932.298-.32.02-.65-.05-.922-.225a1.464%201.464%200%200%201-.59-.744c-.18-.499-.134-1.085.163-1.53.23-.355.619-.61%201.043-.647a1.8%201.8%200%200%201%201.012.206c.152.082.286.192.424.295.228-.281.461-.559.692-.838a3.033%203.033%200%200%200-.595-.403c-.418-.212-.892-.285-1.357-.283Zm11.66.086c-.093%200-.187.002-.28%200-.68.002-1.359-.004-2.038.003.003%201.666%200%203.332.002%204.998h2.497c.239-.002.478-.034.709-.097.276-.076.546-.208.742-.422.194-.208.297-.492.304-.776.016-.278-.032-.572-.195-.804-.175-.252-.453-.408-.734-.514.211-.122.407-.285.521-.505.134-.246.149-.535.117-.807a1.156%201.156%200%200%200-.436-.73c-.264-.207-.599-.304-.93-.334a2.757%202.757%200%200%200-.279-.012Zm-16.715%200v5.002h1.102V8.927c-.368-.002-.735%200-1.102%200zm8.524%200v5.002h2.016a2.87%202.87%200%200%200%201.07-.211%202.445%202.445%200%200%200%201.174-.993c.34-.555.429-1.244.292-1.876a2.367%202.367%200%200%200-.828-1.338c-.478-.387-1.096-.577-1.707-.584h-2.017zm6.949.967c.392.002.784-.001%201.176.002.183.011.38.054.51.19.11.112.136.28.112.43a.436.436%200%200%201-.22.316%201.082%201.082%200%200%201-.483.116c-.365.002-.73-.001-1.094.001-.002-.351%200-.703-.001-1.054zm-5.031.026c.28%200%20.567.053.815.19.274.149.491.396.607.685.113.272.138.574.107.865a1.456%201.456%200%200%201-.335.786%201.425%201.425%200%200%201-.865.466c-.168.031-.34.022-.51.023h-.632V9.92h.813zm5.03%201.948h1.36c.174.006.354.035.505.127.11.066.191.18.212.308.025.15.004.32-.099.44-.102.12-.258.176-.409.2-.172.032-.348.02-.522.022-.35-.001-.698.002-1.047-.001v-1.096z%22%20%2F%3E%3C%2Fsvg%3E", "category": "sources", "keywords": [ "integration", diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index e049285..0515ef0 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -253,6 +253,8 @@ export default class RommIntegration implements PluginType const info: DownloadInfo = { platform: { + source: 'romm', + id: String(rommPlatform.id), slug: rommPlatform.slug, name: rommPlatform.name, family_name: rommPlatform.family_name ?? undefined diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts index c57330e..2e0d507 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts @@ -1,6 +1,6 @@ import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; -import path, { basename, dirname } from 'node:path'; +import path, { } from 'node:path'; import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; import { Glob, pathToFileURL } from "bun"; import { and, eq } from "drizzle-orm"; @@ -12,7 +12,6 @@ import { getSourceGameDetailed } from "@/bun/api/games/services/utils"; import UpdateStoreJob from "@/bun/api/jobs/update-store"; import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services"; -import { path7za } from "7zip-bin"; export default class RommIntegration implements PluginType { @@ -314,6 +313,8 @@ export default class RommIntegration implements PluginType version_system: validDownload.system, version_source: validDownload.id, platform: { + source: 'store', + id: system, slug: system, name: system } diff --git a/src/bun/api/schema/app.ts b/src/bun/api/schema/app.ts index 2226b20..6008689 100644 --- a/src/bun/api/schema/app.ts +++ b/src/bun/api/schema/app.ts @@ -12,15 +12,7 @@ export const games = sqliteTable('games', { main_glob: text("main_glob"), last_played: integer("last_played", { mode: 'timestamp' }), created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(), - metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type<{ - genres?: string[], - companies?: string[], - game_modes?: string[], - age_ratings?: string[]; - player_count?: string; - first_release_date?: number; - average_rating?: number; - }>().notNull(), + metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type().notNull(), slug: text("slug").unique(), platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(), cover: blob("cover", { mode: 'buffer' }), diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index ea76797..f5b2d71 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -57,15 +57,6 @@ export async function getRelevantEmulators () await plugins.hooks.emulators.findEmulatorSource.promise({ emulator, sources: execPaths }); const integrations = findEmulatorPluginIntegration(emulator, execPaths); - const storeEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: emulator }); - - if (storeEmulator) - { - storeEmulator.validSources = execPaths; - storeEmulator.integrations = integrations; - return storeEmulator; - } - let platform: number | null | undefined = null; const validSystemSlug = system_slug.find(s => s.system); if (validSystemSlug?.system) @@ -78,7 +69,17 @@ export async function getRelevantEmulators () systems.forEach(s => platformViability.set(s, true)); } + const storeEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: emulator }); + + if (storeEmulator) + { + storeEmulator.validSources = execPaths; + storeEmulator.integrations = integrations; + return { ...storeEmulator, isCritical: false }; + } + const em: FrontEndEmulator & { isCritical: boolean; } = { + source: 'local', name: emulator, logo: platform ? `/api/romm/platform/local/${platform}/cover` : '', systems: systems.map(s => platformLookup.get(s)).filter(s => !!s).map(e => ({ iconUrl: `/api/romm/image/romm/assets/platforms/${e.es_slug}.svg`, name: e.platform_name ?? 'Unknown', id: e.es_slug ?? '' })), @@ -92,6 +93,7 @@ export async function getRelevantEmulators () })); finalEmulators.push({ + source: 'local', name: 'EMULATORJS', validSources: [{ binPath: `${SERVER_URL(host)}`, type: 'embedded', exists: true }], logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index a706563..504cc25 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -2,7 +2,7 @@ import Elysia from "elysia"; import open from 'open'; import z from "zod"; import os from 'node:os'; -import { cache, cachePath, config, events, taskQueue } from "./app"; +import { cachePath, config, events, taskQueue } from "./app"; import { getAppVersion, isSteamDeck, openExternal } from "../utils"; import fs from 'node:fs/promises'; import buildNotificationsStream from "./notifications"; @@ -14,7 +14,7 @@ import si from 'systeminformation'; import { getStoreFolder } from "./store/services/gamesService"; import ReloadPluginsJob from "./jobs/reload-plugins-job"; import { semver } from "bun"; -import { getOrCached, getOrCachedGithubRelease, githubRequestQueue } from "./cache"; +import { getOrCachedGithubRelease } from "./cache"; import SelfUpdateJob from "./jobs/self-update-job"; async function checkUpdate (force?: boolean) @@ -239,6 +239,10 @@ export const system = new Elysia({ prefix: '/api/system' }) { currentPath = path.resolve(process.cwd(), currentPath); } + const currentPathExists = await fs.exists(currentPath); + if (!currentPathExists) currentPath = dirname(process.cwd()); + const currentPathStat = await fs.stat(currentPath); + if (!currentPathStat.isDirectory()) currentPath = dirname(currentPath); const paths = await fs.readdir(currentPath, { withFileTypes: true }); return { name: path.basename(currentPath), diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index bb890df..a54026a 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -1,8 +1,5 @@ - - -import { and } from 'drizzle-orm'; import EventEmitter from 'node:events'; -import z, { any } from 'zod'; +import z from 'zod'; export class TaskQueue { @@ -10,7 +7,16 @@ export class TaskQueue private queue?: JobContext, any, string>[] = []; private events?: EventEmitter = new EventEmitter(); - public enqueue (id: string, job: T): T extends IJob + constructor() + { + // we need a default error listener or app crashes + this.events?.addListener('error', e => + { + console.error(e); + }); + } + + public enqueue (id: string, job: T, throwOnError?: boolean): T extends IJob ? Promise : never { @@ -35,7 +41,7 @@ export class TaskQueue { job.job.start(); this.activeQueue.push(job.job); - job.job.promise.promise.finally(() => + job.job.promise.promise.catch(e => { }).finally(() => { const index = this.activeQueue.indexOf(job.job); this.activeQueue.splice(index, 1); @@ -235,26 +241,21 @@ export class JobContext, TData, TState extends str } } catch (error) { - try + if (error instanceof Event) { - if (error instanceof Event) + if (error.target instanceof AbortSignal) { - if (error.target instanceof AbortSignal) - { - - } else - { - console.error(error); - } + this.m_promise.resolve(undefined); } else { console.error(error); - this.events.emit('error', { id: this.m_id, job: this, error }); - this.error = error; + this.m_promise.reject(error); } - } finally + } else { - this.m_promise.resolve(undefined); + this.events.emit('error', { id: this.m_id, job: this, error }); + this.error = error; + this.m_promise.reject(error); } } finally diff --git a/src/mainview/assets/sounds.json b/src/mainview/assets/sounds.json index 97b63f9..309705d 100644 --- a/src/mainview/assets/sounds.json +++ b/src/mainview/assets/sounds.json @@ -36,28 +36,52 @@ 34000, 2489.5918367346967 ], - "Classic UI SFX - Chords #16": [ + "Classic UI SFX - Short - High #25": [ 38000, + 2005.215419501134 + ], + "Classic UI SFX - Chords #16": [ + 42000, 4005.215419501134 ], "Classic UI SFX - Short - High #8": [ - 44000, + 48000, 2916.6666666666642 ], "UI_Single_Set 16_03": [ - 48000, + 52000, 309.5918367346968 ], "UI_Single_Set 16_01": [ - 50000, + 54000, 309.5918367346968 ], + "UI_Single_Set 5_02": [ + 56000, + 875.0113378684787 + ], + "UI_Single_Set 5_04": [ + 58000, + 531.247165532882 + ], + "UI_Single_Set 5_03": [ + 60000, + 531.247165532882 + ], + "UI_Single_Set 5_01": [ + 62000, + 875.0113378684787 + ], + "UI_Single_Set 11_02": [ + 64000, + 93.74149659863917 + ], "Classic UI SFX - Short - Low #6": [ - 52000, - 2333.3333333333358 + 66000, + 2333.3333333333285 ], "UI SFX_InGameMenu_Open": [ - 56000, + 70000, 2614.104308390026 ] } diff --git a/src/mainview/assets/sounds.ogg b/src/mainview/assets/sounds.ogg index 0b7ac9b..835432f 100644 --- a/src/mainview/assets/sounds.ogg +++ b/src/mainview/assets/sounds.ogg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c5dd2b1e23a878efe84694fa354e92e07f9394d88217b0f1d925f3b16f044e55 -size 353897 +oid sha256:38721ebc90eb07ef7e00c0a1a64bd363e61dbbd08aa32b12a33da5ead0597948 +size 408079 diff --git a/src/mainview/components/AppCommunication.tsx b/src/mainview/components/AppCommunication.tsx index 98f8c65..bbb26c3 100644 --- a/src/mainview/components/AppCommunication.tsx +++ b/src/mainview/components/AppCommunication.tsx @@ -3,6 +3,7 @@ import { SystemInfoContext } from "../scripts/contexts"; import { systemApi } from "../scripts/clientApi"; import { SystemInfoType } from "@/shared/constants"; import LoadingScreen from "./LoadingScreen"; +import { GamepadKeyboard } from "./GamepadKeyboard"; export default function AppCommunication (data: { children: any; }) { @@ -55,5 +56,6 @@ export default function AppCommunication (data: { children: any; })
    : data.children} + ; } \ No newline at end of file diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index c04db7f..ac30c57 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -8,10 +8,9 @@ export default function CollectionList (data: { id: string, setBackground: (url: string) => void; className?: string; - onFocus?: GameCardFocusHandler; onSelect?: (id: string) => void; saveChildFocus?: 'session' | 'local'; -}) +} & FocusParams) { const router = useRouter(); const { data: collections } = useSuspenseQuery(getCollectionsQuery); @@ -37,7 +36,7 @@ export default function CollectionList (data: { id: `${g.id.source}@${g.id.id}`, title: g.name, focusKey: `collection-${g.id}`, - previewUrl: `${RPC_URL(__HOST__)}${g.path_platform_cover}`, + previewUrls: `${RPC_URL(__HOST__)}${g.path_platform_cover}`, badges: [ {g.game_count} diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index 8108149..9d6632c 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -1,24 +1,17 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { HeaderButton, StickyHeaderUI } from './Header'; import { GameList } from './GameList'; -import { ArrowDownAz, CalendarArrowDown, ClockArrowDown, Drama, Filter, FunnelX, HardDrive, Rocket, Search, Settings2, SortDesc, Store, Tags, User, UserLock } from 'lucide-react'; -import { JSX, Suspense, useRef, useState } from 'react'; +import { JSX, Suspense } from 'react'; import { FloatingShortcuts } from './Shortcuts'; import { AutoFocus } from './AutoFocus'; import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts'; -import { GameListFilterSchema, GameListFilterType } from '@/shared/constants'; +import { GameListFilterType } from '@/shared/constants'; import { HandleGoBack } from '../scripts/utils'; import LoadingCardList from './LoadingCardList'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { gameFiltersQuery, gameQuery } from '../scripts/queries/romm'; -import { useNavigate, useRouter } from '@tanstack/react-router'; +import { useRouter } from '@tanstack/react-router'; import SelectMenu from './SelectMenu'; -import { RoundButton } from './RoundButton'; -import { ContextList, DialogEntry, useContextDialog } from './ContextDialog'; -import classNames from 'classnames'; -import { sourceIconMap } from './Constants'; -import { stat } from 'fs-extra'; -import { FilterUI } from './Filters'; import SideFilters from './SideFilters'; export interface CollectionsDetailParams @@ -75,7 +68,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
    - {finalFilter && data.title} + {!!finalFilter && data.title} {}> - {data.options?.map((o, i) => )} + {data.options?.map((o, i) => )} {data.showCloseButton !== false &&
    } {data.showCloseButton !== false && } action={() => context.close()} id="close-context-dialog" content="Close" />} ; @@ -40,7 +40,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class }; const { ref, focusSelf, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id), - onEnterPress: data.shortcuts ? undefined : handleAction, + onEnterPress: handleAction, onFocus: handleFocus, trackChildren: typeof data.content !== 'string' }); diff --git a/src/mainview/components/FilePicker.tsx b/src/mainview/components/FilePicker.tsx index 6788952..09bc8f5 100644 --- a/src/mainview/components/FilePicker.tsx +++ b/src/mainview/components/FilePicker.tsx @@ -1,9 +1,8 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { ContextList, DialogEntry } from "./ContextDialog"; -import { systemApi } from "../scripts/clientApi"; import { FocusEventHandler, useContext, useRef, useState } from "react"; import path from "pathe"; -import { Check, File, 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 { DirType } from "@/shared/constants"; import classNames from "classnames"; @@ -15,7 +14,6 @@ import toast from "react-hot-toast"; import { FilePickerContext } from "../scripts/contexts"; import useActiveControl from "../scripts/gamepads"; import { createFolderMutation, drivesQuery, filesQuery } from "@queries/system"; -import { showKeyboardHandler } from "../scripts/utils"; function List (data: { id: string, @@ -48,7 +46,7 @@ function List (data: { let icon = ; if (isDefaultPath) { - icon = ; + icon = f.isDirectory ? : ; } else if (!f.isDirectory) { icon = ; @@ -97,7 +95,6 @@ function NewFolderInput (data: { id: string, name: string | undefined, setName: const handleFocus: FocusEventHandler = (e) => { focusSelf(); - showKeyboardHandler(control as any, e.target); }; return
    = { + '⌫': { bg: "var(--color-accent)", color: "var(--color-accent-content)" }, + '⏎': { bg: "var(--color-secondary)", color: "var(--color-secondary-content)" }, + '␣': { bg: "var(--color-info)", color: "var(--color-info-content)" }, +}; +const Shortcuts: Record = { + '⌫': GamePadButtonCode.X, + '␣': GamePadButtonCode.Y, + '⏎': GamePadButtonCode.A, + '←': GamePadButtonCode.Left, + '→': GamePadButtonCode.Right, + '⇧': GamePadButtonCode.RJoy, + '⌥': GamePadButtonCode.LJoy +}; +const KeyElements: Record = { + '⌫': , + '␣': , + '⏎': , + '←': , + '→': , +}; +const DZ = 0.22, TH = 0.85, NS = 'http://www.w3.org/2000/svg'; + +function ang (x: number, y: number) +{ + if (Math.sqrt(x * x + y * y) < DZ) return null; + let a = Math.atan2(x, -y); + if (a < 0) a += Math.PI * 2; + return a; +} + +function gidx (a: number | null, n: number) +{ + return a === null ? -1 : Math.floor(a / (Math.PI * 2) * n) % n; +} + +function buildWheel (side: 0 | 1, shift: boolean, characters: boolean) +{ + const elements: JSX.Element[] = []; + const refs: RefObject[] = []; + const positions: { left: string; top: string; }[] = []; + const W = 258, C = 129, R2 = 107, R1 = 42, n = GetKeys(characters)[side].length, GAP = 0.028; + + for (let i = 0; i < n; i++) + { + const a0 = i / n * Math.PI * 2 - Math.PI / 2 + GAP; + const a1 = (i + 1) / n * Math.PI * 2 - Math.PI / 2 - GAP; + const am = (a0 + a1) / 2; + const ref = createRef(); + const x = Math.cos(am); + const y = Math.sin(am); + refs.push(ref); + + const tr = 66; + positions.push({ left: `50% + ${tr * x}% - 16px`, top: `50% + ${tr * y}% - 16px` }); + + elements.push(<> + + {KeyElements[GetKeys(characters)[side][i]] ?? shift ? GetKeys(characters)[side][i].toUpperCase() : GetKeys(characters)[side][i].toLocaleLowerCase()} + + ); + } + + return { elements, refs, positions }; +} + +export type EditableInput = HTMLInputElement | HTMLTextAreaElement; + +export function typeKey (el: EditableInput, key: string): void +{ + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + + el.value = + el.value.slice(0, start) + + key + + el.value.slice(end); + + const pos = start + key.length; + el.setSelectionRange(pos, pos); +} + +export function backspace (el: EditableInput): void +{ + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + + // selection delete + if (start !== end) + { + el.value = + el.value.slice(0, start) + + el.value.slice(end); + + el.setSelectionRange(start, start); + return; + } + + // nothing to delete + if (start === 0) return; + + el.value = + el.value.slice(0, start - 1) + + el.value.slice(end); + + el.setSelectionRange(start - 1, start - 1); +} + +export function deleteForward (el: EditableInput): void +{ + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + + if (start !== end) + { + el.value = + el.value.slice(0, start) + + el.value.slice(end); + + el.setSelectionRange(start, start); + return; + } + + if (start >= el.value.length) return; + + el.value = + el.value.slice(0, start) + + el.value.slice(start + 1); + + el.setSelectionRange(start, start); +} + +export function enter (el: EditableInput): void +{ + if (el instanceof HTMLTextAreaElement) + { + + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + + const insert = "\n"; + + el.value = + el.value.slice(0, start) + + insert + + el.value.slice(end); + + const pos = start + 1; + el.setSelectionRange(pos, pos); + + } else + { + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true })); + } + +} + +export function arrowLeft (el: EditableInput): void +{ + const pos = el.selectionStart ?? 0; + const newPos = Math.max(0, pos - 1); + + el.setSelectionRange(newPos, newPos); +} + +export function arrowRight (el: EditableInput): void +{ + const pos = el.selectionStart ?? 0; + const newPos = Math.min(el.value.length, pos + 1); + + el.setSelectionRange(newPos, newPos); +} + +export function GamepadKeyboard () +{ + const triggerThreshold = 0.85; + const [focusedInput, setFocusedInput] = useState(null); + const circleRefs = [useRef(null), useRef(null)]; + const sideRefs = [useRef(null), useRef(null)]; + const keyIndicatorRefs = [useRef(null), useRef(null)]; + const activeControl = useActiveControl(); + const hidden = !focusedInput || activeControl.control !== 'gamepad'; + const keyboardRef = useRef(null); + const [shift, setShift] = useState(false); + const [characters, setCharacters] = useState(false); + + useEffect(() => + { + if (!hidden) + { + oneShot('openKeyboard'); + } + }, [hidden]); + + const elements = [buildWheel(0, shift, characters), buildWheel(1, shift, characters)]; + + useEffect(() => + { + let disposed = false; + const lockedIds: [number | undefined, number | undefined] = [undefined, undefined]; + const actionRepeatTimeout: [NodeJS.Timeout | undefined, NodeJS.Timeout | undefined] = [undefined, undefined]; + const actionRepeatCount = [0, 0]; + const prevTriggerValues = [0, 0]; + const buttonValues: Record = {}; + const buttonRepeatTimeout: Record = {}; + const buttonRepeatCounts: Record = {}; + const lastIndexes = [-1, -1]; + + function update () + { + const gps = navigator.getGamepads ? navigator.getGamepads() : []; + const gp = [...gps].find(g => g); + + if (keyboardRef.current && focusedInput && !hidden) + { + const targetRect = focusedInput.getBoundingClientRect(); + const el = keyboardRef.current; + + // First, measure the element itself + const elRect = el.getBoundingClientRect(); + + const margin = 64; // keep some space from edges + + let left = targetRect.left; + let top = targetRect.bottom + 128; + + // Clamp horizontally + if (left + elRect.width > window.innerWidth - margin) + { + left = window.innerWidth - elRect.width - margin; + } + + if (left < margin) + { + left = margin; + } + + // Clamp vertically + if (top + elRect.height > window.innerHeight - margin) + { + // flip above the input if it doesn't fit below + top = targetRect.top - elRect.height - 128; + } + + if (top < margin) + { + top = margin; + } + + el.style.position = "fixed"; + el.style.left = `${left}px`; + el.style.top = `${top}px`; + } + + if (gp && !hidden) + { + function pressKey (el: EditableInput, key: string, repeatCount: number): void + { + const hapticIntensity = 1 / Math.max(repeatCount, 1); + const soundIntensity = 1 / Math.min(2, Math.max(repeatCount * 0.2, 1)); + gp?.vibrationActuator.playEffect('dual-rumble', { duration: 60, strongMagnitude: hapticIntensity, weakMagnitude: hapticIntensity }); + + switch (key) + { + case "⌫": + oneShot('keyPressBackspace', { volume: soundIntensity }); + return backspace(el); + case "Delete": + oneShot('keyPressBackspace', { volume: soundIntensity }); + return deleteForward(el); + case "←": + oneShot('keyPress', { volume: soundIntensity }); + return arrowLeft(el); + case "→": + oneShot('keyPress', { volume: soundIntensity }); + return arrowRight(el); + case "⏎": + oneShot('keyPress', { volume: soundIntensity }); + return enter(el); + case "␣": + oneShot('keyPressSpace', { volume: soundIntensity }); + return typeKey(el, ' '); + case "⇧": + setShift(v => !v); + return; + case "⌥": + setCharacters(v => !v); + return; + default: + oneShot('keyPress', { volume: soundIntensity }); + return typeKey(el, shift ? key.toUpperCase() : key.toLocaleLowerCase()); + } + } + + for (let side = 0; side < 2; side++) + { + const x = gp.axes[side * 2] ?? 0; + const y = gp.axes[side * 2 + 1] ?? 0; + const triggerValue = Math.max(gp.buttons[6 + side]?.value ?? 0, gp.buttons[4 + side]?.value ?? 0); + const angle = ang(x, y); + const keyIndex = lockedIds[side] !== undefined ? lockedIds[side]! : gidx(angle, GetKeys(characters)[side].length); + + elements[side].refs.filter(e => e.current).forEach((e, i) => + { + const active = keyIndex === i; + const key = GetKeys(characters)[side][i]; + const elem = e.current!; + elem.style.backgroundColor = active ? 'var(--color-primary)' : KeyColors[key]?.bg ?? ''; + elem.style.color = active ? 'var(--color-primary-content)' : KeyColors[key]?.color ?? ''; + elem.style.scale = `${active ? 150 : 100}%`; + elem.style.fontStyle = active ? 'bold' : 'normal'; + }); + + const circle = circleRefs[side].current!; + + // Update actions + if (keyIndex >= 0) + { + if (focusedInput) + { + if (triggerValue >= triggerThreshold && prevTriggerValues[side] < triggerThreshold) + { + const timeoutCalc = () => 400 / Math.min(4, Math.max(1, 1 + (actionRepeatCount[side] ?? 0))); + const handleRepeat = () => + { + elements[side].refs[keyIndex].current!.animate([ + { boxShadow: "0 0 0 0 var(--color-base-content)" }, + { boxShadow: "0 0 0 10px transparent" } + ], + { duration: 300, easing: 'ease-out', fill: 'none' } + ); + pressKey(focusedInput, GetKeys(characters)[side][keyIndex], actionRepeatCount[side]); + actionRepeatCount[side]++; + actionRepeatTimeout[side] = setTimeout(handleRepeat, timeoutCalc()); + }; + handleRepeat(); + } + else if (triggerValue < triggerThreshold && prevTriggerValues[side] >= triggerThreshold) + { + clearTimeout(actionRepeatTimeout[side]); + actionRepeatCount[side] = -1; + } + + if (lockedIds[side] === undefined && triggerValue > 0.1) + { + lockedIds[side] = keyIndex; + } else if (lockedIds[side] !== undefined && triggerValue <= 0.1) + { + lockedIds[side] = undefined; + } + } + + keyIndicatorRefs[side].current!.textContent = shift ? GetKeys(characters)[side][keyIndex].toUpperCase() : GetKeys(characters)[side][keyIndex].toLowerCase(); + } else + { + keyIndicatorRefs[side].current!.textContent = ""; + } + + // Update cirlce + const magnitudeSqr = (x * x) + (y * y); + const magnitude = Math.sqrt(magnitudeSqr); + + const elementPos = keyIndex < 0 ? undefined : elements[side].positions[keyIndex]; + //const lerpX = (element?.left ?? 0); + //const lerpY = (element?.top ?? 0); + const size = 12; + circle.style.left = `calc(50% + ${50 * x}% - 16px)`; + circle.style.top = `calc(50% + ${50 * y}% - 16px)`; + circle.style.opacity = `${1 - Math.pow(magnitude, 2)}`; + circle.style.backgroundColor = `color-mix(in srgb, var(--color-base-content), 'var(--color-primary)'} ${magnitude * 100}%)`; + + if (sideRefs[side].current) + { + sideRefs[side].current!.style.background = `radial-gradient( + circle at calc(50% + ${100 * x}px) calc(50% + ${100 * y}px), + color-mix(in srgb, var(--color-primary) 20%, transparent), + transparent + )`; + } + + + if (lastIndexes[side] !== keyIndex) + { + gp.vibrationActuator.playEffect('dual-rumble', { duration: 30, strongMagnitude: 0, weakMagnitude: 0.2 }); + oneShot('keyHover'); + } + + prevTriggerValues[side] = triggerValue; + lastIndexes[side] = keyIndex; + } + + const shortcutKeys = Object.entries(Shortcuts); + function handleButton (key: number, repeatCount: number) + { + if (!focusedInput) return; + const entry = shortcutKeys.find(([n, value]) => value === key); + if (key === GamePadButtonCode.A) return; + if (entry) + { + pressKey(focusedInput, entry[0], repeatCount); + } + } + + for (let i = 0; i < gp.buttons.length; i++) + { + const btn = gp.buttons[i]; + if (btn.value >= 0.85 && buttonValues[i] < 0.85) + { + const timeoutCalc = () => 400 / Math.min(8, Math.max(1, 1 + (buttonRepeatCounts[i] ?? 0))); + const handleRepeat = () => + { + handleButton(i, buttonRepeatCounts[i]); + buttonRepeatCounts[i] = (buttonRepeatCounts[i] ?? -1) + 1; + buttonRepeatTimeout[i] = setTimeout(handleRepeat, timeoutCalc()); + }; + handleRepeat(); + } + else if (btn.value < 0.85 && buttonValues[i] >= 0.85) + { + clearTimeout(buttonRepeatTimeout[i]); + buttonRepeatCounts[i] = -1; + } + + buttonValues[i] = btn.value; + } + } + + if (!disposed && !hidden) requestAnimationFrame(update); + } + + if (!disposed && !hidden) requestAnimationFrame(update); + + return () => + { + disposed = true; + Object.values(buttonRepeatTimeout).forEach(v => clearTimeout(v)); + Object.values(actionRepeatTimeout).forEach(v => clearTimeout(v)); + }; + }, [focusedInput, elements, shift, characters, hidden]); + + useEffect(() => + { + + const handleFocus = (e: FocusEvent) => + { + if (e.target instanceof HTMLInputElement && (e.target.type === 'text' || e.target.type === 'search')) + { + if (!getLocalSetting('autoKeybaord')) return; + if (getLocalSetting('useGameflowKeyboard')) + { + setFocusedInput(e.target); + } else + { + showKeyboardHandler(activeControl.control, e.target); + } + } + }; + + const handleBlur = (e: FocusEvent) => + { + setFocusedInput(null); + }; + + document.addEventListener('focusin', handleFocus); + document.addEventListener('focusout', handleBlur); + + return () => + { + document.removeEventListener('focusin', handleFocus); + document.removeEventListener('focusout', handleBlur); + }; + }, []); + + return ; +} \ No newline at end of file diff --git a/src/mainview/components/HeaderSearchField.tsx b/src/mainview/components/HeaderSearchField.tsx index 50befd9..db83aad 100644 --- a/src/mainview/components/HeaderSearchField.tsx +++ b/src/mainview/components/HeaderSearchField.tsx @@ -1,13 +1,12 @@ import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import { FocusEventHandler, Ref, RefObject, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { oneShot } from "../scripts/audio/audio"; import { Search } from "lucide-react"; import { RoundButton } from "./RoundButton"; import { useEventListener } from "usehooks-ts"; -import { systemApi } from "../scripts/clientApi"; -import { showKeyboardHandler } from "../scripts/utils"; import useActiveControl from "../scripts/gamepads"; +import { twMerge } from "tailwind-merge"; function SearchInput (data: { id: string; @@ -16,6 +15,7 @@ function SearchInput (data: { compact: boolean | undefined; onInputFocus: () => void; setShowInput: (show: boolean) => void; + className?: string; onSubmit: (search: string | undefined) => void; } & FocusParams) { @@ -63,9 +63,7 @@ function SearchInput (data: { data.onSubmit?.(undefined); }, inputRef as any); - const handlInputFocus: FocusEventHandler = e => showKeyboardHandler(control as any, e.target); - - return
    ); diff --git a/src/mainview/components/Shortcuts.tsx b/src/mainview/components/Shortcuts.tsx index 03d5e03..a71eeca 100644 --- a/src/mainview/components/Shortcuts.tsx +++ b/src/mainview/components/Shortcuts.tsx @@ -1,36 +1,36 @@ -import { useContext } from 'react'; import useActiveControl, { GamepadButtonEvent } from '../scripts/gamepads'; -import { GamePadButtonCode, Shortcut, useShortcutContext } from '../scripts/shortcuts'; +import { GamePadButtonCode, useShortcutContext } from '../scripts/shortcuts'; import ShortcutPrompt from './ShortcutPrompt'; import { IconType } from './SvgIcon'; -import { ShortcutsContext } from '../scripts/contexts'; export function FloatingShortcuts () { return
    ; } +export const GamepadIconMap: Record = { + [GamePadButtonCode.A]: 'steamdeck_button_a', + [GamePadButtonCode.B]: 'steamdeck_button_b', + [GamePadButtonCode.X]: 'steamdeck_button_x', + [GamePadButtonCode.Y]: 'steamdeck_button_y', + [GamePadButtonCode.L1]: 'steamdeck_button_l1', + [GamePadButtonCode.R1]: 'steamdeck_button_r1', + [GamePadButtonCode.L2]: 'steamdeck_button_l2', + [GamePadButtonCode.R2]: 'steamdeck_button_r2', + [GamePadButtonCode.Select]: 'steamdeck_button_guide', + [GamePadButtonCode.Start]: 'steamdeck_button_options', + [GamePadButtonCode.LJoy]: 'steamdeck_stick_l_press', + [GamePadButtonCode.RJoy]: 'steamdeck_stick_r_press', + [GamePadButtonCode.Up]: 'steamdeck_dpad_up', + [GamePadButtonCode.Down]: 'steamdeck_dpad_down', + [GamePadButtonCode.Left]: 'steamdeck_dpad_left', + [GamePadButtonCode.Right]: 'steamdeck_dpad_right', + [GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess' +}; + export default function Shortcuts (data: { centerElement?: any; }) { - const iconMap: Record = { - [GamePadButtonCode.A]: 'steamdeck_button_a', - [GamePadButtonCode.B]: 'steamdeck_button_b', - [GamePadButtonCode.X]: 'steamdeck_button_x', - [GamePadButtonCode.Y]: 'steamdeck_button_y', - [GamePadButtonCode.L1]: 'steamdeck_button_l1', - [GamePadButtonCode.R1]: 'steamdeck_button_r1', - [GamePadButtonCode.L2]: 'steamdeck_button_l2', - [GamePadButtonCode.R2]: 'steamdeck_button_r2', - [GamePadButtonCode.Select]: 'steamdeck_button_guide', - [GamePadButtonCode.Start]: 'steamdeck_button_options', - [GamePadButtonCode.LJoy]: 'steamdeck_stick_l_press', - [GamePadButtonCode.RJoy]: 'steamdeck_stick_r_press', - [GamePadButtonCode.Up]: 'steamdeck_dpad_up', - [GamePadButtonCode.Down]: 'steamdeck_dpad_down', - [GamePadButtonCode.Left]: 'steamdeck_dpad_left', - [GamePadButtonCode.Right]: 'steamdeck_dpad_right', - [GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess' - }; + const keyboardMap: Record = { [GamePadButtonCode.A]: 'ENTER', @@ -62,7 +62,7 @@ export default function Shortcuts (data: { centerElement?: any; }) key={s.button} id={`shortcut-${s.button}`} onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} - icon={showKeyboard ? undefined : iconMap[s.button]} + icon={showKeyboard ? undefined : GamepadIconMap[s.button]} label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} /> )}
    @@ -72,7 +72,7 @@ export default function Shortcuts (data: { centerElement?: any; }) key={s.button} id={`shortcut-${s.button}`} onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} - icon={showKeyboard ? undefined : iconMap[s.button]} + icon={showKeyboard ? undefined : GamepadIconMap[s.button]} label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} /> )} diff --git a/src/mainview/components/SvgIcon.tsx b/src/mainview/components/SvgIcon.tsx index 66a5a26..e449d84 100644 --- a/src/mainview/components/SvgIcon.tsx +++ b/src/mainview/components/SvgIcon.tsx @@ -1,5 +1,6 @@ import "virtual:svg-icons/register"; import { StaticAssetPath } from "../gen/static-icon-assets.gen"; +import { CSSProperties } from "react"; type OnlySvgIcon = T extends `${infer Rest}.svg` ? Rest @@ -15,17 +16,19 @@ export default function SvgIcon ({ icon, prefix = "icon", className, + style, ...props }: { icon: IconType; prefix?: string; className?: string; + style?: CSSProperties; }) { const symbolId = `#${prefix}-${icon}`; return ( - : , content: deleteMutation.isPending ? "Deleting" : "Delete", @@ -98,12 +101,16 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, { contextOptions.push({ id: "fix_source", - async action (ctx) + action (ctx) { if (!data.game) return; - await fixMutation.mutateAsync({ source: data.game.id.source, id: data.game.id.id }); + fixMutation.mutate({ source: data.game.id.source, id: data.game.id.id }, { + onSuccess (data, variables, onMutateResult, context) + { + router.navigate({ replace: true }); + }, + }); ctx.close(); - router.navigate({ replace: true }); }, icon: fixMutation.isPending ? : , content: "Try Fix Source", @@ -126,6 +133,18 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, content: "Update Metadata", type: "primary" }); + + contextOptions.push({ + id: 'update-custom', + action (ctx) + { + ctx.close(); + navigate({ to: '/game/update/$source/$id', params: { source: data.source, id: data.id } }); + }, + icon: updateMutation.isPending ? : , + content: "Update Metadata (Interactive)", + type: "primary" + }); } const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: , canClose: !deleteMutation.isPending }); diff --git a/src/mainview/components/game/GameLookup.tsx b/src/mainview/components/game/GameLookup.tsx new file mode 100644 index 0000000..da38987 --- /dev/null +++ b/src/mainview/components/game/GameLookup.tsx @@ -0,0 +1,80 @@ +import { gameLookup } from "@/mainview/scripts/queries/romm"; +import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { useQuery } from "@tanstack/react-query"; +import { Check, Search } from "lucide-react"; +import HeaderSearchField from "../HeaderSearchField"; +import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; +import { scrollIntoViewHandler } from "@/mainview/scripts/utils"; +import { FOCUS_KEYS } from "@/mainview/scripts/types"; + +function Result (data: { + match: GameLookup; + showPlatform: boolean; + selected: boolean; +} & InteractParams) +{ + const { ref, focusKey } = useFocusable({ + focusKey: FOCUS_KEYS.GAME_MATCH({ source: data.match.source, id: data.match.id }), + onFocus (l, p, d) { scrollIntoViewHandler({ block: 'center' })(focusKey, ref.current, d); }, + onEnterPress (p, d) { data.onAction?.({ focusKey }); } + }); + useShortcuts(focusKey, () => [{ + label: "Select", action (e) + { + data.onAction?.({ event: e, focusKey }); + }, button: GamePadButtonCode.A + }]); + return
  • data.onAction?.({ event: e.nativeEvent, focusKey })} className='flex gap-4 items-center not-mobile:drop-shadow-md light:bg-base-100 dark:bg-base-300 p-2 rounded-2xl focusable focusable-primary focusable-hover cursor-pointer'> + {data.match.coverUrl ?
    + + {data.selected && } +
    :
    } +
    +
    {data.match.name}
    +
    {data.match.summary}
    +
      + {data.showPlatform && <> + {data.match.platforms.map(p =>
    • {p.name}
    • )} +
      + } + {data.match.genres.map(g =>
    • {g}
    • )} + {data.match.first_release_date &&
    • {new Date(data.match.first_release_date).toDateString()}
    • } +
    +
    +
  • ; +} + +function SearchField (data: { setSearch: (search: string | undefined) => void; search: string | undefined; }) +{ + const { ref, focusKey } = useFocusable({ focusKey: `search-field-section` }); + return
    + + data.setSearch(v)} search={data.search} id='search-field' /> + +
    ; +} + +export default function GameLookup (data: { + search: string | undefined, + setSearch: (search: string | undefined) => void, + onSelect: (match: GameLookup) => void; + showPlatforms?: boolean; + selected?: FrontEndId; +}) +{ + const { data: lookups, isFetching } = useQuery({ ...gameLookup(data.search), staleTime: 1000 * 60 * 60 }); + + return
    + +
    {isFetching ? : }Results
    +
      + {lookups?.map((l, i) => + { + return + { + data.onSelect(l); + }} />; + })} +
    +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index 96a120f..51de536 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -10,6 +10,7 @@ import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview import ActionButton from "./ActionButton"; import { useRouter } from "@tanstack/react-router"; import { DownloadSourceType } from "@/shared/constants"; +import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; }) { @@ -118,10 +119,14 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so }; let mainButton: any | undefined = undefined; + let showAllCommandsAction: ((focusKey: string) => void) | undefined; + let mainAction: () => void; if (status === 'installed') { + if (validCommands.length > 1) showAllCommandsAction = (focusKey) => showAllCommands(true, focusKey); + mainAction = () => handlePlay(validDefaultCommand); mainButton =
    - handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details} + - {validCommands.length > 1 && - showAllCommands(true, 'allActionsBtn')}> + {showAllCommandsAction && + showAllCommandsAction!('allActionsBtn')}> }
    ; } else if (error) { + mainAction = () => + { + if (status === 'missing-emulator') + { + router.navigate({ to: '/settings/directories' }); + } + }; mainButton = - { - if (status === 'missing-emulator') - { - router.navigate({ to: '/settings/directories' }); - } - }} + onAction={mainAction} id="mainAction"> ; @@ -167,26 +173,27 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so { icon = ; } + mainAction = () => + { + if (installMut.isPending) return; + switch (status) + { + case 'present': + case 'install': + if (installSources && installSources.length > 1) + { + showInstallSource(true, 'mainAction'); + } else + { + installMut.mutate({}); + } + + break; + } + }; mainButton = - { - if (installMut.isPending) return; - switch (status) - { - case 'present': - case 'install': - if (installSources && installSources.length > 1) - { - showInstallSource(true, 'mainAction'); - } else - { - installMut.mutate({}); - } - - break; - } - }} + onAction={mainAction} tooltip={details ?? status} type='primary' id="mainAction"> @@ -194,6 +201,27 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so ; } + useShortcuts('mainAction', () => + { + const shortcuts: Shortcut[] = [{ + button: GamePadButtonCode.A, + action: mainAction + }]; + + if (showAllCommandsAction) + shortcuts.push( + { + button: GamePadButtonCode.Y, + label: "All Commands", + action (e) + { + showAllCommandsAction('mainAction'); + }, + }); + + return shortcuts; + }, [showAllCommandsAction, mainAction]); + const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', { content: { diff --git a/src/mainview/components/options/DownloadDirectoryOption.tsx b/src/mainview/components/options/DownloadDirectoryOption.tsx index bfeb63d..339bcc9 100644 --- a/src/mainview/components/options/DownloadDirectoryOption.tsx +++ b/src/mainview/components/options/DownloadDirectoryOption.tsx @@ -1,12 +1,14 @@ import { useState } from "react"; import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption"; -import { useMutation } from "@tanstack/react-query"; -import { changeDownloadsMutation } from "@queries/settings"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { changeDownloadsMutation, getSettingQuery } from "@queries/settings"; +import { SettingsType } from "@/shared/constants"; -export default function DownloadDirectoryOption (data: PathSettingsOptionParams) +export default function DownloadDirectoryOption (data: PathSettingsOptionParams & { id: KeysWithValueAssignableTo; }) { const [localValue, setLocalValue] = useState(); const [dirty, setDirty] = useState(false); + const { data: defaultValue } = useQuery(getSettingQuery(data.id)); const setSettingMutation = useMutation({ ...changeDownloadsMutation, onSuccess: (d, v, r, cx) => @@ -25,6 +27,7 @@ export default function DownloadDirectoryOption (data: PathSettingsOptionParams) requireConfirmation={data.requireConfirmation} isDirectoryPicker={true} localValue={localValue} + defaultValue={defaultValue as any} setLocalValue={(v) => { setLocalValue(v); diff --git a/src/mainview/components/options/LocalOption.tsx b/src/mainview/components/options/LocalOption.tsx index 8636bf1..d596123 100644 --- a/src/mainview/components/options/LocalOption.tsx +++ b/src/mainview/components/options/LocalOption.tsx @@ -1,4 +1,4 @@ -import { HTMLInputTypeAttribute, JSX } from "react"; +import { JSX } from "react"; import { LocalSettingsSchema, LocalSettingsType } from "@shared/constants"; import { OptionSpace } from "./OptionSpace"; import { OptionInput } from "./OptionInput"; @@ -6,14 +6,9 @@ import { useLocalStorage } from "usehooks-ts"; import { OptionDropdown } from "./OptionDropdown"; export function LocalOption (data: { - label: string; id: keyof LocalSettingsType; - type: HTMLInputTypeAttribute | 'dropdown'; - min?: number; - max?: number; step?: number; placeholder?: string; - values?: string[]; icon?: JSX.Element; children?: any; }) @@ -22,9 +17,20 @@ export function LocalOption (data: { deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v)) }); + const schema = LocalSettingsSchema.shape[data.id].toJSONSchema(); + const typeMapping: Record = { + string: 'text', + integer: 'range', + number: 'range', + boolean: 'checkbox' + }; + return ( - - {data.type === 'dropdown' && data.values && +
    {schema.title ?? data.id}
    +
    {schema.description}
    + }> + {!!schema.enum && String(v))} icon={data.icon} name={data.id ?? ""} placeholder={data.placeholder} defaultValue={localValue} @@ -33,12 +39,12 @@ export function LocalOption (data: { setLocalValue(v); }} value={localValue} />} - {data.type !== 'dropdown' && diff --git a/src/mainview/components/options/OptionSpace.tsx b/src/mainview/components/options/OptionSpace.tsx index 3a9b05c..1a1ce2d 100644 --- a/src/mainview/components/options/OptionSpace.tsx +++ b/src/mainview/components/options/OptionSpace.tsx @@ -35,6 +35,7 @@ export function useOptionContext (params?: { onOptionEnterPress?: () => void; }) export function OptionSpace (data: { id?: string; className?: string; + innerClassName?: string; focusable?: boolean; children?: any | any[]; label?: string | JSX.Element | ((focused: boolean) => JSX.Element); @@ -90,7 +91,7 @@ export function OptionSpace (data: { {!!labelElement &&
    {labelElement}
    } -
    +
    {data.children}
    diff --git a/src/mainview/components/options/PathSettingsOption.tsx b/src/mainview/components/options/PathSettingsOption.tsx index 39c57f9..d81d32f 100644 --- a/src/mainview/components/options/PathSettingsOption.tsx +++ b/src/mainview/components/options/PathSettingsOption.tsx @@ -13,7 +13,7 @@ import { getSettingQuery, setSettingMutation } from "@queries/settings"; export interface PathSettingsOptionParams { label: string; - id: KeysWithValueAssignableTo; + id: string; type: HTMLInputTypeAttribute; placeholder?: string; icon?: JSX.Element; @@ -24,10 +24,11 @@ export interface PathSettingsOptionParams allowNewFolderCreation?: boolean; } -export function PathSettingsOption (data: PathSettingsOptionParams) +export function PathSettingsOption (data: PathSettingsOptionParams & { id: KeysWithValueAssignableTo; }) { const [localValue, setLocalValue] = useState(); const [dirty, setDirty] = useState(false); + const { data: defaultValue } = useQuery(getSettingQuery(data.id)); const setMutation = useMutation({ ...setSettingMutation(data.id), onSuccess: (d, v, r, cx) => @@ -44,6 +45,7 @@ export function PathSettingsOption (data: PathSettingsOptionParams) save={setMutation.mutate} localValue={localValue} allowNewFolderCreation={data.allowNewFolderCreation} + defaultValue={defaultValue as any} setLocalValue={(v) => { setLocalValue(v); @@ -56,16 +58,17 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & { localValue: string | undefined; setLocalValue: (value: string | undefined) => void; isDirty: boolean; + className?: string; + defaultValue: string | undefined; }) { const [isBrowsing, setIsBrowsing] = useState(false); - const { data: defaultValue } = useQuery(getSettingQuery(data.id)); - const changed = defaultValue !== data.localValue; + const changed = data.defaultValue !== data.localValue; useEffect(() => { - data.setLocalValue(String(defaultValue)); - }, [defaultValue]); + data.setLocalValue(String(data.defaultValue ?? '')); + }, [data.defaultValue]); const handleSelectPath = (path: string) => { @@ -92,7 +95,8 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & { }; return ( - {data.label}{changed && }}> + {data.label}{changed && }}> + - {data.requireConfirmation === true && } -
    ); @@ -71,7 +64,7 @@ export function MissingEmulatorsSection ({ onSelect, }: { emulators: FrontEndEmulator[]; - onSelect?: (id: string, focusKey: string) => void; + onSelect?: (em: FrontEndEmulator, focusKey: string) => void; }) { const { ref, focusKey } = useFocusable({ diff --git a/src/mainview/gen/routeTree.gen.ts b/src/mainview/gen/routeTree.gen.ts index 4d647d1..ad674ce 100644 --- a/src/mainview/gen/routeTree.gen.ts +++ b/src/mainview/gen/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emul import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/directories' import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts' import { Route as SettingsAboutRouteImport } from './../routes/settings/about' +import { Route as GameAddRouteImport } from './../routes/game/add' import { Route as StoreTabRouteRouteImport } from './../routes/store/tab/route' import { Route as StoreTabIndexRouteImport } from './../routes/store/tab/index' import { Route as StoreTabGamesRouteImport } from './../routes/store/tab/games' @@ -30,6 +31,7 @@ import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id' import { Route as EmbeddedSourceIdRouteImport } from './../routes/embedded.$source.$id' import { Route as CollectionSourceIdRouteImport } from './../routes/collection.$source.$id' import { Route as StoreDetailsEmulatorIdRouteImport } from './../routes/store/details.emulator.$id' +import { Route as GameUpdateSourceIdRouteImport } from './../routes/game/update.$source.$id' const GamesRoute = GamesRouteImport.update({ id: '/games', @@ -81,6 +83,11 @@ const SettingsAboutRoute = SettingsAboutRouteImport.update({ path: '/about', getParentRoute: () => SettingsRouteRoute, } as any) +const GameAddRoute = GameAddRouteImport.update({ + id: '/game/add', + path: '/game/add', + getParentRoute: () => rootRouteImport, +} as any) const StoreTabRouteRoute = StoreTabRouteRouteImport.update({ id: '/store/tab', path: '/store/tab', @@ -136,12 +143,18 @@ const StoreDetailsEmulatorIdRoute = StoreDetailsEmulatorIdRouteImport.update({ path: '/store/details/emulator/$id', getParentRoute: () => rootRouteImport, } as any) +const GameUpdateSourceIdRoute = GameUpdateSourceIdRouteImport.update({ + id: '/game/update/$source/$id', + path: '/game/update/$source/$id', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/settings': typeof SettingsRouteRouteWithChildren '/games': typeof GamesRoute '/store/tab': typeof StoreTabRouteRouteWithChildren + '/game/add': typeof GameAddRoute '/settings/about': typeof SettingsAboutRoute '/settings/accounts': typeof SettingsAccountsRoute '/settings/directories': typeof SettingsDirectoriesRoute @@ -158,12 +171,14 @@ export interface FileRoutesByFullPath { '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute '/store/tab/': typeof StoreTabIndexRoute + '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/settings': typeof SettingsRouteRouteWithChildren '/games': typeof GamesRoute + '/game/add': typeof GameAddRoute '/settings/about': typeof SettingsAboutRoute '/settings/accounts': typeof SettingsAccountsRoute '/settings/directories': typeof SettingsDirectoriesRoute @@ -180,6 +195,7 @@ export interface FileRoutesByTo { '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute '/store/tab': typeof StoreTabIndexRoute + '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute } export interface FileRoutesById { @@ -188,6 +204,7 @@ export interface FileRoutesById { '/settings': typeof SettingsRouteRouteWithChildren '/games': typeof GamesRoute '/store/tab': typeof StoreTabRouteRouteWithChildren + '/game/add': typeof GameAddRoute '/settings/about': typeof SettingsAboutRoute '/settings/accounts': typeof SettingsAccountsRoute '/settings/directories': typeof SettingsDirectoriesRoute @@ -204,6 +221,7 @@ export interface FileRoutesById { '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute '/store/tab/': typeof StoreTabIndexRoute + '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute } export interface FileRouteTypes { @@ -213,6 +231,7 @@ export interface FileRouteTypes { | '/settings' | '/games' | '/store/tab' + | '/game/add' | '/settings/about' | '/settings/accounts' | '/settings/directories' @@ -229,12 +248,14 @@ export interface FileRouteTypes { | '/store/tab/emulators' | '/store/tab/games' | '/store/tab/' + | '/game/update/$source/$id' | '/store/details/emulator/$id' fileRoutesByTo: FileRoutesByTo to: | '/' | '/settings' | '/games' + | '/game/add' | '/settings/about' | '/settings/accounts' | '/settings/directories' @@ -251,6 +272,7 @@ export interface FileRouteTypes { | '/store/tab/emulators' | '/store/tab/games' | '/store/tab' + | '/game/update/$source/$id' | '/store/details/emulator/$id' id: | '__root__' @@ -258,6 +280,7 @@ export interface FileRouteTypes { | '/settings' | '/games' | '/store/tab' + | '/game/add' | '/settings/about' | '/settings/accounts' | '/settings/directories' @@ -274,6 +297,7 @@ export interface FileRouteTypes { | '/store/tab/emulators' | '/store/tab/games' | '/store/tab/' + | '/game/update/$source/$id' | '/store/details/emulator/$id' fileRoutesById: FileRoutesById } @@ -282,11 +306,13 @@ export interface RootRouteChildren { SettingsRouteRoute: typeof SettingsRouteRouteWithChildren GamesRoute: typeof GamesRoute StoreTabRouteRoute: typeof StoreTabRouteRouteWithChildren + GameAddRoute: typeof GameAddRoute CollectionSourceIdRoute: typeof CollectionSourceIdRoute EmbeddedSourceIdRoute: typeof EmbeddedSourceIdRoute GameSourceIdRoute: typeof GameSourceIdRoute LauncherSourceIdRoute: typeof LauncherSourceIdRoute PlatformSourceIdRoute: typeof PlatformSourceIdRoute + GameUpdateSourceIdRoute: typeof GameUpdateSourceIdRoute StoreDetailsEmulatorIdRoute: typeof StoreDetailsEmulatorIdRoute } @@ -362,6 +388,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsAboutRouteImport parentRoute: typeof SettingsRouteRoute } + '/game/add': { + id: '/game/add' + path: '/game/add' + fullPath: '/game/add' + preLoaderRoute: typeof GameAddRouteImport + parentRoute: typeof rootRouteImport + } '/store/tab': { id: '/store/tab' path: '/store/tab' @@ -439,6 +472,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StoreDetailsEmulatorIdRouteImport parentRoute: typeof rootRouteImport } + '/game/update/$source/$id': { + id: '/game/update/$source/$id' + path: '/game/update/$source/$id' + fullPath: '/game/update/$source/$id' + preLoaderRoute: typeof GameUpdateSourceIdRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -489,11 +529,13 @@ const rootRouteChildren: RootRouteChildren = { SettingsRouteRoute: SettingsRouteRouteWithChildren, GamesRoute: GamesRoute, StoreTabRouteRoute: StoreTabRouteRouteWithChildren, + GameAddRoute: GameAddRoute, CollectionSourceIdRoute: CollectionSourceIdRoute, EmbeddedSourceIdRoute: EmbeddedSourceIdRoute, GameSourceIdRoute: GameSourceIdRoute, LauncherSourceIdRoute: LauncherSourceIdRoute, PlatformSourceIdRoute: PlatformSourceIdRoute, + GameUpdateSourceIdRoute: GameUpdateSourceIdRoute, StoreDetailsEmulatorIdRoute: StoreDetailsEmulatorIdRoute, } export const routeTree = rootRouteImport diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index d0a346e..86c551b 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -24,6 +24,7 @@ import Details from "@/mainview/components/game/Details"; import { AutoFocus } from "@/mainview/components/AutoFocus"; import SelectMenu from "@/mainview/components/SelectMenu"; import { en } from "zod/v4/locales"; +import { IGDBIcon } from "@/mainview/scripts/brandIcons"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => @@ -105,6 +106,8 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; }) stats.push({ label: "Release Date", content: data.game.metadata.first_release_date.toLocaleDateString(), icon: }); if (data.game.emulators) stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) }); + if (data.game.igdb_id) + stats.push({ label: "IGDB", icon: IGDBIcon, content: String(data.game.igdb_id) }); if (data.game.source) stats.push({ label: "Source", content: `${data.game.source} - ${data.game.source_id}` }); const integrations = new Set(data.game.emulators?.flatMap(e => e.integrations).flatMap(i => i.capabilities).filter(c => !!c)); diff --git a/src/mainview/routes/game/add.tsx b/src/mainview/routes/game/add.tsx new file mode 100644 index 0000000..d741765 --- /dev/null +++ b/src/mainview/routes/game/add.tsx @@ -0,0 +1,396 @@ +import { AutoFocus } from '@/mainview/components/AutoFocus'; +import { OptionElement } from '@/mainview/components/ContextDialog'; +import GameLookup from '@/mainview/components/game/GameLookup'; +import { StickyHeaderUI } from '@/mainview/components/Header'; +import LoadingScreen from '@/mainview/components/LoadingScreen'; +import { Button } from '@/mainview/components/options/Button'; +import { PathSettingsOptionBase } from '@/mainview/components/options/PathSettingsOption'; +import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; +import { oneShot } from '@/mainview/scripts/audio/audio'; +import { addManualGameMutation, allGamesInvalidateQuery, gameLookupDetails, platformLookupMatchQuery } from '@/mainview/scripts/queries/romm'; +import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { HandleGoBack } from '@/mainview/scripts/utils'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; +import { zodValidator } from '@tanstack/zod-adapter'; +import { ArrowBigRightDash, Check, CirclePlus, CircleQuestionMark, CircleX, FileSearch, FolderOpen, HardDrive } from 'lucide-react'; +import { basename } from 'pathe'; +import { JSX, useState } from 'react'; +import toast from 'react-hot-toast'; +import { twMerge } from 'tailwind-merge'; +import z from 'zod'; + + +const StateSchema = z.object({ + step: z.number().default(0), + gameLocation: z.string().optional(), + selectedGame: z.object({ source: z.string(), id: z.string() }).optional(), + platformId: z.number().optional(), + search: z.string().optional() +}); + +export const Route = createFileRoute('/game/add')({ + component: RouteComponent, + validateSearch: zodValidator(StateSchema) +}); + +function FileSelectionField (data: { location: string | undefined, setLocation: (location: string | undefined) => void; }) +{ + const [localLocation, setLocalLocation] = useState(data.location); + return ; +} + +const TAG_REGEX = /\(([^)]+)\)|\[([^\]]+)\]/g; +const EXTENSION_REGEX = /\.(([a-z]+\.)*\w+)$/g; +const LEADING_ARTICLE_PATTERN = /^(a|an|the)\b/g; +const COMMA_ARTICLE_PATTERN = /,\s(a|an|the)\b(?=\s*[^\w\s]|$)/g; +const NON_WORD_SPACE_PATTERN = /[^\w\s]/g; +const MULTIPLE_SPACE_PATTERN = /\s+/g; + +function BuildSearch (filePath: string) +{ + const name = basename(filePath); + const nameWithoutExt = name.replace(EXTENSION_REGEX, "").trim(); + if (!nameWithoutExt) return undefined; + const nameWithoutTags = nameWithoutExt.replaceAll(TAG_REGEX, "").trim(); + if (TAG_REGEX.test(nameWithoutExt)) console.log("match"); + if (!nameWithoutTags) return undefined; + + // Lower and replace underscores with spaces + let finalSearch = nameWithoutTags.toLowerCase().replace("_", " "); + + // Remove articles (combined if possible) + finalSearch = finalSearch.replaceAll(LEADING_ARTICLE_PATTERN, ''); + finalSearch = finalSearch.replaceAll(COMMA_ARTICLE_PATTERN, ''); + + // Remove punctuation and normalize spaces in one step + finalSearch = finalSearch.replaceAll(NON_WORD_SPACE_PATTERN, ''); + finalSearch = finalSearch.replaceAll(MULTIPLE_SPACE_PATTERN, ''); + + return nameWithoutTags; +} + +const typeIconMap: Record = { + new: , + existing: , + unknown: +}; + +function Overview (data: {}) +{ + const navigate = useNavigate(); + const router = useRouter(); + const state = Route.useSearch(); + const { data: game } = useQuery(gameLookupDetails(state.selectedGame?.source, state.selectedGame?.id)); + const { data: platform } = useQuery(platformLookupMatchQuery(state.selectedGame?.source, state.platformId)); + const addGame = useMutation({ + ...addManualGameMutation, + onError (error, variables, onMutateResult, context) + { + toast.error(error.message); + }, + async onSuccess (data, variables, onMutateResult, context) + { + if (data.id === null) return; + await context.client.invalidateQueries(allGamesInvalidateQuery); + navigate({ + to: '/game/$source/$id', params: { + source: data.source, id: String(data.id) + }, replace: true + }); + }, + }); + + if (!game) return
    Select A Game
    ; + + return
    +
    Preview
    +
    +
    {!!game[0].coverUrl && }
    +
    +
    {game[0].name}
    +
    {game[0].summary}
    +
    +
    {platform?.details.name}
    + +
    + {!!platform?.match.coverUrl && } +
    {platform?.match.name}
    +
    {platform?.match.family_name}
    + + {!!platform?.match.type && typeIconMap[platform?.match.type]} +
    {platform?.match.type}
    +
    +
    +
    {state.gameLocation}
    +
    +
    +
    Actions
    +
    + + +
    +
    ; +} + +function PlatformEntry (data: { + id: string, + displayName: string, + platformSource: string, + platformId: number; +}) +{ + const state = Route.useSearch(); + const { data: match, isFetching: matchIsFetching } = useQuery({ ...platformLookupMatchQuery(data.platformSource, data.platformId), staleTime: 1000 * 60 * 60 }); + const navigate = useNavigate(); + const handleAction = () => + { + navigate({ to: '/game/add', search: { ...state, platformId: data.platformId, step: 3 }, replace: true }); + oneShot('openGeneric'); + }; + + return +
    {data.displayName}
    +
    + {matchIsFetching ? : match && <> + + {match.match.coverUrl ? : } +
    {match.match.name} - {!!match.match.type && typeIconMap[match.match.type]} {match.match.type}
    + } + + } type={'primary'} />; +} + +function PlatformSelection (data: {}) +{ + const state = Route.useSearch(); + const { data: game, isFetching } = useQuery({ ...gameLookupDetails(state.selectedGame?.source, state.selectedGame?.id), staleTime: 1000 * 60 * 60 }); + if (isFetching) return ; + if (!game) return
    Select A Game
    ; + return
      + {game[0].platforms.map((p, i) => )} +
    ; +} + +function Lookup () +{ + const state = Route.useSearch(); + const [search, setSearch] = useState(state.search); + const navigate = useNavigate(); + const handleSetSelectedGame = (source: string, id: string) => + { + navigate({ to: '/game/add', search: { ...state, selectedGame: { source, id }, platformId: undefined, search, step: 2 }, replace: true }); + oneShot('openGeneric'); + }; + return + { + handleSetSelectedGame(l.source, l.id); + }} />; +} + +const StepDetails = [{ label: "Select Location" }, { label: "Find Match" }, { label: "Select Platform" }, { label: "Confirm" }]; + +function Location () +{ + + const state = Route.useSearch(); + const navigate = useNavigate(); + const handleSetLocation = (location: string | undefined) => + { + if (!location) return; + navigate({ + to: '/game/add', search: { + ...state, + gameLocation: location, + search: BuildSearch(location), + selectedGame: undefined, + platformId: undefined, + step: 1 + }, replace: true + }); + oneShot('openGeneric'); + }; + return
    +
    Select Game Rom
    + +
    + Select The Rom File from your local storage +
    +
    ; +} + +function Details (data: {}) +{ + + const { ref, focusKey } = useFocusable({ focusKey: 'add-game-details-section' }); + const state = Route.useSearch(); + const step = state.step ?? 0; + return
    + + {step === 0 && } + {step === 1 && } + {step === 2 && } + {step === 3 && } + + +
    ; +} + +function getStepDetails (index: number, state: z.infer) +{ + let completed = index < state.step; + if (index === 0 && state.gameLocation) completed = true; + if (index === 1 && state.selectedGame) completed = true; + if (index === 2 && state.platformId) completed = true; + if (index === 3 && state.gameLocation && state.selectedGame && state.platformId) completed = true; + let canNavigate = index <= state.step; + if (index === 1 && state.gameLocation) canNavigate = true; + if (index === 2 && state.selectedGame) canNavigate = true; + if (index === 3 && state.platformId) canNavigate = true; + return { completed, canNavigate }; +} + +function Step (data: { index: number; label: string; }) +{ + const navigate = useNavigate(); + const handleGoToStep = (step: number) => + { + navigate({ to: '/game/add', search: { ...state, step: step }, replace: true }); + oneShot('openGeneric'); + }; + const state = Route.useSearch(); + const step = state.step ?? 0; + const { canNavigate, completed } = getStepDetails(data.index, state); + + const { ref } = useFocusable({ + focusKey: `step-${data.index}`, + focusable: canNavigate, + onFocus: () => + { + if (step === data.index) return; + navigate({ to: '/game/add', search: { ...state, step: data.index }, replace: true }); + oneShot('openGeneric'); + } + }); + return
  • + { + if (!canNavigate) return; + handleGoToStep(data.index); + }} className={twMerge("step not-aria-disabled:cursor-pointer", data.index <= step ? "step-primary" : "")}> + {completed ? : } + {data.label} +
  • ; +} + +function Steps () +{ + const state = Route.useSearch(); + const step = state.step ?? 0; + const { ref, focusKey } = useFocusable({ focusKey: "steps", preferredChildFocusKey: `step-${step}`, saveLastFocusedChild: false }); + return
      + + {StepDetails.map((s, i) => )} + +
    ; +} + +function RouteComponent () +{ + const navigate = useNavigate(); + const state = Route.useSearch(); + const step = state.step ?? 0; + const router = useRouter(); + const queryClient = useQueryClient(); + const isAddingGame = queryClient.isMutating(addManualGameMutation) > 0; + + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'add-game-page', preferredChildFocusKey: 'steps' }); + + const handleReturnStep = (e: Event) => + { + if (step <= 0) + { + HandleGoBack(router, e); + } else + { + const newStep = step - 1; + navigate({ to: '/game/add', search: { ...state, step: newStep }, replace: true }); + } + }; + + const handleStepNavigation = (newStep: number) => + { + if (step === newStep) return; + const { canNavigate } = getStepDetails(newStep, state); + if (!canNavigate) return; + navigate({ to: '/game/add', search: { ...state, step: newStep }, replace: true }); + oneShot('openGeneric'); + }; + + useShortcuts(focusKey, () => [ + { button: GamePadButtonCode.B, label: step === 0 ? "Cancel" : "Prev Step", action: handleReturnStep }, + { button: GamePadButtonCode.Y, label: "Cancel", action: e => HandleGoBack(router, e) }, + { + button: GamePadButtonCode.L1, label: "Prev Step", action (e) + { + handleStepNavigation(Math.max(step - 1, 0)); + }, + }, + { + button: GamePadButtonCode.R1, label: "Next Step", action (e) + { + handleStepNavigation(Math.min(step + 1, 3)); + }, + } + ], [step]); + + return
    + +
    + +
    + +
    +
    + +
    +
    + + {isAddingGame && +
    + +
    Adding Game
    +
    +
    } +
    ; +} diff --git a/src/mainview/routes/game/update.$source.$id.tsx b/src/mainview/routes/game/update.$source.$id.tsx new file mode 100644 index 0000000..1275a8d --- /dev/null +++ b/src/mainview/routes/game/update.$source.$id.tsx @@ -0,0 +1,61 @@ +import { AnimatedBackground } from '@/mainview/components/AnimatedBackground'; +import { AutoFocus } from '@/mainview/components/AutoFocus'; +import GameLookup from '@/mainview/components/game/GameLookup'; +import { StickyHeaderUI } from '@/mainview/components/Header'; +import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; +import { customUpdateMutation, gameInvalidationQuery, gameQuery } from '@/mainview/scripts/queries/romm'; +import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { HandleGoBack } from '@/mainview/scripts/utils'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; +import { useEffect, useState } from 'react'; +import toast from 'react-hot-toast'; + +export const Route = createFileRoute('/game/update/$source/$id')({ + component: RouteComponent, +}); + +function RouteComponent () +{ + const { source, id } = Route.useParams(); + const [search, setSearch] = useState(undefined); + const navigate = useNavigate(); + + const router = useRouter(); + const { data: game } = useQuery(gameQuery(source, id)); + const update = useMutation({ + ...customUpdateMutation, + async onSuccess (data, variables, onMutateResult, context) + { + toast.success("Updated Metadata"); + await context.client.invalidateQueries(gameInvalidationQuery(source, id)); + router.history.back(); + }, + }); + + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: `custom-update-page`, preferredChildFocusKey: 'search-field-section' }); + + useShortcuts(focusKey, () => [{ button: GamePadButtonCode.B, label: "Return", action (e) { HandleGoBack(router, e); }, }]); + useEffect(() => + { + if (search) return; + setSearch(game?.name ?? undefined); + }, [game]); + + return + +
    + + + update.mutate({ source, id, destination: l.source, destinationId: l.id })} + /> + + +
    +
    +
    ; +} diff --git a/src/mainview/routes/games.tsx b/src/mainview/routes/games.tsx index 3742e83..e6fd86e 100644 --- a/src/mainview/routes/games.tsx +++ b/src/mainview/routes/games.tsx @@ -1,12 +1,13 @@ -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { CollectionsDetail } from '../components/CollectionsDetail'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; import { GameListFilterType } from '@/shared/constants'; import { useSessionStorage } from 'usehooks-ts'; import HeaderSearchField from '../components/HeaderSearchField'; -import { useEffect, useState } from 'react'; -import { setFocus } from '@noriginmedia/norigin-spatial-navigation'; +import { useEffect } from 'react'; +import { RoundButton } from '../components/RoundButton'; +import { Plus } from 'lucide-react'; export const Route = createFileRoute('/games')({ component: RouteComponent, @@ -21,6 +22,7 @@ function RouteComponent () const { focus } = Route.useSearch(); const { search } = Route.useSearch(); const [filter, setFilter] = useSessionStorage('all-games-filters', {}); + const navigate = useNavigate(); useEffect(() => { @@ -28,7 +30,13 @@ function RouteComponent () }, [search]); return setFilter({ ...filter, search: v })} search={filter.search} id='search-filter' />} + headerButtonElements={ + [ + { + navigate({ to: '/game/add' }); + }} >, + setFilter({ ...filter, search: v })} search={filter.search} id='search-filter' />] + } localFilter={filter} setLocalFilter={setFilter} focus={focus} diff --git a/src/mainview/routes/platform.$source.$id.tsx b/src/mainview/routes/platform.$source.$id.tsx index e9feb92..73d0265 100644 --- a/src/mainview/routes/platform.$source.$id.tsx +++ b/src/mainview/routes/platform.$source.$id.tsx @@ -1,7 +1,7 @@ import { createFileRoute, useRouter } from "@tanstack/react-router"; import { CollectionsDetail } from "../components/CollectionsDetail"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { GameListFilterSchema, GameListFilterType, RPC_URL } from "../../shared/constants"; +import { GameListFilterType, RPC_URL } from "../../shared/constants"; import { deletePlatformMutation, localPlatformFilter, platformQuery, updatePlatformMutation } from "@queries/romm"; import { zodValidator } from "@tanstack/zod-adapter"; import z from "zod"; @@ -22,7 +22,7 @@ function PlatformTitle (data: {}) const { source, id } = Route.useParams(); const { data: platform } = useQuery(platformQuery(source, id)); - return
    + return
    {!!platform && } @@ -36,9 +36,10 @@ function RouteComponent () const { source, id } = Route.useParams(); const router = useRouter(); const { countHint } = Route.useSearch(); + const { data: platform } = useQuery(platformQuery(source, id)); const [filter, setFilter] = useLocalStorage("platforms-filters", {}); const updatePlatform = useMutation({ - ...updatePlatformMutation(id), onSuccess (data, variables, onMutateResult, context) + ...updatePlatformMutation(source, id), onSuccess (data, variables, onMutateResult, context) { context.client.invalidateQueries(localPlatformFilter(id)); }, @@ -56,7 +57,7 @@ function RouteComponent () }, }); const settingsOptions: DialogEntry[] = []; - if (source === 'local') + if (source === 'local' || platform?.hasLocal) { settingsOptions.push({ id: 'update-platform', @@ -70,7 +71,10 @@ function RouteComponent () router.navigate({ replace: true }); }, }); + } + if (source === 'local') + { settingsOptions.push({ id: 'update-platform', type: "error", @@ -97,7 +101,7 @@ function RouteComponent () icon: , action () { - setPlatformSettingsOpen(true); + setPlatformSettingsOpen(true, 'open-platform-settings-btn'); }, }]} countHint={countHint} diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index 363ef45..f0e9360 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -4,7 +4,7 @@ import { OptionInput } from '../../components/options/OptionInput'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useCallback, useEffect, useState } from 'react'; import { Button } from '../../components/options/Button'; -import { Check, ChevronDown, FileQuestion, FolderSearch, HardDrive, Plug, SearchAlert, Store, Trash } from 'lucide-react'; +import { Check, ChevronDown, FolderSearch, HardDrive, Plug, SearchAlert, Store, Trash } from 'lucide-react'; import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog'; import classNames from 'classnames'; import { twMerge } from 'tailwind-merge'; @@ -80,7 +80,10 @@ function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAdd }; - return + return +
    Custom Emulator Path
    +
    Manually Pick a path to an emulator if not automatically found.
    +
    }> +
    } + {lookups?.matches.map((l, i) => { - data.onSelect(l); - }} />; - })} + return + { + data.onSelect(l); + }} />; + })} + + }
    ; } \ No newline at end of file diff --git a/src/mainview/components/options/Button.tsx b/src/mainview/components/options/Button.tsx index 7970688..de07bdf 100644 --- a/src/mainview/components/options/Button.tsx +++ b/src/mainview/components/options/Button.tsx @@ -27,6 +27,7 @@ export function Button (data: { children?: any, className?: string, disabled?: boolean, + external?: boolean, type?: "reset" | "button" | "submit"; style?: ButtonStyle, shortcutLabel?: string; @@ -65,6 +66,7 @@ export function Button (data: { focused ? data.focusClassName : undefined, classNames({ "btn-accent": focused, + "focusable focusable-primary focusable-hover": data.external }, data.className))} type={data.type ?? 'button'} > diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index 54caee8..801e639 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -121,7 +121,7 @@ export function OptionInput (data: { step={data.step} data-focus={"input"} name={data.name} - value={String(data.value)} + value={data.value === undefined ? undefined : String(data.value)} defaultValue={typeof data.defaultValue === 'string' ? data.defaultValue : undefined} type={data.type} autoComplete={data.autocomplete} diff --git a/src/mainview/routes/game/add.tsx b/src/mainview/routes/game/add.tsx index bf5f481..3a6a2f8 100644 --- a/src/mainview/routes/game/add.tsx +++ b/src/mainview/routes/game/add.tsx @@ -5,6 +5,7 @@ import { StickyHeaderUI } from '@/mainview/components/Header'; import LoadingScreen from '@/mainview/components/LoadingScreen'; import { Button } from '@/mainview/components/options/Button'; import { PathSettingsOptionBase } from '@/mainview/components/options/PathSettingsOption'; +import SelectMenu from '@/mainview/components/SelectMenu'; import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; import { oneShot } from '@/mainview/scripts/audio/audio'; import { addManualGameMutation, allGamesInvalidateQuery, gameLookupDetails, platformLookupMatchQuery } from '@/mainview/scripts/queries/romm'; @@ -252,7 +253,6 @@ function Location () function Details (data: {}) { - const { ref, focusKey } = useFocusable({ focusKey: 'add-game-details-section' }); const state = Route.useSearch(); const step = state.step ?? 0; @@ -318,11 +318,13 @@ function Steps () const state = Route.useSearch(); const step = state.step ?? 0; const { ref, focusKey } = useFocusable({ focusKey: "steps", preferredChildFocusKey: `step-${step}`, saveLastFocusedChild: false }); - return
      - - {StepDetails.map((s, i) => )} - -
    ; + return
    + +
      + + {StepDetails.map((s, i) => )} + +
    ; } function RouteComponent () @@ -374,23 +376,23 @@ function RouteComponent () } ], [step]); - return
    + return
    -
    - -
    +
    + + + {isAddingGame && +
    + +
    Adding Game
    +
    +
    } +
    - - {isAddingGame && -
    - -
    Adding Game
    -
    -
    }
    ; } diff --git a/src/mainview/routes/game/update.$source.$id.tsx b/src/mainview/routes/game/update.$source.$id.tsx index fb9ffba..867112a 100644 --- a/src/mainview/routes/game/update.$source.$id.tsx +++ b/src/mainview/routes/game/update.$source.$id.tsx @@ -1,7 +1,7 @@ import { AnimatedBackground } from '@/mainview/components/AnimatedBackground'; import { AutoFocus } from '@/mainview/components/AutoFocus'; import GameLookupElement from '@/mainview/components/game/GameLookup'; -import { StickyHeaderUI } from '@/mainview/components/Header'; +import { HeaderUI, StickyHeaderUI } from '@/mainview/components/Header'; import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; import { customUpdateMutation, gameInvalidationQuery, gameQuery } from '@/mainview/scripts/queries/romm'; import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; @@ -9,7 +9,7 @@ import { HandleGoBack } from '@/mainview/scripts/utils'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { useMutation, useQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import toast from 'react-hot-toast'; export const Route = createFileRoute('/game/update/$source/$id')({ @@ -44,8 +44,9 @@ function RouteComponent () return +
    - + void, url: string; endsAt: Date; startedAt: Date; code?: string; }) @@ -221,6 +225,8 @@ function RouteComponent () } type="text" />} /> } type="password" placeholder="Password" />} /> + +
    For Romm Client API Token open plugin settings
    } /> diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index 0283ac3..fd83578 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -26,8 +26,6 @@ import } from "lucide-react"; import { JSX, useMemo } from "react"; import { twMerge } from "tailwind-merge"; -import z from "zod"; -import { SettingsSchema } from "../../../shared/constants"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import Shortcuts from "@/mainview/components/Shortcuts"; import { HandleGoBack } from "@/mainview/scripts/utils"; @@ -37,9 +35,6 @@ import SelectMenu from "@/mainview/components/SelectMenu"; export const Route = createFileRoute("/settings")({ component: SettingsUI, - validateSearch: z.object({ - focus: z.keyof(SettingsSchema).optional() - }), staticData: { enterSound: 'openSettings' } diff --git a/src/mainview/scripts/gamepads.ts b/src/mainview/scripts/gamepads.ts index 08321a3..5959d90 100644 --- a/src/mainview/scripts/gamepads.ts +++ b/src/mainview/scripts/gamepads.ts @@ -1,7 +1,7 @@ import { getCurrentFocusKey, navigateByDirection } from "@noriginmedia/norigin-spatial-navigation"; import { GetFocusedElement } from "./spatialNavigation"; import { useEffect, useState } from "react"; -import { getLocalSetting, mobileCheck } from "./utils"; +import { getLocalSetting, isTextInputFocused, mobileCheck } from "./utils"; import { oneShot } from "./audio/audio"; import { Router } from "@/mainview"; @@ -98,7 +98,7 @@ const throttleMap = new Map(); const throttleAcceleration = new Map(); function throttleNav (key: string, dir: string, event: Event) { - if (document.activeElement && document.activeElement instanceof HTMLInputElement) + if (isTextInputFocused()) { return false; } diff --git a/src/mainview/scripts/shortcuts.ts b/src/mainview/scripts/shortcuts.ts index 35316b7..c947d23 100644 --- a/src/mainview/scripts/shortcuts.ts +++ b/src/mainview/scripts/shortcuts.ts @@ -2,6 +2,7 @@ import { DependencyList, useEffect, useState } from "react"; import { GamepadButtonEvent } from "./gamepads"; import { dispatchFocusedEvent, GetFocusedTree } from "./spatialNavigation"; import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; +import { isTextInputFocused } from "./utils"; const shortcutMap = new Map Shortcut[])[]>(); const conflictSet = new Set(); @@ -123,12 +124,21 @@ export function useShortcutContext () if (e.key === 'Escape') { shortcuts.get(GamePadButtonCode.B)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.B })); - } else if (e.key === 'Backspace') + } else { - shortcuts.get(GamePadButtonCode.X)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.X })); - } else if (e.key === ' ') - { - shortcuts.get(GamePadButtonCode.Y)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.Y })); + // We use backspace and space in typing + if (isTextInputFocused()) + { + return false; + } + + if (e.key === 'Backspace') + { + shortcuts.get(GamePadButtonCode.X)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.X })); + } else if (e.key === ' ') + { + shortcuts.get(GamePadButtonCode.Y)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.Y })); + } } }; diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index 7f4bf68..4859ddf 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -13,6 +13,12 @@ export type ScrollSaveParams = { storage?: "session" | "local"; shouldSave?: boolean; }; + +export function isTextInputFocused () +{ + return document.activeElement && document.activeElement instanceof HTMLInputElement; +} + export function useScrollSave (data: ScrollSaveParams) { useEffect(() => From c9cf0b827c71567bbef2d9d399bb06e3d254fa61 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Tue, 5 May 2026 23:01:46 +0300 Subject: [PATCH 57/65] doc: Added icon in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dfc57b3..d806d89 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Gameflow Deck +# Gameflow Deck A Cross-Platform open source Retro gaming frontend designed for handheld and controllers. Focused on building a simple user experience and intuitive UI as a curated community driven experience. From 11c4a802e427856e8ae6ad8a43ccbc2e130a7d15 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Tue, 5 May 2026 23:04:57 +0300 Subject: [PATCH 58/65] chore(release): 1.5.0 --- CHANGELOG.md | 12 ++++++++++++ package.json | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccf01db..8334db8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ 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.5.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.4.0...v1.5.0) (2026-05-05) + + +### Features + +* Implemented local game import (with a wizard) ([06b7e40](https://github.com/simeonradivoev/gameflow-deck/commit/06b7e4074da23afdec3b2ff97f84a9e1486944d2)) + + +### Bug Fixes + +* Navigation blocking now working with focuesed input fields ([4da717c](https://github.com/simeonradivoev/gameflow-deck/commit/4da717c26d9840febd48ee87a6a493a3e1acc6b9)) + ## [1.4.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.3.0...v1.4.0) (2026-04-26) diff --git a/package.json b/package.json index f804746..0f246cb 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "email": "work@simeonradivoev.com", "url": "https://simeonradivoev.com" }, - "version": "1.4.0", + "version": "1.5.0", "description": "Game Launcher", "icon": "./src/mainview/assets/icon.svg", "main": "./src/bun/index.ts", @@ -151,4 +151,4 @@ "vite-tsconfig-paths": "^6.1.1", "zod-to-ts": "^2.0.0" } -} \ No newline at end of file +} From f82bf1215ad8023bedf8e1d05532a90443eb5e48 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Thu, 7 May 2026 04:08:17 +0300 Subject: [PATCH 59/65] doc: Added discord link --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d806d89..0e5864e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Gameflow Deck +# Gameflow Deck A Cross-Platform open source Retro gaming frontend designed for handheld and controllers. Focused on building a simple user experience and intuitive UI as a curated community driven experience. @@ -7,6 +7,12 @@ Focused on building a simple user experience and intuitive UI as a curated commu > This app is actively in development, it is constantly changing and improving. > It will have an opinionated design and will be used as an experiment in discovering a good UX. +## Community + +Join us on Discord, where you can ask questions, submit ideas and get help. + +[![](https://invidget.switchblade.xyz/R9KakhY67d)](https://discord.gg/R9KakhY67d) + ## Features ### Integrations From 9051834aceb1dce56e85cfb78a3a5f8455e5846f Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Thu, 7 May 2026 14:43:48 +0300 Subject: [PATCH 60/65] chore: Updated packages --- bun.lock | 436 +++++++++++++++---------------- package.json | 76 +++--- src/bun/api/jobs/update-store.ts | 36 +-- 3 files changed, 266 insertions(+), 282 deletions(-) diff --git a/bun.lock b/bun.lock index 3568727..5e7c073 100644 --- a/bun.lock +++ b/bun.lock @@ -108,19 +108,19 @@ "packages": { "7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="], - "@ap0nia/eden": ["@ap0nia/eden@1.0.0-next.22", "", { "peerDependencies": { "elysia": "^1.3.1" } }, "sha512-9iH09koK29Yuem80fz8nCt9iHVcJqxUo2QHAr4psI02PhvL70n6aWVo/hlHyYXwOSsSgRQlLl1vPmiulFOUFoA=="], + "@ap0nia/eden": ["@ap0nia/eden@1.6.1", "", { "dependencies": { "elysia": "1.2.15" } }, "sha512-jlsUyh4PsYNnMcPuQ3IJq0hhDNnyRNGYx+MSAJlcgKs4En9qrokLorSbTRvVjA1Mdx4VdzEADcPn99Kbph0SOw=="], "@ap0nia/eden-tanstack-query": ["@ap0nia/eden-tanstack-query@1.0.0-next.22", "", {}, "sha512-eSQ98G4TYzrAdsfRekrvqIrTqrAUFy+YpibZ5fj5KL6/R6FcrS2U2F51iML98baXT4MTpOJARY9p+7x0hiA8Qw=="], "@auth/core": ["@auth/core@0.34.3", "", { "dependencies": { "@panva/hkdf": "^1.1.1", "@types/cookie": "0.6.0", "cookie": "0.6.0", "jose": "^5.1.3", "oauth4webapi": "^2.10.4", "preact": "10.11.3", "preact-render-to-string": "5.2.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw=="], - "@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="], - "@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - "@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="], + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], @@ -140,7 +140,7 @@ "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], - "@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], @@ -162,9 +162,9 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], - "@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="], + "@elysiajs/cors": ["@elysiajs/cors@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-FTCcbH35brTLigF1W7BYySRZomgI/dBEMK9BgK9RP9Nez7zmpGh4koL/Yr1BFv8nYz7CfhRvcM8d/c+XnwMaVQ=="], - "@elysiajs/eden": ["@elysiajs/eden@1.4.6", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-Tsa4NwXEWg/u73vWiYZQ3L5/ecgZSxqiEjYwpS+4qBKXeTZqZKl2hcgHJSVBL+InEDMi35Xugct7qyAXE5oM4Q=="], + "@elysiajs/eden": ["@elysiajs/eden@1.4.9", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-3CKVD4ycVjB8nCNssfmhnUuq3SzSHkUES3v5PNCFr9LxIrx39/HVRAZ8z2sLxrFqzUs48dCBZaxoZzJ5UUVHDA=="], "@emulatorjs/core-81": ["@emulatorjs/core-81@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-oPQEqjpR3z7Yedte4u3sOXDZ4NXAykNcbENjYcB+x3QshF8I+3MQCo8kINOT2lsqqgx91WR4kmEaYQqU39YsDA=="], @@ -324,13 +324,13 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "@hey-api/codegen-core": ["@hey-api/codegen-core@0.6.0", "", { "dependencies": { "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-O6TRP3g1188gdpZC9yio64KkvWD97QpLjaCFnwTgykTZtzQXZbkyw+lYPVfZ7Ee+m7eLau1/jEJmQcUY9G0sLw=="], + "@hey-api/codegen-core": ["@hey-api/codegen-core@0.6.1", "", { "dependencies": { "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-khTIpxhKEAqmRmeLUnAFJQs4Sbg9RPokovJk9rRcC8B5MWH1j3/BRSqfpAIiJUBDU1+nbVg2RVCV+eQ174cdvw=="], "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.2.3", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.1", "lodash": "^4.17.21" } }, "sha512-gRbyyTjzpFVNmbD+Upn3w4dWV+bCXGJbff3A7leDO/tfNxSz1xIb6Ad/5UKtvEW9kDt/2Uyc3XkFZ6rpafvbfQ=="], - "@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.91.0", "", { "dependencies": { "@hey-api/codegen-core": "0.6.0", "@hey-api/json-schema-ref-parser": "1.2.3", "@hey-api/shared": "0.1.0", "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "color-support": "1.1.3", "commander": "14.0.2" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-AHkd982HsPz1XpqRm59URwJyJqTzyzzC30EAp07b/0M9KojjneCPxm8FnvFnXLRTMyKgcOymMsYXuLzJ9mpMHA=="], + "@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.91.1", "", { "dependencies": { "@hey-api/codegen-core": "0.6.1", "@hey-api/json-schema-ref-parser": "1.2.3", "@hey-api/shared": "0.1.1", "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "color-support": "1.1.3", "commander": "14.0.2" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-d16WR35UtthK/ihAIwJaKxrj/zvb5LbYwtVJCyZFFMin2qzDU8Y3Lpk78ensAykrLoaDLzpd0iIyt9JCP5Qmww=="], - "@hey-api/shared": ["@hey-api/shared@0.1.0", "", { "dependencies": { "@hey-api/codegen-core": "0.6.0", "@hey-api/json-schema-ref-parser": "1.2.3", "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "cross-spawn": "7.0.6", "open": "11.0.0", "semver": "7.7.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-qEDMSBWEEWxcBU5XHacjCCnFOVq1YWPPR3owURVep60I7ejfSG5OINxM4eF+p3KJGMcZduzzfq9pd1grStHZBg=="], + "@hey-api/shared": ["@hey-api/shared@0.1.1", "", { "dependencies": { "@hey-api/codegen-core": "0.6.1", "@hey-api/json-schema-ref-parser": "1.2.3", "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "cross-spawn": "7.0.6", "open": "11.0.0", "semver": "7.7.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-/irgNGXw9TL5aKB3S7jCLgh07vgDFkYjSjz7vEWO9xEe6MUhx76zSFzkPspk2UrLghYayvmaKPf1ky4XjNI9ZQ=="], "@hey-api/types": ["@hey-api/types@0.1.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-mZaiPOWH761yD4GjDQvtjS2ZYLu5o5pI1TVSvV/u7cmbybv51/FVtinFBeaE1kFQCKZ8OQpn2ezjLBJrKsGATw=="], @@ -342,63 +342,63 @@ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], - "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], + "@jimp/core": ["@jimp/core@1.6.1", "", { "dependencies": { "@jimp/file-ops": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^21.3.3", "mime": "3" } }, "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A=="], - "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], + "@jimp/diff": ["@jimp/diff@1.6.1", "", { "dependencies": { "@jimp/plugin-resize": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "pixelmatch": "^5.3.0" } }, "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ=="], - "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], + "@jimp/file-ops": ["@jimp/file-ops@1.6.1", "", {}, "sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w=="], - "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], + "@jimp/js-bmp": ["@jimp/js-bmp@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "bmp-ts": "^1.0.9" } }, "sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ=="], - "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], + "@jimp/js-gif": ["@jimp/js-gif@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w=="], - "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], + "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", "jpeg-js": "^0.4.4" } }, "sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg=="], - "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], + "@jimp/js-png": ["@jimp/js-png@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", "pngjs": "^7.0.0" } }, "sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA=="], - "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], + "@jimp/js-tiff": ["@jimp/js-tiff@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", "utif2": "^4.1.0" } }, "sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw=="], - "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="], + "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.1", "", { "dependencies": { "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "zod": "^3.23.8" } }, "sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ=="], - "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="], + "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/utils": "1.6.1" } }, "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ=="], - "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="], + "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.1", "", { "dependencies": { "@jimp/types": "1.6.1", "zod": "^3.23.8" } }, "sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ=="], - "@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="], + "@jimp/plugin-color": ["@jimp/plugin-color@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A=="], - "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="], + "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/plugin-blit": "1.6.1", "@jimp/plugin-resize": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "zod": "^3.23.8" } }, "sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg=="], - "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="], + "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/plugin-crop": "1.6.1", "@jimp/plugin-resize": "1.6.1", "@jimp/types": "1.6.1", "zod": "^3.23.8" } }, "sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg=="], - "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="], + "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "zod": "^3.23.8" } }, "sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg=="], - "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="], + "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.1", "", { "dependencies": { "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "zod": "^3.23.8" } }, "sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g=="], - "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="], + "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.1", "", { "dependencies": { "@jimp/types": "1.6.1" } }, "sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew=="], - "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="], + "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.1", "", { "dependencies": { "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "zod": "^3.23.8" } }, "sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA=="], - "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="], + "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.1", "", { "dependencies": { "@jimp/types": "1.6.1", "zod": "^3.23.8" } }, "sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA=="], - "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="], + "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/js-bmp": "1.6.1", "@jimp/js-jpeg": "1.6.1", "@jimp/js-png": "1.6.1", "@jimp/js-tiff": "1.6.1", "@jimp/plugin-color": "1.6.1", "@jimp/plugin-resize": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "any-base": "^1.1.0" } }, "sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A=="], - "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="], + "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.1", "", { "dependencies": { "@jimp/types": "1.6.1", "zod": "^3.23.8" } }, "sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A=="], - "@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="], + "@jimp/plugin-print": ["@jimp/plugin-print@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/js-jpeg": "1.6.1", "@jimp/js-png": "1.6.1", "@jimp/plugin-blit": "1.6.1", "@jimp/types": "1.6.1", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w=="], - "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="], + "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.1", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww=="], - "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], + "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", "zod": "^3.23.8" } }, "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg=="], - "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="], + "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/plugin-crop": "1.6.1", "@jimp/plugin-resize": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "zod": "^3.23.8" } }, "sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg=="], - "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="], + "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/plugin-color": "1.6.1", "@jimp/plugin-hash": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "zod": "^3.23.8" } }, "sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q=="], - "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], + "@jimp/types": ["@jimp/types@1.6.1", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w=="], - "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + "@jimp/utils": ["@jimp/utils@1.6.1", "", { "dependencies": { "@jimp/types": "1.6.1", "tinycolor2": "^1.6.0" } }, "sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw=="], - "@jimp/wasm-webp": ["@jimp/wasm-webp@1.6.0", "", { "dependencies": { "@jsquash/webp": "^1.4.0", "zod": "^3.23.8" } }, "sha512-P0zUpK6n2XIAn8bt0F6rhSn1+FgteBTrL+TBb6Oqw8v5qEDJoNYkd6LlfZYN8YwtRBTBdZ8GFnWsg2Sar+qOkA=="], + "@jimp/wasm-webp": ["@jimp/wasm-webp@1.6.1", "", { "dependencies": { "@jsquash/webp": "^1.4.0", "zod": "^3.23.8" } }, "sha512-t+Wqkde4xQHP/UZ4bDiDo3pbhFz32E7FvQCUkuFdJDmEDl6gPCs6LQiQVBmumUQYTeVLiLtLzlM9j8s7yF0sXQ=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -416,6 +416,8 @@ "@jsquash/webp": ["@jsquash/webp@1.5.0", "", { "dependencies": { "wasm-feature-detect": "^1.2.11" } }, "sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw=="], + "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], + "@node-minify/clean-css": ["@node-minify/clean-css@9.0.1", "", { "dependencies": { "@node-minify/utils": "9.0.1", "clean-css": "5.3.3" } }, "sha512-GHTMmjGloRvNzqdG7foI0iZeS2QmuYCQvdASJP9sCKjkpH45bygODpXPYKnlzUEpQgYvPK9Q3GxqYnVY9SdoqA=="], "@node-minify/core": ["@node-minify/core@9.0.2", "", { "dependencies": { "@node-minify/utils": "9.0.1", "glob": "10.3.3", "mkdirp": "3.0.1" } }, "sha512-FNhv29Wom6wKrrFKaeAfmZqz7TX5A1E6P+bpd0VIc+DYWMLUIhAViS8riaZg3A1oD0s06s+5BG2Fg7RqMKiKHw=="], @@ -424,12 +426,6 @@ "@node-minify/utils": ["@node-minify/utils@9.0.1", "", { "dependencies": { "gzip-size": "6.0.0" } }, "sha512-aC1+mhKTP3IMa2VcuGl3ui92LO/7CPQWldNGzu3BVGKiMNJ70AKJW/R6huuYCSuQyHDGM9oFwiVClsZnFxn67g=="], - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], - - "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], - - "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@noriginmedia/norigin-spatial-navigation": ["@noriginmedia/norigin-spatial-navigation@3.1.0", "", { "dependencies": { "@noriginmedia/norigin-spatial-navigation-core": "^3.1.0", "@noriginmedia/norigin-spatial-navigation-react": "^3.1.0" } }, "sha512-KPge4ocpDFde7cpZ2aqrPrKmxOxkue983NsfpmE/vX4k2l+Ik8UkucCWGqkcy81TXkEyRhdsYwFTRePNB5qUCg=="], "@noriginmedia/norigin-spatial-navigation-core": ["@noriginmedia/norigin-spatial-navigation-core@3.1.0", "", { "dependencies": { "lodash-es": "^4.17.21" } }, "sha512-AFxJHurTqy+I3NLnaXsLUBa9FZjUryMNFEdLpPrITSqDjk525aINeLMOK1PN7WTiK5xpHL0pbpw0+uVOfWgp4w=="], @@ -472,7 +468,7 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw=="], @@ -528,88 +524,86 @@ "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], - "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="], "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], - "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.4", "", { "dependencies": { "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw=="], - "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="], + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], - "@tanstack/form-core": ["@tanstack/form-core@1.28.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.7.7" } }, "sha512-MX3YveB6SKHAJ2yUwp+Ca/PCguub8bVEnLcLUbFLwdkSRMkP0lMGdaZl+F0JuEgZw56c6iFoRyfILhS7OQpydA=="], + "@tanstack/form-core": ["@tanstack/form-core@1.29.1", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.9.1" } }, "sha512-NIYPO36eEu7nSWvMpbFDQaBWyVtnH/C8fsZ3/XpJUT4uOWgmxsiUvHGbTbDNIQTXAKIkhwEl0sUrqBNn2SfUnw=="], - "@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="], + "@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], - "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], + "@tanstack/query-core": ["@tanstack/query-core@5.100.9", "", {}, "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ=="], - "@tanstack/query-devtools": ["@tanstack/query-devtools@5.93.0", "", {}, "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg=="], + "@tanstack/query-devtools": ["@tanstack/query-devtools@5.100.9", "", {}, "sha512-gqiptrTIhbK2PuCaPRHmWXfJG1NGYVFpAr0HqogEqiSBNB5xDz6fmesQt7w4WgMOqOQPnPHJ3ZDMuhDaXvNO8g=="], - "@tanstack/react-form": ["@tanstack/react-form@1.28.0", "", { "dependencies": { "@tanstack/form-core": "1.28.0", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ibLcf5QkTogV0Ly944CuqGxWTpHyreNA4Cy8Wtky7zE9wtE3HVapQt4/hUuXo51zihfTkv5URiXpoTSKF5Xosg=="], + "@tanstack/react-form": ["@tanstack/react-form@1.29.1", "", { "dependencies": { "@tanstack/form-core": "1.29.1", "@tanstack/react-store": "^0.9.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-hVHk4g0phd0HxRsv2ry6Xt8BqmalT55Q3cokhJBCC1St0hcGZhgwJJbohm9atao45BPG9e55DGvtbwExqZe35g=="], - "@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="], + "@tanstack/react-query": ["@tanstack/react-query@5.100.9", "", { "dependencies": { "@tanstack/query-core": "5.100.9" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A=="], - "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.3", "", { "dependencies": { "@tanstack/query-devtools": "5.93.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.20", "react": "^18 || ^19" } }, "sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA=="], + "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.100.9", "", { "dependencies": { "@tanstack/query-devtools": "5.100.9" }, "peerDependencies": { "@tanstack/react-query": "^5.100.9", "react": "^18 || ^19" } }, "sha512-mM3slaVGXJmz+pOLgXdANj75ikgQCyudyl3kmFvm6brI1JyVeY/+IeD17uDHIvZrD8hfoO2sdZ54RFsHdYAuhA=="], - "@tanstack/react-router": ["@tanstack/react-router@1.157.16", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.157.16", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-xwFQa7S7dhBhm3aJYwU79cITEYgAKSrcL6wokaROIvl2JyIeazn8jueWqUPJzFjv+QF6Q8euKRlKUEyb5q2ymg=="], + "@tanstack/react-router": ["@tanstack/react-router@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.169.2", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ=="], - "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.154.12", "", { "dependencies": { "@tanstack/router-devtools-core": "1.154.12" }, "peerDependencies": { "@tanstack/react-router": "^1.154.12", "@tanstack/router-core": "^1.154.12", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-TcGe7pmeVjk1zD58eMR87GG9OXMx6LDGz5QopmJS4LafvK2hvuaht+eKBnZlCvKLPlXu5juwHT4u+2bYdn6sqQ=="], + "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.166.13", "", { "dependencies": { "@tanstack/router-devtools-core": "1.167.3" }, "peerDependencies": { "@tanstack/react-router": "^1.168.15", "@tanstack/router-core": "^1.168.11", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-6yKRFFJrEEOiGp5RAAuGCYsl81M4XAhJmLcu9PKj+HZle4A3dsP60lwHoqQYWHMK9nKKFkdXR+D8qxzxqtQbEA=="], - "@tanstack/react-router-ssr-query": ["@tanstack/react-router-ssr-query@1.157.17", "", { "dependencies": { "@tanstack/router-ssr-query-core": "1.157.16" }, "peerDependencies": { "@tanstack/query-core": ">=5.90.0", "@tanstack/react-query": ">=5.90.0", "@tanstack/react-router": ">=1.127.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-IrLi+cNLOAiTShuwGUi9WCNWXMG4993fnyXnWMC53M64rhUngPRFCAQiA05BavE/d7bifC6WKDTt1ZhC1Pewaw=="], + "@tanstack/react-router-ssr-query": ["@tanstack/react-router-ssr-query@1.166.12", "", { "dependencies": { "@tanstack/router-ssr-query-core": "1.168.0" }, "peerDependencies": { "@tanstack/query-core": ">=5.90.0", "@tanstack/react-query": ">=5.90.0", "@tanstack/react-router": ">=1.127.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-yDUIoEh+PimAcWmk/2BE0EkI8TwLVeToNzoIuwahmTtBUR+ptZPWbtiPjudO8JZ0BhT3odHtuOn1eBOK0/4NAQ=="], - "@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="], + "@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], - "@tanstack/router-core": ["@tanstack/router-core@1.157.16", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-eJuVgM7KZYTTr4uPorbUzUflmljMVcaX2g6VvhITLnHmg9SBx9RAgtQ1HmT+72mzyIbRSlQ1q0fY/m+of/fosA=="], + "@tanstack/router-core": ["@tanstack/router-core@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw=="], - "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.154.12", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.154.12", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-lvnP9cqknvSSkUjqQRVn61TcBhq72hCFFOzMwdFdFPTO8nMEXvYE6ZZJiXtivwcvsKmO6XVFLMXuJr/928gNkw=="], + "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.167.3", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.168.11", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg=="], - "@tanstack/router-generator": ["@tanstack/router-generator@1.157.16", "", { "dependencies": { "@tanstack/router-core": "1.157.16", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-Ae2M00VTFjjED7glSCi/mMLENRzhEym6NgjoOx7UVNbCC/rLU/5ASDe5VIlDa8QLEqP5Pj088Gi51gjmRuICvQ=="], + "@tanstack/router-generator": ["@tanstack/router-generator@1.166.42", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.169.2", "@tanstack/router-utils": "1.161.8", "@tanstack/virtual-file-routes": "1.161.7", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^3.24.2" } }, "sha512-2qBWC0t78r6b3vI+AbnvCZcFAvbYBDlLuWZrTjQbcjUmwG3qyeQp983tJyDuj9wb5//adG1tgAGXZkJ3aDwdBg=="], - "@tanstack/router-plugin": ["@tanstack/router-plugin@1.157.16", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.157.16", "@tanstack/router-generator": "1.157.16", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.157.16", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-YQg7L06xyCJAYyrEJNZGAnDL8oChILU+G/eSDIwEfcWn5iLk+47x1Gcdxr82++47PWmOPhzuTo8edDQXWs7kAA=="], + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.35", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.169.2", "@tanstack/router-generator": "1.166.42", "@tanstack/router-utils": "1.161.8", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^3.0.0", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.169.2", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-UAScU5VAzLYVY4FML/Cbc5S5TucT4I8Ata05yozGOe4ZfepTKRffA5xWLtD2N+ov5svdv0KTX/kqlZnYPe28mA=="], - "@tanstack/router-ssr-query-core": ["@tanstack/router-ssr-query-core@1.157.16", "", { "peerDependencies": { "@tanstack/query-core": ">=5.90.0", "@tanstack/router-core": ">=1.127.0" } }, "sha512-YuwNG4jdtn+r90yyti8yP27IKaVoflWmRezqnj0gyJxpRauBkK7MVLvWSNbJadnk88b9H+rdtNOF2k3owGaong=="], + "@tanstack/router-ssr-query-core": ["@tanstack/router-ssr-query-core@1.168.0", "", { "peerDependencies": { "@tanstack/query-core": ">=5.90.0", "@tanstack/router-core": ">=1.127.0" } }, "sha512-5yBUAF1d9z2kOFKoz1spvpvkMSTmRnRXEwi+bGKfrXYmt7CfHu3Pk8KUFMln67uQoKQ9VTkcd5tLkjJVrZ2/AQ=="], - "@tanstack/router-utils": ["@tanstack/router-utils@1.154.7", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-61bGx32tMKuEpVRseu2sh1KQe8CfB7793Mch/kyQt0EP3tD7X0sXmimCl3truRiDGUtI0CaSoQV1NPjAII1RBA=="], + "@tanstack/router-utils": ["@tanstack/router-utils@1.161.8", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-xyiLWEKjfBAVhauDSSjXxyf7s8elU6SM+V050sbkofvGmIIvkwPFtDsX7Gvwh14kBd6iCwAT+RiPvXTxAptY0Q=="], - "@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="], + "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], - "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.154.7", "", {}, "sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg=="], + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="], - "@tanstack/zod-adapter": ["@tanstack/zod-adapter@1.162.4", "", { "peerDependencies": { "@tanstack/react-router": ">=1.43.2", "zod": "^3.23.8" } }, "sha512-sO4n2o9F7gZKHZb/nW/fMcDaeVcbFZ2a7zCA+GkaHJwRmhKKlQQ0dae9pc8wOMMG+QkfH1Wysq+tg2RNvm/kpg=="], + "@tanstack/zod-adapter": ["@tanstack/zod-adapter@1.166.9", "", { "peerDependencies": { "@tanstack/react-router": ">=1.43.2", "zod": "^3.23.8" } }, "sha512-HHllQ/CKGi8YBbftv6OmzojtHM6Rk4UszAFICAgUMbwiqtKqjlIZQ/7mv2IPNxBb8YlOQgzyQ4jz2UTEXIi6YA=="], "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], - "@trysound/sax": ["@trysound/sax@0.2.0", "", {}, "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="], - "@types/adm-zip": ["@types/adm-zip@0.5.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q=="], "@types/audiosprite": ["@types/audiosprite@0.7.3", "", {}, "sha512-P4rUuHPt2kWPMqyObfh1SfqS2H/ZuTxByh00ecuI2tOdvP5b8NznuBeQgemDXV9v8b4pewFPB9G3BuYRONqD7A=="], @@ -622,7 +616,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], @@ -662,7 +656,7 @@ "@types/rclone.js": ["@types/rclone.js@0.6.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-BssKAAVRY//fxGKso8SatyOwiD7X0toDofNnVxZlIXmN7UHrn2UBTxldNAjgUvWA91qJyeEPfKmeJpZVhLugXg=="], - "@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -672,19 +666,17 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], "JSONStream": ["JSONStream@1.3.5", "", { "dependencies": { "jsonparse": "^1.2.0", "through": ">=2.2.7 <3" }, "bin": { "JSONStream": "./bin.js" } }, "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ=="], - "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], - "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "add-stream": ["add-stream@1.0.0", "", {}, "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ=="], - "adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="], + "adm-zip": ["adm-zip@0.5.17", "", {}, "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ=="], "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], @@ -712,8 +704,6 @@ "arrify": ["arrify@1.0.1", "", {}, "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA=="], - "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], - "async": ["async@0.9.2", "", {}, "sha512-l6ToIJIotphWahxxHyzK9bnLR6kM4jJIIgLShZeqLY7iboHoGkdgFl7W2/Ivi4SkMJYGKqW8vSuk0uKUj6qsSw=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], @@ -734,8 +724,6 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ=="], @@ -756,13 +744,11 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], - "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], - "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], - "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -832,7 +818,7 @@ "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="], - "conf": ["conf@15.0.2", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-JBSrutapCafTrddF9dH3lc7+T2tBycGF4uPkI4Js+g4vLLEhG6RZcFi3aJd5zntdf5tQxAejJt8dihkoQ/eSJw=="], + "conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="], "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], @@ -876,7 +862,7 @@ "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], - "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], + "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], @@ -890,7 +876,7 @@ "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], - "css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="], + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], @@ -902,7 +888,7 @@ "cycle": ["cycle@1.0.3", "", {}, "sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA=="], - "daisyui": ["daisyui@5.5.14", "", {}, "sha512-L47rvw7I7hK68TA97VB8Ee0woHew+/ohR6Lx6Ah/krfISOqcG4My7poNpX5Mo5/ytMxiR40fEaz6njzDi7cuSg=="], + "daisyui": ["daisyui@5.5.19", "", {}, "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA=="], "dargs": ["dargs@7.0.0", "", {}, "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg=="], @@ -958,9 +944,9 @@ "dotgitignore": ["dotgitignore@2.1.0", "", { "dependencies": { "find-up": "^3.0.0", "minimatch": "^3.0.4" } }, "sha512-sCm11ak2oY6DglEPpCB8TixLjWAxd3kJTs6UIcSasNYxXdFPV+YKlye92c8H4kKFqV5qYMIh7d+cYecEg0dIkA=="], - "drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], - "drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="], + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], "dts-bundle-generator": ["dts-bundle-generator@9.5.1", "", { "dependencies": { "typescript": ">=5.0.2", "yargs": "^17.6.0" }, "bin": { "dts-bundle-generator": "dist/bin/dts-bundle-generator.js" } }, "sha512-DxpJOb2FNnEyOzMkG11sxO2dmxPjthoVWxfKqWYJ/bI/rT1rvTMktF5EKjAYrRZu6Z6t3NhOUZ0sZ5ZXevOfbA=="], @@ -974,7 +960,7 @@ "electron-to-chromium": ["electron-to-chromium@1.5.277", "", {}, "sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw=="], - "elysia": ["elysia@1.4.22", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.6", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Q90VCb1RVFxnFaRV0FDoSylESQQLWgLHFmWciQJdX9h3b2cSasji9KWEUvaJuy/L9ciAGg4RAhUVfsXHg5K2RQ=="], + "elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -984,7 +970,7 @@ "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], - "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], + "enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="], "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -1002,23 +988,15 @@ "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], - "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], - "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], - - "exact-mirror": ["exact-mirror@0.2.6", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-7s059UIx9/tnOKSySzUk5cPGkoILhTE4p6ncf6uIPaQ+9aRBQzQjc9+q85l51+oZ+P6aBxh084pD0CzBQPcFUA=="], + "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], @@ -1032,11 +1010,11 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fast-xml-builder": ["fast-xml-builder@1.1.9", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-jcyKVSEX13iseJqg7n/KWw+xnu/7fdrZ333Fac54KjHDIELVCfDDJXYIm6DTJ0Su4gSzrhqiK0DzY/wZbF40mw=="], + + "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -1044,7 +1022,7 @@ "figures": ["figures@3.2.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg=="], - "file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="], + "file-type": ["file-type@21.3.4", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -1058,7 +1036,7 @@ "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], - "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], + "fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -1142,7 +1120,7 @@ "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], - "immutable": ["immutable@5.1.4", "", {}, "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA=="], + "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="], "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], @@ -1200,7 +1178,7 @@ "jackspeak": ["jackspeak@2.3.6", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ=="], - "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], + "jimp": ["jimp@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/diff": "1.6.1", "@jimp/js-bmp": "1.6.1", "@jimp/js-gif": "1.6.1", "@jimp/js-jpeg": "1.6.1", "@jimp/js-png": "1.6.1", "@jimp/js-tiff": "1.6.1", "@jimp/plugin-blit": "1.6.1", "@jimp/plugin-blur": "1.6.1", "@jimp/plugin-circle": "1.6.1", "@jimp/plugin-color": "1.6.1", "@jimp/plugin-contain": "1.6.1", "@jimp/plugin-cover": "1.6.1", "@jimp/plugin-crop": "1.6.1", "@jimp/plugin-displace": "1.6.1", "@jimp/plugin-dither": "1.6.1", "@jimp/plugin-fisheye": "1.6.1", "@jimp/plugin-flip": "1.6.1", "@jimp/plugin-hash": "1.6.1", "@jimp/plugin-mask": "1.6.1", "@jimp/plugin-print": "1.6.1", "@jimp/plugin-quantize": "1.6.1", "@jimp/plugin-resize": "1.6.1", "@jimp/plugin-rotate": "1.6.1", "@jimp/plugin-threshold": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1" } }, "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -1232,29 +1210,29 @@ "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], - "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], @@ -1310,14 +1288,12 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], - "mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], "meow": ["meow@8.1.2", "", { "dependencies": { "@types/minimist": "^1.2.0", "camelcase-keys": "^6.2.2", "decamelize-keys": "^1.1.0", "hard-rejection": "^2.1.0", "minimist-options": "4.1.0", "normalize-package-data": "^3.0.0", "read-pkg-up": "^7.0.1", "redent": "^3.0.0", "trim-newlines": "^3.0.0", "type-fest": "^0.18.0", "yargs-parser": "^20.2.3" } }, "sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q=="], - "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -1360,8 +1336,6 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -1404,14 +1378,12 @@ "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], - "node-downloader-helper": ["node-downloader-helper@2.1.10", "", { "bin": { "ndh": "bin/ndh" } }, "sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg=="], + "node-downloader-helper": ["node-downloader-helper@2.1.11", "", { "bin": { "ndh": "bin/ndh" } }, "sha512-882fH2C9AWdiPCwz/2beq5t8FGMZK9Dx8TJUOIxzMCbvG7XUKM5BuJwN5f0NKo4SCQK6jR4p2TPm54mYGdGchQ=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], - "node-html-parser": ["node-html-parser@7.0.2", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-DxodLVh7a6JMkYzWyc8nBX9MaF4M0lLFYkJHlWOiu7+9/I6mwNK9u5TbAMC7qfqDJEPX9OIoWA2A9t4C2l1mUQ=="], - "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "node-stream-zip": ["node-stream-zip@1.15.0", "", {}, "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw=="], @@ -1450,7 +1422,7 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - "p-queue": ["p-queue@9.1.2", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^7.0.0" } }, "sha512-ktsDOALzTYTWWF1PbkNVg2rOt+HaOaMWJMUnt7T3qf5tvZ1L8dBW3tObzprBcXNMKkwj+yFSLqHso0x+UFcJXw=="], + "p-queue": ["p-queue@9.2.0", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g=="], "p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], @@ -1478,6 +1450,8 @@ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -1490,8 +1464,6 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], - "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -1528,8 +1500,6 @@ "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], - "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], - "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], @@ -1544,17 +1514,15 @@ "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], - "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="], "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], - "react-error-boundary": ["react-error-boundary@6.1.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-02k9WQ/mUhdbXir0tC1NiMesGzRPaCsJEWU/4bcFrbY1YMZOtHShtZP6zw0SJrBWA/31H0KT9/FgdL8+sPKgHA=="], + "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="], "react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="], @@ -1562,7 +1530,7 @@ "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], - "react-qr-code": ["react-qr-code@2.0.18", "", { "dependencies": { "prop-types": "^15.8.1", "qr.js": "0.0.0" }, "peerDependencies": { "react": "*" } }, "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg=="], + "react-qr-code": ["react-qr-code@2.0.21", "", { "dependencies": { "prop-types": "^15.8.1", "qr.js": "0.0.0" }, "peerDependencies": { "react": "*" } }, "sha512-xaywjo0eaF4S3LOz6ns5eoPbM2E+q9HYl4VATYpxK4bBniOhQ9noY2RJ9G4SnZFhUwzx63FUT6KdHzfKgUwyuQ=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], @@ -1572,12 +1540,8 @@ "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], - "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], - "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], @@ -1594,61 +1558,57 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - "rollup": ["rollup@4.56.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.56.0", "@rollup/rollup-android-arm64": "4.56.0", "@rollup/rollup-darwin-arm64": "4.56.0", "@rollup/rollup-darwin-x64": "4.56.0", "@rollup/rollup-freebsd-arm64": "4.56.0", "@rollup/rollup-freebsd-x64": "4.56.0", "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", "@rollup/rollup-linux-arm-musleabihf": "4.56.0", "@rollup/rollup-linux-arm64-gnu": "4.56.0", "@rollup/rollup-linux-arm64-musl": "4.56.0", "@rollup/rollup-linux-loong64-gnu": "4.56.0", "@rollup/rollup-linux-loong64-musl": "4.56.0", "@rollup/rollup-linux-ppc64-gnu": "4.56.0", "@rollup/rollup-linux-ppc64-musl": "4.56.0", "@rollup/rollup-linux-riscv64-gnu": "4.56.0", "@rollup/rollup-linux-riscv64-musl": "4.56.0", "@rollup/rollup-linux-s390x-gnu": "4.56.0", "@rollup/rollup-linux-x64-gnu": "4.56.0", "@rollup/rollup-linux-x64-musl": "4.56.0", "@rollup/rollup-openbsd-x64": "4.56.0", "@rollup/rollup-openharmony-arm64": "4.56.0", "@rollup/rollup-win32-arm64-msvc": "4.56.0", "@rollup/rollup-win32-ia32-msvc": "4.56.0", "@rollup/rollup-win32-x64-gnu": "4.56.0", "@rollup/rollup-win32-x64-msvc": "4.56.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], - "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "sass": ["sass@1.97.3", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg=="], + "sass": ["sass@1.99.0", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q=="], - "sass-embedded": ["sass-embedded@1.97.3", "", { "dependencies": { "@bufbuild/protobuf": "^2.5.0", "colorjs.io": "^0.5.0", "immutable": "^5.0.2", "rxjs": "^7.4.0", "supports-color": "^8.1.1", "sync-child-process": "^1.0.2", "varint": "^6.0.0" }, "optionalDependencies": { "sass-embedded-all-unknown": "1.97.3", "sass-embedded-android-arm": "1.97.3", "sass-embedded-android-arm64": "1.97.3", "sass-embedded-android-riscv64": "1.97.3", "sass-embedded-android-x64": "1.97.3", "sass-embedded-darwin-arm64": "1.97.3", "sass-embedded-darwin-x64": "1.97.3", "sass-embedded-linux-arm": "1.97.3", "sass-embedded-linux-arm64": "1.97.3", "sass-embedded-linux-musl-arm": "1.97.3", "sass-embedded-linux-musl-arm64": "1.97.3", "sass-embedded-linux-musl-riscv64": "1.97.3", "sass-embedded-linux-musl-x64": "1.97.3", "sass-embedded-linux-riscv64": "1.97.3", "sass-embedded-linux-x64": "1.97.3", "sass-embedded-unknown-all": "1.97.3", "sass-embedded-win32-arm64": "1.97.3", "sass-embedded-win32-x64": "1.97.3" }, "bin": { "sass": "dist/bin/sass.js" } }, "sha512-eKzFy13Nk+IRHhlAwP3sfuv+PzOrvzUkwJK2hdoCKYcWGSdmwFpeGpWmyewdw8EgBnsKaSBtgf/0b2K635ecSA=="], + "sass-embedded": ["sass-embedded@1.99.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.5.0", "colorjs.io": "^0.5.0", "immutable": "^5.1.5", "rxjs": "^7.4.0", "supports-color": "^8.1.1", "sync-child-process": "^1.0.2", "varint": "^6.0.0" }, "optionalDependencies": { "sass-embedded-all-unknown": "1.99.0", "sass-embedded-android-arm": "1.99.0", "sass-embedded-android-arm64": "1.99.0", "sass-embedded-android-riscv64": "1.99.0", "sass-embedded-android-x64": "1.99.0", "sass-embedded-darwin-arm64": "1.99.0", "sass-embedded-darwin-x64": "1.99.0", "sass-embedded-linux-arm": "1.99.0", "sass-embedded-linux-arm64": "1.99.0", "sass-embedded-linux-musl-arm": "1.99.0", "sass-embedded-linux-musl-arm64": "1.99.0", "sass-embedded-linux-musl-riscv64": "1.99.0", "sass-embedded-linux-musl-x64": "1.99.0", "sass-embedded-linux-riscv64": "1.99.0", "sass-embedded-linux-x64": "1.99.0", "sass-embedded-unknown-all": "1.99.0", "sass-embedded-win32-arm64": "1.99.0", "sass-embedded-win32-x64": "1.99.0" }, "bin": { "sass": "dist/bin/sass.js" } }, "sha512-gF/juR1aX02lZHkvwxdF80SapkQeg2fetoDF6gIQkNbSw5YEUFspMkyGTjPjgZSgIHuZpy+Wz4PlebKnLXMjdg=="], - "sass-embedded-all-unknown": ["sass-embedded-all-unknown@1.97.3", "", { "dependencies": { "sass": "1.97.3" }, "cpu": [ "!arm", "!x64", "!arm64", ] }, "sha512-t6N46NlPuXiY3rlmG6/+1nwebOBOaLFOOVqNQOC2cJhghOD4hh2kHNQQTorCsbY9S1Kir2la1/XLBwOJfui0xg=="], + "sass-embedded-all-unknown": ["sass-embedded-all-unknown@1.99.0", "", { "dependencies": { "sass": "1.99.0" }, "cpu": [ "!arm", "!x64", "!arm64", ] }, "sha512-qPIRG8Uhjo6/OKyAKixTnwMliTz+t9K6Duk0mx5z+K7n0Ts38NSJz2sjDnc7cA/8V9Lb3q09H38dZ1CLwD+ssw=="], - "sass-embedded-android-arm": ["sass-embedded-android-arm@1.97.3", "", { "os": "android", "cpu": "arm" }, "sha512-cRTtf/KV/q0nzGZoUzVkeIVVFv3L/tS1w4WnlHapphsjTXF/duTxI8JOU1c/9GhRPiMdfeXH7vYNcMmtjwX7jg=="], + "sass-embedded-android-arm": ["sass-embedded-android-arm@1.99.0", "", { "os": "android", "cpu": "arm" }, "sha512-EHvJ0C7/VuP78Qr6f8gIUVUmCqIorEQpw2yp3cs3SMg02ZuumlhjXvkTcFBxHmFdFR23vTNk1WnhY6QSeV1nFQ=="], - "sass-embedded-android-arm64": ["sass-embedded-android-arm64@1.97.3", "", { "os": "android", "cpu": "arm64" }, "sha512-aiZ6iqiHsUsaDx0EFbbmmA0QgxicSxVVN3lnJJ0f1RStY0DthUkquGT5RJ4TPdaZ6ebeJWkboV4bra+CP766eA=="], + "sass-embedded-android-arm64": ["sass-embedded-android-arm64@1.99.0", "", { "os": "android", "cpu": "arm64" }, "sha512-fNHhdnP23yqqieCbAdym4N47AleSwjbNt6OYIYx4DdACGdtERjQB4iOX/TaKsW034MupfF7SjnAAK8w7Ptldtg=="], - "sass-embedded-android-riscv64": ["sass-embedded-android-riscv64@1.97.3", "", { "os": "android", "cpu": "none" }, "sha512-zVEDgl9JJodofGHobaM/q6pNETG69uuBIGQHRo789jloESxxZe82lI3AWJQuPmYCOG5ElfRthqgv89h3gTeLYA=="], + "sass-embedded-android-riscv64": ["sass-embedded-android-riscv64@1.99.0", "", { "os": "android", "cpu": "none" }, "sha512-4zqDFRvgGDTL5vTHuIhRxUpXFoh0Cy7Gm5Ywk19ASd8Settmd14YdPRZPmMxfgS1GH292PofV1fq1ifiSEJWBw=="], - "sass-embedded-android-x64": ["sass-embedded-android-x64@1.97.3", "", { "os": "android", "cpu": "x64" }, "sha512-3ke0le7ZKepyXn/dKKspYkpBC0zUk/BMciyP5ajQUDy4qJwobd8zXdAq6kOkdiMB+d9UFJOmEkvgFJHl3lqwcw=="], + "sass-embedded-android-x64": ["sass-embedded-android-x64@1.99.0", "", { "os": "android", "cpu": "x64" }, "sha512-Uk53k/dGYt04RjOL4gFjZ0Z9DH9DKh8IA8WsXUkNqsxerAygoy3zqRBS2zngfE9K2jiOM87q+1R1p87ory9oQQ=="], - "sass-embedded-darwin-arm64": ["sass-embedded-darwin-arm64@1.97.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fuqMTqO4gbOmA/kC5b9y9xxNYw6zDEyfOtMgabS7Mz93wimSk2M1quQaTJnL98Mkcsl2j+7shNHxIS/qpcIDDA=="], + "sass-embedded-darwin-arm64": ["sass-embedded-darwin-arm64@1.99.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u61/7U3IGLqoO6gL+AHeiAtlTPFwJK1+964U8gp45ZN0hzh1yrARf5O1mivXv8NnNgJvbG2wWJbiNZP0lG/lTg=="], - "sass-embedded-darwin-x64": ["sass-embedded-darwin-x64@1.97.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-b/2RBs/2bZpP8lMkyZ0Px0vkVkT8uBd0YXpOwK7iOwYkAT8SsO4+WdVwErsqC65vI5e1e5p1bb20tuwsoQBMVA=="], + "sass-embedded-darwin-x64": ["sass-embedded-darwin-x64@1.99.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-j/kkk/NcXdIameLezSfXjgCiBkVcA+G60AXrX768/3g0miK1g7M9dj7xOhCb1i7/wQeiEI3rw2LLuO63xRIn4A=="], - "sass-embedded-linux-arm": ["sass-embedded-linux-arm@1.97.3", "", { "os": "linux", "cpu": "arm" }, "sha512-2lPQ7HQQg4CKsH18FTsj2hbw5GJa6sBQgDsls+cV7buXlHjqF8iTKhAQViT6nrpLK/e8nFCoaRgSqEC8xMnXuA=="], + "sass-embedded-linux-arm": ["sass-embedded-linux-arm@1.99.0", "", { "os": "linux", "cpu": "arm" }, "sha512-d4IjJZrX2+AwB2YCy1JySwdptJECNP/WfAQLUl8txI3ka8/d3TUI155GtelnoZUkio211PwIeFvvAeZ9RXPQnw=="], - "sass-embedded-linux-arm64": ["sass-embedded-linux-arm64@1.97.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-IP1+2otCT3DuV46ooxPaOKV1oL5rLjteRzf8ldZtfIEcwhSgSsHgA71CbjYgLEwMY9h4jeal8Jfv3QnedPvSjg=="], + "sass-embedded-linux-arm64": ["sass-embedded-linux-arm64@1.99.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-btNcFpItcB56L40n8hDeL7sRSMLDXQ56nB5h2deddJx1n60rpKSElJmkaDGHtpkrY+CTtDRV0FZDjHeTJddYew=="], - "sass-embedded-linux-musl-arm": ["sass-embedded-linux-musl-arm@1.97.3", "", { "os": "linux", "cpu": "arm" }, "sha512-cBTMU68X2opBpoYsSZnI321gnoaiMBEtc+60CKCclN6PCL3W3uXm8g4TLoil1hDD6mqU9YYNlVG6sJ+ZNef6Lg=="], + "sass-embedded-linux-musl-arm": ["sass-embedded-linux-musl-arm@1.99.0", "", { "os": "linux", "cpu": "arm" }, "sha512-2gvHOupgIw3ytatXT4nFUow71LFbuOZPEwG+HUzcNQDH8ue4Ez8cr03vsv5MDv3lIjOKcXwDvWD980t18MwkoQ=="], - "sass-embedded-linux-musl-arm64": ["sass-embedded-linux-musl-arm64@1.97.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Lij0SdZCsr+mNRSyDZ7XtJpXEITrYsaGbOTz5e6uFLJ9bmzUbV7M8BXz2/cA7bhfpRPT7/lwRKPdV4+aR9Ozcw=="], + "sass-embedded-linux-musl-arm64": ["sass-embedded-linux-musl-arm64@1.99.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Hi2bt/IrM5P4FBKz6EcHAlniwfpoz9mnTdvSd58y+avA3SANM76upIkAdSayA8ZGwyL3gZokru1AKDPF9lJDNw=="], - "sass-embedded-linux-musl-riscv64": ["sass-embedded-linux-musl-riscv64@1.97.3", "", { "os": "linux", "cpu": "none" }, "sha512-sBeLFIzMGshR4WmHAD4oIM7WJVkSoCIEwutzptFtGlSlwfNiijULp+J5hA2KteGvI6Gji35apR5aWj66wEn/iA=="], + "sass-embedded-linux-musl-riscv64": ["sass-embedded-linux-musl-riscv64@1.99.0", "", { "os": "linux", "cpu": "none" }, "sha512-mKqGvVaJ9rHMqyZsF0kikQe4NO0f4osb67+X6nLhBiVDKvyazQHJ3zJQreNefIE36yL2sjHIclSB//MprzaQDg=="], - "sass-embedded-linux-musl-x64": ["sass-embedded-linux-musl-x64@1.97.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/oWJ+OVrDg7ADDQxRLC/4g1+Nsz1g4mkYS2t6XmyMJKFTFK50FVI2t5sOdFH+zmMp+nXHKM036W94y9m4jjEcw=="], + "sass-embedded-linux-musl-x64": ["sass-embedded-linux-musl-x64@1.99.0", "", { "os": "linux", "cpu": "x64" }, "sha512-huhgOMmOc30r7CH7qbRbT9LerSEGSnWuS4CYNOskr9BvNeQp4dIneFufNRGZ7hkOAxUM8DglxIZJN/cyAT95Ew=="], - "sass-embedded-linux-riscv64": ["sass-embedded-linux-riscv64@1.97.3", "", { "os": "linux", "cpu": "none" }, "sha512-l3IfySApLVYdNx0Kjm7Zehte1CDPZVcldma3dZt+TfzvlAEerM6YDgsk5XEj3L8eHBCgHgF4A0MJspHEo2WNfA=="], + "sass-embedded-linux-riscv64": ["sass-embedded-linux-riscv64@1.99.0", "", { "os": "linux", "cpu": "none" }, "sha512-mevFPIFAVhrH90THifxLfOntFmHtcEKOcdWnep2gJ0X4DVva4AiVIRlQe/7w9JFx5+gnDRE1oaJJkzuFUuYZsA=="], - "sass-embedded-linux-x64": ["sass-embedded-linux-x64@1.97.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kwqwc/jSSlcpRjULAOVbndqEy2GBzo6OBmmuBVINWUaJLJ8Kczz3vIsDUWLfWz/kTEw9FHBSiL0WCtYLVAXSLg=="], + "sass-embedded-linux-x64": ["sass-embedded-linux-x64@1.99.0", "", { "os": "linux", "cpu": "x64" }, "sha512-9k7IkULqIZdCIVt4Mboryt6vN8Mjmm3EhI1P3mClU5y5i3wLK5ExC3cbVWk047KsID/fvB1RLslqghXJx5BoxA=="], - "sass-embedded-unknown-all": ["sass-embedded-unknown-all@1.97.3", "", { "dependencies": { "sass": "1.97.3" }, "os": [ "!linux", "!win32", "!darwin", "!android", ] }, "sha512-/GHajyYJmvb0IABUQHbVHf1nuHPtIDo/ClMZ81IDr59wT5CNcMe7/dMNujXwWugtQVGI5UGmqXWZQCeoGnct8Q=="], + "sass-embedded-unknown-all": ["sass-embedded-unknown-all@1.99.0", "", { "dependencies": { "sass": "1.99.0" }, "os": [ "!linux", "!win32", "!darwin", "!android", ] }, "sha512-P7MxiUtL/XzGo3PX0CaB8lNNEFLQWKikPA8pbKytx9ZCLZSDkt2NJcdAbblB/sqMs4AV3EK2NadV8rI/diq3xg=="], - "sass-embedded-win32-arm64": ["sass-embedded-win32-arm64@1.97.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-RDGtRS1GVvQfMGAmVXNxYiUOvPzn9oO1zYB/XUM9fudDRnieYTcUytpNTQZLs6Y1KfJxgt5Y+giRceC92fT8Uw=="], + "sass-embedded-win32-arm64": ["sass-embedded-win32-arm64@1.99.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8whpsW7S+uO8QApKfQuc36m3P9EISzbVZOgC79goob4qGy09u8Gz/rYvw8h1prJDSjltpHGhOzBE6LDz7WvzVw=="], - "sass-embedded-win32-x64": ["sass-embedded-win32-x64@1.97.3", "", { "os": "win32", "cpu": "x64" }, "sha512-SFRa2lED9UEwV6vIGeBXeBOLKF+rowF3WmNfb/BzhxmdAsKofCXrJ8ePW7OcDVrvNEbTOGwhsReIsF5sH8fVaw=="], + "sass-embedded-win32-x64": ["sass-embedded-win32-x64@1.99.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ipuOv1R2K4MHeuCEAZGpuUbAgma4gb0sdacyrTjJtMOy/OY9UvWfVlwErdB09KIkp4fPDpQJDJfvYN6bC8jeNg=="], - "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -1656,9 +1616,9 @@ "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "seroval": ["seroval@1.4.2", "", {}, "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ=="], + "seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], - "seroval-plugins": ["seroval-plugins@1.4.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-X7p4MEDTi+60o2sXZ4bnDBhgsUYDSkQEvzYZuJyFqWg9jcoPsHts5nrg5O956py2wyt28lUrBxk0M0/wU8URpA=="], + "seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1688,7 +1648,7 @@ "socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="], - "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1730,6 +1690,8 @@ "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="], + "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], "stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="], @@ -1744,7 +1706,9 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "svgo": ["svgo@3.3.2", "", { "dependencies": { "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.0.0" }, "bin": "./bin/svgo" }, "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw=="], + "svg-icon-baker": ["svg-icon-baker@2.0.1", "", { "dependencies": { "css-tree": "^3.2.1", "fast-xml-builder": "^1.1.5", "fast-xml-parser": "^5.7.2", "svgo": "^4.0.1" } }, "sha512-1QWVlle2fSUra129CEKpo5sn4hLGa0KCd7v8kX+PkD8e1x8fAQXB5rkc8J8mXTHe6iSzcOS7j4Y2K+q9RaUoNQ=="], + + "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], "sync-child-process": ["sync-child-process@1.0.2", "", { "dependencies": { "sync-message-port": "^1.0.0" } }, "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA=="], @@ -1754,15 +1718,15 @@ "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], - "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], - "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], + "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="], "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], - "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="], + "terser": ["terser@5.36.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w=="], "text-extensions": ["text-extensions@1.9.0", "", {}, "sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ=="], @@ -1770,10 +1734,6 @@ "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], - "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], - - "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], - "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], @@ -1788,7 +1748,7 @@ "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], - "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], "tough-cookie-file-store": ["tough-cookie-file-store@3.3.0", "", { "dependencies": { "tough-cookie": "^6.0.0" } }, "sha512-FbO/cOi/jp4wweo8soVNG/ZjDsgpBZWqaxWwu7gRKvsjg/Qt44kStp87VLfJnin749DlTbZDYvV1wuSr5jly2g=="], @@ -1840,7 +1800,7 @@ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], "unzip-stream": ["unzip-stream@0.3.4", "", { "dependencies": { "binary": "^0.3.0", "mkdirp": "^0.5.1" } }, "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw=="], @@ -1866,9 +1826,9 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + "vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], - "vite-plugin-svg-icons-ng": ["vite-plugin-svg-icons-ng@1.5.2", "", { "dependencies": { "fast-glob": "^3.3.3", "fs-extra": "^11.3.2", "node-html-parser": "^7.0.1", "svgo": "^3.3.2" }, "peerDependencies": { "vite": ">=5.0.0" } }, "sha512-A68obs8XDT+q8q8dKyjrT/v0qw8h5pEBKXJ27aUXjARYeJ6MNvhIhRLLiUwnSrbn/B4TBF4UVaWRXKftAqP7+A=="], + "vite-plugin-svg-icons-ng": ["vite-plugin-svg-icons-ng@1.9.0", "", { "dependencies": { "svg-icon-baker": "2.0.1", "tinyglobby": "^0.2.16" }, "peerDependencies": { "vite": ">=5.0.0" } }, "sha512-vIyinFqjR5gEJiDt1MTFGewAJnwyB7tkZ9fjKQ9m9Wa7XmxTTAcj8h1l3C4zA02K6y/4ZuPYCLzHLovoUPDW6w=="], "vite-static-assets-plugin": ["vite-static-assets-plugin@1.2.2", "", { "dependencies": { "chalk": "^5.4.1", "chokidar": "^3.5.3", "minimatch": "^10.0.1" }, "peerDependencies": { "typescript": "^5.0.0", "vite": "^6.2.0" } }, "sha512-0mzHsxFa46Np5AixQcdWYLVH6eJxeok7qL7tXmxYavg/Uo0e5z+J6gavJ0TJ6dmJSe2Z+gwmDb64bCCZfg+gqA=="], @@ -1922,16 +1882,36 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-ts": ["zod-to-ts@2.0.0", "", { "peerDependencies": { "typescript": "^5.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-aHsUgIl+CQutKAxtRNeZslLCLXoeuSq+j5HU7q3kvi/c2KIAo6q4YjT7/lwFfACxLB923ELHYMkHmlxiqFy4lw=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@ap0nia/eden/elysia": ["elysia@1.2.15", "", { "dependencies": { "@sinclair/typebox": "^0.34.15", "cookie": "^1.0.2", "memoirist": "^0.3.0", "openapi-types": "^12.1.3" }, "peerDependencies": { "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-/oUSNb83jIWAGi6uSmbQ7Uy0RSJ9NimbVToSLnYS8jjsGId3zgdHqprsdf4rIMInOmEM8skjsFhZ4x8C5AB6+w=="], + + "@babel/core/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/core/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/generator/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/parser/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/template/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], + + "@babel/template/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + + "@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], + + "@babel/traverse/@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="], + + "@babel/traverse/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -1940,8 +1920,6 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - "@jimp/core/file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], - "@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -1982,8 +1960,6 @@ "@node-minify/core/mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], - "@node-minify/terser/terser": ["terser@5.36.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -1996,22 +1972,32 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="], + "@tanstack/router-generator/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="], + "@tanstack/router-generator/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@tanstack/router-utils/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@tanstack/router-utils/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "@types/babel__core/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + + "@types/babel__template/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "babel-dead-code-elimination/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], + + "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "compare-func/dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], "conventional-changelog-writer/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2038,8 +2024,6 @@ "glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "handlebars/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "handlebars/wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], @@ -2060,8 +2044,6 @@ "meow/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "minimist-options/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="], "nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="], @@ -2084,34 +2066,38 @@ "read-pkg-up/find-up": ["find-up@2.1.0", "", { "dependencies": { "locate-path": "^2.0.0" } }, "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ=="], - "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], - "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "standard-version/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "standard-version/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "svgo/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "tough-cookie-file-store/tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + "tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], + "vite-plugin-svg-icons-ng/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "vite-static-assets-plugin/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "winston/async": ["async@1.0.0", "", {}, "sha512-5mO7DX4CbJzp9zjaFXusQQ4tzKJARjNB1Ih1pVBi8wkbmXy/xzIDgEMXxWePLzt2OdFwaxfneIlT1nCiXubrPQ=="], + "xml2js/sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], + + "@ap0nia/eden/elysia/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "@ap0nia/eden/elysia/memoirist": ["memoirist@0.3.1", "", {}, "sha512-lmk4Z45IuVZPT67nxAdD3rAsNExxMEBFXgCeJGJnoLkYOjmZnJ8Hmi+MGdl9oLKtAENFAAgG8FvV3Z8BNiqy8w=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -2162,13 +2148,15 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "@jimp/core/file-type/strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], - - "@jimp/core/file-type/token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], - "@node-minify/core/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "@node-minify/terser/terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "@tanstack/router-utils/tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "babel-dead-code-elimination/@babel/core/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], + + "babel-dead-code-elimination/@babel/core/@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="], + + "babel-dead-code-elimination/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], @@ -2258,6 +2246,8 @@ "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "vite-plugin-svg-icons-ng/tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], @@ -2342,6 +2332,8 @@ "meow/read-pkg-up/read-pkg/normalize-package-data/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + "meow/read-pkg-up/read-pkg/parse-json/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], + "read-pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@1.3.0", "", { "dependencies": { "p-try": "^1.0.0" } }, "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q=="], "standard-version/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], diff --git a/package.json b/package.json index 0f246cb..da5fcec 100644 --- a/package.json +++ b/package.json @@ -55,53 +55,53 @@ "dependencies": { "7zip-bin": "^5.2.0", "@auth/core": "^0.34.3", - "@elysiajs/cors": "^1.4.1", - "@elysiajs/eden": "^1.4.6", - "@jimp/wasm-webp": "^1.6.0", + "@elysiajs/cors": "^1.4.2", + "@elysiajs/eden": "^1.4.9", + "@jimp/wasm-webp": "^1.6.1", "@phalcode/ts-igdb-client": "^1.0.26", "cheerio": "^1.2.0", - "conf": "^15.0.2", - "drizzle-orm": "^0.45.1", - "elysia": "^1.4.22", - "fs-extra": "^11.3.3", + "conf": "^15.1.0", + "drizzle-orm": "^0.45.2", + "elysia": "^1.4.28", + "fs-extra": "^11.3.5", "get-folder-size": "^5.0.0", "ini": "^6.0.0", - "jimp": "^1.6.0", + "jimp": "^1.6.1", "mustache": "^4.2.0", "node-7z": "^3.0.0", "node-disk-info": "^1.3.0", - "node-downloader-helper": "^2.1.10", + "node-downloader-helper": "^2.1.11", "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", "open": "^11.0.0", - "p-queue": "^9.1.2", + "p-queue": "^9.2.0", "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", "systeminformation": "^5.31.5", - "tapable": "^2.3.0", - "tough-cookie": "^6.0.0", + "tapable": "^2.3.3", + "tough-cookie": "^6.0.1", "tough-cookie-file-store": "^3.3.0", "unzip-stream": "^0.3.4", "webview-bun": "^2.4.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "devDependencies": { - "@ap0nia/eden": "^1.0.0-next.22", + "@ap0nia/eden": "^1.6.1", "@ap0nia/eden-tanstack-query": "^1.0.0-next.22", "@emulatorjs/emulatorjs": "^4.2.3", - "@hey-api/openapi-ts": "^0.91.0", + "@hey-api/openapi-ts": "^0.91.1", "@noriginmedia/norigin-spatial-navigation": "^3.1.0", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-form": "^1.28.0", - "@tanstack/react-query": "^5.90.20", - "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-router": "^1.157.16", - "@tanstack/react-router-devtools": "^1.154.12", - "@tanstack/react-router-ssr-query": "^1.157.17", - "@tanstack/router-plugin": "^1.157.16", - "@tanstack/zod-adapter": "^1.162.4", + "@tailwindcss/vite": "^4.2.4", + "@tanstack/react-form": "^1.29.1", + "@tanstack/react-query": "^5.100.9", + "@tanstack/react-query-devtools": "^5.100.9", + "@tanstack/react-router": "^1.169.2", + "@tanstack/react-router-devtools": "^1.166.13", + "@tanstack/react-router-ssr-query": "^1.166.12", + "@tanstack/router-plugin": "^1.167.35", + "@tanstack/zod-adapter": "^1.166.9", "@types/adm-zip": "^0.5.8", "@types/audiosprite": "^0.7.3", "@types/bun": "latest", @@ -112,11 +112,11 @@ "@types/mustache": "^4.2.6", "@types/node-7z": "^2.1.11", "@types/rclone.js": "^0.6.3", - "@types/react": "^19.2.9", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", - "@vitejs/plugin-react": "^5.1.2", - "adm-zip": "^0.5.16", + "@vitejs/plugin-react": "^5.2.0", + "adm-zip": "^0.5.17", "animate.css": "^4.1.1", "app-builder-bin": "^5.0.0-alpha.13", "audiosprite": "^0.7.2", @@ -124,29 +124,29 @@ "classnames": "^2.5.1", "concurrently": "^9.2.1", "cross-env": "^10.1.0", - "daisyui": "^5.5.14", - "drizzle-kit": "^0.31.9", + "daisyui": "^5.5.19", + "drizzle-kit": "^0.31.10", "dts-bundle-generator": "^9.5.1", "eden-tanstack-query": "^0.0.9", "howler": "^2.2.4", "lucide-react": "^0.563.0", "pretty-bytes": "^7.1.0", "pretty-ms": "^9.3.0", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-error-boundary": "^6.1.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-error-boundary": "^6.1.1", "react-hot-toast": "^2.6.0", "react-markdown": "^10.1.0", - "react-qr-code": "^2.0.18", - "sass-embedded": "^1.97.3", + "react-qr-code": "^2.0.21", + "sass-embedded": "^1.99.0", "standard-version": "^9.5.0", - "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.1.18", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.4", "tailwindcss-animate": "^1.0.7", "typescript": "^5.9.3", "usehooks-ts": "^3.1.1", - "vite": "^7.3.1", - "vite-plugin-svg-icons-ng": "^1.5.2", + "vite": "^7.3.3", + "vite-plugin-svg-icons-ng": "^1.9.0", "vite-static-assets-plugin": "^1.2.2", "vite-tsconfig-paths": "^6.1.1", "zod-to-ts": "^2.0.0" diff --git a/src/bun/api/jobs/update-store.ts b/src/bun/api/jobs/update-store.ts index b051f07..48129a0 100644 --- a/src/bun/api/jobs/update-store.ts +++ b/src/bun/api/jobs/update-store.ts @@ -20,16 +20,12 @@ export default class UpdateStoreJob implements IJob this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0"; } - async start (context: JobContext) + async runCommand (commands: string[]) { - 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], { + let proc = Bun.spawn([process.execPath, ...commands, "--registry", this.registry.href, '--json'], { cwd: storeFolder, stdout: 'pipe', stderr: 'pipe', @@ -45,23 +41,19 @@ export default class UpdateStoreJob implements IJob if (stderr) console.error(stderr); await proc.exited; + } + + async start (context: JobContext) + { + if (process.env.CUSTOM_STORE_PATH) return; + + const storeFolder = getStoreRootFolder(); + await ensureDir(storeFolder); + + console.log("Adding Store Package"); + await this.runCommand(["add", `${this.packageName}@${this.storeVersion}`]); 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; + await this.runCommand(["update", `${this.packageName}@${this.storeVersion}`]); } } \ No newline at end of file From 38cb7525527b5ad4f6eb284cdad0001fd87eaf7e Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 10 May 2026 01:46:57 +0300 Subject: [PATCH 61/65] feat: Implemented public plugin system accessible from the store. feat: Implemented external ryujinx integration plugin refactor: moved sdk types and schemas to own workspace package fix: Fixed emulator launch with no game --- bun.lock | 128 ++-- package.json | 14 +- scripts/build-sdk.ts | 64 -- scripts/sdk/package.json | 9 - scripts/sdk/sdk.ts | 18 - src/bun/api/app.ts | 5 +- src/bun/api/cache.ts | 5 +- src/bun/api/drives.ts | 2 +- src/bun/api/emulatorjs/emulatorjs.ts | 2 +- src/bun/api/games/collections.ts | 2 +- src/bun/api/games/games.ts | 5 +- src/bun/api/games/platforms.ts | 2 +- .../api/games/services/launchGameService.ts | 2 +- src/bun/api/games/services/statusService.ts | 43 +- src/bun/api/games/services/utils.ts | 2 +- src/bun/api/jobs/bios-download-job.ts | 2 +- src/bun/api/jobs/emulator-download-job.ts | 6 +- src/bun/api/jobs/import-job.ts | 4 +- src/bun/api/jobs/install-job.ts | 4 +- src/bun/api/jobs/jobs.ts | 2 +- src/bun/api/jobs/launch-game-job.ts | 6 +- src/bun/api/jobs/login-job.ts | 2 +- src/bun/api/jobs/plugin-operation-job.ts | 62 ++ src/bun/api/jobs/reload-plugins-job.ts | 2 +- src/bun/api/jobs/self-update-job.ts | 2 +- src/bun/api/jobs/twitch-login-job.ts | 2 +- src/bun/api/jobs/update-store.ts | 70 +- src/bun/api/notifications.ts | 2 +- .../com.simeonradivoev.gameflow.cemu/cemu.ts | 2 +- .../dolphin.ts | 2 +- .../pcsx2.ts | 4 +- .../ppsspp.ts | 4 +- .../com.simeonradivoev.gameflow.xemu/xemu.ts | 2 +- .../xenia.ts | 4 +- .../com.simeonradivoev.gameflow.es/es-de.ts | 4 +- .../rclone.ts | 2 +- .../com.simeonradivoev.gameflow.igdb/igdb.ts | 4 +- .../com.simeonradivoev.gameflow.romm/romm.ts | 4 +- .../package.json | 4 +- .../services.ts | 3 +- .../store.ts | 7 +- src/bun/api/plugins/plugin-manager.ts | 56 +- src/bun/api/plugins/plugins.ts | 40 +- src/bun/api/plugins/register-plugins.ts | 138 ++-- src/bun/api/plugins/services.ts | 62 ++ src/bun/api/schema/app.ts | 3 +- src/bun/api/settings/services.ts | 2 +- src/bun/api/settings/settings.ts | 14 +- .../api/store/services/emulatorsService.ts | 3 +- src/bun/api/store/services/gamesService.ts | 3 +- src/bun/api/store/store.ts | 56 +- src/bun/api/system.ts | 3 +- src/bun/types/types.ts | 18 - src/bun/utils.ts | 3 +- src/bun/utils/downloader.ts | 2 +- src/mainview/components/AppCommunication.tsx | 2 +- src/mainview/components/CollectionsDetail.tsx | 2 +- src/mainview/components/FilePicker.tsx | 2 +- src/mainview/components/FrontEndGameCard.tsx | 2 +- src/mainview/components/GameList.tsx | 5 +- src/mainview/components/GamepadKeyboard.tsx | 2 +- src/mainview/components/HeaderSearchField.tsx | 1 - src/mainview/components/LoadMoreButton.tsx | 1 + src/mainview/components/Notifications.tsx | 2 +- src/mainview/components/SideFilters.tsx | 4 +- src/mainview/components/game/Achievements.tsx | 2 +- .../components/game/ActionButtons.tsx | 2 +- src/mainview/components/game/Details.tsx | 2 +- src/mainview/components/game/GameLookup.tsx | 2 +- src/mainview/components/game/MainActions.tsx | 3 +- .../options/DownloadDirectoryOption.tsx | 3 +- .../components/options/LocalOption.tsx | 2 +- .../components/options/PathSettingsOption.tsx | 3 +- .../components/options/SettingsDropdown.tsx | 3 +- .../components/options/SettingsOption.tsx | 3 +- .../components/store/EmulatorsSection.tsx | 2 +- .../components/store/GamesSection.tsx | 2 +- .../store/MissingEmulatorsSection.tsx | 2 +- .../components/store/StoreEmulatorCard.tsx | 2 +- src/mainview/gen/routeTree.gen.ts | 42 ++ src/mainview/query-options.ts | 3 +- .../routes/collection.$source.$id.tsx | 2 +- src/mainview/routes/game/$source.$id.tsx | 2 +- .../routes/game/update.$source.$id.tsx | 6 +- src/mainview/routes/games.tsx | 2 +- src/mainview/routes/index.tsx | 2 +- src/mainview/routes/platform.$source.$id.tsx | 3 +- src/mainview/routes/settings/accounts.tsx | 3 +- src/mainview/routes/settings/directories.tsx | 5 +- src/mainview/routes/settings/emulators.tsx | 8 +- src/mainview/routes/settings/interface.tsx | 6 +- .../routes/settings/plugin.$source.tsx | 55 +- src/mainview/routes/settings/plugins.tsx | 16 +- src/mainview/routes/settings/update.tsx | 2 +- .../routes/store/details.emulator.$id.tsx | 2 +- .../routes/store/details.plugin.$id.tsx | 161 +++++ src/mainview/routes/store/tab/games.tsx | 4 +- src/mainview/routes/store/tab/index.tsx | 2 +- src/mainview/routes/store/tab/plugins.tsx | 151 +++++ src/mainview/routes/store/tab/route.tsx | 8 +- src/mainview/scripts/contexts.ts | 3 +- src/mainview/scripts/queries/plugins.ts | 50 +- src/mainview/scripts/queries/romm.ts | 4 +- src/mainview/scripts/queries/settings.ts | 10 +- src/mainview/scripts/queries/store.ts | 22 +- src/mainview/scripts/types.ts | 3 +- src/mainview/scripts/utils.ts | 2 +- .../packages/gameflow-sdk}/README.md | 15 + src/packages/gameflow-sdk/build.ts | 27 + .../gameflow-sdk}/hooks/app.ts | 2 +- .../gameflow-sdk}/hooks/auth.ts | 3 +- .../gameflow-sdk}/hooks/emulators.ts | 9 +- .../gameflow-sdk}/hooks/games.ts | 6 +- .../gameflow-sdk}/hooks/store.ts | 3 +- .../gameflow-sdk/index.ts} | 61 +- src/packages/gameflow-sdk/package.json | 51 ++ .../packages/gameflow-sdk}/sdk.tsconfig.json | 22 +- src/packages/gameflow-sdk/shared.ts | 631 ++++++++++++++++++ .../gameflow-sdk}/task-queue.ts | 3 +- src/shared/constants.ts | 209 +----- src/shared/types.schema.ts | 0 src/shared/types.ts | 387 ----------- src/tests/downloads.test.ts | 2 +- tsconfig.json | 1 + 124 files changed, 1918 insertions(+), 1067 deletions(-) delete mode 100644 scripts/build-sdk.ts delete mode 100644 scripts/sdk/package.json delete mode 100644 scripts/sdk/sdk.ts create mode 100644 src/bun/api/jobs/plugin-operation-job.ts create mode 100644 src/bun/api/plugins/services.ts delete mode 100644 src/bun/types/types.ts create mode 100644 src/mainview/routes/store/details.plugin.$id.tsx create mode 100644 src/mainview/routes/store/tab/plugins.tsx rename {scripts/sdk => src/packages/gameflow-sdk}/README.md (53%) create mode 100644 src/packages/gameflow-sdk/build.ts rename src/{bun/api => packages/gameflow-sdk}/hooks/app.ts (88%) rename src/{bun/api => packages/gameflow-sdk}/hooks/auth.ts (81%) rename src/{bun/api => packages/gameflow-sdk}/hooks/emulators.ts (83%) rename src/{bun/api => packages/gameflow-sdk}/hooks/games.ts (92%) rename src/{bun/api => packages/gameflow-sdk}/hooks/store.ts (86%) rename src/{bun/types/types.schema.ts => packages/gameflow-sdk/index.ts} (72%) create mode 100644 src/packages/gameflow-sdk/package.json rename {scripts/sdk => src/packages/gameflow-sdk}/sdk.tsconfig.json (52%) create mode 100644 src/packages/gameflow-sdk/shared.ts rename src/{bun/api => packages/gameflow-sdk}/task-queue.ts (99%) create mode 100644 src/shared/types.schema.ts diff --git a/bun.lock b/bun.lock index 5e7c073..cd65d27 100644 --- a/bun.lock +++ b/bun.lock @@ -7,53 +7,54 @@ "dependencies": { "7zip-bin": "^5.2.0", "@auth/core": "^0.34.3", - "@elysiajs/cors": "^1.4.1", - "@elysiajs/eden": "^1.4.6", - "@jimp/wasm-webp": "^1.6.0", + "@elysiajs/cors": "^1.4.2", + "@elysiajs/eden": "^1.4.9", + "@jimp/wasm-webp": "^1.6.1", "@phalcode/ts-igdb-client": "^1.0.26", "cheerio": "^1.2.0", - "conf": "^15.0.2", - "drizzle-orm": "^0.45.1", - "elysia": "^1.4.22", - "fs-extra": "^11.3.3", + "conf": "^15.1.0", + "drizzle-orm": "^0.45.2", + "elysia": "^1.4.28", + "fs-extra": "^11.3.5", "get-folder-size": "^5.0.0", "ini": "^6.0.0", - "jimp": "^1.6.0", + "jimp": "^1.6.1", "mustache": "^4.2.0", "node-7z": "^3.0.0", "node-disk-info": "^1.3.0", - "node-downloader-helper": "^2.1.10", + "node-downloader-helper": "^2.1.11", "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", + "npm-check-updates": "^22.1.1", "open": "^11.0.0", - "p-queue": "^9.1.2", + "p-queue": "^9.2.0", "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", "systeminformation": "^5.31.5", - "tapable": "^2.3.0", - "tough-cookie": "^6.0.0", + "tapable": "^2.3.3", + "tough-cookie": "^6.0.1", "tough-cookie-file-store": "^3.3.0", "unzip-stream": "^0.3.4", "webview-bun": "^2.4.0", - "zod": "^4.3.6", + "zod": "^4.4.3", }, "devDependencies": { - "@ap0nia/eden": "^1.0.0-next.22", + "@ap0nia/eden": "^1.6.1", "@ap0nia/eden-tanstack-query": "^1.0.0-next.22", "@emulatorjs/emulatorjs": "^4.2.3", - "@hey-api/openapi-ts": "^0.91.0", + "@hey-api/openapi-ts": "^0.91.1", "@noriginmedia/norigin-spatial-navigation": "^3.1.0", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-form": "^1.28.0", - "@tanstack/react-query": "^5.90.20", - "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-router": "^1.157.16", - "@tanstack/react-router-devtools": "^1.154.12", - "@tanstack/react-router-ssr-query": "^1.157.17", - "@tanstack/router-plugin": "^1.157.16", - "@tanstack/zod-adapter": "^1.162.4", + "@tailwindcss/vite": "^4.2.4", + "@tanstack/react-form": "^1.29.1", + "@tanstack/react-query": "^5.100.9", + "@tanstack/react-query-devtools": "^5.100.9", + "@tanstack/react-router": "^1.169.2", + "@tanstack/react-router-devtools": "^1.166.13", + "@tanstack/react-router-ssr-query": "^1.166.12", + "@tanstack/router-plugin": "^1.167.35", + "@tanstack/zod-adapter": "^1.166.9", "@types/adm-zip": "^0.5.8", "@types/audiosprite": "^0.7.3", "@types/bun": "latest", @@ -64,11 +65,11 @@ "@types/mustache": "^4.2.6", "@types/node-7z": "^2.1.11", "@types/rclone.js": "^0.6.3", - "@types/react": "^19.2.9", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", - "@vitejs/plugin-react": "^5.1.2", - "adm-zip": "^0.5.16", + "@vitejs/plugin-react": "^5.2.0", + "adm-zip": "^0.5.17", "animate.css": "^4.1.1", "app-builder-bin": "^5.0.0-alpha.13", "audiosprite": "^0.7.2", @@ -76,32 +77,71 @@ "classnames": "^2.5.1", "concurrently": "^9.2.1", "cross-env": "^10.1.0", - "daisyui": "^5.5.14", - "drizzle-kit": "^0.31.9", - "dts-bundle-generator": "^9.5.1", + "daisyui": "^5.5.19", + "drizzle-kit": "^0.31.10", "eden-tanstack-query": "^0.0.9", "howler": "^2.2.4", "lucide-react": "^0.563.0", "pretty-bytes": "^7.1.0", "pretty-ms": "^9.3.0", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-error-boundary": "^6.1.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-error-boundary": "^6.1.1", "react-hot-toast": "^2.6.0", "react-markdown": "^10.1.0", - "react-qr-code": "^2.0.18", - "sass-embedded": "^1.97.3", + "react-qr-code": "^2.0.21", + "sass-embedded": "^1.99.0", "standard-version": "^9.5.0", - "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.1.18", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.4", "tailwindcss-animate": "^1.0.7", "typescript": "^5.9.3", "usehooks-ts": "^3.1.1", - "vite": "^7.3.1", - "vite-plugin-svg-icons-ng": "^1.5.2", + "vite": "^7.3.3", + "vite-plugin-svg-icons-ng": "^1.9.0", "vite-static-assets-plugin": "^1.2.2", "vite-tsconfig-paths": "^6.1.1", - "zod-to-ts": "^2.0.0", + }, + }, + "src/packages/gameflow-sdk": { + "name": "@simeonradivoev/gameflow-sdk", + "version": "1.5.3", + "bin": { + "gameflow-build": "build.ts", + }, + "peerDependencies": { + "7zip-bin": "^5.2.0", + "@auth/core": "^0.34.3", + "@elysiajs/cors": "^1.4.2", + "@elysiajs/eden": "^1.4.9", + "@jimp/wasm-webp": "^1.6.1", + "@phalcode/ts-igdb-client": "^1.0.26", + "cheerio": "^1.2.0", + "conf": "^15.1.0", + "drizzle-orm": "^0.45.2", + "elysia": "^1.4.28", + "fs-extra": "^11.3.5", + "get-folder-size": "^5.0.0", + "ini": "^6.0.0", + "jimp": "^1.6.1", + "mustache": "^4.2.0", + "node-7z": "^3.0.0", + "node-disk-info": "^1.3.0", + "node-downloader-helper": "^2.1.11", + "node-stream-zip": "^1.15.0", + "node-unrar-js": "^2.0.2", + "open": "^11.0.0", + "p-queue": "^9.2.0", + "pathe": "^2.0.3", + "slugify": "^1.6.9", + "smol-toml": "^1.6.1", + "systeminformation": "^5.31.5", + "tapable": "^2.3.3", + "tough-cookie": "^6.0.1", + "tough-cookie-file-store": "^3.3.0", + "unzip-stream": "^0.3.4", + "webview-bun": "^2.4.0", + "zod": "^4.4.3", }, }, }, @@ -520,6 +560,8 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="], + "@simeonradivoev/gameflow-sdk": ["@simeonradivoev/gameflow-sdk@workspace:src/packages/gameflow-sdk"], + "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], @@ -948,8 +990,6 @@ "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], - "dts-bundle-generator": ["dts-bundle-generator@9.5.1", "", { "dependencies": { "typescript": ">=5.0.2", "yargs": "^17.6.0" }, "bin": { "dts-bundle-generator": "dist/bin/dts-bundle-generator.js" } }, "sha512-DxpJOb2FNnEyOzMkG11sxO2dmxPjthoVWxfKqWYJ/bI/rT1rvTMktF5EKjAYrRZu6Z6t3NhOUZ0sZ5ZXevOfbA=="], - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="], @@ -1394,6 +1434,8 @@ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "npm-check-updates": ["npm-check-updates@22.1.1", "", { "bin": { "npm-check-updates": "build/cli.js", "ncu": "build/cli.js" } }, "sha512-uWSxJW25dy5ZM4SdLsi0VBgPSJlo7u+jARQ6Xql+85YYCoqXU2ZaympAZ6237/oybCq/I4nXddE9S9BTwBfBXA=="], + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], "nypm": ["nypm@0.6.4", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw=="], @@ -1884,8 +1926,6 @@ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "zod-to-ts": ["zod-to-ts@2.0.0", "", { "peerDependencies": { "typescript": "^5.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-aHsUgIl+CQutKAxtRNeZslLCLXoeuSq+j5HU7q3kvi/c2KIAo6q4YjT7/lwFfACxLB923ELHYMkHmlxiqFy4lw=="], - "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@ap0nia/eden/elysia": ["elysia@1.2.15", "", { "dependencies": { "@sinclair/typebox": "^0.34.15", "cookie": "^1.0.2", "memoirist": "^0.3.0", "openapi-types": "^12.1.3" }, "peerDependencies": { "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-/oUSNb83jIWAGi6uSmbQ7Uy0RSJ9NimbVToSLnYS8jjsGId3zgdHqprsdf4rIMInOmEM8skjsFhZ4x8C5AB6+w=="], diff --git a/package.json b/package.json index da5fcec..bf49ccc 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ }, "packageManager": "bun@1.3.9", "type": "module", + "workspaces": [ + "./src/packages/gameflow-sdk" + ], "scripts": { "dev": "NODE_ENV=development bun run build:vite && conc 'bun run ./scripts/dev.ts'", "dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run ./scripts/dev.ts'", @@ -27,6 +30,7 @@ "build:prod:vite": "NODE_ENV=production bun run build:vite", "build:dev:vite": "NODE_ENV=development bun run build:vite", "build": "bun run build:vite && bun run ./scripts/package-bun.ts", + "build:non-compiled": "bun run build:vite && NON_COMPILED=true bun run ./scripts/package-bun.ts", "build:prod": "NODE_ENV=production bun run build", "build:linux": "TARGET=bun-linux-x64 bun run build", "openapi-ts": "bun run ./scripts/romm/openapi-ts.ts", @@ -49,8 +53,7 @@ "download:nwjs": "bun scripts/download-nw.ts", "build:audiosprites": "bun ./scripts/generate-audio-sprites.ts", "tsc": "tsc --noEmit", - "build:sdk": "bun ./scripts/build-sdk.ts", - "publish:sdk": "bun build:sdk && bun publish --cwd ./dist-sdk/ --access public" + "publish:sdk": "bun publish --cwd ./src/packages/gameflow-sdk/ --access public" }, "dependencies": { "7zip-bin": "^5.2.0", @@ -73,6 +76,7 @@ "node-downloader-helper": "^2.1.11", "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", + "npm-check-updates": "^22.1.1", "open": "^11.0.0", "p-queue": "^9.2.0", "pathe": "^2.0.3", @@ -126,7 +130,6 @@ "cross-env": "^10.1.0", "daisyui": "^5.5.19", "drizzle-kit": "^0.31.10", - "dts-bundle-generator": "^9.5.1", "eden-tanstack-query": "^0.0.9", "howler": "^2.2.4", "lucide-react": "^0.563.0", @@ -148,7 +151,6 @@ "vite": "^7.3.3", "vite-plugin-svg-icons-ng": "^1.9.0", "vite-static-assets-plugin": "^1.2.2", - "vite-tsconfig-paths": "^6.1.1", - "zod-to-ts": "^2.0.0" + "vite-tsconfig-paths": "^6.1.1" } -} +} \ No newline at end of file diff --git a/scripts/build-sdk.ts b/scripts/build-sdk.ts deleted file mode 100644 index 11ee929..0000000 --- a/scripts/build-sdk.ts +++ /dev/null @@ -1,64 +0,0 @@ -import path from 'node:path'; -import appPkg from '../package.json'; -import sdkTsConfig from './sdk/sdk.tsconfig.json'; -import sdkPackage from './sdk/package.json'; -import { emptyDir } from 'fs-extra'; -import { generateDtsBundle } from 'dts-bundle-generator'; -import { zodToTs, createAuxiliaryTypeStore, printNode } from 'zod-to-ts'; -import fs from 'node:fs/promises'; - -import * as types from './sdk/sdk'; - -const zodTypeRegex = /z\.infer/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(); \ No newline at end of file diff --git a/scripts/sdk/package.json b/scripts/sdk/package.json deleted file mode 100644 index 17d855e..0000000 --- a/scripts/sdk/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@simeonradivoev/gameflow-sdk", - "types": "index.d.ts", - "description": "plugin SDK for the Gameflow Deck Launcher", - "keywords": [ - "gameflow", - "sdk" - ] -} \ No newline at end of file diff --git a/scripts/sdk/sdk.ts b/scripts/sdk/sdk.ts deleted file mode 100644 index 6568653..0000000 --- a/scripts/sdk/sdk.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { SettingsType } from '@/shared/constants'; -import Conf from 'conf'; -import { AppEventMap } from '../../src/bun/types/types'; -import EventEmitter from "node:events"; -import { TaskQueue } from '@/bun/api/task-queue'; - -export * from '../../src/bun/types/types.schema'; -export * from '../../src/bun/types/types'; -export * from '../../src/bun/api/hooks/app'; -export * from '../../src/shared/constants'; -export * from '../../src/shared/types'; -export * from '../../src/shared/utils'; - -export declare const config: Conf; -export declare let events: EventEmitter; -export declare let taskQueue: TaskQueue; - -export { }; \ No newline at end of file diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index db06c81..68ec287 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -1,5 +1,5 @@ -import { TaskQueue } from "./task-queue"; +import { TaskQueue, AppEventMap } from "@simeonradivoev/gameflow-sdk"; import { Database } from "bun:sqlite"; import { CookieJar } from 'tough-cookie'; import FileCookieStore from 'tough-cookie-file-store'; @@ -8,7 +8,7 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite"; import Conf from "conf"; import projectPackage from '~/package.json'; -import { SettingsSchema, SettingsType } from "@shared/constants"; +import { SettingsType, SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared'; import { client } from "@clients/romm/client.gen"; import * as schema from "@schema/app"; import cacheSchema from "@schema/cache"; @@ -24,7 +24,6 @@ import controls from './controls/controls'; import { RunAPIServer } from "./rpc"; import { RunBunServer } from "../server"; import ReloadPluginsJob from "./jobs/reload-plugins-job"; -import { AppEventMap } from "../types/types"; export let config: Conf; export let customEmulators: Conf>; diff --git a/src/bun/api/cache.ts b/src/bun/api/cache.ts index cdad6b1..04abd1e 100644 --- a/src/bun/api/cache.ts +++ b/src/bun/api/cache.ts @@ -1,7 +1,7 @@ import { eq } from "drizzle-orm"; import { cache } from "./app"; import cacheSchema from "@schema/cache"; -import { GithubReleaseSchema } from "@/shared/constants"; +import { GithubReleaseSchema } from '@simeonradivoev/gameflow-sdk/shared'; import PQueue from "p-queue"; import z from "zod"; @@ -11,7 +11,8 @@ export const CACHE_KEYS = { STORE_GAME_MANIFEST: 'store-game-manifest' } as const; -export const githubRequestQueue = new PQueue({ intervalCap: 10, interval: 1000 * 60 * 10, strict: true }); +// we aggressively cache github data so burst of calls is fine. +export const githubRequestQueue = new PQueue({ intervalCap: 60, interval: 1000 * 60 * 60, strict: true }); export async function getOrCached (key: string, getter: (lastValue: T | undefined) => Promise, options?: { expireMs?: number; force?: boolean; }): Promise { diff --git a/src/bun/api/drives.ts b/src/bun/api/drives.ts index a7f0565..99452d8 100644 --- a/src/bun/api/drives.ts +++ b/src/bun/api/drives.ts @@ -1,7 +1,7 @@ import si from 'systeminformation'; import fs from 'node:fs'; import os from "node:os"; -import { Drive } from '@/shared/types'; +import { Drive } from '@simeonradivoev/gameflow-sdk/shared'; async function getAccess (path: string) { diff --git a/src/bun/api/emulatorjs/emulatorjs.ts b/src/bun/api/emulatorjs/emulatorjs.ts index d770a43..d733d34 100644 --- a/src/bun/api/emulatorjs/emulatorjs.ts +++ b/src/bun/api/emulatorjs/emulatorjs.ts @@ -5,7 +5,7 @@ import z from "zod"; import path from 'node:path'; import { config, events, plugins } from "../app"; import { getLocalGame, updateLocalLastPlayed } from "../games/services/statusService"; -import { SaveFileChange } from "@/shared/types"; +import { SaveFileChange } from "@simeonradivoev/gameflow-sdk/shared"; // TODO: use the retroarch cores based on ES-DE export const cores: Record = { diff --git a/src/bun/api/games/collections.ts b/src/bun/api/games/collections.ts index 1a49a12..ae31430 100644 --- a/src/bun/api/games/collections.ts +++ b/src/bun/api/games/collections.ts @@ -1,6 +1,6 @@ import Elysia, { status } from "elysia"; import { plugins } from "../app"; -import { FrontEndCollection } from "@/shared/types"; +import { FrontEndCollection } from "@simeonradivoev/gameflow-sdk/shared"; export default new Elysia() .get('/collections', async () => diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 1b73e78..3c2575c 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -4,7 +4,8 @@ import { and, desc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm" import z from "zod"; import * as schema from "@schema/app"; import fs from "node:fs/promises"; -import { GameListFilterSchema, SERVER_URL } from "@shared/constants"; +import { SERVER_URL } from "@shared/constants"; +import { GameListFilterSchema } from '@simeonradivoev/gameflow-sdk/shared'; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; @@ -22,7 +23,7 @@ import { LaunchGameJob } from "../jobs/launch-game-job"; import { cores } from "../emulatorjs/emulatorjs"; import { findEmulatorPluginIntegration } from "../store/services/emulatorsService"; import { ImportJob } from "../jobs/import-job"; -import { EmulatorSourceEntryType, EmulatorSystem, FrontEndFilterLists, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailedEmulator, FrontEndGameTypeWithIds, FrontEndId, GameLookup } from "@/shared/types"; +import { EmulatorSourceEntryType, EmulatorSystem, FrontEndFilterLists, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailedEmulator, FrontEndGameTypeWithIds, FrontEndId, GameLookup } from "@simeonradivoev/gameflow-sdk/shared"; // A custom jimp that supports webp const Jimp = createJimp({ diff --git a/src/bun/api/games/platforms.ts b/src/bun/api/games/platforms.ts index de888ba..10aaf42 100644 --- a/src/bun/api/games/platforms.ts +++ b/src/bun/api/games/platforms.ts @@ -4,7 +4,7 @@ import { and, count, eq, getTableColumns, not, notExists, or } from "drizzle-orm import { config, db, plugins } from "../app"; import * as schema from "@schema/app"; import { findPlatform } from "./services/utils"; -import { FrontEndPlatformType } from "@/shared/types"; +import { FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared"; export default new Elysia() .get('/platforms', async () => diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index 248d6f2..490850d 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -6,7 +6,7 @@ import { config, taskQueue } from '../../app'; import { LaunchGameJob } from '../../jobs/launch-game-job'; import { getStoreEmulatorPackage } from '../../store/services/gamesService'; import { getOrCachedScoopPackage } from '../../store/services/emulatorsService'; -import { CommandEntry, EmulatorSourceEntryType, FrontEndId } from '@/shared/types'; +import { CommandEntry, EmulatorSourceEntryType, FrontEndId } from '@simeonradivoev/gameflow-sdk/shared'; export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string) { diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 05bade3..1eaed5b 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -8,9 +8,10 @@ import z from "zod"; import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { LaunchGameJob } from "../../jobs/launch-game-job"; import * as appSchema from "@schema/app"; -import { DownloadSourceSchema, RPC_URL } from "@/shared/constants"; +import { RPC_URL } from "@/shared/constants"; +import { DownloadSourceSchema } from '@simeonradivoev/gameflow-sdk/shared'; import { host } from "@/bun/utils/host"; -import { CommandEntry, FrontEndId, GameLookup, GameStatusType, LocalDownloadFileEntry } from "@/shared/types"; +import { CommandEntry, FrontEndId, GameLookup, GameStatusType, LocalDownloadFileEntry } from "@simeonradivoev/gameflow-sdk/shared"; export class CommandSearchError extends Error { @@ -115,11 +116,15 @@ export async function update (source: string, id: string) const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)]; if (paths_screenshots.length <= 0 && sourceGame.igdb_id) { - const matches: GameLookup[] = []; - await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id), matches }); - if (matches.length > 0) + const matches = new Map(); + await plugins.hooks.games.gameLookup.promise(matches, { source: 'igdb', id: String(sourceGame.igdb_id) }); + if (matches.size > 0) { - paths_screenshots.push(...matches[0].screenshotUrls); + const firstMatches = matches.values().next().value; + if (firstMatches && firstMatches.length > 0) + { + paths_screenshots.push(...firstMatches[0].screenshotUrls); + } } } @@ -244,7 +249,31 @@ export async function getValidLaunchCommandsForGame (source: string, id: string) commands: commands.filter(c => c.valid), gameId: { id: String(localGame.id), source: 'local' }, source: localGame.source ?? source, - sourceId: String(localGame.source_id) ?? id, + sourceId: localGame.source_id ? String(localGame.source_id) : id, + }; + } + else + { + return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`); + } + } else if (source === 'emulator') + { + const commands = await plugins.hooks.games.buildLaunchCommands.promise({ + source, + sourceId: id, + id: { source: source, id: id }, + systemSlug: "", + gamePath: null + }); + + if (commands instanceof Error || !commands) return commands; + + const validCommand = commands.find(c => c.valid); + if (validCommand) + { + return { + commands: commands.filter(c => c.valid), + gameId: { id, source } }; } else diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index fd4b2d9..aaac97b 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -8,7 +8,7 @@ import { RPC_URL } from "@shared/constants"; import { hashFile } from "@/bun/utils"; import { host } from "@/bun/utils/host"; import * as emulatorSchema from "@schema/emulators"; -import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata } from "@/shared/types"; +import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata } from "@simeonradivoev/gameflow-sdk/shared"; export async function calculateSize (installPath: string | null) { diff --git a/src/bun/api/jobs/bios-download-job.ts b/src/bun/api/jobs/bios-download-job.ts index be46c5f..64537c1 100644 --- a/src/bun/api/jobs/bios-download-job.ts +++ b/src/bun/api/jobs/bios-download-job.ts @@ -1,5 +1,5 @@ import z from "zod"; -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; import { config, plugins } from "../app"; import { simulateProgress } from "@/bun/utils"; import { Downloader } from "@/bun/utils/downloader"; diff --git a/src/bun/api/jobs/emulator-download-job.ts b/src/bun/api/jobs/emulator-download-job.ts index 7dbaf6e..9aa35a6 100644 --- a/src/bun/api/jobs/emulator-download-job.ts +++ b/src/bun/api/jobs/emulator-download-job.ts @@ -1,6 +1,6 @@ -import { EmulatorPackageType } from "@/shared/constants"; +import { EmulatorPackageType } from '@simeonradivoev/gameflow-sdk/shared'; import { getStoreEmulatorPackage } from "../store/services/gamesService"; -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; import z from "zod"; import { config, plugins } from "../app"; import path from 'node:path'; @@ -12,7 +12,7 @@ import { simulateProgress } from "@/bun/utils"; import { path7za } from "7zip-bin"; import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService"; import { $ } from "bun"; -import { EmulatorSourceEntryType } from "@/shared/types"; +import { EmulatorSourceEntryType } from "@simeonradivoev/gameflow-sdk/shared"; type EmulatorDownloadStates = "download" | "extract"; diff --git a/src/bun/api/jobs/import-job.ts b/src/bun/api/jobs/import-job.ts index 3e608a4..f1d25be 100644 --- a/src/bun/api/jobs/import-job.ts +++ b/src/bun/api/jobs/import-job.ts @@ -1,10 +1,10 @@ import { eq, or } from "drizzle-orm"; import { db, plugins } from "../app"; import { createLocalGame } from "../games/services/utils"; -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; import * as schema from "@schema/app"; import z from "zod"; -import { GameLookup } from "@/shared/types"; +import { GameLookup } from "@simeonradivoev/gameflow-sdk/shared"; export class ImportJob implements IJob, string> { diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index b6809e2..a9433d4 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -1,4 +1,4 @@ -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; import fs from 'node:fs/promises'; import path from 'node:path'; import { config, events, plugins } from "../app"; @@ -11,7 +11,7 @@ import { ensureDir, move } from "fs-extra"; import { path7za } from "7zip-bin"; import StreamZip from 'node-stream-zip'; import { which } from "bun"; -import { DownloadInfo } from "@/shared/types"; +import { DownloadInfo } from "@simeonradivoev/gameflow-sdk/shared"; interface JobConfig { diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index 328c04e..f75605c 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -6,7 +6,7 @@ import TwitchLoginJob from "./twitch-login-job"; import UpdateStoreJob from "./update-store"; import { EmulatorDownloadJob } from "./emulator-download-job"; import { getErrorMessage } from "@/bun/utils"; -import { IJob } from "../task-queue"; +import { IJob } from "../../../packages/gameflow-sdk/task-queue"; import { LaunchGameJob } from "./launch-game-job"; import { BiosDownloadJob } from "./bios-download-job"; import { InstallJob } from "./install-job"; diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index d81c25c..f5072e9 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -1,13 +1,13 @@ import z from "zod"; -import { IJob, JobContext } from "../task-queue"; -import { ActiveGameSchema, ActiveGameType } from "@/bun/types/types.schema"; +import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; +import { ActiveGameSchema, ActiveGameType } from "@simeonradivoev/gameflow-sdk"; import { config, db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; import { eq } from "drizzle-orm"; import { spawn } from 'node:child_process'; import { updateLocalLastPlayed } from "../games/services/statusService"; import { getErrorMessage } from "@/bun/utils"; -import { CommandEntry, FrontEndId, SaveSlots } from "@/shared/types"; +import { CommandEntry, FrontEndId, SaveSlots } from "@simeonradivoev/gameflow-sdk/shared"; export class LaunchGameJob implements IJob, string> { diff --git a/src/bun/api/jobs/login-job.ts b/src/bun/api/jobs/login-job.ts index f0726bd..dd112ad 100644 --- a/src/bun/api/jobs/login-job.ts +++ b/src/bun/api/jobs/login-job.ts @@ -1,5 +1,5 @@ import Elysia, { status } from "elysia"; -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; import { LOGIN_PORT, SERVER_URL } from "@/shared/constants"; import { host, localIp } from "@/bun/utils/host"; import cors from "@elysiajs/cors"; diff --git a/src/bun/api/jobs/plugin-operation-job.ts b/src/bun/api/jobs/plugin-operation-job.ts new file mode 100644 index 0000000..db39819 --- /dev/null +++ b/src/bun/api/jobs/plugin-operation-job.ts @@ -0,0 +1,62 @@ +import z from "zod"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk"; +import { plugins } from "../app"; +import { canUninstall, runBunPackageCommand } from "../plugins/services"; +import { getPlugin, registerPlugin, unregisterPlugin } from "../plugins/register-plugins"; +import { PluginRegistry } from "@/shared/constants"; + +export default class PluginOperationJob implements IJob +{ + 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, 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; + } + + + } +} \ No newline at end of file diff --git a/src/bun/api/jobs/reload-plugins-job.ts b/src/bun/api/jobs/reload-plugins-job.ts index 4796fc8..5e404d3 100644 --- a/src/bun/api/jobs/reload-plugins-job.ts +++ b/src/bun/api/jobs/reload-plugins-job.ts @@ -1,5 +1,5 @@ import z from "zod"; -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk"; import { plugins } from "../app"; export default class ReloadPluginsJob implements IJob diff --git a/src/bun/api/jobs/self-update-job.ts b/src/bun/api/jobs/self-update-job.ts index 05ac4e6..ca2684e 100644 --- a/src/bun/api/jobs/self-update-job.ts +++ b/src/bun/api/jobs/self-update-job.ts @@ -1,5 +1,5 @@ import z from "zod"; -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk"; import { events } from "../app"; import { Downloader } from "@/bun/utils/downloader"; import path from 'node:path'; diff --git a/src/bun/api/jobs/twitch-login-job.ts b/src/bun/api/jobs/twitch-login-job.ts index 1023e83..42d98a9 100644 --- a/src/bun/api/jobs/twitch-login-job.ts +++ b/src/bun/api/jobs/twitch-login-job.ts @@ -1,4 +1,4 @@ -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk"; import secrets from "../secrets"; import open from "open"; import z from "zod"; diff --git a/src/bun/api/jobs/update-store.ts b/src/bun/api/jobs/update-store.ts index 48129a0..697fb3a 100644 --- a/src/bun/api/jobs/update-store.ts +++ b/src/bun/api/jobs/update-store.ts @@ -1,59 +1,57 @@ import { ensureDir } from "fs-extra"; -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk"; import { getStoreRootFolder } from "../store/services/gamesService"; -import { tmpdir } from "node:os"; -import path from "node:path"; import z from "zod"; +import { runBunPackageCommand } from "../plugins/services"; +import { PluginRegistry } from "@/shared/constants"; +import path from "node:path"; +import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json'; -export default class UpdateStoreJob implements IJob +export default class UpdateStoreJob implements IJob { static id = "update-store" as const; static dataSchema = z.never(); packageName: string; - registry: URL; storeVersion: string; constructor() { this.packageName = process.env.STORE_PACKAGE_NAME ?? "@simeonradivoev/gameflow-store"; - this.registry = new URL(process.env.STORE_REGISTRY ?? "https://registry.npmjs.org"); this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0"; } - async runCommand (commands: string[]) + async start (context: JobContext) { - const tempCache = path.join(tmpdir(), "gameflow-bun-cache"); - const storeFolder = getStoreRootFolder(); - - let proc = Bun.spawn([process.execPath, ...commands, "--registry", this.registry.href, '--json'], { - cwd: storeFolder, - stdout: 'pipe', - stderr: 'pipe', - env: { - BUN_BE_BUN: "1", - BUN_INSTALL_CACHE_DIR: tempCache - } - }); - - let stdout = await new Response(proc.stdout).text(); - console.log(stdout); - let stderr = await new Response(proc.stderr).text(); - if (stderr) - console.error(stderr); - await proc.exited; - } - - async start (context: JobContext) - { - if (process.env.CUSTOM_STORE_PATH) return; - const storeFolder = getStoreRootFolder(); await ensureDir(storeFolder); + const storePackageFile = Bun.file(path.join(storeFolder, "package.json")); + if (!await storePackageFile.exists()) + { + await storePackageFile.write(JSON.stringify({ dependencies: {} }, null, 3)); + } - console.log("Adding Store Package"); - await this.runCommand(["add", `${this.packageName}@${this.storeVersion}`]); + const storePackage = await Bun.file(path.join(storeFolder, "package.json")).json(); - console.log("Updating Store Package"); - await this.runCommand(["update", `${this.packageName}@${this.storeVersion}`]); + if (!storePackage.dependencies?.[sdkPkg.name] || storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version) + { + let response = await runBunPackageCommand(["add", `${sdkPkg.name}@${sdkPkg.version}`, "--registry", PluginRegistry, '--omit', 'peer']); + console.log(response); + } + + // probably just means we couldn't find a version of the sdk, just install latest + if (storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version) + { + let response = await runBunPackageCommand(["add", '--exact', `${sdkPkg.name}@latest`, "--registry", PluginRegistry, '--omit', 'peer']); + console.log(response); + } + + if (process.env.CUSTOM_STORE_PATH) return; + + if (!storePackage.dependencies?.['@simeonradivoev/gameflow-store']) + { + context.setProgress(0.5, "Adding Store"); + let response = await runBunPackageCommand(["add", `${this.packageName}@${this.storeVersion}`, "--registry", PluginRegistry, '--omit', 'peer']); + console.log(response); + } } } \ No newline at end of file diff --git a/src/bun/api/notifications.ts b/src/bun/api/notifications.ts index 514ee58..e1c135c 100644 --- a/src/bun/api/notifications.ts +++ b/src/bun/api/notifications.ts @@ -1,5 +1,5 @@ -import { FrontendNotification } from '@/shared/types'; +import { FrontendNotification } from '@simeonradivoev/gameflow-sdk/shared'; import { events } from './app'; export default function buildNotificationsStream () diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts index 9fe34a4..a9e6865 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts @@ -1,4 +1,4 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import path from 'node:path'; import { config } from "@/bun/api/app"; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index 111b9c5..6a44901 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -1,6 +1,6 @@ import { config } from "@/bun/api/app"; -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import path from 'node:path'; import desc from './package.json'; import { ensureDir } from "fs-extra"; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index 23c2736..58d61aa 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -1,12 +1,12 @@ import { config } from "@/bun/api/app"; -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import defaultConfig from './PCSX2.ini' with { type: 'file' }; import path from 'node:path'; import { ensureDir } from "fs-extra"; import desc from './package.json'; import ini from 'ini'; -import { EmulatorCapabilities } from "@/shared/types"; +import { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared"; export default class PCSX2Integration implements PluginType { diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index 87886dd..f69fdaf 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -1,4 +1,4 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import { config } from "@/bun/api/app"; import configFilePathWin32 from './win32/ppsspp.ini' with { type: 'file' }; @@ -11,7 +11,7 @@ import { ensureDir } from "fs-extra"; import { homedir } from "node:os"; import ini from 'ini'; import fs from 'node:fs/promises'; -import { EmulatorCapabilities } from "@/shared/types"; +import { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared"; export default class PPSSPPIntegration implements PluginType { diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts index ff8c3f9..57506de 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts @@ -1,4 +1,4 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import { config } from "@/bun/api/app"; import path from "node:path"; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts index eb0715d..9d37d99 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts @@ -1,6 +1,6 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; -import GameflowHooks from "@/bun/api/hooks/app"; +import { GameflowHooks } from "@simeonradivoev/gameflow-sdk"; import { config } from "@/bun/api/app"; import path from "node:path"; import { ensureDir } from "fs-extra"; diff --git a/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts index cf57e13..77cd201 100644 --- a/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts +++ b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts @@ -1,4 +1,4 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import { config, customEmulators, db, emulatorsDb } from "@/bun/api/app"; import * as emulatorSchema from '@schema/emulators'; @@ -13,7 +13,7 @@ import { findStoreEmulatorExec } from "@/bun/api/games/services/launchGameServic import { which } from "bun"; import os from 'node:os'; import { getLocalGameMatch } from "@/bun/api/games/services/utils"; -import { CommandEntry, EmulatorSourceEntryType } from "@/shared/types"; +import { CommandEntry, EmulatorSourceEntryType } from "@simeonradivoev/gameflow-sdk/shared"; export default class IgdbIntegration implements PluginType { diff --git a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts index a31c94d..8ab31a0 100644 --- a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts +++ b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts @@ -1,4 +1,4 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import { config, db, events } from "@/bun/api/app"; import path from 'node:path'; diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts index 7c39e01..c2be6d3 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts @@ -1,10 +1,10 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import secrets from "@/bun/api/secrets"; import PQueue from 'p-queue'; import * as igdb from '@phalcode/ts-igdb-client'; import { checkLoginAndRefreshTwitch } from "@/bun/api/auth"; -import { GameLookup } from "@/shared/types"; +import { GameLookup } from "@simeonradivoev/gameflow-sdk/shared"; export default class IgdbIntegration implements PluginType { diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index ac9474f..93c6fbe 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -1,6 +1,6 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, PlatformSchema, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; import { config, events } from "@/bun/api/app"; @@ -14,7 +14,7 @@ import { client } from "@/clients/romm/client.gen"; import { validateGameSource } from "@/bun/api/games/services/statusService"; import z from "zod"; import { checkLoginAndRefreshRomm } from "@/bun/api/auth"; -import { DownloadFileEntry, DownloadInfo, FrontEndCollection, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, FrontEndGameTypeWithIds, FrontEndPlatformType } from "@/shared/types"; +import { DownloadFileEntry, DownloadInfo, FrontEndCollection, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, FrontEndGameTypeWithIds, FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared"; import Conf from "conf"; const SettingsSchema = z.object({ diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json index 713f76f..644c332 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json @@ -1,8 +1,8 @@ { "name": "com.simeonradivoev.gameflow.store", - "displayName": "Gameflow Store", + "displayName": "Gameflow Store Integration", "version": "0.0.1", - "description": "The internal gameflow store", + "description": "The internal gameflow store integration. This is the logic of the store that uses the data only store package", "main": "./store.ts", "category": "sources", "canDisable": false, diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts index de08c46..17c5f12 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts @@ -1,5 +1,4 @@ import { getStoreFolder } from "@/bun/api/store/services/gamesService"; -import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, StoreDownloadType, StoreGameSchema, StoreGameType } from "@/shared/constants"; import os from 'node:os'; import path from "node:path"; import * as appSchema from '@schema/app'; @@ -12,7 +11,7 @@ import { shuffleInPlace } from "@/bun/utils"; import mustache from "mustache"; import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; import fs from "node:fs/promises"; -import { CommandEntry, EmulatorSourceEntryType, EmulatorSystem, FrontEndEmulator, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, SaveFileChange } from "@/shared/types"; +import { CommandEntry, EmulatorSourceEntryType, EmulatorSystem, FrontEndEmulator, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, SaveFileChange, EmulatorDownloadInfoType, StoreDownloadType, StoreGameType, EmulatorPackageType, EmulatorDownloadInfoSchema, StoreGameSchema } from "@simeonradivoev/gameflow-sdk/shared"; export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; }) { diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts index 3215548..9b514a2 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts @@ -1,4 +1,4 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import path, { } from 'node:path'; import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; @@ -12,7 +12,7 @@ import { getSourceGameDetailed } from "@/bun/api/games/services/utils"; import UpdateStoreJob from "@/bun/api/jobs/update-store"; import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services"; -import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@/shared/types"; +import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@simeonradivoev/gameflow-sdk/shared"; export default class RommIntegration implements PluginType { @@ -151,7 +151,8 @@ export default class RommIntegration implements PluginType if (!validDownload || !validDownload.bin) return; const glob = new Glob(validDownload.bin); const files = await Array.fromAsync(glob.scan({ cwd: emulatorPath })); - if (files.length > 0) + // es-de also searches for store executables so there might be duplicates, check first. + if (files.length > 0 && !sources.find(s => s.type === 'store')) { sources.push({ binPath: path.join(emulatorPath, files[0]), exists: true, rootPath: emulatorPath, type: 'store' }); } diff --git a/src/bun/api/plugins/plugin-manager.ts b/src/bun/api/plugins/plugin-manager.ts index e3511b5..1fab907 100644 --- a/src/bun/api/plugins/plugin-manager.ts +++ b/src/bun/api/plugins/plugin-manager.ts @@ -1,10 +1,13 @@ -import GameflowHooks from "../hooks/app"; -import { PluginDescriptionType, PluginLoadingContextType, PluginType } from "../../types/types.schema"; -import { config } from "../app"; +import { GameflowHooks } from "@simeonradivoev/gameflow-sdk"; +import { PluginDescriptionType, PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; +import { config, events, taskQueue } from "../app"; import Conf from "conf"; import projectPackage from '~/package.json'; import z from "zod"; -import { PluginSourceType } from "@/shared/types"; +import { PluginSourceType, PluginUpdateCheck } from "@simeonradivoev/gameflow-sdk/shared"; +import { getUpdates } from "./services"; +import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json'; +import { semver } from "bun"; export const pluginZodRegistry = z.registry<{ requiresRestart?: boolean; @@ -21,9 +24,19 @@ export class PluginManager description: PluginDescriptionType, source: PluginSourceType; config?: Conf; + update?: PluginUpdateCheck; + incompatible?: boolean; }> = {}; + unregister (id: string) + { + if (!this.plugins[id]) return false; + delete this.plugins[id]; + console.log("Plugin", id, "unregistered"); + return true; + } + register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType) { try @@ -68,16 +81,33 @@ export class PluginManager }; } - private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; }) + checkValidity (plugin: PluginDescriptionType) + { + const sdkDep = plugin.peerDependencies?.[sdkPkg.name]; + if (sdkDep) + { + return semver.satisfies(sdkPkg.version, sdkDep); + } + return true; + } + + private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; }, update: string | undefined) { const plugin = this.plugins[name]; if (plugin) { + plugin.update = update && !semver.satisfies(plugin.description.version, update) ? { current: plugin.description.version, new: update } : undefined; + const ctx: PluginLoadingContextType = { hooks: this.hooks, setProgress: reloadCtx.setProgress.bind(reloadCtx), config: plugin.config as any, - zodRegistry: pluginZodRegistry + zodRegistry: pluginZodRegistry, + app: { + config, + events, + taskQueue + } }; if (plugin.loaded) @@ -88,7 +118,14 @@ export class PluginManager try { - if (plugin.enabled || plugin.description.canDisable === false) + plugin.incompatible = !this.checkValidity(plugin.description); + if (plugin.incompatible) + { + console.error(plugin.description.name, "Incompatible sdk verison"); + return; + } + + if (plugin.enabled || plugin.description.canDisable === false || plugin.description.name === '@simeonradivoev/gameflow-store') { console.log("Loading Plugin", plugin.description.name); await plugin.plugin.load(ctx); @@ -106,10 +143,13 @@ export class PluginManager async reloadAll (ctx: { setProgress: (progress: number, state: string) => void; }) { this.hooks = new GameflowHooks(); + + const outdated = await getUpdates(); + for await (const id of Object.keys(this.plugins)) { ctx.setProgress(0, `Loading ${id}`); - await this.reload(id, ctx); + await this.reload(id, ctx, outdated?.[id]); } } diff --git a/src/bun/api/plugins/plugins.ts b/src/bun/api/plugins/plugins.ts index eed9466..ddfad06 100644 --- a/src/bun/api/plugins/plugins.ts +++ b/src/bun/api/plugins/plugins.ts @@ -3,7 +3,9 @@ import { plugins, taskQueue } from "../app"; import z from "zod"; import { toggleElementInConfig } from "@/bun/utils"; import ReloadPluginsJob from "../jobs/reload-plugins-job"; -import { FrontendPlugin } from "@/shared/types"; +import { FrontendPlugin } from "@simeonradivoev/gameflow-sdk/shared"; +import { canDisable, canUninstall } from "./services"; +import PluginOperationJob from "../jobs/plugin-operation-job"; export default new Elysia({ prefix: '/plugins' }) .get('/', async () => @@ -17,25 +19,27 @@ export default new Elysia({ prefix: '/plugins' }) description: p.description.description, source: p.source, version: p.description.version, - canDisable: p.description.canDisable ?? true, + canDisable: canDisable(p.description), icon: p.description.icon, category: p.description.category, - hasSettings: !!p.config || !!p.plugin.eventsNames + hasSettings: !!p.config || !!p.plugin.eventsNames, + canUninstall: canUninstall(p.description, p.source), + update: p.update }; return plugin; }); }) .get('/:id', async ({ params: { id } }) => { - const plugin = plugins.plugins[id]; - return plugin.description; + const plugin = plugins.plugins[decodeURIComponent(id)]; + return { ...plugin.description, update: plugin.update }; }) .post('/:id', async ({ params: { id }, body: { enabled } }) => { - const plugin = plugins.plugins[id]; + const plugin = plugins.plugins[decodeURIComponent(id)]; if (plugin) { - if (plugin.description.canDisable === false) + if (!canDisable(plugin.description)) { return status("Forbidden"); } @@ -48,4 +52,26 @@ export default new Elysia({ prefix: '/plugins' }) } }, { body: z.object({ enabled: z.boolean() }) + }).post('/install', async ({ body: { id } }) => + { + if (taskQueue.hasActiveOfType(PluginOperationJob) || taskQueue.hasActiveOfType(ReloadPluginsJob)) return; + await taskQueue.enqueue(PluginOperationJob.id, new PluginOperationJob("add", id)); + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); + }, { + body: z.object({ id: z.string() }) + }).post('/update', async ({ body: { id } }) => + { + if (taskQueue.hasActiveOfType(PluginOperationJob) || taskQueue.hasActiveOfType(ReloadPluginsJob)) return; + await taskQueue.enqueue(PluginOperationJob.id, new PluginOperationJob("update", id)); + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); + }, { + body: z.object({ id: z.string() }) + }) + .post('/uninstall', async ({ body: { id } }) => + { + if (taskQueue.hasActiveOfType(PluginOperationJob) || taskQueue.hasActiveOfType(ReloadPluginsJob)) return; + await taskQueue.enqueue(PluginOperationJob.id, new PluginOperationJob("remove", id)); + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); + }, { + body: z.object({ id: z.string() }) }); \ No newline at end of file diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts index ead6f54..1275740 100644 --- a/src/bun/api/plugins/register-plugins.ts +++ b/src/bun/api/plugins/register-plugins.ts @@ -11,12 +11,78 @@ import igdb from './builtin/sources/com.simeonradivoev.gameflow.igdb/package.jso import store from './builtin/sources/com.simeonradivoev.gameflow.store/package.json'; import es from './builtin/launchers/com.simeonradivoev.gameflow.es/package.json'; import rclone from './builtin/other/com.simeonradivoev.gameflow.rclone/package.json'; -import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/types.schema"; +import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@simeonradivoev/gameflow-sdk"; import path from 'node:path'; import { getStoreRootFolder } from "../store/services/gamesService"; +import { getUpdates } from "./services"; +import { PluginSourceType } from "@simeonradivoev/gameflow-sdk/shared"; +import { taskQueue } from "../app"; +import UpdateStoreJob from "../jobs/update-store"; type PluginEntry = PluginDescriptionType & { load: () => Promise; }; +const blacklist = new Set(['@simeonradivoev/gameflow-sdk']); + +export async function getPlugin (id: string, pluginManager: PluginManager) +{ + const pluginPath = path.join(getStoreRootFolder(), 'node_modules', id); + const pluginPackageFile = Bun.file(path.join(pluginPath, 'package.json')); + if (await pluginPackageFile.exists()) + { + const pluginPackage = await PluginDescriptionSchema.safeParseAsync(await pluginPackageFile.json()); + if (pluginPackage.success) + { + const mainPath = path.join(pluginPath, pluginPackage.data.main); + if (await Bun.file(mainPath).exists()) + { + const entry: PluginEntry = { ...pluginPackage.data, load: () => import(mainPath) }; + return entry; + } else + { + console.error("Main file for", id, "does not exist"); + } + } else + { + console.error("Invalid Package for", id, pluginPackage.error.message); + } + } else + { + console.error("Package for", id, "does not exist"); + } +} + +export async function unregisterPlugin (id: string, pluginManager: PluginManager) +{ + return pluginManager.unregister(id); +} + +export async function registerPlugin (plugin: PluginEntry, source: PluginSourceType, pluginManager: PluginManager) +{ + if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(plugin.name)) + { + console.log("Skipping", plugin.name, "missing in whitelist"); + return; + } + + if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(plugin.name)) + { + console.log("Skipping", plugin.name, "found in whitelist"); + return; + } + + const file = await plugin.load(); + if (file.default && typeof file.default === 'function') + { + const pluginInstance = new file.default(); + await PluginSchema.parseAsync(pluginInstance); + const description = await PluginDescriptionSchema.parseAsync(plugin); + pluginManager.register(pluginInstance, description, source); + } else + { + console.log("Skipping", plugin.name, "invalid main. Has to be class with load method"); + } +} + export default async function register (pluginManager: PluginManager) { const plugins: PluginEntry[] = [ @@ -33,53 +99,41 @@ export default async function register (pluginManager: PluginManager) { ...rclone, load: () => import('./builtin/other/com.simeonradivoev.gameflow.rclone/rclone') }, ]; - const storePackageFile = path.join(getStoreRootFolder(), 'package.json'); - const storePackage = await Bun.file(storePackageFile).json(); + await Promise.all(plugins.map(p => registerPlugin(p, 'builtin', pluginManager))); - if (storePackage.dependencies) + const storePackageFilePath = path.join(getStoreRootFolder(), 'package.json'); + if (!await Bun.file(storePackageFilePath).exists()) { - const storePlugins = await Promise.all(Object.keys(storePackage.dependencies).map(async p => + console.log("Store is missing. Updating it."); + await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); + console.log("Store Updated"); + } + const storePackage = await Bun.file(storePackageFilePath).json(); + + if (storePackage?.dependencies) + { + const storePlugins = await Promise.all(Object.keys(storePackage.dependencies).filter(p => !blacklist.has(p)).map(async p => { - const pluginPath = path.join(getStoreRootFolder(), 'node_modules', p); - const pluginPackageFile = Bun.file(path.join(pluginPath, 'package.json')); - if (await pluginPackageFile.exists()) - { - const pluginPackage = await PluginDescriptionSchema.safeParseAsync(await pluginPackageFile.json()); - if (pluginPackage.success) - { - const mainPath = path.join(pluginPath, pluginPackage.data.main); - if (await Bun.file(mainPath).exists()) - { - const entry: PluginEntry = { ...pluginPackage.data, load: () => import(mainPath) }; - return entry; - } - } - } + return getPlugin(p, pluginManager); })); - plugins.push(...storePlugins.filter(p => !!p)); - } + console.log("Checking for outdated packages"); + const outdated = await getUpdates(); - await Promise.all(plugins.filter(p => - { - if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(p.name)) + const validPlugins = storePlugins.filter(p => !!p); + + if (outdated) { - return false; + validPlugins.forEach(p => + { + const newVersion = outdated[p.name]; + if (newVersion) + { + console.log("Plugin", p.name, "has update", p.version, "=>", newVersion); + } + }); } - if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(p.name)) - { - return false; - } - return true; - }).map(async (pluginPackage) => - { - const file = await pluginPackage.load(); - if (file.default && typeof file.default === 'function') - { - const pluginInstance = new file.default(); - await PluginSchema.parseAsync(pluginInstance); - const description = await PluginDescriptionSchema.parseAsync(pluginPackage); - pluginManager.register(pluginInstance, description, 'builtin'); - } - })); + + await Promise.all(validPlugins.map(p => registerPlugin(p, 'store', pluginManager))); + } } \ No newline at end of file diff --git a/src/bun/api/plugins/services.ts b/src/bun/api/plugins/services.ts new file mode 100644 index 0000000..9452b7e --- /dev/null +++ b/src/bun/api/plugins/services.ts @@ -0,0 +1,62 @@ +import path from 'node:path'; +import os from 'node:os'; +import { getStoreRootFolder } from '../store/services/gamesService'; +import { PluginDescriptionType } from '@simeonradivoev/gameflow-sdk'; +import { run } from 'npm-check-updates'; + +export function canDisable (description: PluginDescriptionType) +{ + if (description.name === '@simeonradivoev/gameflow-store') + { + return false; + } + return description.canDisable ?? true; +} + +export async function getUpdates () +{ + const updated = await run({ packageManager: 'bun', peer: true, cwd: getStoreRootFolder(), jsonUpgraded: true, reject: ['@simeonradivoev/gameflow-sdk'] }); + return updated as Record; +} + +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]; +} \ No newline at end of file diff --git a/src/bun/api/schema/app.ts b/src/bun/api/schema/app.ts index a30c4fb..7db68ba 100644 --- a/src/bun/api/schema/app.ts +++ b/src/bun/api/schema/app.ts @@ -1,4 +1,5 @@ -import { LocalGameMetadata } from "@/shared/types"; + +import { LocalGameMetadata } from "@simeonradivoev/gameflow-sdk/shared"; import { sql, relations } from "drizzle-orm"; import { integer, text, sqliteTable, blob } from "drizzle-orm/sqlite-core"; diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index a560de6..e0897ea 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -7,7 +7,7 @@ import { cores } from '../emulatorjs/emulatorjs'; import { SERVER_URL } from '@/shared/constants'; import { host } from '@/bun/utils/host'; import { findEmulatorPluginIntegration } from '../store/services/emulatorsService'; -import { EmulatorSourceEntryType, FrontEndEmulator } from '@/shared/types'; +import { EmulatorSourceEntryType, FrontEndEmulator } from '@simeonradivoev/gameflow-sdk/shared'; /** * Get emulators based on local games. Only the ones we probably need. diff --git a/src/bun/api/settings/settings.ts b/src/bun/api/settings/settings.ts index c315701..e4e2da1 100644 --- a/src/bun/api/settings/settings.ts +++ b/src/bun/api/settings/settings.ts @@ -1,5 +1,5 @@ import z from "zod"; -import { SettingsSchema } from "@shared/constants"; +import { SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared'; import Elysia, { status } from "elysia"; import { config, customEmulators, plugins, taskQueue } from "../app"; import fs from 'node:fs/promises'; @@ -96,27 +96,27 @@ export const settings = new Elysia({ prefix: '/api/settings' }) }) .get('/definitions/:source', async ({ params: { source } }) => { - return plugins.plugins[source].plugin.settingsSchema?.toJSONSchema() as JSONSchema7; + return plugins.plugins[decodeURIComponent(source)].plugin.settingsSchema?.toJSONSchema() as JSONSchema7; }) .get('/actions/:source', async ({ params: { source } }) => { - const plugin = plugins.plugins[source]?.plugin; + const plugin = plugins.plugins[decodeURIComponent(source)]?.plugin; if (!plugin.eventsNames) return []; return plugin.eventsNames; }) .post('/actions/:source/:id', async ({ params: { source, id } }) => { - return await plugins.plugins[source]?.plugin.onEvent?.(id); + return await plugins.plugins[decodeURIComponent(source)]?.plugin.onEvent?.(decodeURIComponent(id)); }) .get('/:source/:id', async ({ params: { source, id } }) => { - return { value: plugins.plugins[source].config?.get(id) }; + return { value: plugins.plugins[decodeURIComponent(source)].config?.get(decodeURIComponent(id)) }; }) .put('/:source/:id', async ({ params: { source, id }, body: { value } }) => { - const plugin = plugins.plugins[source]; + const plugin = plugins.plugins[decodeURIComponent(source)]; if (!plugin.config) return status("Not Found", "Plugin has no config"); - const settingSchema = plugin.plugin.settingsSchema?.shape[id] as z.ZodObject; + const settingSchema = plugin.plugin.settingsSchema?.shape[decodeURIComponent(id)] as z.ZodObject; if (!settingSchema) return status("Not Found", "Could not find setting"); const meta = pluginZodRegistry.get(settingSchema); diff --git a/src/bun/api/store/services/emulatorsService.ts b/src/bun/api/store/services/emulatorsService.ts index c61ed00..6dfcde1 100644 --- a/src/bun/api/store/services/emulatorsService.ts +++ b/src/bun/api/store/services/emulatorsService.ts @@ -1,8 +1,7 @@ -import { EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants"; import { config, plugins } from "../../app"; import { getOrCached, getOrCachedGithubRelease } from "../../cache"; import path from "node:path"; -import { EmulatorSourceEntryType, EmulatorSupport } from "@/shared/types"; +import { EmulatorSourceEntryType, EmulatorSupport, ScoopPackageSchema, EmulatorPackageType, EmulatorDownloadInfoType } from "@simeonradivoev/gameflow-sdk/shared"; export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]): EmulatorSupport[] { diff --git a/src/bun/api/store/services/gamesService.ts b/src/bun/api/store/services/gamesService.ts index f2149ff..b475b89 100644 --- a/src/bun/api/store/services/gamesService.ts +++ b/src/bun/api/store/services/gamesService.ts @@ -1,10 +1,9 @@ -import { EmulatorPackageSchema, EmulatorPackageType } from "@/shared/constants"; import { and, eq, or } from "drizzle-orm"; import { config, emulatorsDb } from '../../app'; import path from "node:path"; import fs from 'node:fs/promises'; import * as emulatorSchema from '@schema/emulators'; -import { EmulatorSystem } from "@/shared/types"; +import { EmulatorSystem, EmulatorPackageType, EmulatorPackageSchema } from "@simeonradivoev/gameflow-sdk/shared"; export function getStoreRootFolder () { diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 85463b3..39d5630 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -3,7 +3,6 @@ import Elysia, { status } from "elysia"; import { config, db, plugins, taskQueue } from "../app"; import path from "node:path"; import fs from 'node:fs/promises'; -import { EmulatorDownloadInfoSchema } from "@/shared/constants"; import * as appSchema from '@schema/app'; import z from "zod"; import { convertLocalToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; @@ -13,7 +12,17 @@ import { getStoreFolder } from "./services/gamesService"; import { EmulatorDownloadJob } from "../jobs/emulator-download-job"; import { BiosDownloadJob } from "../jobs/bios-download-job"; import { findEmulatorPluginIntegration, getEmulatorPath } from "./services/emulatorsService"; -import { EmulatorSourceEntryType, FrontEndEmulator, FrontEndGameTypeDetailed } from "@/shared/types"; +import { EmulatorSourceEntryType, FrontEndEmulator, FrontEndGameTypeDetailed, PluginBunDetailsSchema, PluginEntrySchema, EmulatorDownloadInfoSchema } from "@simeonradivoev/gameflow-sdk/shared"; +import PQueue from "p-queue"; +import { hasPackage, runBunPackageCommand } from "../plugins/services"; +import { semver } from "bun"; + +const npmQueue = new PQueue({ intervalCap: 60, interval: 1000 * 60, strict: true }); +const pluginsResponseSchema = z.object({ + objects: z.array(PluginEntrySchema), + total: z.number(), + time: z.coerce.date() +}); export const store = new Elysia({ prefix: '/api/store' }) .get('/emulators', async ({ query }) => @@ -109,6 +118,49 @@ export const store = new Elysia({ prefix: '/api/store' }) gameCount }; }) + .get('/plugin', async ({ query: { plugin } }) => + { + const pluginsRes = await runBunPackageCommand(['info', plugin]); + const pluginData = await PluginBunDetailsSchema.parseAsync(JSON.parse(pluginsRes)); + const existingVersion = plugins.plugins[plugin]?.description.version; + + return { + ...pluginData, + installed: !!plugins.plugins[plugin] || await hasPackage(plugin), + update: existingVersion && semver.order(pluginData.version, existingVersion) > 0 ? { from: existingVersion } : undefined + }; + }, + { + query: z.object({ plugin: z.string() }) + }) + .get('/plugins', async ({ query: { search } }) => + { + //TODO: Find a better way to search keywords and a search term at the same time + const pluginsRes = await npmQueue.add(() => fetch(`https://registry.npmjs.com/-/v1/search?text=keywords:gameflow-plugin`)); + if (!pluginsRes.ok) return status(pluginsRes.status, pluginsRes.statusText); + const data: z.infer = await pluginsRes.json(); + if (search) + { + data.objects = data.objects.filter(o => + { + if (o.package.description && o.package.description.includes(search)) return true; + if (o.package.name.includes(search)) return true; + if (o.package.keywords.includes(search)) return true; + return false; + }); + data.total = data.objects.length; + } + await Promise.all(data.objects.map(async o => + { + const existingVersion = plugins.plugins[o.package.name]?.description.version; + o.installed = !!plugins.plugins[o.package.name] || await hasPackage(o.package.name); + o.update = existingVersion && semver.order(o.package.version, existingVersion) > 0 ? { from: existingVersion } : undefined; + })); + return data as any; + }, { + query: z.object({ search: z.string().optional() }), + response: pluginsResponseSchema + }) .get('/media/*', async ({ params }) => { return Bun.file(path.join(getStoreFolder(), params["*"])); diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 66e7742..5408a6d 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -7,7 +7,7 @@ import { getAppVersion, isSteamDeck, openExternal } from "../utils"; import fs from 'node:fs/promises'; import buildNotificationsStream from "./notifications"; import path, { dirname } from "node:path"; -import { DirSchema, SystemInfoSchema } from "@/shared/constants"; +import { SystemInfoSchema, DirSchema, DownloadsDrive } from '@simeonradivoev/gameflow-sdk/shared'; import { getDevices, getDevicesCurated } from "./drives"; import getFolderSize from "get-folder-size"; import si from 'systeminformation'; @@ -16,7 +16,6 @@ import ReloadPluginsJob from "./jobs/reload-plugins-job"; import { semver } from "bun"; import { getOrCachedGithubRelease } from "./cache"; import SelfUpdateJob from "./jobs/self-update-job"; -import { DownloadsDrive } from "@/shared/types"; async function checkUpdate (force?: boolean) { diff --git a/src/bun/types/types.ts b/src/bun/types/types.ts deleted file mode 100644 index 6802ff9..0000000 --- a/src/bun/types/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { EmulatorDownloadInfoType, EmulatorPackageType } from "@/shared/constants"; -import { FrontendNotification } from "@/shared/types"; - -export interface AppEventMap -{ - exitapp: []; - notification: [FrontendNotification]; - focus: []; -} - -export interface EmulatorPostInstallContext -{ - emulator: string; - emulatorPackage?: EmulatorPackageType; - path: string; - update: boolean; - info: EmulatorDownloadInfoType; -} \ No newline at end of file diff --git a/src/bun/utils.ts b/src/bun/utils.ts index f03a42c..fe44ad2 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -1,10 +1,9 @@ import { $, sleep } from 'bun'; import path from 'node:path'; -import { SettingsType } from '@/shared/constants'; +import { SettingsType, KeysWithValueAssignableTo } from '@simeonradivoev/gameflow-sdk/shared'; import { config } from './api/app'; import fs from 'node:fs/promises'; import packageDef from '~/package.json'; -import { KeysWithValueAssignableTo } from '@/shared/types'; export function checkRunning (pid: number) { diff --git a/src/bun/utils/downloader.ts b/src/bun/utils/downloader.ts index 000542a..f0f30ca 100644 --- a/src/bun/utils/downloader.ts +++ b/src/bun/utils/downloader.ts @@ -5,7 +5,7 @@ import fs from 'node:fs/promises'; import { createWriteStream } from "node:fs"; import { config, jar } from "../api/app"; import { moveAllFiles } from "../utils"; -import { DownloadFileEntry } from "@/shared/types"; +import { DownloadFileEntry } from "@simeonradivoev/gameflow-sdk/shared"; export interface ProgressStats { diff --git a/src/mainview/components/AppCommunication.tsx b/src/mainview/components/AppCommunication.tsx index bbb26c3..3dec17a 100644 --- a/src/mainview/components/AppCommunication.tsx +++ b/src/mainview/components/AppCommunication.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { SystemInfoContext } from "../scripts/contexts"; import { systemApi } from "../scripts/clientApi"; -import { SystemInfoType } from "@/shared/constants"; +import { SystemInfoType } from '@simeonradivoev/gameflow-sdk/shared'; import LoadingScreen from "./LoadingScreen"; import { GamepadKeyboard } from "./GamepadKeyboard"; diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index 9d6632c..72c391a 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -5,7 +5,7 @@ import { JSX, Suspense } from 'react'; import { FloatingShortcuts } from './Shortcuts'; import { AutoFocus } from './AutoFocus'; import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts'; -import { GameListFilterType } from '@/shared/constants'; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; import { HandleGoBack } from '../scripts/utils'; import LoadingCardList from './LoadingCardList'; import { useQuery, useQueryClient } from '@tanstack/react-query'; diff --git a/src/mainview/components/FilePicker.tsx b/src/mainview/components/FilePicker.tsx index 67c1a8b..aefa842 100644 --- a/src/mainview/components/FilePicker.tsx +++ b/src/mainview/components/FilePicker.tsx @@ -4,7 +4,7 @@ import { FocusEventHandler, useContext, useRef, useState } from "react"; import path from "pathe"; import { Check, File, FileInput, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import { DirType } from "@/shared/constants"; +import { DirType } from '@simeonradivoev/gameflow-sdk/shared'; import classNames from "classnames"; import { twMerge } from "tailwind-merge"; import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"; diff --git a/src/mainview/components/FrontEndGameCard.tsx b/src/mainview/components/FrontEndGameCard.tsx index c6b8e12..093be25 100644 --- a/src/mainview/components/FrontEndGameCard.tsx +++ b/src/mainview/components/FrontEndGameCard.tsx @@ -4,7 +4,7 @@ import { FileQuestion, HardDrive, Store } from "lucide-react"; import { JSX } from "react"; import { FOCUS_KEYS } from "../scripts/types"; import { useRouter } from "@tanstack/react-router"; -import { FrontEndGameType, FrontEndId } from "@/shared/types"; +import { FrontEndGameType, FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; export default function FrontEndGameCard (data: { index: number, game: FrontEndGameType; showSource?: boolean; } & FocusParams & InteractParams) { diff --git a/src/mainview/components/GameList.tsx b/src/mainview/components/GameList.tsx index 67e689e..80c4944 100644 --- a/src/mainview/components/GameList.tsx +++ b/src/mainview/components/GameList.tsx @@ -1,13 +1,14 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { GameMetaExtra, CardList } from "./CardList"; -import { DefaultRommStaleTime, GameListFilterType, RPC_URL } from "@shared/constants"; +import { DefaultRommStaleTime, RPC_URL } from "@shared/constants"; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; import { useNavigate } from "@tanstack/react-router"; import { HardDrive } from "lucide-react"; import { JSX, useContext } from "react"; import { useLocalSetting } from "../scripts/utils"; import { AnimatedBackgroundContext } from "../scripts/contexts"; import { allGamesQuery } from "@queries/romm"; -import { FrontEndGameType, FrontEndId } from "@/shared/types"; +import { FrontEndGameType, FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; export interface GameListParams extends FocusParams { diff --git a/src/mainview/components/GamepadKeyboard.tsx b/src/mainview/components/GamepadKeyboard.tsx index 7f3b994..37e533b 100644 --- a/src/mainview/components/GamepadKeyboard.tsx +++ b/src/mainview/components/GamepadKeyboard.tsx @@ -60,7 +60,7 @@ function buildWheel (side: 0 | 1, shift: boolean, characters: boolean) const elements: JSX.Element[] = []; const refs: RefObject[] = []; const positions: { left: string; top: string; }[] = []; - const W = 258, C = 129, R2 = 107, R1 = 42, n = GetKeys(characters)[side].length, GAP = 0.028; + const n = GetKeys(characters)[side].length, GAP = 0.028; for (let i = 0; i < n; i++) { diff --git a/src/mainview/components/HeaderSearchField.tsx b/src/mainview/components/HeaderSearchField.tsx index 36d0eb0..823af58 100644 --- a/src/mainview/components/HeaderSearchField.tsx +++ b/src/mainview/components/HeaderSearchField.tsx @@ -5,7 +5,6 @@ import { oneShot } from "../scripts/audio/audio"; import { Search } from "lucide-react"; import { RoundButton } from "./RoundButton"; import { useEventListener } from "usehooks-ts"; -import useActiveControl from "../scripts/gamepads"; import { twMerge } from "tailwind-merge"; function SearchInput (data: { diff --git a/src/mainview/components/LoadMoreButton.tsx b/src/mainview/components/LoadMoreButton.tsx index d042049..d52e4e0 100644 --- a/src/mainview/components/LoadMoreButton.tsx +++ b/src/mainview/components/LoadMoreButton.tsx @@ -1,6 +1,7 @@ import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { FOCUS_KEYS } from "../scripts/types"; import { useIntersectionObserver } from "usehooks-ts"; +import { FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; export default function LoadMoreButton (data: { isFetching: boolean; hidden?: boolean, lastId?: FrontEndId; } & FocusParams & InteractParams) { diff --git a/src/mainview/components/Notifications.tsx b/src/mainview/components/Notifications.tsx index 4fbe03d..c13c4b6 100644 --- a/src/mainview/components/Notifications.tsx +++ b/src/mainview/components/Notifications.tsx @@ -1,5 +1,5 @@ import { RPC_URL } from "@/shared/constants"; -import { FrontendNotification } from "@/shared/types"; +import { FrontendNotification } from "@simeonradivoev/gameflow-sdk/shared"; import { Clock, CloudUpload, Save } from "lucide-react"; import { useEffect } from "react"; import toast, { ToastOptions } from "react-hot-toast"; diff --git a/src/mainview/components/SideFilters.tsx b/src/mainview/components/SideFilters.tsx index 180030d..6f99336 100644 --- a/src/mainview/components/SideFilters.tsx +++ b/src/mainview/components/SideFilters.tsx @@ -1,4 +1,4 @@ -import { GameListFilterType } from "@/shared/constants"; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; import { RoundButton } from "./RoundButton"; import classNames from "classnames"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; @@ -6,7 +6,7 @@ import { useFocusable, FocusContext } from "@noriginmedia/norigin-spatial-naviga import { ArrowDownAz, ClockArrowDown, CalendarArrowDown, Rocket, HardDrive, SortDesc, User, Drama, FunnelX, Store } from "lucide-react"; import { sourceIconMap } from "./Constants"; import { useContextDialog, ContextList, DialogEntry } from "./ContextDialog"; -import { FrontEndFilterLists } from "@/shared/types"; +import { FrontEndFilterLists } from "@simeonradivoev/gameflow-sdk/shared"; function FilterButton (data: { id: string, diff --git a/src/mainview/components/game/Achievements.tsx b/src/mainview/components/game/Achievements.tsx index e9445cb..9fbe814 100644 --- a/src/mainview/components/game/Achievements.tsx +++ b/src/mainview/components/game/Achievements.tsx @@ -1,5 +1,5 @@ -import { FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement } from "@/shared/types"; +import { FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement } from "@simeonradivoev/gameflow-sdk/shared"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { Medal } from "lucide-react"; diff --git a/src/mainview/components/game/ActionButtons.tsx b/src/mainview/components/game/ActionButtons.tsx index 02db473..1a60c93 100644 --- a/src/mainview/components/game/ActionButtons.tsx +++ b/src/mainview/components/game/ActionButtons.tsx @@ -10,7 +10,7 @@ import ActionButton from "./ActionButton"; import { useLocalStorage } from "usehooks-ts"; import FocusTooltip from "../FocusTooltip"; import { useBlocker, useNavigate, useRouter } from "@tanstack/react-router"; -import { FrontEndGameTypeDetailed } from "@/shared/types"; +import { FrontEndGameTypeDetailed } from "@simeonradivoev/gameflow-sdk/shared"; function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractParams) { diff --git a/src/mainview/components/game/Details.tsx b/src/mainview/components/game/Details.tsx index c0ac4ea..99a7054 100644 --- a/src/mainview/components/game/Details.tsx +++ b/src/mainview/components/game/Details.tsx @@ -10,7 +10,7 @@ import prettyMilliseconds from 'pretty-ms'; import { useQuery } from "@tanstack/react-query"; import { validateSourceQuery } from "@/mainview/scripts/queries/romm"; import { sourceIconMap } from "../Constants"; -import { FrontEndGameTypeDetailed } from "@/shared/types"; +import { FrontEndGameTypeDetailed } from "@simeonradivoev/gameflow-sdk/shared"; export function DetailElement (data: { icon: JSX.Element; tooltip?: string | null, children?: any | any[]; }) { diff --git a/src/mainview/components/game/GameLookup.tsx b/src/mainview/components/game/GameLookup.tsx index 3b15009..bac4928 100644 --- a/src/mainview/components/game/GameLookup.tsx +++ b/src/mainview/components/game/GameLookup.tsx @@ -6,7 +6,7 @@ import HeaderSearchField from "../HeaderSearchField"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import { scrollIntoViewHandler } from "@/mainview/scripts/utils"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; -import { FrontEndId, GameLookup } from "@/shared/types"; +import { FrontEndId, GameLookup } from "@simeonradivoev/gameflow-sdk/shared"; import { gameLookupQuery } from "@/mainview/scripts/queries/romm"; import { Button } from "../options/Button"; import { useNavigate } from "@tanstack/react-router"; diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index a2caabc..20bb27b 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -9,9 +9,8 @@ import { Clock, Crosshair, Download, EllipsisVertical, Import, PackageOpen, Play import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview/scripts/queries/romm"; import ActionButton from "./ActionButton"; import { useRouter } from "@tanstack/react-router"; -import { DownloadSourceType } from "@/shared/constants"; import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; -import { CommandEntry, FrontEndGameTypeDetailed } from "@/shared/types"; +import { CommandEntry, FrontEndGameTypeDetailed, DownloadSourceType } from "@simeonradivoev/gameflow-sdk/shared"; export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; }) { diff --git a/src/mainview/components/options/DownloadDirectoryOption.tsx b/src/mainview/components/options/DownloadDirectoryOption.tsx index 9cbe29f..de902d3 100644 --- a/src/mainview/components/options/DownloadDirectoryOption.tsx +++ b/src/mainview/components/options/DownloadDirectoryOption.tsx @@ -2,8 +2,7 @@ import { useState } from "react"; import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption"; import { useMutation, useQuery } from "@tanstack/react-query"; import { changeDownloadsMutation, getSettingQuery } from "@queries/settings"; -import { SettingsType } from "@/shared/constants"; -import { KeysWithValueAssignableTo } from "@/shared/types"; +import { KeysWithValueAssignableTo, SettingsType } from "@simeonradivoev/gameflow-sdk/shared"; export default function DownloadDirectoryOption (data: PathSettingsOptionParams & { id: KeysWithValueAssignableTo; }) { diff --git a/src/mainview/components/options/LocalOption.tsx b/src/mainview/components/options/LocalOption.tsx index d596123..25ac7b6 100644 --- a/src/mainview/components/options/LocalOption.tsx +++ b/src/mainview/components/options/LocalOption.tsx @@ -1,5 +1,5 @@ import { JSX } from "react"; -import { LocalSettingsSchema, LocalSettingsType } from "@shared/constants"; +import { LocalSettingsSchema, LocalSettingsType } from '@simeonradivoev/gameflow-sdk/shared'; import { OptionSpace } from "./OptionSpace"; import { OptionInput } from "./OptionInput"; import { useLocalStorage } from "usehooks-ts"; diff --git a/src/mainview/components/options/PathSettingsOption.tsx b/src/mainview/components/options/PathSettingsOption.tsx index 2c25fb2..7b2789f 100644 --- a/src/mainview/components/options/PathSettingsOption.tsx +++ b/src/mainview/components/options/PathSettingsOption.tsx @@ -1,5 +1,4 @@ import { HTMLInputTypeAttribute, JSX, useEffect, useState } from "react"; -import { SettingsType } from "../../../shared/constants"; import { useMutation, useQuery } from "@tanstack/react-query"; import { OptionSpace } from "./OptionSpace"; import { OptionInput } from "./OptionInput"; @@ -9,7 +8,7 @@ import { ContextDialog } from "../ContextDialog"; import FilePicker from "../FilePicker"; import { setFocus } from "@noriginmedia/norigin-spatial-navigation"; import { getSettingQuery, setSettingMutation } from "@queries/settings"; -import { KeysWithValueAssignableTo } from "@/shared/types"; +import { KeysWithValueAssignableTo, SettingsType } from "@simeonradivoev/gameflow-sdk/shared"; export interface PathSettingsOptionParams { diff --git a/src/mainview/components/options/SettingsDropdown.tsx b/src/mainview/components/options/SettingsDropdown.tsx index 18eabd5..6887b52 100644 --- a/src/mainview/components/options/SettingsDropdown.tsx +++ b/src/mainview/components/options/SettingsDropdown.tsx @@ -1,10 +1,9 @@ import { JSX, useCallback, useEffect, useState } from "react"; -import { SettingsType } from "../../../shared/constants"; import { useMutation, useQuery } from "@tanstack/react-query"; import { OptionSpace } from "./OptionSpace"; import { getSettingQuery, setSettingMutation } from "@queries/settings"; import { OptionDropdown } from "./OptionDropdown"; -import { KeysWithValueAssignableTo } from "@/shared/types"; +import { KeysWithValueAssignableTo, SettingsType } from "@simeonradivoev/gameflow-sdk/shared"; export function SettingsDropdown (data: { label: string; diff --git a/src/mainview/components/options/SettingsOption.tsx b/src/mainview/components/options/SettingsOption.tsx index 55357d7..20bcda0 100644 --- a/src/mainview/components/options/SettingsOption.tsx +++ b/src/mainview/components/options/SettingsOption.tsx @@ -1,10 +1,9 @@ import { HTMLInputTypeAttribute, JSX, useCallback, useEffect, useState } from "react"; -import { SettingsType } from "../../../shared/constants"; import { useMutation, useQuery } from "@tanstack/react-query"; import { OptionSpace } from "./OptionSpace"; import { OptionInput } from "./OptionInput"; import { getSettingQuery, setSettingMutation } from "@queries/settings"; -import { KeysWithValueAssignableTo } from "@/shared/types"; +import { KeysWithValueAssignableTo, SettingsType } from "@simeonradivoev/gameflow-sdk/shared"; export function SettingsOption (data: { label: string; diff --git a/src/mainview/components/store/EmulatorsSection.tsx b/src/mainview/components/store/EmulatorsSection.tsx index eec1325..a7712dc 100644 --- a/src/mainview/components/store/EmulatorsSection.tsx +++ b/src/mainview/components/store/EmulatorsSection.tsx @@ -12,7 +12,7 @@ import { StoreEmulatorCard } from "./StoreEmulatorCard"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import Carousel from "../Carousel"; import { useRouter } from "@tanstack/react-router"; -import { FrontEndEmulator } from "@/shared/types"; +import { FrontEndEmulator } from "@simeonradivoev/gameflow-sdk/shared"; function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant?: boolean; }) => void; }) { diff --git a/src/mainview/components/store/GamesSection.tsx b/src/mainview/components/store/GamesSection.tsx index ff3cbbb..ccdd076 100644 --- a/src/mainview/components/store/GamesSection.tsx +++ b/src/mainview/components/store/GamesSection.tsx @@ -10,7 +10,7 @@ import FrontEndGameCard from "../FrontEndGameCard"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import Carousel from "../Carousel"; import { twMerge } from "tailwind-merge"; -import { FrontEndGameType, FrontEndId } from "@/shared/types"; +import { FrontEndGameType, FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; export function GamesSection (data: { games?: FrontEndGameType[]; diff --git a/src/mainview/components/store/MissingEmulatorsSection.tsx b/src/mainview/components/store/MissingEmulatorsSection.tsx index 84f150b..6339dde 100644 --- a/src/mainview/components/store/MissingEmulatorsSection.tsx +++ b/src/mainview/components/store/MissingEmulatorsSection.tsx @@ -8,7 +8,7 @@ import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import { RPC_URL } from "@/shared/constants"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import { oneShot } from "@/mainview/scripts/audio/audio"; -import { FrontEndEmulator } from "@/shared/types"; +import { FrontEndEmulator } from "@simeonradivoev/gameflow-sdk/shared"; // ── Single missing-emulator card ─────────────────────────────────────────── interface MissingCardProps diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index 09b3ca3..8645a01 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -10,7 +10,7 @@ import { JSX } from "react"; import { oneShot } from "@/mainview/scripts/audio/audio"; import { useQuery } from "@tanstack/react-query"; import { getUpdateInfoForEmulator } from "@/mainview/scripts/queries/store"; -import { FrontEndEmulator } from "@/shared/types"; +import { FrontEndEmulator } from "@simeonradivoev/gameflow-sdk/shared"; export const emulatorStatusIcons: Record = { store: , diff --git a/src/mainview/gen/routeTree.gen.ts b/src/mainview/gen/routeTree.gen.ts index ad674ce..62fef18 100644 --- a/src/mainview/gen/routeTree.gen.ts +++ b/src/mainview/gen/routeTree.gen.ts @@ -22,6 +22,7 @@ import { Route as SettingsAboutRouteImport } from './../routes/settings/about' import { Route as GameAddRouteImport } from './../routes/game/add' import { Route as StoreTabRouteRouteImport } from './../routes/store/tab/route' import { Route as StoreTabIndexRouteImport } from './../routes/store/tab/index' +import { Route as StoreTabPluginsRouteImport } from './../routes/store/tab/plugins' import { Route as StoreTabGamesRouteImport } from './../routes/store/tab/games' import { Route as StoreTabEmulatorsRouteImport } from './../routes/store/tab/emulators' import { Route as SettingsPluginSourceRouteImport } from './../routes/settings/plugin.$source' @@ -30,6 +31,7 @@ import { Route as LauncherSourceIdRouteImport } from './../routes/launcher.$sour import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id' import { Route as EmbeddedSourceIdRouteImport } from './../routes/embedded.$source.$id' import { Route as CollectionSourceIdRouteImport } from './../routes/collection.$source.$id' +import { Route as StoreDetailsPluginIdRouteImport } from './../routes/store/details.plugin.$id' import { Route as StoreDetailsEmulatorIdRouteImport } from './../routes/store/details.emulator.$id' import { Route as GameUpdateSourceIdRouteImport } from './../routes/game/update.$source.$id' @@ -98,6 +100,11 @@ const StoreTabIndexRoute = StoreTabIndexRouteImport.update({ path: '/', getParentRoute: () => StoreTabRouteRoute, } as any) +const StoreTabPluginsRoute = StoreTabPluginsRouteImport.update({ + id: '/plugins', + path: '/plugins', + getParentRoute: () => StoreTabRouteRoute, +} as any) const StoreTabGamesRoute = StoreTabGamesRouteImport.update({ id: '/games', path: '/games', @@ -138,6 +145,11 @@ const CollectionSourceIdRoute = CollectionSourceIdRouteImport.update({ path: '/collection/$source/$id', getParentRoute: () => rootRouteImport, } as any) +const StoreDetailsPluginIdRoute = StoreDetailsPluginIdRouteImport.update({ + id: '/store/details/plugin/$id', + path: '/store/details/plugin/$id', + getParentRoute: () => rootRouteImport, +} as any) const StoreDetailsEmulatorIdRoute = StoreDetailsEmulatorIdRouteImport.update({ id: '/store/details/emulator/$id', path: '/store/details/emulator/$id', @@ -170,9 +182,11 @@ export interface FileRoutesByFullPath { '/settings/plugin/$source': typeof SettingsPluginSourceRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute + '/store/tab/plugins': typeof StoreTabPluginsRoute '/store/tab/': typeof StoreTabIndexRoute '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute + '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -194,9 +208,11 @@ export interface FileRoutesByTo { '/settings/plugin/$source': typeof SettingsPluginSourceRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute + '/store/tab/plugins': typeof StoreTabPluginsRoute '/store/tab': typeof StoreTabIndexRoute '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute + '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -220,9 +236,11 @@ export interface FileRoutesById { '/settings/plugin/$source': typeof SettingsPluginSourceRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute + '/store/tab/plugins': typeof StoreTabPluginsRoute '/store/tab/': typeof StoreTabIndexRoute '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute + '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -247,9 +265,11 @@ export interface FileRouteTypes { | '/settings/plugin/$source' | '/store/tab/emulators' | '/store/tab/games' + | '/store/tab/plugins' | '/store/tab/' | '/game/update/$source/$id' | '/store/details/emulator/$id' + | '/store/details/plugin/$id' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -271,9 +291,11 @@ export interface FileRouteTypes { | '/settings/plugin/$source' | '/store/tab/emulators' | '/store/tab/games' + | '/store/tab/plugins' | '/store/tab' | '/game/update/$source/$id' | '/store/details/emulator/$id' + | '/store/details/plugin/$id' id: | '__root__' | '/' @@ -296,9 +318,11 @@ export interface FileRouteTypes { | '/settings/plugin/$source' | '/store/tab/emulators' | '/store/tab/games' + | '/store/tab/plugins' | '/store/tab/' | '/game/update/$source/$id' | '/store/details/emulator/$id' + | '/store/details/plugin/$id' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -314,6 +338,7 @@ export interface RootRouteChildren { PlatformSourceIdRoute: typeof PlatformSourceIdRoute GameUpdateSourceIdRoute: typeof GameUpdateSourceIdRoute StoreDetailsEmulatorIdRoute: typeof StoreDetailsEmulatorIdRoute + StoreDetailsPluginIdRoute: typeof StoreDetailsPluginIdRoute } declare module '@tanstack/react-router' { @@ -409,6 +434,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StoreTabIndexRouteImport parentRoute: typeof StoreTabRouteRoute } + '/store/tab/plugins': { + id: '/store/tab/plugins' + path: '/plugins' + fullPath: '/store/tab/plugins' + preLoaderRoute: typeof StoreTabPluginsRouteImport + parentRoute: typeof StoreTabRouteRoute + } '/store/tab/games': { id: '/store/tab/games' path: '/games' @@ -465,6 +497,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CollectionSourceIdRouteImport parentRoute: typeof rootRouteImport } + '/store/details/plugin/$id': { + id: '/store/details/plugin/$id' + path: '/store/details/plugin/$id' + fullPath: '/store/details/plugin/$id' + preLoaderRoute: typeof StoreDetailsPluginIdRouteImport + parentRoute: typeof rootRouteImport + } '/store/details/emulator/$id': { id: '/store/details/emulator/$id' path: '/store/details/emulator/$id' @@ -511,12 +550,14 @@ const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren( interface StoreTabRouteRouteChildren { StoreTabEmulatorsRoute: typeof StoreTabEmulatorsRoute StoreTabGamesRoute: typeof StoreTabGamesRoute + StoreTabPluginsRoute: typeof StoreTabPluginsRoute StoreTabIndexRoute: typeof StoreTabIndexRoute } const StoreTabRouteRouteChildren: StoreTabRouteRouteChildren = { StoreTabEmulatorsRoute: StoreTabEmulatorsRoute, StoreTabGamesRoute: StoreTabGamesRoute, + StoreTabPluginsRoute: StoreTabPluginsRoute, StoreTabIndexRoute: StoreTabIndexRoute, } @@ -537,6 +578,7 @@ const rootRouteChildren: RootRouteChildren = { PlatformSourceIdRoute: PlatformSourceIdRoute, GameUpdateSourceIdRoute: GameUpdateSourceIdRoute, StoreDetailsEmulatorIdRoute: StoreDetailsEmulatorIdRoute, + StoreDetailsPluginIdRoute: StoreDetailsPluginIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/mainview/query-options.ts b/src/mainview/query-options.ts index a52c649..879d632 100644 --- a/src/mainview/query-options.ts +++ b/src/mainview/query-options.ts @@ -1,6 +1,7 @@ import { keepPreviousData, queryOptions } from "@tanstack/react-query"; import { getRomApiRomsIdGetOptions, getRomsApiRomsGetOptions } from "../clients/romm/@tanstack/react-query.gen"; -import { DefaultRommStaleTime, GameListFilterType } from "../shared/constants"; +import { DefaultRommStaleTime } from "../shared/constants"; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; export function gamesQueryOptions (filter?: GameListFilterType) { diff --git a/src/mainview/routes/collection.$source.$id.tsx b/src/mainview/routes/collection.$source.$id.tsx index 3b73d25..a08c164 100644 --- a/src/mainview/routes/collection.$source.$id.tsx +++ b/src/mainview/routes/collection.$source.$id.tsx @@ -6,7 +6,7 @@ import { AnimatedBackgroundContext } from '../scripts/contexts'; import { getCollectionQuery } from '@queries/romm'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; -import { GameListFilterType } from '@/shared/constants'; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; import { useLocalStorage } from 'usehooks-ts'; export const Route = createFileRoute('/collection/$source/$id')({ diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 064deee..761e9ea 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -24,7 +24,7 @@ import Details from "@/mainview/components/game/Details"; import { AutoFocus } from "@/mainview/components/AutoFocus"; import SelectMenu from "@/mainview/components/SelectMenu"; import { IGDBIcon } from "@/mainview/scripts/brandIcons"; -import { FrontEndGameTypeDetailed } from "@/shared/types"; +import { FrontEndGameTypeDetailed } from "@simeonradivoev/gameflow-sdk/shared"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => diff --git a/src/mainview/routes/game/update.$source.$id.tsx b/src/mainview/routes/game/update.$source.$id.tsx index 867112a..0a6ef83 100644 --- a/src/mainview/routes/game/update.$source.$id.tsx +++ b/src/mainview/routes/game/update.$source.$id.tsx @@ -1,15 +1,15 @@ import { AnimatedBackground } from '@/mainview/components/AnimatedBackground'; import { AutoFocus } from '@/mainview/components/AutoFocus'; import GameLookupElement from '@/mainview/components/game/GameLookup'; -import { HeaderUI, StickyHeaderUI } from '@/mainview/components/Header'; +import { HeaderUI } from '@/mainview/components/Header'; import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; import { customUpdateMutation, gameInvalidationQuery, gameQuery } from '@/mainview/scripts/queries/romm'; import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; import { HandleGoBack } from '@/mainview/scripts/utils'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; -import { useEffect, useRef, useState } from 'react'; +import { createFileRoute, useRouter } from '@tanstack/react-router'; +import { useEffect, useState } from 'react'; import toast from 'react-hot-toast'; export const Route = createFileRoute('/game/update/$source/$id')({ diff --git a/src/mainview/routes/games.tsx b/src/mainview/routes/games.tsx index e6fd86e..cd1fe45 100644 --- a/src/mainview/routes/games.tsx +++ b/src/mainview/routes/games.tsx @@ -2,7 +2,7 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { CollectionsDetail } from '../components/CollectionsDetail'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; -import { GameListFilterType } from '@/shared/constants'; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; import { useSessionStorage } from 'usehooks-ts'; import HeaderSearchField from '../components/HeaderSearchField'; import { useEffect } from 'react'; diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 9505d30..a9d33c1 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -50,7 +50,7 @@ import SelectMenu from "../components/SelectMenu"; import HeaderSearchField from "../components/HeaderSearchField"; import CardElement from "../components/CardElement"; import { Router } from ".."; -import { FrontEndId } from "@/shared/types"; +import { FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, diff --git a/src/mainview/routes/platform.$source.$id.tsx b/src/mainview/routes/platform.$source.$id.tsx index 73d0265..f4df81d 100644 --- a/src/mainview/routes/platform.$source.$id.tsx +++ b/src/mainview/routes/platform.$source.$id.tsx @@ -1,7 +1,8 @@ import { createFileRoute, useRouter } from "@tanstack/react-router"; import { CollectionsDetail } from "../components/CollectionsDetail"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { GameListFilterType, RPC_URL } from "../../shared/constants"; +import { RPC_URL } from "../../shared/constants"; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; import { deletePlatformMutation, localPlatformFilter, platformQuery, updatePlatformMutation } from "@queries/romm"; import { zodValidator } from "@tanstack/zod-adapter"; import z from "zod"; diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index ce5586c..eacd432 100644 --- a/src/mainview/routes/settings/accounts.tsx +++ b/src/mainview/routes/settings/accounts.tsx @@ -13,7 +13,8 @@ import useEffect, useRef, } from "react"; -import { RommLoginDataSchema, RPC_URL } from "@shared/constants"; +import { RPC_URL } from "@shared/constants"; +import { RommLoginDataSchema } from '@simeonradivoev/gameflow-sdk/shared'; import toast from "react-hot-toast"; import { OptionSpace } from "../../components/options/OptionSpace"; import { useSettingsForm, useSettingsFormContext } from "../../components/options/SettingsAppForm"; diff --git a/src/mainview/routes/settings/directories.tsx b/src/mainview/routes/settings/directories.tsx index 4af256b..5adee26 100644 --- a/src/mainview/routes/settings/directories.tsx +++ b/src/mainview/routes/settings/directories.tsx @@ -13,10 +13,13 @@ import { systemApi } from '@/mainview/scripts/clientApi'; import useActiveControl from '@/mainview/scripts/gamepads'; import { changeDownloadsMutation } from '@queries/settings'; import { downloadDrivesQuery } from '@/mainview/scripts/queries/system'; -import { DownloadsDrive } from '@/shared/types'; +import { DownloadsDrive } from '@simeonradivoev/gameflow-sdk/shared'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; export const Route = createFileRoute('/settings/directories')({ component: RouteComponent, + validateSearch: zodValidator(z.object({ focus: z.string().optional() })) }); function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; refetchDrives: () => void; }) diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index 7e3f381..9abddb4 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -8,7 +8,8 @@ import { Check, ChevronDown, FolderSearch, HardDrive, Plug, SearchAlert, Store, import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog'; import classNames from 'classnames'; import { twMerge } from 'tailwind-merge'; -import { RPC_URL, SettingsSchema } from '../../../shared/constants'; +import { RPC_URL } from '../../../shared/constants'; +import { SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared'; import emulators from '@emulators'; import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts'; @@ -20,11 +21,14 @@ import { FOCUS_KEYS } from '@/mainview/scripts/types'; import { scrollIntoNearestParent, scrollIntoViewHandler, useDragScroll } from '@/mainview/scripts/utils'; import { SettingsOption } from '@/mainview/components/options/SettingsOption'; import { SettingsDropdown } from '@/mainview/components/options/SettingsDropdown'; -import { FrontEndEmulator } from '@/shared/types'; +import { FrontEndEmulator } from '@simeonradivoev/gameflow-sdk/shared'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; export const Route = createFileRoute('/settings/emulators')({ component: RouteComponent, pendingComponent: EmulatorsPending, + validateSearch: zodValidator(z.object({ focus: z.string().optional() })) }); function EmulatorsPending () diff --git a/src/mainview/routes/settings/interface.tsx b/src/mainview/routes/settings/interface.tsx index f1a42c8..8ef70d4 100644 --- a/src/mainview/routes/settings/interface.tsx +++ b/src/mainview/routes/settings/interface.tsx @@ -1,11 +1,15 @@ import { LocalOption } from '@/mainview/components/options/LocalOption'; -import { LocalSettingsSchema, settingRegistry } from '@/shared/constants'; +import { settingRegistry } from '@simeonradivoev/gameflow-sdk/shared'; +import { LocalSettingsSchema } from '@simeonradivoev/gameflow-sdk/shared'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { createFileRoute } from '@tanstack/react-router'; import { Terminal } from 'lucide-react'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; export const Route = createFileRoute('/settings/interface')({ component: RouteComponent, + validateSearch: zodValidator(z.object({ focus: z.string().optional() })) }); function RouteComponent () diff --git a/src/mainview/routes/settings/plugin.$source.tsx b/src/mainview/routes/settings/plugin.$source.tsx index 7263bea..c80b592 100644 --- a/src/mainview/routes/settings/plugin.$source.tsx +++ b/src/mainview/routes/settings/plugin.$source.tsx @@ -5,23 +5,25 @@ import { OptionDropdown } from '@/mainview/components/options/OptionDropdown'; import { OptionInput } from '@/mainview/components/options/OptionInput'; import { OptionSpace } from '@/mainview/components/options/OptionSpace'; import { RoundButton } from '@/mainview/components/RoundButton'; -import { getPluginDetailsQuery } from '@/mainview/scripts/queries/plugins'; +import { allPluginsFilter, getPluginDetailsQuery, updatePluginMutation } from '@/mainview/scripts/queries/plugins'; import { getPluginActionsQuery, getPluginSettingQuery, getPluginSettingsDefinitionQuery, pluginActionMutation, setPluginSettingMutation } from '@/mainview/scripts/queries/settings'; import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; import { scrollIntoViewHandler } from '@/mainview/scripts/utils'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { PluginUpdateCheck } from '@simeonradivoev/gameflow-sdk/shared'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { JSONSchema7 } from 'json-schema'; -import { ArrowLeft, CirclePlay, Settings2 } from 'lucide-react'; +import { ArrowLeft, ArrowRight, CircleFadingArrowUp, CirclePlay, Settings2 } from 'lucide-react'; import toast from 'react-hot-toast'; export const Route = createFileRoute('/settings/plugin/$source')({ component: RouteComponent, pendingComponent: Loading, async loader (ctx) { - const definitions = await ctx.context.queryClient.fetchQuery(getPluginSettingsDefinitionQuery(ctx.params.source)); - const actions = await ctx.context.queryClient.fetchQuery(getPluginActionsQuery(ctx.params.source)); + const source = decodeURIComponent(ctx.params.source); + const definitions = await ctx.context.queryClient.fetchQuery(getPluginSettingsDefinitionQuery(source)); + const actions = await ctx.context.queryClient.fetchQuery(getPluginActionsQuery(source)); await new Promise(resolve => setTimeout(resolve, 1000)); return { definitions, actions }; }, @@ -38,7 +40,8 @@ function Loading () function PluginAction (data: { id: string, title: string | undefined, description: string | undefined; action: string; reload: () => void; }) { - const { source } = Route.useParams(); + const { source: sourceRaw } = Route.useParams(); + const source = decodeURIComponent(sourceRaw); const action = useMutation({ ...pluginActionMutation(source, data.id), onSuccess (acitonData, variables, onMutateResult, context) @@ -67,7 +70,8 @@ function PluginAction (data: { id: string, title: string | undefined, descriptio function PluginOption (data: { name: string, title?: string, prop: JSONSchema7; }) { - const { source } = Route.useParams(); + const { source: sourceRaw } = Route.useParams(); + const source = decodeURIComponent(sourceRaw); const { data: value, refetch: refetchValue } = useQuery(getPluginSettingQuery(source, data.name)); const setValue = useMutation({ ...setPluginSettingMutation(source, data.name), @@ -108,12 +112,21 @@ function PluginOption (data: { name: string, title?: string, prop: JSONSchema7; ; } -function Settings () +function Settings (data: { update: PluginUpdateCheck | undefined; }) { const { definitions, actions } = Route.useLoaderData(); - const { source } = Route.useParams(); + const { source: sourceRaw } = Route.useParams(); + const source = decodeURIComponent(sourceRaw); const queryClient = useQueryClient(); - + const navigate = useNavigate(); + const update = useMutation({ + ...updatePluginMutation(source), + onSuccess (data, variables, onMutateResult, context) + { + context.client.invalidateQueries(allPluginsFilter); + navigate({ to: '/settings/plugin/$source', params: { source: encodeURIComponent(source) }, replace: true }); + }, + }); const handleReload = () => { queryClient.refetchQueries(getPluginSettingsDefinitionQuery(source)); @@ -121,7 +134,7 @@ function Settings () }; const { ref, focusKey } = useFocusable({ focusKey: 'plugin-settings', - focusable: (definitions?.properties && Object.keys(definitions?.properties).length > 0) || actions.length > 0 + focusable: (definitions?.properties && Object.keys(definitions?.properties).length > 0) || actions.length > 0 || !!data.update }); return
    @@ -148,6 +161,15 @@ function Settings () })}
    Actions
    + {!!data.update && +
    Update
    +
    {data?.update?.current} {'>'} {data?.update?.new}
    +
    }> + + } {actions?.map(a => )}
    ; @@ -155,7 +177,8 @@ function Settings () function RouteComponent () { - const { source } = Route.useParams(); + const { source: sourceRaw } = Route.useParams(); + const source = decodeURIComponent(sourceRaw); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'plugins' }); const { data } = useQuery(getPluginDetailsQuery(source)); @@ -167,17 +190,17 @@ function RouteComponent ()
    -
    +
    - {data?.displayName} +
    {data?.displayName}
    +
    {data?.version}
    + {!!data?.update &&
    {data?.update.new}
    }
      {data?.keywords?.map((k, i) =>
    • {k}
    • )}
    {data?.description}
    - - - +
    ; diff --git a/src/mainview/routes/settings/plugins.tsx b/src/mainview/routes/settings/plugins.tsx index c9476ba..c55fc8f 100644 --- a/src/mainview/routes/settings/plugins.tsx +++ b/src/mainview/routes/settings/plugins.tsx @@ -3,13 +3,13 @@ import { pluginCategoryIcons, pluginCategoryPriorities } from '@/mainview/compon import { OptionInput } from '@/mainview/components/options/OptionInput'; import { OptionSpace } from '@/mainview/components/options/OptionSpace'; import { RoundButton } from '@/mainview/components/RoundButton'; -import { enablePluginMutation, getAllPluginsQuery } from '@/mainview/scripts/queries/plugins'; +import { enablePluginMutation, getAllPluginsQuery, uninstallPluginMutation } from '@/mainview/scripts/queries/plugins'; import { GamePadButtonCode, Shortcut } from '@/mainview/scripts/shortcuts'; -import { FrontendPlugin } from '@/shared/types'; +import { FrontendPlugin } from '@simeonradivoev/gameflow-sdk/shared'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { useMutation, useQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; -import { Eye, Puzzle, Search, Settings2 } from 'lucide-react'; +import { CircleFadingArrowUp, Eye, Puzzle, Settings2, Trash } from 'lucide-react'; export const Route = createFileRoute('/settings/plugins')({ component: RouteComponent, @@ -33,7 +33,9 @@ function Plugin (data: { }, }); - const handleDetails = () => navigate({ to: '/settings/plugin/$source', params: { source: data.plugin.name }, replace: true, viewTransition: { types: ['slide-up'] } }); + const uninstall = useMutation(uninstallPluginMutation(data.plugin.name)); + const handleUninstall = () => uninstall.mutate(); + const handleDetails = () => navigate({ to: '/settings/plugin/$source', params: { source: encodeURIComponent(data.plugin.name) }, replace: true, viewTransition: { types: ['slide-up'] } }); return : }
    -
    {data.plugin.displayName}
    +
    {data.plugin.displayName ?? data.plugin.name}
    {data.plugin.name} ({data.plugin.version})
    {data.plugin.hasSettings && } + {data.plugin.update &&
    + +
    }
    @@ -55,6 +60,7 @@ function Plugin (data: { >
    {data.plugin.hasSettings ? : } + {data.plugin.canUninstall && {uninstall.isPending ? : }} {data.plugin.canDisable && data.setEnabled(!!v)} value={data.plugin.enabled} name={data.plugin.name} type="checkbox" />}
    ; diff --git a/src/mainview/routes/settings/update.tsx b/src/mainview/routes/settings/update.tsx index 905ada6..3f27639 100644 --- a/src/mainview/routes/settings/update.tsx +++ b/src/mainview/routes/settings/update.tsx @@ -3,7 +3,7 @@ import DotsLoading from '@/mainview/components/backgrounds/dots'; import { Button } from '@/mainview/components/options/Button'; import { checkUpdateMutation, hasUpdateQuery, updateMutation } from '@/mainview/scripts/queries/system'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { CircleFadingArrowUp, RefreshCcw } from 'lucide-react'; import { MarkdownAsync } from 'react-markdown'; diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 60b3917..3383ea8 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -29,7 +29,7 @@ import FocusTooltip from "@/mainview/components/FocusTooltip"; import { AutoFocus } from "@/mainview/components/AutoFocus"; import { FilterUI } from "@/mainview/components/Filters"; import Markdown from "react-markdown"; -import { FrontEndEmulatorDetailed } from "@/shared/types"; +import { FrontEndEmulatorDetailed } from "@simeonradivoev/gameflow-sdk/shared"; export const Route = createFileRoute('/store/details/emulator/$id')({ component: RouteComponent, diff --git a/src/mainview/routes/store/details.plugin.$id.tsx b/src/mainview/routes/store/details.plugin.$id.tsx new file mode 100644 index 0000000..5e5168c --- /dev/null +++ b/src/mainview/routes/store/details.plugin.$id.tsx @@ -0,0 +1,161 @@ +import { AutoFocus } from '@/mainview/components/AutoFocus'; +import DotsLoading from '@/mainview/components/backgrounds/dots'; +import { StickyHeaderUI } from '@/mainview/components/Header'; +import LoadingScreen from '@/mainview/components/LoadingScreen'; +import { Button } from '@/mainview/components/options/Button'; +import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; +import StatList, { StatEntry } from '@/mainview/components/StatList'; +import { installPluginMutation, pluginFilter, uninstallPluginMutation, updatePluginMutation } from '@/mainview/scripts/queries/plugins'; +import { pluginDetailsQuery } from '@/mainview/scripts/queries/store'; +import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { HandleGoBack } from '@/mainview/scripts/utils'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { QueryClient, useMutation } from '@tanstack/react-query'; +import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; +import { ArrowRight, CircleFadingArrowUp, Download, Settings, Trash } from 'lucide-react'; +import prettyBytes from 'pretty-bytes'; +import { Suspense } from 'react'; + +export const Route = createFileRoute('/store/details/plugin/$id')({ + component: RouteComponent, + pendingComponent: Loading, + async loader (ctx) + { + const id = decodeURIComponent(ctx.params.id); + const data = await ctx.context.queryClient.fetchQuery(pluginDetailsQuery(id)); + return { data }; + }, +}); + +function Loading () +{ + const { ref, focusSelf } = useFocusable({ focusKey: 'plugin-details' }); + return <> + + + ; +} + +function Details () +{ + const { id } = Route.useParams(); + const plugin = decodeURIComponent(id); + const { data } = Route.useLoaderData(); + const navigate = useNavigate(); + const handleRefresh = (client: QueryClient) => + { + client.invalidateQueries(pluginFilter(plugin)); + navigate({ to: '/store/details/plugin/$id', params: { id: encodeURIComponent(id) }, replace: true }); + }; + const update = useMutation({ + ...updatePluginMutation(plugin), + onSuccess (data, variables, onMutateResult, context) + { + handleRefresh(context.client); + }, + }); + const install = useMutation({ + ...installPluginMutation(plugin), + onSuccess (data, variables, onMutateResult, context) + { + handleRefresh(context.client); + }, + }); + const uninstall = useMutation({ + ...uninstallPluginMutation(plugin), + onSuccess (data, variables, onMutateResult, context) + { + handleRefresh(context.client); + }, + }); + + const stats: StatEntry[] = []; + if (data.devDependencies) + { + stats.push({ content: Object.keys(data.devDependencies), label: "Dev Dependecies" }); + } + if (data.dependencies) + { + stats.push({ content: Object.keys(data.dependencies), label: "Dependecies" }); + } + if (data.maintainers) + { + stats.push({ content: data.maintainers.map(m => m.name), label: "Maintainers" }); + } + if (data.dist) + { + stats.push({ content: prettyBytes(data.dist.unpackedSize), label: "Size" }); + } + if (data.license) + { + stats.push({ content: data.license, label: "License" }); + } + return <> + +
    +
    +
    {data.name}
    +
    +
    + {data.update ? <> +
    {data.update.from}
    + +
    {data.version}
    + : +
    {data.version}
    } + +
    + by {data.author?.name ?? data._npmUser?.name}
    +
    +
    + {data.installed && <> + {!!data.update && } + + + + } + {!data.installed && } + +
    +
    +
    Details
    +
    +
    {data.description}
    + +
    +
    Keywords
    +
    + {data.keywords.map(k =>
  • {k}
  • )} +
    + ; +} + +function RouteComponent () +{ + const router = useRouter(); + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'plugin-details' }); + useShortcuts(focusKey, () => [{ + label: "Return", button: GamePadButtonCode.B, action (e) + { + HandleGoBack(router, e); + }, + }]); + return
    + + + }> +
    + + + + +
    ; +} diff --git a/src/mainview/routes/store/tab/games.tsx b/src/mainview/routes/store/tab/games.tsx index c36b824..21e059f 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -8,13 +8,13 @@ import LoadMoreButton from '@/mainview/components/LoadMoreButton'; import { storeGamesInfiniteQuery } from '@queries/store'; import InvalidStoreError from '@/mainview/components/store/InvalidStoreError'; import { CardList, GameMetaExtra } from '@/mainview/components/CardList'; -import { GameListFilterType, RPC_URL } from '@/shared/constants'; +import { RPC_URL } from '@/shared/constants'; +import { GameListFilterType, FrontEndGameType } from '@simeonradivoev/gameflow-sdk/shared'; import { useSessionStorage } from 'usehooks-ts'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; import SideFilters from '@/mainview/components/SideFilters'; import { gameFiltersQuery } from '@/mainview/scripts/queries/romm'; -import { FrontEndGameType } from '@/shared/types'; export const Route = createFileRoute('/store/tab/games')({ component: RouteComponent, diff --git a/src/mainview/routes/store/tab/index.tsx b/src/mainview/routes/store/tab/index.tsx index 08ca653..dcb53c8 100644 --- a/src/mainview/routes/store/tab/index.tsx +++ b/src/mainview/routes/store/tab/index.tsx @@ -16,7 +16,7 @@ import { useQuery } from '@tanstack/react-query'; import { autoEmulatorsQuery } from '@queries/settings'; import { storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@queries/store'; import ImageWithFallbacks from '@/mainview/components/ImageWithFallbacks'; -import { FrontEndGameTypeDetailed } from '@/shared/types'; +import { FrontEndGameTypeDetailed } from '@simeonradivoev/gameflow-sdk/shared'; export const Route = createFileRoute('/store/tab/')({ component: RouteComponent diff --git a/src/mainview/routes/store/tab/plugins.tsx b/src/mainview/routes/store/tab/plugins.tsx new file mode 100644 index 0000000..db55628 --- /dev/null +++ b/src/mainview/routes/store/tab/plugins.tsx @@ -0,0 +1,151 @@ +import { allPluginsFilter, installPluginMutation, uninstallPluginMutation, updatePluginMutation } from '@/mainview/scripts/queries/plugins'; +import { pluginsQuery } from '@/mainview/scripts/queries/store'; +import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { FOCUS_KEYS } from '@/mainview/scripts/types'; +import { PluginEntryType } from '@simeonradivoev/gameflow-sdk/shared'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { QueryClient, useMutation, useQuery } from '@tanstack/react-query'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { zodValidator } from '@tanstack/zod-adapter'; +import { CircleFadingArrowUp, Dot, Download, HardDrive, Puzzle } from 'lucide-react'; +import prettyMilliseconds from 'pretty-ms'; +import { useSessionStorage } from 'usehooks-ts'; +import z from 'zod'; + +export const Route = createFileRoute('/store/tab/plugins')({ + component: RouteComponent, + validateSearch: zodValidator(z.object({ + search: z.string().optional() + })) +}); + +function PluginCard (data: { plugin: PluginEntryType; }) +{ + const navigate = useNavigate(); + const onAction = () => + { + navigate({ to: '/store/details/plugin/$id', params: { id: decodeURIComponent(data.plugin.package.name) } }); + }; + const { ref, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.PLUGIN_ENTRY(data.plugin.package.sanitized_name), onEnterPress: onAction }); + const handleRefresh = (client: QueryClient) => + { + client.invalidateQueries(allPluginsFilter); + navigate({ to: '/store/tab/plugins', replace: true }); + }; + const update = useMutation({ + ...updatePluginMutation(data.plugin.package.name), + onSuccess (data, variables, onMutateResult, context) + { + handleRefresh(context.client); + }, + }); + const install = useMutation({ + ...installPluginMutation(data.plugin.package.name), + onSuccess (f, variables, onMutateResult, context) + { + handleRefresh(context.client); + } + }); + const uninstall = useMutation({ + ...uninstallPluginMutation(data.plugin.package.name), + onSuccess (f, variables, onMutateResult, context) + { + handleRefresh(context.client); + } + }); + useShortcuts(focusKey, () => + { + const shortcuts: Shortcut[] = [{ + label: "Details", button: GamePadButtonCode.A, action (e) + { + onAction(); + }, + }]; + + if (data.plugin.installed) + { + shortcuts.push({ + label: "Uninstall", + button: GamePadButtonCode.X, + action (e) + { + uninstall.mutate(); + }, + }); + + if (data.plugin.update) + { + shortcuts.push({ + label: "Update", + button: GamePadButtonCode.Y, + action (e) + { + update.mutate(); + }, + }); + } + + } else + { + shortcuts.push({ + label: "Install", + button: GamePadButtonCode.X, + action (e) + { + install.mutate(); + }, + }); + } + return shortcuts; + }, [data.plugin.installed, install.isPending, uninstall.isPending]); + return
    +
    +
    + {data.plugin.installed && } + {data.plugin.update && } + {data.plugin.package.name} + {(install.isPending || uninstall.isPending) && } +
    +
    {data.plugin.package.description}
    +
      {data.plugin.package.keywords.concat(...data.plugin.installed ? ["installed"] : []).map(k =>
    • {k}
    • )}
    +
      +
    • {data.plugin.package.publisher.username}
    • + +
    • {data.plugin.package.version}
    • + +
    • {prettyMilliseconds(new Date().getTime() - data.plugin.package.date.getTime(), { hideSeconds: true })}
    • + +
    • {data.plugin.package.license}
    • + {install.isPending && <> + +
    • installing
    • + } + {uninstall.isPending && <> + +
    • uninstalling
    • + } +
    +
    +
    +
    + + {data.plugin.downloads.monthly} +
    +
    +
    ; +} + +function RouteComponent () +{ + const [search] = useSessionStorage(`${Route.to}-search`, undefined); + const { data: plugins } = useQuery(pluginsQuery(search)); + const { ref, focusKey } = useFocusable({ focusKey: "plugins-store" }); + return
    + +
    {plugins?.total} Plugins
    +
    + {plugins?.objects.map((p, i) => )} +
    +
    +
    ; +} diff --git a/src/mainview/routes/store/tab/route.tsx b/src/mainview/routes/store/tab/route.tsx index b874445..05b4c1e 100644 --- a/src/mainview/routes/store/tab/route.tsx +++ b/src/mainview/routes/store/tab/route.tsx @@ -14,6 +14,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useMatchRoute, useRouter } from '@tanstack/react-router'; import { createFileRoute, Outlet } from '@tanstack/react-router'; import { zodValidator } from '@tanstack/zod-adapter'; +import { Gamepad2, Home, Joystick, Puzzle } from 'lucide-react'; import { useRef } from 'react'; import { useSessionStorage } from 'usehooks-ts'; import z from 'zod'; @@ -93,9 +94,10 @@ function RouteComponent () const headerRef = useRef(null); const sentinelRef = useRef(null); const filters: Record = { - home: { label: "Home", selected: useIsSettings(''), }, - emulators: { label: "Emulators", selected: useIsSettings('emulators') }, - games: { label: "Games", selected: useIsSettings('games') } + home: { label: "Home", icon: , selected: useIsSettings(''), }, + emulators: { label: "Emulators", icon: , selected: useIsSettings('emulators') }, + games: { label: "Games", icon: , selected: useIsSettings('games') }, + plugins: { label: "Plugins", icon: , selected: useIsSettings('plugins') } }; const [search, setSearch] = useSessionStorage(`${router.history.location.pathname}-search`, undefined); const [, setGamesSearch] = useSessionStorage(`/store/tab/games-search`, undefined); diff --git a/src/mainview/scripts/contexts.ts b/src/mainview/scripts/contexts.ts index f54f0e2..3b33a01 100644 --- a/src/mainview/scripts/contexts.ts +++ b/src/mainview/scripts/contexts.ts @@ -1,8 +1,7 @@ -import { SystemInfoType } from "@/shared/constants"; +import { SystemInfoType, Drive } from '@simeonradivoev/gameflow-sdk/shared'; import { Direction, FocusDetails } from "@noriginmedia/norigin-spatial-navigation"; import { createContext } from "react"; import { Shortcut } from "./shortcuts"; -import { Drive } from "@/shared/types"; export const StoreContext = createContext({} as { showDetails: (type: 'emulator' | 'game', source: string, id: string, focusSource: string) => void; diff --git a/src/mainview/scripts/queries/plugins.ts b/src/mainview/scripts/queries/plugins.ts index 323b168..8e6e948 100644 --- a/src/mainview/scripts/queries/plugins.ts +++ b/src/mainview/scripts/queries/plugins.ts @@ -1,4 +1,4 @@ -import { mutationOptions, queryOptions } from "@tanstack/react-query"; +import { mutationOptions, QueryFilters, queryOptions } from "@tanstack/react-query"; import { pluginsApi } from "../clientApi"; export const getAllPluginsQuery = queryOptions({ @@ -14,7 +14,7 @@ export const getAllPluginsQuery = queryOptions({ export const getPluginDetailsQuery = (source: string) => queryOptions({ queryKey: ['plugins', source], queryFn: async () => { - const { data, error } = await pluginsApi.plugins({ id: source }).get(); + const { data, error } = await pluginsApi.plugins({ id: encodeURIComponent(source) }).get(); if (error) throw error; return data; } @@ -24,7 +24,51 @@ export const enablePluginMutation = mutationOptions({ mutationKey: ['plugin', 'enable'], mutationFn: async (vars: { id: string, enabled: boolean; }) => { - const { error } = await pluginsApi.plugins({ id: vars.id }).post({ enabled: vars.enabled }); + const { error } = await pluginsApi.plugins({ id: encodeURIComponent(vars.id) }).post({ enabled: vars.enabled }); if (error) throw error; } +}); + +export const installPluginMutation = (id: string) => mutationOptions({ + mutationKey: ['plugin', 'install', id], + mutationFn: async () => + { + const { data, error } = await pluginsApi.plugins.install.post({ id }); + if (error) throw error; + return data; + } +}); + +export const updatePluginMutation = (id: string) => mutationOptions({ + mutationKey: ['plugin', 'update', id], + mutationFn: async () => + { + const { data, error } = await pluginsApi.plugins.update.post({ id }); + if (error) throw error; + return data; + } +}); + +export const uninstallPluginMutation = (id: string) => mutationOptions({ + mutationKey: ['plugin', 'uninstall', id], + mutationFn: async () => + { + const { data, error } = await pluginsApi.plugins.uninstall.post({ id: id }); + if (error) throw error; + return data; + } +}); + +export const pluginFilter = (id: string): QueryFilters => ({ + predicate (query) + { + return query.queryKey.includes(id); + }, +}); + +export const allPluginsFilter: QueryFilters = ({ + predicate (query) + { + return query.queryKey.includes('plugin') || query.queryKey.includes('plugins'); + }, }); \ No newline at end of file diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts index 4a7a4f6..63f8623 100644 --- a/src/mainview/scripts/queries/romm.ts +++ b/src/mainview/scripts/queries/romm.ts @@ -1,9 +1,9 @@ -import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants"; +import { DefaultRommStaleTime } from "@/shared/constants"; +import { GameListFilterType, RommLoginDataSchema, FrontEndId } from '@simeonradivoev/gameflow-sdk/shared'; import { rommApi, settingsApi } from "../clientApi"; import { InvalidateQueryFilters, mutationOptions, QueryClient, QueryFilters, queryOptions } from "@tanstack/react-query"; import z from "zod"; import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen"; -import { FrontEndId } from "@/shared/types"; export const allGamesQuery = (filter?: GameListFilterType) => queryOptions({ queryKey: ['games', filter ?? 'all'], diff --git a/src/mainview/scripts/queries/settings.ts b/src/mainview/scripts/queries/settings.ts index e0f605e..03956af 100644 --- a/src/mainview/scripts/queries/settings.ts +++ b/src/mainview/scripts/queries/settings.ts @@ -138,7 +138,7 @@ export const getPluginSettingsDefinitionQuery = (source: string) => queryOptions queryKey: ['settings', source, 'definitions'], queryFn: async () => { - const { data: value, error } = await settingsApi.api.settings.definitions({ source }).get(); + const { data: value, error } = await settingsApi.api.settings.definitions({ source: encodeURIComponent(source) }).get(); if (error) throw error; return value; @@ -148,7 +148,7 @@ export const getPluginSettingQuery = (source: string, id: string) => queryOption queryKey: ["setting", source, id], queryFn: async () => { - const { data, error } = await settingsApi.api.settings({ source })({ id }).get(); + const { data, error } = await settingsApi.api.settings({ source: encodeURIComponent(source) })({ id: encodeURIComponent(id) }).get(); if (error) throw error; return data; @@ -158,7 +158,7 @@ export const setPluginSettingMutation = (source: string, id: string) => mutation mutationKey: ["setting", source, id], mutationFn: async (value: any) => { - const { data, error } = await settingsApi.api.settings({ source })({ id }).put({ value }); + const { data, error } = await settingsApi.api.settings({ source: encodeURIComponent(source) })({ id: encodeURIComponent(id) }).put({ value }); if (error) throw error; return data; @@ -167,7 +167,7 @@ export const setPluginSettingMutation = (source: string, id: string) => mutation export const getPluginActionsQuery = (source: string) => queryOptions({ queryKey: ['plugin', source, 'actions'], queryFn: async () => { - const { data, error } = await settingsApi.api.settings.actions({ source }).get(); + const { data, error } = await settingsApi.api.settings.actions({ source: encodeURIComponent(source) }).get(); if (error) throw error; return data; @@ -177,7 +177,7 @@ export const pluginActionMutation = (source: string, id: string) => mutationOpti mutationKey: ["plugin", source, "action"], mutationFn: async () => { - const { data, error, response } = await settingsApi.api.settings.actions({ source })({ id }).post(); + const { data, error, response } = await settingsApi.api.settings.actions({ source: encodeURIComponent(source) })({ id: encodeURIComponent(id) }).post(); if (error) throw error; return { data: data as any, response }; diff --git a/src/mainview/scripts/queries/store.ts b/src/mainview/scripts/queries/store.ts index 6347944..a428f40 100644 --- a/src/mainview/scripts/queries/store.ts +++ b/src/mainview/scripts/queries/store.ts @@ -1,8 +1,6 @@ import { infiniteQueryOptions, mutationOptions, queryOptions } from "@tanstack/react-query"; import { rommApi, storeApi } from "../clientApi"; -import { GameListFilterType } from "@/shared/constants"; -import { FrontEndGameType } from "@/shared/types"; - +import { GameListFilterType, FrontEndGameType } from '@simeonradivoev/gameflow-sdk/shared'; export const storeEmulatorsQuery = (filters: { search?: string; }) => queryOptions({ queryKey: ['store-emulators', filters], queryFn: async () => @@ -97,4 +95,22 @@ export const getUpdateInfoForEmulator = (id: string) => queryOptions({ if (error) throw error; return data; } +}); +export const pluginsQuery = (search?: string) => queryOptions({ + queryKey: ['plugins', 'store', search ?? 'all'], + queryFn: async () => + { + const { data, error } = await storeApi.api.store.plugins.get({ query: { search } }); + if (error) throw error; + return data; + } +}); +export const pluginDetailsQuery = (id: string) => queryOptions({ + queryKey: ['plugin', 'store', id], + queryFn: async () => + { + const { data, error } = await storeApi.api.store.plugin.get({ query: { plugin: id } }); + if (error) throw error; + return data; + } }); \ No newline at end of file diff --git a/src/mainview/scripts/types.ts b/src/mainview/scripts/types.ts index a192266..6ab944a 100644 --- a/src/mainview/scripts/types.ts +++ b/src/mainview/scripts/types.ts @@ -1,4 +1,4 @@ -import { FrontEndId } from "@/shared/types"; +import { FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; export const FOCUS_KEYS = { NAV_CATEGORIES: "NAV_CATEGORIES", @@ -14,4 +14,5 @@ export const FOCUS_KEYS = { GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`, GAME_MATCH: (id: FrontEndId) => `GAME_${id.source}_${id.id}`, STATS_SECTION: "STATS_SECTION", + PLUGIN_ENTRY: (id: string) => `PLUGIN_${id}` } as const; \ No newline at end of file diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index 4859ddf..6635bd6 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -1,4 +1,4 @@ -import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants"; +import { LocalSettingsSchema, LocalSettingsType } from '@simeonradivoev/gameflow-sdk/shared'; import { DependencyList, RefObject, useEffect, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; import { jobsApi, systemApi } from "./clientApi"; diff --git a/scripts/sdk/README.md b/src/packages/gameflow-sdk/README.md similarity index 53% rename from scripts/sdk/README.md rename to src/packages/gameflow-sdk/README.md index 7ac9193..8338233 100644 --- a/scripts/sdk/README.md +++ b/src/packages/gameflow-sdk/README.md @@ -13,3 +13,18 @@ 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. 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. diff --git a/src/packages/gameflow-sdk/build.ts b/src/packages/gameflow-sdk/build.ts new file mode 100644 index 0000000..9f18b88 --- /dev/null +++ b/src/packages/gameflow-sdk/build.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env bun + +import pkg from './package.json'; + +import { parseArgs } from "util"; + +const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + outdir: { type: "string", default: "dist" }, + minify: { type: "boolean", default: false }, + sourcemap: { type: "string", default: "none" }, // "none" | "inline" | "external" + entry: { type: "string", default: "src/index.ts" }, + }, + allowPositionals: true, +}); + +await Bun.build({ + entrypoints: [values.entry], + outdir: values.outdir, + minify: values.minify, + sourcemap: values.sourcemap as any, + external: [...Object.keys(pkg.peerDependencies), pkg.name], + target: "bun", +}); + +console.log(`✅ Built to ${values.outdir}`); \ No newline at end of file diff --git a/src/bun/api/hooks/app.ts b/src/packages/gameflow-sdk/hooks/app.ts similarity index 88% rename from src/bun/api/hooks/app.ts rename to src/packages/gameflow-sdk/hooks/app.ts index bd549ca..d8fef7a 100644 --- a/src/bun/api/hooks/app.ts +++ b/src/packages/gameflow-sdk/hooks/app.ts @@ -3,7 +3,7 @@ import EmulatorHooks from "./emulators"; import GameHooks from "./games"; import StoreHooks from "./store"; -export default class GameflowHooks +export class GameflowHooks { games = new GameHooks(); emulators = new EmulatorHooks(); diff --git a/src/bun/api/hooks/auth.ts b/src/packages/gameflow-sdk/hooks/auth.ts similarity index 81% rename from src/bun/api/hooks/auth.ts rename to src/packages/gameflow-sdk/hooks/auth.ts index 992d91e..cb8dd1b 100644 --- a/src/bun/api/hooks/auth.ts +++ b/src/packages/gameflow-sdk/hooks/auth.ts @@ -1,5 +1,6 @@ -import { DownloadFileEntry } from "@/shared/types"; + import { AsyncSeriesHook } from "tapable"; +import { DownloadFileEntry } from "../shared"; export default class AuthHooks { diff --git a/src/bun/api/hooks/emulators.ts b/src/packages/gameflow-sdk/hooks/emulators.ts similarity index 83% rename from src/bun/api/hooks/emulators.ts rename to src/packages/gameflow-sdk/hooks/emulators.ts index 402bb21..768e56f 100644 --- a/src/bun/api/hooks/emulators.ts +++ b/src/packages/gameflow-sdk/hooks/emulators.ts @@ -1,5 +1,6 @@ -import { EmulatorPostInstallContext } from "@/bun/types/types"; -import { DownloadFileEntry, EmulatorSourceEntryType, EmulatorSystem } from "@/shared/types"; + +import { EmulatorPostInstallContextType } from "../index"; +import { DownloadFileEntry, EmulatorSourceEntryType, EmulatorSystem } from "../shared"; import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; export default class EmulatorHooks @@ -13,7 +14,7 @@ export default class EmulatorHooks /** * Triggered when emulator is downloaded or updated */ - emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContext], { emulator: string; }>(['ctx']); + emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContextType], { emulator: string; }>(['ctx']); findEmulatorSource = new AsyncSeriesHook<[ctx: { emulator: string; sources: EmulatorSourceEntryType[]; }]>(['ctx']); findEmulatorForSystem = new AsyncSeriesHook<[ctx: { system: string; emulators: string[]; }]>(['ctx']); @@ -24,7 +25,7 @@ export default class EmulatorHooks { return { ...tap, - fn: async (ctx: EmulatorPostInstallContext, ...rest: any[]) => + fn: async (ctx: EmulatorPostInstallContextType, ...rest: any[]) => { if (ctx.emulator === tap.emulator) { diff --git a/src/bun/api/hooks/games.ts b/src/packages/gameflow-sdk/hooks/games.ts similarity index 92% rename from src/bun/api/hooks/games.ts rename to src/packages/gameflow-sdk/hooks/games.ts index bb1f4bc..9083e47 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/packages/gameflow-sdk/hooks/games.ts @@ -1,6 +1,6 @@ -import { EmulatorPackageType, GameListFilterType } from '@/shared/constants'; -import { CommandEntry, DownloadInfo, EmulatorSourceEntryType, EmulatorSupport, EmulatorSystem, FrontEndCollection, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeWithIds, FrontEndId, FrontEndPlatformType, GameLookup, SaveFileChange, SaveSlots } from '@/shared/types'; -import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, Hook, AsyncSeriesWaterfallHook } from 'tapable'; + +import { EmulatorPackageType, GameListFilterType, CommandEntry, DownloadInfo, EmulatorSourceEntryType, EmulatorSupport, EmulatorSystem, FrontEndCollection, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeWithIds, FrontEndId, FrontEndPlatformType, GameLookup, SaveFileChange, SaveSlots } from '../shared'; +import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable'; export default class GameHooks { diff --git a/src/bun/api/hooks/store.ts b/src/packages/gameflow-sdk/hooks/store.ts similarity index 86% rename from src/bun/api/hooks/store.ts rename to src/packages/gameflow-sdk/hooks/store.ts index b08cee5..c7f43e5 100644 --- a/src/bun/api/hooks/store.ts +++ b/src/packages/gameflow-sdk/hooks/store.ts @@ -1,5 +1,4 @@ -import { EmulatorDownloadInfoType } from "@/shared/constants"; -import { FrontEndEmulator, FrontEndEmulatorDetailed, FrontEndGameTypeDetailed } from "@/shared/types"; +import { FrontEndEmulator, FrontEndEmulatorDetailed, FrontEndGameTypeDetailed, EmulatorDownloadInfoType } from "../shared"; import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; export default class StoreHooks diff --git a/src/bun/types/types.schema.ts b/src/packages/gameflow-sdk/index.ts similarity index 72% rename from src/bun/types/types.schema.ts rename to src/packages/gameflow-sdk/index.ts index c4738fc..4149891 100644 --- a/src/bun/types/types.schema.ts +++ b/src/packages/gameflow-sdk/index.ts @@ -1,7 +1,20 @@ import z from "zod"; -import GameflowHooks from "../api/hooks/app"; -import Conf from "conf"; +import { GameflowHooks } from "./hooks/app"; +import { EmulatorDownloadInfoSchema, EmulatorPackageSchema, FrontendNotification, SettingsType } from "./shared"; import { $ZodRegistry } from "zod/v4/core"; +import Conf from "conf"; +import { EventEmitter } from 'node:events'; +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({ hooks: z.instanceof(GameflowHooks) @@ -10,16 +23,22 @@ export const PluginContextSchema = z.object({ export const PluginLoadingContextSchema = z.object({ setProgress: z.function().input([z.number(), z.string()]).output(z.void()), config: z.instanceof(Conf).describe("Per plugin config. It will use the settings schema defined in the plugin class"), - zodRegistry: z.instanceof($ZodRegistry).describe("Used by the settings to register metadata for the UI") + zodRegistry: z.instanceof($ZodRegistry).describe("Used by the settings to register metadata for the UI"), + app: z.object({ + config: z.instanceof(Conf), + events: z.instanceof(EventEmitter), + taskQueue: z.instanceof(TaskQueue) + }) }).extend(PluginContextSchema.shape); export const PluginDescriptionSchema = z.object({ name: z.string(), - displayName: z.string(), + displayName: z.string().optional(), version: z.string(), - description: z.string(), + description: z.string().optional(), icon: z.url().optional().describe("Can be an external URL to an image or a data url"), keywords: z.array(z.string()).optional(), + peerDependencies: z.record(z.string(), z.string()).optional(), category: z.string().default("other"), main: z.string().describe("The main entry. It must export a default class implementing PluginType"), canDisable: z.boolean().default(true).optional().describe("Can the plugin be disabled or enabled by the user") @@ -42,16 +61,6 @@ export const PluginSchema = z.object({ }).or(z.record(z.string(), z.any()))).optional() }); -export type PluginType = Record> = Omit, "load" | 'settingsMigrations'> & { - load: (ctx: PluginLoadingContextType) => Promise; - settingsMigrations?: Record) => void>; -}; -export type PluginContextType = z.infer; -export type PluginLoadingContextType = Record> = z.infer & { - config: Conf; -}; -export type PluginDescriptionType = z.infer; - export const ActiveGameSchema = z.object({ process: z.any().optional(), gameId: z.object({ id: z.string(), source: z.string() }), @@ -60,4 +69,24 @@ export const ActiveGameSchema = z.object({ name: z.string(), command: z.object({ command: z.string().or(z.string().array()), startDir: z.string().optional() }) }); -export type ActiveGameType = z.infer; \ No newline at end of file + +export const EmulatorPostInstallContextSchema = z.object({ + emulator: z.string(), + emulatorPackage: EmulatorPackageSchema.optional(), + path: z.string(), + update: z.boolean(), + info: EmulatorDownloadInfoSchema, +}); + +export type ActiveGameType = z.infer; +export type PluginDescriptionType = z.infer; +export type PluginContextType = z.infer; +export type PluginLoadingContextType = Record> = z.infer & { + config: Conf; +}; +export type PluginType = Record> = Omit, "load" | 'settingsMigrations'> & { + load: (ctx: PluginLoadingContextType) => Promise; + settingsMigrations?: Record) => void>; +}; +export type EmulatorPostInstallContextType = z.infer; + diff --git a/src/packages/gameflow-sdk/package.json b/src/packages/gameflow-sdk/package.json new file mode 100644 index 0000000..7e51c16 --- /dev/null +++ b/src/packages/gameflow-sdk/package.json @@ -0,0 +1,51 @@ +{ + "name": "@simeonradivoev/gameflow-sdk", + "version": "1.5.3", + "types": "index.d.ts", + "description": "plugin SDK for the Gameflow Deck Launcher", + "exports": { + ".": "./index.ts", + "./shared": "./shared.ts" + }, + "bin": { + "gameflow-build": "build.ts" + }, + "peerDependencies": { + "7zip-bin": "^5.2.0", + "@auth/core": "^0.34.3", + "@elysiajs/cors": "^1.4.2", + "@elysiajs/eden": "^1.4.9", + "@jimp/wasm-webp": "^1.6.1", + "@phalcode/ts-igdb-client": "^1.0.26", + "cheerio": "^1.2.0", + "conf": "^15.1.0", + "drizzle-orm": "^0.45.2", + "elysia": "^1.4.28", + "fs-extra": "^11.3.5", + "get-folder-size": "^5.0.0", + "ini": "^6.0.0", + "jimp": "^1.6.1", + "mustache": "^4.2.0", + "node-7z": "^3.0.0", + "node-disk-info": "^1.3.0", + "node-downloader-helper": "^2.1.11", + "node-stream-zip": "^1.15.0", + "node-unrar-js": "^2.0.2", + "open": "^11.0.0", + "p-queue": "^9.2.0", + "pathe": "^2.0.3", + "slugify": "^1.6.9", + "smol-toml": "^1.6.1", + "systeminformation": "^5.31.5", + "tapable": "^2.3.3", + "tough-cookie": "^6.0.1", + "tough-cookie-file-store": "^3.3.0", + "unzip-stream": "^0.3.4", + "webview-bun": "^2.4.0", + "zod": "^4.4.3" + }, + "keywords": [ + "gameflow", + "sdk" + ] +} \ No newline at end of file diff --git a/scripts/sdk/sdk.tsconfig.json b/src/packages/gameflow-sdk/sdk.tsconfig.json similarity index 52% rename from scripts/sdk/sdk.tsconfig.json rename to src/packages/gameflow-sdk/sdk.tsconfig.json index 8da436e..707544a 100644 --- a/scripts/sdk/sdk.tsconfig.json +++ b/src/packages/gameflow-sdk/sdk.tsconfig.json @@ -17,26 +17,6 @@ "outDir": "../../dist-sdk", "types": [ "node" - ], - "paths": { - "@/*": [ - "../../src/*" - ], - "~/*": [ - "../../*" - ], - "@shared/*": [ - "../../src/shared/*" - ], - "@clients/*": [ - "../../src/clients/*" - ], - "@schema/*": [ - "../../src/bun/api/schema/*" - ], - "@queries/*": [ - "../../src/mainview/scripts/queries/*" - ] - } + ] } } \ No newline at end of file diff --git a/src/packages/gameflow-sdk/shared.ts b/src/packages/gameflow-sdk/shared.ts new file mode 100644 index 0000000..147db36 --- /dev/null +++ b/src/packages/gameflow-sdk/shared.ts @@ -0,0 +1,631 @@ +import * as z from "zod"; + +export const settingRegistry = z.registry<{ + dev?: boolean; +}>(); + +export const SettingsSchema = z.object({ + rommAddress: z.url().optional(), + rommUser: z.string().default('admin').optional(), + windowSize: z.object({ width: z.number(), height: z.number() }).optional(), + windowPosition: z.object({ x: z.number(), y: z.number() }).optional(), + downloadPath: z.string(), + launchInFullscreen: z.boolean().default(true), + disabledPlugins: z.array(z.string()).default([]), + emulatorResolution: z.enum(['720p', '1080p', '1440p', '4k']).default('720p'), + emulatorWidescreen: z.boolean().default(true) +}); export const LocalSettingsSchema = z.object({ + backgroundBlur: z.boolean().default(true).meta({ title: "Background Blur" }), + backgroundAnimation: z.boolean().default(true).meta({ title: "Background Animation" }), + theme: z.enum(['dark', 'light', 'auto']).default('auto').meta({ title: "Theme" }), + soundEffects: z.boolean().default(true).meta({ title: "Sounds" }), + soundEffectsVolume: z.number().min(0).max(100).default(50).meta({ title: "Sound Volume" }), + hapticsEffects: z.boolean().default(true).meta({ title: "Haptics" }), + showRouterDevOptions: z.boolean().default(false).meta({ title: "Show Router Options" }).register(settingRegistry, { dev: true }), + showQueryDevOptions: z.boolean().default(false).meta({ title: "Show Query Options" }).register(settingRegistry, { dev: true }), + useGameflowKeyboard: z.boolean().default(true).describe("Show the gameflow on screen keyboard when using a controller").meta({ title: "Use Gameflow Keyboard" }), + autoKeybaord: z.boolean().default(true).describe("Open on screen keybaord automatically").meta({ title: "Auto Keyboard" }) +}); +export const GameListFilterSchema = z.object({ + platform_source: z.string().optional(), + platform_slug: z.string().optional(), + platform_id: z.coerce.number().optional(), + collection_id: z.coerce.number().optional(), + collection_source: z.string().optional(), + limit: z.coerce.number().optional(), + search: z.string().optional(), + offset: z.coerce.number().optional(), + source: z.string().optional(), + localOnly: z.coerce.boolean().optional(), + orderBy: z.literal(['added', 'activity', 'name', 'release']).optional(), + age_ratings: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), + genres: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), + keywords: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), +}); +export const DownloadSourceSchema = z.object({ + id: z.string(), + name: z.string() +}); +export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() }); +export type GameListFilterType = z.infer; +export const DirSchema = z.object({ name: z.string(), parentPath: z.string(), isDirectory: z.boolean() }); +export type DirType = z.infer; +export const CustomEmulatorSchema = z.record(z.string(), z.string()); +export const GithubManifestSchema = z.object({ + sha: z.hash('sha1'), + url: z.url(), + tree: z.array(z.object({ + path: z.string(), + mode: z.string(), + type: z.enum(['blob', 'tree']), + sha: z.hash('sha1'), + url: z.url() + })) +}); +export const StoreGameSaveSchema = z.object({ + cwd: z.string(), + globs: z.string().array() +}); +export const StoreDownloadSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('direct'), + url: z.url(), + name: z.string().optional(), + system: z.string(), + main: z.string().optional(), + saves: z.record(z.string(), StoreGameSaveSchema).optional() + }), + z.object({ + type: z.literal("itch"), + path: z.string(), + name: z.string().optional(), + system: z.string(), + saves: z.record(z.string(), StoreGameSaveSchema).optional() + }) +]); +export const NewGameSchema = z.object({ + name: z.string(), + summary: z.string(), + genres: z.string().regex(/^$|^(\s*\S[^,]*)(\s*,\s*\S[^,]*)*\s*$/, { + message: "Must be a comma-separated list", + }) +}); +export const StoreGameSchema = z.object({ + name: z.string(), + description: z.string(), + version: z.string(), + homepage: z.string().optional(), + keywords: z.string().array().optional(), + genres: z.string().array().optional(), + companies: z.string().array().optional(), + screenshots: z.string().array().optional(), + covers: z.string().array().optional(), + igdb_id: z.number().optional(), + ra_id: z.number().optional(), + sgdb_id: z.number().optional(), + first_release_date: z.union([z.number(), z.date()]).optional(), + player_count: z.string().optional(), + saves: z.record(z.string(), z.record(z.string(), StoreGameSaveSchema)).optional(), + downloads: z.record(z.string(), StoreDownloadSchema) +}); +export const EmulatorPackageSchema = z.object({ + name: z.string(), + description: z.string(), + homepage: z.url(), + logo: z.url(), + type: z.enum(['emulator']), + os: z.array(z.enum(['darwin', 'linux', 'win32', 'android'])), + keywords: z.array(z.string()).optional(), + downloads: z.record(z.string(), z.array(z.discriminatedUnion('type', [ + z.object({ + type: z.literal(['github', 'gitlab']), + pattern: z.string(), + path: z.string(), + bin: z.string().optional() + }), + z.object({ + type: z.literal('direct'), + url: z.url(), + bin: z.string().optional() + }), + z.object({ + type: z.literal('scoop'), + url: z.url(), + bin: z.string().optional() + }) + ]))).optional(), + systems: z.array(z.string()), + bios: z.literal(["required", "optional"]).optional() +}); +export const ScoopPackageSchema = z.object({ + version: z.string(), + url: z.url().optional(), + description: z.string(), + bin: z.string().optional(), + architecture: z.record(z.string(), z.object({ + url: z.url(), + hash: z.string().optional(), + extract_dir: z.string().optional() + })).optional() +}); +export const SystemInfoSchema = z.object({ + battery: z.object({ + percent: z.number(), + isCharging: z.boolean(), + acConnected: z.boolean(), + hasBattery: z.boolean() + }), + wifiConnections: z.array(z.object({ signalLevel: z.number() })), + bluetoothDevices: z.array(z.object({ connected: z.boolean() })) +}); +export const GithubReleaseSchema = z.object({ + id: z.number(), + tag_name: z.string().optional(), + url: z.url(), + body: z.string(), + assets: z.array(z.object({ + name: z.string(), + browser_download_url: z.url(), + content_type: z.string().optional() + })) +}); +export const EmulatorDownloadInfoSchema = z.object({ + id: z.string(), + version: z.string().optional(), + url: z.url().optional(), + description: z.string().optional(), + downloadDate: z.coerce.date(), + type: z.string() +}); +export const PluginEntrySchema = z.object({ + downloads: z.object({ + monthly: z.number(), + weekly: z.number() + }), + searchScore: z.number(), + installed: z.boolean(), + update: z.object({ from: z.string() }).optional(), + package: z.object({ + name: z.string(), + keywords: z.string().array(), + version: z.string(), + description: z.string().optional(), + sanitized_name: z.string(), + license: z.string().optional(), + publisher: z.object({ + email: z.string(), + username: z.string(), + trustedPublisher: z.object({ + id: z.string(), + oidcConfigId: z.string() + }).optional() + }), + date: z.coerce.date(), + links: z.object({ + homepage: z.string().optional(), + repository: z.string().optional(), + bugs: z.string().optional(), + npm: z.url() + }) + }) +}); +export const PluginBunDetailsSchema = z.object({ + name: z.string(), + keywords: z.string().array(), + version: z.string(), + author: z.object({ name: z.string().optional() }).optional(), + license: z.string().optional(), + devDependencies: z.record(z.string(), z.string()).optional(), + dependencies: z.record(z.string(), z.string()).optional(), + maintainers: z.object({ name: z.string() }).array().optional(), + dist: z.object({ unpackedSize: z.number() }), + description: z.string().optional(), + _npmUser: z.object({ name: z.string() }).optional() +}); +export type EmulatorPackageType = z.infer; +export type StoreGameType = z.infer; +export type StoreDownloadType = z.infer; +export type SettingsType = z.infer; +export type LocalSettingsType = z.infer; +export const PlatformSchema = z.object({ slug: z.string() }); +export type SystemInfoType = z.infer; +export type EmulatorDownloadInfoType = z.infer; +export type DownloadSourceType = z.infer; +export type PluginEntryType = z.infer; +export type PluginBunDetailsType = z.infer; + +export interface SaveFileChange +{ + subPath: string | string[]; + isGlob?: true; + cwd: string; + shared: boolean; + fixedSize?: boolean; +} + +export type EmulatorSourceType = 'custom' | 'store' | 'registry' | 'system' | 'static' | 'embedded'; + +export interface EmulatorSourceEntryType +{ + binPath: string; + rootPath?: string; + type: EmulatorSourceType; + exists: boolean; +} + +export interface FrontEndEmulator +{ + name: string; + source: string; + logo: string; + systems: EmulatorSystem[]; + description?: string; + gameCount: number; + validSources: EmulatorSourceEntryType[]; + integrations: EmulatorSupport[]; +} + +export interface EmulatorSystem { id: string, romm_slug?: string, name: string, iconUrl: string; } + +export interface FrontEndEmulatorDetailedDownload +{ + name: string; + type: string | undefined; + version?: string; +} + +export interface FrontEndEmulatorDetailed extends FrontEndEmulator +{ + homepage: string; + description: string; + downloads: FrontEndEmulatorDetailedDownload[]; + keywords?: string[]; + screenshots: string[]; + biosRequirement?: "required" | "optional"; + bios?: string[]; + storeDownloadInfo?: { hasUpdate: boolean; version?: string, type: string; description?: string; }; +} + +export interface FrontEndGameTypeDetailedAchievement +{ + id: string; + title: string; + description?: string; + date?: Date; + date_hardcode?: Date; + badge_url?: string; + display_order: number; + type?: string; +} + +export interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator +{ + +} + +export interface FrontEndGameTypeDetailed extends Exclude +{ + summary: string | null; + fs_size_bytes: number | null; + missing: boolean; + local: boolean; + version?: string | null; + version_system?: string | null; + version_source?: string | null; + metadata: FrontEndGameMetadataDetailed, + emulators?: FrontEndGameTypeDetailedEmulator[], + achievements?: { + unlocked: number; + total: number; + entires: FrontEndGameTypeDetailedAchievement[]; + }; +}; + +export interface Drive +{ + parent: string | null; + device: string; + label: string; + mountPoint: string | null; + type: string; + size: number; + used: number; + isRemovable: boolean; + interfaceType: string | null; + hasWriteAccess: boolean; + hasReadAccess: boolean; +} + +export interface DownloadsDrive +{ + device: string; + label: string; + mountPoint: string | null; + isRemovable: boolean; + size: number; + used: number; + isCurrentlyUsed: boolean; + unusableReason: 'not_enough_space' | 'already_used' | null; +} + +export interface FrontendNotification +{ + title?: string; + message: string; + type: 'success' | 'error' | 'info' | 'custom'; + icon?: "save" | "upload" | "clock"; + duration?: number; +} + +export interface CommandEntry +{ + /** The ID of the command. Could be just an index or a string */ + id: string | number; + /** The front end label for the command. Mainly gotten from ES-DE list */ + label?: string; + /** Compiled command to be executed */ + command: string | string[]; + /** Environment variables */ + env?: Record, + /** The path the spawned process will start at */ + startDir?: string; + /** Is the command valid, for example does the executable exists */ + valid: boolean; + /** Run the command as shell. Defaults is true */ + shell?: boolean; + /** For what emulator is the command */ + emulator?: string; + /** Where the emulator came from */ + emulatorSource?: EmulatorSourceType; + /** Metadata for the command */ + metadata: { + romPath?: string; + emulatorBin?: string; + /** The root directory of the emulator */ + emulatorDir?: string; + }; +} + +export interface FrontEndId +{ + id: string; + source: string; +} + +// Stuff stored in the local sqlite metadata field +export interface LocalGameMetadata +{ + genres?: string[], + companies?: string[], + game_modes?: string[], + age_ratings?: string[]; + player_count?: string; + first_release_date?: number; + average_rating?: number; +} + +export interface FrontEndPlatformType +{ + id: FrontEndId; + slug: string; + name: string; + family_name?: string | null; + path_cover: string | null; + game_count: number; + updated_at: Date; + hasLocal: boolean; + paths_screenshots: string[]; +} + +export interface FrontEndGameTypeWithIds extends FrontEndGameType +{ + igdb_id: number | null; + ra_id: number | null; +} + +export interface FrontEndFilterSets +{ + age_ratings: Set, + player_counts: Set, + languages: Set, + companies: Set, + genres: Set; +} + +export interface FrontEndFilterLists +{ + age_ratings: string[], + player_counts: string[], + languages: string[], + companies: string[], + genres: string[]; +} + +export interface FrontEndGameMetadata +{ + first_release_date: Date | null; +} + +export interface FrontEndGameMetadataDetailed extends FrontEndGameMetadata +{ + genres: string[], + companies: string[], + game_modes: string[], + age_ratings: string[]; + player_count: string | null; + average_rating: number | null; +} + +export interface FrontEndGameType +{ + platform_display_name: string | null, + path_platform_cover: string | null; + id: FrontEndId, + source: string | null, + source_id: string | null, + path_fs: string | null, + path_covers: string[], + last_played: Date | null, + updated_at: Date, + metadata: FrontEndGameMetadata, + slug: string | null, + name: string | null, + platform_id: number | null, + platform_slug: string | null, + paths_screenshots: string[]; +}; + +export type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing' | 'queued'; + +export interface GameInstallProgress +{ + progress?: number; + status?: GameStatusType; + details?: string; + commands?: CommandEntry[]; + error?: any; +} + +export type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted'; +export type GameInstallProgressEvent = 'refresh'; + +export interface FrontendPlugin +{ + name: string; + displayName?: string; + description?: string; + category: string; + enabled: boolean; + canDisable: boolean; + canUninstall: boolean; + source: PluginSourceType; + hasSettings: boolean; + version: string; + icon?: string; + update?: PluginUpdateCheck; +} + +export interface PluginUpdateCheck +{ + current: string; + new: string; +} + +export type PluginSourceType = "builtin" | "store"; + +export type KeysWithValueAssignableTo = { + [K in keyof T]: Exclude extends Value ? K : never; +}[keyof T]; + +export interface DownloadInfo +{ + id: string; + screenshotUrls: string[]; + coverUrl: string; + platform?: DownloadPlatform; + slug?: string; + path_fs?: string; + main_glob?: string; + summary?: string; + name: string; + last_played?: Date; + igdb_id?: number; + ra_id?: number; + source_id: string; + system_slug: string; + extract_path?: string; + metadata?: any; + files: DownloadFileEntry[]; + auth?: string; + version?: string; + version_source?: string; + version_system?: string; +} + +export interface DownloadPlatform +{ + id: string; + source: string; + igdb_id?: number; + igdb_slug?: string; + ra_id?: number; + moby_id?: number; + slug: string; + name: string; + /** Like Sony or Nintendo */ + family_name?: string; +} + +export interface DownloadFileEntry +{ + url: URL; + /** The path of the file, excluding the name */ + file_path: string; + /** Just the name of the file including the extension */ + file_name: string; + /** Checksum of the file */ + sha1?: string; + /** Size in bytes */ + size?: number; +} + +export interface LocalDownloadFileEntry extends DownloadFileEntry +{ + /** Exists on the file system */ + exists: boolean; + /** Matches the checksum */ + matches: boolean; +} + +export interface FrontEndCollection +{ + id: FrontEndId; + name: string; + description: string; + path_platform_cover: string | null; + game_count: number; +} + +export type EmulatorCapabilities = "saves" | "fullscreen" | "resolution" | "batch" | "states" | "config"; + +export interface EmulatorSupport +{ + id: string; + source?: EmulatorSourceEntryType; + supportLevel?: "partial" | "full"; + capabilities?: EmulatorCapabilities[]; +} + +export interface GameLookup +{ + source: string; + id: string; + coverUrl: string | null | undefined; + slug: string | null | undefined; + screenshotUrls: string[]; + name: string; + summary: string | null | undefined; + genres: string[]; + companies: string[]; + game_modes: string[]; + age_ratings: string[]; + player_count: string | undefined; + first_release_date: number | undefined; + average_rating: number | undefined; + keywords: string[]; + igdb_id: number | undefined; + platforms: { + id: number; + name?: string | null; + displayName: string; + slug: string; + }[]; +} + +export interface AutoSaveChange +{ + subPath: string; + cwd: string; +} + +export type SaveSlots = Record; diff --git a/src/bun/api/task-queue.ts b/src/packages/gameflow-sdk/task-queue.ts similarity index 99% rename from src/bun/api/task-queue.ts rename to src/packages/gameflow-sdk/task-queue.ts index 97e783d..b86aab6 100644 --- a/src/bun/api/task-queue.ts +++ b/src/packages/gameflow-sdk/task-queue.ts @@ -1,6 +1,7 @@ -import { JobStatus } from '@/shared/types'; + import EventEmitter from 'node:events'; import z from 'zod'; +import { JobStatus } from './shared'; export class TaskQueue { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5eb5fdf..3c9d776 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,5 +1,3 @@ -import * as z from 'zod'; - export const LOGIN_PORT = 5196; export const OAUTH_REDIRECT_PORT = 5194; export const SERVER_PORT = 5173; @@ -10,211 +8,6 @@ export const RPC_PORT = 8787; export const RPC_URL = (host: string) => `http://${host}:${RPC_PORT}`; export const EMULATORJS_URL = (host: string) => `http://${host}:${EMULATORJS_PORT}`; export const SOCKETS_URL = (host: string) => `ws://${host}:${RPC_PORT}`; -export const settingRegistry = z.registry<{ - dev?: boolean; -}>(); export const DefaultRommStaleTime = 60 * 1000; // A minute - -export const SettingsSchema = z.object({ - rommAddress: z.url().optional(), - rommUser: z.string().default('admin').optional(), - windowSize: z.object({ width: z.number(), height: z.number() }).optional(), - windowPosition: z.object({ x: z.number(), y: z.number() }).optional(), - downloadPath: z.string(), - launchInFullscreen: z.boolean().default(true), - disabledPlugins: z.array(z.string()).default([]), - emulatorResolution: z.enum(['720p', '1080p', '1440p', '4k']).default('720p'), - emulatorWidescreen: z.boolean().default(true) -}); - -export const LocalSettingsSchema = z.object({ - backgroundBlur: z.boolean().default(true).meta({ title: "Background Blur" }), - backgroundAnimation: z.boolean().default(true).meta({ title: "Background Animation" }), - theme: z.enum(['dark', 'light', 'auto']).default('auto').meta({ title: "Theme" }), - soundEffects: z.boolean().default(true).meta({ title: "Sounds" }), - soundEffectsVolume: z.number().min(0).max(100).default(50).meta({ title: "Sound Volume" }), - hapticsEffects: z.boolean().default(true).meta({ title: "Haptics" }), - showRouterDevOptions: z.boolean().default(false).meta({ title: "Show Router Options" }).register(settingRegistry, { dev: true }), - showQueryDevOptions: z.boolean().default(false).meta({ title: "Show Query Options" }).register(settingRegistry, { dev: true }), - useGameflowKeyboard: z.boolean().default(true).describe("Show the gameflow on screen keyboard when using a controller").meta({ title: "Use Gameflow Keyboard" }), - autoKeybaord: z.boolean().default(true).describe("Open on screen keybaord automatically").meta({ title: "Auto Keyboard" }) -}); - -export const GameListFilterSchema = z.object({ - platform_source: z.string().optional(), - platform_slug: z.string().optional(), - platform_id: z.coerce.number().optional(), - collection_id: z.coerce.number().optional(), - collection_source: z.string().optional(), - limit: z.coerce.number().optional(), - search: z.string().optional(), - offset: z.coerce.number().optional(), - source: z.string().optional(), - localOnly: z.coerce.boolean().optional(), - orderBy: z.literal(['added', 'activity', 'name', 'release']).optional(), - age_ratings: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), - genres: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), - keywords: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), -}); - -export const DownloadSourceSchema = z.object({ - id: z.string(), - name: z.string() -}); - -export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() }); - -export type GameListFilterType = z.infer; - -export const DirSchema = z.object({ name: z.string(), parentPath: z.string(), isDirectory: z.boolean() }); -export type DirType = z.infer; - -export const CustomEmulatorSchema = z.record(z.string(), z.string()); - -export const GithubManifestSchema = z.object({ - sha: z.hash('sha1'), - url: z.url(), - tree: z.array(z.object({ - path: z.string(), - mode: z.string(), - type: z.enum(['blob', 'tree']), - sha: z.hash('sha1'), - url: z.url() - })) -}); - -export const StoreGameSaveSchema = z.object({ - cwd: z.string(), - globs: z.string().array() -}); - -export const StoreDownloadSchema = z.discriminatedUnion('type', [ - z.object({ - type: z.literal('direct'), - url: z.url(), - name: z.string().optional(), - system: z.string(), - main: z.string().optional(), - saves: z.record(z.string(), StoreGameSaveSchema).optional() - }), - z.object({ - type: z.literal("itch"), - path: z.string(), - name: z.string().optional(), - system: z.string(), - saves: z.record(z.string(), StoreGameSaveSchema).optional() - }) -]); - -export const NewGameSchema = z.object({ - name: z.string(), - summary: z.string(), - genres: z.string().regex(/^$|^(\s*\S[^,]*)(\s*,\s*\S[^,]*)*\s*$/, { - message: "Must be a comma-separated list", - }) -}); - -export const StoreGameSchema = z.object({ - name: z.string(), - description: z.string(), - version: z.string(), - homepage: z.string().optional(), - keywords: z.string().array().optional(), - genres: z.string().array().optional(), - companies: z.string().array().optional(), - screenshots: z.string().array().optional(), - covers: z.string().array().optional(), - igdb_id: z.number().optional(), - ra_id: z.number().optional(), - sgdb_id: z.number().optional(), - first_release_date: z.union([z.number(), z.date()]).optional(), - player_count: z.string().optional(), - saves: z.record(z.string(), z.record(z.string(), StoreGameSaveSchema)).optional(), - downloads: z.record(z.string(), StoreDownloadSchema) -}); - -export const EmulatorPackageSchema = z.object({ - name: z.string(), - description: z.string(), - homepage: z.url(), - logo: z.url(), - type: z.enum(['emulator']), - os: z.array(z.enum(['darwin', 'linux', 'win32', 'android'])), - keywords: z.array(z.string()).optional(), - downloads: z.record(z.string(), z.array(z.discriminatedUnion('type', [ - z.object({ - type: z.literal(['github', 'gitlab']), - pattern: z.string(), - path: z.string(), - bin: z.string().optional() - }), - z.object({ - type: z.literal('direct'), - url: z.url(), - bin: z.string().optional() - }), - z.object({ - type: z.literal('scoop'), - url: z.url(), - bin: z.string().optional() - }) - ]))).optional(), - systems: z.array(z.string()), - bios: z.literal(["required", "optional"]).optional() -}); - -export const ScoopPackageSchema = z.object({ - version: z.string(), - url: z.url().optional(), - description: z.string(), - bin: z.string().optional(), - architecture: z.record(z.string(), z.object({ - url: z.url(), - hash: z.string().optional(), - extract_dir: z.string().optional() - })).optional() -}); - -export const SystemInfoSchema = z.object({ - battery: z.object({ - percent: z.number(), - isCharging: z.boolean(), - acConnected: z.boolean(), - hasBattery: z.boolean() - - }), - wifiConnections: z.array(z.object({ signalLevel: z.number() })), - bluetoothDevices: z.array(z.object({ connected: z.boolean() })) -}); - -export const GithubReleaseSchema = z.object({ - id: z.number(), - tag_name: z.string().optional(), - url: z.url(), - body: z.string(), - assets: z.array(z.object({ - name: z.string(), - browser_download_url: z.url(), - content_type: z.string().optional() - })) -}); - -export const EmulatorDownloadInfoSchema = z.object({ - id: z.string(), - version: z.string().optional(), - url: z.url().optional(), - description: z.string().optional(), - downloadDate: z.coerce.date(), - type: z.string() -}); - -export type EmulatorPackageType = z.infer; -export type StoreGameType = z.infer; -export type StoreDownloadType = z.infer; -export type SettingsType = z.infer; -export type LocalSettingsType = z.infer; -export const PlatformSchema = z.object({ slug: z.string() }); -export type SystemInfoType = z.infer; -export type EmulatorDownloadInfoType = z.infer; -export type DownloadSourceType = z.infer; +export const PluginRegistry = process.env.STORE_REGISTRY ?? "https://registry.npmjs.org"; \ No newline at end of file diff --git a/src/shared/types.schema.ts b/src/shared/types.schema.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/shared/types.ts b/src/shared/types.ts index dc72a7a..e69de29 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,387 +0,0 @@ -export interface SaveFileChange -{ - subPath: string | string[]; - isGlob?: true; - cwd: string; - shared: boolean; - fixedSize?: boolean; -} - -export type EmulatorSourceType = 'custom' | 'store' | 'registry' | 'system' | 'static' | 'embedded'; - -export interface EmulatorSourceEntryType -{ - binPath: string; - rootPath?: string; - type: EmulatorSourceType; - exists: boolean; -} - -export interface FrontEndEmulator -{ - name: string; - source: string; - logo: string; - systems: EmulatorSystem[]; - description?: string; - gameCount: number; - validSources: EmulatorSourceEntryType[]; - integrations: EmulatorSupport[]; -} - -export interface EmulatorSystem { id: string, romm_slug?: string, name: string, iconUrl: string; } - -export interface FrontEndEmulatorDetailedDownload -{ - name: string; - type: string | undefined; - version?: string; -} - -export interface FrontEndEmulatorDetailed extends FrontEndEmulator -{ - homepage: string; - description: string; - downloads: FrontEndEmulatorDetailedDownload[]; - keywords?: string[]; - screenshots: string[]; - biosRequirement?: "required" | "optional"; - bios?: string[]; - storeDownloadInfo?: { hasUpdate: boolean; version?: string, type: string; description?: string; }; -} - -export interface FrontEndGameTypeDetailedAchievement -{ - id: string; - title: string; - description?: string; - date?: Date; - date_hardcode?: Date; - badge_url?: string; - display_order: number; - type?: string; -} - -export interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator -{ - -} - -export interface FrontEndGameTypeDetailed extends Exclude -{ - summary: string | null; - fs_size_bytes: number | null; - missing: boolean; - local: boolean; - version?: string | null; - version_system?: string | null; - version_source?: string | null; - metadata: FrontEndGameMetadataDetailed, - emulators?: FrontEndGameTypeDetailedEmulator[], - achievements?: { - unlocked: number; - total: number; - entires: FrontEndGameTypeDetailedAchievement[]; - }; -}; - -export interface Drive -{ - parent: string | null; - device: string; - label: string; - mountPoint: string | null; - type: string; - size: number; - used: number; - isRemovable: boolean; - interfaceType: string | null; - hasWriteAccess: boolean; - hasReadAccess: boolean; -} - -export interface DownloadsDrive -{ - device: string; - label: string; - mountPoint: string | null; - isRemovable: boolean; - size: number; - used: number; - isCurrentlyUsed: boolean; - unusableReason: 'not_enough_space' | 'already_used' | null; -} - -export interface FrontendNotification -{ - title?: string; - message: string; - type: 'success' | 'error' | 'info' | 'custom'; - icon?: "save" | "upload" | "clock"; - duration?: number; -} - -export interface CommandEntry -{ - /** The ID of the command. Could be just an index or a string */ - id: string | number; - /** The front end label for the command. Mainly gotten from ES-DE list */ - label?: string; - /** Compiled command to be executed */ - command: string | string[]; - /** Environment variables */ - env?: Record, - /** The path the spawned process will start at */ - startDir?: string; - /** Is the command valid, for example does the executable exists */ - valid: boolean; - /** Run the command as shell. Defaults is true */ - shell?: boolean; - /** For what emulator is the command */ - emulator?: string; - /** Where the emulator came from */ - emulatorSource?: EmulatorSourceType; - /** Metadata for the command */ - metadata: { - romPath?: string; - emulatorBin?: string; - /** The root directory of the emulator */ - emulatorDir?: string; - }; -} - -export interface FrontEndId -{ - id: string; - source: string; -} - -// Stuff stored in the local sqlite metadata field -export interface LocalGameMetadata -{ - genres?: string[], - companies?: string[], - game_modes?: string[], - age_ratings?: string[]; - player_count?: string; - first_release_date?: number; - average_rating?: number; -} - -export interface FrontEndPlatformType -{ - id: FrontEndId; - slug: string; - name: string; - family_name?: string | null; - path_cover: string | null; - game_count: number; - updated_at: Date; - hasLocal: boolean; - paths_screenshots: string[]; -} - -export interface FrontEndGameTypeWithIds extends FrontEndGameType -{ - igdb_id: number | null; - ra_id: number | null; -} - -export interface FrontEndFilterSets -{ - age_ratings: Set, - player_counts: Set, - languages: Set, - companies: Set, - genres: Set; -} - -export interface FrontEndFilterLists -{ - age_ratings: string[], - player_counts: string[], - languages: string[], - companies: string[], - genres: string[]; -} - -export interface FrontEndGameMetadata -{ - first_release_date: Date | null; -} - -export interface FrontEndGameMetadataDetailed extends FrontEndGameMetadata -{ - genres: string[], - companies: string[], - game_modes: string[], - age_ratings: string[]; - player_count: string | null; - average_rating: number | null; -} - -export interface FrontEndGameType -{ - platform_display_name: string | null, - path_platform_cover: string | null; - id: FrontEndId, - source: string | null, - source_id: string | null, - path_fs: string | null, - path_covers: string[], - last_played: Date | null, - updated_at: Date, - metadata: FrontEndGameMetadata, - slug: string | null, - name: string | null, - platform_id: number | null, - platform_slug: string | null, - paths_screenshots: string[]; -}; - -export type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing' | 'queued'; - -export interface GameInstallProgress -{ - progress?: number; - status?: GameStatusType; - details?: string; - commands?: CommandEntry[]; - error?: any; -} - -export type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted'; -export type GameInstallProgressEvent = 'refresh'; - -export interface FrontendPlugin -{ - name: string; - displayName: string; - description: string; - category: string; - enabled: boolean; - canDisable: boolean; - source: PluginSourceType; - hasSettings: boolean; - version: string; - icon?: string; -} - -export type PluginSourceType = "builtin"; - -export type KeysWithValueAssignableTo = { - [K in keyof T]: Exclude extends Value ? K : never; -}[keyof T]; - -export interface DownloadInfo -{ - id: string; - screenshotUrls: string[]; - coverUrl: string; - platform?: DownloadPlatform; - slug?: string; - path_fs?: string; - main_glob?: string; - summary?: string; - name: string; - last_played?: Date; - igdb_id?: number; - ra_id?: number; - source_id: string; - system_slug: string; - extract_path?: string; - metadata?: any; - files: DownloadFileEntry[]; - auth?: string; - version?: string; - version_source?: string; - version_system?: string; -} - -export interface DownloadPlatform -{ - id: string; - source: string; - igdb_id?: number; - igdb_slug?: string; - ra_id?: number; - moby_id?: number; - slug: string; - name: string; - /** Like Sony or Nintendo */ - family_name?: string; -} - -export interface DownloadFileEntry -{ - url: URL; - /** The path of the file, excluding the name */ - file_path: string; - /** Just the name of the file including the extension */ - file_name: string; - /** Checksum of the file */ - sha1?: string; - /** Size in bytes */ - size?: number; -} - -export interface LocalDownloadFileEntry extends DownloadFileEntry -{ - /** Exists on the file system */ - exists: boolean; - /** Matches the checksum */ - matches: boolean; -} - -export interface FrontEndCollection -{ - id: FrontEndId; - name: string; - description: string; - path_platform_cover: string | null; - game_count: number; -} - -export type EmulatorCapabilities = "saves" | "fullscreen" | "resolution" | "batch" | "states" | "config"; - -export interface EmulatorSupport -{ - id: string; - source?: EmulatorSourceEntryType; - supportLevel?: "partial" | "full"; - capabilities?: EmulatorCapabilities[]; -} - -export interface GameLookup -{ - source: string; - id: string; - coverUrl: string | null | undefined; - slug: string | null | undefined; - screenshotUrls: string[]; - name: string; - summary: string | null | undefined; - genres: string[]; - companies: string[]; - game_modes: string[]; - age_ratings: string[]; - player_count: string | undefined; - first_release_date: number | undefined; - average_rating: number | undefined; - keywords: string[]; - igdb_id: number | undefined; - platforms: { - id: number; - name?: string | null; - displayName: string; - slug: string; - }[]; -} - -export interface AutoSaveChange -{ - subPath: string; - cwd: string; -} - -export type SaveSlots = Record; \ No newline at end of file diff --git a/src/tests/downloads.test.ts b/src/tests/downloads.test.ts index 6a58e55..7be5dd0 100644 --- a/src/tests/downloads.test.ts +++ b/src/tests/downloads.test.ts @@ -4,7 +4,7 @@ import * as app from '@/bun/api/app'; import fs from 'node:fs/promises'; import path from "node:path"; import AdmZip from "adm-zip"; -import { DownloadInfo } from '@/shared/types'; +import { DownloadInfo } from '@simeonradivoev/gameflow-sdk/shared'; describe("Download Tests", () => { diff --git a/tsconfig.json b/tsconfig.json index 7fb79f1..500e8f7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,7 @@ "noUnusedLocals": true, "noFallthroughCasesInSwitch": true, "paths": { + "@simeonradivoev/gameflow-sdk/*": ["./src/packages/gameflow-sdk/*"], "@/*": [ "./src/*" ], From 2e78ddf08e633b2d0897deff0f0b5e0bde084dd0 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 10 May 2026 02:51:49 +0300 Subject: [PATCH 62/65] refactor: moved to commit-and-tag-version --- .versionrc | 18 ++++++++ bun.lock | 126 +++++++++++++++++++++++++++------------------------ package.json | 2 +- 3 files changed, 86 insertions(+), 60 deletions(-) create mode 100644 .versionrc diff --git a/.versionrc b/.versionrc new file mode 100644 index 0000000..fa5c461 --- /dev/null +++ b/.versionrc @@ -0,0 +1,18 @@ +{ + "packageFiles": [ + { + "filename": "package.json", + "type": "json" + } + ], + "bumpFiles": [ + { + "filename": "package.json", + "type": "json" + }, + { + "filename": "src/packages/gameflow-sdk/package.json", + "type": "json" + } + ] +} \ No newline at end of file diff --git a/bun.lock b/bun.lock index cd65d27..c4a2b79 100644 --- a/bun.lock +++ b/bun.lock @@ -75,6 +75,7 @@ "audiosprite": "^0.7.2", "babel-plugin-react-compiler": "^1.0.0", "classnames": "^2.5.1", + "commit-and-tag-version": "^12.7.3", "concurrently": "^9.2.1", "cross-env": "^10.1.0", "daisyui": "^5.5.19", @@ -91,7 +92,6 @@ "react-markdown": "^10.1.0", "react-qr-code": "^2.0.21", "sass-embedded": "^1.99.0", - "standard-version": "^9.5.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", "tailwindcss-animate": "^1.0.7", @@ -730,7 +730,7 @@ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], @@ -810,7 +810,7 @@ "chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -836,9 +836,9 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="], @@ -852,6 +852,8 @@ "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + "commit-and-tag-version": ["commit-and-tag-version@12.7.3", "", { "dependencies": { "chalk": "^2.4.2", "conventional-changelog": "4.0.0", "conventional-changelog-config-spec": "2.1.0", "conventional-changelog-conventionalcommits": "6.1.0", "conventional-recommended-bump": "7.0.1", "detect-indent": "^6.1.0", "detect-newline": "^3.1.0", "dotgitignore": "^2.1.0", "fast-xml-parser": "^5.5.6", "figures": "^3.2.0", "find-up": "^5.0.0", "git-semver-tags": "^5.0.1", "semver": "^7.7.2", "yaml": "^2.6.0", "yargs": "^17.7.2" }, "bin": { "commit-and-tag-version": "bin/cli.js" } }, "sha512-rbauuCDU98yEHMy/LrNNu8HLTuGv7C2kN/3GXC59L18aJGii0eiryCESb1SEHXNFem2/2ngWG/Pq6qaCqw3aCw=="], + "compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], @@ -866,39 +868,39 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], - "conventional-changelog": ["conventional-changelog@3.1.25", "", { "dependencies": { "conventional-changelog-angular": "^5.0.12", "conventional-changelog-atom": "^2.0.8", "conventional-changelog-codemirror": "^2.0.8", "conventional-changelog-conventionalcommits": "^4.5.0", "conventional-changelog-core": "^4.2.1", "conventional-changelog-ember": "^2.0.9", "conventional-changelog-eslint": "^3.0.9", "conventional-changelog-express": "^2.0.6", "conventional-changelog-jquery": "^3.0.11", "conventional-changelog-jshint": "^2.0.9", "conventional-changelog-preset-loader": "^2.3.4" } }, "sha512-ryhi3fd1mKf3fSjbLXOfK2D06YwKNic1nC9mWqybBHdObPd8KJ2vjaXZfYj1U23t+V8T8n0d7gwnc9XbIdFbyQ=="], + "conventional-changelog": ["conventional-changelog@4.0.0", "", { "dependencies": { "conventional-changelog-angular": "^6.0.0", "conventional-changelog-atom": "^3.0.0", "conventional-changelog-codemirror": "^3.0.0", "conventional-changelog-conventionalcommits": "^6.0.0", "conventional-changelog-core": "^5.0.0", "conventional-changelog-ember": "^3.0.0", "conventional-changelog-eslint": "^4.0.0", "conventional-changelog-express": "^3.0.0", "conventional-changelog-jquery": "^4.0.0", "conventional-changelog-jshint": "^3.0.0", "conventional-changelog-preset-loader": "^3.0.0" } }, "sha512-JbZjwE1PzxQCvm+HUTIr+pbSekS8qdOZzMakdFyPtdkEWwFvwEJYONzjgMm0txCb2yBcIcfKDmg8xtCKTdecNQ=="], - "conventional-changelog-angular": ["conventional-changelog-angular@5.0.13", "", { "dependencies": { "compare-func": "^2.0.0", "q": "^1.5.1" } }, "sha512-i/gipMxs7s8L/QeuavPF2hLnJgH6pEZAttySB6aiQLWcX3puWDL3ACVmvBhJGxnAy52Qc15ua26BufY6KpmrVA=="], + "conventional-changelog-angular": ["conventional-changelog-angular@6.0.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-6qLgrBF4gueoC7AFVHu51nHL9pF9FRjXrH+ceVf7WmAfH3gs+gEYOkvxhjMPjZu57I4AGUGoNTY8V7Hrgf1uqg=="], - "conventional-changelog-atom": ["conventional-changelog-atom@2.0.8", "", { "dependencies": { "q": "^1.5.1" } }, "sha512-xo6v46icsFTK3bb7dY/8m2qvc8sZemRgdqLb/bjpBsH2UyOS8rKNTgcb5025Hri6IpANPApbXMg15QLb1LJpBw=="], + "conventional-changelog-atom": ["conventional-changelog-atom@3.0.0", "", {}, "sha512-pnN5bWpH+iTUWU3FaYdw5lJmfWeqSyrUkG+wyHBI9tC1dLNnHkbAOg1SzTQ7zBqiFrfo55h40VsGXWMdopwc5g=="], - "conventional-changelog-codemirror": ["conventional-changelog-codemirror@2.0.8", "", { "dependencies": { "q": "^1.5.1" } }, "sha512-z5DAsn3uj1Vfp7po3gpt2Boc+Bdwmw2++ZHa5Ak9k0UKsYAO5mH1UBTN0qSCuJZREIhX6WU4E1p3IW2oRCNzQw=="], + "conventional-changelog-codemirror": ["conventional-changelog-codemirror@3.0.0", "", {}, "sha512-wzchZt9HEaAZrenZAUUHMCFcuYzGoZ1wG/kTRMICxsnW5AXohYMRxnyecP9ob42Gvn5TilhC0q66AtTPRSNMfw=="], "conventional-changelog-config-spec": ["conventional-changelog-config-spec@2.1.0", "", {}, "sha512-IpVePh16EbbB02V+UA+HQnnPIohgXvJRxHcS5+Uwk4AT5LjzCZJm5sp/yqs5C6KZJ1jMsV4paEV13BN1pvDuxQ=="], - "conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@4.6.3", "", { "dependencies": { "compare-func": "^2.0.0", "lodash": "^4.17.15", "q": "^1.5.1" } }, "sha512-LTTQV4fwOM4oLPad317V/QNQ1FY4Hju5qeBIM1uTHbrnCE+Eg4CdRZ3gO2pUeR+tzWdp80M2j3qFFEDWVqOV4g=="], + "conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@6.1.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-3cS3GEtR78zTfMzk0AizXKKIdN4OvSh7ibNz6/DPbhWWQu7LqE/8+/GqSodV+sywUR2gpJAdP/1JFf4XtN7Zpw=="], - "conventional-changelog-core": ["conventional-changelog-core@4.2.4", "", { "dependencies": { "add-stream": "^1.0.0", "conventional-changelog-writer": "^5.0.0", "conventional-commits-parser": "^3.2.0", "dateformat": "^3.0.0", "get-pkg-repo": "^4.0.0", "git-raw-commits": "^2.0.8", "git-remote-origin-url": "^2.0.0", "git-semver-tags": "^4.1.1", "lodash": "^4.17.15", "normalize-package-data": "^3.0.0", "q": "^1.5.1", "read-pkg": "^3.0.0", "read-pkg-up": "^3.0.0", "through2": "^4.0.0" } }, "sha512-gDVS+zVJHE2v4SLc6B0sLsPiloR0ygU7HaDW14aNJE1v4SlqJPILPl/aJC7YdtRE4CybBf8gDwObBvKha8Xlyg=="], + "conventional-changelog-core": ["conventional-changelog-core@5.0.2", "", { "dependencies": { "add-stream": "^1.0.0", "conventional-changelog-writer": "^6.0.0", "conventional-commits-parser": "^4.0.0", "dateformat": "^3.0.3", "get-pkg-repo": "^4.2.1", "git-raw-commits": "^3.0.0", "git-remote-origin-url": "^2.0.0", "git-semver-tags": "^5.0.0", "normalize-package-data": "^3.0.3", "read-pkg": "^3.0.0", "read-pkg-up": "^3.0.0" } }, "sha512-RhQOcDweXNWvlRwUDCpaqXzbZemKPKncCWZG50Alth72WITVd6nhVk9MJ6w1k9PFNBcZ3YwkdkChE+8+ZwtUug=="], - "conventional-changelog-ember": ["conventional-changelog-ember@2.0.9", "", { "dependencies": { "q": "^1.5.1" } }, "sha512-ulzIReoZEvZCBDhcNYfDIsLTHzYHc7awh+eI44ZtV5cx6LVxLlVtEmcO+2/kGIHGtw+qVabJYjdI5cJOQgXh1A=="], + "conventional-changelog-ember": ["conventional-changelog-ember@3.0.0", "", {}, "sha512-7PYthCoSxIS98vWhVcSphMYM322OxptpKAuHYdVspryI0ooLDehRXWeRWgN+zWSBXKl/pwdgAg8IpLNSM1/61A=="], - "conventional-changelog-eslint": ["conventional-changelog-eslint@3.0.9", "", { "dependencies": { "q": "^1.5.1" } }, "sha512-6NpUCMgU8qmWmyAMSZO5NrRd7rTgErjrm4VASam2u5jrZS0n38V7Y9CzTtLT2qwz5xEChDR4BduoWIr8TfwvXA=="], + "conventional-changelog-eslint": ["conventional-changelog-eslint@4.0.0", "", {}, "sha512-nEZ9byP89hIU0dMx37JXQkE1IpMmqKtsaR24X7aM3L6Yy/uAtbb+ogqthuNYJkeO1HyvK7JsX84z8649hvp43Q=="], - "conventional-changelog-express": ["conventional-changelog-express@2.0.6", "", { "dependencies": { "q": "^1.5.1" } }, "sha512-SDez2f3iVJw6V563O3pRtNwXtQaSmEfTCaTBPCqn0oG0mfkq0rX4hHBq5P7De2MncoRixrALj3u3oQsNK+Q0pQ=="], + "conventional-changelog-express": ["conventional-changelog-express@3.0.0", "", {}, "sha512-HqxihpUMfIuxvlPvC6HltA4ZktQEUan/v3XQ77+/zbu8No/fqK3rxSZaYeHYant7zRxQNIIli7S+qLS9tX9zQA=="], - "conventional-changelog-jquery": ["conventional-changelog-jquery@3.0.11", "", { "dependencies": { "q": "^1.5.1" } }, "sha512-x8AWz5/Td55F7+o/9LQ6cQIPwrCjfJQ5Zmfqi8thwUEKHstEn4kTIofXub7plf1xvFA2TqhZlq7fy5OmV6BOMw=="], + "conventional-changelog-jquery": ["conventional-changelog-jquery@4.0.0", "", {}, "sha512-TTIN5CyzRMf8PUwyy4IOLmLV2DFmPtasKN+x7EQKzwSX8086XYwo+NeaeA3VUT8bvKaIy5z/JoWUvi7huUOgaw=="], - "conventional-changelog-jshint": ["conventional-changelog-jshint@2.0.9", "", { "dependencies": { "compare-func": "^2.0.0", "q": "^1.5.1" } }, "sha512-wMLdaIzq6TNnMHMy31hql02OEQ8nCQfExw1SE0hYL5KvU+JCTuPaDO+7JiogGT2gJAxiUGATdtYYfh+nT+6riA=="], + "conventional-changelog-jshint": ["conventional-changelog-jshint@3.0.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-bQof4byF4q+n+dwFRkJ/jGf9dCNUv4/kCDcjeCizBvfF81TeimPZBB6fT4HYbXgxxfxWXNl/i+J6T0nI4by6DA=="], - "conventional-changelog-preset-loader": ["conventional-changelog-preset-loader@2.3.4", "", {}, "sha512-GEKRWkrSAZeTq5+YjUZOYxdHq+ci4dNwHvpaBC3+ENalzFWuCWa9EZXSuZBpkr72sMdKB+1fyDV4takK1Lf58g=="], + "conventional-changelog-preset-loader": ["conventional-changelog-preset-loader@3.0.0", "", {}, "sha512-qy9XbdSLmVnwnvzEisjxdDiLA4OmV3o8db+Zdg4WiFw14fP3B6XNz98X0swPPpkTd/pc1K7+adKgEDM1JCUMiA=="], - "conventional-changelog-writer": ["conventional-changelog-writer@5.0.1", "", { "dependencies": { "conventional-commits-filter": "^2.0.7", "dateformat": "^3.0.0", "handlebars": "^4.7.7", "json-stringify-safe": "^5.0.1", "lodash": "^4.17.15", "meow": "^8.0.0", "semver": "^6.0.0", "split": "^1.0.0", "through2": "^4.0.0" }, "bin": { "conventional-changelog-writer": "cli.js" } }, "sha512-5WsuKUfxW7suLblAbFnxAcrvf6r+0b7GvNaWUwUIk0bXMnENP/PEieGKVUQrjPqwPT4o3EPAASBXiY6iHooLOQ=="], + "conventional-changelog-writer": ["conventional-changelog-writer@6.0.1", "", { "dependencies": { "conventional-commits-filter": "^3.0.0", "dateformat": "^3.0.3", "handlebars": "^4.7.7", "json-stringify-safe": "^5.0.1", "meow": "^8.1.2", "semver": "^7.0.0", "split": "^1.0.1" }, "bin": { "conventional-changelog-writer": "cli.js" } }, "sha512-359t9aHorPw+U+nHzUXHS5ZnPBOizRxfQsWT5ZDHBfvfxQOAik+yfuhKXG66CN5LEWPpMNnIMHUTCKeYNprvHQ=="], - "conventional-commits-filter": ["conventional-commits-filter@2.0.7", "", { "dependencies": { "lodash.ismatch": "^4.4.0", "modify-values": "^1.0.0" } }, "sha512-ASS9SamOP4TbCClsRHxIHXRfcGCnIoQqkvAzCSbZzTFLfcTqJVugB0agRgsEELsqaeWgsXv513eS116wnlSSPA=="], + "conventional-commits-filter": ["conventional-commits-filter@3.0.0", "", { "dependencies": { "lodash.ismatch": "^4.4.0", "modify-values": "^1.0.1" } }, "sha512-1ymej8b5LouPx9Ox0Dw/qAO2dVdfpRFq28e5Y0jJEU8ZrLdy0vOSkkIInwmxErFGhg6SALro60ZrwYFVTUDo4Q=="], - "conventional-commits-parser": ["conventional-commits-parser@3.2.4", "", { "dependencies": { "JSONStream": "^1.0.4", "is-text-path": "^1.0.1", "lodash": "^4.17.15", "meow": "^8.0.0", "split2": "^3.0.0", "through2": "^4.0.0" }, "bin": { "conventional-commits-parser": "cli.js" } }, "sha512-nK7sAtfi+QXbxHCYfhpZsfRtaitZLIA6889kFIouLvz6repszQDgxBu7wf2WbU+Dco7sAnNCJYERCwt54WPC2Q=="], + "conventional-commits-parser": ["conventional-commits-parser@4.0.0", "", { "dependencies": { "JSONStream": "^1.3.5", "is-text-path": "^1.0.1", "meow": "^8.1.2", "split2": "^3.2.2" }, "bin": { "conventional-commits-parser": "cli.js" } }, "sha512-WRv5j1FsVM5FISJkoYMR6tPk07fkKT0UodruX4je86V4owk451yjXAKzKAPOs9l7y59E2viHUS9eQ+dfUA9NSg=="], - "conventional-recommended-bump": ["conventional-recommended-bump@6.1.0", "", { "dependencies": { "concat-stream": "^2.0.0", "conventional-changelog-preset-loader": "^2.3.4", "conventional-commits-filter": "^2.0.7", "conventional-commits-parser": "^3.2.0", "git-raw-commits": "^2.0.8", "git-semver-tags": "^4.1.1", "meow": "^8.0.0", "q": "^1.5.1" }, "bin": { "conventional-recommended-bump": "cli.js" } }, "sha512-uiApbSiNGM/kkdL9GTOLAqC4hbptObFo4wW2QRyHsKciGAfQuLU1ShZ1BIVI/+K2BE/W1AWYQMCXAsv4dyKPaw=="], + "conventional-recommended-bump": ["conventional-recommended-bump@7.0.1", "", { "dependencies": { "concat-stream": "^2.0.0", "conventional-changelog-preset-loader": "^3.0.0", "conventional-commits-filter": "^3.0.0", "conventional-commits-parser": "^4.0.0", "git-raw-commits": "^3.0.0", "git-semver-tags": "^5.0.0", "meow": "^8.1.2" }, "bin": { "conventional-recommended-bump": "cli.js" } }, "sha512-Ft79FF4SlOFvX4PkwFDRnaNiIVX7YbmqGU0RwccUaiGvgp3S0a8ipR2/Qxk31vclDNM+GSdJOVs2KrsUCjblVA=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -1100,11 +1102,11 @@ "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], - "git-raw-commits": ["git-raw-commits@2.0.11", "", { "dependencies": { "dargs": "^7.0.0", "lodash": "^4.17.15", "meow": "^8.0.0", "split2": "^3.0.0", "through2": "^4.0.0" }, "bin": { "git-raw-commits": "cli.js" } }, "sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A=="], + "git-raw-commits": ["git-raw-commits@3.0.0", "", { "dependencies": { "dargs": "^7.0.0", "meow": "^8.1.2", "split2": "^3.2.2" }, "bin": { "git-raw-commits": "cli.js" } }, "sha512-b5OHmZ3vAgGrDn/X0kS+9qCfNKWe4K/jFnhwzVWWg0/k5eLa3060tZShrRg8Dja5kPc+YjS0Gc6y7cRr44Lpjw=="], "git-remote-origin-url": ["git-remote-origin-url@2.0.0", "", { "dependencies": { "gitconfiglocal": "^1.0.0", "pify": "^2.3.0" } }, "sha512-eU+GGrZgccNJcsDH5LkXR3PB9M958hxc7sbA8DFJjrv9j4L2P/eZfKhM+QD6wyzpiv+b1BpK0XrYCxkovtjSLw=="], - "git-semver-tags": ["git-semver-tags@4.1.1", "", { "dependencies": { "meow": "^8.0.0", "semver": "^6.0.0" }, "bin": { "git-semver-tags": "cli.js" } }, "sha512-OWyMt5zBe7xFs8vglMmhM9lRQzCWL3WjHtxNNfJTMngGym7pC1kh8sP6jevfydJ6LP3ZvGxfb6ABYgPUM0mtsA=="], + "git-semver-tags": ["git-semver-tags@5.0.1", "", { "dependencies": { "meow": "^8.1.2", "semver": "^7.0.0" }, "bin": { "git-semver-tags": "cli.js" } }, "sha512-hIvOeZwRbQ+7YEUmCkHqo8FOLQZCEn18yevLHADlFPZY02KJGsu5FZt9YW/lybfK2uhWFI7Qg/07LekJiTv7iA=="], "gitconfiglocal": ["gitconfiglocal@1.0.0", "", { "dependencies": { "ini": "^1.3.2" } }, "sha512-spLUXeTAVHxDtKsJc8FkFVgFtMdEN9qPGpL23VfSHx4fP4+Ds097IXLvymbnDH8FnmxX5Nr9bPw3A+AQ6mWEaQ=="], @@ -1122,7 +1124,7 @@ "gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="], - "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], "hard-rejection": ["hard-rejection@2.1.0", "", {}, "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA=="], @@ -1180,7 +1182,7 @@ "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], - "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], @@ -1550,8 +1552,6 @@ "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], - "q": ["q@1.5.1", "", {}, "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw=="], - "qr.js": ["qr.js@0.0.0", "", {}, "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ=="], "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], @@ -1596,7 +1596,7 @@ "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], - "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], @@ -1712,8 +1712,6 @@ "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], - "standard-version": ["standard-version@9.5.0", "", { "dependencies": { "chalk": "^2.4.2", "conventional-changelog": "3.1.25", "conventional-changelog-config-spec": "2.1.0", "conventional-changelog-conventionalcommits": "4.6.3", "conventional-recommended-bump": "6.1.0", "detect-indent": "^6.0.0", "detect-newline": "^3.1.0", "dotgitignore": "^2.1.0", "figures": "^3.1.0", "find-up": "^5.0.0", "git-semver-tags": "^4.0.0", "semver": "^7.1.1", "stringify-package": "^1.0.1", "yargs": "^16.0.0" }, "bin": { "standard-version": "bin/cli.js" } }, "sha512-3zWJ/mmZQsOaO+fOlsa0+QK90pwhNd042qEcw6hKFNoLFs7peGyvPffpEBbK/DSGPbyOvli0mUIFv5A4qTjh2Q=="], - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1722,8 +1720,6 @@ "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], - "stringify-package": ["stringify-package@1.0.1", "", {}, "sha512-sa4DUQsYciMP1xhKWGuFM04fB0LG/9DlluZoSVywUMRNvzid6XucHK0/90xGxRoHrAaROrcHK1aPKaijCtSrhg=="], - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1774,7 +1770,7 @@ "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], - "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], + "through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="], "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], @@ -1918,6 +1914,8 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -2036,11 +2034,11 @@ "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], - "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], "compare-func/dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], - "conventional-changelog-writer/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "concurrently/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], @@ -2054,12 +2052,8 @@ "engine.io/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - "get-pkg-repo/through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="], - "get-pkg-repo/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], - "git-semver-tags/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "gitconfiglocal/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], "glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], @@ -2074,8 +2068,12 @@ "http-proxy/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "http-server/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + "is-core-module/hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + "load-json-file/pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], "meow/read-pkg-up": ["read-pkg-up@7.0.1", "", { "dependencies": { "find-up": "^4.1.0", "read-pkg": "^5.2.0", "type-fest": "^0.8.1" } }, "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg=="], @@ -2110,16 +2108,14 @@ "sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "standard-version/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], - - "standard-version/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], - "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "through2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "tough-cookie-file-store/tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], "tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], @@ -2132,6 +2128,10 @@ "winston/async": ["async@1.0.0", "", {}, "sha512-5mO7DX4CbJzp9zjaFXusQQ4tzKJARjNB1Ih1pVBi8wkbmXy/xzIDgEMXxWePLzt2OdFwaxfneIlT1nCiXubrPQ=="], + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "xml2js/sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], "@ap0nia/eden/elysia/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -2200,18 +2200,26 @@ "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + + "concurrently/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "concurrently/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], "dotgitignore/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], - "get-pkg-repo/through2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - "get-pkg-repo/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], "get-pkg-repo/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], "hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "http-server/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "http-server/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "meow/read-pkg-up/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "meow/read-pkg-up/read-pkg": ["read-pkg@5.2.0", "", { "dependencies": { "@types/normalize-package-data": "^2.4.0", "normalize-package-data": "^2.5.0", "parse-json": "^5.0.0", "type-fest": "^0.6.0" } }, "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg=="], @@ -2226,13 +2234,7 @@ "sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "standard-version/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], - - "standard-version/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], - - "standard-version/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], - - "standard-version/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], + "through2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], @@ -2340,13 +2342,19 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "wrap-ansi-cjs/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "@node-minify/core/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "concurrently/chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "dotgitignore/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], "dotgitignore/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], - "get-pkg-repo/through2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "http-server/chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "meow/read-pkg-up/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -2360,24 +2368,24 @@ "read-pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], - "standard-version/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + "wrap-ansi-cjs/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "standard-version/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + "wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concurrently/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "dotgitignore/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "http-server/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "meow/read-pkg-up/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "meow/read-pkg-up/read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@2.8.9", "", {}, "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw=="], "meow/read-pkg-up/read-pkg/normalize-package-data/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], - "meow/read-pkg-up/read-pkg/parse-json/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], - "read-pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@1.3.0", "", { "dependencies": { "p-try": "^1.0.0" } }, "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q=="], - "standard-version/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], - "meow/read-pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "read-pkg-up/find-up/locate-path/p-locate/p-limit/p-try": ["p-try@1.0.0", "", {}, "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww=="], diff --git a/package.json b/package.json index bf49ccc..b1b8c89 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "audiosprite": "^0.7.2", "babel-plugin-react-compiler": "^1.0.0", "classnames": "^2.5.1", + "commit-and-tag-version": "^12.7.3", "concurrently": "^9.2.1", "cross-env": "^10.1.0", "daisyui": "^5.5.19", @@ -142,7 +143,6 @@ "react-markdown": "^10.1.0", "react-qr-code": "^2.0.21", "sass-embedded": "^1.99.0", - "standard-version": "^9.5.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", "tailwindcss-animate": "^1.0.7", From 9a3e60562589a0ffb884374113dc1d731ab2f1bf Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 10 May 2026 02:53:05 +0300 Subject: [PATCH 63/65] chore(release): 1.6.0 --- CHANGELOG.md | 9 ++++++++- package.json | 6 +++--- src/packages/gameflow-sdk/package.json | 4 ++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8334db8..0a70168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog -All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. + +## [1.6.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.5.0...v1.6.0) (2026-05-09) + + +### Features + +* Implemented public plugin system accessible from the store. ([38cb752](https://github.com/simeonradivoev/gameflow-deck/commit/38cb7525527b5ad4f6eb284cdad0001fd87eaf7e)) ## [1.5.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.4.0...v1.5.0) (2026-05-05) diff --git a/package.json b/package.json index b1b8c89..99a3e52 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "email": "work@simeonradivoev.com", "url": "https://simeonradivoev.com" }, - "version": "1.5.0", + "version": "1.6.0", "description": "Game Launcher", "icon": "./src/mainview/assets/icon.svg", "main": "./src/bun/index.ts", @@ -46,7 +46,7 @@ "flatpak:install": "bun run flatpak:build && flatpak --user install --reinstall \"$PWD/.config/flatpak/repo\" com.simeonradivoev.gameflow-deck", "build:prod:appimage": "bun run build:prod && bun run ./scripts/build-appimage.ts", "build:dev:appimage": "bun run build && bun run ./scripts/build-appimage.ts", - "version:generate": "standard-version --sign", + "version:generate": "commit-and-tag-version --sign", "package:Linux": "bun run build:prod:appimage", "package:Windows": "bun run build:prod", "download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium", @@ -153,4 +153,4 @@ "vite-static-assets-plugin": "^1.2.2", "vite-tsconfig-paths": "^6.1.1" } -} \ No newline at end of file +} diff --git a/src/packages/gameflow-sdk/package.json b/src/packages/gameflow-sdk/package.json index 7e51c16..09354fe 100644 --- a/src/packages/gameflow-sdk/package.json +++ b/src/packages/gameflow-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@simeonradivoev/gameflow-sdk", - "version": "1.5.3", + "version": "1.6.0", "types": "index.d.ts", "description": "plugin SDK for the Gameflow Deck Launcher", "exports": { @@ -48,4 +48,4 @@ "gameflow", "sdk" ] -} \ No newline at end of file +} From 9141fb35d48ae272e5ba73f28683d13ba5ca49a3 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Fri, 15 May 2026 13:50:55 +0300 Subject: [PATCH 64/65] feat: Implemented link game importing feat: Implemented download page for downloading roms from various sources using plugins. Added support for internet archive external plugin. feat: Added tasks page to track running tasks/downloads feat: Added tanstack caching feat: Added quick play action Fixes #6 feat: Added quick emulator launch action fix: Made task queue only support 1 task per group and task ID should now be unique --- bun.lock | 102 +++---- package.json | 20 +- scripts/dev.ts | 7 + src/bun/api/controls/windows.ts | 1 - src/bun/api/games/games.ts | 66 ++++- src/bun/api/games/services/utils.ts | 38 ++- src/bun/api/jobs/bios-download-job.ts | 36 ++- src/bun/api/jobs/emulator-download-job.ts | 52 ++-- src/bun/api/jobs/import-job.ts | 77 ++++-- src/bun/api/jobs/install-job.ts | 158 ++--------- src/bun/api/jobs/jobs.ts | 95 ++++++- src/bun/api/jobs/launch-game-job.ts | 2 +- src/bun/api/jobs/login-job.ts | 2 +- src/bun/api/jobs/test-download-job.ts | 30 ++ .../com.simeonradivoev.gameflow.romm/romm.ts | 5 +- .../services.ts | 3 +- .../store.ts | 134 ++++++++- src/bun/api/settings/settings.ts | 6 + src/bun/api/system.ts | 8 + src/bun/utils.ts | 7 + src/bun/utils/downloader.ts | 22 +- src/mainview/components/AppCommunication.tsx | 32 ++- src/mainview/components/CardList.tsx | 34 ++- src/mainview/components/ContextDialog.tsx | 6 +- src/mainview/components/Filters.tsx | 2 +- src/mainview/components/GameList.tsx | 12 +- src/mainview/components/GamepadKeyboard.tsx | 4 - .../components/GlobalContextDialog.tsx | 28 ++ src/mainview/components/Header.tsx | 44 ++- src/mainview/components/HeaderSearchField.tsx | 4 +- src/mainview/components/RoundButton.tsx | 3 +- src/mainview/components/Screenshots.tsx | 7 +- src/mainview/components/SelectMenu.tsx | 4 +- src/mainview/components/SideFilters.tsx | 260 ++++++++++++------ .../components/game/ActionButtons.tsx | 6 +- src/mainview/components/game/MainActions.tsx | 155 +++++------ src/mainview/components/options/Button.tsx | 17 +- .../components/store/StoreEmulatorCard.tsx | 34 ++- src/mainview/gen/routeTree.gen.ts | 64 +++++ src/mainview/index.css | 1 + src/mainview/index.tsx | 42 ++- src/mainview/routes/__root.tsx | 9 +- src/mainview/routes/game/$source.$id.tsx | 4 +- src/mainview/routes/game/add.tsx | 39 ++- src/mainview/routes/games.tsx | 2 +- src/mainview/routes/index.tsx | 59 +++- src/mainview/routes/platform.$source.$id.tsx | 14 +- src/mainview/routes/settings/emulators.tsx | 3 +- src/mainview/routes/settings/route.tsx | 7 + src/mainview/routes/settings/tasks.tsx | 123 +++++++++ .../store/details.download.$source.$id.tsx | 129 +++++++++ .../routes/store/details.emulator.$id.tsx | 17 +- src/mainview/routes/store/tab/download.tsx | 109 ++++++++ src/mainview/routes/store/tab/emulators.tsx | 14 +- src/mainview/routes/store/tab/games.tsx | 5 +- src/mainview/routes/store/tab/plugins.tsx | 9 +- src/mainview/routes/store/tab/route.tsx | 3 +- src/mainview/scripts/contexts.ts | 12 +- src/mainview/scripts/queries/romm.ts | 36 ++- src/mainview/scripts/types.ts | 4 +- src/mainview/types.d.ts | 1 + src/packages/gameflow-sdk/hooks/app.ts | 37 +++ src/packages/gameflow-sdk/hooks/emulators.ts | 3 + src/packages/gameflow-sdk/hooks/games.ts | 50 +++- src/packages/gameflow-sdk/package.json | 10 +- src/packages/gameflow-sdk/shared.ts | 79 ++++++ src/packages/gameflow-sdk/task-queue.ts | 64 ++++- src/shared/types.schema.ts | 0 src/shared/types.ts | 0 src/shared/utils.ts | 10 +- 70 files changed, 1922 insertions(+), 560 deletions(-) create mode 100644 src/bun/api/jobs/test-download-job.ts create mode 100644 src/mainview/components/GlobalContextDialog.tsx create mode 100644 src/mainview/routes/settings/tasks.tsx create mode 100644 src/mainview/routes/store/details.download.$source.$id.tsx create mode 100644 src/mainview/routes/store/tab/download.tsx delete mode 100644 src/shared/types.schema.ts delete mode 100644 src/shared/types.ts diff --git a/bun.lock b/bun.lock index c4a2b79..5fbc781 100644 --- a/bun.lock +++ b/bun.lock @@ -25,13 +25,13 @@ "node-downloader-helper": "^2.1.11", "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", - "npm-check-updates": "^22.1.1", + "npm-check-updates": "^22.2.0", "open": "^11.0.0", "p-queue": "^9.2.0", "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", - "systeminformation": "^5.31.5", + "systeminformation": "^5.31.6", "tapable": "^2.3.3", "tough-cookie": "^6.0.1", "tough-cookie-file-store": "^3.3.0", @@ -46,10 +46,11 @@ "@hey-api/openapi-ts": "^0.91.1", "@noriginmedia/norigin-spatial-navigation": "^3.1.0", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.2.4", - "@tanstack/react-form": "^1.29.1", - "@tanstack/react-query": "^5.100.9", - "@tanstack/react-query-devtools": "^5.100.9", + "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-form": "^1.32.0", + "@tanstack/react-query": "^5.100.10", + "@tanstack/react-query-devtools": "^5.100.10", + "@tanstack/react-query-persist-client": "^5.100.10", "@tanstack/react-router": "^1.169.2", "@tanstack/react-router-devtools": "^1.166.13", "@tanstack/react-router-ssr-query": "^1.166.12", @@ -82,6 +83,7 @@ "drizzle-kit": "^0.31.10", "eden-tanstack-query": "^0.0.9", "howler": "^2.2.4", + "idb-keyval": "^6.2.2", "lucide-react": "^0.563.0", "pretty-bytes": "^7.1.0", "pretty-ms": "^9.3.0", @@ -92,30 +94,26 @@ "react-markdown": "^10.1.0", "react-qr-code": "^2.0.21", "sass-embedded": "^1.99.0", - "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.4", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.0", "tailwindcss-animate": "^1.0.7", "typescript": "^5.9.3", "usehooks-ts": "^3.1.1", "vite": "^7.3.3", - "vite-plugin-svg-icons-ng": "^1.9.0", + "vite-plugin-svg-icons-ng": "^1.9.1", "vite-static-assets-plugin": "^1.2.2", "vite-tsconfig-paths": "^6.1.1", }, }, "src/packages/gameflow-sdk": { "name": "@simeonradivoev/gameflow-sdk", - "version": "1.5.3", + "version": "1.6.0", "bin": { "gameflow-build": "build.ts", }, "peerDependencies": { "7zip-bin": "^5.2.0", "@auth/core": "^0.34.3", - "@elysiajs/cors": "^1.4.2", - "@elysiajs/eden": "^1.4.9", - "@jimp/wasm-webp": "^1.6.1", - "@phalcode/ts-igdb-client": "^1.0.26", "cheerio": "^1.2.0", "conf": "^15.1.0", "drizzle-orm": "^0.45.2", @@ -135,12 +133,8 @@ "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", - "systeminformation": "^5.31.5", "tapable": "^2.3.3", - "tough-cookie": "^6.0.1", - "tough-cookie-file-store": "^3.3.0", "unzip-stream": "^0.3.4", - "webview-bun": "^2.4.0", "zod": "^4.4.3", }, }, @@ -566,55 +560,59 @@ "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], - "@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="], "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], - "@tailwindcss/vite": ["@tailwindcss/vite@4.2.4", "", { "dependencies": { "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="], "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], - "@tanstack/form-core": ["@tanstack/form-core@1.29.1", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.9.1" } }, "sha512-NIYPO36eEu7nSWvMpbFDQaBWyVtnH/C8fsZ3/XpJUT4uOWgmxsiUvHGbTbDNIQTXAKIkhwEl0sUrqBNn2SfUnw=="], + "@tanstack/form-core": ["@tanstack/form-core@1.32.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.9.1" } }, "sha512-Tn5VRDSjyqjmaet2tJMuEWDRFyrCaon03vxXPlSSaiSs6C/N7lCIwGCXJbZXEUq1kTj8jYN9qyXHbsz4LQHcow=="], "@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], - "@tanstack/query-core": ["@tanstack/query-core@5.100.9", "", {}, "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ=="], + "@tanstack/query-core": ["@tanstack/query-core@5.100.10", "", {}, "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w=="], - "@tanstack/query-devtools": ["@tanstack/query-devtools@5.100.9", "", {}, "sha512-gqiptrTIhbK2PuCaPRHmWXfJG1NGYVFpAr0HqogEqiSBNB5xDz6fmesQt7w4WgMOqOQPnPHJ3ZDMuhDaXvNO8g=="], + "@tanstack/query-devtools": ["@tanstack/query-devtools@5.100.10", "", {}, "sha512-3DmJf25hDPus5IpVvp6ujXv6bKV2zPzI9vpbAmpJigsL/H6DPvPjmf7/Q9yVKEke//8fgeQ45abjgnLuyYxAiw=="], - "@tanstack/react-form": ["@tanstack/react-form@1.29.1", "", { "dependencies": { "@tanstack/form-core": "1.29.1", "@tanstack/react-store": "^0.9.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-hVHk4g0phd0HxRsv2ry6Xt8BqmalT55Q3cokhJBCC1St0hcGZhgwJJbohm9atao45BPG9e55DGvtbwExqZe35g=="], + "@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.100.10", "", { "dependencies": { "@tanstack/query-core": "5.100.10" } }, "sha512-O9Pey40DhTTDBABS0bHr+KNL5/VMf6PrqjexS8WoDDtnkaoWM+y0MSe0V9E5W+BwvkjM33mB3aYcCxa175gZTQ=="], - "@tanstack/react-query": ["@tanstack/react-query@5.100.9", "", { "dependencies": { "@tanstack/query-core": "5.100.9" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A=="], + "@tanstack/react-form": ["@tanstack/react-form@1.32.0", "", { "dependencies": { "@tanstack/form-core": "1.32.0", "@tanstack/react-store": "^0.9.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6WP5SQTA6/H9crCpvpq3ZppYWqtrdE5NjOy6ebABi6uAQPqhfTzrdjS9t40mCZCFtGI5585OhJV6zBP/KN2zcw=="], - "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.100.9", "", { "dependencies": { "@tanstack/query-devtools": "5.100.9" }, "peerDependencies": { "@tanstack/react-query": "^5.100.9", "react": "^18 || ^19" } }, "sha512-mM3slaVGXJmz+pOLgXdANj75ikgQCyudyl3kmFvm6brI1JyVeY/+IeD17uDHIvZrD8hfoO2sdZ54RFsHdYAuhA=="], + "@tanstack/react-query": ["@tanstack/react-query@5.100.10", "", { "dependencies": { "@tanstack/query-core": "5.100.10" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q=="], + + "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.100.10", "", { "dependencies": { "@tanstack/query-devtools": "5.100.10" }, "peerDependencies": { "@tanstack/react-query": "^5.100.10", "react": "^18 || ^19" } }, "sha512-zes0+o9ef5rAZXJ9f/SeaLs2nufJaeVkZkl/Or9NGrWVF41kL9Od9ED9nCwtQlgiF2VGtrzhEw5AU/igAO+aAg=="], + + "@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.100.10", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.100.10" }, "peerDependencies": { "@tanstack/react-query": "^5.100.10", "react": "^18 || ^19" } }, "sha512-EImacngLXYEtzlrIPf8IAqKN3foS7cmSj4GWqsHJvc7K+8fy2c3s7mdV8oTJeii/TvrzO4X9fcnXi6tUHMIOHA=="], "@tanstack/react-router": ["@tanstack/react-router@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.169.2", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ=="], @@ -658,7 +656,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], @@ -790,7 +788,7 @@ "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], - "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -1158,6 +1156,8 @@ "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], @@ -1436,7 +1436,7 @@ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], - "npm-check-updates": ["npm-check-updates@22.1.1", "", { "bin": { "npm-check-updates": "build/cli.js", "ncu": "build/cli.js" } }, "sha512-uWSxJW25dy5ZM4SdLsi0VBgPSJlo7u+jARQ6Xql+85YYCoqXU2ZaympAZ6237/oybCq/I4nXddE9S9BTwBfBXA=="], + "npm-check-updates": ["npm-check-updates@22.2.0", "", { "bin": { "npm-check-updates": "build/cli.js", "ncu": "build/cli.js" } }, "sha512-kaxgbkGkCOtoSrsUXShgcEiEfrRPqmOGk6Yeya+5hoNptblu9vuE8/PLABUSJz+IeNgKJBFxcC3UrBYmKsB8iA=="], "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], @@ -1752,13 +1752,13 @@ "sync-message-port": ["sync-message-port@1.2.0", "", {}, "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg=="], - "systeminformation": ["systeminformation@5.31.5", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-5SyLdip4/3alxD4Kh+63bUQTJmu7YMfYQTC+koZy7X73HgNqZSD2P4wOZQWtUncvPvcEmnfIjCoygN4MRoEejQ=="], + "systeminformation": ["systeminformation@5.31.6", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], - "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], - "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="], + "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], @@ -1866,7 +1866,7 @@ "vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], - "vite-plugin-svg-icons-ng": ["vite-plugin-svg-icons-ng@1.9.0", "", { "dependencies": { "svg-icon-baker": "2.0.1", "tinyglobby": "^0.2.16" }, "peerDependencies": { "vite": ">=5.0.0" } }, "sha512-vIyinFqjR5gEJiDt1MTFGewAJnwyB7tkZ9fjKQ9m9Wa7XmxTTAcj8h1l3C4zA02K6y/4ZuPYCLzHLovoUPDW6w=="], + "vite-plugin-svg-icons-ng": ["vite-plugin-svg-icons-ng@1.9.1", "", { "dependencies": { "svg-icon-baker": "2.0.1", "tinyglobby": "^0.2.16" }, "peerDependencies": { "vite": ">=5.0.0" } }, "sha512-g00nlit2havo0VRxpLiPkeJfMYt0DL/RO8X5HHop72rbMEZB5H1Fk7qXLWbTIO2/PkwJ8zSq0+h28ItaE1YQHQ=="], "vite-static-assets-plugin": ["vite-static-assets-plugin@1.2.2", "", { "dependencies": { "chalk": "^5.4.1", "chokidar": "^3.5.3", "minimatch": "^10.0.1" }, "peerDependencies": { "typescript": "^5.0.0", "vite": "^6.2.0" } }, "sha512-0mzHsxFa46Np5AixQcdWYLVH6eJxeok7qL7tXmxYavg/Uo0e5z+J6gavJ0TJ6dmJSe2Z+gwmDb64bCCZfg+gqA=="], @@ -1998,13 +1998,15 @@ "@node-minify/core/mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + "@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], diff --git a/package.json b/package.json index 99a3e52..d770d67 100644 --- a/package.json +++ b/package.json @@ -76,13 +76,13 @@ "node-downloader-helper": "^2.1.11", "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", - "npm-check-updates": "^22.1.1", + "npm-check-updates": "^22.2.0", "open": "^11.0.0", "p-queue": "^9.2.0", "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", - "systeminformation": "^5.31.5", + "systeminformation": "^5.31.6", "tapable": "^2.3.3", "tough-cookie": "^6.0.1", "tough-cookie-file-store": "^3.3.0", @@ -97,10 +97,11 @@ "@hey-api/openapi-ts": "^0.91.1", "@noriginmedia/norigin-spatial-navigation": "^3.1.0", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.2.4", - "@tanstack/react-form": "^1.29.1", - "@tanstack/react-query": "^5.100.9", - "@tanstack/react-query-devtools": "^5.100.9", + "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-form": "^1.32.0", + "@tanstack/react-query": "^5.100.10", + "@tanstack/react-query-devtools": "^5.100.10", + "@tanstack/react-query-persist-client": "^5.100.10", "@tanstack/react-router": "^1.169.2", "@tanstack/react-router-devtools": "^1.166.13", "@tanstack/react-router-ssr-query": "^1.166.12", @@ -133,6 +134,7 @@ "drizzle-kit": "^0.31.10", "eden-tanstack-query": "^0.0.9", "howler": "^2.2.4", + "idb-keyval": "^6.2.2", "lucide-react": "^0.563.0", "pretty-bytes": "^7.1.0", "pretty-ms": "^9.3.0", @@ -143,13 +145,13 @@ "react-markdown": "^10.1.0", "react-qr-code": "^2.0.21", "sass-embedded": "^1.99.0", - "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.4", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.0", "tailwindcss-animate": "^1.0.7", "typescript": "^5.9.3", "usehooks-ts": "^3.1.1", "vite": "^7.3.3", - "vite-plugin-svg-icons-ng": "^1.9.0", + "vite-plugin-svg-icons-ng": "^1.9.1", "vite-static-assets-plugin": "^1.2.2", "vite-tsconfig-paths": "^6.1.1" } diff --git a/scripts/dev.ts b/scripts/dev.ts index ffc843c..1331f36 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -85,6 +85,13 @@ watch("./src/bun", { recursive: true }, (event, filename) => restart(); }); +watch("./src/packages", { recursive: true }, (event, filename) => +{ + if (restarting) return; + console.log(`[watcher] ${event}: ${filename} — restarting...`); + restart(); +}); + let server: Bun.Subprocess | undefined = spawnServer(); if (!process.env.HEADLESS) { diff --git a/src/bun/api/controls/windows.ts b/src/bun/api/controls/windows.ts index 40fc7d9..2621c26 100644 --- a/src/bun/api/controls/windows.ts +++ b/src/bun/api/controls/windows.ts @@ -72,7 +72,6 @@ export class GamepadWindows implements IGamepadBackend private index: number; private buffer = new ArrayBuffer(16); private view = new DataView(this.buffer); - private prevButtons = 0; private currButtons = 0; constructor(index = 0) { this.index = index; } diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 3c2575c..f06f71f 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -5,7 +5,7 @@ import z from "zod"; import * as schema from "@schema/app"; import fs from "node:fs/promises"; import { SERVER_URL } from "@shared/constants"; -import { GameListFilterSchema } from '@simeonradivoev/gameflow-sdk/shared'; +import { CommandEntry, DownloadLookupEntry, DownloadsLookupFilterValues, GameListFilterSchema } from '@simeonradivoev/gameflow-sdk/shared'; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; @@ -512,7 +512,25 @@ export default new Elysia() await plugins.hooks.games.gameLookup.promise(matches, { source, id }); return Array.from(matches.values()).flatMap(m => m); }) - .post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) => + .get('/game/:source/:id/commands', async ({ params: { id, source }, set }) => + { + const validCommands = await getValidLaunchCommandsForGame(source, id); + if (validCommands instanceof Error) + { + return errorToResponse(validCommands, set); + } + return validCommands as { + commands: CommandEntry[]; + gameId: FrontEndId; + source?: string; + sourceId?: string; + } | undefined; + }, { + response: z.object({ + commands: z.custom().array() + }) + }) + .post('/game/:source/:id/play', async ({ params: { id, source }, body: { command_id }, set }) => { const validCommands = await getValidLaunchCommandsForGame(source, id); if (validCommands) @@ -525,7 +543,7 @@ export default new Elysia() { try { - const validCommand = body.command_id ? validCommands.commands.find(c => c.id === body.command_id) : validCommands.commands[0]; + const validCommand = command_id ? validCommands.commands.find(c => c.id === command_id) : validCommands.commands[0]; if (validCommand) { // launch command waits for the game to exit, we don't want that. @@ -676,7 +694,10 @@ export default new Elysia() .post('/add/custom', async ({ body: { source, id, platformId, gamePath } }) => { if (taskQueue.hasActiveOfType(ImportJob)) return status("Conflict", "Import Job Already Running"); - const data = await taskQueue.enqueue(ImportJob.id, new ImportJob(source, id, gamePath, platformId), true); + const data = await taskQueue.enqueue(ImportJob.query({ source, id }), new ImportJob(source, id, gamePath, platformId), { + throwOnCancel: true + + }); return { source: 'local', id: data.localId }; }, { body: z.object({ @@ -685,4 +706,41 @@ export default new Elysia() gamePath: z.string(), platformId: z.number() }) + }).get('/downloads/lookup', async ({ query: { search, page, rows, orderBy, sortDirection, source } }) => + { + const matches = new Map(); + 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; }); \ No newline at end of file diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index aaac97b..9bef2f4 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -8,7 +8,7 @@ import { RPC_URL } from "@shared/constants"; import { hashFile } from "@/bun/utils"; import { host } from "@/bun/utils/host"; import * as emulatorSchema from "@schema/emulators"; -import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata } from "@simeonradivoev/gameflow-sdk/shared"; +import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared"; export async function calculateSize (installPath: string | null) { @@ -467,4 +467,40 @@ export async function createLocalGame (info: { }); return id; +} + +export async function downloadGame (ctx: { + downloads: DownloadFileEntry[], + auth?: string, + id: string, + abortSignal?: AbortSignal, + setProgress?: (progress: number, state: "download" | "extract", info: Partial>) => void, + extract_path?: string; + path_fs?: string; + +}): Promise +{ + 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; } \ No newline at end of file diff --git a/src/bun/api/jobs/bios-download-job.ts b/src/bun/api/jobs/bios-download-job.ts index 64537c1..7a4edba 100644 --- a/src/bun/api/jobs/bios-download-job.ts +++ b/src/bun/api/jobs/bios-download-job.ts @@ -1,35 +1,44 @@ -import z from "zod"; -import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; import { config, plugins } from "../app"; import { simulateProgress } from "@/bun/utils"; import { Downloader } from "@/bun/utils/downloader"; import path from 'node:path'; import { ensureDir } from "fs-extra"; import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService"; +import { DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared"; -export class BiosDownloadJob implements IJob, "download"> +interface BiosDownloadJobData extends DownloadJobData +{ + emulator: string; +} + +export class BiosDownloadJob implements IJob { static id = "bios-download-job" as const; - static dataSchema = z.object({ emulator: z.string() }); static query = (q: { id: string; }) => `${BiosDownloadJob.id}-${q.id}`; group: string = "bios-download"; - emulator: string; + data: BiosDownloadJobData; dryRun: boolean; constructor(emulator: string, init?: { dryRun?: boolean; }) { - this.emulator = emulator; + this.data = { + emulator, + name: "Download Emulator Bios" + }; this.dryRun = init?.dryRun ?? false; } - async start (context: JobContext, "download">, z.infer, "download">) + async start (context: JobContext, BiosDownloadJobData, "download">) { - const emulator = await getStoreEmulatorPackage(this.emulator); + const emulator = await getStoreEmulatorPackage(this.data.emulator); if (!emulator) throw new Error("Could Not Find Emulator"); + this.data.name = `${emulator.name} Bios`; + this.data.preview_url = emulator.logo; const systems = await buildStoreFrontendEmulatorSystems(emulator); - const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); + const biosFolder = path.join(config.get('downloadPath'), "bios", this.data.emulator); await ensureDir(biosFolder); - const files = await plugins.hooks.emulators.fetchBiosDownload.promise({ emulator: this.emulator, systems, biosFolder }); + const files = await plugins.hooks.emulators.fetchBiosDownload.promise({ emulator: this.data.emulator, systems, biosFolder }); if (!files) throw new Error("Could not find source to download from"); @@ -45,9 +54,12 @@ export class BiosDownloadJob implements IJob { context.setProgress(stats.progress, "download"); + this.data.downloaded = stats.downloaded; + this.data.speed = stats.speed; + this.data.total = stats.total; }, }); @@ -57,6 +69,6 @@ export class BiosDownloadJob implements IJob, EmulatorDownloadStates> +interface EmulatorDownloadJobData extends DownloadJobData +{ + emulator: string; +} + +export class EmulatorDownloadJob implements IJob { static id = "download-emulator" as const; - static dataSchema = z.object({ emulator: z.string() }); - emulator: string; downloadSource: string; emulatorPackage?: EmulatorPackageType; dryRun: boolean; isUpdate: boolean; + data: EmulatorDownloadJobData = { + name: "Download Emulator", + emulator: "" + }; constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; isUpdate?: boolean; }) { - this.emulator = emulator; + this.data.emulator = emulator; this.downloadSource = downloadSource; this.dryRun = init?.dryRun ?? false; this.isUpdate = init?.isUpdate ?? false; } - async start (context: JobContext, EmulatorDownloadStates>) + async start (context: JobContext) { - this.emulatorPackage = await getStoreEmulatorPackage(this.emulator); + this.emulatorPackage = await getStoreEmulatorPackage(this.data.emulator); if (!this.emulatorPackage) throw new Error("Emulator not found"); + this.data.name = this.emulatorPackage.name; + this.data.preview_url = this.emulatorPackage.logo; const { url, info } = await getEmulatorDownload(this.emulatorPackage, this.downloadSource); - const emulatorsFolder = getEmulatorPath(this.emulator); + const emulatorsFolder = getEmulatorPath(this.data.emulator); if (this.dryRun) { @@ -49,29 +57,33 @@ export class EmulatorDownloadJob implements IJob { context.setProgress(stats.progress, 'download'); + this.data.total = stats.total; + this.data.downloaded = stats.downloaded; + this.data.speed = stats.speed; }, }); const destinationPaths = await downloader.start(); + context.abortSignal.throwIfAborted(); if (destinationPaths) { - const isArchive = destinationPaths[0].endsWith('.7z') || destinationPaths[0].endsWith('.zip') || destinationPaths[0].endsWith('.tar'); + const archive = isArchive(destinationPaths[0]); const isAppImage = destinationPaths[0].endsWith(".AppImage"); - if (!isArchive && !isAppImage) + if (!archive && !isAppImage) { throw new Error("Invalid Download Type"); } - if (isArchive) + if (archive) { if (destinationPaths[0]) { @@ -120,10 +132,10 @@ export class EmulatorDownloadJob implements IJob e.type === 'store')?.binPath ?? emulatorsFolder, info, @@ -136,7 +148,7 @@ export class EmulatorDownloadJob implements IJob, string> +interface ImportJobData extends DownloadJobData +{ + localId: number | null; +} + +export class ImportJob implements IJob { static id = "import-job" as const; - static dataSchema = z.object({ localId: z.number().nullable() }); + static query = (q: { source: string; id: string; }) => `${ImportJob.id}-${q.source}-${q.id}`; + data: ImportJobData = { + localId: null, + name: "Import Game" + }; group?: 'import-job'; gamePath: string; source: string; id: string; platformId: number; - localId: number | null = null; constructor(source: string, id: string, gamePath: string, platformId: number) { @@ -25,18 +36,20 @@ export class ImportJob implements IJob, str this.platformId = platformId; } - exposeData (): z.infer + exposeData () { - return { localId: this.localId }; + return this.data; } - async start (context: JobContext, string>, z.infer, string>): Promise + async start (context: JobContext, ImportJobData, string>): Promise { const matchesMap = new Map(); await plugins.hooks.games.gameLookup.promise(matchesMap, { source: this.source, id: this.id }); const matches = matchesMap.values().next().value; if (!matches || matches.length <= 0) throw Error("Could not Find Game"); const match = matches[0]; + this.data.name = match.name; + this.data.preview_url = match.coverUrl; let cover: Buffer | undefined = undefined; let coverType: string | undefined = undefined; @@ -50,24 +63,56 @@ export class ImportJob implements IJob, str } } + const platformMatch = match.platforms.find(p => p.id === this.platformId); + + const finalFiles: string[] = []; + + if (isUrl(this.gamePath)) + { + const archive = isArchive(this.gamePath); + const downloadedFiles = await downloadGame({ + downloads: [{ + file_path: this.id, + file_name: basename(this.gamePath), + url: new URL(this.gamePath) + }], + extract_path: archive ? '.tmp' : undefined, + path_fs: path.join('roms', platformMatch?.slug ?? this.source, this.id), + abortSignal: context.abortSignal, + id: `game-${this.source}-${this.id}`, + setProgress: (progress, state, info) => + { + context.setProgress(progress, state); + this.data.speed = info.speed; + this.data.total = info.total; + this.data.downloaded = info.downloaded; + }, + }); + + if (downloadedFiles) + finalFiles.push(...downloadedFiles); + } else + { + finalFiles.push(this.gamePath); + } + const localSearchFilters: any[] = []; if (match.igdb_id) localSearchFilters.push(eq(schema.games.igdb_id, match.igdb_id)); if (match.slug) localSearchFilters.push(eq(schema.games.slug, match.slug)); localSearchFilters.push(eq(schema.games.name, match.name)); - localSearchFilters.push(eq(schema.games.path_fs, this.gamePath)); + localSearchFilters.push(inArray(schema.games.path_fs, finalFiles)); const existingLocalGame = await db.query.games.findFirst({ where: or(...localSearchFilters) }); + context.abortSignal.throwIfAborted(); if (existingLocalGame) throw new Error("Game Already Exists"); - const platformMatch = match.platforms.find(p => p.id === this.platformId); - - this.localId = await createLocalGame({ + this.data.localId = await createLocalGame({ name: match.name, system_slug: platformMatch?.slug, source: undefined, source_id: undefined, slug: match.slug, - path_fs: this.gamePath, + path_fs: finalFiles[0], summary: match.summary, igdb_id: match.igdb_id, ra_id: undefined, diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index a9433d4..3d3c867 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -1,17 +1,12 @@ -import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; import fs from 'node:fs/promises'; import path from 'node:path'; import { config, events, plugins } from "../app"; import { simulateProgress } from "@/bun/utils"; -import { Downloader } from "@/bun/utils/downloader"; -import Seven from 'node-7z'; import z from "zod"; -import { checkFiles, createLocalGame } from "../games/services/utils"; -import { ensureDir, move } from "fs-extra"; -import { path7za } from "7zip-bin"; -import StreamZip from 'node-stream-zip'; -import { which } from "bun"; -import { DownloadInfo } from "@simeonradivoev/gameflow-sdk/shared"; +import { checkFiles, createLocalGame, downloadGame } from "../games/services/utils"; +import { ensureDir } from "fs-extra"; +import { DownloadInfo, DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared"; interface JobConfig { @@ -22,7 +17,7 @@ interface JobConfig export type InstallJobStates = 'download' | 'extract'; -export class InstallJob implements IJob +export class InstallJob implements IJob { static id = "install-job" as const; static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`; @@ -34,6 +29,9 @@ export class InstallJob implements IJob public localGameId?: number; public group = InstallJob.id; public localPath?: string; + data: DownloadJobData = { + name: "Install Game" + }; constructor(id: string, source: string, config?: JobConfig) { @@ -42,7 +40,7 @@ export class InstallJob implements IJob this.source = source; } - public async start (cx: JobContext) + public async start (cx: JobContext) { cx.setProgress(0, 'download'); await fs.mkdir(config.get('downloadPath'), { recursive: true }); @@ -58,131 +56,31 @@ export class InstallJob implements IJob if (!info) throw new Error(`Could not find downloader for source ${this.source}`); - const files = await checkFiles(info.files, !!info.extract_path); + this.data.name = info.name; + this.data.preview_url = info.coverUrl; + const files = await checkFiles(info.files, !!info.extract_path); if (this.config?.dryDownload !== true && files.some(f => !f.exists || !f.matches)) { - const headers: Record = {}; - if (info.auth) - headers['Authorization'] = info.auth; - const downloader = new Downloader(`game-${this.source}-${this.gameId}`, - files.filter(f => !f.exists || !f.matches), - config.get('downloadPath'), + const downloadedFiles = await downloadGame({ + downloads: files.filter(f => !f.exists || !f.matches), + extract_path: info.extract_path, + path_fs: info.path_fs, + abortSignal: cx.abortSignal, + auth: info.auth, + id: `game-${this.source}-${this.gameId}`, + setProgress: (process, state, info) => { - signal: cx.abortSignal, - headers, - onProgress (stats) - { - cx.setProgress(stats.progress, 'download'); - }, - }); + cx.setProgress(process, state); + this.data.downloaded = info.downloaded; + this.data.speed = info.speed; + this.data.total = info.total; + }, + }); - const downloadedFiles = await downloader.start(); - if (!downloadedFiles) - { - return; - } - - if (info.extract_path && downloadedFiles) - { - let progress = 0; - const progressDelta = 1 / downloadedFiles.length; - const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path); - - for (const filePath of downloadedFiles) - { - await new Promise(async (resolve, reject) => - { - let sevenZipPath = process.env.ZIP7_PATH ?? path7za; - - if (filePath.endsWith('.rar')) - { - let newPath: string | undefined; - if (process.platform === 'win32' && await fs.exists("C:\\Program Files\\7-Zip\\7z.exe")) - { - newPath = "C:\\Program Files\\7-Zip\\7z.exe"; - } else - { - newPath = which('7z') ?? undefined; - } - - if (!newPath) - { - await fs.rm(filePath); - reject(new Error("No RAR Support")); - return; - } - - sevenZipPath = newPath; - } - - let rejected = false; - const seven = Seven.extractFull(filePath, extractPath, { $bin: sevenZipPath, $progress: true }); - seven.on('progress', p => - { - cx.setProgress(progress + p.percent * progressDelta, "extract"); - }); - seven.on('error', e => - { - reject(e); - rejected = true; - }); - seven.on('end', async () => - { - if (rejected) return; - await fs.rm(filePath); - resolve(true); - }); - }).catch(async e => - { - if (filePath.endsWith('.zip')) - { - cx.setProgress(0, "extract"); - console.error(e); - console.warn("Could not extract", filePath, "with 7zip trying zip extractor"); - await ensureDir(extractPath); - const zip = new StreamZip.async({ file: filePath }); - let entryCount = await zip.entriesCount; - let entryCounter = entryCount; - zip.on('extract', (entry, outPath) => - { - entryCounter--; - cx.setProgress(progress + (1 - (entryCounter / entryCount)) * 100 * progressDelta, "extract"); - }); - const count = await zip.extract(null, extractPath); - console.log(`Extracted ${count} entries`); - await zip.close(); - await fs.rm(filePath); - } else - { - throw e; - } - }); - - progress += progressDelta * 100; - } - - // check if 1 root folder we need to get rid of - const contents = await fs.readdir(extractPath); - if (contents.length === 1) - { - const stat = await fs.stat(path.join(extractPath, contents[0])); - if (stat.isDirectory()) - { - console.log("Found 1 root folder, using that instead"); - const tmpGameFolder = `${extractPath} (1)`; - await move(path.join(extractPath, contents[0]), tmpGameFolder, { overwrite: true }); - await move(tmpGameFolder, extractPath, { overwrite: true }); - } - } - - finalFiles.push(extractPath); - - } else - { + if (downloadedFiles) finalFiles.push(...downloadedFiles); - } } if (this.config?.dryDownload === true && info.extract_path) @@ -193,7 +91,7 @@ export class InstallJob implements IJob const coverResponse = await fetch(info.coverUrl); const cover = Buffer.from(await coverResponse.arrayBuffer()); - if (cx.abortSignal.aborted) return; + cx.abortSignal.throwIfAborted(); this.localGameId = await createLocalGame({ cover, diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index f75605c..e7e20a2 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -6,19 +6,21 @@ import TwitchLoginJob from "./twitch-login-job"; import UpdateStoreJob from "./update-store"; import { EmulatorDownloadJob } from "./emulator-download-job"; import { getErrorMessage } from "@/bun/utils"; -import { IJob } from "../../../packages/gameflow-sdk/task-queue"; +import { BaseEvent, IJob } from "@simeonradivoev/gameflow-sdk/task-queue"; import { LaunchGameJob } from "./launch-game-job"; import { BiosDownloadJob } from "./bios-download-job"; import { InstallJob } from "./install-job"; import ReloadPluginsJob from "./reload-plugins-job"; +import { FrontEndJob } from "@simeonradivoev/gameflow-sdk/shared"; function registerJob< const Path extends string, - const Schema extends z.ZodTypeAny, - const Query extends z.ZodTypeAny, + Schema, const States extends string, - T extends IJob, States> -> (_job: { id: Path; dataSchema: Schema; query?: (q: any) => string; } & (new (...args: any[]) => T)) +> (_job: { + id: Path; + query?: (q: any) => string; +} & (new (...args: any[]) => IJob)) { return new Elysia().ws(_job.id, { body: z.discriminatedUnion('type', [ @@ -30,9 +32,9 @@ function registerJob< type: z.literal(['data', 'started', 'progress']), state: z.string().optional(), progress: z.number(), - data: _job.dataSchema + data: z.custom() }), - z.object({ type: z.literal(['completed', 'ended']), data: _job.dataSchema }), + z.object({ type: z.literal(['completed', 'ended']), data: z.custom() }), z.object({ type: z.literal('waiting') }), z.object({ type: z.literal('error'), error: z.string() }) ]), @@ -42,7 +44,7 @@ function registerJob< const job = taskQueue.findJob(jobId, _job); if (job) { - ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() }); + ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() as Schema }); } else { ws.send({ type: 'waiting' }); @@ -102,6 +104,83 @@ function registerJob< } export const jobs = new Elysia({ prefix: '/api/jobs' }) + .ws('/list', { + response: z.discriminatedUnion('type', [ + z.object({ type: z.literal("allJobs"), active: z.custom().array(), queued: z.custom().array() }), + z.object({ type: z.literal("started"), job: z.custom() }), + z.object({ type: z.literal("progress"), job: z.custom() }), + z.object({ type: z.literal("queued"), job: z.custom() }), + z.object({ type: z.literal("aborted"), id: z.string() }), + z.object({ type: z.literal("ended"), id: z.string() }), + ]), + body: z.discriminatedUnion('type', [ + z.object({ type: z.literal("cancel"), id: z.string() }) + ]), + message (ws, message) + { + switch (message.type) + { + case "cancel": + taskQueue.cancelJob(message.id); + break; + } + }, + open (ws) + { + ws.send({ + type: 'allJobs', + active: taskQueue.getActiveJobs().map(j => + { + const job: FrontEndJob = { + id: j.id, + data: j.job.exposeData?.(), + progress: j.progress, + state: j.state, + status: j.status + }; + + return job; + }), + queued: taskQueue.getQueuedJobs()?.map(j => + { + const job: FrontEndJob = { + id: j.id, + data: j.job.exposeData?.(), + progress: j.progress, + state: j.state, + status: j.status + }; + + return job; + }) ?? [] + }); + + (ws.data as any).dispose = [taskQueue.on('started', (e: BaseEvent) => + { + ws.send({ type: "started", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } }); + }), + taskQueue.on('progress', (e: BaseEvent) => + { + ws.send({ type: "progress", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } }); + }), + taskQueue.on('queued', (e: BaseEvent) => + { + ws.send({ type: "queued", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } }); + }), + taskQueue.on('abort', (e: BaseEvent) => + { + ws.send({ type: "aborted", id: e.id }); + }), + taskQueue.on('ended', (e: BaseEvent) => + { + ws.send({ type: "ended", id: e.id }); + })]; + }, + close (ws, code, reason) + { + (ws.data as any).dispose.forEach((d: any) => d()); + }, + }) .use(registerJob(LaunchGameJob)) .use(registerJob(LoginJob)) .use(registerJob(TwitchLoginJob)) diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index f5072e9..3ce0e83 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -1,5 +1,5 @@ import z from "zod"; -import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; import { ActiveGameSchema, ActiveGameType } from "@simeonradivoev/gameflow-sdk"; import { config, db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; diff --git a/src/bun/api/jobs/login-job.ts b/src/bun/api/jobs/login-job.ts index dd112ad..fb5d69a 100644 --- a/src/bun/api/jobs/login-job.ts +++ b/src/bun/api/jobs/login-job.ts @@ -1,5 +1,5 @@ import Elysia, { status } from "elysia"; -import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; import { LOGIN_PORT, SERVER_URL } from "@/shared/constants"; import { host, localIp } from "@/bun/utils/host"; import cors from "@elysiajs/cors"; diff --git a/src/bun/api/jobs/test-download-job.ts b/src/bun/api/jobs/test-download-job.ts new file mode 100644 index 0000000..313b00b --- /dev/null +++ b/src/bun/api/jobs/test-download-job.ts @@ -0,0 +1,30 @@ +import { DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; +import { sleep } from "bun"; + +export class TestDownloadJob implements IJob +{ + data: DownloadJobData = { + speed: 1686, + downloaded: 0, + total: 6615841, + name: "Test Download Job" + }; + + group = "test-download"; + + async start (context: JobContext, DownloadJobData, string>): Promise + { + 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; + } + +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 93c6fbe..2e63269 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -6,7 +6,7 @@ import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiC import { config, events } from "@/bun/api/app"; import path from 'node:path'; import fs from 'node:fs/promises'; -import { hashFile, isSteamDeckGameMode } from "@/bun/utils"; +import { hashFile, isArchive, isSteamDeckGameMode } from "@/bun/utils"; import { CACHE_KEYS, getOrCached } from "@/bun/api/cache"; import secrets from "@/bun/api/secrets"; import { getAuthToken } from "@/clients/romm/core/auth.gen"; @@ -254,8 +254,7 @@ export default class RommIntegration implements PluginType let path_fs = path.join(rom.fs_path, rom.fs_name); if (files.length === 1) { - const name = files[0].file_name.toLocaleLowerCase(); - if (name.endsWith('.zip') || name.endsWith('.7z') || name.endsWith('.rar')) + if (isArchive(files[0].file_name)) { extract_path = '.'; path_fs = path.join(rom.fs_path, rom.slug ?? rom.fs_name_no_ext); diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts index 17c5f12..4935dd7 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts @@ -12,6 +12,7 @@ import mustache from "mustache"; import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; import fs from "node:fs/promises"; import { CommandEntry, EmulatorSourceEntryType, EmulatorSystem, FrontEndEmulator, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, SaveFileChange, EmulatorDownloadInfoType, StoreDownloadType, StoreGameType, EmulatorPackageType, EmulatorDownloadInfoSchema, StoreGameSchema } from "@simeonradivoev/gameflow-sdk/shared"; +import { isUrl } from "@/shared/utils"; export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; }) { @@ -39,7 +40,7 @@ export async function getStoreGame (id: string) function convertStoreMediaToPath (c: string) { - if (c.startsWith('http')) + if (isUrl(c)) { return `/api/romm/image?url=${encodeURIComponent(c)}`; } else diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts index 9b514a2..118eb11 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts @@ -2,7 +2,7 @@ import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-s import desc from './package.json'; import path, { } from 'node:path'; import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; -import { Glob, pathToFileURL } from "bun"; +import { Glob, pathToFileURL, which } from "bun"; import { and, eq } from "drizzle-orm"; import * as emulatorSchema from '@schema/emulators'; @@ -13,6 +13,12 @@ import UpdateStoreJob from "@/bun/api/jobs/update-store"; import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services"; import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@simeonradivoev/gameflow-sdk/shared"; +import { isUrl } from "@/shared/utils"; +import { Downloader } from "@/bun/utils/downloader"; +import { ensureDir, move } from "fs-extra"; +import StreamZip from "node-stream-zip"; +import { path7za } from "7zip-bin"; +import Seven from 'node-7z'; export default class RommIntegration implements PluginType { @@ -295,7 +301,7 @@ export default class RommIntegration implements PluginType const info: DownloadInfo = { id: validDownload.id, - coverUrl: game.covers?.[0] ? game.covers[0].startsWith('http') ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "", + coverUrl: game.covers?.[0] ? isUrl(game.covers[0]) ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "", screenshotUrls: game.screenshots ?? [], files: [{ url: new URL(validDownload.url), @@ -325,5 +331,129 @@ export default class RommIntegration implements PluginType return info; }); }); + + ctx.hooks.downloadFiles.tapPromise(desc.name, async ({ id, files, downloadPath, abortSignal, auth, updateProgress }) => + { + const headers: Record = {}; + 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]; + } + }); } } \ No newline at end of file diff --git a/src/bun/api/settings/settings.ts b/src/bun/api/settings/settings.ts index e4e2da1..ebd5b91 100644 --- a/src/bun/api/settings/settings.ts +++ b/src/bun/api/settings/settings.ts @@ -10,6 +10,8 @@ import { getRelevantEmulators } from "./services"; import type { JSONSchema7 } from "json-schema"; import ReloadPluginsJob from "../jobs/reload-plugins-job"; import { pluginZodRegistry } from "../plugins/plugin-manager"; +import { TestDownloadJob } from "../jobs/test-download-job"; +import { randomUUIDv7 } from "bun"; export const settings = new Elysia({ prefix: '/api/settings' }) .get('/emulators/automatic', async () => @@ -112,6 +114,10 @@ export const settings = new Elysia({ prefix: '/api/settings' }) { return { value: plugins.plugins[decodeURIComponent(source)].config?.get(decodeURIComponent(id)) }; }) + .post('/test/download', async () => + { + taskQueue.enqueue(randomUUIDv7(), new TestDownloadJob()); + }) .put('/:source/:id', async ({ params: { source, id }, body: { value } }) => { const plugin = plugins.plugins[decodeURIComponent(source)]; diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 5408a6d..2124144 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -86,6 +86,7 @@ export const system = new Elysia({ prefix: '/api/system' }) z.object({ type: z.literal('info'), data: SystemInfoSchema }), z.object({ type: z.literal('focus') }), z.object({ type: z.literal('loading'), progress: z.number(), state: z.string().optional() }), + z.object({ type: z.literal('activeTask'), progress: z.number().nullable() }), z.object({ type: z.literal('loaded') }), ]), async open (ws) @@ -94,6 +95,8 @@ export const system = new Elysia({ prefix: '/api/system' }) if (existingLoading) ws.send({ type: 'loading', progress: existingLoading.progress, state: existingLoading.state }); else ws.send({ type: 'loaded' }); + ws.send({ type: 'activeTask', progress: taskQueue.getActiveJobs()[0]?.progress }); + const startInfo = async () => { const battery = await si.battery(); @@ -116,6 +119,8 @@ export const system = new Elysia({ prefix: '/api/system' }) dispose.push(taskQueue.on('progress', e => { + ws.send({ type: 'activeTask', progress: e.progress }); + if (e.id === ReloadPluginsJob.id) { ws.send({ type: "loading", progress: e.progress, state: e.state }); @@ -127,6 +132,8 @@ export const system = new Elysia({ prefix: '/api/system' }) })); dispose.push(taskQueue.on('started', e => { + ws.send({ type: 'activeTask', progress: 0 }); + if (e.id === ReloadPluginsJob.id) ws.send({ type: "loading", progress: e.job.progress, state: e.job.state }); else if (e.id === SelfUpdateJob.id) @@ -134,6 +141,7 @@ export const system = new Elysia({ prefix: '/api/system' }) })); dispose.push(taskQueue.on('ended', e => { + ws.send({ type: 'activeTask', progress: null }); if (e.id !== ReloadPluginsJob.id && e.id !== SelfUpdateJob.id) return; ws.send({ type: "loaded" }); })); diff --git a/src/bun/utils.ts b/src/bun/utils.ts index fe44ad2..a3868e4 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -5,6 +5,8 @@ import { config } from './api/app'; import fs from 'node:fs/promises'; import packageDef from '~/package.json'; +const archiveRegex = /.(zip|rar|7zip|7z|tar|tar.gz)$/i; + export function checkRunning (pid: number) { try @@ -178,4 +180,9 @@ export async function moveAllFiles (srcDir: string, destDir: string) export function getAppVersion () { return process.env.VERSION_OVERRIDE ?? packageDef.version; +} + +export function isArchive (path: string) +{ + return archiveRegex.test(path); } \ No newline at end of file diff --git a/src/bun/utils/downloader.ts b/src/bun/utils/downloader.ts index f0f30ca..920e7c8 100644 --- a/src/bun/utils/downloader.ts +++ b/src/bun/utils/downloader.ts @@ -5,12 +5,7 @@ import fs from 'node:fs/promises'; import { createWriteStream } from "node:fs"; import { config, jar } from "../api/app"; import { moveAllFiles } from "../utils"; -import { DownloadFileEntry } from "@simeonradivoev/gameflow-sdk/shared"; - -export interface ProgressStats -{ - progress: number; -} +import { DownloadFileEntry, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared"; interface TmpDownloadMetadata { @@ -32,6 +27,7 @@ export class Downloader id: string; tmpPath: string; tmpPathMeta: string; + downloadSpeed: number = 0; /** * @@ -163,10 +159,7 @@ export class Downloader }); const totalBytes = totalSize || Number(res.headers.get("content-length")) || 0; - if (totalSize <= 0) - bytesReceived = 0; - else - bytesReceived += start; + bytesReceived += start; const reader = res.body!.getReader(); @@ -181,10 +174,11 @@ export class Downloader if (totalBytes > 0 && this.onProgress) { const percent = (bytesReceived / totalBytes) * 100; - - if (Date.now() - lastUpdate > 100) + const timeDelta = Date.now() - lastUpdate; + if (timeDelta > 100) { - this.onProgress({ progress: percent }); + this.downloadSpeed = this.downloadSpeed * 0.8 + Math.round(value.length / (timeDelta / 1000)) * 0.2; + this.onProgress({ progress: percent, downloaded: bytesReceived, total: totalBytes, speed: this.downloadSpeed }); lastUpdate = Date.now(); } } @@ -194,7 +188,7 @@ export class Downloader if (this.signal.reason === 'cancel') { console.log("Canceling Download and cleaning up files"); - await fs.rm(this.tmpPath, { recursive: true }); + await fs.rm(this.tmpPath, { recursive: true, maxRetries: 3, retryDelay: 3 }); await fs.rm(this.tmpPathMeta); return; } diff --git a/src/mainview/components/AppCommunication.tsx b/src/mainview/components/AppCommunication.tsx index 3dec17a..df727ad 100644 --- a/src/mainview/components/AppCommunication.tsx +++ b/src/mainview/components/AppCommunication.tsx @@ -1,13 +1,14 @@ import { useEffect, useRef, useState } from "react"; -import { SystemInfoContext } from "../scripts/contexts"; +import { AppContext, SystemInfoContext } from "../scripts/contexts"; import { systemApi } from "../scripts/clientApi"; -import { SystemInfoType } from '@simeonradivoev/gameflow-sdk/shared'; +import { AppInfoContext, SystemInfoType } from '@simeonradivoev/gameflow-sdk/shared'; import LoadingScreen from "./LoadingScreen"; import { GamepadKeyboard } from "./GamepadKeyboard"; export default function AppCommunication (data: { children: any; }) { const [systemInfo, setSystemInfo] = useState(); + const [appContext, setAppContext] = useState({} as AppInfoContext); const [loadingInfo, setLoadingInfo] = useState(undefined); const [loading, setLoading] = useState(true); const loadingProgressBarRef = useRef(null); @@ -25,6 +26,9 @@ export default function AppCommunication (data: { children: any; }) case "focus": window.focus(); break; + case "activeTask": + setAppContext(c => ({ ...c, activeTaskProgress: data.progress })); + break; case "loading": setLoadingInfo(data.state); if (loadingProgressBarRef.current) @@ -45,17 +49,19 @@ export default function AppCommunication (data: { children: any; }) }, []); return - {loading ? - -
    -
    - - {loadingInfo} + + {loading ? + +
    +
    + + {loadingInfo} +
    +
    - -
    - - : data.children} - + + : data.children} + + ; } \ No newline at end of file diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index d05ce7b..8511374 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -6,7 +6,7 @@ import import CardElement, { GameCardParams } from "./CardElement"; import { JSX } from "react"; import { twMerge } from "tailwind-merge"; -import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"; import { oneShot } from "../scripts/audio/audio"; export interface GameMetaExtra extends GameMeta @@ -16,7 +16,7 @@ export interface GameMetaExtra extends GameMeta focusKey: string; } -function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusParams & InteractParams) +function LocalCardElement (data: { game: GameMetaExtra, i: number; onQuickAction?: (ctx: InteractParamsArgs) => void; } & FocusParams & InteractParams) { let preview: GameCardParams['preview'] = data.game.preview; if (!preview && data.game.previewUrls) @@ -31,7 +31,28 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara oneShot('click'); }; - useShortcuts(data.game.focusKey, () => [{ label: "Details", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]); + const handleAltAction = (ctx: InteractParamsArgs) => + { + data.game.onQuickAction?.(); + data.onQuickAction?.({ event, focusKey: data.game.focusKey }); + oneShot('click'); + }; + + useShortcuts(data.game.focusKey, () => + { + const options: Shortcut[] = [{ + label: "Details", + button: GamePadButtonCode.A, + action: event => handleAction({ event, focusKey: data.game.focusKey }) + }]; + + if (data.onQuickAction || data.game.onQuickAction) + { + options.push({ label: "Play", button: GamePadButtonCode.X, action: event => handleAltAction({ event, focusKey: data.game.focusKey }) }); + } + + return options; + }, [data.onQuickAction, data.game.onQuickAction, data.game.focusKey]); return ( {data.games.map((g, i) => data.onSelectGame?.(g.id)} i={i} />)} + key={g.id} + onFocus={data.onFocus} + game={g} + onAction={() => data.onSelectGame?.(g.id)} + i={i} + />)} {data.finalElement} diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index 353f429..54babea 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -64,7 +64,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class className={ twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}> -
    @@ -166,7 +166,7 @@ export function ContextDialog (data: { }] : [], [data.open]); return @@ -174,7 +174,7 @@ export function ContextDialog (data: {
    -
      +
        {!!data.rootFocusKey && (data.showShortcuts ?? true) &&
      • } diff --git a/src/mainview/components/GameList.tsx b/src/mainview/components/GameList.tsx index 80c4944..1075a9f 100644 --- a/src/mainview/components/GameList.tsx +++ b/src/mainview/components/GameList.tsx @@ -2,13 +2,15 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { GameMetaExtra, CardList } from "./CardList"; import { DefaultRommStaleTime, 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 { JSX, useContext } from "react"; import { useLocalSetting } from "../scripts/utils"; import { AnimatedBackgroundContext } from "../scripts/contexts"; import { allGamesQuery } from "@queries/romm"; import { FrontEndGameType, FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; +import { isUrl } from "@/shared/utils"; +import { FOCUS_KEYS } from "../scripts/types"; export interface GameListParams extends FocusParams { @@ -17,6 +19,7 @@ export interface GameListParams extends FocusParams grid?: boolean, setBackground?: (url: string) => void; onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void; + onQuickAction?: (id: FrontEndId, source: string | null, sourceId: string | null) => void; focus?: string; className?: string; finalElement?: JSX.Element | JSX.Element[]; @@ -97,7 +100,7 @@ export function GameList (data: GameListParams) const previewUrls = g.path_covers.map(c => { - const url = c.startsWith("http") ? new URL(c) : new URL(`${RPC_URL(__HOST__)}${c}`); + const url = isUrl(c) ? new URL(c) : new URL(`${RPC_URL(__HOST__)}${c}`); url.searchParams.delete('ts'); return url; }); @@ -105,13 +108,13 @@ export function GameList (data: GameListParams) let platformUrl: URL | undefined = undefined; if (g.path_platform_cover) { - platformUrl = g.path_platform_cover.startsWith("http") ? new URL(g.path_platform_cover) : new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); + platformUrl = isUrl(g.path_platform_cover) ? new URL(g.path_platform_cover) : new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); platformUrl.searchParams.set('width', "64"); } return { id: `${g.id.source}@${g.id.id}`, - focusKey: `${data.id}-${g.id.source}@${g.id.id}`, + focusKey: FOCUS_KEYS.GAME_LIST_CARD(data.id, g.id), title: g.name ?? "", subtitle: (
        @@ -122,6 +125,7 @@ export function GameList (data: GameListParams) previewUrls: previewUrls, badges: badges, onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g), + onQuickAction: data.onQuickAction ? () => data.onQuickAction?.(g.id, g.source, g.source_id) : undefined, onFocus: () => handleFocus(g.id, g.source, g.source_id) } satisfies GameMetaExtra; }, diff --git a/src/mainview/components/GamepadKeyboard.tsx b/src/mainview/components/GamepadKeyboard.tsx index 37e533b..75005a1 100644 --- a/src/mainview/components/GamepadKeyboard.tsx +++ b/src/mainview/components/GamepadKeyboard.tsx @@ -387,10 +387,6 @@ export function GamepadKeyboard () const magnitudeSqr = (x * x) + (y * y); const magnitude = Math.sqrt(magnitudeSqr); - const elementPos = keyIndex < 0 ? undefined : elements[side].positions[keyIndex]; - //const lerpX = (element?.left ?? 0); - //const lerpY = (element?.top ?? 0); - const size = 12; circle.style.left = `calc(50% + ${50 * x}% - 16px)`; circle.style.top = `calc(50% + ${50 * y}% - 16px)`; circle.style.opacity = `${1 - Math.pow(magnitude, 2)}`; diff --git a/src/mainview/components/GlobalContextDialog.tsx b/src/mainview/components/GlobalContextDialog.tsx new file mode 100644 index 0000000..0fcc23d --- /dev/null +++ b/src/mainview/components/GlobalContextDialog.tsx @@ -0,0 +1,28 @@ +import { useState } from "react"; +import { GlobalDialogContext } from "../scripts/contexts"; +import { useContextDialog } from "./ContextDialog"; + +export default function GlobalContextDialog (data: { children: any; }) +{ + const [currentContext, setCurrentContext] = useState(undefined); + const [preferredChildFocusKey, setPreferredChildFocusKey] = useState(undefined); + const [onCloseCallback, setOnCloseCallback] = useState<(() => void) | undefined>(undefined); + + const { dialog, setOpen } = useContextDialog('global-context-dialog', { + content: currentContext, + onClose: onCloseCallback, + preferredChildFocusKey: preferredChildFocusKey + }); + return + {data.children} + {dialog} + ; +} \ No newline at end of file diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index 932dada..d38ef5b 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -29,10 +29,11 @@ import { twMerge } from "tailwind-merge"; import { TwitchIcon } from "../scripts/brandIcons"; import { rommLoggedInQuery } from "../scripts/queries/romm"; import { twitchLoginVerificationQuery } from "../scripts/queries/settings"; -import { SystemInfoContext } from "../scripts/contexts"; +import { AppContext, SystemInfoContext } from "../scripts/contexts"; import { useNavigate, useRouter } from "@tanstack/react-router"; import { oneShot } from "../scripts/audio/audio"; import { hasUpdateQuery } from "../scripts/queries/system"; +import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; function HeaderAvatar (data: { id: string; @@ -73,6 +74,7 @@ export interface HeaderButton external?: boolean; action?: () => void; className?: string; + shortcutLabel?: string; } export interface HeaderAccount @@ -111,14 +113,22 @@ function NotificationStatus () function ClockStatus () { - const ref = useRef(null); + const navigate = useNavigate(); + const app = useContext(AppContext); + const refClock = useRef(null); + const activeTaskProgress = app.activeTaskProgress; + const handleTaskClick = () => + { + navigate({ to: '/settings/tasks' }); + }; + const { ref, focusKey } = useFocusable({ focusKey: 'tasks-indicator', focusable: !!activeTaskProgress, onEnterPress: handleTaskClick }); useEffect(() => { function update () { - if (ref.current) + if (refClock.current) { - ref.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + refClock.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } } @@ -142,7 +152,16 @@ function ClockStatus () return () => clearTimeout(timeout); }, []); - return
        ; + useShortcuts(focusKey, () => [{ + label: "Downloads", button: GamePadButtonCode.A, action (e) + { + handleTaskClick(); + }, + }]); + + return
        + + {activeTaskProgress ?
        : }
        ; } function BluetoothStatus () @@ -288,6 +307,7 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement {data.buttonElements} {data.buttons?.map(b => {data.title} - , id: "header-settings-btn", action: goToSettings, external: true }]} /> + , + id: "header-settings-btn", + action: goToSettings, + external: true, + shortcutLabel: "Settings" + } + ]} /> diff --git a/src/mainview/components/HeaderSearchField.tsx b/src/mainview/components/HeaderSearchField.tsx index 823af58..198c552 100644 --- a/src/mainview/components/HeaderSearchField.tsx +++ b/src/mainview/components/HeaderSearchField.tsx @@ -96,10 +96,10 @@ export default function HeaderSearchField (data: { isFocusBoundary: data.compact && showInput }); - return
        + return
        {(!data.compact || showInput) && } - {data.compact && !showInput && setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} >} + {data.compact && !showInput && setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} >}
        ; } \ No newline at end of file diff --git a/src/mainview/components/RoundButton.tsx b/src/mainview/components/RoundButton.tsx index 386723b..01f9017 100644 --- a/src/mainview/components/RoundButton.tsx +++ b/src/mainview/components/RoundButton.tsx @@ -9,10 +9,11 @@ export function RoundButton (data: { external?: boolean; style?: ButtonStyle; cssStyle?: CSSProperties; + shortcutLabel?: string; } & InteractParams & FocusParams) { return ( - diff --git a/src/mainview/components/Screenshots.tsx b/src/mainview/components/Screenshots.tsx index 42d76d3..e65a965 100644 --- a/src/mainview/components/Screenshots.tsx +++ b/src/mainview/components/Screenshots.tsx @@ -8,6 +8,7 @@ import Carousel from "./Carousel"; import { ContextDialog } from "./ContextDialog"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { twMerge } from "tailwind-merge"; +import { isUrl } from "@/shared/utils"; function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams) { @@ -21,8 +22,9 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n scrollIntoNearestParent(ref.current, { behavior: details.instant ? 'instant' : 'smooth' }); } }); 4096; + const url = isUrl(data.path) ? data.path : `${RPC_URL(__HOST__)}${data.path}`; return
        - focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" decoding="async" /> + focusSelf({ nativeEvent: e.nativeEvent })} src={url} loading="lazy" decoding="async" />
        data.onAction?.({ event: e.nativeEvent, focusKey })}>
        ; } @@ -59,8 +61,9 @@ function Preview (data: { id: string; screenshots?: string[]; preview: number; s } } ], [data.preview, focusKey, data.screenshots?.length ?? 0]); + const url = isUrl(data.screenshots?.[data.preview]) ? data.screenshots?.[data.preview] : `${RPC_URL(__HOST__)}${data.screenshots?.[data.preview]}`; - return ; + return ; } export default function Screenshots (data: { screenshots?: string[]; className?: string; } & FocusParams) diff --git a/src/mainview/components/SelectMenu.tsx b/src/mainview/components/SelectMenu.tsx index fa10743..42d21c8 100644 --- a/src/mainview/components/SelectMenu.tsx +++ b/src/mainview/components/SelectMenu.tsx @@ -2,7 +2,7 @@ import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { useMatchRoute, useNavigate, useRouter } from "@tanstack/react-router"; import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; -import { DoorOpen, Gamepad2, Puzzle, RefreshCcw, Settings, Store } from "lucide-react"; +import { DoorOpen, Gamepad2, Home, Puzzle, RefreshCcw, Settings, Store } from "lucide-react"; import { systemApi } from "../scripts/clientApi"; import { FOCUS_KEYS } from "../scripts/types"; @@ -15,7 +15,7 @@ export default function SelectMenu (data: { rootFocusKey: string; }) const options: DialogEntry[] = [ { content: "Home", - icon: , + icon: , action (ctx) { setOpen(false); diff --git a/src/mainview/components/SideFilters.tsx b/src/mainview/components/SideFilters.tsx index 6f99336..930bf2b 100644 --- a/src/mainview/components/SideFilters.tsx +++ b/src/mainview/components/SideFilters.tsx @@ -1,25 +1,25 @@ -import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; +import { DownloadsLookupFilter, DownloadsLookupFilterValues, GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; import { RoundButton } from "./RoundButton"; import classNames from "classnames"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { useFocusable, FocusContext } from "@noriginmedia/norigin-spatial-navigation"; -import { ArrowDownAz, ClockArrowDown, CalendarArrowDown, Rocket, HardDrive, SortDesc, User, Drama, FunnelX, Store } from "lucide-react"; +import { ArrowDownAz, ClockArrowDown, CalendarArrowDown, Rocket, HardDrive, SortDesc, User, Drama, FunnelX, Store, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react"; import { sourceIconMap } from "./Constants"; -import { useContextDialog, ContextList, DialogEntry } from "./ContextDialog"; +import { ContextList, DialogEntry } from "./ContextDialog"; import { FrontEndFilterLists } from "@simeonradivoev/gameflow-sdk/shared"; +import { useContext } from 'react'; +import { GlobalDialogContext } from '../scripts/contexts'; function FilterButton (data: { id: string, filters?: GameListFilterType, tooltip: string, icon: any; - dialog: { - setToggle: (focNewSourceFocusKey?: string | undefined) => void; - }; + dialog: (focNewSourceFocusKey: string) => void; isActive: boolean; }) { - const handleAction = () => data.dialog.setToggle(data.id); + const handleAction = () => data.dialog(data.id); useShortcuts(data.id, () => [{ label: data.tooltip, action: handleAction, button: GamePadButtonCode.A }]); return
        ; } +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: ({ + 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: }, { label: 'desc', icon: }] + .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: (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
        + + } /> + } /> + + {!data.filters?.source && + } /> + } + + {Object.values(data.localFilter).some(v => v !== undefined) && + <> +
        + data.setLocalFilter({})} className='p-3 drop-shadow-md!' > + + } +
        +
        ; +} + export default function SideFilters (data: { id: string, filters?: GameListFilterType; @@ -42,96 +125,107 @@ export default function SideFilters (data: { { const { ref, focusKey } = useFocusable({ focusKey: data.id }); + const globalDialog = useContext(GlobalDialogContext); - const orderByDialog = useContextDialog('order-by-dialog', { - content: }, - { stat: "activity", icon: }, - { stat: "added", icon: }, - { stat: "release", icon: }, - ] 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}`, + const openSourceDialog = (focusKey: string) => + { + globalDialog.openContext({ + content: (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(); + }, + })).concat({ + content: "Local Only", + icon: , + 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: ({ + content: g, + selected: data.localFilter.genres?.includes(g), + id: `genre-filter-${g}`, type: 'primary', action (ctx) { - data.setLocalFilter({ ...data.localFilter, orderBy: o.stat }); + 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(); }, - }))} />, - preferredChildFocusKey: `sort-by-${data.localFilter.orderBy}` - }); + }))} /> + }, focusKey); + }; - const sourceFilterDialog = useContextDialog('source-filter-dialog', { - content: (o => ({ - content: o, - icon: sourceIconMap[o], - selected: data.localFilter.source === o, - id: `source-filter-${o}`, + const openSortingDialog = (focusKey: string) => + { + globalDialog.openContext({ + content: }, + { stat: "activity", icon: }, + { stat: "added", icon: }, + { stat: "release", icon: }, + ] 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: ({ + 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, source: undefined }); - else data.setLocalFilter({ ...data.localFilter, source: o }); + 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(); }, - })).concat({ - content: "Local Only", - icon: , - 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: ({ - 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: ({ - 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(); - }, - }))} /> - }); + }))} /> + }, focusKey); + }; return
        - } /> - 0} icon={} /> - 0} icon={} /> + } /> + 0} icon={} /> + 0} icon={} /> {!data.filters?.source && - } /> + } /> } {Object.values(data.localFilter).some(v => v !== undefined) && <> @@ -139,10 +233,6 @@ export default function SideFilters (data: { data.setLocalFilter({})} className='p-3 drop-shadow-md!' > } - {orderByDialog.dialog} - {sourceFilterDialog.dialog} - {genreFilterDialog.dialog} - {ageRatingFilterDialog.dialog}
        ; } \ No newline at end of file diff --git a/src/mainview/components/game/ActionButtons.tsx b/src/mainview/components/game/ActionButtons.tsx index 1a60c93..d37ea00 100644 --- a/src/mainview/components/game/ActionButtons.tsx +++ b/src/mainview/components/game/ActionButtons.tsx @@ -30,7 +30,11 @@ function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractP ; } -export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; }) +export default function ActionButtons (data: { + game?: FrontEndGameTypeDetailed, + source: string, + id: string; +}) { const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots'); const navigate = useNavigate(); diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index 20bb27b..6f772af 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -1,21 +1,20 @@ import { rommApi } from "@/mainview/scripts/clientApi"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { JSX, useEffect, useRef, useState } from "react"; +import { JSX, useContext, useEffect, useRef, useState } from "react"; import { getErrorMessage } from "react-error-boundary"; import toast from "react-hot-toast"; import { useLocalStorage } from "usehooks-ts"; -import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; +import { ContextList, DialogEntry } from "../ContextDialog"; import { Clock, Crosshair, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react"; import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview/scripts/queries/romm"; import ActionButton from "./ActionButton"; -import { useRouter } from "@tanstack/react-router"; +import { useNavigate, UseNavigateResult, useRouter } from "@tanstack/react-router"; import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; import { CommandEntry, FrontEndGameTypeDetailed, DownloadSourceType } from "@simeonradivoev/gameflow-sdk/shared"; +import { GlobalDialogContext } from "@/mainview/scripts/contexts"; -export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; }) +export function usePlayMutation (navigate: UseNavigateResult) { - const installMut = useMutation(installMutation(data.source, data.id)); - const router = useRouter(); const playMut = useMutation({ ...playMutation, onError (error) { @@ -23,9 +22,36 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so }, onSuccess (data, { source, id }, onMutateResult, context) { - router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id } }); + navigate({ to: '/launcher/$source/$id', params: { source: source, id: id } }); }, }); + + return playMut; +} + +export function playGame (source: string, id: string, cmd: CommandEntry, navigate: UseNavigateResult, playMutation: (options: { source: string, id: string, command_id: string | number; }) => void) +{ + if (cmd.emulator === 'EMULATORJS') + { + const params = new URLSearchParams(Array.isArray(cmd.command) ? cmd.command[0] : cmd.command); + navigate({ to: '/embedded/$source/$id', params: { source: source, id: id }, search: Object.fromEntries(params.entries()) }); + } else + { + playMutation({ source: source, id: id, command_id: cmd.id }); + } +} + +export default function MainActions (data: { + game?: FrontEndGameTypeDetailed, + source: string, + id: string; +}) +{ + const installMut = useMutation(installMutation(data.source, data.id)); + const router = useRouter(); + + const navigate = useNavigate(); + const globalDialog = useContext(GlobalDialogContext); const ws = useRef<{ send: (data: string) => void; }>(undefined); const [progress, setProgress] = useState(undefined); const [status, setStatus] = useState(undefined); @@ -42,7 +68,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so if (preferredCommand && c.id !== preferredCommand) return false; return true; }); - + const playMut = usePlayMutation(navigate); useEffect(() => { const sub = rommApi.api.romm.status({ source: data.source })({ id: data.id }).subscribe(); @@ -99,32 +125,33 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so } const showProgress = progress !== null && !!progressIcon; - useEffect(() => - { - if (showProgress) return; - showInstallOptions(false); - }, [showProgress]); - const handlePlay = (cmd?: CommandEntry) => - { - if (!cmd) return; - if (cmd.emulator === 'EMULATORJS') - { - const params = new URLSearchParams(Array.isArray(cmd.command) ? cmd.command[0] : cmd.command); - router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()) }); - } else - { - playMut.mutate({ source: data.source, id: data.id, command_id: cmd.id }); - } - }; + let mainButton: any | undefined = undefined; let showAllCommandsAction: ((focusKey: string) => void) | undefined; let mainAction: () => void; if (status === 'installed') { - if (validCommands.length > 1) showAllCommandsAction = (focusKey) => showAllCommands(true, focusKey); - mainAction = () => handlePlay(validDefaultCommand); + if (validCommands.length > 1) showAllCommandsAction = (focusKey) => globalDialog.openContext({ + content: + { + 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 =
        1) { - showInstallSource(true, 'mainAction'); + globalDialog.openContext({ + content: ({ + content: s.name, + action (ctx) + { + installMut.mutate({ downloadId: s.id }); + ctx.close(); + }, + type: 'primary', + id: s.id + } satisfies DialogEntry)) ?? []} /> + }, 'mainAction'); } else { installMut.mutate({}); @@ -222,55 +260,21 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so return shortcuts; }, [showAllCommandsAction, mainAction]); - const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', { - content: - { - 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: - }); - - const { dialog: installSourcesDialog, setOpen: showInstallSource } = useContextDialog('install-source-dialog', { - content: ({ - content: s.name, - action (ctx) - { - installMut.mutate({ downloadId: s.id }); - ctx.close(); - }, - type: 'primary', - id: s.id - } satisfies DialogEntry)) ?? []} /> - }); - return
        {mainButton}
        - {showProgress && showInstallOptions(true, "progress")} key="progress" square tooltip={details} type="base" id="progress" > + {showProgress && globalDialog.openContext({ + content: + }, "progress")} key="progress" square tooltip={details} type="base" id="progress" >
        {progressIcon} @@ -278,8 +282,5 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
        } - {installSourcesDialog} - {installOptionsDialog} - {allCommandDialog}
        ; } \ No newline at end of file diff --git a/src/mainview/components/options/Button.tsx b/src/mainview/components/options/Button.tsx index de07bdf..e131123 100644 --- a/src/mainview/components/options/Button.tsx +++ b/src/mainview/components/options/Button.tsx @@ -12,7 +12,7 @@ import { oneShot } from "@/mainview/scripts/audio/audio"; export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'; const styles = { - base: 'dark:bg-base-200 light:bg-base-300 text-base-content active:not-disabled:bg-base-300! active:not-disabled:text-base-content! active:not-disabled:ring-offset-base-content', + base: 'dark:bg-base-200 light:bg-base-100 text-base-content active:not-disabled:bg-base-300! active:not-disabled:text-base-content! active:not-disabled:ring-offset-base-content', accent: "bg-accent text-accent-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:ring-offset-accent", primary: "bg-primary text-primary-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-primary", secondary: "bg-secondary text-secondary-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-secondary", @@ -22,6 +22,17 @@ const styles = { error: "bg-error text-error-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-error", }; +const externalStyles = { + base: '', + accent: "focusable-accent", + primary: "focusable-primary", + secondary: "focusable-secondary", + info: "focusable-info", + success: "focusable-success", + warning: "focusable-warning", + error: "focusable-error", +}; + export function Button (data: { id: string, children?: any, @@ -64,9 +75,9 @@ export function Button (data: { className={twMerge("flex items-center justify-center px-4 py-2 disabled:bg-base-200/40 disabled:text-base-content/40 not-disabled:cursor-pointer rounded-3xl md:text-lg not-control-mouse:focused:drop-shadow-lg border border-base-content/5 not-control-mouse:focused:bg-base-content not-control-mouse:focused:text-base-100 control-mouse:hover:not-disabled:bg-base-content control-mouse:hover:not-disabled:text-base-100 active:not-disabled:transition-none active:not-disabled:ring-offset-4", styles[data.style ?? 'base'], focused ? data.focusClassName : undefined, + data.external ? `focusable focusable-hover ${externalStyles[data.style as keyof typeof externalStyles]}` : '', classNames({ - "btn-accent": focused, - "focusable focusable-primary focusable-hover": data.external + "btn-accent": focused }, data.className))} type={data.type ?? 'button'} > diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index 8645a01..3479579 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -6,11 +6,15 @@ import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import { CircleFadingArrowUp, FileQuestion, IceCream2, Package, Store, WandSparkles } from "lucide-react"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import { FlatpackIcon } from "@/mainview/scripts/brandIcons"; -import { JSX } from "react"; +import { JSX, useContext } from "react"; import { oneShot } from "@/mainview/scripts/audio/audio"; import { useQuery } from "@tanstack/react-query"; import { getUpdateInfoForEmulator } from "@/mainview/scripts/queries/store"; import { FrontEndEmulator } from "@simeonradivoev/gameflow-sdk/shared"; +import { rommApi } from "@/mainview/scripts/clientApi"; +import { useNavigate } from "@tanstack/react-router"; +import { GlobalDialogContext } from "@/mainview/scripts/contexts"; +import { ContextList, DialogEntry } from "../ContextDialog"; export const emulatorStatusIcons: Record = { store: , @@ -28,6 +32,7 @@ export function StoreEmulatorCard (data: { className?: string; }) { + const navigate = useNavigate(); const handleSelect = () => { data.onSelect?.(data.emulator.name, focusKey); @@ -45,7 +50,32 @@ export function StoreEmulatorCard (data: { const { data: updateInfo } = useQuery(getUpdateInfoForEmulator(data.emulator.name)); - useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]); + const globalDialogContext = useContext(GlobalDialogContext); + useShortcuts(focusKey, () => [{ + button: GamePadButtonCode.A, + label: "Details", + action: handleSelect + + }, { + button: GamePadButtonCode.Y, + label: "Launch Emulator", + action: e => + { + const entries: DialogEntry[] = data.emulator.validSources.filter(s => s.exists).map(s => ({ + content: `Launch: ${s.type}`, + type: 'primary', + icon: emulatorStatusIcons[s.type], + action (ctx) + { + if (!data.emulator) return; + rommApi.api.romm.game({ source: 'emulator' })({ id: data.emulator.name }).play.post({ command_id: s.type }); + ctx.close(); + navigate({ to: '/launcher/$source/$id', params: { source: 'emulator', id: data.emulator.name } }); + }, id: `open-${s.type}` + } satisfies DialogEntry)); + globalDialogContext.openContext({ content: }, focusKey); + } + }], [handleSelect]); return (
        SettingsRouteRoute, } as any) +const SettingsTasksRoute = SettingsTasksRouteImport.update({ + id: '/tasks', + path: '/tasks', + getParentRoute: () => SettingsRouteRoute, +} as any) const SettingsPluginsRoute = SettingsPluginsRouteImport.update({ id: '/plugins', path: '/plugins', @@ -115,6 +123,11 @@ const StoreTabEmulatorsRoute = StoreTabEmulatorsRouteImport.update({ path: '/emulators', getParentRoute: () => StoreTabRouteRoute, } as any) +const StoreTabDownloadRoute = StoreTabDownloadRouteImport.update({ + id: '/download', + path: '/download', + getParentRoute: () => StoreTabRouteRoute, +} as any) const SettingsPluginSourceRoute = SettingsPluginSourceRouteImport.update({ id: '/plugin/$source', path: '/plugin/$source', @@ -160,6 +173,12 @@ const GameUpdateSourceIdRoute = GameUpdateSourceIdRouteImport.update({ path: '/game/update/$source/$id', getParentRoute: () => rootRouteImport, } as any) +const StoreDetailsDownloadSourceIdRoute = + StoreDetailsDownloadSourceIdRouteImport.update({ + id: '/store/details/download/$source/$id', + path: '/store/details/download/$source/$id', + getParentRoute: () => rootRouteImport, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -173,6 +192,7 @@ export interface FileRoutesByFullPath { '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute '/settings/plugins': typeof SettingsPluginsRoute + '/settings/tasks': typeof SettingsTasksRoute '/settings/update': typeof SettingsUpdateRoute '/collection/$source/$id': typeof CollectionSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute @@ -180,6 +200,7 @@ export interface FileRoutesByFullPath { '/launcher/$source/$id': typeof LauncherSourceIdRoute '/platform/$source/$id': typeof PlatformSourceIdRoute '/settings/plugin/$source': typeof SettingsPluginSourceRoute + '/store/tab/download': typeof StoreTabDownloadRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute '/store/tab/plugins': typeof StoreTabPluginsRoute @@ -187,6 +208,7 @@ export interface FileRoutesByFullPath { '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute + '/store/details/download/$source/$id': typeof StoreDetailsDownloadSourceIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -199,6 +221,7 @@ export interface FileRoutesByTo { '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute '/settings/plugins': typeof SettingsPluginsRoute + '/settings/tasks': typeof SettingsTasksRoute '/settings/update': typeof SettingsUpdateRoute '/collection/$source/$id': typeof CollectionSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute @@ -206,6 +229,7 @@ export interface FileRoutesByTo { '/launcher/$source/$id': typeof LauncherSourceIdRoute '/platform/$source/$id': typeof PlatformSourceIdRoute '/settings/plugin/$source': typeof SettingsPluginSourceRoute + '/store/tab/download': typeof StoreTabDownloadRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute '/store/tab/plugins': typeof StoreTabPluginsRoute @@ -213,6 +237,7 @@ export interface FileRoutesByTo { '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute + '/store/details/download/$source/$id': typeof StoreDetailsDownloadSourceIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -227,6 +252,7 @@ export interface FileRoutesById { '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute '/settings/plugins': typeof SettingsPluginsRoute + '/settings/tasks': typeof SettingsTasksRoute '/settings/update': typeof SettingsUpdateRoute '/collection/$source/$id': typeof CollectionSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute @@ -234,6 +260,7 @@ export interface FileRoutesById { '/launcher/$source/$id': typeof LauncherSourceIdRoute '/platform/$source/$id': typeof PlatformSourceIdRoute '/settings/plugin/$source': typeof SettingsPluginSourceRoute + '/store/tab/download': typeof StoreTabDownloadRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute '/store/tab/plugins': typeof StoreTabPluginsRoute @@ -241,6 +268,7 @@ export interface FileRoutesById { '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute + '/store/details/download/$source/$id': typeof StoreDetailsDownloadSourceIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -256,6 +284,7 @@ export interface FileRouteTypes { | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/settings/tasks' | '/settings/update' | '/collection/$source/$id' | '/embedded/$source/$id' @@ -263,6 +292,7 @@ export interface FileRouteTypes { | '/launcher/$source/$id' | '/platform/$source/$id' | '/settings/plugin/$source' + | '/store/tab/download' | '/store/tab/emulators' | '/store/tab/games' | '/store/tab/plugins' @@ -270,6 +300,7 @@ export interface FileRouteTypes { | '/game/update/$source/$id' | '/store/details/emulator/$id' | '/store/details/plugin/$id' + | '/store/details/download/$source/$id' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -282,6 +313,7 @@ export interface FileRouteTypes { | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/settings/tasks' | '/settings/update' | '/collection/$source/$id' | '/embedded/$source/$id' @@ -289,6 +321,7 @@ export interface FileRouteTypes { | '/launcher/$source/$id' | '/platform/$source/$id' | '/settings/plugin/$source' + | '/store/tab/download' | '/store/tab/emulators' | '/store/tab/games' | '/store/tab/plugins' @@ -296,6 +329,7 @@ export interface FileRouteTypes { | '/game/update/$source/$id' | '/store/details/emulator/$id' | '/store/details/plugin/$id' + | '/store/details/download/$source/$id' id: | '__root__' | '/' @@ -309,6 +343,7 @@ export interface FileRouteTypes { | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/settings/tasks' | '/settings/update' | '/collection/$source/$id' | '/embedded/$source/$id' @@ -316,6 +351,7 @@ export interface FileRouteTypes { | '/launcher/$source/$id' | '/platform/$source/$id' | '/settings/plugin/$source' + | '/store/tab/download' | '/store/tab/emulators' | '/store/tab/games' | '/store/tab/plugins' @@ -323,6 +359,7 @@ export interface FileRouteTypes { | '/game/update/$source/$id' | '/store/details/emulator/$id' | '/store/details/plugin/$id' + | '/store/details/download/$source/$id' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -339,6 +376,7 @@ export interface RootRouteChildren { GameUpdateSourceIdRoute: typeof GameUpdateSourceIdRoute StoreDetailsEmulatorIdRoute: typeof StoreDetailsEmulatorIdRoute StoreDetailsPluginIdRoute: typeof StoreDetailsPluginIdRoute + StoreDetailsDownloadSourceIdRoute: typeof StoreDetailsDownloadSourceIdRoute } declare module '@tanstack/react-router' { @@ -371,6 +409,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsUpdateRouteImport parentRoute: typeof SettingsRouteRoute } + '/settings/tasks': { + id: '/settings/tasks' + path: '/tasks' + fullPath: '/settings/tasks' + preLoaderRoute: typeof SettingsTasksRouteImport + parentRoute: typeof SettingsRouteRoute + } '/settings/plugins': { id: '/settings/plugins' path: '/plugins' @@ -455,6 +500,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StoreTabEmulatorsRouteImport parentRoute: typeof StoreTabRouteRoute } + '/store/tab/download': { + id: '/store/tab/download' + path: '/download' + fullPath: '/store/tab/download' + preLoaderRoute: typeof StoreTabDownloadRouteImport + parentRoute: typeof StoreTabRouteRoute + } '/settings/plugin/$source': { id: '/settings/plugin/$source' path: '/plugin/$source' @@ -518,6 +570,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GameUpdateSourceIdRouteImport parentRoute: typeof rootRouteImport } + '/store/details/download/$source/$id': { + id: '/store/details/download/$source/$id' + path: '/store/details/download/$source/$id' + fullPath: '/store/details/download/$source/$id' + preLoaderRoute: typeof StoreDetailsDownloadSourceIdRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -528,6 +587,7 @@ interface SettingsRouteRouteChildren { SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute SettingsInterfaceRoute: typeof SettingsInterfaceRoute SettingsPluginsRoute: typeof SettingsPluginsRoute + SettingsTasksRoute: typeof SettingsTasksRoute SettingsUpdateRoute: typeof SettingsUpdateRoute SettingsPluginSourceRoute: typeof SettingsPluginSourceRoute } @@ -539,6 +599,7 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = { SettingsEmulatorsRoute: SettingsEmulatorsRoute, SettingsInterfaceRoute: SettingsInterfaceRoute, SettingsPluginsRoute: SettingsPluginsRoute, + SettingsTasksRoute: SettingsTasksRoute, SettingsUpdateRoute: SettingsUpdateRoute, SettingsPluginSourceRoute: SettingsPluginSourceRoute, } @@ -548,6 +609,7 @@ const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren( ) interface StoreTabRouteRouteChildren { + StoreTabDownloadRoute: typeof StoreTabDownloadRoute StoreTabEmulatorsRoute: typeof StoreTabEmulatorsRoute StoreTabGamesRoute: typeof StoreTabGamesRoute StoreTabPluginsRoute: typeof StoreTabPluginsRoute @@ -555,6 +617,7 @@ interface StoreTabRouteRouteChildren { } const StoreTabRouteRouteChildren: StoreTabRouteRouteChildren = { + StoreTabDownloadRoute: StoreTabDownloadRoute, StoreTabEmulatorsRoute: StoreTabEmulatorsRoute, StoreTabGamesRoute: StoreTabGamesRoute, StoreTabPluginsRoute: StoreTabPluginsRoute, @@ -579,6 +642,7 @@ const rootRouteChildren: RootRouteChildren = { GameUpdateSourceIdRoute: GameUpdateSourceIdRoute, StoreDetailsEmulatorIdRoute: StoreDetailsEmulatorIdRoute, StoreDetailsPluginIdRoute: StoreDetailsPluginIdRoute, + StoreDetailsDownloadSourceIdRoute: StoreDetailsDownloadSourceIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/mainview/index.css b/src/mainview/index.css index 4c82b71..332862e 100644 --- a/src/mainview/index.css +++ b/src/mainview/index.css @@ -9,6 +9,7 @@ @theme { --breakpoint-sm: 0px; --breakpoint-md: 1024px; + --breakpoint-lg: 1280px; --page-scroll-bg: transparent; --animation-size: 1; diff --git a/src/mainview/index.tsx b/src/mainview/index.tsx index f5639f9..166cc4f 100644 --- a/src/mainview/index.tsx +++ b/src/mainview/index.tsx @@ -8,7 +8,7 @@ import RouterProvider, } from "@tanstack/react-router"; import { routeTree } from "./gen/routeTree.gen"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient } from "@tanstack/react-query"; import "./scripts/gamepads"; import "./scripts/windowEvents"; import "./scripts/spatialNavigation"; @@ -16,6 +16,16 @@ import NotFound from "./components/NotFound"; import Error from "./components/Error"; import serviceWorker from './scripts/serviceWorker?worker&url'; import App from "./App"; +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; +import { createStore, get, set, del } from "idb-keyval"; +import +{ + PersistedClient, + Persister, +} from '@tanstack/react-query-persist-client'; +import pkg from '../../package.json'; + +const idbStore = createStore("tanstack-query", "cache"); if ('serviceWorker' in navigator) { @@ -24,7 +34,31 @@ if ('serviceWorker' in navigator) const hashHistory = createHashHistory({}); -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 1000 * 60 * 60 * 24 * 5, // 5 days + } + } +}); + +export function createIDBPersister (idbValidKey: IDBValidKey = 'reactQuery'): Persister +{ + return { + persistClient: async (client: PersistedClient) => + { + await set(idbValidKey, client, idbStore); + }, + restoreClient: async () => + { + return await get(idbValidKey, idbStore); + }, + removeClient: async () => + { + await del(idbValidKey, idbStore); + }, + } satisfies Persister; +} export interface RouterContext { @@ -74,9 +108,9 @@ if (!rootElement.innerHTML) root.render( - + - + , ); diff --git a/src/mainview/routes/__root.tsx b/src/mainview/routes/__root.tsx index fbe2f26..cafbab4 100644 --- a/src/mainview/routes/__root.tsx +++ b/src/mainview/routes/__root.tsx @@ -8,6 +8,7 @@ import { useEffect } from "react"; import AppCommunication from "../components/AppCommunication"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; +import GlobalContextDialog from "../components/GlobalContextDialog"; export const Route = createRootRouteWithContext()({ component: RootComponent, @@ -39,9 +40,11 @@ function RootComponent () return (
        - - - + + + + + {queryDevOptions && } diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 761e9ea..13ea8ac 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -33,7 +33,9 @@ export const Route = createFileRoute("/game/$source/$id")({ }, component: RouteComponent, errorComponent: Error, - validateSearch: zodValidator(z.object({ focus: z.string().optional() })), + validateSearch: zodValidator(z.object({ + focus: z.string().optional(), + })), staticData: { enterSound: 'openDetails', goBackSound: "returnDetails" diff --git a/src/mainview/routes/game/add.tsx b/src/mainview/routes/game/add.tsx index 3a6a2f8..6399cd0 100644 --- a/src/mainview/routes/game/add.tsx +++ b/src/mainview/routes/game/add.tsx @@ -8,15 +8,18 @@ import { PathSettingsOptionBase } from '@/mainview/components/options/PathSettin import SelectMenu from '@/mainview/components/SelectMenu'; import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; import { oneShot } from '@/mainview/scripts/audio/audio'; +import { rommApi } from '@/mainview/scripts/clientApi'; import { addManualGameMutation, allGamesInvalidateQuery, gameLookupDetails, platformLookupMatchQuery } from '@/mainview/scripts/queries/romm'; import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; import { HandleGoBack } from '@/mainview/scripts/utils'; +import { isUrl } from '@/shared/utils'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; import { zodValidator } from '@tanstack/zod-adapter'; -import { ArrowBigRightDash, Check, CirclePlus, CircleQuestionMark, CircleX, FileSearch, FolderOpen, HardDrive } from 'lucide-react'; +import { ArrowBigRightDash, Check, CirclePlus, CircleQuestionMark, CircleX, File, FileSearch, FolderOpen, Globe, HardDrive, Link, Save } from 'lucide-react'; import { basename } from 'pathe'; +import prettyBytes from 'pretty-bytes'; import { JSX, useState } from 'react'; import toast from 'react-hot-toast'; import { twMerge } from 'tailwind-merge'; @@ -39,6 +42,7 @@ export const Route = createFileRoute('/game/add')({ function FileSelectionField (data: { location: string | undefined, setLocation: (location: string | undefined) => void; }) { const [localLocation, setLocalLocation] = useState(data.location); + const navigate = useNavigate(); return ; + > + + ; } const TAG_REGEX = /\(([^)]+)\)|\[([^\]]+)\]/g; @@ -95,6 +101,17 @@ function Overview (data: {}) const navigate = useNavigate(); const router = useRouter(); const state = Route.useSearch(); + const linkInfo = useQuery({ + enabled (query) + { + return isUrl(query.queryKey[1]); + }, + queryKey: ['dl-link-info', state.gameLocation], + queryFn: async () => + { + return rommApi.api.romm.download.file.info.get({ query: { file_url: state.gameLocation! } }); + } + }); const { data: game } = useQuery(gameLookupDetails(state.selectedGame?.source, state.selectedGame?.id)); const { data: platform } = useQuery(platformLookupMatchQuery(state.selectedGame?.source, state.platformId)); const addGame = useMutation({ @@ -105,7 +122,7 @@ function Overview (data: {}) }, async onSuccess (data, variables, onMutateResult, context) { - if (data.id === null) return; + if (data.id === null || isUrl(state.gameLocation)) return; await context.client.invalidateQueries(allGamesInvalidateQuery); navigate({ to: '/game/$source/$id', params: { @@ -136,7 +153,13 @@ function Overview (data: {})
        {platform?.match.type}
        -
        {state.gameLocation}
        +
        {isUrl(state.gameLocation) ? : }{state.gameLocation}
        +
        + + {linkInfo.isFetching ? : (linkInfo.data?.data?.size && prettyBytes(linkInfo.data.data.size))} + + {linkInfo.isFetching ? : (linkInfo.data?.data?.content_type && linkInfo.data.data.content_type)} +
        Actions
        @@ -150,6 +173,11 @@ function Overview (data: {}) gamePath: state.gameLocation, platformId: state.platformId }); + if (isUrl(state.gameLocation)) + { + navigate({ to: '/settings/tasks' }); + } + }} > Add Game
        ; } diff --git a/src/mainview/routes/games.tsx b/src/mainview/routes/games.tsx index cd1fe45..bd0fc8e 100644 --- a/src/mainview/routes/games.tsx +++ b/src/mainview/routes/games.tsx @@ -31,7 +31,7 @@ function RouteComponent () return + [ { navigate({ to: '/game/add' }); }} >, diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index a9d33c1..8ae562a 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -14,6 +14,7 @@ import import { createFileRoute, + useNavigate, useRouter, } from "@tanstack/react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; @@ -40,7 +41,7 @@ import z from "zod"; import CollectionList from "../components/CollectionList"; import { zodValidator } from '@tanstack/zod-adapter'; import { mobileCheck, scrollIntoViewHandler, useDragScroll } from "../scripts/utils"; -import { AnimatedBackgroundContext } from "../scripts/contexts"; +import { AnimatedBackgroundContext, GlobalDialogContext } from "../scripts/contexts"; import Carousel from "../components/Carousel"; import { closeMutation } from "@queries/system"; import { gameQuery } from "../scripts/queries/romm"; @@ -51,6 +52,10 @@ import HeaderSearchField from "../components/HeaderSearchField"; import CardElement from "../components/CardElement"; import { Router } from ".."; import { FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; +import { playGame, usePlayMutation } from "../components/game/MainActions"; +import { rommApi } from "../scripts/clientApi"; +import { ContextList, DialogEntry } from "../components/ContextDialog"; +import { FOCUS_KEYS } from "../scripts/types"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -152,6 +157,9 @@ function HomeList (data: { focusKey: "home-list", preferredChildFocusKey: `${data.selectedFilter}-list` }); + const navigate = useNavigate(); + const playGameMut = usePlayMutation(navigate); + const globalDialog = useContext(GlobalDialogContext); const handleNodeFocus = (id: string, node: HTMLElement, details: FocusDetails) => { @@ -169,6 +177,52 @@ function HomeList (data: { router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); }; + async function handleGamePlay (id: FrontEndId, source: string | null, sourceId: string | null) + { + const finalSource = source ?? id.source; + const finalId = String(sourceId ?? id.id); + + const validCommands = await rommApi.api.romm.game({ source: finalSource })({ id: finalId }).commands.get(); + if (validCommands.data) + { + const preferredCommand = localStorage.getItem(`${finalSource}-${finalId}-preferred-command`); + if (preferredCommand) + { + playGame(finalSource, finalId, validCommands.data.commands[JSON.parse(preferredCommand)], navigate, playGameMut.mutate); + } else + { + if (validCommands.data.commands.length > 1) + { + globalDialog.openContext({ + content: + { + const option: DialogEntry = { + id: String(c.id), + content: c.label ?? String(c.id), + type: "primary", + action (ctx) + { + localStorage.setItem(`${finalSource}-${finalId}-preferred-command`, JSON.stringify(i)); + ctx.close(); + playGame(finalSource, finalId, validCommands.data.commands[0], navigate, playGameMut.mutate); + }, + }; + + return option; + }) + } /> + }, FOCUS_KEYS.GAME_LIST_CARD('games-list', id)); + } else if (validCommands.data.commands.length === 1) + { + playGame(finalSource, finalId, validCommands.data.commands[0], navigate, playGameMut.mutate); + } + + } + } + + } + let activeList: JSX.Element; switch (data.selectedFilter) { @@ -190,6 +244,7 @@ function HomeList (data: { activeList = <> { @@ -203,7 +258,7 @@ function HomeList (data: { setBackground={bg.setBackground} filters={{ limit: 12, orderBy: 'activity' }} finalElement={[ - , + , ]} emptyElement={[ diff --git a/src/mainview/routes/platform.$source.$id.tsx b/src/mainview/routes/platform.$source.$id.tsx index f4df81d..bc35faf 100644 --- a/src/mainview/routes/platform.$source.$id.tsx +++ b/src/mainview/routes/platform.$source.$id.tsx @@ -8,8 +8,10 @@ import { zodValidator } from "@tanstack/zod-adapter"; import z from "zod"; import { useLocalStorage } from "usehooks-ts"; import { RefreshCcw, Settings2 } from "lucide-react"; -import { ContextList, DialogEntry, useContextDialog } from "../components/ContextDialog"; +import { ContextList, DialogEntry } from "../components/ContextDialog"; import toast from "react-hot-toast"; +import { useContext } from "react"; +import { GlobalDialogContext } from "../scripts/contexts"; export const Route = createFileRoute("/platform/$source/$id")({ component: RouteComponent, @@ -45,6 +47,7 @@ function RouteComponent () context.client.invalidateQueries(localPlatformFilter(id)); }, }); + const globalDialog = useContext(GlobalDialogContext); const deletePlatform = useMutation({ ...deletePlatformMutation(id), onError (error, variables, onMutateResult, context) @@ -77,7 +80,7 @@ function RouteComponent () if (source === 'local') { settingsOptions.push({ - id: 'update-platform', + id: 'delete-platform', type: "error", content: "Delete", icon: deletePlatform.isPending ? : , @@ -88,10 +91,6 @@ function RouteComponent () }); } - const { dialog: platformSettingsDialog, setOpen: setPlatformSettingsOpen } = useContextDialog('platform-settings-dialog', { - content: - }); - return (
        , action () { - setPlatformSettingsOpen(true, 'open-platform-settings-btn'); + globalDialog.openContext({ content: }, 'open-platform-settings-btn'); }, }]} countHint={countHint} title={} filters={{ platform_id: Number(id), platform_source: source }} /> - {platformSettingsDialog}
        ); } diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index 9abddb4..17344df 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -24,6 +24,7 @@ import { SettingsDropdown } from '@/mainview/components/options/SettingsDropdown import { FrontEndEmulator } from '@simeonradivoev/gameflow-sdk/shared'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; +import { isUrl } from '@/shared/utils'; export const Route = createFileRoute('/settings/emulators')({ component: RouteComponent, @@ -238,7 +239,7 @@ function EmulatorBadge (data: { let logoUrl: string | undefined = undefined; if (data.emulator.logo) { - if (data.emulator.logo.startsWith('http')) + if (isUrl(data.emulator.logo)) { logoUrl = data.emulator.logo; } else diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index fd83578..625e884 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -16,6 +16,7 @@ import classNames from "classnames"; import { ArrowBigLeft, + Cog, FingerprintPattern, HardDrive, Info, @@ -155,6 +156,12 @@ function SettingsMenu (data: {}) label="Plugins" icon={} /> + } + /> ([]); + const [queuedJobs, setQueuedJobs] = useState([]); + const wsRef = useRef<{ send: (data: any) => void; }>(null); + + useEffect(() => + { + const sub = jobsApi.api.jobs.list.subscribe(); + wsRef.current = { + send (data) + { + sub.ws.send(JSON.stringify(data)); + }, + }; + sub.on('message', e => + { + switch (e.data.type) + { + case 'allJobs': + setActiveJobs(e.data.active); + setQueuedJobs(e.data.queued); + break; + + case 'aborted': + const abortedJobId = e.data.id; + setActiveJobs(jobs => jobs.map(j => j.id === abortedJobId ? { ...j, status: 'aborted' } : j)); + setQueuedJobs(jobs => jobs.filter(j => j.id !== abortedJobId)); + break; + + case 'queued': + const queuedJob = e.data.job; + setQueuedJobs(jobs => [...jobs, queuedJob]); + break; + + case 'progress': + const progressJob = e.data.job; + setActiveJobs(jobs => jobs.map(j => j.id === progressJob.id ? progressJob : j)); + break; + + case 'started': + const newJob = e.data.job; + setActiveJobs(jobs => [newJob, ...jobs]); + setQueuedJobs(jobs => jobs.filter(j => j.id !== newJob.id)); + break; + + case 'ended': + const endedJobId = e.data.id; + setActiveJobs(jobs => jobs.filter(j => j.id !== endedJobId)); + break; + } + }); + + return () => + { + sub.close(); + wsRef.current = null; + }; + }, []); + + const handleCancel = (id: string) => + { + wsRef.current?.send({ type: 'cancel', id: id }); + }; + + return
        +
        Active
        +
          + {activeJobs.map((job, i) =>
        • +
          +
          + {job.data.preview_url ? : } +
          +
          {job.data.name ?? job.id}
          +
          +
          +
          +
          +
          {job.state}
          +
          {job.progress.toFixed(1)}%
          +
          + +
          + {job.data.downloaded != null && job.data.total != null &&
          {prettyBytes(job.data.downloaded)}/{prettyBytes(job.data.total)}
          } + {job.data.speed != null &&
          {prettyBytes(job.data.speed)}/s
          } +
          +
          + +
          +
        • )} +
        +
        Queued
        +
          + {queuedJobs.map((job, i) =>
        • +
          +
          +
          {job.data.name ?? job.id}
          +
          +
          +
          +
          + {job.data.total !== undefined &&
          {prettyBytes(job.data.total)}
          } +
          +
          + +
          +
        • )} +
        +
        ; +} diff --git a/src/mainview/routes/store/details.download.$source.$id.tsx b/src/mainview/routes/store/details.download.$source.$id.tsx new file mode 100644 index 0000000..2100442 --- /dev/null +++ b/src/mainview/routes/store/details.download.$source.$id.tsx @@ -0,0 +1,129 @@ +import { AutoFocus } from '@/mainview/components/AutoFocus'; +import DotsLoading from '@/mainview/components/backgrounds/dots'; +import { ContextList, DialogEntry } from '@/mainview/components/ContextDialog'; +import { StickyHeaderUI } from '@/mainview/components/Header'; +import { Button } from '@/mainview/components/options/Button'; +import Screenshots from '@/mainview/components/Screenshots'; +import SelectMenu from '@/mainview/components/SelectMenu'; +import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; +import { GlobalDialogContext } from '@/mainview/scripts/contexts'; +import { downloadLookupQuery } from '@/mainview/scripts/queries/romm'; +import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { HandleGoBack } from '@/mainview/scripts/utils'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; +import { Download } from 'lucide-react'; +import prettyBytes from 'pretty-bytes'; +import { useContext } from 'react'; + +export const Route = createFileRoute('/store/details/download/$source/$id')({ + component: RouteComponent, + pendingComponent: Loading, + async loader (ctx) + { + const data = await ctx.context.queryClient.fetchQuery(downloadLookupQuery(decodeURIComponent(ctx.params.source), decodeURIComponent(ctx.params.id))); + return { data }; + } +}); + +function Loading () +{ + const { ref, focusSelf } = useFocusable({ focusKey: 'download-details' }); + return <> + + + ; +} + +const imagesMap = new Set(['JPEG', 'PNG', 'Motion JPEG', 'Item Image']); +const videoFormat = new Set(['h.264']); +const downloadsBlacklist = new Set(['JPEG Thumb', 'Metadata', 'Thumbnail', 'Item Tile', 'Archive BitTorrent', ...videoFormat, ...imagesMap]); + +function Details (data: { onDownload: (focusKey: string) => void; }) +{ + const { data: download } = Route.useLoaderData(); + const screenshots = download.files.filter(f => f.format && imagesMap.has(f.format)).map(f => f.download_url); + if (screenshots.length <= 0 && download.cover_url) screenshots.push(download.cover_url); + return
        + +
        +
        +
        + {!!download.cover_url && } +
        +
        {download.name}
        +
        +
        {download.date?.toDateString()}
        +
        +
        {download.source}
        +
        +
        +
        +
        + +
        +
        +
        + {!!download.summary &&
        +
        +
        } +
        +
        Downloads
        +
          + {download.files.filter(f => f.format && !downloadsBlacklist.has(f.format)).map(f =>
        • + {f.id} + {!!f.size && prettyBytes(f.size)} +
        • )} +
        +
        + +
        +
        +
        ; +} + +function RouteComponent () +{ + const navigate = useNavigate(); + const router = useRouter(); + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'download-details', preferredChildFocusKey: 'download-btn' }); + const { data } = Route.useLoaderData(); + const globalDialog = useContext(GlobalDialogContext); + + useShortcuts(focusKey, () => [{ + label: "Return", + action: (e) => HandleGoBack(router, e), + button: GamePadButtonCode.B + }], [router]); + + return
        + + +
        globalDialog.openContext({ + content: f.format && !downloadsBlacklist.has(f.format)).map(f => + { + const option: DialogEntry = { + id: f.id, + content: f.id, + type: 'primary', + action (ctx) + { + navigate({ + to: '/game/add', search: { + gameLocation: f.download_url, + search: data.name, + step: 1 + } + }); + }, + }; + + return option; + })} /> + }, focusKey)} /> + + + + +
        ; +} diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 3383ea8..d8c8372 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useContext, useRef, useState } from "react"; import { useFocusable, @@ -11,7 +11,7 @@ import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; import { rommApi, systemApi } from "@/mainview/scripts/clientApi"; import { Button } from "@/mainview/components/options/Button"; import { ChevronDown, CircleFadingArrowUp, CloudUpload, Cpu, Download, Fullscreen, Gamepad2, Info, Monitor, Puzzle, Settings, Settings2, Terminal, Trash2, TriangleAlert, WandSparkles } from "lucide-react"; -import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog"; +import { ContextList, DialogEntry } from "@/mainview/components/ContextDialog"; import { RPC_URL } from "@/shared/constants"; import Screenshots from "@/mainview/components/Screenshots"; import { StickyHeaderUI } from "@/mainview/components/Header"; @@ -30,6 +30,7 @@ import { AutoFocus } from "@/mainview/components/AutoFocus"; import { FilterUI } from "@/mainview/components/Filters"; import Markdown from "react-markdown"; import { FrontEndEmulatorDetailed } from "@simeonradivoev/gameflow-sdk/shared"; +import { GlobalDialogContext } from "@/mainview/scripts/contexts"; export const Route = createFileRoute('/store/details/emulator/$id')({ component: RouteComponent, @@ -65,6 +66,7 @@ function TitleArea (data: { onUpdate: (source: string) => void; }) { + const globalDialog = useContext(GlobalDialogContext); const navigation = useNavigate(); const queryClient = useQueryClient(); const deleteMutation = useMutation({ @@ -253,14 +255,12 @@ function TitleArea (data: { installButtonContent = <>Unsupported; } - const { dialog: installOptionsDialog, setOpen } = useContextDialog("install-context-menu", { - content: - }); + const openOptionsDialog = (focusKey: string) => globalDialog.openContext({ content: }, focusKey); const handleOptionsOpen = () => { if (isInstalling || !data.emulator) return false; - setOpen(true, 'install-btn'); + openOptionsDialog('install-btn'); }; return
        @@ -294,10 +294,10 @@ function TitleArea (data: {
        {(data.emulator?.storeDownloadInfo?.hasUpdate || !data.emulator?.storeDownloadInfo) && installedFromStore && !!updateToVersion &&
        - +
        } {(!data.emulator?.bios || data.emulator.bios.length <= 0) && (data.emulator?.biosRequirement === 'required') && installedFromStore &&
        - +
        }
        - {installOptionsDialog}
        ; } diff --git a/src/mainview/routes/store/tab/download.tsx b/src/mainview/routes/store/tab/download.tsx new file mode 100644 index 0000000..062698a --- /dev/null +++ b/src/mainview/routes/store/tab/download.tsx @@ -0,0 +1,109 @@ +import DotsLoading from '@/mainview/components/backgrounds/dots'; +import LoadMoreButton from '@/mainview/components/LoadMoreButton'; +import { SideDownloadFilters } from '@/mainview/components/SideFilters'; +import { downloadLookupFiltersQuery, downloadsLookupQuery } from '@/mainview/scripts/queries/romm'; +import { scrollIntoViewHandler } from '@/mainview/scripts/utils'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { DownloadLookupEntry, DownloadsLookupFilter } from '@simeonradivoev/gameflow-sdk/shared'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { DownloadIcon, Eye, MessageCircle, Save, Star } from 'lucide-react'; +import prettyBytes from 'pretty-bytes'; +import { useSessionStorage } from 'usehooks-ts'; + +export const Route = createFileRoute('/store/tab/download')({ + component: RouteComponent, +}); + +function Download (data: { focusKey: string, match: DownloadLookupEntry; }) +{ + const navigate = useNavigate(); + const handleAction = () => navigate({ + to: '/store/details/download/$source/$id', params: { + source: encodeURIComponent(data.match.source), + id: encodeURIComponent(data.match.id) + } + }); + const { ref, focusKey } = useFocusable({ + focusKey: data.focusKey, + onFocus: (l, p, d) => scrollIntoViewHandler({ behavior: "smooth", block: "center", inline: "center" })(focusKey, ref.current, d), + onEnterPress: handleAction + }); + return
      • + {!!data.match.cover_url && } +
        +
        {data.match.name}
        +
        {data.match.date?.toDateString()}
        +
          + {!!data.match.size &&
        • {prettyBytes(data.match.size)}
        • } + {!!data.match.download_count &&
        • {data.match.download_count}
        • } + {!!data.match.view_count &&
        • {data.match.view_count}
        • } + {!!data.match.comment_count &&
        • {data.match.comment_count}
        • } + {!!data.match.rating &&
        • {data.match.rating}
        • } +
        +
        +
      • ; +} + +function Downloads (data: { + pages: { + data: DownloadLookupEntry[]; + totalCount: number; + nextPage: number; + }[]; + hasNextPage: boolean, + isFetchingNextPage: boolean, + isFetching: boolean, + fetchNextPage: () => void, + error: string | undefined; +}) +{ + const { ref, focusKey } = useFocusable({ focusKey: 'downloads-list' }); + return
          + + {data.pages.flatMap((page, p) => page.data.map((match, i) => ))} + {data.hasNextPage && + { + if (data.isFetchingNextPage || data.isFetching) + return; + data.fetchNextPage(); + }} />} + {!!data.error} + +
        ; +} + +function RouteComponent () +{ + const [search] = useSessionStorage(`${Route.to}-search`, undefined); + const [filter, setFilter] = useSessionStorage('store-download-lookup-filters', {}); + const { data, error, isPending, isFetching, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteQuery({ + ...downloadsLookupQuery({ ...filter, search }), + maxPages: 10, + refetchOnMount: false + }); + const { ref, focusKey } = useFocusable({ + focusKey: "main-area", + preferredChildFocusKey: "downloads-list" + }); + + const { data: lookupFilters } = useQuery(downloadLookupFiltersQuery); + + return
        + +
        + {isFetching && } + Results + {isPending ? : {data?.pages[0].totalCount}} +
        + {isPending && } + {data && } +
        + +
        + +
        +
        ; +} diff --git a/src/mainview/routes/store/tab/emulators.tsx b/src/mainview/routes/store/tab/emulators.tsx index 0d4ba0e..1fb831f 100644 --- a/src/mainview/routes/store/tab/emulators.tsx +++ b/src/mainview/routes/store/tab/emulators.tsx @@ -11,10 +11,15 @@ import { useQuery } from '@tanstack/react-query'; import { storeEmulatorsQuery } from '@queries/store'; import InvalidStoreError from '@/mainview/components/store/InvalidStoreError'; import { useSessionStorage } from 'usehooks-ts'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; export const Route = createFileRoute('/store/tab/emulators')({ component: RouteComponent, - errorComponent: InvalidStoreError + errorComponent: InvalidStoreError, + validateSearch: zodValidator(z.object({ + search: z.string().optional() + })) }); function RouteComponent () @@ -26,7 +31,11 @@ function RouteComponent () preferredChildFocusKey: focus }); const storeContext = useContext(StoreContext); - const { data: emulators } = useQuery({ ...storeEmulatorsQuery({ search }), retry: false, throwOnError: true }); + const { data: emulators } = useQuery({ + ...storeEmulatorsQuery({ search }), + retry: false, + throwOnError: true + }); useEffect(() => { @@ -62,6 +71,7 @@ function RouteComponent () /> )) ?? Array.from({ length: 10 }).map((_, i) =>
        )}
        + ; diff --git a/src/mainview/routes/store/tab/games.tsx b/src/mainview/routes/store/tab/games.tsx index 21e059f..9176c20 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -15,6 +15,7 @@ import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; import SideFilters from '@/mainview/components/SideFilters'; import { gameFiltersQuery } from '@/mainview/scripts/queries/romm'; +import { isUrl } from '@/shared/utils'; export const Route = createFileRoute('/store/tab/games')({ component: RouteComponent, @@ -68,7 +69,7 @@ function RouteComponent () Games
        -
        +
        {data.plugin.package.description}
        -
          {data.plugin.package.keywords.concat(...data.plugin.installed ? ["installed"] : []).map(k =>
        • {k}
        • )}
        +
          {data.plugin.package.keywords.concat(...data.plugin.installed ? ["installed"] : []).map((k, i) =>
        • {k}
        • )}
        • {data.plugin.package.publisher.username}
        • diff --git a/src/mainview/routes/store/tab/route.tsx b/src/mainview/routes/store/tab/route.tsx index 05b4c1e..81a5a01 100644 --- a/src/mainview/routes/store/tab/route.tsx +++ b/src/mainview/routes/store/tab/route.tsx @@ -14,7 +14,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useMatchRoute, useRouter } from '@tanstack/react-router'; import { createFileRoute, Outlet } from '@tanstack/react-router'; import { zodValidator } from '@tanstack/zod-adapter'; -import { Gamepad2, Home, Joystick, Puzzle } from 'lucide-react'; +import { DownloadCloud, Gamepad2, Home, Joystick, Puzzle } from 'lucide-react'; import { useRef } from 'react'; import { useSessionStorage } from 'usehooks-ts'; import z from 'zod'; @@ -97,6 +97,7 @@ function RouteComponent () home: { label: "Home", icon: , selected: useIsSettings(''), }, emulators: { label: "Emulators", icon: , selected: useIsSettings('emulators') }, games: { label: "Games", icon: , selected: useIsSettings('games') }, + download: { label: "Download", icon: , selected: useIsSettings('download') }, plugins: { label: "Plugins", icon: , selected: useIsSettings('plugins') } }; const [search, setSearch] = useSessionStorage(`${router.history.location.pathname}-search`, undefined); diff --git a/src/mainview/scripts/contexts.ts b/src/mainview/scripts/contexts.ts index 3b33a01..0c958b1 100644 --- a/src/mainview/scripts/contexts.ts +++ b/src/mainview/scripts/contexts.ts @@ -1,4 +1,4 @@ -import { SystemInfoType, Drive } from '@simeonradivoev/gameflow-sdk/shared'; +import { SystemInfoType, Drive, AppInfoContext } from '@simeonradivoev/gameflow-sdk/shared'; import { Direction, FocusDetails } from "@noriginmedia/norigin-spatial-navigation"; import { createContext } from "react"; import { Shortcut } from "./shortcuts"; @@ -45,6 +45,16 @@ export const ShortcutsContext = createContext({} as { export const SystemInfoContext = createContext({} as SystemInfoType | undefined); +export const AppContext = createContext({} as AppInfoContext); + +export const GlobalDialogContext = createContext({} as { + openContext: (options: { + content: any; + preferredChildFocusKey?: string; + onClose?: () => void; + }, focusKey: string) => void; +}); + export const GameDetailsContext = createContext<{ update: () => void; }>({} as any); \ No newline at end of file diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts index 63f8623..5c76d2b 100644 --- a/src/mainview/scripts/queries/romm.ts +++ b/src/mainview/scripts/queries/romm.ts @@ -1,7 +1,7 @@ import { DefaultRommStaleTime } from "@/shared/constants"; -import { GameListFilterType, RommLoginDataSchema, FrontEndId } from '@simeonradivoev/gameflow-sdk/shared'; +import { GameListFilterType, RommLoginDataSchema, FrontEndId, DownloadLookupEntry, DownloadsLookupFilter } from '@simeonradivoev/gameflow-sdk/shared'; import { rommApi, settingsApi } from "../clientApi"; -import { InvalidateQueryFilters, mutationOptions, QueryClient, QueryFilters, queryOptions } from "@tanstack/react-query"; +import { infiniteQueryOptions, InvalidateQueryFilters, mutationOptions, QueryClient, QueryFilters, queryOptions } from "@tanstack/react-query"; import z from "zod"; import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen"; @@ -293,4 +293,36 @@ export const addManualGameMutation = mutationOptions({ if (error) throw error; return data; } +}); + +export const downloadsLookupQuery = (filter: DownloadsLookupFilter) => infiniteQueryOptions<{ data: DownloadLookupEntry[], totalCount: number, nextPage: number; }>({ + initialPageParam: 1, + queryKey: ["downloads", filter], + getNextPageParam: (lastPage, pages) => lastPage.nextPage, + queryFn: async (params) => + { + const pageParam = params.pageParam as number; + const { data, error } = await rommApi.api.romm.downloads.lookup.get({ query: { ...filter, page: pageParam } }); + if (error) throw error; + return { data: data.matches, totalCount: data.totalCount, nextPage: pageParam + 1 }; + } +}); + +export const downloadLookupQuery = (source: string, id: string) => queryOptions({ + queryKey: ["downloads", source, id], + queryFn: async () => + { + const { data, error } = await rommApi.api.romm.download.lookup({ source: encodeURIComponent(source) })({ id: encodeURIComponent(id) }).get(); + if (error) throw error; + return data; + } +}); + +export const downloadLookupFiltersQuery = queryOptions({ + queryKey: ['game', 'filters'], queryFn: async () => + { + const { data, error } = await rommApi.api.romm.download.lookup.filters.get(); + if (error) throw error; + return data; + } }); \ No newline at end of file diff --git a/src/mainview/scripts/types.ts b/src/mainview/scripts/types.ts index 6ab944a..fbfcb36 100644 --- a/src/mainview/scripts/types.ts +++ b/src/mainview/scripts/types.ts @@ -12,7 +12,9 @@ export const FOCUS_KEYS = { EMULATOR_CARD: (id: string) => `EMULATOR_${id}`, GAME_SECTION: "GAME_SECTION", GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`, + GAME_LIST_CARD: (list: string, id: FrontEndId) => `LIST_${list}_GAME_${id.source}_${id.id}`, GAME_MATCH: (id: FrontEndId) => `GAME_${id.source}_${id.id}`, STATS_SECTION: "STATS_SECTION", - PLUGIN_ENTRY: (id: string) => `PLUGIN_${id}` + PLUGIN_ENTRY: (id: string) => `PLUGIN_${id}`, + DOWNLOAD_ENTRY: (source: string, id: string) => `DOWNLOAD_${source}_${id}` } as const; \ No newline at end of file diff --git a/src/mainview/types.d.ts b/src/mainview/types.d.ts index 1a1246e..2a0c101 100644 --- a/src/mainview/types.d.ts +++ b/src/mainview/types.d.ts @@ -50,6 +50,7 @@ declare interface GameMeta extends FocusParams { id: string, onSelect?: () => void, + onQuickAction?: () => void, title: string, subtitle?: any, previewUrls?: string | URL[]; diff --git a/src/packages/gameflow-sdk/hooks/app.ts b/src/packages/gameflow-sdk/hooks/app.ts index d8fef7a..1b73daa 100644 --- a/src/packages/gameflow-sdk/hooks/app.ts +++ b/src/packages/gameflow-sdk/hooks/app.ts @@ -1,7 +1,9 @@ +import { AsyncSeriesBailHook } from "tapable"; import AuthHooks from "./auth"; import EmulatorHooks from "./emulators"; import GameHooks from "./games"; import StoreHooks from "./store"; +import { DownloadFileEntry, ProgressStats } from "../shared"; export class GameflowHooks { @@ -9,4 +11,39 @@ export class GameflowHooks emulators = new EmulatorHooks(); auth = new AuthHooks(); store = new StoreHooks(); + /** Download the given files and return their final paths. */ + downloadFiles = new AsyncSeriesBailHook<[ctx: { + /** Unique ID of the download */ + id: string, + /** The root download path. Each file has it's own download sub path */ + downloadPath: string, + abortSignal?: AbortSignal, + /** Authentication needed for download. Should be put in the headers. */ + auth?: string, + /** The files to download */ + files: DownloadFileEntry[]; + /** Call it to update progress in the UI */ + updateProgress: (stats: ProgressStats) => void; + + }], { + /** What downloaded the files. Will be passed to {@link postDownloadFiles} files hook. */ + source: string, + /** The file paths ot the downloaded files. */ + files: string[]; + } | undefined>(['ctx']); + /** Called after {@link downloadFiles} has finished downloading. + * @returns The modified file paths. + */ + postDownloadFiles = new AsyncSeriesBailHook<[ctx: { + /** Who downloaded the files. Passed from the {@link downloadFiles} hook. */ + source: string; + /** Can be directories or files */ + files: string[]; + /** The root downloads folder. */ + downloadPath: string, + /** The sub path where the archive should be extracted to. This will be a sub path of `path_fs` */ + extract_path?: string; + /** This is the parent path for the extracted files. */ + path_fs?: string; + }], string[] | undefined>(['ctx']); } \ No newline at end of file diff --git a/src/packages/gameflow-sdk/hooks/emulators.ts b/src/packages/gameflow-sdk/hooks/emulators.ts index 768e56f..d852d06 100644 --- a/src/packages/gameflow-sdk/hooks/emulators.ts +++ b/src/packages/gameflow-sdk/hooks/emulators.ts @@ -5,6 +5,7 @@ import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; export default class EmulatorHooks { + /** Download emulator bios files */ fetchBiosDownload = new AsyncSeriesBailHook<[ctx: { emulator: string; systems: EmulatorSystem[]; @@ -15,7 +16,9 @@ export default class EmulatorHooks * Triggered when emulator is downloaded or updated */ emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContextType], { 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']); + /** Match emulators for a given system */ findEmulatorForSystem = new AsyncSeriesHook<[ctx: { system: string; emulators: string[]; }]>(['ctx']); constructor() diff --git a/src/packages/gameflow-sdk/hooks/games.ts b/src/packages/gameflow-sdk/hooks/games.ts index 9083e47..314b138 100644 --- a/src/packages/gameflow-sdk/hooks/games.ts +++ b/src/packages/gameflow-sdk/hooks/games.ts @@ -1,30 +1,32 @@ -import { EmulatorPackageType, GameListFilterType, CommandEntry, DownloadInfo, EmulatorSourceEntryType, EmulatorSupport, EmulatorSystem, FrontEndCollection, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeWithIds, FrontEndId, FrontEndPlatformType, GameLookup, SaveFileChange, SaveSlots } from '../shared'; +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 { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable'; export default class GameHooks { + /** Build commands the game can be launched with. */ buildLaunchCommands = new AsyncSeriesBailHook<[ctx: { source: string | null; sourceId: string | null; id: FrontEndId; systemSlug: string; gamePath: string | null, + /** The glob pattern for the main executable of the game */ mainGlob?: string | null, }], CommandEntry[] | Error | undefined>(['ctx']); /** override the launch command for an emulator - * @param ctx.autoValidCommands The auto generated command for example based on the ES-DE listing - * @param ctx.emulator The emulator ID if any - * @param ctx.game.source The source of the game - * @param ctx.game.sourceId The ID of the source. This could be for example the ROMM ID the game was * @returns The argument list to be used when running the emulator. * If no emulator bin in the command entry is found the actual command will be used as the bin. */ emulatorLaunch = new AsyncSeriesBailHook<[ctx: { + /** The auto generated command for example based on the ES-DE listing */ autoValidCommand: CommandEntry; + /** Don't actually launch just see if it can be launched */ dryRun: boolean, game: { + /** The source of the game */ source?: string; + /** The ID of the source. This could be for example the ROMM ID the game was */ sourceId?: string; id: FrontEndId; platformSlug?: string; @@ -41,34 +43,36 @@ export default class GameHooks }], EmulatorSupport | undefined, { emulator: string; }>(['ctx']); /** * Fetches and returns a list of games converted to frontend. - * @param ctx.localGameIds This is local game ids in the format '@' */ fetchGames = new AsyncSeriesHook<[ctx: { query: GameListFilterType; games: FrontEndGameTypeWithIds[]; }]>(['ctx']); + /** Return all filters the users can apply for a give source. */ fetchFilters = new AsyncSeriesHook<[ctx: { source?: string; filters: FrontEndFilterSets; }]>(['ctx']); + /** Get game metadata */ fetchGame = new AsyncSeriesBailHook<[ctx: { source: string; localGame?: FrontEndGameTypeDetailed; id: string; }], FrontEndGameTypeDetailed | undefined>(['ctx']); + /** Search for a given game based on the igdb id or ra id. */ searchGame = new AsyncSeriesBailHook<[ctx: { source: string; igdb_id?: number; ra_id?: number; }], FrontEndGameTypeDetailed | undefined>(['ctx']); - /** Get download file URLs - * @param ctx.checksum Check if file already exists using checksums - */ + /** Get download file URLs */ fetchDownloads = new AsyncSeriesBailHook<[ctx: { source: string; id: string; + /** If there are multiple downloads, use the one with same ID */ downloadId?: string; }], DownloadInfo[] | undefined>(['ctx']); + /** Get the paths to rom files. This is mainly used for emulator js. */ fetchRomFiles = new AsyncSeriesBailHook<[ctx: { source: string; id: string; @@ -86,6 +90,7 @@ export default class GameHooks source: string; id: string; }], FrontEndPlatformType | undefined>(['ctx']); + /** Lookup a given platform with a given slug or id. This may or may not exist. */ platformLookup = new AsyncSeriesBailHook<[ctx: { source?: string; id?: string; @@ -96,6 +101,23 @@ export default class GameHooks name?: string; family_name?: string; } | 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, 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, ctx: { source?: string, id?: string; @@ -104,6 +126,7 @@ export default class GameHooks fetchPlatforms = new AsyncSeriesHook<[ctx: { platforms: FrontEndPlatformType[]; }]>(['ctx']); + /** Called before the game is played. */ prePlay = new AsyncSeriesHook<[ctx: { source: string, id: string; @@ -115,20 +138,25 @@ export default class GameHooks }; }]>(["ctx"]); /** - * @param changedSaveFiles Auto detected changed files. This is mainly used to see what changed during gameplay - * @param validChangedSaveFiles This will be final valid changes to be saved using save integrations like rclone + * Called after the game process has finished. */ postPlay = new AsyncSeriesHook<[ctx: { source: string, id: string; saveFolderSlots?: SaveSlots; + /** Auto detected changed files. This is mainly used to see what changed during gameplay */ changedSaveFiles: { subPath: string, cwd: string; }[], + /** This will be final valid changes to be saved using save integrations like rclone */ validChangedSaveFiles: Record, + /** The command that was used to launch the game */ command: CommandEntry; gameInfo: { platformSlug?: string; }; }]>(["ctx"]); + /** Called after game install + * This includes game being downloaded and registered in the database. + */ postInstall = new AsyncSeriesHook<[ctx: { source: string, id: string; diff --git a/src/packages/gameflow-sdk/package.json b/src/packages/gameflow-sdk/package.json index 09354fe..4e4ce28 100644 --- a/src/packages/gameflow-sdk/package.json +++ b/src/packages/gameflow-sdk/package.json @@ -13,10 +13,6 @@ "peerDependencies": { "7zip-bin": "^5.2.0", "@auth/core": "^0.34.3", - "@elysiajs/cors": "^1.4.2", - "@elysiajs/eden": "^1.4.9", - "@jimp/wasm-webp": "^1.6.1", - "@phalcode/ts-igdb-client": "^1.0.26", "cheerio": "^1.2.0", "conf": "^15.1.0", "drizzle-orm": "^0.45.2", @@ -36,16 +32,12 @@ "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", - "systeminformation": "^5.31.5", "tapable": "^2.3.3", - "tough-cookie": "^6.0.1", - "tough-cookie-file-store": "^3.3.0", "unzip-stream": "^0.3.4", - "webview-bun": "^2.4.0", "zod": "^4.4.3" }, "keywords": [ "gameflow", "sdk" ] -} +} \ No newline at end of file diff --git a/src/packages/gameflow-sdk/shared.ts b/src/packages/gameflow-sdk/shared.ts index 147db36..96ddb01 100644 --- a/src/packages/gameflow-sdk/shared.ts +++ b/src/packages/gameflow-sdk/shared.ts @@ -250,6 +250,7 @@ export interface EmulatorSourceEntryType binPath: string; rootPath?: string; type: EmulatorSourceType; + /** Does the emulator exist in the file system */ exists: boolean; } @@ -489,6 +490,15 @@ export interface GameInstallProgress export type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted'; export type GameInstallProgressEvent = 'refresh'; +export interface FrontEndJob +{ + id: string; + data: any; + progress: number; + state?: string; + status: string; +} + export interface FrontendPlugin { name: string; @@ -622,10 +632,79 @@ export interface GameLookup }[]; } +export interface DownloadLookupEntry +{ + source: string; + id: string; + cover_url: string | null | undefined; + name: string; + summary: string | null | undefined; + size: number | null | undefined; + date: Date | null | undefined; + rating: number | null | undefined; + view_count: number | null | undefined; + download_count: number | null | undefined; + comment_count: number | null | undefined; +} + +export interface DownloadLookupDetailsFile +{ + id: string; + format: string | null | undefined; + mtime: Date | null | undefined; + size: number | null | undefined; + download_url: string; +} + +export interface DownloadLookupDetails +{ + source: string; + id: string; + cover_url: string | null | undefined; + name: string; + summary: string | null | undefined; + date: Date | null | undefined; + files: DownloadLookupDetailsFile[]; +} + export interface AutoSaveChange { subPath: string; cwd: string; } +export interface AppInfoContext +{ + activeTaskProgress: number | null; +} + export type SaveSlots = Record; + +/** Jobs that are downloading stuff can implement this data interface to show up in the downloads screen */ +export interface DownloadJobData extends Partial> +{ + preview_url?: string | null; + name?: string; +} + +export interface ProgressStats +{ + progress: number; + speed: number; + total: number; + downloaded: number; +} + +export interface DownloadsLookupFilter +{ + source?: string, + orderBy?: string, + search?: string; + sortDirection?: "desc" | "asc"; +} + +export interface DownloadsLookupFilterValues +{ + orderBy: string[], + source: string[]; +} \ No newline at end of file diff --git a/src/packages/gameflow-sdk/task-queue.ts b/src/packages/gameflow-sdk/task-queue.ts index b86aab6..9ed555e 100644 --- a/src/packages/gameflow-sdk/task-queue.ts +++ b/src/packages/gameflow-sdk/task-queue.ts @@ -18,14 +18,24 @@ export class TaskQueue }); } - public enqueue (id: string, job: T, throwOnError?: boolean): T extends IJob + public enqueue (id: string, job: T, options?: { throwOnCancel?: boolean; }): T extends IJob ? Promise : never { this.disposeSafeguard(); if (!this.queue || !this.events) throw new Error("Queue disposed"); - const context = new JobContext(id, this.events, job); + if (this.activeQueue.some(j => j.id === id)) throw new Error(`Job with ID ${id} already active`); + if (this.queue.some(j => j.id === id)) throw new Error(`Job with ${id} already queued`); + const context = new JobContext(id, this.events, job, options); 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.processQueue(); return context.promise.promise as any; @@ -35,7 +45,24 @@ export class TaskQueue { if (!this.queue) return Promise.resolve(); - const next = this.queue.filter(j => !j.job.group || !this.activeQueue.some(a => a.job.group === j.job.group)).map((job, i) => ({ i, job })); + let activeGroupsSet = new Set(this.activeQueue.filter(j => j.job.group).map(j => j.job.group)); + 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)); @@ -82,6 +109,14 @@ export class TaskQueue return job?.promise.promise ?? Promise.resolve(); } + 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 ( id: string, type: new (...args: any[]) => T @@ -99,6 +134,16 @@ export class TaskQueue return undefined as any; } + public getActiveJobs () + { + return this.activeQueue; + } + + public getQueuedJobs () + { + return this.queue; + } + public on (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void { this.events?.on(event, listener); @@ -170,6 +215,7 @@ export interface CompletedEvent extends BaseEvent export interface IJob { + /** What group does the job belong to. Grouped jobs can only have 1 active job per group */ group?: string; start (context: JobContext, TData, TState>): Promise; exposeData?(): TData; @@ -210,12 +256,14 @@ export class JobContext, TData, TState extends str private events: EventEmitter; private abortController: AbortController; private m_promise: PromiseWithResolvers; + private throwOnCancel: boolean; private readonly m_job: T; - constructor(id: string, events: EventEmitter, job: T) + constructor(id: string, events: EventEmitter, job: T, options?: { throwOnCancel?: boolean; }) { this.m_id = id; this.m_job = job; + this.throwOnCancel = options?.throwOnCancel ?? false; this.abortController = new AbortController(); this.abortController.signal.addEventListener('abort', () => { @@ -247,7 +295,13 @@ export class JobContext, TData, TState extends str { if (error.target instanceof AbortSignal) { - this.m_promise.resolve(undefined); + if (this.throwOnCancel) + { + this.m_promise.reject(this.abortSignal.reason); + } else + { + this.m_promise.resolve(undefined); + } } else { console.error(error); diff --git a/src/shared/types.schema.ts b/src/shared/types.schema.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/shared/types.ts b/src/shared/types.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 1e128cf..2b3623d 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -22,4 +22,12 @@ export async function delay (delay: number | Date, signal?: AbortSignal) } }); -}; \ No newline at end of file +}; + +const urlRegex = /^https?:\/\//; + +export function isUrl (value: string | undefined) +{ + if (!value) return false; + return urlRegex.test(value); +} \ No newline at end of file From 55939858842eed0bc328ea68de6cf4ca565fb8b6 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Fri, 15 May 2026 15:07:51 +0300 Subject: [PATCH 65/65] chore: Fixed tests --- .gitignore | 1 + src/bun/api/app.ts | 7 ++ src/bun/api/games/games.ts | 6 +- .../jobs/{update-store.ts => ensure-store.ts} | 27 +++--- src/bun/api/jobs/jobs.ts | 4 +- .../store.ts | 10 +-- src/bun/api/plugins/register-plugins.ts | 89 ++++++++++--------- src/bun/api/plugins/services.ts | 2 + src/bun/api/store/store.ts | 6 +- src/bun/utils.ts | 15 ++++ src/packages/gameflow-sdk/index.ts | 3 +- src/packages/gameflow-sdk/task-queue.ts | 29 ++++++ src/tests/preload.ts | 6 +- 13 files changed, 139 insertions(+), 66 deletions(-) rename src/bun/api/jobs/{update-store.ts => ensure-store.ts} (60%) diff --git a/.gitignore b/.gitignore index e7e5c74..880e27d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ downloads gameflow-deck.code-workspace .env.local src/tests/mock-roms/db.sqlite +src/tests/mock-roms/store src/tests/mock-config bin .config/flatpak/repo diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index 68ec287..4695726 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -116,6 +116,13 @@ export async function cleanup () cleannedUp = true; } +/** Reset the cleanup flags. This is mainly used by tests since they run the same app. */ +export async function resetCleanup () +{ + cleaningUp = false; + cleannedUp = false; +} + export async function reloadDatabase () { await ensureDir(config.get('downloadPath')); diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index f06f71f..d922b36 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -454,18 +454,18 @@ export default new Elysia() }, { params: z.object({ id: z.string(), source: z.string() }), }) - .post('/game/:source/:id/install', async ({ params: { id, source }, body: { downloadId } }) => + .post('/game/:source/:id/install', async ({ params: { id, source }, body }) => { if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob)) { - return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, { downloadId })); + return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, body)); } else { return status('Not Implemented'); } }, { params: z.object({ id: z.string(), source: z.string() }), - body: z.object({ downloadId: z.string().optional() }), + body: z.object({ downloadId: z.string().optional() }).optional(), response: z.any() }) .delete('/game/:source/:id/install', async ({ params: { id, source } }) => diff --git a/src/bun/api/jobs/update-store.ts b/src/bun/api/jobs/ensure-store.ts similarity index 60% rename from src/bun/api/jobs/update-store.ts rename to src/bun/api/jobs/ensure-store.ts index 697fb3a..bce028b 100644 --- a/src/bun/api/jobs/update-store.ts +++ b/src/bun/api/jobs/ensure-store.ts @@ -6,8 +6,9 @@ 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 UpdateStoreJob implements IJob +export default class EnsureStore implements IJob { static id = "update-store" as const; static dataSchema = z.never(); @@ -20,7 +21,7 @@ export default class UpdateStoreJob implements IJob this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0"; } - async start (context: JobContext) + async start (context: JobContext) { const storeFolder = getStoreRootFolder(); await ensureDir(storeFolder); @@ -32,17 +33,23 @@ export default class UpdateStoreJob implements IJob const storePackage = await Bun.file(path.join(storeFolder, "package.json")).json(); - if (!storePackage.dependencies?.[sdkPkg.name] || storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version) + if (IsPluginAllowed(sdkPkg.name)) { - let response = await runBunPackageCommand(["add", `${sdkPkg.name}@${sdkPkg.version}`, "--registry", PluginRegistry, '--omit', 'peer']); - console.log(response); - } + 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) + // 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 { - let response = await runBunPackageCommand(["add", '--exact', `${sdkPkg.name}@latest`, "--registry", PluginRegistry, '--omit', 'peer']); - console.log(response); + console.log("Ignoring SDK package"); } if (process.env.CUSTOM_STORE_PATH) return; diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index e7e20a2..5471e56 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -3,7 +3,7 @@ import z, { _ZodType } from "zod"; import { taskQueue } from "../app"; import { LoginJob } from "./login-job"; import TwitchLoginJob from "./twitch-login-job"; -import UpdateStoreJob from "./update-store"; +import EnsureStore from "./ensure-store"; import { EmulatorDownloadJob } from "./emulator-download-job"; import { getErrorMessage } from "@/bun/utils"; import { BaseEvent, IJob } from "@simeonradivoev/gameflow-sdk/task-queue"; @@ -184,7 +184,7 @@ export const jobs = new Elysia({ prefix: '/api/jobs' }) .use(registerJob(LaunchGameJob)) .use(registerJob(LoginJob)) .use(registerJob(TwitchLoginJob)) - .use(registerJob(UpdateStoreJob)) + .use(registerJob(EnsureStore)) .use(registerJob(BiosDownloadJob)) .use(registerJob(InstallJob)) .use(registerJob(ReloadPluginsJob)) diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts index 118eb11..90d1cbc 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts @@ -2,14 +2,14 @@ import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-s import desc from './package.json'; import path, { } from 'node:path'; import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; -import { Glob, pathToFileURL, which } from "bun"; +import { Glob, pathToFileURL, sleep, which } from "bun"; import { and, eq } from "drizzle-orm"; import * as emulatorSchema from '@schema/emulators'; import { config, emulatorsDb, taskQueue } from "@/bun/api/app"; import fs from "node:fs/promises"; import { getSourceGameDetailed } from "@/bun/api/games/services/utils"; -import UpdateStoreJob from "@/bun/api/jobs/update-store"; +import EnsureStore from "@/bun/api/jobs/ensure-store"; import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services"; import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@simeonradivoev/gameflow-sdk/shared"; @@ -20,7 +20,7 @@ import StreamZip from "node-stream-zip"; import { path7za } from "7zip-bin"; import Seven from 'node-7z'; -export default class RommIntegration implements PluginType +export default class StoreIntegration implements PluginType { eventsNames = [{ id: 'updateStore', title: "Update Store", description: "Update the Store Manifest", action: "Update" }]; @@ -29,7 +29,7 @@ export default class RommIntegration implements PluginType switch (e) { case 'updateStore': - await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); + await taskQueue.enqueue(EnsureStore.id, new EnsureStore()); return { reload: true }; } } @@ -38,7 +38,7 @@ export default class RommIntegration implements PluginType { console.log("Store Directory is ", getStoreFolder()); ctx.setProgress(0, "Updating Store"); - await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); + await taskQueue.enqueue(EnsureStore.id, new EnsureStore()); } async load (ctx: PluginLoadingContextType) diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts index 1275740..a4b5666 100644 --- a/src/bun/api/plugins/register-plugins.ts +++ b/src/bun/api/plugins/register-plugins.ts @@ -14,10 +14,12 @@ import rclone from './builtin/other/com.simeonradivoev.gameflow.rclone/package.j import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@simeonradivoev/gameflow-sdk"; import path from 'node:path'; import { getStoreRootFolder } from "../store/services/gamesService"; -import { getUpdates } from "./services"; +import { getUpdates, runBunPackageCommand } from "./services"; import { PluginSourceType } from "@simeonradivoev/gameflow-sdk/shared"; import { taskQueue } from "../app"; -import UpdateStoreJob from "../jobs/update-store"; +import EnsureStore from "../jobs/ensure-store"; +import { PluginRegistry } from "@/shared/constants"; +import { IsPluginAllowed } from "@/bun/utils"; type PluginEntry = PluginDescriptionType & { load: () => Promise; }; @@ -58,15 +60,9 @@ export async function unregisterPlugin (id: string, pluginManager: PluginManager export async function registerPlugin (plugin: PluginEntry, source: PluginSourceType, pluginManager: PluginManager) { - if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(plugin.name)) + if (!IsPluginAllowed(plugin.name)) { - console.log("Skipping", plugin.name, "missing in whitelist"); - return; - } - - if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(plugin.name)) - { - console.log("Skipping", plugin.name, "found in whitelist"); + console.log("Skipping", plugin.name, "plugin not allowed"); return; } @@ -101,39 +97,52 @@ export default async function register (pluginManager: PluginManager) await Promise.all(plugins.map(p => registerPlugin(p, 'builtin', pluginManager))); - const storePackageFilePath = path.join(getStoreRootFolder(), 'package.json'); - if (!await Bun.file(storePackageFilePath).exists()) + if (IsPluginAllowed('@simeonradivoev/gameflow-store')) { - console.log("Store is missing. Updating it."); - await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); - console.log("Store Updated"); - } - const storePackage = await Bun.file(storePackageFilePath).json(); - - if (storePackage?.dependencies) - { - const storePlugins = await Promise.all(Object.keys(storePackage.dependencies).filter(p => !blacklist.has(p)).map(async p => + const storePackageFilePath = path.join(getStoreRootFolder(), 'package.json'); + if (!await Bun.file(storePackageFilePath).exists()) { - return getPlugin(p, pluginManager); - })); - - console.log("Checking for outdated packages"); - const outdated = await getUpdates(); - - const validPlugins = storePlugins.filter(p => !!p); - - if (outdated) - { - validPlugins.forEach(p => - { - const newVersion = outdated[p.name]; - if (newVersion) - { - console.log("Plugin", p.name, "has update", p.version, "=>", newVersion); - } - }); + console.log("Store is missing. Updating it."); + await taskQueue.enqueue(EnsureStore.id, new EnsureStore()); + console.log("Store Updated"); } + const storePackage = await Bun.file(storePackageFilePath).json(); - await Promise.all(validPlugins.map(p => registerPlugin(p, 'store', pluginManager))); + if (storePackage?.dependencies) + { + const storePlugins = await Promise.all(Object.keys(storePackage.dependencies).filter(p => !blacklist.has(p)).map(async p => + { + return getPlugin(p, pluginManager); + })); + + console.log("Checking for outdated packages"); + const outdated = await getUpdates(); + + const validPlugins = storePlugins.filter(p => !!p); + + if (outdated) + { + for await (const plugin of validPlugins) + { + const newVersion = outdated[plugin.name]; + if (newVersion) + { + console.log("Plugin", plugin.name, "has update", plugin.version, "=>", newVersion); + } + + if (plugin.autoUpdate) + { + console.log("Auto Updating Plugin", plugin.name); + let response = await runBunPackageCommand(["add", `${plugin.name}@${newVersion}`, "--registry", PluginRegistry, '--omit', 'peer']); + console.log(response); + } + } + } + + await Promise.all(validPlugins.map(p => registerPlugin(p, 'store', pluginManager))); + } + } else + { + console.log('Skipping Store Packages'); } } \ No newline at end of file diff --git a/src/bun/api/plugins/services.ts b/src/bun/api/plugins/services.ts index 9452b7e..0ba40b3 100644 --- a/src/bun/api/plugins/services.ts +++ b/src/bun/api/plugins/services.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import { getStoreRootFolder } from '../store/services/gamesService'; import { PluginDescriptionType } from '@simeonradivoev/gameflow-sdk'; import { run } from 'npm-check-updates'; +import { existsSync } from 'node:fs'; export function canDisable (description: PluginDescriptionType) { @@ -15,6 +16,7 @@ export function canDisable (description: PluginDescriptionType) export async function getUpdates () { + if (!existsSync(getStoreRootFolder())) return {}; const updated = await run({ packageManager: 'bun', peer: true, cwd: getStoreRootFolder(), jsonUpgraded: true, reject: ['@simeonradivoev/gameflow-sdk'] }); return updated as Record; } diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 39d5630..7706699 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -188,16 +188,16 @@ export const store = new Elysia({ prefix: '/api/store' }) emulator.integrations = integrations; return emulator; }, { params: z.object({ id: z.string() }) }) - .post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) => + .post('/install/emulator/:id/:source', async ({ params: { source, id }, body }) => { if (taskQueue.hasActiveOfType(EmulatorDownloadJob)) { return status("Conflict", "Installation already running"); } - const job = new EmulatorDownloadJob(id, source, { isUpdate }); + const job = new EmulatorDownloadJob(id, source, body); return taskQueue.enqueue(EmulatorDownloadJob.id, job); }, { - body: z.object({ isUpdate: z.boolean().optional() }) + body: z.object({ isUpdate: z.boolean().optional() }).optional() }) .delete('/emulator/:id', async ({ params: { id } }) => { diff --git a/src/bun/utils.ts b/src/bun/utils.ts index a3868e4..6fbc630 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -185,4 +185,19 @@ export function getAppVersion () 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; } \ No newline at end of file diff --git a/src/packages/gameflow-sdk/index.ts b/src/packages/gameflow-sdk/index.ts index 4149891..c78c757 100644 --- a/src/packages/gameflow-sdk/index.ts +++ b/src/packages/gameflow-sdk/index.ts @@ -41,7 +41,8 @@ export const PluginDescriptionSchema = z.object({ peerDependencies: z.record(z.string(), z.string()).optional(), category: z.string().default("other"), main: z.string().describe("The main entry. It must export a default class implementing PluginType"), - canDisable: z.boolean().default(true).optional().describe("Can the plugin be disabled or enabled by the user") + 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({ diff --git a/src/packages/gameflow-sdk/task-queue.ts b/src/packages/gameflow-sdk/task-queue.ts index 9ed555e..e86cebc 100644 --- a/src/packages/gameflow-sdk/task-queue.ts +++ b/src/packages/gameflow-sdk/task-queue.ts @@ -91,6 +91,11 @@ export class TaskQueue return this.activeQueue.length > 0; } + public hasQueued () + { + return this.queue && this.queue.length > 0; + } + public hasActiveOfType (type: any) { for (const entry of this.activeQueue) @@ -109,6 +114,30 @@ export class TaskQueue 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) diff --git a/src/tests/preload.ts b/src/tests/preload.ts index e9a17b9..40cf49d 100644 --- a/src/tests/preload.ts +++ b/src/tests/preload.ts @@ -1,18 +1,20 @@ import { beforeAll, beforeEach, afterEach } from 'bun:test'; import { resolve } from 'node:path'; import * as app from '@/bun/api/app'; -import { remove } from 'fs-extra'; +import { ensureDir, remove } from 'fs-extra'; export async function LoadApp () { console.log("Loading App"); await app.load(); + await app.taskQueue.waitForAll(); } export async function CleanupApp () { console.log("Cleaning Up App"); await app.cleanup(); + await app.resetCleanup(); } beforeAll(async () => @@ -20,7 +22,7 @@ beforeAll(async () => process.env.CUSTOM_STORE_PATH = resolve('./src/tests/mock-store'); process.env.CONFIG_CWD = resolve('./src/tests/mock-config'); process.env.DEFAULT_DOWNLOAD_PATH = resolve('./src/tests/mock-roms'); - process.env.PLUGIN_BLACKLIST = 'com.simeonradivoev.gameflow.rclone'; + process.env.PLUGIN_BLACKLIST = 'com.simeonradivoev.gameflow.rclone,@simeonradivoev/gameflow-store,com.simeonradivoev.gameflow.romm,com.simeonradivoev.gameflow.igdb,@simeonradivoev/gameflow-sdk'; }); async function FileCleanup ()
    Version {systemInfo?.data?.version}
    Update - { - hasUpdate && hasUpdate.hasUpdate > 0 ? - : - - } - {} -
    Agent {navigator.userAgent}