Compare commits
No commits in common. "master" and "v1.4.0" have entirely different histories.
203 changed files with 2261 additions and 6886 deletions
|
|
@ -4,4 +4,4 @@ Comment=GameFlow Deck
|
||||||
Exec=gameflow
|
Exec=gameflow
|
||||||
Icon=com.simeonradivoev.gameflow-deck
|
Icon=com.simeonradivoev.gameflow-deck
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Game;
|
Categories=Games;
|
||||||
BIN
.github/screenshots/3d screenshot.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/3d screenshot.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/3nhuKCK6E3.jpg
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/3nhuKCK6E3.jpg
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
BIN
.github/screenshots/3nhuKCK6E3.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/3nhuKCK6E3.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/6wz3gW8c2h.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/6wz3gW8c2h.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/EWPHmIBEE5.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/EWPHmIBEE5.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/GL7SkQbHIY.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/GL7SkQbHIY.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/MMeJxl4IXr.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/MMeJxl4IXr.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/Pkazk0RufB.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/Pkazk0RufB.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/iunZbvYEGp-ezgif.com-optimize.gif
(Stored with Git LFS)
vendored
BIN
.github/screenshots/iunZbvYEGp-ezgif.com-optimize.gif
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/mockup-1777308293568.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/mockup-1777308293568.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/rBY2mgTLy0.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/rBY2mgTLy0.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/xNj7scPEDQ.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/xNj7scPEDQ.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/yObFD2LySH.jpg
(Stored with Git LFS)
vendored
BIN
.github/screenshots/yObFD2LySH.jpg
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/zEQxtzhPGx.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/zEQxtzhPGx.png
(Stored with Git LFS)
vendored
Binary file not shown.
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -28,7 +28,6 @@ downloads
|
||||||
gameflow-deck.code-workspace
|
gameflow-deck.code-workspace
|
||||||
.env.local
|
.env.local
|
||||||
src/tests/mock-roms/db.sqlite
|
src/tests/mock-roms/db.sqlite
|
||||||
src/tests/mock-roms/store
|
|
||||||
src/tests/mock-config
|
src/tests/mock-config
|
||||||
bin
|
bin
|
||||||
.config/flatpak/repo
|
.config/flatpak/repo
|
||||||
|
|
|
||||||
18
.versionrc
18
.versionrc
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"packageFiles": [
|
|
||||||
{
|
|
||||||
"filename": "package.json",
|
|
||||||
"type": "json"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"bumpFiles": [
|
|
||||||
{
|
|
||||||
"filename": "package.json",
|
|
||||||
"type": "json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "src/packages/gameflow-sdk/package.json",
|
|
||||||
"type": "json"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -1,25 +1,6 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||||
|
|
||||||
## [1.6.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.5.0...v1.6.0) (2026-05-09)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* Implemented public plugin system accessible from the store. ([38cb752](https://github.com/simeonradivoev/gameflow-deck/commit/38cb7525527b5ad4f6eb284cdad0001fd87eaf7e))
|
|
||||||
|
|
||||||
## [1.5.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.4.0...v1.5.0) (2026-05-05)
|
|
||||||
|
|
||||||
|
|
||||||
### 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)
|
## [1.4.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.3.0...v1.4.0) (2026-04-26)
|
||||||
|
|
||||||
|
|
|
||||||
64
README.md
64
README.md
|
|
@ -4,78 +4,53 @@ 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.
|
Focused on building a simple user experience and intuitive UI as a curated community driven experience.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> This app is actively in development, it is constantly changing and improving.
|
> 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.
|
> 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://discord.gg/R9KakhY67d)
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Integrations
|
### Integrations
|
||||||
|
|
||||||
- **[ROMM](https://github.com/rommapp/romm)** - download, sync and update roms and platforms.
|
- **[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.
|
- **[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.
|
- **[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)
|
- **[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
|
### 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.
|
- **Emulators** - (WIP) Download and install emulators and automatically configure them
|
||||||
- **Free Curated Games** - Download free curated games and homebrew roms without ever leaving the app
|
- **Free Curated Games** - Download free curreted games and homebrew roms without ever leaving the app
|
||||||
|
|
||||||
### Others
|
### Others
|
||||||
|
|
||||||
- **Cross Platform** - Can run on multiple platforms. Built with web technologies and bun backend.
|
- **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.
|
- **Steam Deck Support** - Extensively tested with the steam deck. It can use flatpak installed browsers.
|
||||||
- **Lightweight** - It uses the window's webview as a frontend, reducing build size and ram usage.
|
- **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 Windows it first uses webview2 then your browser
|
||||||
- On linux it does ship with NW.js to work on most distros. A big one is the steam deck missing WebKitGTK.
|
- On linux it uses WebKitGTK or a browser even from flatpak
|
||||||
- Not tested on Mac yet
|
- 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 Downloads** - Downloads roms from ROMM automatically
|
||||||
- **Automatic Emulator Discovery** - Using the configs of the excellent ES-DE to discover installed emulators and launch roms. You can bring your existing configurations.
|
- **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.
|
- 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.
|
||||||
- **Cloud/Device Save Sync** - For supported games and emulators.
|
- **Cloud/Device Save Sync** - For supported games and emulators.
|
||||||
- **Dark and Light** - Dark and light themes for your preference.
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<img src=".github/screenshots/3d screenshot.png" title="Home Screen Showing games sorted by latest activity" width="25%"></img>
|
<img src=".github/screenshots/Pkazk0RufB.png" width="25%"></img>
|
||||||
<img src=".github/screenshots/3nhuKCK6E3.png" title="Game Details." width="25%"></img>
|
<img src=".github/screenshots/3nhuKCK6E3.jpg" width="25%"></img>
|
||||||
<img src=".github/screenshots/yObFD2LySH.jpg" title="Home Screen in dark mode" width="25%"></img>
|
<img src=".github/screenshots/yObFD2LySH.jpg" width="25%"></img>
|
||||||
<img src=".github/screenshots/GL7SkQbHIY.png" title="Plugins Page" width="25%"></img>
|
<img src=".github/screenshots/GL7SkQbHIY.png" width="25%"></img>
|
||||||
<img src=".github/screenshots/CpBLzTNM6N.png" title="Store Home Page" width="25%"></img>
|
<img src=".github/screenshots/CpBLzTNM6N.png" width="25%"></img>
|
||||||
<img src=".github/screenshots/xNj7scPEDQ.png" title="Store emulator details" width="25%"></img>
|
<img src=".github/screenshots/xNj7scPEDQ.png" width="25%"></img>
|
||||||
<img src=".github/screenshots/zEQxtzhPGx.png" title="Store Emulators in dark mode" width="25%"></img>
|
|
||||||
<img src=".github/screenshots/MMeJxl4IXr.png" title="Store Emulators in light mode" width="25%"></img>
|
|
||||||
<img src=".github/screenshots/EWPHmIBEE5.png" title="Platform Grouping List" width="25%"></img>
|
|
||||||
<img src=".github/screenshots/iunZbvYEGp-ezgif.com-optimize.gif" title="Platform Grouping List" width="76%"></img>
|
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
|
|
||||||
- I want to build an open and free platform where you can play and discover new hidden gems from the past.
|
- 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 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 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 curated 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 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.
|
|
||||||
|
|
||||||
## 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
|
## Development
|
||||||
|
|
||||||
|
|
@ -106,17 +81,6 @@ But given it's an existing setup, say from emudeck it won't matter much as it's
|
||||||
- `bun run openapi-ts` generated the openapi client calls from romm's API
|
- `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:windows` builds an package to be distributed on windows
|
||||||
- `bun run package:linux` builds an AppImage to be distributed on linux
|
- `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.
|
|
||||||
|
|
||||||
## Plugins
|
|
||||||
|
|
||||||
To create a plugin create a new npm project and install:
|
|
||||||
`bun i --peer @simeonradivoev/gameflow-sdk`
|
|
||||||
|
|
||||||
Then publish the package to npmjs with a tag `gameflow-plugin` to appear in the UI.
|
|
||||||
For more info check the [SDK README](./scripts/sdk/README.md)
|
|
||||||
|
|
||||||
### Tech Stack
|
### Tech Stack
|
||||||
|
|
||||||
|
|
|
||||||
108
package.json
108
package.json
|
|
@ -1,12 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "com.simeonradivoev.gameflow-deck",
|
"name": "com.simeonradivoev.gameflow-deck",
|
||||||
"displayName": "Gameflow",
|
"displayName": "Gameflow",
|
||||||
"author": {
|
"version": "1.4.0",
|
||||||
"name": "Simeon Radivoev",
|
|
||||||
"email": "work@simeonradivoev.com",
|
|
||||||
"url": "https://simeonradivoev.com"
|
|
||||||
},
|
|
||||||
"version": "1.6.0",
|
|
||||||
"description": "Game Launcher",
|
"description": "Game Launcher",
|
||||||
"icon": "./src/mainview/assets/icon.svg",
|
"icon": "./src/mainview/assets/icon.svg",
|
||||||
"main": "./src/bun/index.ts",
|
"main": "./src/bun/index.ts",
|
||||||
|
|
@ -18,9 +13,6 @@
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.3.9",
|
"packageManager": "bun@1.3.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
|
||||||
"./src/packages/gameflow-sdk"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_ENV=development bun run build:vite && conc 'bun run ./scripts/dev.ts'",
|
"dev": "NODE_ENV=development bun run build:vite && conc 'bun run ./scripts/dev.ts'",
|
||||||
"dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run ./scripts/dev.ts'",
|
"dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run ./scripts/dev.ts'",
|
||||||
|
|
@ -30,7 +22,6 @@
|
||||||
"build:prod:vite": "NODE_ENV=production bun run build:vite",
|
"build:prod:vite": "NODE_ENV=production bun run build:vite",
|
||||||
"build:dev:vite": "NODE_ENV=development bun run build:vite",
|
"build:dev:vite": "NODE_ENV=development bun run build:vite",
|
||||||
"build": "bun run build:vite && bun run ./scripts/package-bun.ts",
|
"build": "bun run build:vite && bun run ./scripts/package-bun.ts",
|
||||||
"build:non-compiled": "bun run build:vite && NON_COMPILED=true bun run ./scripts/package-bun.ts",
|
|
||||||
"build:prod": "NODE_ENV=production bun run build",
|
"build:prod": "NODE_ENV=production bun run build",
|
||||||
"build:linux": "TARGET=bun-linux-x64 bun run build",
|
"build:linux": "TARGET=bun-linux-x64 bun run build",
|
||||||
"openapi-ts": "bun run ./scripts/romm/openapi-ts.ts",
|
"openapi-ts": "bun run ./scripts/romm/openapi-ts.ts",
|
||||||
|
|
@ -46,71 +37,64 @@
|
||||||
"flatpak:install": "bun run flatpak:build && flatpak --user install --reinstall \"$PWD/.config/flatpak/repo\" com.simeonradivoev.gameflow-deck",
|
"flatpak:install": "bun run flatpak:build && flatpak --user install --reinstall \"$PWD/.config/flatpak/repo\" com.simeonradivoev.gameflow-deck",
|
||||||
"build:prod:appimage": "bun run build:prod && bun run ./scripts/build-appimage.ts",
|
"build:prod:appimage": "bun run build:prod && bun run ./scripts/build-appimage.ts",
|
||||||
"build:dev:appimage": "bun run build && bun run ./scripts/build-appimage.ts",
|
"build:dev:appimage": "bun run build && bun run ./scripts/build-appimage.ts",
|
||||||
"version:generate": "commit-and-tag-version --sign",
|
"version:generate": "standard-version --sign",
|
||||||
"package:Linux": "bun run build:prod:appimage",
|
"package:Linux": "bun run build:prod:appimage",
|
||||||
"package:Windows": "bun run build:prod",
|
"package:Windows": "bun run build:prod",
|
||||||
"download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium",
|
"download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium",
|
||||||
"download:nwjs": "bun scripts/download-nw.ts",
|
"download:nwjs": "bun scripts/download-nw.ts",
|
||||||
"build:audiosprites": "bun ./scripts/generate-audio-sprites.ts",
|
"build:audiosprites": "bun ./scripts/generate-audio-sprites.ts",
|
||||||
"tsc": "tsc --noEmit",
|
"tsc": "tsc --noEmit"
|
||||||
"publish:sdk": "bun publish --cwd ./src/packages/gameflow-sdk/ --access public"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"@auth/core": "^0.34.3",
|
"@auth/core": "^0.34.3",
|
||||||
"@elysiajs/cors": "^1.4.2",
|
"@elysiajs/cors": "^1.4.1",
|
||||||
"@elysiajs/eden": "^1.4.9",
|
"@elysiajs/eden": "^1.4.6",
|
||||||
"@jimp/wasm-webp": "^1.6.1",
|
"@jimp/wasm-webp": "^1.6.0",
|
||||||
"@phalcode/ts-igdb-client": "^1.0.26",
|
"@phalcode/ts-igdb-client": "^1.0.26",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"conf": "^15.1.0",
|
"conf": "^15.0.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.1",
|
||||||
"elysia": "^1.4.28",
|
"elysia": "^1.4.22",
|
||||||
"fs-extra": "^11.3.5",
|
"fs-extra": "^11.3.3",
|
||||||
"get-folder-size": "^5.0.0",
|
"get-folder-size": "^5.0.0",
|
||||||
"ini": "^6.0.0",
|
"ini": "^6.0.0",
|
||||||
"jimp": "^1.6.1",
|
"jimp": "^1.6.0",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"node-7z": "^3.0.0",
|
"node-7z": "^3.0.0",
|
||||||
"node-disk-info": "^1.3.0",
|
"node-disk-info": "^1.3.0",
|
||||||
"node-downloader-helper": "^2.1.11",
|
"node-downloader-helper": "^2.1.10",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
"node-unrar-js": "^2.0.2",
|
"node-unrar-js": "^2.0.2",
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.0",
|
||||||
"p-queue": "^9.2.0",
|
"p-queue": "^9.1.2",
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"slugify": "^1.6.9",
|
"slugify": "^1.6.9",
|
||||||
"smol-toml": "^1.6.1",
|
"smol-toml": "^1.6.1",
|
||||||
"systeminformation": "^5.31.6",
|
"systeminformation": "^5.31.5",
|
||||||
"tapable": "^2.3.3",
|
"tapable": "^2.3.0",
|
||||||
"tough-cookie": "^6.0.1",
|
"tough-cookie": "^6.0.0",
|
||||||
"tough-cookie-file-store": "^3.3.0",
|
"tough-cookie-file-store": "^3.3.0",
|
||||||
"unzip-stream": "^0.3.4",
|
"unzip-stream": "^0.3.4",
|
||||||
"webview-bun": "^2.4.0",
|
"webview-bun": "^2.4.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.3.6"
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"@tanstack/router-generator": {
|
|
||||||
"zod": "^3.23.8"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ap0nia/eden": "^1.6.1",
|
"@ap0nia/eden": "^1.0.0-next.22",
|
||||||
"@ap0nia/eden-tanstack-query": "^1.0.0-next.22",
|
"@ap0nia/eden-tanstack-query": "^1.0.0-next.22",
|
||||||
"@emulatorjs/emulatorjs": "^4.2.3",
|
"@emulatorjs/emulatorjs": "^4.2.3",
|
||||||
"@hey-api/openapi-ts": "^0.91.1",
|
"@hey-api/openapi-ts": "^0.91.0",
|
||||||
"@noriginmedia/norigin-spatial-navigation": "^3.1.0",
|
"@noriginmedia/norigin-spatial-navigation": "^2.3.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/react-form": "^1.32.0",
|
"@tanstack/react-form": "^1.28.0",
|
||||||
"@tanstack/react-query": "^5.100.10",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"@tanstack/react-query-devtools": "^5.100.10",
|
"@tanstack/react-query-devtools": "^5.91.3",
|
||||||
"@tanstack/react-query-persist-client": "^5.100.10",
|
"@tanstack/react-router": "^1.157.16",
|
||||||
"@tanstack/react-router": "^1.169.2",
|
"@tanstack/react-router-devtools": "^1.154.12",
|
||||||
"@tanstack/react-router-devtools": "^1.166.13",
|
"@tanstack/react-router-ssr-query": "^1.157.17",
|
||||||
"@tanstack/react-router-ssr-query": "^1.166.12",
|
"@tanstack/router-plugin": "^1.157.16",
|
||||||
"@tanstack/router-plugin": "^1.167.35",
|
"@tanstack/zod-adapter": "^1.162.4",
|
||||||
"@tanstack/zod-adapter": "^1.166.9",
|
|
||||||
"@types/adm-zip": "^0.5.8",
|
"@types/adm-zip": "^0.5.8",
|
||||||
"@types/audiosprite": "^0.7.3",
|
"@types/audiosprite": "^0.7.3",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
|
@ -121,42 +105,42 @@
|
||||||
"@types/mustache": "^4.2.6",
|
"@types/mustache": "^4.2.6",
|
||||||
"@types/node-7z": "^2.1.11",
|
"@types/node-7z": "^2.1.11",
|
||||||
"@types/rclone.js": "^0.6.3",
|
"@types/rclone.js": "^0.6.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.9",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/unzip-stream": "^0.3.4",
|
"@types/unzip-stream": "^0.3.4",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.16",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
"app-builder-bin": "^5.0.0-alpha.13",
|
"app-builder-bin": "^5.0.0-alpha.13",
|
||||||
"audiosprite": "^0.7.2",
|
"audiosprite": "^0.7.2",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"commit-and-tag-version": "^12.7.3",
|
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"daisyui": "^5.5.19",
|
"daisyui": "^5.5.14",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.9",
|
||||||
|
"dts-bundle-generator": "^9.5.1",
|
||||||
"eden-tanstack-query": "^0.0.9",
|
"eden-tanstack-query": "^0.0.9",
|
||||||
"howler": "^2.2.4",
|
"howler": "^2.2.4",
|
||||||
"idb-keyval": "^6.2.2",
|
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"pretty-bytes": "^7.1.0",
|
"pretty-bytes": "^7.1.0",
|
||||||
"pretty-ms": "^9.3.0",
|
"pretty-ms": "^9.3.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.4",
|
||||||
"react-error-boundary": "^6.1.1",
|
"react-error-boundary": "^6.1.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-qr-code": "^2.0.21",
|
"react-qr-code": "^2.0.18",
|
||||||
"sass-embedded": "^1.99.0",
|
"sass-embedded": "^1.97.3",
|
||||||
"tailwind-merge": "^3.6.0",
|
"standard-version": "^9.5.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
"vite": "^7.3.3",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-svg-icons-ng": "^1.9.1",
|
"vite-plugin-svg-icons-ng": "^1.5.2",
|
||||||
"vite-static-assets-plugin": "^1.2.2",
|
"vite-static-assets-plugin": "^1.2.2",
|
||||||
"vite-tsconfig-paths": "^6.1.1"
|
"vite-tsconfig-paths": "^6.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ function spawnServer ()
|
||||||
stderr: 'inherit',
|
stderr: 'inherit',
|
||||||
stdin: 'inherit',
|
stdin: 'inherit',
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
killSignal: 'SIGKILL',
|
killSignal: 'SIGUSR1',
|
||||||
ipc (message, subprocess, handle)
|
ipc (message, subprocess, handle)
|
||||||
{
|
{
|
||||||
if (message === 'focus')
|
if (message === 'focus')
|
||||||
|
|
@ -85,20 +85,13 @@ watch("./src/bun", { recursive: true }, (event, filename) =>
|
||||||
restart();
|
restart();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch("./src/packages", { recursive: true }, (event, filename) =>
|
|
||||||
{
|
|
||||||
if (restarting) return;
|
|
||||||
console.log(`[watcher] ${event}: ${filename} — restarting...`);
|
|
||||||
restart();
|
|
||||||
});
|
|
||||||
|
|
||||||
let server: Bun.Subprocess | undefined = spawnServer();
|
let server: Bun.Subprocess | undefined = spawnServer();
|
||||||
if (!process.env.HEADLESS)
|
if (!process.env.HEADLESS)
|
||||||
{
|
{
|
||||||
spawnBrowser()?.then(async e =>
|
spawnBrowser()?.then(async e =>
|
||||||
{
|
{
|
||||||
if (!server) return;
|
if (!server) return;
|
||||||
abortController.abort();
|
server.kill("SIGUSR1");
|
||||||
await server.exited;
|
await server.exited;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import audioSprite from 'audiosprite';
|
import audioSprite from 'audiosprite';
|
||||||
|
import { $ } from 'bun';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { soundMap } from '../src/mainview/scripts/audio/audioConstants';
|
import { soundMap } from '../src/mainview/scripts/audio/audioConstants';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
|
|
||||||
|
const lockfile = Bun.argv[2] ?? "bun.lockb";
|
||||||
const output = Bun.argv[3] ?? ".config/flatpak/sources.gen.json";
|
const output = Bun.argv[3] ?? ".config/flatpak/sources.gen.json";
|
||||||
|
|
||||||
const text = await $`bun ./bun.lockb --hash: 0000000000000000-0000000000000000-0000000000000000-0000000000000000`.text();
|
const text = await $`bun ./bun.lockb --hash: 0000000000000000-0000000000000000-0000000000000000-0000000000000000`.text();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
|
||||||
import { TaskQueue, AppEventMap } from "@simeonradivoev/gameflow-sdk";
|
import { TaskQueue } from "./task-queue";
|
||||||
import { Database } from "bun:sqlite";
|
import { Database } from "bun:sqlite";
|
||||||
import { CookieJar } from 'tough-cookie';
|
import { CookieJar } from 'tough-cookie';
|
||||||
import FileCookieStore from 'tough-cookie-file-store';
|
import FileCookieStore from 'tough-cookie-file-store';
|
||||||
|
|
@ -8,7 +8,7 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
|
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
import Conf from "conf";
|
import Conf from "conf";
|
||||||
import projectPackage from '~/package.json';
|
import projectPackage from '~/package.json';
|
||||||
import { SettingsType, SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared';
|
import { SettingsSchema, SettingsType } from "@shared/constants";
|
||||||
import { client } from "@clients/romm/client.gen";
|
import { client } from "@clients/romm/client.gen";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import cacheSchema from "@schema/cache";
|
import cacheSchema from "@schema/cache";
|
||||||
|
|
@ -116,13 +116,6 @@ export async function cleanup ()
|
||||||
cleannedUp = true;
|
cleannedUp = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset the cleanup flags. This is mainly used by tests since they run the same app. */
|
|
||||||
export async function resetCleanup ()
|
|
||||||
{
|
|
||||||
cleaningUp = false;
|
|
||||||
cleannedUp = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reloadDatabase ()
|
export async function reloadDatabase ()
|
||||||
{
|
{
|
||||||
await ensureDir(config.get('downloadPath'));
|
await ensureDir(config.get('downloadPath'));
|
||||||
|
|
|
||||||
|
|
@ -138,12 +138,6 @@ export async function checkLoginAndRefreshTwitch ()
|
||||||
|
|
||||||
export async function checkLoginAndRefreshRomm ()
|
export async function checkLoginAndRefreshRomm ()
|
||||||
{
|
{
|
||||||
//TODO: move to plugin logic
|
|
||||||
if (plugins.plugins['com.simeonradivoev.gameflow.romm'].config?.get('clientApiToken'))
|
|
||||||
{
|
|
||||||
return { hasLogin: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' });
|
const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' });
|
||||||
if (!access_token)
|
if (!access_token)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { cache } from "./app";
|
import { cache } from "./app";
|
||||||
import cacheSchema from "@schema/cache";
|
import cacheSchema from "@schema/cache";
|
||||||
import { GithubReleaseSchema } from '@simeonradivoev/gameflow-sdk/shared';
|
import { GithubReleaseSchema } from "@/shared/constants";
|
||||||
import PQueue from "p-queue";
|
import PQueue from "p-queue";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
|
|
@ -11,8 +11,7 @@ export const CACHE_KEYS = {
|
||||||
STORE_GAME_MANIFEST: 'store-game-manifest'
|
STORE_GAME_MANIFEST: 'store-game-manifest'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// we aggressively cache github data so burst of calls is fine.
|
export const githubRequestQueue = new PQueue({ intervalCap: 10, interval: 1000 * 60 * 10, strict: true });
|
||||||
export const githubRequestQueue = new PQueue({ intervalCap: 60, interval: 1000 * 60 * 60, strict: true });
|
|
||||||
|
|
||||||
export async function getOrCached<T> (key: string, getter: (lastValue: T | undefined) => Promise<T>, options?: { expireMs?: number; force?: boolean; }): Promise<T>
|
export async function getOrCached<T> (key: string, getter: (lastValue: T | undefined) => Promise<T>, options?: { expireMs?: number; force?: boolean; }): Promise<T>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ export class GamepadWindows implements IGamepadBackend
|
||||||
private index: number;
|
private index: number;
|
||||||
private buffer = new ArrayBuffer(16);
|
private buffer = new ArrayBuffer(16);
|
||||||
private view = new DataView(this.buffer);
|
private view = new DataView(this.buffer);
|
||||||
|
private prevButtons = 0;
|
||||||
private currButtons = 0;
|
private currButtons = 0;
|
||||||
|
|
||||||
constructor(index = 0) { this.index = index; }
|
constructor(index = 0) { this.index = index; }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import si from 'systeminformation';
|
import si from 'systeminformation';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { Drive } from '@simeonradivoev/gameflow-sdk/shared';
|
|
||||||
|
|
||||||
async function getAccess (path: string)
|
async function getAccess (path: string)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import z from "zod";
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { config, events, plugins } from "../app";
|
import { config, events, plugins } from "../app";
|
||||||
import { getLocalGame, updateLocalLastPlayed } from "../games/services/statusService";
|
import { getLocalGame, updateLocalLastPlayed } from "../games/services/statusService";
|
||||||
import { SaveFileChange } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
// TODO: use the retroarch cores based on ES-DE
|
// TODO: use the retroarch cores based on ES-DE
|
||||||
export const cores: Record<string, string> = {
|
export const cores: Record<string, string> = {
|
||||||
|
|
@ -84,7 +83,7 @@ export default new Elysia({ prefix: '/emulatorjs' })
|
||||||
await plugins.hooks.games.postPlay.promise({
|
await plugins.hooks.games.postPlay.promise({
|
||||||
source,
|
source,
|
||||||
id,
|
id,
|
||||||
saveFolderSlots: { 'emulatorjs': { cwd: path.join(config.get('downloadPath'), "saves", "EMULATORJS") } },
|
saveFolderPath: path.join(config.get('downloadPath'), "saves", "EMULATORJS"),
|
||||||
gameInfo: { platformSlug: localGame?.platform.slug },
|
gameInfo: { platformSlug: localGame?.platform.slug },
|
||||||
changedSaveFiles: [],
|
changedSaveFiles: [],
|
||||||
validChangedSaveFiles: changedSaveFiles,
|
validChangedSaveFiles: changedSaveFiles,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { plugins } from "../app";
|
import { plugins } from "../app";
|
||||||
import { FrontEndCollection } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export default new Elysia()
|
export default new Elysia()
|
||||||
.get('/collections', async () =>
|
.get('/collections', async () =>
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@ import { and, desc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm"
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { SERVER_URL } from "@shared/constants";
|
import { GameListFilterSchema, SERVER_URL } from "@shared/constants";
|
||||||
import { CommandEntry, DownloadLookupEntry, DownloadsLookupFilterValues, GameListFilterSchema } from '@simeonradivoev/gameflow-sdk/shared';
|
|
||||||
import { InstallJob } from "../jobs/install-job";
|
import { InstallJob } from "../jobs/install-job";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
|
import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
|
||||||
import buildStatusResponse, { customUpdate, fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService";
|
import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService";
|
||||||
import { errorToResponse } from "elysia/adapter/bun/handler";
|
import { errorToResponse } from "elysia/adapter/bun/handler";
|
||||||
import { launchCommand } from "./services/launchGameService";
|
import { launchCommand } from "./services/launchGameService";
|
||||||
import { getErrorMessage, SeededRandom } from "@/bun/utils";
|
import { getErrorMessage, SeededRandom } from "@/bun/utils";
|
||||||
|
|
@ -22,8 +21,6 @@ import { host } from "@/bun/utils/host";
|
||||||
import { LaunchGameJob } from "../jobs/launch-game-job";
|
import { LaunchGameJob } from "../jobs/launch-game-job";
|
||||||
import { cores } from "../emulatorjs/emulatorjs";
|
import { cores } from "../emulatorjs/emulatorjs";
|
||||||
import { findEmulatorPluginIntegration } from "../store/services/emulatorsService";
|
import { findEmulatorPluginIntegration } from "../store/services/emulatorsService";
|
||||||
import { ImportJob } from "../jobs/import-job";
|
|
||||||
import { EmulatorSourceEntryType, EmulatorSystem, FrontEndFilterLists, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailedEmulator, FrontEndGameTypeWithIds, FrontEndId, GameLookup } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
// A custom jimp that supports webp
|
// A custom jimp that supports webp
|
||||||
const Jimp = createJimp({
|
const Jimp = createJimp({
|
||||||
|
|
@ -454,18 +451,18 @@ export default new Elysia()
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ id: z.string(), source: z.string() }),
|
params: z.object({ id: z.string(), source: z.string() }),
|
||||||
})
|
})
|
||||||
.post('/game/:source/:id/install', async ({ params: { id, source }, body }) =>
|
.post('/game/:source/:id/install', async ({ params: { id, source }, body: { downloadId } }) =>
|
||||||
{
|
{
|
||||||
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
|
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
|
||||||
{
|
{
|
||||||
return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, body));
|
return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, { downloadId }));
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
return status('Not Implemented');
|
return status('Not Implemented');
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ id: z.string(), source: z.string() }),
|
params: z.object({ id: z.string(), source: z.string() }),
|
||||||
body: z.object({ downloadId: z.string().optional() }).optional(),
|
body: z.object({ downloadId: z.string().optional() }),
|
||||||
response: z.any()
|
response: z.any()
|
||||||
})
|
})
|
||||||
.delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
.delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
||||||
|
|
@ -494,43 +491,7 @@ export default new Elysia()
|
||||||
{
|
{
|
||||||
return update(source, id);
|
return update(source, id);
|
||||||
})
|
})
|
||||||
.post('/game/:source/:id/update', async ({ params: { id, source }, body }) =>
|
.post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) =>
|
||||||
{
|
|
||||||
return customUpdate(source, id, body.source, body.id);
|
|
||||||
}, { body: z.object({ source: z.string(), id: z.string() }) })
|
|
||||||
.get('/lookup', async ({ query: { search } }) =>
|
|
||||||
{
|
|
||||||
const matches = new Map<string, GameLookup[]>();
|
|
||||||
await plugins.hooks.games.gameLookup.promise(matches, { search });
|
|
||||||
return { hadMatchers: matches.size > 0, matches: Array.from(matches.values()).flatMap(m => m) };
|
|
||||||
}, {
|
|
||||||
query: z.object({ search: z.string() })
|
|
||||||
})
|
|
||||||
.get('/lookup/:source/:id', async ({ params: { source, id } }) =>
|
|
||||||
{
|
|
||||||
const matches = new Map<string, GameLookup[]>();
|
|
||||||
await plugins.hooks.games.gameLookup.promise(matches, { source, id });
|
|
||||||
return Array.from(matches.values()).flatMap(m => m);
|
|
||||||
})
|
|
||||||
.get('/game/:source/:id/commands', async ({ params: { id, source }, set }) =>
|
|
||||||
{
|
|
||||||
const validCommands = await getValidLaunchCommandsForGame(source, id);
|
|
||||||
if (validCommands instanceof Error)
|
|
||||||
{
|
|
||||||
return errorToResponse(validCommands, set);
|
|
||||||
}
|
|
||||||
return validCommands as {
|
|
||||||
commands: CommandEntry[];
|
|
||||||
gameId: FrontEndId;
|
|
||||||
source?: string;
|
|
||||||
sourceId?: string;
|
|
||||||
} | undefined;
|
|
||||||
}, {
|
|
||||||
response: z.object({
|
|
||||||
commands: z.custom<CommandEntry>().array()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.post('/game/:source/:id/play', async ({ params: { id, source }, body: { command_id }, set }) =>
|
|
||||||
{
|
{
|
||||||
const validCommands = await getValidLaunchCommandsForGame(source, id);
|
const validCommands = await getValidLaunchCommandsForGame(source, id);
|
||||||
if (validCommands)
|
if (validCommands)
|
||||||
|
|
@ -543,7 +504,7 @@ export default new Elysia()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const validCommand = command_id ? validCommands.commands.find(c => c.id === command_id) : validCommands.commands[0];
|
const validCommand = body.command_id ? validCommands.commands.find(c => c.id === body.command_id) : validCommands.commands[0];
|
||||||
if (validCommand)
|
if (validCommand)
|
||||||
{
|
{
|
||||||
// launch command waits for the game to exit, we don't want that.
|
// launch command waits for the game to exit, we don't want that.
|
||||||
|
|
@ -690,57 +651,4 @@ export default new Elysia()
|
||||||
rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank);
|
rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank);
|
||||||
|
|
||||||
return rankedGames.map(g => g.game).slice(0, 10);
|
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.query({ source, id }), new ImportJob(source, id, gamePath, platformId), {
|
|
||||||
throwOnCancel: true
|
|
||||||
|
|
||||||
});
|
|
||||||
return { source: 'local', id: data.localId };
|
|
||||||
}, {
|
|
||||||
body: z.object({
|
|
||||||
source: z.string(),
|
|
||||||
id: z.string(),
|
|
||||||
gamePath: z.string(),
|
|
||||||
platformId: z.number()
|
|
||||||
})
|
|
||||||
}).get('/downloads/lookup', async ({ query: { search, page, rows, orderBy, sortDirection, source } }) =>
|
|
||||||
{
|
|
||||||
const matches = new Map<string, { count: number, items: DownloadLookupEntry[]; }>();
|
|
||||||
await plugins.hooks.games.downloadsLookup.promise(matches, { search, page, rows, orderBy, sortDirection, source });
|
|
||||||
const allValues = Array.from(matches.values());
|
|
||||||
return { hadMatchers: matches.size > 0, matches: allValues.flatMap(m => m.items), totalCount: allValues.reduce((p, c) => p + c.count, 0) };
|
|
||||||
}, {
|
|
||||||
query: z.object({
|
|
||||||
search: z.string().optional(),
|
|
||||||
page: z.coerce.number().optional(),
|
|
||||||
rows: z.coerce.number().optional(),
|
|
||||||
orderBy: z.string().optional(),
|
|
||||||
sortDirection: z.literal(["desc", "asc"]).optional(),
|
|
||||||
source: z.string().optional()
|
|
||||||
})
|
|
||||||
}).get('/download/lookup/:source/:id', async ({ params: { source, id } }) =>
|
|
||||||
{
|
|
||||||
const match = await plugins.hooks.games.downloadLookup.promise({ source, id });
|
|
||||||
if (!match) return status("Not Found");
|
|
||||||
return match;
|
|
||||||
}).get('/download/file/info', async ({ query: { file_url } }) =>
|
|
||||||
{
|
|
||||||
const response = await fetch(file_url, { method: "HEAD" });
|
|
||||||
if (!response.ok) return status('Internal Server Error', response.statusText);
|
|
||||||
return { size: Number(response.headers.get('content-length')), content_type: response.headers.get('content-type') };
|
|
||||||
}, {
|
|
||||||
query: z.object({ file_url: z.url() })
|
|
||||||
}).get('/download/lookup/filters', async () =>
|
|
||||||
{
|
|
||||||
const filters: DownloadsLookupFilterValues = {
|
|
||||||
source: [],
|
|
||||||
orderBy: []
|
|
||||||
};
|
|
||||||
|
|
||||||
await plugins.hooks.games.downloadsLookupFilters.promise({ filters });
|
|
||||||
|
|
||||||
return filters;
|
|
||||||
});
|
});
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { and, count, eq, getTableColumns, not, notExists, or } from "drizzle-orm";
|
import { and, count, eq, getTableColumns, not, notExists } from "drizzle-orm";
|
||||||
import { config, db, plugins } from "../app";
|
import { db, plugins } from "../app";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import { findPlatform } from "./services/utils";
|
|
||||||
import { FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export default new Elysia()
|
export default new Elysia()
|
||||||
.get('/platforms', async () =>
|
.get('/platforms', async () =>
|
||||||
|
|
@ -93,8 +91,7 @@ export default new Elysia()
|
||||||
{
|
{
|
||||||
const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id });
|
const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id });
|
||||||
if (!remotePlatform) return status("Not Found");
|
if (!remotePlatform) return status("Not Found");
|
||||||
const local = await db.query.platforms.findFirst({ where: or(eq(schema.platforms.slug, remotePlatform?.slug), eq(schema.platforms.name, remotePlatform?.name)) });
|
return remotePlatform;
|
||||||
return { ...remotePlatform, hasLocal: !!local };
|
|
||||||
}
|
}
|
||||||
}, { params: z.object({ source: z.string(), id: z.string() }) })
|
}, { params: z.object({ source: z.string(), id: z.string() }) })
|
||||||
.get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
|
.get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
|
||||||
|
|
@ -117,31 +114,15 @@ export default new Elysia()
|
||||||
}
|
}
|
||||||
return status(200, coverBlob.cover);
|
return status(200, coverBlob.cover);
|
||||||
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) })
|
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) })
|
||||||
.post('/platform/:source/:id/update', async ({ params: { source, id } }) =>
|
.post('/platform/local/:id/update', async ({ params: { id } }) =>
|
||||||
{
|
{
|
||||||
const where: any[] = [];
|
const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, Number(id)) });
|
||||||
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");
|
if (!localPlatform) return status("Not Found");
|
||||||
|
|
||||||
const platformLookup = await plugins.hooks.games.platformLookup.promise({
|
const platformLookup = await plugins.hooks.games.platformLookup.promise({
|
||||||
slug: localPlatform.slug
|
slug: localPlatform.slug
|
||||||
});
|
});
|
||||||
let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${localPlatform.slug}.svg`);
|
let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${localPlatform.slug}.svg`);
|
||||||
if (!platformCover.ok && platformLookup?.url_logo)
|
if (!platformCover.ok && platformLookup?.url_logo)
|
||||||
{
|
{
|
||||||
platformCover = await fetch(platformLookup.url_logo);
|
platformCover = await fetch(platformLookup.url_logo);
|
||||||
|
|
@ -163,23 +144,4 @@ export default new Elysia()
|
||||||
.where(eq(schema.games.platform_id, Number(id)))
|
.where(eq(schema.games.platform_id, Number(id)))
|
||||||
))).returning();
|
))).returning();
|
||||||
if (deleted.length <= 0) return status("Not Found");
|
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."
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
@ -6,7 +6,6 @@ import { config, taskQueue } from '../../app';
|
||||||
import { LaunchGameJob } from '../../jobs/launch-game-job';
|
import { LaunchGameJob } from '../../jobs/launch-game-job';
|
||||||
import { getStoreEmulatorPackage } from '../../store/services/gamesService';
|
import { getStoreEmulatorPackage } from '../../store/services/gamesService';
|
||||||
import { getOrCachedScoopPackage } from '../../store/services/emulatorsService';
|
import { getOrCachedScoopPackage } from '../../store/services/emulatorsService';
|
||||||
import { CommandEntry, EmulatorSourceEntryType, FrontEndId } from '@simeonradivoev/gameflow-sdk/shared';
|
|
||||||
|
|
||||||
export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string)
|
export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,12 @@ import { getErrorMessage } from "@/bun/utils";
|
||||||
import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils";
|
import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils";
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import Elysia from "elysia";
|
import Elysia from "elysia";
|
||||||
import z from "zod";
|
import z, { string } from "zod";
|
||||||
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
|
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
|
||||||
import { LaunchGameJob } from "../../jobs/launch-game-job";
|
import { LaunchGameJob } from "../../jobs/launch-game-job";
|
||||||
import * as appSchema from "@schema/app";
|
import * as appSchema from "@schema/app";
|
||||||
import { RPC_URL } from "@/shared/constants";
|
import { DownloadSourceSchema, RPC_URL } from "@/shared/constants";
|
||||||
import { DownloadSourceSchema } from '@simeonradivoev/gameflow-sdk/shared';
|
|
||||||
import { host } from "@/bun/utils/host";
|
import { host } from "@/bun/utils/host";
|
||||||
import { CommandEntry, FrontEndId, GameLookup, GameStatusType, LocalDownloadFileEntry } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export class CommandSearchError extends Error
|
export class CommandSearchError extends Error
|
||||||
{
|
{
|
||||||
|
|
@ -43,64 +41,6 @@ export async function getLocalGame (source: string, id: string)
|
||||||
return localGame;
|
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 matchesMap = new Map<string, GameLookup[]>();
|
|
||||||
await plugins.hooks.games.gameLookup.promise(matchesMap, { source: destination, id: destinationId });
|
|
||||||
const matches = matchesMap.values().next().value;
|
|
||||||
if (!matches || 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<ArrayBuffer> | 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)
|
export async function update (source: string, id: string)
|
||||||
{
|
{
|
||||||
const localGame = await getLocalGame(source, id);
|
const localGame = await getLocalGame(source, id);
|
||||||
|
|
@ -116,15 +56,10 @@ export async function update (source: string, id: string)
|
||||||
const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)];
|
const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)];
|
||||||
if (paths_screenshots.length <= 0 && sourceGame.igdb_id)
|
if (paths_screenshots.length <= 0 && sourceGame.igdb_id)
|
||||||
{
|
{
|
||||||
const matches = new Map<string, GameLookup[]>();
|
const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id) });
|
||||||
await plugins.hooks.games.gameLookup.promise(matches, { source: 'igdb', id: String(sourceGame.igdb_id) });
|
if (igdbLookup)
|
||||||
if (matches.size > 0)
|
|
||||||
{
|
{
|
||||||
const firstMatches = matches.values().next().value;
|
paths_screenshots.push(...igdbLookup.screenshotUrls);
|
||||||
if (firstMatches && firstMatches.length > 0)
|
|
||||||
{
|
|
||||||
paths_screenshots.push(...firstMatches[0].screenshotUrls);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,31 +184,7 @@ export async function getValidLaunchCommandsForGame (source: string, id: string)
|
||||||
commands: commands.filter(c => c.valid),
|
commands: commands.filter(c => c.valid),
|
||||||
gameId: { id: String(localGame.id), source: 'local' },
|
gameId: { id: String(localGame.id), source: 'local' },
|
||||||
source: localGame.source ?? source,
|
source: localGame.source ?? source,
|
||||||
sourceId: localGame.source_id ? String(localGame.source_id) : id,
|
sourceId: String(localGame.source_id) ?? id,
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`);
|
|
||||||
}
|
|
||||||
} else if (source === 'emulator')
|
|
||||||
{
|
|
||||||
const commands = await plugins.hooks.games.buildLaunchCommands.promise({
|
|
||||||
source,
|
|
||||||
sourceId: id,
|
|
||||||
id: { source: source, id: id },
|
|
||||||
systemSlug: "",
|
|
||||||
gamePath: null
|
|
||||||
});
|
|
||||||
|
|
||||||
if (commands instanceof Error || !commands) return commands;
|
|
||||||
|
|
||||||
const validCommand = commands.find(c => c.valid);
|
|
||||||
if (validCommand)
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
commands: commands.filter(c => c.valid),
|
|
||||||
gameId: { id, source }
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,23 @@ import getFolderSize from "get-folder-size";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { config, db, emulatorsDb, plugins } from "../../app";
|
import { config, db, emulatorsDb, plugins } from "../../app";
|
||||||
import { and, eq, or } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import { RPC_URL } from "@shared/constants";
|
import { RPC_URL, StoreGameType } from "@shared/constants";
|
||||||
import { hashFile } from "@/bun/utils";
|
import { hashFile } from "@/bun/utils";
|
||||||
import { host } from "@/bun/utils/host";
|
import { host } from "@/bun/utils/host";
|
||||||
import * as emulatorSchema from "@schema/emulators";
|
import secrets from "../../secrets";
|
||||||
import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export async function calculateSize (installPath: string | null)
|
export async function calculateSize (installPath: string | null)
|
||||||
{
|
{
|
||||||
if (!installPath) return null;
|
if (!installPath) return null;
|
||||||
const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath);
|
return (await getFolderSize(path.join(config.get('downloadPath'), installPath))).size;
|
||||||
return (await getFolderSize(finalPath)).size;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkInstalled (installPath: string | null)
|
export async function checkInstalled (installPath: string | null)
|
||||||
{
|
{
|
||||||
if (!installPath) return false;
|
if (!installPath) return false;
|
||||||
const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath);
|
return fs.exists(path.join(config.get('downloadPath'), installPath));
|
||||||
return fs.exists(finalPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScreenshotLocalGameMatch (id: string, source: string)
|
export function getScreenshotLocalGameMatch (id: string, source: string)
|
||||||
|
|
@ -174,333 +171,4 @@ export async function checkFiles (files: DownloadFileEntry[], isArchive: boolean
|
||||||
}
|
}
|
||||||
return { ...f, exists: false, matches: false } satisfies LocalDownloadFileEntry;
|
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<ArrayBufferLike> | 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 = new Map<string, GameLookup[]>();
|
|
||||||
await plugins.hooks.games.gameLookup.promise(matches, { source: 'igdb', id: String(info.igdb_id) });
|
|
||||||
info.screenshotUrls.push(...(matches.values().next().value?.[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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadGame (ctx: {
|
|
||||||
downloads: DownloadFileEntry[],
|
|
||||||
auth?: string,
|
|
||||||
id: string,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
setProgress?: (progress: number, state: "download" | "extract", info: Partial<Omit<ProgressStats, 'progress'>>) => void,
|
|
||||||
extract_path?: string;
|
|
||||||
path_fs?: string;
|
|
||||||
|
|
||||||
}): Promise<string[] | undefined>
|
|
||||||
{
|
|
||||||
const downloadedFiles = await plugins.hooks.downloadFiles.promise({
|
|
||||||
id: ctx.id,
|
|
||||||
auth: ctx.auth,
|
|
||||||
files: ctx.downloads,
|
|
||||||
downloadPath: config.get('downloadPath'),
|
|
||||||
abortSignal: ctx.abortSignal,
|
|
||||||
updateProgress: (stats) => ctx.setProgress?.(stats.progress, 'download', stats)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!downloadedFiles)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalFiles = await plugins.hooks.postDownloadFiles.promise({
|
|
||||||
files: downloadedFiles.files,
|
|
||||||
source: downloadedFiles.source,
|
|
||||||
extract_path: ctx.extract_path,
|
|
||||||
downloadPath: config.get('downloadPath'),
|
|
||||||
path_fs: ctx.path_fs
|
|
||||||
}) ?? downloadedFiles.files;
|
|
||||||
|
|
||||||
return finalFiles;
|
|
||||||
}
|
}
|
||||||
12
src/bun/api/hooks/app.ts
Normal file
12
src/bun/api/hooks/app.ts
Normal file
|
|
@ -0,0 +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();
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
|
|
||||||
import { AsyncSeriesHook } from "tapable";
|
import { AsyncSeriesHook } from "tapable";
|
||||||
import { DownloadFileEntry } from "../shared";
|
|
||||||
|
|
||||||
export default class AuthHooks
|
export class AuthHooks
|
||||||
{
|
{
|
||||||
loginComplete = new AsyncSeriesHook<[ctx: {
|
loginComplete = new AsyncSeriesHook<[ctx: {
|
||||||
service: string;
|
service: string;
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
|
import { EmulatorDownloadInfoType, EmulatorPackageType } from "@/shared/constants";
|
||||||
import { EmulatorPostInstallContextType } from "../index";
|
|
||||||
import { DownloadFileEntry, EmulatorSourceEntryType, EmulatorSystem } from "../shared";
|
|
||||||
import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable";
|
import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable";
|
||||||
|
|
||||||
export default class EmulatorHooks
|
interface EmulatorPostInstallContext
|
||||||
|
{
|
||||||
|
emulator: string;
|
||||||
|
emulatorPackage?: EmulatorPackageType;
|
||||||
|
path: string;
|
||||||
|
update: boolean;
|
||||||
|
info: EmulatorDownloadInfoType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmulatorHooks
|
||||||
{
|
{
|
||||||
/** Download emulator bios files */
|
|
||||||
fetchBiosDownload = new AsyncSeriesBailHook<[ctx: {
|
fetchBiosDownload = new AsyncSeriesBailHook<[ctx: {
|
||||||
emulator: string;
|
emulator: string;
|
||||||
systems: EmulatorSystem[];
|
systems: EmulatorSystem[];
|
||||||
|
|
@ -15,10 +21,8 @@ export default class EmulatorHooks
|
||||||
/**
|
/**
|
||||||
* Triggered when emulator is downloaded or updated
|
* Triggered when emulator is downloaded or updated
|
||||||
*/
|
*/
|
||||||
emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContextType], { emulator: string; }>(['ctx']);
|
emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContext], { emulator: string; }>(['ctx']);
|
||||||
/** Find locations of emulators on the system. Be it already installed ones or ones downloaded by the store. */
|
|
||||||
findEmulatorSource = new AsyncSeriesHook<[ctx: { emulator: string; sources: EmulatorSourceEntryType[]; }]>(['ctx']);
|
findEmulatorSource = new AsyncSeriesHook<[ctx: { emulator: string; sources: EmulatorSourceEntryType[]; }]>(['ctx']);
|
||||||
/** Match emulators for a given system */
|
|
||||||
findEmulatorForSystem = new AsyncSeriesHook<[ctx: { system: string; emulators: string[]; }]>(['ctx']);
|
findEmulatorForSystem = new AsyncSeriesHook<[ctx: { system: string; emulators: string[]; }]>(['ctx']);
|
||||||
|
|
||||||
constructor()
|
constructor()
|
||||||
|
|
@ -28,7 +32,7 @@ export default class EmulatorHooks
|
||||||
{
|
{
|
||||||
return {
|
return {
|
||||||
...tap,
|
...tap,
|
||||||
fn: async (ctx: EmulatorPostInstallContextType, ...rest: any[]) =>
|
fn: async (ctx: EmulatorPostInstallContext, ...rest: any[]) =>
|
||||||
{
|
{
|
||||||
if (ctx.emulator === tap.emulator)
|
if (ctx.emulator === tap.emulator)
|
||||||
{
|
{
|
||||||
|
|
@ -1,32 +1,29 @@
|
||||||
|
import { EmulatorPackageType, GameListFilterType } from '@/shared/constants';
|
||||||
import { EmulatorPackageType, GameListFilterType, CommandEntry, DownloadInfo, EmulatorSourceEntryType, EmulatorSupport, EmulatorSystem, FrontEndCollection, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeWithIds, FrontEndId, FrontEndPlatformType, GameLookup, SaveFileChange, SaveSlots, DownloadLookupEntry, DownloadLookupDetails, DownloadsLookupFilterValues, DownloadsLookupFilter } from '../shared';
|
|
||||||
import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable';
|
import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable';
|
||||||
|
|
||||||
export default class GameHooks
|
export class GameHooks
|
||||||
{
|
{
|
||||||
/** Build commands the game can be launched with. */
|
|
||||||
buildLaunchCommands = new AsyncSeriesBailHook<[ctx: {
|
buildLaunchCommands = new AsyncSeriesBailHook<[ctx: {
|
||||||
source: string | null;
|
source: string | null;
|
||||||
sourceId: string | null;
|
sourceId: string | null;
|
||||||
id: FrontEndId;
|
id: FrontEndId;
|
||||||
systemSlug: string;
|
systemSlug: string;
|
||||||
gamePath: string | null,
|
gamePath: string | null,
|
||||||
/** The glob pattern for the main executable of the game */
|
|
||||||
mainGlob?: string | null,
|
mainGlob?: string | null,
|
||||||
}], CommandEntry[] | Error | undefined>(['ctx']);
|
}], CommandEntry[] | Error | undefined>(['ctx']);
|
||||||
/** override the launch command for an emulator
|
/** override the launch command for an emulator
|
||||||
|
* @param ctx.autoValidCommands The auto generated command for example based on the ES-DE listing
|
||||||
|
* @param ctx.emulator The emulator ID if any
|
||||||
|
* @param ctx.game.source The source of the game
|
||||||
|
* @param ctx.game.sourceId The ID of the source. This could be for example the ROMM ID the game was
|
||||||
* @returns The argument list to be used when running the emulator.
|
* @returns The argument list to be used when running the emulator.
|
||||||
* If no emulator bin in the command entry is found the actual command will be used as the bin.
|
* If no emulator bin in the command entry is found the actual command will be used as the bin.
|
||||||
*/
|
*/
|
||||||
emulatorLaunch = new AsyncSeriesBailHook<[ctx: {
|
emulatorLaunch = new AsyncSeriesBailHook<[ctx: {
|
||||||
/** The auto generated command for example based on the ES-DE listing */
|
|
||||||
autoValidCommand: CommandEntry;
|
autoValidCommand: CommandEntry;
|
||||||
/** Don't actually launch just see if it can be launched */
|
|
||||||
dryRun: boolean,
|
dryRun: boolean,
|
||||||
game: {
|
game: {
|
||||||
/** The source of the game */
|
|
||||||
source?: string;
|
source?: string;
|
||||||
/** The ID of the source. This could be for example the ROMM ID the game was */
|
|
||||||
sourceId?: string;
|
sourceId?: string;
|
||||||
id: FrontEndId;
|
id: FrontEndId;
|
||||||
platformSlug?: string;
|
platformSlug?: string;
|
||||||
|
|
@ -43,36 +40,34 @@ export default class GameHooks
|
||||||
}], EmulatorSupport | undefined, { emulator: string; }>(['ctx']);
|
}], EmulatorSupport | undefined, { emulator: string; }>(['ctx']);
|
||||||
/**
|
/**
|
||||||
* Fetches and returns a list of games converted to frontend.
|
* Fetches and returns a list of games converted to frontend.
|
||||||
|
* @param ctx.localGameIds This is local game ids in the format '<source>@<sourceId>'
|
||||||
*/
|
*/
|
||||||
fetchGames = new AsyncSeriesHook<[ctx: {
|
fetchGames = new AsyncSeriesHook<[ctx: {
|
||||||
query: GameListFilterType;
|
query: GameListFilterType;
|
||||||
games: FrontEndGameTypeWithIds[];
|
games: FrontEndGameTypeWithIds[];
|
||||||
}]>(['ctx']);
|
}]>(['ctx']);
|
||||||
/** Return all filters the users can apply for a give source. */
|
|
||||||
fetchFilters = new AsyncSeriesHook<[ctx: {
|
fetchFilters = new AsyncSeriesHook<[ctx: {
|
||||||
source?: string;
|
source?: string;
|
||||||
filters: FrontEndFilterSets;
|
filters: FrontEndFilterSets;
|
||||||
}]>(['ctx']);
|
}]>(['ctx']);
|
||||||
/** Get game metadata */
|
|
||||||
fetchGame = new AsyncSeriesBailHook<[ctx: {
|
fetchGame = new AsyncSeriesBailHook<[ctx: {
|
||||||
source: string;
|
source: string;
|
||||||
localGame?: FrontEndGameTypeDetailed;
|
localGame?: FrontEndGameTypeDetailed;
|
||||||
id: string;
|
id: string;
|
||||||
}], FrontEndGameTypeDetailed | undefined>(['ctx']);
|
}], FrontEndGameTypeDetailed | undefined>(['ctx']);
|
||||||
/** Search for a given game based on the igdb id or ra id. */
|
|
||||||
searchGame = new AsyncSeriesBailHook<[ctx: {
|
searchGame = new AsyncSeriesBailHook<[ctx: {
|
||||||
source: string;
|
source: string;
|
||||||
igdb_id?: number;
|
igdb_id?: number;
|
||||||
ra_id?: number;
|
ra_id?: number;
|
||||||
}], FrontEndGameTypeDetailed | undefined>(['ctx']);
|
}], FrontEndGameTypeDetailed | undefined>(['ctx']);
|
||||||
/** Get download file URLs */
|
/** Get download file URLs
|
||||||
|
* @param ctx.checksum Check if file already exists using checksums
|
||||||
|
*/
|
||||||
fetchDownloads = new AsyncSeriesBailHook<[ctx: {
|
fetchDownloads = new AsyncSeriesBailHook<[ctx: {
|
||||||
source: string;
|
source: string;
|
||||||
id: string;
|
id: string;
|
||||||
/** If there are multiple downloads, use the one with same ID */
|
|
||||||
downloadId?: string;
|
downloadId?: string;
|
||||||
}], DownloadInfo[] | undefined>(['ctx']);
|
}], DownloadInfo[] | undefined>(['ctx']);
|
||||||
/** Get the paths to rom files. This is mainly used for emulator js. */
|
|
||||||
fetchRomFiles = new AsyncSeriesBailHook<[ctx: {
|
fetchRomFiles = new AsyncSeriesBailHook<[ctx: {
|
||||||
source: string;
|
source: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -90,7 +85,6 @@ export default class GameHooks
|
||||||
source: string;
|
source: string;
|
||||||
id: string;
|
id: string;
|
||||||
}], FrontEndPlatformType | undefined>(['ctx']);
|
}], FrontEndPlatformType | undefined>(['ctx']);
|
||||||
/** Lookup a given platform with a given slug or id. This may or may not exist. */
|
|
||||||
platformLookup = new AsyncSeriesBailHook<[ctx: {
|
platformLookup = new AsyncSeriesBailHook<[ctx: {
|
||||||
source?: string;
|
source?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
@ -101,32 +95,10 @@ export default class GameHooks
|
||||||
name?: string;
|
name?: string;
|
||||||
family_name?: string;
|
family_name?: string;
|
||||||
} | undefined>(['ctx']);
|
} | undefined>(['ctx']);
|
||||||
/** Lookup downloads based on a search pattern.
|
gameLookup = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], { screenshotUrls: string[]; } | undefined>(['ctx']);
|
||||||
* This is just downloads. Doesn't actually have to be a game.
|
|
||||||
* This is mainly used to manually add games from outside sources */
|
|
||||||
downloadsLookup = new AsyncSeriesWaterfallHook<[matches: Map<string, {
|
|
||||||
count: number;
|
|
||||||
items: DownloadLookupEntry[];
|
|
||||||
}>, ctx: {
|
|
||||||
page?: number;
|
|
||||||
rows?: number;
|
|
||||||
} & DownloadsLookupFilter]>(['matches', 'ctx']);
|
|
||||||
/** List all available filters */
|
|
||||||
downloadsLookupFilters = new AsyncSeriesHook<[ctx: {
|
|
||||||
filters: DownloadsLookupFilterValues;
|
|
||||||
}]>(['ctx']);
|
|
||||||
/** Look for the files for a download the user can pick from */
|
|
||||||
downloadLookup = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], DownloadLookupDetails | undefined>(['ctx']);
|
|
||||||
/** Look up game metadata based on a search */
|
|
||||||
gameLookup = new AsyncSeriesWaterfallHook<[matches: Map<string, GameLookup[]>, ctx: {
|
|
||||||
source?: string,
|
|
||||||
id?: string;
|
|
||||||
search?: string;
|
|
||||||
}]>(['matches', 'ctx']);
|
|
||||||
fetchPlatforms = new AsyncSeriesHook<[ctx: {
|
fetchPlatforms = new AsyncSeriesHook<[ctx: {
|
||||||
platforms: FrontEndPlatformType[];
|
platforms: FrontEndPlatformType[];
|
||||||
}]>(['ctx']);
|
}]>(['ctx']);
|
||||||
/** Called before the game is played. */
|
|
||||||
prePlay = new AsyncSeriesHook<[ctx: {
|
prePlay = new AsyncSeriesHook<[ctx: {
|
||||||
source: string,
|
source: string,
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -138,25 +110,20 @@ export default class GameHooks
|
||||||
};
|
};
|
||||||
}]>(["ctx"]);
|
}]>(["ctx"]);
|
||||||
/**
|
/**
|
||||||
* Called after the game process has finished.
|
* @param changedSaveFiles Auto detected changed files. This is mainly used to see what changed during gameplay
|
||||||
|
* @param validChangedSaveFiles This will be final valid changes to be saved using save integrations like rclone
|
||||||
*/
|
*/
|
||||||
postPlay = new AsyncSeriesHook<[ctx: {
|
postPlay = new AsyncSeriesHook<[ctx: {
|
||||||
source: string,
|
source: string,
|
||||||
id: string;
|
id: string;
|
||||||
saveFolderSlots?: SaveSlots;
|
saveFolderSlots?: Record<string, { cwd: string; }>;
|
||||||
/** Auto detected changed files. This is mainly used to see what changed during gameplay */
|
|
||||||
changedSaveFiles: { subPath: string, cwd: string; }[],
|
changedSaveFiles: { subPath: string, cwd: string; }[],
|
||||||
/** This will be final valid changes to be saved using save integrations like rclone */
|
|
||||||
validChangedSaveFiles: Record<string, SaveFileChange>,
|
validChangedSaveFiles: Record<string, SaveFileChange>,
|
||||||
/** The command that was used to launch the game */
|
|
||||||
command: CommandEntry;
|
command: CommandEntry;
|
||||||
gameInfo: {
|
gameInfo: {
|
||||||
platformSlug?: string;
|
platformSlug?: string;
|
||||||
};
|
};
|
||||||
}]>(["ctx"]);
|
}]>(["ctx"]);
|
||||||
/** Called after game install
|
|
||||||
* This includes game being downloaded and registered in the database.
|
|
||||||
*/
|
|
||||||
postInstall = new AsyncSeriesHook<[ctx: {
|
postInstall = new AsyncSeriesHook<[ctx: {
|
||||||
source: string,
|
source: string,
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { FrontEndEmulator, FrontEndEmulatorDetailed, FrontEndGameTypeDetailed, EmulatorDownloadInfoType } from "../shared";
|
import { EmulatorDownloadInfoType } from "@/shared/constants";
|
||||||
import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable";
|
import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable";
|
||||||
|
|
||||||
export default class StoreHooks
|
export class StoreHooks
|
||||||
{
|
{
|
||||||
fetchFeaturedGames = new AsyncSeriesHook<[ctx: { games: FrontEndGameTypeDetailed[]; }]>(['ctx']);
|
fetchFeaturedGames = new AsyncSeriesHook<[ctx: { games: FrontEndGameTypeDetailed[]; }]>(['ctx']);
|
||||||
fetchEmulators = new AsyncSeriesHook<[ctx: { emulators: FrontEndEmulator[]; search?: string; }]>(['ctx']);
|
fetchEmulators = new AsyncSeriesHook<[ctx: { emulators: FrontEndEmulator[]; search?: string; }]>(['ctx']);
|
||||||
|
|
@ -1,44 +1,35 @@
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
import z from "zod";
|
||||||
|
import { IJob, JobContext } from "../task-queue";
|
||||||
import { config, plugins } from "../app";
|
import { config, plugins } from "../app";
|
||||||
import { simulateProgress } from "@/bun/utils";
|
import { simulateProgress } from "@/bun/utils";
|
||||||
import { Downloader } from "@/bun/utils/downloader";
|
import { Downloader } from "@/bun/utils/downloader";
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
|
import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
|
||||||
import { DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
interface BiosDownloadJobData extends DownloadJobData
|
export class BiosDownloadJob implements IJob<z.infer<typeof BiosDownloadJob.dataSchema>, "download">
|
||||||
{
|
|
||||||
emulator: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BiosDownloadJob implements IJob<BiosDownloadJobData, "download">
|
|
||||||
{
|
{
|
||||||
static id = "bios-download-job" as const;
|
static id = "bios-download-job" as const;
|
||||||
|
static dataSchema = z.object({ emulator: z.string() });
|
||||||
static query = (q: { id: string; }) => `${BiosDownloadJob.id}-${q.id}`;
|
static query = (q: { id: string; }) => `${BiosDownloadJob.id}-${q.id}`;
|
||||||
group: string = "bios-download";
|
group: string = "bios-download";
|
||||||
data: BiosDownloadJobData;
|
emulator: string;
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
|
|
||||||
constructor(emulator: string, init?: { dryRun?: boolean; })
|
constructor(emulator: string, init?: { dryRun?: boolean; })
|
||||||
{
|
{
|
||||||
this.data = {
|
this.emulator = emulator;
|
||||||
emulator,
|
|
||||||
name: "Download Emulator Bios"
|
|
||||||
};
|
|
||||||
this.dryRun = init?.dryRun ?? false;
|
this.dryRun = init?.dryRun ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async start (context: JobContext<IJob<BiosDownloadJobData, "download">, BiosDownloadJobData, "download">)
|
async start (context: JobContext<IJob<z.infer<typeof BiosDownloadJob.dataSchema>, "download">, z.infer<typeof BiosDownloadJob.dataSchema>, "download">)
|
||||||
{
|
{
|
||||||
const emulator = await getStoreEmulatorPackage(this.data.emulator);
|
const emulator = await getStoreEmulatorPackage(this.emulator);
|
||||||
if (!emulator) throw new Error("Could Not Find Emulator");
|
if (!emulator) throw new Error("Could Not Find Emulator");
|
||||||
this.data.name = `${emulator.name} Bios`;
|
|
||||||
this.data.preview_url = emulator.logo;
|
|
||||||
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
||||||
const biosFolder = path.join(config.get('downloadPath'), "bios", this.data.emulator);
|
const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
|
||||||
await ensureDir(biosFolder);
|
await ensureDir(biosFolder);
|
||||||
const files = await plugins.hooks.emulators.fetchBiosDownload.promise({ emulator: this.data.emulator, systems, biosFolder });
|
const files = await plugins.hooks.emulators.fetchBiosDownload.promise({ emulator: this.emulator, systems, biosFolder });
|
||||||
|
|
||||||
if (!files) throw new Error("Could not find source to download from");
|
if (!files) throw new Error("Could not find source to download from");
|
||||||
|
|
||||||
|
|
@ -54,12 +45,9 @@ export class BiosDownloadJob implements IJob<BiosDownloadJobData, "download">
|
||||||
const downloader = new Downloader('bios-download', files.files, biosFolder, {
|
const downloader = new Downloader('bios-download', files.files, biosFolder, {
|
||||||
signal: context.abortSignal,
|
signal: context.abortSignal,
|
||||||
headers,
|
headers,
|
||||||
onProgress: (stats) =>
|
onProgress (stats)
|
||||||
{
|
{
|
||||||
context.setProgress(stats.progress, "download");
|
context.setProgress(stats.progress, "download");
|
||||||
this.data.downloaded = stats.downloaded;
|
|
||||||
this.data.speed = stats.speed;
|
|
||||||
this.data.total = stats.total;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -69,6 +57,6 @@ export class BiosDownloadJob implements IJob<BiosDownloadJobData, "download">
|
||||||
|
|
||||||
exposeData ()
|
exposeData ()
|
||||||
{
|
{
|
||||||
return this.data;
|
return { emulator: this.emulator };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,54 +1,45 @@
|
||||||
import { DownloadJobData, EmulatorPackageType } from '@simeonradivoev/gameflow-sdk/shared';
|
import { EmulatorPackageType } from "@/shared/constants";
|
||||||
import { getStoreEmulatorPackage } from "../store/services/gamesService";
|
import { getStoreEmulatorPackage } from "../store/services/gamesService";
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
import { IJob, JobContext } from "../task-queue";
|
||||||
|
import z from "zod";
|
||||||
import { config, plugins } from "../app";
|
import { config, plugins } from "../app";
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import Seven from 'node-7z';
|
import Seven from 'node-7z';
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { Downloader } from "@/bun/utils/downloader";
|
import { Downloader } from "@/bun/utils/downloader";
|
||||||
import { ensureDir, move } from "fs-extra";
|
import { ensureDir, move } from "fs-extra";
|
||||||
import { isArchive, simulateProgress } from "@/bun/utils";
|
import { simulateProgress } from "@/bun/utils";
|
||||||
import { path7za } from "7zip-bin";
|
import { path7za } from "7zip-bin";
|
||||||
import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService";
|
import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService";
|
||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
import { EmulatorSourceEntryType } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
type EmulatorDownloadStates = "download" | "extract";
|
type EmulatorDownloadStates = "download" | "extract";
|
||||||
|
|
||||||
interface EmulatorDownloadJobData extends DownloadJobData
|
export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>
|
||||||
{
|
|
||||||
emulator: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EmulatorDownloadJob implements IJob<EmulatorDownloadJobData, EmulatorDownloadStates>
|
|
||||||
{
|
{
|
||||||
static id = "download-emulator" as const;
|
static id = "download-emulator" as const;
|
||||||
|
static dataSchema = z.object({ emulator: z.string() });
|
||||||
|
emulator: string;
|
||||||
downloadSource: string;
|
downloadSource: string;
|
||||||
emulatorPackage?: EmulatorPackageType;
|
emulatorPackage?: EmulatorPackageType;
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
isUpdate: boolean;
|
isUpdate: boolean;
|
||||||
data: EmulatorDownloadJobData = {
|
|
||||||
name: "Download Emulator",
|
|
||||||
emulator: ""
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; isUpdate?: boolean; })
|
constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; isUpdate?: boolean; })
|
||||||
{
|
{
|
||||||
this.data.emulator = emulator;
|
this.emulator = emulator;
|
||||||
this.downloadSource = downloadSource;
|
this.downloadSource = downloadSource;
|
||||||
this.dryRun = init?.dryRun ?? false;
|
this.dryRun = init?.dryRun ?? false;
|
||||||
this.isUpdate = init?.isUpdate ?? false;
|
this.isUpdate = init?.isUpdate ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async start (context: JobContext<EmulatorDownloadJob, EmulatorDownloadJobData, EmulatorDownloadStates>)
|
async start (context: JobContext<EmulatorDownloadJob, z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>)
|
||||||
{
|
{
|
||||||
this.emulatorPackage = await getStoreEmulatorPackage(this.data.emulator);
|
this.emulatorPackage = await getStoreEmulatorPackage(this.emulator);
|
||||||
if (!this.emulatorPackage) throw new Error("Emulator not found");
|
if (!this.emulatorPackage) throw new Error("Emulator not found");
|
||||||
this.data.name = this.emulatorPackage.name;
|
|
||||||
this.data.preview_url = this.emulatorPackage.logo;
|
|
||||||
const { url, info } = await getEmulatorDownload(this.emulatorPackage, this.downloadSource);
|
const { url, info } = await getEmulatorDownload(this.emulatorPackage, this.downloadSource);
|
||||||
|
|
||||||
const emulatorsFolder = getEmulatorPath(this.data.emulator);
|
const emulatorsFolder = getEmulatorPath(this.emulator);
|
||||||
|
|
||||||
if (this.dryRun)
|
if (this.dryRun)
|
||||||
{
|
{
|
||||||
|
|
@ -57,33 +48,29 @@ export class EmulatorDownloadJob implements IJob<EmulatorDownloadJobData, Emulat
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
|
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
|
||||||
const downloader = new Downloader(this.data.emulator,
|
const downloader = new Downloader(this.emulator,
|
||||||
[{ url, file_name: path.basename(url.pathname), file_path: this.data.emulator }],
|
[{ url, file_name: path.basename(url.pathname), file_path: this.emulator }],
|
||||||
tmpFolder,
|
tmpFolder,
|
||||||
{
|
{
|
||||||
signal: context.abortSignal,
|
signal: context.abortSignal,
|
||||||
onProgress: (stats) =>
|
onProgress (stats)
|
||||||
{
|
{
|
||||||
context.setProgress(stats.progress, 'download');
|
context.setProgress(stats.progress, 'download');
|
||||||
this.data.total = stats.total;
|
|
||||||
this.data.downloaded = stats.downloaded;
|
|
||||||
this.data.speed = stats.speed;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const destinationPaths = await downloader.start();
|
const destinationPaths = await downloader.start();
|
||||||
context.abortSignal.throwIfAborted();
|
|
||||||
if (destinationPaths)
|
if (destinationPaths)
|
||||||
{
|
{
|
||||||
const archive = isArchive(destinationPaths[0]);
|
const isArchive = destinationPaths[0].endsWith('.7z') || destinationPaths[0].endsWith('.zip') || destinationPaths[0].endsWith('.tar');
|
||||||
const isAppImage = destinationPaths[0].endsWith(".AppImage");
|
const isAppImage = destinationPaths[0].endsWith(".AppImage");
|
||||||
|
|
||||||
if (!archive && !isAppImage)
|
if (!isArchive && !isAppImage)
|
||||||
{
|
{
|
||||||
throw new Error("Invalid Download Type");
|
throw new Error("Invalid Download Type");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (archive)
|
if (isArchive)
|
||||||
{
|
{
|
||||||
if (destinationPaths[0])
|
if (destinationPaths[0])
|
||||||
{
|
{
|
||||||
|
|
@ -132,10 +119,10 @@ export class EmulatorDownloadJob implements IJob<EmulatorDownloadJobData, Emulat
|
||||||
await Bun.write(`${emulatorsFolder}.json`, JSON.stringify(info, null, 3));
|
await Bun.write(`${emulatorsFolder}.json`, JSON.stringify(info, null, 3));
|
||||||
|
|
||||||
const execs: EmulatorSourceEntryType[] = [];
|
const execs: EmulatorSourceEntryType[] = [];
|
||||||
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: this.data.emulator, sources: execs });
|
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: this.emulator, sources: execs });
|
||||||
|
|
||||||
await plugins.hooks.emulators.emulatorPostInstall.promise({
|
await plugins.hooks.emulators.emulatorPostInstall.promise({
|
||||||
emulator: this.data.emulator,
|
emulator: this.emulator,
|
||||||
emulatorPackage: this.emulatorPackage,
|
emulatorPackage: this.emulatorPackage,
|
||||||
path: execs.find(e => e.type === 'store')?.binPath ?? emulatorsFolder,
|
path: execs.find(e => e.type === 'store')?.binPath ?? emulatorsFolder,
|
||||||
info,
|
info,
|
||||||
|
|
@ -148,7 +135,7 @@ export class EmulatorDownloadJob implements IJob<EmulatorDownloadJobData, Emulat
|
||||||
|
|
||||||
exposeData ()
|
exposeData ()
|
||||||
{
|
{
|
||||||
return this.data;
|
return { emulator: this.emulator };
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import { ensureDir } from "fs-extra";
|
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
|
|
||||||
import { getStoreRootFolder } from "../store/services/gamesService";
|
|
||||||
import z from "zod";
|
|
||||||
import { runBunPackageCommand } from "../plugins/services";
|
|
||||||
import { PluginRegistry } from "@/shared/constants";
|
|
||||||
import path from "node:path";
|
|
||||||
import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json';
|
|
||||||
import { IsPluginAllowed } from "@/bun/utils";
|
|
||||||
|
|
||||||
export default class EnsureStore implements IJob<never, string>
|
|
||||||
{
|
|
||||||
static id = "update-store" as const;
|
|
||||||
static dataSchema = z.never();
|
|
||||||
packageName: string;
|
|
||||||
storeVersion: string;
|
|
||||||
|
|
||||||
constructor()
|
|
||||||
{
|
|
||||||
this.packageName = process.env.STORE_PACKAGE_NAME ?? "@simeonradivoev/gameflow-store";
|
|
||||||
this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0";
|
|
||||||
}
|
|
||||||
|
|
||||||
async start (context: JobContext<EnsureStore, never, string>)
|
|
||||||
{
|
|
||||||
const storeFolder = getStoreRootFolder();
|
|
||||||
await ensureDir(storeFolder);
|
|
||||||
const storePackageFile = Bun.file(path.join(storeFolder, "package.json"));
|
|
||||||
if (!await storePackageFile.exists())
|
|
||||||
{
|
|
||||||
await storePackageFile.write(JSON.stringify({ dependencies: {} }, null, 3));
|
|
||||||
}
|
|
||||||
|
|
||||||
const storePackage = await Bun.file(path.join(storeFolder, "package.json")).json();
|
|
||||||
|
|
||||||
if (IsPluginAllowed(sdkPkg.name))
|
|
||||||
{
|
|
||||||
if (!storePackage.dependencies?.[sdkPkg.name] || storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version)
|
|
||||||
{
|
|
||||||
let response = await runBunPackageCommand(["add", `${sdkPkg.name}@${sdkPkg.version}`, "--registry", PluginRegistry, '--omit', 'peer']);
|
|
||||||
console.log(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
// probably just means we couldn't find a version of the sdk, just install latest
|
|
||||||
if (storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version)
|
|
||||||
{
|
|
||||||
let response = await runBunPackageCommand(["add", '--exact', `${sdkPkg.name}@latest`, "--registry", PluginRegistry, '--omit', 'peer']);
|
|
||||||
console.log(response);
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
console.log("Ignoring SDK package");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.CUSTOM_STORE_PATH) return;
|
|
||||||
|
|
||||||
if (!storePackage.dependencies?.['@simeonradivoev/gameflow-store'])
|
|
||||||
{
|
|
||||||
context.setProgress(0.5, "Adding Store");
|
|
||||||
let response = await runBunPackageCommand(["add", `${this.packageName}@${this.storeVersion}`, "--registry", PluginRegistry, '--omit', 'peer']);
|
|
||||||
console.log(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
import { eq, inArray, or } from "drizzle-orm";
|
|
||||||
import { db, plugins } from "../app";
|
|
||||||
import { createLocalGame, downloadGame } from "../games/services/utils";
|
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
|
||||||
import * as schema from "@schema/app";
|
|
||||||
import { DownloadJobData, GameLookup } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
import { isUrl } from "@/shared/utils";
|
|
||||||
import { basename } from "node:path";
|
|
||||||
import path from 'node:path';
|
|
||||||
import { isArchive } from "@/bun/utils";
|
|
||||||
|
|
||||||
interface ImportJobData extends DownloadJobData
|
|
||||||
{
|
|
||||||
localId: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ImportJob implements IJob<ImportJobData, string>
|
|
||||||
{
|
|
||||||
static id = "import-job" as const;
|
|
||||||
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;
|
|
||||||
|
|
||||||
constructor(source: string, id: string, gamePath: string, platformId: number)
|
|
||||||
{
|
|
||||||
this.gamePath = gamePath;
|
|
||||||
this.source = source;
|
|
||||||
this.id = id;
|
|
||||||
this.platformId = platformId;
|
|
||||||
}
|
|
||||||
|
|
||||||
exposeData ()
|
|
||||||
{
|
|
||||||
return this.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async start (context: JobContext<IJob<ImportJobData, string>, ImportJobData, string>): Promise<any>
|
|
||||||
{
|
|
||||||
const matchesMap = new Map<string, GameLookup[]>();
|
|
||||||
await plugins.hooks.games.gameLookup.promise(matchesMap, { source: this.source, id: this.id });
|
|
||||||
const matches = matchesMap.values().next().value;
|
|
||||||
if (!matches || matches.length <= 0) throw Error("Could not Find Game");
|
|
||||||
const match = matches[0];
|
|
||||||
this.data.name = match.name;
|
|
||||||
this.data.preview_url = match.coverUrl;
|
|
||||||
|
|
||||||
let cover: Buffer<ArrayBufferLike> | undefined = undefined;
|
|
||||||
let coverType: string | undefined = undefined;
|
|
||||||
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 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(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");
|
|
||||||
|
|
||||||
this.data.localId = await createLocalGame({
|
|
||||||
name: match.name,
|
|
||||||
system_slug: platformMatch?.slug,
|
|
||||||
source: undefined,
|
|
||||||
source_id: undefined,
|
|
||||||
slug: match.slug,
|
|
||||||
path_fs: finalFiles[0],
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
import { IJob, JobContext } from "../task-queue";
|
||||||
|
import { and, eq, or } from 'drizzle-orm';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import * as schema from "@schema/app";
|
||||||
import { config, events, plugins } from "../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 { simulateProgress } from "@/bun/utils";
|
import { simulateProgress } from "@/bun/utils";
|
||||||
|
import { Downloader } from "@/bun/utils/downloader";
|
||||||
|
import Seven from 'node-7z';
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { checkFiles, createLocalGame, downloadGame } from "../games/services/utils";
|
import { checkFiles } from "../games/services/utils";
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir, move } from "fs-extra";
|
||||||
import { DownloadInfo, DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
|
import { path7za } from "7zip-bin";
|
||||||
|
import StreamZip from 'node-stream-zip';
|
||||||
|
import { which } from "bun";
|
||||||
|
|
||||||
interface JobConfig
|
interface JobConfig
|
||||||
{
|
{
|
||||||
|
|
@ -17,7 +26,7 @@ interface JobConfig
|
||||||
|
|
||||||
export type InstallJobStates = 'download' | 'extract';
|
export type InstallJobStates = 'download' | 'extract';
|
||||||
|
|
||||||
export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
|
export class InstallJob implements IJob<never, InstallJobStates>
|
||||||
{
|
{
|
||||||
static id = "install-job" as const;
|
static id = "install-job" as const;
|
||||||
static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`;
|
static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`;
|
||||||
|
|
@ -28,10 +37,6 @@ export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
|
||||||
// The local game ID of newly created entry, if successful
|
// The local game ID of newly created entry, if successful
|
||||||
public localGameId?: number;
|
public localGameId?: number;
|
||||||
public group = InstallJob.id;
|
public group = InstallJob.id;
|
||||||
public localPath?: string;
|
|
||||||
data: DownloadJobData = {
|
|
||||||
name: "Install Game"
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(id: string, source: string, config?: JobConfig)
|
constructor(id: string, source: string, config?: JobConfig)
|
||||||
{
|
{
|
||||||
|
|
@ -40,47 +45,146 @@ export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
|
||||||
this.source = source;
|
this.source = source;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start (cx: JobContext<InstallJob, DownloadJobData, InstallJobStates>)
|
public async start (cx: JobContext<InstallJob, never, InstallJobStates>)
|
||||||
{
|
{
|
||||||
cx.setProgress(0, 'download');
|
cx.setProgress(0, 'download');
|
||||||
await fs.mkdir(config.get('downloadPath'), { recursive: true });
|
await fs.mkdir(config.get('downloadPath'), { recursive: true });
|
||||||
|
|
||||||
const downloadPath = config.get('downloadPath');
|
const downloadPath = config.get('downloadPath');
|
||||||
const finalFiles: string[] = [];
|
|
||||||
let info: DownloadInfo | undefined;
|
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[] = [];
|
||||||
|
|
||||||
if (this.config?.dryRun !== true)
|
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}`);
|
|
||||||
|
|
||||||
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))
|
if (this.config?.dryDownload !== true && files.some(f => !f.exists || !f.matches))
|
||||||
{
|
{
|
||||||
const downloadedFiles = await downloadGame({
|
const headers: Record<string, string> = {};
|
||||||
downloads: files.filter(f => !f.exists || !f.matches),
|
if (info.auth)
|
||||||
extract_path: info.extract_path,
|
headers['Authorization'] = info.auth;
|
||||||
path_fs: info.path_fs,
|
const downloader = new Downloader(`game-${this.source}-${this.gameId}`,
|
||||||
abortSignal: cx.abortSignal,
|
files.filter(f => !f.exists || !f.matches),
|
||||||
auth: info.auth,
|
config.get('downloadPath'),
|
||||||
id: `game-${this.source}-${this.gameId}`,
|
|
||||||
setProgress: (process, state, info) =>
|
|
||||||
{
|
{
|
||||||
cx.setProgress(process, state);
|
signal: cx.abortSignal,
|
||||||
this.data.downloaded = info.downloaded;
|
headers,
|
||||||
this.data.speed = info.speed;
|
onProgress (stats)
|
||||||
this.data.total = info.total;
|
{
|
||||||
},
|
cx.setProgress(stats.progress, 'download');
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (downloadedFiles)
|
const downloadedFiles = await downloader.start();
|
||||||
|
if (!downloadedFiles)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.extract_path && downloadedFiles)
|
||||||
|
{
|
||||||
|
let progress = 0;
|
||||||
|
const progressDelta = 1 / downloadedFiles.length;
|
||||||
|
const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path);
|
||||||
|
|
||||||
|
for (const filePath of downloadedFiles)
|
||||||
|
{
|
||||||
|
await new Promise(async (resolve, reject) =>
|
||||||
|
{
|
||||||
|
let sevenZipPath = process.env.ZIP7_PATH ?? path7za;
|
||||||
|
|
||||||
|
if (filePath.endsWith('.rar'))
|
||||||
|
{
|
||||||
|
let newPath: string | undefined;
|
||||||
|
if (process.platform === 'win32' && await fs.exists("C:\\Program Files\\7-Zip\\7z.exe"))
|
||||||
|
{
|
||||||
|
newPath = "C:\\Program Files\\7-Zip\\7z.exe";
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
newPath = which('7z') ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newPath)
|
||||||
|
{
|
||||||
|
await fs.rm(filePath);
|
||||||
|
reject(new Error("No RAR Support"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sevenZipPath = newPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rejected = false;
|
||||||
|
const seven = Seven.extractFull(filePath, extractPath, { $bin: sevenZipPath, $progress: true });
|
||||||
|
seven.on('progress', p =>
|
||||||
|
{
|
||||||
|
cx.setProgress(progress + p.percent * progressDelta, "extract");
|
||||||
|
});
|
||||||
|
seven.on('error', e =>
|
||||||
|
{
|
||||||
|
reject(e);
|
||||||
|
rejected = true;
|
||||||
|
});
|
||||||
|
seven.on('end', async () =>
|
||||||
|
{
|
||||||
|
if (rejected) return;
|
||||||
|
await fs.rm(filePath);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
}).catch(async e =>
|
||||||
|
{
|
||||||
|
if (filePath.endsWith('.zip'))
|
||||||
|
{
|
||||||
|
cx.setProgress(0, "extract");
|
||||||
|
console.error(e);
|
||||||
|
console.warn("Could not extract", filePath, "with 7zip trying zip extractor");
|
||||||
|
await ensureDir(extractPath);
|
||||||
|
const zip = new StreamZip.async({ file: filePath });
|
||||||
|
let entryCount = await zip.entriesCount;
|
||||||
|
let entryCounter = entryCount;
|
||||||
|
zip.on('extract', (entry, outPath) =>
|
||||||
|
{
|
||||||
|
entryCounter--;
|
||||||
|
cx.setProgress(progress + (1 - (entryCounter / entryCount)) * 100 * progressDelta, "extract");
|
||||||
|
});
|
||||||
|
const count = await zip.extract(null, extractPath);
|
||||||
|
console.log(`Extracted ${count} entries`);
|
||||||
|
await zip.close();
|
||||||
|
await fs.rm(filePath);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
progress += progressDelta * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if 1 root folder we need to get rid of
|
||||||
|
const contents = await fs.readdir(extractPath);
|
||||||
|
if (contents.length === 1)
|
||||||
|
{
|
||||||
|
const stat = await fs.stat(path.join(extractPath, contents[0]));
|
||||||
|
if (stat.isDirectory())
|
||||||
|
{
|
||||||
|
console.log("Found 1 root folder, using that instead");
|
||||||
|
const tmpGameFolder = `${extractPath} (1)`;
|
||||||
|
await move(path.join(extractPath, contents[0]), tmpGameFolder, { overwrite: true });
|
||||||
|
await move(tmpGameFolder, extractPath, { overwrite: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalFiles.push(extractPath);
|
||||||
|
|
||||||
|
} else
|
||||||
|
{
|
||||||
finalFiles.push(...downloadedFiles);
|
finalFiles.push(...downloadedFiles);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.config?.dryDownload === true && info.extract_path)
|
if (this.config?.dryDownload === true && info.extract_path)
|
||||||
|
|
@ -91,34 +195,145 @@ export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
|
||||||
const coverResponse = await fetch(info.coverUrl);
|
const coverResponse = await fetch(info.coverUrl);
|
||||||
const cover = Buffer.from(await coverResponse.arrayBuffer());
|
const cover = Buffer.from(await coverResponse.arrayBuffer());
|
||||||
|
|
||||||
cx.abortSignal.throwIfAborted();
|
if (cx.abortSignal.aborted) return;
|
||||||
|
|
||||||
this.localGameId = await createLocalGame({
|
await db.transaction(async (tx) =>
|
||||||
cover,
|
{
|
||||||
coverType: coverResponse.headers.get('content-type'),
|
// Search for existing platform
|
||||||
system_slug: info.system_slug,
|
const platformSearch = [eq(schema.platforms.slug, info.system_slug)];
|
||||||
source_id: info.source_id,
|
const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, info.system_slug)];
|
||||||
source: this.source,
|
|
||||||
slug: info.slug,
|
if (info.platform)
|
||||||
path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined),
|
{
|
||||||
summary: info.summary,
|
if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id));
|
||||||
igdb_id: info.igdb_id,
|
if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug));
|
||||||
ra_id: info.ra_id,
|
if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id));
|
||||||
name: info.name,
|
if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id));
|
||||||
main_glob: info.main_glob,
|
|
||||||
version: info.version,
|
esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, 'romm'));
|
||||||
version_source: info.version_source,
|
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.slug));
|
||||||
screenshotUrls: info.screenshotUrls,
|
}
|
||||||
version_system: info.version_system,
|
|
||||||
metadata: info.metadata,
|
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({
|
||||||
platform: info.platform
|
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
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
|
} else
|
||||||
{
|
{
|
||||||
await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal);
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,24 +3,22 @@ import z, { _ZodType } from "zod";
|
||||||
import { taskQueue } from "../app";
|
import { taskQueue } from "../app";
|
||||||
import { LoginJob } from "./login-job";
|
import { LoginJob } from "./login-job";
|
||||||
import TwitchLoginJob from "./twitch-login-job";
|
import TwitchLoginJob from "./twitch-login-job";
|
||||||
import EnsureStore from "./ensure-store";
|
import UpdateStoreJob from "./update-store";
|
||||||
import { EmulatorDownloadJob } from "./emulator-download-job";
|
import { EmulatorDownloadJob } from "./emulator-download-job";
|
||||||
import { getErrorMessage } from "@/bun/utils";
|
import { getErrorMessage } from "@/bun/utils";
|
||||||
import { BaseEvent, IJob } from "@simeonradivoev/gameflow-sdk/task-queue";
|
import { IJob } from "../task-queue";
|
||||||
import { LaunchGameJob } from "./launch-game-job";
|
import { LaunchGameJob } from "./launch-game-job";
|
||||||
import { BiosDownloadJob } from "./bios-download-job";
|
import { BiosDownloadJob } from "./bios-download-job";
|
||||||
import { InstallJob } from "./install-job";
|
import { InstallJob } from "./install-job";
|
||||||
import ReloadPluginsJob from "./reload-plugins-job";
|
import ReloadPluginsJob from "./reload-plugins-job";
|
||||||
import { FrontEndJob } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
function registerJob<
|
function registerJob<
|
||||||
const Path extends string,
|
const Path extends string,
|
||||||
Schema,
|
const Schema extends z.ZodTypeAny,
|
||||||
|
const Query extends z.ZodTypeAny,
|
||||||
const States extends string,
|
const States extends string,
|
||||||
> (_job: {
|
T extends IJob<z.infer<Schema>, States>
|
||||||
id: Path;
|
> (_job: { id: Path; dataSchema: Schema; query?: (q: any) => string; } & (new (...args: any[]) => T))
|
||||||
query?: (q: any) => string;
|
|
||||||
} & (new (...args: any[]) => IJob<Schema, States>))
|
|
||||||
{
|
{
|
||||||
return new Elysia().ws(_job.id, {
|
return new Elysia().ws(_job.id, {
|
||||||
body: z.discriminatedUnion('type', [
|
body: z.discriminatedUnion('type', [
|
||||||
|
|
@ -32,9 +30,9 @@ function registerJob<
|
||||||
type: z.literal(['data', 'started', 'progress']),
|
type: z.literal(['data', 'started', 'progress']),
|
||||||
state: z.string().optional(),
|
state: z.string().optional(),
|
||||||
progress: z.number(),
|
progress: z.number(),
|
||||||
data: z.custom<Schema>()
|
data: _job.dataSchema
|
||||||
}),
|
}),
|
||||||
z.object({ type: z.literal(['completed', 'ended']), data: z.custom<Schema>() }),
|
z.object({ type: z.literal(['completed', 'ended']), data: _job.dataSchema }),
|
||||||
z.object({ type: z.literal('waiting') }),
|
z.object({ type: z.literal('waiting') }),
|
||||||
z.object({ type: z.literal('error'), error: z.string() })
|
z.object({ type: z.literal('error'), error: z.string() })
|
||||||
]),
|
]),
|
||||||
|
|
@ -44,7 +42,7 @@ function registerJob<
|
||||||
const job = taskQueue.findJob(jobId, _job);
|
const job = taskQueue.findJob(jobId, _job);
|
||||||
if (job)
|
if (job)
|
||||||
{
|
{
|
||||||
ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() as Schema });
|
ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
ws.send({ type: 'waiting' });
|
ws.send({ type: 'waiting' });
|
||||||
|
|
@ -104,87 +102,10 @@ function registerJob<
|
||||||
}
|
}
|
||||||
|
|
||||||
export const jobs = new Elysia({ prefix: '/api/jobs' })
|
export const jobs = new Elysia({ prefix: '/api/jobs' })
|
||||||
.ws('/list', {
|
|
||||||
response: z.discriminatedUnion('type', [
|
|
||||||
z.object({ type: z.literal("allJobs"), active: z.custom<FrontEndJob>().array(), queued: z.custom<FrontEndJob>().array() }),
|
|
||||||
z.object({ type: z.literal("started"), job: z.custom<FrontEndJob>() }),
|
|
||||||
z.object({ type: z.literal("progress"), job: z.custom<FrontEndJob>() }),
|
|
||||||
z.object({ type: z.literal("queued"), job: z.custom<FrontEndJob>() }),
|
|
||||||
z.object({ type: z.literal("aborted"), id: z.string() }),
|
|
||||||
z.object({ type: z.literal("ended"), id: z.string() }),
|
|
||||||
]),
|
|
||||||
body: z.discriminatedUnion('type', [
|
|
||||||
z.object({ type: z.literal("cancel"), id: z.string() })
|
|
||||||
]),
|
|
||||||
message (ws, message)
|
|
||||||
{
|
|
||||||
switch (message.type)
|
|
||||||
{
|
|
||||||
case "cancel":
|
|
||||||
taskQueue.cancelJob(message.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
open (ws)
|
|
||||||
{
|
|
||||||
ws.send({
|
|
||||||
type: 'allJobs',
|
|
||||||
active: taskQueue.getActiveJobs().map(j =>
|
|
||||||
{
|
|
||||||
const job: FrontEndJob = {
|
|
||||||
id: j.id,
|
|
||||||
data: j.job.exposeData?.(),
|
|
||||||
progress: j.progress,
|
|
||||||
state: j.state,
|
|
||||||
status: j.status
|
|
||||||
};
|
|
||||||
|
|
||||||
return job;
|
|
||||||
}),
|
|
||||||
queued: taskQueue.getQueuedJobs()?.map(j =>
|
|
||||||
{
|
|
||||||
const job: FrontEndJob = {
|
|
||||||
id: j.id,
|
|
||||||
data: j.job.exposeData?.(),
|
|
||||||
progress: j.progress,
|
|
||||||
state: j.state,
|
|
||||||
status: j.status
|
|
||||||
};
|
|
||||||
|
|
||||||
return job;
|
|
||||||
}) ?? []
|
|
||||||
});
|
|
||||||
|
|
||||||
(ws.data as any).dispose = [taskQueue.on('started', (e: BaseEvent) =>
|
|
||||||
{
|
|
||||||
ws.send({ type: "started", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
|
|
||||||
}),
|
|
||||||
taskQueue.on('progress', (e: BaseEvent) =>
|
|
||||||
{
|
|
||||||
ws.send({ type: "progress", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
|
|
||||||
}),
|
|
||||||
taskQueue.on('queued', (e: BaseEvent) =>
|
|
||||||
{
|
|
||||||
ws.send({ type: "queued", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
|
|
||||||
}),
|
|
||||||
taskQueue.on('abort', (e: BaseEvent) =>
|
|
||||||
{
|
|
||||||
ws.send({ type: "aborted", id: e.id });
|
|
||||||
}),
|
|
||||||
taskQueue.on('ended', (e: BaseEvent) =>
|
|
||||||
{
|
|
||||||
ws.send({ type: "ended", id: e.id });
|
|
||||||
})];
|
|
||||||
},
|
|
||||||
close (ws, code, reason)
|
|
||||||
{
|
|
||||||
(ws.data as any).dispose.forEach((d: any) => d());
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.use(registerJob(LaunchGameJob))
|
.use(registerJob(LaunchGameJob))
|
||||||
.use(registerJob(LoginJob))
|
.use(registerJob(LoginJob))
|
||||||
.use(registerJob(TwitchLoginJob))
|
.use(registerJob(TwitchLoginJob))
|
||||||
.use(registerJob(EnsureStore))
|
.use(registerJob(UpdateStoreJob))
|
||||||
.use(registerJob(BiosDownloadJob))
|
.use(registerJob(BiosDownloadJob))
|
||||||
.use(registerJob(InstallJob))
|
.use(registerJob(InstallJob))
|
||||||
.use(registerJob(ReloadPluginsJob))
|
.use(registerJob(ReloadPluginsJob))
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
import { IJob, JobContext } from "../task-queue";
|
||||||
import { ActiveGameSchema, ActiveGameType } from "@simeonradivoev/gameflow-sdk";
|
import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema";
|
||||||
import { config, db, events, plugins } from "../app";
|
import { config, db, events, plugins } from "../app";
|
||||||
import * as appSchema from "@schema/app";
|
import * as appSchema from "@schema/app";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
import fs from "node:fs/promises";
|
||||||
import { updateLocalLastPlayed } from "../games/services/statusService";
|
import { updateLocalLastPlayed } from "../games/services/statusService";
|
||||||
import { getErrorMessage } from "@/bun/utils";
|
import { getErrorMessage } from "@/bun/utils";
|
||||||
import { CommandEntry, FrontEndId, SaveSlots } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, string>
|
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, string>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
import { IJob, JobContext } from "../task-queue";
|
||||||
import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
|
import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
|
||||||
import { host, localIp } from "@/bun/utils/host";
|
import { host, localIp } from "@/bun/utils/host";
|
||||||
import cors from "@elysiajs/cors";
|
import cors from "@elysiajs/cors";
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
import z from "zod";
|
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
|
|
||||||
import { plugins } from "../app";
|
|
||||||
import { canUninstall, runBunPackageCommand } from "../plugins/services";
|
|
||||||
import { getPlugin, registerPlugin, unregisterPlugin } from "../plugins/register-plugins";
|
|
||||||
import { PluginRegistry } from "@/shared/constants";
|
|
||||||
|
|
||||||
export default class PluginOperationJob implements IJob<never, string>
|
|
||||||
{
|
|
||||||
static id = "plugin-operation-job" as const;
|
|
||||||
static dataSchema = z.never();
|
|
||||||
group = "plugin-operations";
|
|
||||||
operation: "add" | "update" | "remove";
|
|
||||||
plugin: string;
|
|
||||||
|
|
||||||
constructor(operation: "add" | "update" | "remove", plugin: string)
|
|
||||||
{
|
|
||||||
this.plugin = plugin;
|
|
||||||
this.operation = operation;
|
|
||||||
}
|
|
||||||
|
|
||||||
async start (context: JobContext<IJob<never, string>, never, string>)
|
|
||||||
{
|
|
||||||
switch (this.operation)
|
|
||||||
{
|
|
||||||
case "add":
|
|
||||||
//TODO: find the latest compatible version with the current sdk version
|
|
||||||
const addResponse = await runBunPackageCommand(["add", this.plugin, '--omit', 'peer', "--registry", PluginRegistry]);
|
|
||||||
console.log(addResponse);
|
|
||||||
const addPlugin = await getPlugin(this.plugin, plugins);
|
|
||||||
if (!addPlugin) throw new Error(`${this.plugin} Not Found`);
|
|
||||||
await registerPlugin(addPlugin, 'store', plugins);
|
|
||||||
break;
|
|
||||||
case "update":
|
|
||||||
const existingPlugin = plugins.plugins[this.plugin];
|
|
||||||
if (!existingPlugin) throw new Error(`${this.plugin} Not Found`);
|
|
||||||
if (!existingPlugin.update?.new) throw new Error(`No Update Found`);
|
|
||||||
let updatePlugin = await getPlugin(this.plugin, plugins);
|
|
||||||
if (!updatePlugin) throw new Error(`${this.plugin} Not Found`);
|
|
||||||
await unregisterPlugin(this.plugin, plugins);
|
|
||||||
const updateResponse = await runBunPackageCommand(["update", `${this.plugin}@${existingPlugin.update?.new}`, '--omit', 'peer', "--registry", PluginRegistry, '--latest']);
|
|
||||||
console.log(updateResponse);
|
|
||||||
updatePlugin = await getPlugin(this.plugin, plugins);
|
|
||||||
if (!updatePlugin) throw new Error(`Something Went Wrong during update. Missing Plugin: ${this.plugin}`);
|
|
||||||
await registerPlugin(updatePlugin, existingPlugin.source, plugins);
|
|
||||||
break;
|
|
||||||
case "remove":
|
|
||||||
const removePlugin = plugins.plugins[this.plugin];
|
|
||||||
if (!removePlugin) throw new Error(`${this.plugin} Not Found`);
|
|
||||||
if (!canUninstall(removePlugin.description, removePlugin.source))
|
|
||||||
{
|
|
||||||
throw new Error("Uninstall Not Allowed");
|
|
||||||
}
|
|
||||||
const response = await runBunPackageCommand(['remove', this.plugin, "--registry", PluginRegistry, '--omit', 'peer']);
|
|
||||||
console.log(response);
|
|
||||||
await unregisterPlugin(this.plugin, plugins);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
|
import { IJob, JobContext } from "../task-queue";
|
||||||
import { plugins } from "../app";
|
import { plugins } from "../app";
|
||||||
|
|
||||||
export default class ReloadPluginsJob implements IJob<never, string>
|
export default class ReloadPluginsJob implements IJob<never, string>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
|
import { IJob, JobContext } from "../task-queue";
|
||||||
import { events } from "../app";
|
import { cleanPromise, cleanup, events, plugins } from "../app";
|
||||||
|
import fs from 'fs/promises';
|
||||||
import { Downloader } from "@/bun/utils/downloader";
|
import { Downloader } from "@/bun/utils/downloader";
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import { DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
|
||||||
import { sleep } from "bun";
|
|
||||||
|
|
||||||
export class TestDownloadJob implements IJob<DownloadJobData, string>
|
|
||||||
{
|
|
||||||
data: DownloadJobData = {
|
|
||||||
speed: 1686,
|
|
||||||
downloaded: 0,
|
|
||||||
total: 6615841,
|
|
||||||
name: "Test Download Job"
|
|
||||||
};
|
|
||||||
|
|
||||||
group = "test-download";
|
|
||||||
|
|
||||||
async start (context: JobContext<IJob<DownloadJobData, string>, DownloadJobData, string>): Promise<any>
|
|
||||||
{
|
|
||||||
for (let i = 0; i < 10; i++)
|
|
||||||
{
|
|
||||||
await sleep(1000);
|
|
||||||
context.setProgress(i / 10 * 100, 'download');
|
|
||||||
if (context.abortSignal.aborted) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
exposeData (): DownloadJobData
|
|
||||||
{
|
|
||||||
return this.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
|
import { IJob, JobContext } from "../task-queue";
|
||||||
import secrets from "../secrets";
|
import secrets from "../secrets";
|
||||||
import open from "open";
|
import open from "open";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
|
||||||
67
src/bun/api/jobs/update-store.ts
Normal file
67
src/bun/api/jobs/update-store.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { ensureDir } from "fs-extra";
|
||||||
|
import { IJob, JobContext } from "../task-queue";
|
||||||
|
import { getStoreRootFolder } from "../store/services/gamesService";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export default class UpdateStoreJob implements IJob<never, never>
|
||||||
|
{
|
||||||
|
static id = "update-store" as const;
|
||||||
|
static dataSchema = z.never();
|
||||||
|
packageName: string;
|
||||||
|
registry: URL;
|
||||||
|
storeVersion: string;
|
||||||
|
|
||||||
|
constructor()
|
||||||
|
{
|
||||||
|
this.packageName = process.env.STORE_PACKAGE_NAME ?? "@simeonradivoev/gameflow-store";
|
||||||
|
this.registry = new URL(process.env.STORE_REGISTRY ?? "https://registry.npmjs.org");
|
||||||
|
this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
async start (context: JobContext<UpdateStoreJob, never, never>)
|
||||||
|
{
|
||||||
|
if (process.env.CUSTOM_STORE_PATH) return;
|
||||||
|
|
||||||
|
const tempCache = path.join(tmpdir(), "gameflow-bun-cache");
|
||||||
|
const storeFolder = getStoreRootFolder();
|
||||||
|
await ensureDir(storeFolder);
|
||||||
|
|
||||||
|
console.log("Adding Store Package");
|
||||||
|
let proc = Bun.spawn([process.execPath, "add", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], {
|
||||||
|
cwd: storeFolder,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
env: {
|
||||||
|
BUN_BE_BUN: "1",
|
||||||
|
BUN_INSTALL_CACHE_DIR: tempCache
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = await new Response(proc.stdout).text();
|
||||||
|
console.log(stdout);
|
||||||
|
let stderr = await new Response(proc.stderr).text();
|
||||||
|
if (stderr)
|
||||||
|
console.error(stderr);
|
||||||
|
await proc.exited;
|
||||||
|
|
||||||
|
console.log("Updating Store Package");
|
||||||
|
proc = Bun.spawn([process.execPath, "update", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], {
|
||||||
|
cwd: storeFolder,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
env: {
|
||||||
|
BUN_BE_BUN: "1",
|
||||||
|
BUN_INSTALL_CACHE_DIR: tempCache
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stdout = await new Response(proc.stdout).text();
|
||||||
|
console.log(stdout);
|
||||||
|
stderr = await new Response(proc.stderr).text();
|
||||||
|
if (stderr)
|
||||||
|
console.error(stderr);
|
||||||
|
await proc.exited;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
|
|
||||||
import { FrontendNotification } from '@simeonradivoev/gameflow-sdk/shared';
|
|
||||||
import { events } from './app';
|
import { events } from './app';
|
||||||
|
|
||||||
export default function buildNotificationsStream ()
|
export default function buildNotificationsStream ()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
import { PluginContextType, PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { config } from "@/bun/api/app";
|
import { config } from "@/bun/api/app";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
|
||||||
import { config } from "@/bun/api/app";
|
import { config } from "@/bun/api/app";
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
|
|
||||||
import { config } from "@/bun/api/app";
|
import { config } from "@/bun/api/app";
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
import defaultConfig from './PCSX2.ini' with { type: 'file' };
|
import defaultConfig from './PCSX2.ini' with { type: 'file' };
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import ini from 'ini';
|
import ini from 'ini';
|
||||||
import { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export default class PCSX2Integration implements PluginType
|
export default class PCSX2Integration implements PluginType
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import { config } from "@/bun/api/app";
|
import { config } from "@/bun/api/app";
|
||||||
import configFilePathWin32 from './win32/ppsspp.ini' with { type: 'file' };
|
import configFilePathWin32 from './win32/ppsspp.ini' with { type: 'file' };
|
||||||
|
|
@ -11,7 +11,6 @@ import { ensureDir } from "fs-extra";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import ini from 'ini';
|
import ini from 'ini';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export default class PPSSPPIntegration implements PluginType
|
export default class PPSSPPIntegration implements PluginType
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import { config } from "@/bun/api/app";
|
import { config } from "@/bun/api/app";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
import { platform } from "os";
|
||||||
|
|
||||||
const SECTOR_SIZE = 0x800;
|
const SECTOR_SIZE = 0x800;
|
||||||
const MAGIC = "MICROSOFT*XBOX*MEDIA";
|
const MAGIC = "MICROSOFT*XBOX*MEDIA";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import { GameflowHooks } from "@simeonradivoev/gameflow-sdk";
|
import { GameflowHooks } from "@/bun/api/hooks/app";
|
||||||
import { config } from "@/bun/api/app";
|
import { config } from "@/bun/api/app";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import { config, customEmulators, db, emulatorsDb } from "@/bun/api/app";
|
import { config, customEmulators, db, emulatorsDb } from "@/bun/api/app";
|
||||||
import * as emulatorSchema from '@schema/emulators';
|
import * as emulatorSchema from '@schema/emulators';
|
||||||
|
|
@ -13,7 +13,6 @@ import { findStoreEmulatorExec } from "@/bun/api/games/services/launchGameServic
|
||||||
import { which } from "bun";
|
import { which } from "bun";
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { getLocalGameMatch } from "@/bun/api/games/services/utils";
|
import { getLocalGameMatch } from "@/bun/api/games/services/utils";
|
||||||
import { CommandEntry, EmulatorSourceEntryType } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export default class IgdbIntegration implements PluginType
|
export default class IgdbIntegration implements PluginType
|
||||||
{
|
{
|
||||||
|
|
@ -289,7 +288,7 @@ export default class IgdbIntegration implements PluginType
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadPath = config.get('downloadPath');
|
const downloadPath = config.get('downloadPath');
|
||||||
const gamePath = path.isAbsolute(data.gamePath) ? data.gamePath : path.join(downloadPath, data.gamePath);
|
const gamePath = path.join(downloadPath, data.gamePath);
|
||||||
|
|
||||||
const validFiles: string[] = await this.getRomFilePaths(gamePath, { systemSlug: data.systemSlug, mainGlob: data.mainGlob });
|
const validFiles: string[] = await this.getRomFilePaths(gamePath, { systemSlug: data.systemSlug, mainGlob: data.mainGlob });
|
||||||
|
|
||||||
|
|
@ -450,7 +449,7 @@ export default class IgdbIntegration implements PluginType
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadPath = config.get('downloadPath');
|
const downloadPath = config.get('downloadPath');
|
||||||
const path_fs = path.isAbsolute(localGame.path_fs) ? localGame.path_fs : path.join(downloadPath, localGame.path_fs);
|
const path_fs = path.join(downloadPath, localGame.path_fs);
|
||||||
|
|
||||||
return this.getRomFilePaths(path_fs, { systemSlug: localGame.platform.es_slug ?? undefined, mainGlob: localGame.main_glob });
|
return this.getRomFilePaths(path_fs, { systemSlug: localGame.platform.es_slug ?? undefined, mainGlob: localGame.main_glob });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "Rclone integration for syncing saves",
|
"description": "Rclone integration for syncing saves",
|
||||||
"main": "./rclone.ts",
|
"main": "./rclone.ts",
|
||||||
"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",
|
"icon": "https://forum.rclone.org/uploads/default/original/2X/8/8a14ccd453604987a64820f56c6afa75c229aa17.png",
|
||||||
"category": "saves",
|
"category": "saves",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"integration",
|
"integration",
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import { config, db, events } from "@/bun/api/app";
|
import { config, db, events } from "@/bun/api/app";
|
||||||
import path from 'node:path';
|
import path, { dirname } from 'node:path';
|
||||||
import unzip from 'unzip-stream';
|
import unzip from 'unzip-stream';
|
||||||
import { ensureDir } from "fs-extra";
|
import { chmodSync, ensureDir } from "fs-extra";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import { pipeline } from "node:stream/promises";
|
import { pipeline } from "node:stream/promises";
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { randomUUIDv7 } from "bun";
|
import { randomUUIDv7, sleep } from "bun";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { createInterface } from "node:readline";
|
import { createInterface } from "node:readline";
|
||||||
import { getLocalGameMatch } from "@/bun/api/games/services/utils";
|
import { getLocalGameMatch } from "@/bun/api/games/services/utils";
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import secrets from "@/bun/api/secrets";
|
import secrets from "@/bun/api/secrets";
|
||||||
import PQueue from 'p-queue';
|
import PQueue from 'p-queue';
|
||||||
import * as igdb from '@phalcode/ts-igdb-client';
|
import * as igdb from '@phalcode/ts-igdb-client';
|
||||||
import { checkLoginAndRefreshTwitch } from "@/bun/api/auth";
|
import { checkLoginAndRefreshTwitch } from "@/bun/api/auth";
|
||||||
import { GameLookup } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export default class IgdbIntegration implements PluginType
|
export default class IgdbIntegration implements PluginType
|
||||||
{
|
{
|
||||||
|
|
@ -43,56 +42,18 @@ export default class IgdbIntegration implements PluginType
|
||||||
{
|
{
|
||||||
await checkLoginAndRefreshTwitch();
|
await checkLoginAndRefreshTwitch();
|
||||||
|
|
||||||
ctx.hooks.games.gameLookup.tapPromise(desc.name, async (matches, { source, id, search }) =>
|
ctx.hooks.games.gameLookup.tapPromise(desc.name, async ({ source, id }) =>
|
||||||
{
|
{
|
||||||
if (!process.env.TWITCH_CLIENT_ID) return matches;
|
if (!process.env.TWITCH_CLIENT_ID) return;
|
||||||
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
|
if (source !== 'igdb') return;
|
||||||
if (!access_token)
|
|
||||||
{
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((source === 'igdb' && id) || search)
|
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 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();
|
||||||
const { data: games } = await this.queue.add(() => client.request('games')
|
return { screenshotUrls: data.filter(s => s.url).map(s => `https://images.igdb.com/igdb/image/upload/t_720p/${s.image_id}.webp`) };
|
||||||
.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.set(desc.name, 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 ?? undefined,
|
|
||||||
platforms: g.platforms?.map(p => ({ id: p.id!, name: p.abbreviation, displayName: p.name!, slug: p.slug! })) ?? [],
|
|
||||||
slug: g.slug
|
|
||||||
};
|
|
||||||
|
|
||||||
return lookup;
|
|
||||||
}));
|
|
||||||
|
|
||||||
return matches;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return matches.set(desc.name, []);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) =>
|
ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) =>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "IGDB Metadata Integration",
|
"description": "IGDB Metadata Integration",
|
||||||
"main": "./igdb.ts",
|
"main": "./igdb.ts",
|
||||||
"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",
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/IGDB_logo.svg/1920px-IGDB_logo.svg.png",
|
||||||
"category": "sources",
|
"category": "sources",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"integration",
|
"integration",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
|
|
||||||
|
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, PlatformSchema, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
|
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, PlatformSchema, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
|
||||||
import { config, events } from "@/bun/api/app";
|
import { config, events } from "@/bun/api/app";
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { hashFile, isArchive, isSteamDeckGameMode } from "@/bun/utils";
|
import { hashFile, isSteamDeckGameMode } from "@/bun/utils";
|
||||||
import { CACHE_KEYS, getOrCached } from "@/bun/api/cache";
|
import { CACHE_KEYS, getOrCached } from "@/bun/api/cache";
|
||||||
import secrets from "@/bun/api/secrets";
|
import secrets from "@/bun/api/secrets";
|
||||||
import { getAuthToken } from "@/clients/romm/core/auth.gen";
|
import { getAuthToken } from "@/clients/romm/core/auth.gen";
|
||||||
|
|
@ -14,12 +14,9 @@ import { client } from "@/clients/romm/client.gen";
|
||||||
import { validateGameSource } from "@/bun/api/games/services/statusService";
|
import { validateGameSource } from "@/bun/api/games/services/statusService";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { checkLoginAndRefreshRomm } from "@/bun/api/auth";
|
import { checkLoginAndRefreshRomm } from "@/bun/api/auth";
|
||||||
import { DownloadFileEntry, DownloadInfo, FrontEndCollection, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, FrontEndGameTypeWithIds, FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
import Conf from "conf";
|
|
||||||
|
|
||||||
const SettingsSchema = z.object({
|
const SettingsSchema = z.object({
|
||||||
savesSync: z.boolean().default(false).describe("Experimental save sync support"),
|
savesSync: z.boolean().default(false).describe("Experimental save sync support")
|
||||||
clientApiToken: z.string().optional().describe("Generate a long lived token from the ROMM server")
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type SettingsType = z.infer<typeof SettingsSchema>;
|
type SettingsType = z.infer<typeof SettingsSchema>;
|
||||||
|
|
@ -41,34 +38,26 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAccessToken (config: Conf<SettingsType>)
|
async updateClient ()
|
||||||
{
|
|
||||||
if (process.env.ROMM_CLIENT_TOKEN) return process.env.ROMM_CLIENT_TOKEN;
|
|
||||||
const client_token = config.get('clientApiToken');
|
|
||||||
if (client_token) return client_token;
|
|
||||||
return (await secrets.get({ service: 'gameflow', name: 'romm_access_token' })) ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateClient (pluginConfig: Conf<SettingsType>)
|
|
||||||
{
|
{
|
||||||
client.setConfig({
|
client.setConfig({
|
||||||
baseUrl: config.get('rommAddress'),
|
baseUrl: config.get('rommAddress'),
|
||||||
auth: (auth) =>
|
async auth (auth)
|
||||||
{
|
{
|
||||||
if (auth.scheme === 'bearer')
|
if (auth.scheme === 'bearer')
|
||||||
{
|
{
|
||||||
return this.getAccessToken(pluginConfig);
|
return (await secrets.get({ service: 'gameflow', name: 'romm_access_token' })) ?? undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAuthToken (config: Conf<SettingsType>)
|
async getAuthToken ()
|
||||||
{
|
{
|
||||||
return getAuthToken({
|
return getAuthToken({
|
||||||
scheme: 'bearer',
|
scheme: 'bearer',
|
||||||
type: "http"
|
type: "http"
|
||||||
}, async (a) => this.getAccessToken(config));
|
}, async (a) => (await secrets.get({ service: "gameflow", name: 'romm_access_token' })) ?? undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllRommPlatforms ()
|
async getAllRommPlatforms ()
|
||||||
|
|
@ -156,9 +145,9 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
{
|
{
|
||||||
this.isSteamDeck = isSteamDeckGameMode();
|
this.isSteamDeck = isSteamDeckGameMode();
|
||||||
ctx.setProgress(0, "Logging Into Romm");
|
ctx.setProgress(0, "Logging Into Romm");
|
||||||
await this.updateClient(ctx.config);
|
await this.updateClient();
|
||||||
await checkLoginAndRefreshRomm();
|
await checkLoginAndRefreshRomm();
|
||||||
await this.updateClient(ctx.config);
|
await this.updateClient();
|
||||||
|
|
||||||
ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) =>
|
ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) =>
|
||||||
{
|
{
|
||||||
|
|
@ -209,7 +198,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
{
|
{
|
||||||
if (!await this.checkRemote()) return;
|
if (!await this.checkRemote()) return;
|
||||||
if (service !== 'romm') return;
|
if (service !== 'romm') return;
|
||||||
await this.updateClient(ctx.config);
|
await this.updateClient();
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) =>
|
ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) =>
|
||||||
|
|
@ -254,7 +243,8 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
let path_fs = path.join(rom.fs_path, rom.fs_name);
|
let path_fs = path.join(rom.fs_path, rom.fs_name);
|
||||||
if (files.length === 1)
|
if (files.length === 1)
|
||||||
{
|
{
|
||||||
if (isArchive(files[0].file_name))
|
const name = files[0].file_name.toLocaleLowerCase();
|
||||||
|
if (name.endsWith('.zip') || name.endsWith('.7z') || name.endsWith('.rar'))
|
||||||
{
|
{
|
||||||
extract_path = '.';
|
extract_path = '.';
|
||||||
path_fs = path.join(rom.fs_path, rom.slug ?? rom.fs_name_no_ext);
|
path_fs = path.join(rom.fs_path, rom.slug ?? rom.fs_name_no_ext);
|
||||||
|
|
@ -263,8 +253,6 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
|
|
||||||
const info: DownloadInfo = {
|
const info: DownloadInfo = {
|
||||||
platform: {
|
platform: {
|
||||||
source: 'romm',
|
|
||||||
id: String(rommPlatform.id),
|
|
||||||
slug: rommPlatform.slug,
|
slug: rommPlatform.slug,
|
||||||
name: rommPlatform.name,
|
name: rommPlatform.name,
|
||||||
family_name: rommPlatform.family_name ?? undefined
|
family_name: rommPlatform.family_name ?? undefined
|
||||||
|
|
@ -282,7 +270,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
system_slug: rommPlatform.slug,
|
system_slug: rommPlatform.slug,
|
||||||
metadata: rom.metadatum,
|
metadata: rom.metadatum,
|
||||||
files,
|
files,
|
||||||
auth: await this.getAuthToken(ctx.config),
|
auth: await this.getAuthToken(),
|
||||||
extract_path,
|
extract_path,
|
||||||
id: "romm"
|
id: "romm"
|
||||||
};
|
};
|
||||||
|
|
@ -319,7 +307,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (files.length > 0) return { files, auth: await this.getAuthToken(ctx.config) };
|
if (files.length > 0) return { files, auth: await this.getAuthToken() };
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) =>
|
ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) =>
|
||||||
|
|
@ -454,7 +442,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
const rommSlot = saveFiles.data.slots.find(s => s.slot === 'gameflow' && s.latest.file_name_no_tags === slot);
|
const rommSlot = saveFiles.data.slots.find(s => s.slot === 'gameflow' && s.latest.file_name_no_tags === slot);
|
||||||
if (rommSlot)
|
if (rommSlot)
|
||||||
{
|
{
|
||||||
const auth = await this.getAuthToken(ctx.config);
|
const auth = await this.getAuthToken();
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (auth)
|
if (auth)
|
||||||
headers['Authorization'] = auth;
|
headers['Authorization'] = auth;
|
||||||
|
|
@ -544,7 +532,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
url.searchParams.set('emulator', command.emulator);
|
url.searchParams.set('emulator', command.emulator);
|
||||||
url.searchParams.set('overwrite', "true");
|
url.searchParams.set('overwrite', "true");
|
||||||
|
|
||||||
const auth = await this.getAuthToken(ctx.config);
|
const auth = await this.getAuthToken();
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (auth)
|
if (auth)
|
||||||
headers['Authorization'] = auth;
|
headers['Authorization'] = auth;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"name": "com.simeonradivoev.gameflow.store",
|
"name": "com.simeonradivoev.gameflow.store",
|
||||||
"displayName": "Gameflow Store Integration",
|
"displayName": "Gameflow Store",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "The internal gameflow store integration. This is the logic of the store that uses the data only store package",
|
"description": "The internal gameflow store",
|
||||||
"main": "./store.ts",
|
"main": "./store.ts",
|
||||||
"category": "sources",
|
"category": "sources",
|
||||||
"canDisable": false,
|
"canDisable": false,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { getStoreFolder } from "@/bun/api/store/services/gamesService";
|
import { getStoreFolder } from "@/bun/api/store/services/gamesService";
|
||||||
|
import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, StoreDownloadType, StoreGameSchema, StoreGameType } from "@/shared/constants";
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import * as appSchema from '@schema/app';
|
import * as appSchema from '@schema/app';
|
||||||
|
|
@ -11,8 +12,6 @@ import { shuffleInPlace } from "@/bun/utils";
|
||||||
import mustache from "mustache";
|
import mustache from "mustache";
|
||||||
import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService";
|
import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { CommandEntry, EmulatorSourceEntryType, EmulatorSystem, FrontEndEmulator, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, SaveFileChange, EmulatorDownloadInfoType, StoreDownloadType, StoreGameType, EmulatorPackageType, EmulatorDownloadInfoSchema, StoreGameSchema } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
import { isUrl } from "@/shared/utils";
|
|
||||||
|
|
||||||
export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; })
|
export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; })
|
||||||
{
|
{
|
||||||
|
|
@ -40,7 +39,7 @@ export async function getStoreGame (id: string)
|
||||||
|
|
||||||
function convertStoreMediaToPath (c: string)
|
function convertStoreMediaToPath (c: string)
|
||||||
{
|
{
|
||||||
if (isUrl(c))
|
if (c.startsWith('http'))
|
||||||
{
|
{
|
||||||
return `/api/romm/image?url=${encodeURIComponent(c)}`;
|
return `/api/romm/image?url=${encodeURIComponent(c)}`;
|
||||||
} else
|
} else
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,20 @@
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import path, { } from 'node:path';
|
import path, { basename, dirname } from 'node:path';
|
||||||
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService";
|
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService";
|
||||||
import { Glob, pathToFileURL, which } from "bun";
|
import { Glob, pathToFileURL } from "bun";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import * as emulatorSchema from '@schema/emulators';
|
import * as emulatorSchema from '@schema/emulators';
|
||||||
|
|
||||||
import { config, emulatorsDb, taskQueue } from "@/bun/api/app";
|
import { config, emulatorsDb, taskQueue } from "@/bun/api/app";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { getSourceGameDetailed } from "@/bun/api/games/services/utils";
|
import { getSourceGameDetailed } from "@/bun/api/games/services/utils";
|
||||||
import EnsureStore from "@/bun/api/jobs/ensure-store";
|
import UpdateStoreJob from "@/bun/api/jobs/update-store";
|
||||||
import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService";
|
import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService";
|
||||||
import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services";
|
import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services";
|
||||||
import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
import { 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 { path7za } from "7zip-bin";
|
||||||
import Seven from 'node-7z';
|
|
||||||
|
|
||||||
export default class StoreIntegration implements PluginType
|
export default class RommIntegration implements PluginType
|
||||||
{
|
{
|
||||||
eventsNames = [{ id: 'updateStore', title: "Update Store", description: "Update the Store Manifest", action: "Update" }];
|
eventsNames = [{ id: 'updateStore', title: "Update Store", description: "Update the Store Manifest", action: "Update" }];
|
||||||
|
|
||||||
|
|
@ -29,7 +23,7 @@ export default class StoreIntegration implements PluginType
|
||||||
switch (e)
|
switch (e)
|
||||||
{
|
{
|
||||||
case 'updateStore':
|
case 'updateStore':
|
||||||
await taskQueue.enqueue(EnsureStore.id, new EnsureStore());
|
await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
|
||||||
return { reload: true };
|
return { reload: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -38,7 +32,7 @@ export default class StoreIntegration implements PluginType
|
||||||
{
|
{
|
||||||
console.log("Store Directory is ", getStoreFolder());
|
console.log("Store Directory is ", getStoreFolder());
|
||||||
ctx.setProgress(0, "Updating Store");
|
ctx.setProgress(0, "Updating Store");
|
||||||
await taskQueue.enqueue(EnsureStore.id, new EnsureStore());
|
await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
|
||||||
}
|
}
|
||||||
|
|
||||||
async load (ctx: PluginLoadingContextType)
|
async load (ctx: PluginLoadingContextType)
|
||||||
|
|
@ -157,8 +151,7 @@ export default class StoreIntegration implements PluginType
|
||||||
if (!validDownload || !validDownload.bin) return;
|
if (!validDownload || !validDownload.bin) return;
|
||||||
const glob = new Glob(validDownload.bin);
|
const glob = new Glob(validDownload.bin);
|
||||||
const files = await Array.fromAsync(glob.scan({ cwd: emulatorPath }));
|
const files = await Array.fromAsync(glob.scan({ cwd: emulatorPath }));
|
||||||
// es-de also searches for store executables so there might be duplicates, check first.
|
if (files.length > 0)
|
||||||
if (files.length > 0 && !sources.find(s => s.type === 'store'))
|
|
||||||
{
|
{
|
||||||
sources.push({ binPath: path.join(emulatorPath, files[0]), exists: true, rootPath: emulatorPath, type: 'store' });
|
sources.push({ binPath: path.join(emulatorPath, files[0]), exists: true, rootPath: emulatorPath, type: 'store' });
|
||||||
}
|
}
|
||||||
|
|
@ -301,7 +294,7 @@ export default class StoreIntegration implements PluginType
|
||||||
|
|
||||||
const info: DownloadInfo = {
|
const info: DownloadInfo = {
|
||||||
id: validDownload.id,
|
id: validDownload.id,
|
||||||
coverUrl: game.covers?.[0] ? isUrl(game.covers[0]) ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "",
|
coverUrl: game.covers?.[0] ? game.covers[0].startsWith('http') ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "",
|
||||||
screenshotUrls: game.screenshots ?? [],
|
screenshotUrls: game.screenshots ?? [],
|
||||||
files: [{
|
files: [{
|
||||||
url: new URL(validDownload.url),
|
url: new URL(validDownload.url),
|
||||||
|
|
@ -321,8 +314,6 @@ export default class StoreIntegration implements PluginType
|
||||||
version_system: validDownload.system,
|
version_system: validDownload.system,
|
||||||
version_source: validDownload.id,
|
version_source: validDownload.id,
|
||||||
platform: {
|
platform: {
|
||||||
source: 'store',
|
|
||||||
id: system,
|
|
||||||
slug: system,
|
slug: system,
|
||||||
name: system
|
name: system
|
||||||
}
|
}
|
||||||
|
|
@ -331,129 +322,5 @@ export default class StoreIntegration implements PluginType
|
||||||
return info;
|
return info;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.hooks.downloadFiles.tapPromise(desc.name, async ({ id, files, downloadPath, abortSignal, auth, updateProgress }) =>
|
|
||||||
{
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (auth)
|
|
||||||
headers['Authorization'] = auth;
|
|
||||||
const downloader = new Downloader(id,
|
|
||||||
files,
|
|
||||||
downloadPath,
|
|
||||||
{
|
|
||||||
signal: abortSignal,
|
|
||||||
headers,
|
|
||||||
onProgress: updateProgress,
|
|
||||||
});
|
|
||||||
|
|
||||||
const downloadedFiles = await downloader.start();
|
|
||||||
if (downloadedFiles)
|
|
||||||
{
|
|
||||||
return { source: desc.name, files: downloadedFiles };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.hooks.postDownloadFiles.tapPromise(desc.name, async ({ files, extract_path, source, downloadPath, path_fs }) =>
|
|
||||||
{
|
|
||||||
if (extract_path && files && source === desc.name)
|
|
||||||
{
|
|
||||||
let progress = 0;
|
|
||||||
const progressDelta = 1 / files.length;
|
|
||||||
const extractPath = path.join(downloadPath, path_fs ?? '', extract_path);
|
|
||||||
|
|
||||||
for (const filePath of files)
|
|
||||||
{
|
|
||||||
await new Promise(async (resolve, reject) =>
|
|
||||||
{
|
|
||||||
let sevenZipPath = process.env.ZIP7_PATH ?? path7za;
|
|
||||||
|
|
||||||
if (filePath.endsWith('.rar'))
|
|
||||||
{
|
|
||||||
let newPath: string | undefined;
|
|
||||||
if (process.platform === 'win32' && await fs.exists("C:\\Program Files\\7-Zip\\7z.exe"))
|
|
||||||
{
|
|
||||||
newPath = "C:\\Program Files\\7-Zip\\7z.exe";
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
newPath = which('7z') ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newPath)
|
|
||||||
{
|
|
||||||
await fs.rm(filePath);
|
|
||||||
reject(new Error("No RAR Support"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sevenZipPath = newPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rejected = false;
|
|
||||||
const seven = Seven.extractFull(filePath, extractPath, { $bin: sevenZipPath, $progress: true });
|
|
||||||
seven.on('progress', p =>
|
|
||||||
{
|
|
||||||
ctx.setProgress?.(progress + p.percent * progressDelta, "extract", {
|
|
||||||
speed: 0,
|
|
||||||
total: 0,
|
|
||||||
downloaded: 0
|
|
||||||
});
|
|
||||||
});
|
|
||||||
seven.on('error', e =>
|
|
||||||
{
|
|
||||||
reject(e);
|
|
||||||
rejected = true;
|
|
||||||
});
|
|
||||||
seven.on('end', async () =>
|
|
||||||
{
|
|
||||||
if (rejected) return;
|
|
||||||
await fs.rm(filePath);
|
|
||||||
resolve(true);
|
|
||||||
});
|
|
||||||
}).catch(async e =>
|
|
||||||
{
|
|
||||||
if (filePath.endsWith('.zip'))
|
|
||||||
{
|
|
||||||
ctx.setProgress?.(0, "extract", {});
|
|
||||||
console.error(e);
|
|
||||||
console.warn("Could not extract", filePath, "with 7zip trying zip extractor");
|
|
||||||
await ensureDir(extractPath);
|
|
||||||
const zip = new StreamZip.async({ file: filePath });
|
|
||||||
let entryCount = await zip.entriesCount;
|
|
||||||
let entryCounter = entryCount;
|
|
||||||
zip.on('extract', (entry, outPath) =>
|
|
||||||
{
|
|
||||||
entryCounter--;
|
|
||||||
ctx.setProgress?.(progress + (1 - (entryCounter / entryCount)) * 100 * progressDelta, "extract", {});
|
|
||||||
});
|
|
||||||
const count = await zip.extract(null, extractPath);
|
|
||||||
console.log(`Extracted ${count} entries`);
|
|
||||||
await zip.close();
|
|
||||||
await fs.rm(filePath);
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
progress += progressDelta * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if 1 root folder we need to get rid of
|
|
||||||
const contents = await fs.readdir(extractPath);
|
|
||||||
if (contents.length === 1)
|
|
||||||
{
|
|
||||||
const stat = await fs.stat(path.join(extractPath, contents[0]));
|
|
||||||
if (stat.isDirectory())
|
|
||||||
{
|
|
||||||
console.log("Found 1 root folder, using that instead");
|
|
||||||
const tmpGameFolder = `${extractPath} (1)`;
|
|
||||||
await move(path.join(extractPath, contents[0]), tmpGameFolder, { overwrite: true });
|
|
||||||
await move(tmpGameFolder, extractPath, { overwrite: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [extractPath];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import { GameflowHooks } from "@simeonradivoev/gameflow-sdk";
|
import { GameflowHooks } from "../hooks/app";
|
||||||
import { PluginDescriptionType, PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
import { PluginDescriptionType, PluginLoadingContextType, PluginType } from "../../types/typesc.schema";
|
||||||
import { config, events, taskQueue } from "../app";
|
import { config } from "../app";
|
||||||
import Conf from "conf";
|
import Conf from "conf";
|
||||||
import projectPackage from '~/package.json';
|
import projectPackage from '~/package.json';
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { PluginSourceType, PluginUpdateCheck } from "@simeonradivoev/gameflow-sdk/shared";
|
import { EventEmitter } from "node:stream";
|
||||||
import { getUpdates } from "./services";
|
|
||||||
import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json';
|
|
||||||
import { semver } from "bun";
|
|
||||||
|
|
||||||
export const pluginZodRegistry = z.registry<{
|
export const pluginZodRegistry = z.registry<{
|
||||||
requiresRestart?: boolean;
|
requiresRestart?: boolean;
|
||||||
|
|
@ -24,19 +21,9 @@ export class PluginManager
|
||||||
description: PluginDescriptionType,
|
description: PluginDescriptionType,
|
||||||
source: PluginSourceType;
|
source: PluginSourceType;
|
||||||
config?: Conf;
|
config?: Conf;
|
||||||
update?: PluginUpdateCheck;
|
|
||||||
incompatible?: boolean;
|
|
||||||
|
|
||||||
}> = {};
|
}> = {};
|
||||||
|
|
||||||
unregister (id: string)
|
|
||||||
{
|
|
||||||
if (!this.plugins[id]) return false;
|
|
||||||
delete this.plugins[id];
|
|
||||||
console.log("Plugin", id, "unregistered");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType)
|
register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
@ -81,33 +68,16 @@ export class PluginManager
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
checkValidity (plugin: PluginDescriptionType)
|
private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; })
|
||||||
{
|
|
||||||
const sdkDep = plugin.peerDependencies?.[sdkPkg.name];
|
|
||||||
if (sdkDep)
|
|
||||||
{
|
|
||||||
return semver.satisfies(sdkPkg.version, sdkDep);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; }, update: string | undefined | null)
|
|
||||||
{
|
{
|
||||||
const plugin = this.plugins[name];
|
const plugin = this.plugins[name];
|
||||||
if (plugin)
|
if (plugin)
|
||||||
{
|
{
|
||||||
plugin.update = update && !semver.satisfies(plugin.description.version, update) ? { current: plugin.description.version, new: update } : undefined;
|
|
||||||
|
|
||||||
const ctx: PluginLoadingContextType = {
|
const ctx: PluginLoadingContextType = {
|
||||||
hooks: this.hooks,
|
hooks: this.hooks,
|
||||||
setProgress: reloadCtx.setProgress.bind(reloadCtx),
|
setProgress: reloadCtx.setProgress.bind(reloadCtx),
|
||||||
config: plugin.config as any,
|
config: plugin.config as any,
|
||||||
zodRegistry: pluginZodRegistry,
|
zodRegistry: pluginZodRegistry
|
||||||
app: {
|
|
||||||
config,
|
|
||||||
events,
|
|
||||||
taskQueue
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (plugin.loaded)
|
if (plugin.loaded)
|
||||||
|
|
@ -118,14 +88,7 @@ export class PluginManager
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
plugin.incompatible = !this.checkValidity(plugin.description);
|
if (plugin.enabled || plugin.description.canDisable === false)
|
||||||
if (plugin.incompatible)
|
|
||||||
{
|
|
||||||
console.error(plugin.description.name, "Incompatible sdk verison");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (plugin.enabled || plugin.description.canDisable === false || plugin.description.name === '@simeonradivoev/gameflow-store')
|
|
||||||
{
|
{
|
||||||
console.log("Loading Plugin", plugin.description.name);
|
console.log("Loading Plugin", plugin.description.name);
|
||||||
await plugin.plugin.load(ctx);
|
await plugin.plugin.load(ctx);
|
||||||
|
|
@ -143,13 +106,10 @@ export class PluginManager
|
||||||
async reloadAll (ctx: { setProgress: (progress: number, state: string) => void; })
|
async reloadAll (ctx: { setProgress: (progress: number, state: string) => void; })
|
||||||
{
|
{
|
||||||
this.hooks = new GameflowHooks();
|
this.hooks = new GameflowHooks();
|
||||||
|
|
||||||
const outdated = await getUpdates();
|
|
||||||
|
|
||||||
for await (const id of Object.keys(this.plugins))
|
for await (const id of Object.keys(this.plugins))
|
||||||
{
|
{
|
||||||
ctx.setProgress(0, `Loading ${id}`);
|
ctx.setProgress(0, `Loading ${id}`);
|
||||||
await this.reload(id, ctx, outdated.find(i => i.package === id)?.update);
|
await this.reload(id, ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,6 @@ import { plugins, taskQueue } from "../app";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { toggleElementInConfig } from "@/bun/utils";
|
import { toggleElementInConfig } from "@/bun/utils";
|
||||||
import ReloadPluginsJob from "../jobs/reload-plugins-job";
|
import ReloadPluginsJob from "../jobs/reload-plugins-job";
|
||||||
import { FrontendPlugin } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
import { canDisable, canUninstall } from "./services";
|
|
||||||
import PluginOperationJob from "../jobs/plugin-operation-job";
|
|
||||||
|
|
||||||
export default new Elysia({ prefix: '/plugins' })
|
export default new Elysia({ prefix: '/plugins' })
|
||||||
.get('/', async () =>
|
.get('/', async () =>
|
||||||
|
|
@ -19,27 +16,25 @@ export default new Elysia({ prefix: '/plugins' })
|
||||||
description: p.description.description,
|
description: p.description.description,
|
||||||
source: p.source,
|
source: p.source,
|
||||||
version: p.description.version,
|
version: p.description.version,
|
||||||
canDisable: canDisable(p.description),
|
canDisable: p.description.canDisable ?? true,
|
||||||
icon: p.description.icon,
|
icon: p.description.icon,
|
||||||
category: p.description.category,
|
category: p.description.category,
|
||||||
hasSettings: !!p.config || !!p.plugin.eventsNames,
|
hasSettings: !!p.config || !!p.plugin.eventsNames
|
||||||
canUninstall: canUninstall(p.description, p.source),
|
|
||||||
update: p.update
|
|
||||||
};
|
};
|
||||||
return plugin;
|
return plugin;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.get('/:id', async ({ params: { id } }) =>
|
.get('/:id', async ({ params: { id } }) =>
|
||||||
{
|
{
|
||||||
const plugin = plugins.plugins[decodeURIComponent(id)];
|
const plugin = plugins.plugins[id];
|
||||||
return { ...plugin.description, update: plugin.update };
|
return plugin.description;
|
||||||
})
|
})
|
||||||
.post('/:id', async ({ params: { id }, body: { enabled } }) =>
|
.post('/:id', async ({ params: { id }, body: { enabled } }) =>
|
||||||
{
|
{
|
||||||
const plugin = plugins.plugins[decodeURIComponent(id)];
|
const plugin = plugins.plugins[id];
|
||||||
if (plugin)
|
if (plugin)
|
||||||
{
|
{
|
||||||
if (!canDisable(plugin.description))
|
if (plugin.description.canDisable === false)
|
||||||
{
|
{
|
||||||
return status("Forbidden");
|
return status("Forbidden");
|
||||||
}
|
}
|
||||||
|
|
@ -52,26 +47,4 @@ export default new Elysia({ prefix: '/plugins' })
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
body: z.object({ enabled: z.boolean() })
|
body: z.object({ enabled: z.boolean() })
|
||||||
}).post('/install', async ({ body: { id } }) =>
|
|
||||||
{
|
|
||||||
if (taskQueue.hasActiveOfType(PluginOperationJob) || taskQueue.hasActiveOfType(ReloadPluginsJob)) return;
|
|
||||||
await taskQueue.enqueue(PluginOperationJob.id, new PluginOperationJob("add", id));
|
|
||||||
await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob());
|
|
||||||
}, {
|
|
||||||
body: z.object({ id: z.string() })
|
|
||||||
}).post('/update', async ({ body: { id } }) =>
|
|
||||||
{
|
|
||||||
if (taskQueue.hasActiveOfType(PluginOperationJob) || taskQueue.hasActiveOfType(ReloadPluginsJob)) return;
|
|
||||||
await taskQueue.enqueue(PluginOperationJob.id, new PluginOperationJob("update", id));
|
|
||||||
await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob());
|
|
||||||
}, {
|
|
||||||
body: z.object({ id: z.string() })
|
|
||||||
})
|
|
||||||
.post('/uninstall', async ({ body: { id } }) =>
|
|
||||||
{
|
|
||||||
if (taskQueue.hasActiveOfType(PluginOperationJob) || taskQueue.hasActiveOfType(ReloadPluginsJob)) return;
|
|
||||||
await taskQueue.enqueue(PluginOperationJob.id, new PluginOperationJob("remove", id));
|
|
||||||
await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob());
|
|
||||||
}, {
|
|
||||||
body: z.object({ id: z.string() })
|
|
||||||
});
|
});
|
||||||
|
|
@ -11,77 +11,11 @@ import igdb from './builtin/sources/com.simeonradivoev.gameflow.igdb/package.jso
|
||||||
import store from './builtin/sources/com.simeonradivoev.gameflow.store/package.json';
|
import store from './builtin/sources/com.simeonradivoev.gameflow.store/package.json';
|
||||||
import es from './builtin/launchers/com.simeonradivoev.gameflow.es/package.json';
|
import es from './builtin/launchers/com.simeonradivoev.gameflow.es/package.json';
|
||||||
import rclone from './builtin/other/com.simeonradivoev.gameflow.rclone/package.json';
|
import rclone from './builtin/other/com.simeonradivoev.gameflow.rclone/package.json';
|
||||||
import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@simeonradivoev/gameflow-sdk";
|
import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema";
|
||||||
import path from 'node:path';
|
|
||||||
import { getStoreRootFolder } from "../store/services/gamesService";
|
|
||||||
import { getUpdates, runBunPackageCommand } from "./services";
|
|
||||||
import { PluginSourceType } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
import { taskQueue } from "../app";
|
|
||||||
import EnsureStore from "../jobs/ensure-store";
|
|
||||||
import { PluginRegistry } from "@/shared/constants";
|
|
||||||
import { IsPluginAllowed } from "@/bun/utils";
|
|
||||||
|
|
||||||
type PluginEntry = PluginDescriptionType & { load: () => Promise<any>; };
|
|
||||||
|
|
||||||
const blacklist = new Set(['@simeonradivoev/gameflow-sdk']);
|
|
||||||
|
|
||||||
export async function getPlugin (id: string, pluginManager: PluginManager)
|
|
||||||
{
|
|
||||||
const pluginPath = path.join(getStoreRootFolder(), 'node_modules', id);
|
|
||||||
const pluginPackageFile = Bun.file(path.join(pluginPath, 'package.json'));
|
|
||||||
if (await pluginPackageFile.exists())
|
|
||||||
{
|
|
||||||
const pluginPackage = await PluginDescriptionSchema.safeParseAsync(await pluginPackageFile.json());
|
|
||||||
if (pluginPackage.success)
|
|
||||||
{
|
|
||||||
const mainPath = path.join(pluginPath, pluginPackage.data.main);
|
|
||||||
if (await Bun.file(mainPath).exists())
|
|
||||||
{
|
|
||||||
const entry: PluginEntry = { ...pluginPackage.data, load: () => import(mainPath) };
|
|
||||||
return entry;
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
console.error("Main file for", id, "does not exist");
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
console.error("Invalid Package for", id, pluginPackage.error.message);
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
console.error("Package for", id, "does not exist");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unregisterPlugin (id: string, pluginManager: PluginManager)
|
|
||||||
{
|
|
||||||
return pluginManager.unregister(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerPlugin (plugin: PluginEntry, source: PluginSourceType, pluginManager: PluginManager)
|
|
||||||
{
|
|
||||||
if (!IsPluginAllowed(plugin.name))
|
|
||||||
{
|
|
||||||
console.log("Skipping", plugin.name, "plugin not allowed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = await plugin.load();
|
|
||||||
if (file.default && typeof file.default === 'function')
|
|
||||||
{
|
|
||||||
const pluginInstance = new file.default();
|
|
||||||
await PluginSchema.parseAsync(pluginInstance);
|
|
||||||
const description = await PluginDescriptionSchema.parseAsync(plugin);
|
|
||||||
pluginManager.register(pluginInstance, description, source);
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
console.log("Skipping", plugin.name, "invalid main. Has to be class with load method");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function register (pluginManager: PluginManager)
|
export default async function register (pluginManager: PluginManager)
|
||||||
{
|
{
|
||||||
const plugins: PluginEntry[] = [
|
const plugins: (PluginDescriptionType & { main: string; load: () => Promise<any>; })[] = [
|
||||||
{ ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') },
|
{ ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') },
|
||||||
{ ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') },
|
{ ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') },
|
||||||
{ ...dolphin, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin') },
|
{ ...dolphin, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin') },
|
||||||
|
|
@ -95,59 +29,26 @@ export default async function register (pluginManager: PluginManager)
|
||||||
{ ...rclone, load: () => import('./builtin/other/com.simeonradivoev.gameflow.rclone/rclone') },
|
{ ...rclone, load: () => import('./builtin/other/com.simeonradivoev.gameflow.rclone/rclone') },
|
||||||
];
|
];
|
||||||
|
|
||||||
await Promise.all(plugins.map(p => registerPlugin(p, 'builtin', pluginManager)));
|
await Promise.all(plugins.filter(p =>
|
||||||
|
|
||||||
if (IsPluginAllowed('@simeonradivoev/gameflow-store'))
|
|
||||||
{
|
{
|
||||||
const storePackageFilePath = path.join(getStoreRootFolder(), 'package.json');
|
if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(p.name))
|
||||||
if (!await Bun.file(storePackageFilePath).exists())
|
|
||||||
{
|
{
|
||||||
console.log("Store is missing. Updating it.");
|
return false;
|
||||||
await taskQueue.enqueue(EnsureStore.id, new EnsureStore());
|
|
||||||
console.log("Store Updated");
|
|
||||||
}
|
}
|
||||||
const storePackage = await Bun.file(storePackageFilePath).json();
|
if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(p.name))
|
||||||
|
|
||||||
if (storePackage?.dependencies)
|
|
||||||
{
|
{
|
||||||
const storePlugins = await Promise.all(Object.keys(storePackage.dependencies).filter(p => !blacklist.has(p)).map(async p =>
|
return false;
|
||||||
{
|
|
||||||
return getPlugin(p, pluginManager);
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log("Checking for outdated packages");
|
|
||||||
const outdated = await getUpdates();
|
|
||||||
|
|
||||||
const validPlugins = storePlugins.filter(p => !!p);
|
|
||||||
|
|
||||||
if (outdated)
|
|
||||||
{
|
|
||||||
for (let i = 0; i < validPlugins.length; i++)
|
|
||||||
{
|
|
||||||
const plugin = validPlugins[i];
|
|
||||||
const newVersion = outdated.find(i => i.package === plugin.name);
|
|
||||||
if (newVersion)
|
|
||||||
{
|
|
||||||
console.log("Plugin", plugin.name, "has update", plugin.version, "=>", newVersion.update);
|
|
||||||
|
|
||||||
if (plugin.autoUpdate || plugin.name === '@simeonradivoev/gameflow-store')
|
|
||||||
{
|
|
||||||
console.log("Auto Updating Plugin", plugin.name);
|
|
||||||
let response = await runBunPackageCommand(["add", `${plugin.name}@${newVersion?.update}`, "--registry", PluginRegistry, '--omit', 'peer']);
|
|
||||||
console.log(response);
|
|
||||||
// Update plugin package
|
|
||||||
const newPlugin = await getPlugin(plugin.name, pluginManager);
|
|
||||||
if (newPlugin)
|
|
||||||
validPlugins[i] = newPlugin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(validPlugins.map(p => registerPlugin(p, 'store', pluginManager)));
|
|
||||||
}
|
}
|
||||||
} else
|
return true;
|
||||||
|
}).map(async (pluginPackage) =>
|
||||||
{
|
{
|
||||||
console.log('Skipping Store Packages');
|
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');
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import path from 'node:path';
|
|
||||||
import os from 'node:os';
|
|
||||||
import { getStoreRootFolder } from '../store/services/gamesService';
|
|
||||||
import { PluginDescriptionType } from '@simeonradivoev/gameflow-sdk';
|
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { checkOutdated } from './update-check';
|
|
||||||
|
|
||||||
export function canDisable (description: PluginDescriptionType)
|
|
||||||
{
|
|
||||||
if (description.name === '@simeonradivoev/gameflow-store')
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return description.canDisable ?? true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUpdates ()
|
|
||||||
{
|
|
||||||
if (!existsSync(getStoreRootFolder())) return [];
|
|
||||||
const results = await checkOutdated(getStoreRootFolder());
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canUninstall (description: PluginDescriptionType, source: string)
|
|
||||||
{
|
|
||||||
if (description.name === '@simeonradivoev/gameflow-store')
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return source !== 'builtin';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runBunPackageCommand (commands: string[])
|
|
||||||
{
|
|
||||||
const tempCache = path.join(os.tmpdir(), "gameflow-bun-cache");
|
|
||||||
const storeFolder = getStoreRootFolder();
|
|
||||||
|
|
||||||
let proc = Bun.spawn([process.execPath, ...commands, '--json'], {
|
|
||||||
cwd: storeFolder,
|
|
||||||
stdout: 'pipe',
|
|
||||||
stderr: 'pipe',
|
|
||||||
env: {
|
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
BUN_INSTALL_CACHE_DIR: tempCache
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = await new Response(proc.stdout).text();
|
|
||||||
let stderr = await new Response(proc.stderr).text();
|
|
||||||
if (stderr)
|
|
||||||
console.error(stderr);
|
|
||||||
await proc.exited;
|
|
||||||
return stdout;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hasPackage (id: string)
|
|
||||||
{
|
|
||||||
const storeFolder = getStoreRootFolder();
|
|
||||||
const packagePath = path.join(storeFolder, 'package.json');
|
|
||||||
const packageFile = Bun.file(packagePath);
|
|
||||||
if (!await packageFile.exists()) return false;
|
|
||||||
const pkg = await packageFile.json();
|
|
||||||
return !!pkg.dependencies?.[id];
|
|
||||||
}
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
import { semver } from "bun";
|
|
||||||
import { readFile } from "fs/promises";
|
|
||||||
import { join } from "path";
|
|
||||||
import { getOrCached } from "../cache";
|
|
||||||
import { PluginRegistry } from "@/shared/constants";
|
|
||||||
import sdkPkg from '@/packages/gameflow-sdk/package.json';
|
|
||||||
|
|
||||||
interface UpdateInfo
|
|
||||||
{
|
|
||||||
package: string,
|
|
||||||
current: string,
|
|
||||||
update: string | null,
|
|
||||||
latest: string,
|
|
||||||
sdkConstrained: boolean,
|
|
||||||
sdkRange: string,
|
|
||||||
note: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBunOutdated (cwd: string)
|
|
||||||
{
|
|
||||||
const proc = Bun.spawnSync([process.execPath, "outdated"], {
|
|
||||||
stderr: "inherit", env: {
|
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
NO_COLOR: "1",
|
|
||||||
}, cwd: cwd
|
|
||||||
});
|
|
||||||
const output = proc.stdout.toString();
|
|
||||||
const lines = output.split("\n").filter(Boolean);
|
|
||||||
|
|
||||||
const headerIndex = lines.findIndex(
|
|
||||||
(l) => l.includes("Package") && l.includes("Current")
|
|
||||||
);
|
|
||||||
if (headerIndex === -1) return [];
|
|
||||||
|
|
||||||
return lines
|
|
||||||
.slice(headerIndex + 1)
|
|
||||||
.filter((line) => !/^[-─╌| ]+$/.test(line))
|
|
||||||
.map((line) =>
|
|
||||||
{
|
|
||||||
const [, pkg, current, , latest] = line.split("|").map((c) => c.trim());
|
|
||||||
return pkg ? { package: pkg, current, latest } : null;
|
|
||||||
})
|
|
||||||
.filter(p => p !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getInstalledVersion (cwd: string, pkg: string)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const raw = await readFile(join(cwd, "node_modules", pkg, "package.json"), "utf8");
|
|
||||||
return JSON.parse(raw).version ?? null;
|
|
||||||
} catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAllVersions (pkg: string)
|
|
||||||
{
|
|
||||||
const res = await fetch(`${PluginRegistry}/${pkg}`);
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return Object.keys(data.versions ?? {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchPeerDeps (pkg: string, version: string)
|
|
||||||
{
|
|
||||||
const peerDependencies = await getOrCached(`npm-${pkg}-${version}`, async () =>
|
|
||||||
{
|
|
||||||
const res = await fetch(`${PluginRegistry}/${pkg}/${version}`);
|
|
||||||
if (!res.ok)
|
|
||||||
{
|
|
||||||
throw new Error(`Error while fetching peer deps for ${pkg} ${version} ${res.status} ${res.statusText}`);
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
return data.peerDependencies ?? {};
|
|
||||||
}, {
|
|
||||||
//5 days
|
|
||||||
expireMs: 1000 * 60 * 60 * 24 * 5
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
return peerDependencies;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findBestVersion (pkg: string, allVersions: string[], sdkVersion: string)
|
|
||||||
{
|
|
||||||
// Sort descending so we find the highest compatible version first
|
|
||||||
const sorted = [...allVersions].sort((a, b) => semver.order(b, a));
|
|
||||||
|
|
||||||
for (const version of sorted)
|
|
||||||
{
|
|
||||||
const peers = await fetchPeerDeps(pkg, version);
|
|
||||||
const sdkRange = peers[sdkPkg.name];
|
|
||||||
|
|
||||||
if (!sdkRange)
|
|
||||||
{
|
|
||||||
// No peer dep on SDK — compatible by default
|
|
||||||
return { version, sdkRange: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (semver.satisfies(sdkVersion, sdkRange))
|
|
||||||
{
|
|
||||||
return { version, sdkRange };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkOutdated (cwd: string)
|
|
||||||
{
|
|
||||||
const outdated = parseBunOutdated(cwd);
|
|
||||||
|
|
||||||
if (outdated.length === 0)
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const sdkVersion = await getInstalledVersion(cwd, sdkPkg.name);
|
|
||||||
if (!sdkVersion)
|
|
||||||
{
|
|
||||||
console.error(`Could not find installed version of ${sdkPkg.name} in node_modules.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
|
||||||
outdated.map(async ({ package: pkg, current, latest }) =>
|
|
||||||
{
|
|
||||||
const allVersions = await fetchAllVersions(pkg);
|
|
||||||
|
|
||||||
// Check if the outright latest is already SDK compatible
|
|
||||||
const latestPeers = await fetchPeerDeps(pkg, latest);
|
|
||||||
const latestSdkRange = latestPeers[sdkPkg.name];
|
|
||||||
|
|
||||||
const latestCompatible =
|
|
||||||
!latestSdkRange || semver.satisfies(sdkVersion, latestSdkRange);
|
|
||||||
|
|
||||||
if (latestCompatible)
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
package: pkg,
|
|
||||||
current,
|
|
||||||
update: latest,
|
|
||||||
latest,
|
|
||||||
sdkConstrained: false,
|
|
||||||
sdkRange: latestSdkRange ?? null,
|
|
||||||
note: null
|
|
||||||
} satisfies UpdateInfo as UpdateInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
const best = await findBestVersion(pkg, allVersions, sdkVersion);
|
|
||||||
|
|
||||||
return {
|
|
||||||
package: pkg,
|
|
||||||
current,
|
|
||||||
update: best?.version ?? null,
|
|
||||||
latest,
|
|
||||||
sdkConstrained: true,
|
|
||||||
sdkRange: best?.sdkRange ?? null,
|
|
||||||
note: best
|
|
||||||
? `Latest (${latest}) requires incompatible SDK range; best compatible: ${best.version}`
|
|
||||||
: `No version of ${pkg} is compatible with ${sdkPkg.name}@${sdkVersion}`,
|
|
||||||
} satisfies UpdateInfo as UpdateInfo;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
import { LocalGameMetadata } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
import { sql, relations } from "drizzle-orm";
|
import { sql, relations } from "drizzle-orm";
|
||||||
import { integer, text, sqliteTable, blob } from "drizzle-orm/sqlite-core";
|
import { integer, text, sqliteTable, blob } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
|
@ -14,7 +12,15 @@ export const games = sqliteTable('games', {
|
||||||
main_glob: text("main_glob"),
|
main_glob: text("main_glob"),
|
||||||
last_played: integer("last_played", { mode: 'timestamp' }),
|
last_played: integer("last_played", { mode: 'timestamp' }),
|
||||||
created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(),
|
created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(),
|
||||||
metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type<LocalGameMetadata>().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(),
|
||||||
slug: text("slug").unique(),
|
slug: text("slug").unique(),
|
||||||
platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(),
|
platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(),
|
||||||
cover: blob("cover", { mode: 'buffer' }),
|
cover: blob("cover", { mode: 'buffer' }),
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { cores } from '../emulatorjs/emulatorjs';
|
||||||
import { SERVER_URL } from '@/shared/constants';
|
import { SERVER_URL } from '@/shared/constants';
|
||||||
import { host } from '@/bun/utils/host';
|
import { host } from '@/bun/utils/host';
|
||||||
import { findEmulatorPluginIntegration } from '../store/services/emulatorsService';
|
import { findEmulatorPluginIntegration } from '../store/services/emulatorsService';
|
||||||
import { EmulatorSourceEntryType, FrontEndEmulator } from '@simeonradivoev/gameflow-sdk/shared';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get emulators based on local games. Only the ones we probably need.
|
* Get emulators based on local games. Only the ones we probably need.
|
||||||
|
|
@ -58,6 +57,15 @@ export async function getRelevantEmulators ()
|
||||||
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator, sources: execPaths });
|
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator, sources: execPaths });
|
||||||
const integrations = findEmulatorPluginIntegration(emulator, 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;
|
let platform: number | null | undefined = null;
|
||||||
const validSystemSlug = system_slug.find(s => s.system);
|
const validSystemSlug = system_slug.find(s => s.system);
|
||||||
if (validSystemSlug?.system)
|
if (validSystemSlug?.system)
|
||||||
|
|
@ -70,17 +78,7 @@ export async function getRelevantEmulators ()
|
||||||
systems.forEach(s => platformViability.set(s, true));
|
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; } = {
|
const em: FrontEndEmulator & { isCritical: boolean; } = {
|
||||||
source: 'local',
|
|
||||||
name: emulator,
|
name: emulator,
|
||||||
logo: platform ? `/api/romm/platform/local/${platform}/cover` : '',
|
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 ?? '' })),
|
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 ?? '' })),
|
||||||
|
|
@ -94,7 +92,6 @@ export async function getRelevantEmulators ()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
finalEmulators.push({
|
finalEmulators.push({
|
||||||
source: 'local',
|
|
||||||
name: 'EMULATORJS',
|
name: 'EMULATORJS',
|
||||||
validSources: [{ binPath: `${SERVER_URL(host)}`, type: 'embedded', exists: true }],
|
validSources: [{ binPath: `${SERVER_URL(host)}`, type: 'embedded', exists: true }],
|
||||||
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
|
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared';
|
import { SettingsSchema } from "@shared/constants";
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { config, customEmulators, plugins, taskQueue } from "../app";
|
import { config, customEmulators, plugins, taskQueue } from "../app";
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
|
@ -10,8 +10,6 @@ import { getRelevantEmulators } from "./services";
|
||||||
import type { JSONSchema7 } from "json-schema";
|
import type { JSONSchema7 } from "json-schema";
|
||||||
import ReloadPluginsJob from "../jobs/reload-plugins-job";
|
import ReloadPluginsJob from "../jobs/reload-plugins-job";
|
||||||
import { pluginZodRegistry } from "../plugins/plugin-manager";
|
import { pluginZodRegistry } from "../plugins/plugin-manager";
|
||||||
import { TestDownloadJob } from "../jobs/test-download-job";
|
|
||||||
import { randomUUIDv7 } from "bun";
|
|
||||||
|
|
||||||
export const settings = new Elysia({ prefix: '/api/settings' })
|
export const settings = new Elysia({ prefix: '/api/settings' })
|
||||||
.get('/emulators/automatic', async () =>
|
.get('/emulators/automatic', async () =>
|
||||||
|
|
@ -98,31 +96,27 @@ export const settings = new Elysia({ prefix: '/api/settings' })
|
||||||
})
|
})
|
||||||
.get('/definitions/:source', async ({ params: { source } }) =>
|
.get('/definitions/:source', async ({ params: { source } }) =>
|
||||||
{
|
{
|
||||||
return plugins.plugins[decodeURIComponent(source)].plugin.settingsSchema?.toJSONSchema() as JSONSchema7;
|
return plugins.plugins[source].plugin.settingsSchema?.toJSONSchema() as JSONSchema7;
|
||||||
})
|
})
|
||||||
.get('/actions/:source', async ({ params: { source } }) =>
|
.get('/actions/:source', async ({ params: { source } }) =>
|
||||||
{
|
{
|
||||||
const plugin = plugins.plugins[decodeURIComponent(source)]?.plugin;
|
const plugin = plugins.plugins[source]?.plugin;
|
||||||
if (!plugin.eventsNames) return [];
|
if (!plugin.eventsNames) return [];
|
||||||
return plugin.eventsNames;
|
return plugin.eventsNames;
|
||||||
})
|
})
|
||||||
.post('/actions/:source/:id', async ({ params: { source, id } }) =>
|
.post('/actions/:source/:id', async ({ params: { source, id } }) =>
|
||||||
{
|
{
|
||||||
return await plugins.plugins[decodeURIComponent(source)]?.plugin.onEvent?.(decodeURIComponent(id));
|
return await plugins.plugins[source]?.plugin.onEvent?.(id);
|
||||||
})
|
})
|
||||||
.get('/:source/:id', async ({ params: { source, id } }) =>
|
.get('/:source/:id', async ({ params: { source, id } }) =>
|
||||||
{
|
{
|
||||||
return { value: plugins.plugins[decodeURIComponent(source)].config?.get(decodeURIComponent(id)) };
|
return { value: plugins.plugins[source].config?.get(id) };
|
||||||
})
|
|
||||||
.post('/test/download', async () =>
|
|
||||||
{
|
|
||||||
taskQueue.enqueue(randomUUIDv7(), new TestDownloadJob());
|
|
||||||
})
|
})
|
||||||
.put('/:source/:id', async ({ params: { source, id }, body: { value } }) =>
|
.put('/:source/:id', async ({ params: { source, id }, body: { value } }) =>
|
||||||
{
|
{
|
||||||
const plugin = plugins.plugins[decodeURIComponent(source)];
|
const plugin = plugins.plugins[source];
|
||||||
if (!plugin.config) return status("Not Found", "Plugin has no config");
|
if (!plugin.config) return status("Not Found", "Plugin has no config");
|
||||||
const settingSchema = plugin.plugin.settingsSchema?.shape[decodeURIComponent(id)] as z.ZodObject;
|
const settingSchema = plugin.plugin.settingsSchema?.shape[id] as z.ZodObject;
|
||||||
if (!settingSchema) return status("Not Found", "Could not find setting");
|
if (!settingSchema) return status("Not Found", "Could not find setting");
|
||||||
const meta = pluginZodRegistry.get(settingSchema);
|
const meta = pluginZodRegistry.get(settingSchema);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
import { EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants";
|
||||||
import { config, plugins } from "../../app";
|
import { config, plugins } from "../../app";
|
||||||
import { getOrCached, getOrCachedGithubRelease } from "../../cache";
|
import { getOrCached, getOrCachedGithubRelease } from "../../cache";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { EmulatorSourceEntryType, EmulatorSupport, ScoopPackageSchema, EmulatorPackageType, EmulatorDownloadInfoType } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]): EmulatorSupport[]
|
export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]): EmulatorSupport[]
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
|
import { EmulatorPackageSchema, EmulatorPackageType, GithubManifestSchema, StoreGameSchema } from "@/shared/constants";
|
||||||
|
import { CACHE_KEYS, getOrCached } from "../../cache";
|
||||||
import { and, eq, or } from "drizzle-orm";
|
import { and, eq, or } from "drizzle-orm";
|
||||||
import { config, emulatorsDb } from '../../app';
|
import { config, emulatorsDb } from '../../app';
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import * as emulatorSchema from '@schema/emulators';
|
import * as emulatorSchema from '@schema/emulators';
|
||||||
import { EmulatorSystem, EmulatorPackageType, EmulatorPackageSchema } from "@simeonradivoev/gameflow-sdk/shared";
|
import { shuffleInPlace } from "@/bun/utils";
|
||||||
|
import { Glob } from "bun";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function getStoreRootFolder ()
|
export function getStoreRootFolder ()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import Elysia, { status } from "elysia";
|
||||||
import { config, db, plugins, taskQueue } from "../app";
|
import { config, db, plugins, taskQueue } from "../app";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
import { EmulatorDownloadInfoSchema } from "@/shared/constants";
|
||||||
import * as appSchema from '@schema/app';
|
import * as appSchema from '@schema/app';
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { convertLocalToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
|
import { convertLocalToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
|
||||||
|
|
@ -12,17 +13,6 @@ import { getStoreFolder } from "./services/gamesService";
|
||||||
import { EmulatorDownloadJob } from "../jobs/emulator-download-job";
|
import { EmulatorDownloadJob } from "../jobs/emulator-download-job";
|
||||||
import { BiosDownloadJob } from "../jobs/bios-download-job";
|
import { BiosDownloadJob } from "../jobs/bios-download-job";
|
||||||
import { findEmulatorPluginIntegration, getEmulatorPath } from "./services/emulatorsService";
|
import { findEmulatorPluginIntegration, getEmulatorPath } from "./services/emulatorsService";
|
||||||
import { EmulatorSourceEntryType, FrontEndEmulator, FrontEndGameTypeDetailed, PluginBunDetailsSchema, PluginEntrySchema, EmulatorDownloadInfoSchema } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
import PQueue from "p-queue";
|
|
||||||
import { hasPackage, runBunPackageCommand } from "../plugins/services";
|
|
||||||
import { semver } from "bun";
|
|
||||||
|
|
||||||
const npmQueue = new PQueue({ intervalCap: 60, interval: 1000 * 60, strict: true });
|
|
||||||
const pluginsResponseSchema = z.object({
|
|
||||||
objects: z.array(PluginEntrySchema),
|
|
||||||
total: z.number(),
|
|
||||||
time: z.coerce.date()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const store = new Elysia({ prefix: '/api/store' })
|
export const store = new Elysia({ prefix: '/api/store' })
|
||||||
.get('/emulators', async ({ query }) =>
|
.get('/emulators', async ({ query }) =>
|
||||||
|
|
@ -118,49 +108,6 @@ export const store = new Elysia({ prefix: '/api/store' })
|
||||||
gameCount
|
gameCount
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.get('/plugin', async ({ query: { plugin } }) =>
|
|
||||||
{
|
|
||||||
const pluginsRes = await runBunPackageCommand(['info', plugin]);
|
|
||||||
const pluginData = await PluginBunDetailsSchema.parseAsync(JSON.parse(pluginsRes));
|
|
||||||
const existingVersion = plugins.plugins[plugin]?.description.version;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...pluginData,
|
|
||||||
installed: !!plugins.plugins[plugin] || await hasPackage(plugin),
|
|
||||||
update: existingVersion && semver.order(pluginData.version, existingVersion) > 0 ? { from: existingVersion } : undefined
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query: z.object({ plugin: z.string() })
|
|
||||||
})
|
|
||||||
.get('/plugins', async ({ query: { search } }) =>
|
|
||||||
{
|
|
||||||
//TODO: Find a better way to search keywords and a search term at the same time
|
|
||||||
const pluginsRes = await npmQueue.add(() => fetch(`https://registry.npmjs.com/-/v1/search?text=keywords:gameflow-plugin`));
|
|
||||||
if (!pluginsRes.ok) return status(pluginsRes.status, pluginsRes.statusText);
|
|
||||||
const data: z.infer<typeof pluginsResponseSchema> = await pluginsRes.json();
|
|
||||||
if (search)
|
|
||||||
{
|
|
||||||
data.objects = data.objects.filter(o =>
|
|
||||||
{
|
|
||||||
if (o.package.description && o.package.description.includes(search)) return true;
|
|
||||||
if (o.package.name.includes(search)) return true;
|
|
||||||
if (o.package.keywords.includes(search)) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
data.total = data.objects.length;
|
|
||||||
}
|
|
||||||
await Promise.all(data.objects.map(async o =>
|
|
||||||
{
|
|
||||||
const existingVersion = plugins.plugins[o.package.name]?.description.version;
|
|
||||||
o.installed = !!plugins.plugins[o.package.name] || await hasPackage(o.package.name);
|
|
||||||
o.update = existingVersion && semver.order(o.package.version, existingVersion) > 0 ? { from: existingVersion } : undefined;
|
|
||||||
}));
|
|
||||||
return data as any;
|
|
||||||
}, {
|
|
||||||
query: z.object({ search: z.string().optional() }),
|
|
||||||
response: pluginsResponseSchema
|
|
||||||
})
|
|
||||||
.get('/media/*', async ({ params }) =>
|
.get('/media/*', async ({ params }) =>
|
||||||
{
|
{
|
||||||
return Bun.file(path.join(getStoreFolder(), params["*"]));
|
return Bun.file(path.join(getStoreFolder(), params["*"]));
|
||||||
|
|
@ -188,16 +135,16 @@ export const store = new Elysia({ prefix: '/api/store' })
|
||||||
emulator.integrations = integrations;
|
emulator.integrations = integrations;
|
||||||
return emulator;
|
return emulator;
|
||||||
}, { params: z.object({ id: z.string() }) })
|
}, { params: z.object({ id: z.string() }) })
|
||||||
.post('/install/emulator/:id/:source', async ({ params: { source, id }, body }) =>
|
.post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) =>
|
||||||
{
|
{
|
||||||
if (taskQueue.hasActiveOfType(EmulatorDownloadJob))
|
if (taskQueue.hasActiveOfType(EmulatorDownloadJob))
|
||||||
{
|
{
|
||||||
return status("Conflict", "Installation already running");
|
return status("Conflict", "Installation already running");
|
||||||
}
|
}
|
||||||
const job = new EmulatorDownloadJob(id, source, body);
|
const job = new EmulatorDownloadJob(id, source, { isUpdate });
|
||||||
return taskQueue.enqueue(EmulatorDownloadJob.id, job);
|
return taskQueue.enqueue(EmulatorDownloadJob.id, job);
|
||||||
}, {
|
}, {
|
||||||
body: z.object({ isUpdate: z.boolean().optional() }).optional()
|
body: z.object({ isUpdate: z.boolean().optional() })
|
||||||
})
|
})
|
||||||
.delete('/emulator/:id', async ({ params: { id } }) =>
|
.delete('/emulator/:id', async ({ params: { id } }) =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,19 @@ import Elysia from "elysia";
|
||||||
import open from 'open';
|
import open from 'open';
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { cachePath, config, events, taskQueue } from "./app";
|
import { cache, cachePath, config, events, taskQueue } from "./app";
|
||||||
import { getAppVersion, isSteamDeck, openExternal } from "../utils";
|
import { getAppVersion, isSteamDeck, openExternal } from "../utils";
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import buildNotificationsStream from "./notifications";
|
import buildNotificationsStream from "./notifications";
|
||||||
import path, { dirname } from "node:path";
|
import path, { dirname } from "node:path";
|
||||||
import { SystemInfoSchema, DirSchema, DownloadsDrive } from '@simeonradivoev/gameflow-sdk/shared';
|
import { DirSchema, SystemInfoSchema } from "@/shared/constants";
|
||||||
import { getDevices, getDevicesCurated } from "./drives";
|
import { getDevices, getDevicesCurated } from "./drives";
|
||||||
import getFolderSize from "get-folder-size";
|
import getFolderSize from "get-folder-size";
|
||||||
import si from 'systeminformation';
|
import si from 'systeminformation';
|
||||||
import { getStoreFolder } from "./store/services/gamesService";
|
import { getStoreFolder } from "./store/services/gamesService";
|
||||||
import ReloadPluginsJob from "./jobs/reload-plugins-job";
|
import ReloadPluginsJob from "./jobs/reload-plugins-job";
|
||||||
import { semver } from "bun";
|
import { semver } from "bun";
|
||||||
import { getOrCachedGithubRelease } from "./cache";
|
import { getOrCached, getOrCachedGithubRelease, githubRequestQueue } from "./cache";
|
||||||
import SelfUpdateJob from "./jobs/self-update-job";
|
import SelfUpdateJob from "./jobs/self-update-job";
|
||||||
|
|
||||||
async function checkUpdate (force?: boolean)
|
async function checkUpdate (force?: boolean)
|
||||||
|
|
@ -86,7 +86,6 @@ export const system = new Elysia({ prefix: '/api/system' })
|
||||||
z.object({ type: z.literal('info'), data: SystemInfoSchema }),
|
z.object({ type: z.literal('info'), data: SystemInfoSchema }),
|
||||||
z.object({ type: z.literal('focus') }),
|
z.object({ type: z.literal('focus') }),
|
||||||
z.object({ type: z.literal('loading'), progress: z.number(), state: z.string().optional() }),
|
z.object({ type: z.literal('loading'), progress: z.number(), state: z.string().optional() }),
|
||||||
z.object({ type: z.literal('activeTask'), progress: z.number().nullable() }),
|
|
||||||
z.object({ type: z.literal('loaded') }),
|
z.object({ type: z.literal('loaded') }),
|
||||||
]),
|
]),
|
||||||
async open (ws)
|
async open (ws)
|
||||||
|
|
@ -95,8 +94,6 @@ export const system = new Elysia({ prefix: '/api/system' })
|
||||||
if (existingLoading) ws.send({ type: 'loading', progress: existingLoading.progress, state: existingLoading.state });
|
if (existingLoading) ws.send({ type: 'loading', progress: existingLoading.progress, state: existingLoading.state });
|
||||||
else ws.send({ type: 'loaded' });
|
else ws.send({ type: 'loaded' });
|
||||||
|
|
||||||
ws.send({ type: 'activeTask', progress: taskQueue.getActiveJobs()[0]?.progress });
|
|
||||||
|
|
||||||
const startInfo = async () =>
|
const startInfo = async () =>
|
||||||
{
|
{
|
||||||
const battery = await si.battery();
|
const battery = await si.battery();
|
||||||
|
|
@ -119,8 +116,6 @@ export const system = new Elysia({ prefix: '/api/system' })
|
||||||
|
|
||||||
dispose.push(taskQueue.on('progress', e =>
|
dispose.push(taskQueue.on('progress', e =>
|
||||||
{
|
{
|
||||||
ws.send({ type: 'activeTask', progress: e.progress });
|
|
||||||
|
|
||||||
if (e.id === ReloadPluginsJob.id)
|
if (e.id === ReloadPluginsJob.id)
|
||||||
{
|
{
|
||||||
ws.send({ type: "loading", progress: e.progress, state: e.state });
|
ws.send({ type: "loading", progress: e.progress, state: e.state });
|
||||||
|
|
@ -132,8 +127,6 @@ export const system = new Elysia({ prefix: '/api/system' })
|
||||||
}));
|
}));
|
||||||
dispose.push(taskQueue.on('started', e =>
|
dispose.push(taskQueue.on('started', e =>
|
||||||
{
|
{
|
||||||
ws.send({ type: 'activeTask', progress: 0 });
|
|
||||||
|
|
||||||
if (e.id === ReloadPluginsJob.id)
|
if (e.id === ReloadPluginsJob.id)
|
||||||
ws.send({ type: "loading", progress: e.job.progress, state: e.job.state });
|
ws.send({ type: "loading", progress: e.job.progress, state: e.job.state });
|
||||||
else if (e.id === SelfUpdateJob.id)
|
else if (e.id === SelfUpdateJob.id)
|
||||||
|
|
@ -141,7 +134,6 @@ export const system = new Elysia({ prefix: '/api/system' })
|
||||||
}));
|
}));
|
||||||
dispose.push(taskQueue.on('ended', e =>
|
dispose.push(taskQueue.on('ended', e =>
|
||||||
{
|
{
|
||||||
ws.send({ type: 'activeTask', progress: null });
|
|
||||||
if (e.id !== ReloadPluginsJob.id && e.id !== SelfUpdateJob.id) return;
|
if (e.id !== ReloadPluginsJob.id && e.id !== SelfUpdateJob.id) return;
|
||||||
ws.send({ type: "loaded" });
|
ws.send({ type: "loaded" });
|
||||||
}));
|
}));
|
||||||
|
|
@ -247,10 +239,6 @@ export const system = new Elysia({ prefix: '/api/system' })
|
||||||
{
|
{
|
||||||
currentPath = path.resolve(process.cwd(), currentPath);
|
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 });
|
const paths = await fs.readdir(currentPath, { withFileTypes: true });
|
||||||
return {
|
return {
|
||||||
name: path.basename(currentPath),
|
name: path.basename(currentPath),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
|
||||||
|
|
||||||
|
import { and } from 'drizzle-orm';
|
||||||
import EventEmitter from 'node:events';
|
import EventEmitter from 'node:events';
|
||||||
import z from 'zod';
|
import z, { any } from 'zod';
|
||||||
import { JobStatus } from './shared';
|
|
||||||
|
|
||||||
export class TaskQueue
|
export class TaskQueue
|
||||||
{
|
{
|
||||||
|
|
@ -9,33 +10,14 @@ export class TaskQueue
|
||||||
private queue?: JobContext<IJob<any, string>, any, string>[] = [];
|
private queue?: JobContext<IJob<any, string>, any, string>[] = [];
|
||||||
private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>();
|
private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>();
|
||||||
|
|
||||||
constructor()
|
public enqueue<T> (id: string, job: T): T extends IJob<infer TData, infer TState extends string>
|
||||||
{
|
|
||||||
// we need a default error listener or app crashes
|
|
||||||
this.events?.addListener('error', e =>
|
|
||||||
{
|
|
||||||
console.error(e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public enqueue<T> (id: string, job: T, options?: { throwOnCancel?: boolean; }): T extends IJob<infer TData, infer TState extends string>
|
|
||||||
? Promise<TData>
|
? Promise<TData>
|
||||||
: never
|
: never
|
||||||
{
|
{
|
||||||
this.disposeSafeguard();
|
this.disposeSafeguard();
|
||||||
if (!this.queue || !this.events) throw new Error("Queue disposed");
|
if (!this.queue || !this.events) throw new Error("Queue disposed");
|
||||||
if (this.activeQueue.some(j => j.id === id)) throw new Error(`Job with ID ${id} already active`);
|
const context = new JobContext<any, any, any>(id, this.events, job);
|
||||||
if (this.queue.some(j => j.id === id)) throw new Error(`Job with ${id} already queued`);
|
|
||||||
const context = new JobContext<any, any, any>(id, this.events, job, options);
|
|
||||||
this.queue.push(context as any);
|
this.queue.push(context as any);
|
||||||
context.abortSignal.addEventListener('abort', () =>
|
|
||||||
{
|
|
||||||
const queueIndex = this.queue?.findIndex(c => c === context);
|
|
||||||
if (queueIndex !== undefined && queueIndex >= 0)
|
|
||||||
{
|
|
||||||
this.queue?.splice(queueIndex, 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.events?.emit('queued', { id: context.id, job: context });
|
this.events?.emit('queued', { id: context.id, job: context });
|
||||||
this.processQueue();
|
this.processQueue();
|
||||||
return context.promise.promise as any;
|
return context.promise.promise as any;
|
||||||
|
|
@ -45,24 +27,7 @@ export class TaskQueue
|
||||||
{
|
{
|
||||||
if (!this.queue) return Promise.resolve();
|
if (!this.queue) return Promise.resolve();
|
||||||
|
|
||||||
let activeGroupsSet = new Set(this.activeQueue.filter(j => j.job.group).map(j => j.job.group));
|
const next = this.queue.filter(j => !j.job.group || !this.activeQueue.some(a => a.job.group === j.job.group)).map((job, i) => ({ i, job }));
|
||||||
const next = this.queue.filter(j =>
|
|
||||||
{
|
|
||||||
if (j.job.group)
|
|
||||||
{
|
|
||||||
// Only take one task per group to be active
|
|
||||||
if (!activeGroupsSet.has(j.job.group))
|
|
||||||
{
|
|
||||||
activeGroupsSet.add(j.job.group);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}).map((job, i) => ({ i, job }));
|
|
||||||
|
|
||||||
next.reverse().forEach(({ i }) => this.queue!.splice(i, 1));
|
next.reverse().forEach(({ i }) => this.queue!.splice(i, 1));
|
||||||
|
|
||||||
|
|
@ -70,7 +35,7 @@ export class TaskQueue
|
||||||
{
|
{
|
||||||
job.job.start();
|
job.job.start();
|
||||||
this.activeQueue.push(job.job);
|
this.activeQueue.push(job.job);
|
||||||
job.job.promise.promise.catch(e => { }).finally(() =>
|
job.job.promise.promise.finally(() =>
|
||||||
{
|
{
|
||||||
const index = this.activeQueue.indexOf(job.job);
|
const index = this.activeQueue.indexOf(job.job);
|
||||||
this.activeQueue.splice(index, 1);
|
this.activeQueue.splice(index, 1);
|
||||||
|
|
@ -91,11 +56,6 @@ export class TaskQueue
|
||||||
return this.activeQueue.length > 0;
|
return this.activeQueue.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasQueued ()
|
|
||||||
{
|
|
||||||
return this.queue && this.queue.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public hasActiveOfType (type: any)
|
public hasActiveOfType (type: any)
|
||||||
{
|
{
|
||||||
for (const entry of this.activeQueue)
|
for (const entry of this.activeQueue)
|
||||||
|
|
@ -114,38 +74,6 @@ export class TaskQueue
|
||||||
return job?.promise.promise ?? Promise.resolve();
|
return job?.promise.promise ?? Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
public waitForAll ()
|
|
||||||
{
|
|
||||||
return new Promise((resolve) =>
|
|
||||||
{
|
|
||||||
if (!this.hasActive())
|
|
||||||
{
|
|
||||||
resolve(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEnded = () =>
|
|
||||||
{
|
|
||||||
if (!this.hasActive() && !this.hasQueued())
|
|
||||||
{
|
|
||||||
resolve(true);
|
|
||||||
this.events?.removeListener('ended', handleEnded);
|
|
||||||
this.events?.removeListener('abort', handleEnded);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.events?.on('ended', handleEnded);
|
|
||||||
this.events?.on('abort', handleEnded);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public cancelJob (id: string)
|
|
||||||
{
|
|
||||||
const job = this.queue?.find(j => j.id === id)
|
|
||||||
?? this.activeQueue?.find(j => j.id === id);
|
|
||||||
|
|
||||||
job?.abort('cancel');
|
|
||||||
}
|
|
||||||
|
|
||||||
public findJob<T> (
|
public findJob<T> (
|
||||||
id: string,
|
id: string,
|
||||||
type: new (...args: any[]) => T
|
type: new (...args: any[]) => T
|
||||||
|
|
@ -163,16 +91,6 @@ export class TaskQueue
|
||||||
return undefined as any;
|
return undefined as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getActiveJobs ()
|
|
||||||
{
|
|
||||||
return this.activeQueue;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getQueuedJobs ()
|
|
||||||
{
|
|
||||||
return this.queue;
|
|
||||||
}
|
|
||||||
|
|
||||||
public on<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void
|
public on<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void
|
||||||
{
|
{
|
||||||
this.events?.on(event, listener);
|
this.events?.on(event, listener);
|
||||||
|
|
@ -244,7 +162,6 @@ export interface CompletedEvent extends BaseEvent
|
||||||
|
|
||||||
export interface IJob<TData, TState extends string>
|
export interface IJob<TData, TState extends string>
|
||||||
{
|
{
|
||||||
/** What group does the job belong to. Grouped jobs can only have 1 active job per group */
|
|
||||||
group?: string;
|
group?: string;
|
||||||
start (context: JobContext<IJob<TData, TState>, TData, TState>): Promise<any>;
|
start (context: JobContext<IJob<TData, TState>, TData, TState>): Promise<any>;
|
||||||
exposeData?(): TData;
|
exposeData?(): TData;
|
||||||
|
|
@ -285,14 +202,12 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
|
||||||
private events: EventEmitter<EventsList>;
|
private events: EventEmitter<EventsList>;
|
||||||
private abortController: AbortController;
|
private abortController: AbortController;
|
||||||
private m_promise: PromiseWithResolvers<TData | undefined>;
|
private m_promise: PromiseWithResolvers<TData | undefined>;
|
||||||
private throwOnCancel: boolean;
|
|
||||||
private readonly m_job: T;
|
private readonly m_job: T;
|
||||||
|
|
||||||
constructor(id: string, events: EventEmitter<EventsList>, job: T, options?: { throwOnCancel?: boolean; })
|
constructor(id: string, events: EventEmitter<EventsList>, job: T)
|
||||||
{
|
{
|
||||||
this.m_id = id;
|
this.m_id = id;
|
||||||
this.m_job = job;
|
this.m_job = job;
|
||||||
this.throwOnCancel = options?.throwOnCancel ?? false;
|
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
this.abortController.signal.addEventListener('abort', () =>
|
this.abortController.signal.addEventListener('abort', () =>
|
||||||
{
|
{
|
||||||
|
|
@ -320,27 +235,26 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
|
||||||
}
|
}
|
||||||
} catch (error)
|
} catch (error)
|
||||||
{
|
{
|
||||||
if (error instanceof Event)
|
try
|
||||||
{
|
{
|
||||||
if (error.target instanceof AbortSignal)
|
if (error instanceof Event)
|
||||||
{
|
{
|
||||||
if (this.throwOnCancel)
|
if (error.target instanceof AbortSignal)
|
||||||
{
|
{
|
||||||
this.m_promise.reject(this.abortSignal.reason);
|
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
this.m_promise.resolve(undefined);
|
console.error(error);
|
||||||
}
|
}
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
console.error(error);
|
console.error(error);
|
||||||
this.m_promise.reject(error);
|
this.events.emit('error', { id: this.m_id, job: this, error });
|
||||||
|
this.error = error;
|
||||||
}
|
}
|
||||||
} else
|
} finally
|
||||||
{
|
{
|
||||||
this.events.emit('error', { id: this.m_id, job: this, error });
|
this.m_promise.resolve(undefined);
|
||||||
this.error = error;
|
|
||||||
this.m_promise.reject(error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} finally
|
} finally
|
||||||
19
src/bun/types/helpers.d.ts
vendored
19
src/bun/types/helpers.d.ts
vendored
|
|
@ -1,19 +0,0 @@
|
||||||
declare module '*.bat' {
|
|
||||||
const content: string;
|
|
||||||
export default content;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.sh' {
|
|
||||||
const content: string;
|
|
||||||
export default content;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.ini' {
|
|
||||||
const content: string;
|
|
||||||
export default content;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.bin' {
|
|
||||||
const content: string;
|
|
||||||
export default content;
|
|
||||||
}
|
|
||||||
42
src/bun/types/types.d.ts
vendored
Normal file
42
src/bun/types/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
declare interface ObjectConstructor
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Groups members of an iterable according to the return value of the passed callback.
|
||||||
|
* @param items An iterable.
|
||||||
|
* @param keySelector A callback which will be invoked for each item in items.
|
||||||
|
*/
|
||||||
|
groupBy<K extends PropertyKey, T> (
|
||||||
|
items: Iterable<T>,
|
||||||
|
keySelector: (item: T, index: number) => K,
|
||||||
|
): Partial<Record<K, T[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface MapConstructor
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Groups members of an iterable according to the return value of the passed callback.
|
||||||
|
* @param items An iterable.
|
||||||
|
* @param keySelector A callback which will be invoked for each item in items.
|
||||||
|
*/
|
||||||
|
groupBy<K, T> (
|
||||||
|
items: Iterable<T>,
|
||||||
|
keySelector: (item: T, index: number) => K,
|
||||||
|
): Map<K, T[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
63
src/bun/types/typesc.schema.ts
Normal file
63
src/bun/types/typesc.schema.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
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(),
|
||||||
|
category: z.string().default("other"),
|
||||||
|
canDisable: z.boolean().default(true).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PluginSchema = z.object({
|
||||||
|
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.object({
|
||||||
|
openTab: z.string().optional(),
|
||||||
|
reload: z.boolean().optional()
|
||||||
|
}).or(z.record(z.string(), z.any()))).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PluginType<T extends Record<string, any> = Record<string, any>> = Omit<z.infer<typeof PluginSchema>, "load" | 'settingsMigrations'> & {
|
||||||
|
load: (ctx: PluginLoadingContextType<T>) => Promise<void>;
|
||||||
|
settingsMigrations?: Record<string, (conf: Conf<T>) => void>;
|
||||||
|
};
|
||||||
|
export type PluginContextType = z.infer<typeof PluginContextSchema>;
|
||||||
|
export type PluginLoadingContextType<TSettings extends Record<string, any> = Record<string, any>> = z.infer<typeof PluginLoadingContextSchema> & {
|
||||||
|
config: Conf<TSettings>;
|
||||||
|
};
|
||||||
|
export type PluginDescriptionType = z.infer<typeof PluginDescriptionSchema>;
|
||||||
|
|
||||||
|
export const ActiveGameSchema = z.object({
|
||||||
|
process: z.any().optional(),
|
||||||
|
gameId: z.object({ id: z.string(), source: z.string() }),
|
||||||
|
source: z.string().optional(),
|
||||||
|
sourceId: z.string().optional(),
|
||||||
|
name: z.string(),
|
||||||
|
command: z.object({ command: z.string().or(z.string().array()), startDir: z.string().optional() })
|
||||||
|
});
|
||||||
|
export type ActiveGameType = z.infer<typeof ActiveGameSchema>;
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import { $, sleep } from 'bun';
|
import { $, sleep } from 'bun';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { SettingsType, KeysWithValueAssignableTo } from '@simeonradivoev/gameflow-sdk/shared';
|
import { SettingsType } from '@/shared/constants';
|
||||||
import { config } from './api/app';
|
import { config } from './api/app';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import packageDef from '~/package.json';
|
import packageDef from '~/package.json';
|
||||||
|
|
||||||
const archiveRegex = /.(zip|rar|7zip|7z|tar|tar.gz)$/i;
|
|
||||||
|
|
||||||
export function checkRunning (pid: number)
|
export function checkRunning (pid: number)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
@ -180,24 +178,4 @@ export async function moveAllFiles (srcDir: string, destDir: string)
|
||||||
export function getAppVersion ()
|
export function getAppVersion ()
|
||||||
{
|
{
|
||||||
return process.env.VERSION_OVERRIDE ?? packageDef.version;
|
return process.env.VERSION_OVERRIDE ?? packageDef.version;
|
||||||
}
|
|
||||||
|
|
||||||
export function isArchive (path: string)
|
|
||||||
{
|
|
||||||
return archiveRegex.test(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IsPluginAllowed (id: string)
|
|
||||||
{
|
|
||||||
if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(id))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(id))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
@ -5,7 +5,11 @@ import fs from 'node:fs/promises';
|
||||||
import { createWriteStream } from "node:fs";
|
import { createWriteStream } from "node:fs";
|
||||||
import { config, jar } from "../api/app";
|
import { config, jar } from "../api/app";
|
||||||
import { moveAllFiles } from "../utils";
|
import { moveAllFiles } from "../utils";
|
||||||
import { DownloadFileEntry, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
export interface ProgressStats
|
||||||
|
{
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface TmpDownloadMetadata
|
interface TmpDownloadMetadata
|
||||||
{
|
{
|
||||||
|
|
@ -27,7 +31,6 @@ export class Downloader
|
||||||
id: string;
|
id: string;
|
||||||
tmpPath: string;
|
tmpPath: string;
|
||||||
tmpPathMeta: string;
|
tmpPathMeta: string;
|
||||||
downloadSpeed: number = 0;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
|
@ -159,7 +162,10 @@ export class Downloader
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalBytes = totalSize || Number(res.headers.get("content-length")) || 0;
|
const totalBytes = totalSize || Number(res.headers.get("content-length")) || 0;
|
||||||
bytesReceived += start;
|
if (totalSize <= 0)
|
||||||
|
bytesReceived = 0;
|
||||||
|
else
|
||||||
|
bytesReceived += start;
|
||||||
|
|
||||||
const reader = res.body!.getReader();
|
const reader = res.body!.getReader();
|
||||||
|
|
||||||
|
|
@ -174,11 +180,10 @@ export class Downloader
|
||||||
if (totalBytes > 0 && this.onProgress)
|
if (totalBytes > 0 && this.onProgress)
|
||||||
{
|
{
|
||||||
const percent = (bytesReceived / totalBytes) * 100;
|
const percent = (bytesReceived / totalBytes) * 100;
|
||||||
const timeDelta = Date.now() - lastUpdate;
|
|
||||||
if (timeDelta > 100)
|
if (Date.now() - lastUpdate > 100)
|
||||||
{
|
{
|
||||||
this.downloadSpeed = this.downloadSpeed * 0.8 + Math.round(value.length / (timeDelta / 1000)) * 0.2;
|
this.onProgress({ progress: percent });
|
||||||
this.onProgress({ progress: percent, downloaded: bytesReceived, total: totalBytes, speed: this.downloadSpeed });
|
|
||||||
lastUpdate = Date.now();
|
lastUpdate = Date.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +193,7 @@ export class Downloader
|
||||||
if (this.signal.reason === 'cancel')
|
if (this.signal.reason === 'cancel')
|
||||||
{
|
{
|
||||||
console.log("Canceling Download and cleaning up files");
|
console.log("Canceling Download and cleaning up files");
|
||||||
await fs.rm(this.tmpPath, { recursive: true, maxRetries: 3, retryDelay: 3 });
|
await fs.rm(this.tmpPath, { recursive: true });
|
||||||
await fs.rm(this.tmpPathMeta);
|
await fs.rm(this.tmpPathMeta);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,18 @@ interface BrowserResult
|
||||||
source: GetBrowserSource;
|
source: GetBrowserSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLATFORM_MAP: Record<string, string> = {
|
||||||
|
linux: "linux",
|
||||||
|
win32: "windows",
|
||||||
|
darwin: 'macos'
|
||||||
|
};
|
||||||
|
|
||||||
|
const ARCH_MAP: Record<string, Record<string, string>> = {
|
||||||
|
linux: { x64: "x86_64", arm64: "arm64" },
|
||||||
|
darwin: { x64: "x86_64", arm64: "arm64" },
|
||||||
|
win32: { x64: "x64", arm64: "arm64" },
|
||||||
|
};
|
||||||
|
|
||||||
/** The expected binary path per platform after extraction */
|
/** The expected binary path per platform after extraction */
|
||||||
async function getBundledBinaryPath (outDir: string, version: string, platform: string, arch: string): Promise<string | undefined>
|
async function getBundledBinaryPath (outDir: string, version: string, platform: string, arch: string): Promise<string | undefined>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -36,52 +36,28 @@
|
||||||
34000,
|
34000,
|
||||||
2489.5918367346967
|
2489.5918367346967
|
||||||
],
|
],
|
||||||
"Classic UI SFX - Short - High #25": [
|
|
||||||
38000,
|
|
||||||
2005.215419501134
|
|
||||||
],
|
|
||||||
"Classic UI SFX - Chords #16": [
|
"Classic UI SFX - Chords #16": [
|
||||||
42000,
|
38000,
|
||||||
4005.215419501134
|
4005.215419501134
|
||||||
],
|
],
|
||||||
"Classic UI SFX - Short - High #8": [
|
"Classic UI SFX - Short - High #8": [
|
||||||
48000,
|
44000,
|
||||||
2916.6666666666642
|
2916.6666666666642
|
||||||
],
|
],
|
||||||
"UI_Single_Set 16_03": [
|
"UI_Single_Set 16_03": [
|
||||||
52000,
|
48000,
|
||||||
309.5918367346968
|
309.5918367346968
|
||||||
],
|
],
|
||||||
"UI_Single_Set 16_01": [
|
"UI_Single_Set 16_01": [
|
||||||
54000,
|
50000,
|
||||||
309.5918367346968
|
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": [
|
"Classic UI SFX - Short - Low #6": [
|
||||||
66000,
|
52000,
|
||||||
2333.3333333333285
|
2333.3333333333358
|
||||||
],
|
],
|
||||||
"UI SFX_InGameMenu_Open": [
|
"UI SFX_InGameMenu_Open": [
|
||||||
70000,
|
56000,
|
||||||
2614.104308390026
|
2614.104308390026
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
src/mainview/assets/sounds.ogg
(Stored with Git LFS)
BIN
src/mainview/assets/sounds.ogg
(Stored with Git LFS)
Binary file not shown.
|
|
@ -1,14 +1,12 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { AppContext, SystemInfoContext } from "../scripts/contexts";
|
import { SystemInfoContext } from "../scripts/contexts";
|
||||||
import { systemApi } from "../scripts/clientApi";
|
import { systemApi } from "../scripts/clientApi";
|
||||||
import { AppInfoContext, SystemInfoType } from '@simeonradivoev/gameflow-sdk/shared';
|
import { SystemInfoType } from "@/shared/constants";
|
||||||
import LoadingScreen from "./LoadingScreen";
|
import LoadingScreen from "./LoadingScreen";
|
||||||
import { GamepadKeyboard } from "./GamepadKeyboard";
|
|
||||||
|
|
||||||
export default function AppCommunication (data: { children: any; })
|
export default function AppCommunication (data: { children: any; })
|
||||||
{
|
{
|
||||||
const [systemInfo, setSystemInfo] = useState<SystemInfoType | undefined>();
|
const [systemInfo, setSystemInfo] = useState<SystemInfoType | undefined>();
|
||||||
const [appContext, setAppContext] = useState<AppInfoContext>({} as AppInfoContext);
|
|
||||||
const [loadingInfo, setLoadingInfo] = useState<string | undefined>(undefined);
|
const [loadingInfo, setLoadingInfo] = useState<string | undefined>(undefined);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const loadingProgressBarRef = useRef<HTMLProgressElement>(null);
|
const loadingProgressBarRef = useRef<HTMLProgressElement>(null);
|
||||||
|
|
@ -26,9 +24,6 @@ export default function AppCommunication (data: { children: any; })
|
||||||
case "focus":
|
case "focus":
|
||||||
window.focus();
|
window.focus();
|
||||||
break;
|
break;
|
||||||
case "activeTask":
|
|
||||||
setAppContext(c => ({ ...c, activeTaskProgress: data.progress }));
|
|
||||||
break;
|
|
||||||
case "loading":
|
case "loading":
|
||||||
setLoadingInfo(data.state);
|
setLoadingInfo(data.state);
|
||||||
if (loadingProgressBarRef.current)
|
if (loadingProgressBarRef.current)
|
||||||
|
|
@ -49,19 +44,16 @@ export default function AppCommunication (data: { children: any; })
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <SystemInfoContext value={systemInfo}>
|
return <SystemInfoContext value={systemInfo}>
|
||||||
<AppContext value={appContext}>
|
{loading ?
|
||||||
{loading ?
|
<LoadingScreen>
|
||||||
<LoadingScreen>
|
<div className="flex flex-col items-center gap-4">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex gap-2">
|
||||||
<div className="flex gap-2">
|
<span className="loading loading-spinner loading-xl"></span>
|
||||||
<span className="loading loading-spinner loading-xl"></span>
|
{loadingInfo}
|
||||||
{loadingInfo}
|
|
||||||
</div>
|
|
||||||
<progress ref={loadingProgressBarRef} className="progress w-[20vw]" value={0} max="100"></progress>
|
|
||||||
</div>
|
</div>
|
||||||
</LoadingScreen>
|
<progress ref={loadingProgressBarRef} className="progress w-[20vw]" value={0} max="100"></progress>
|
||||||
: data.children}
|
</div>
|
||||||
<GamepadKeyboard />
|
</LoadingScreen>
|
||||||
</AppContext>
|
: data.children}
|
||||||
</SystemInfoContext>;
|
</SystemInfoContext>;
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { doesFocusableExist, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
import { doesFocusableExist, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useLayoutEffect } from "react";
|
||||||
|
|
||||||
export function AutoFocus (data: {
|
export function AutoFocus (data: {
|
||||||
parentKey?: string;
|
parentKey?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
import { FocusDetails, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { JSX } from "react";
|
import { JSX } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@ import
|
||||||
FocusContext,
|
FocusContext,
|
||||||
useFocusable,
|
useFocusable,
|
||||||
} from "@noriginmedia/norigin-spatial-navigation";
|
} from "@noriginmedia/norigin-spatial-navigation";
|
||||||
|
import { GameMeta } from "../../shared/constants";
|
||||||
import CardElement, { GameCardParams } from "./CardElement";
|
import CardElement, { GameCardParams } from "./CardElement";
|
||||||
import { JSX } from "react";
|
import { JSX } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
|
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
||||||
import { oneShot } from "../scripts/audio/audio";
|
import { oneShot } from "../scripts/audio/audio";
|
||||||
|
|
||||||
export interface GameMetaExtra extends GameMeta
|
export interface GameMetaExtra extends GameMeta
|
||||||
|
|
@ -16,7 +17,7 @@ export interface GameMetaExtra extends GameMeta
|
||||||
focusKey: string;
|
focusKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LocalCardElement (data: { game: GameMetaExtra, i: number; onQuickAction?: (ctx: InteractParamsArgs) => void; } & FocusParams & InteractParams)
|
function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusParams & InteractParams)
|
||||||
{
|
{
|
||||||
let preview: GameCardParams['preview'] = data.game.preview;
|
let preview: GameCardParams['preview'] = data.game.preview;
|
||||||
if (!preview && data.game.previewUrls)
|
if (!preview && data.game.previewUrls)
|
||||||
|
|
@ -31,28 +32,7 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; onQuickAction
|
||||||
oneShot('click');
|
oneShot('click');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAltAction = (ctx: InteractParamsArgs) =>
|
useShortcuts(data.game.focusKey, () => [{ label: "Details", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]);
|
||||||
{
|
|
||||||
data.game.onQuickAction?.();
|
|
||||||
data.onQuickAction?.({ event, focusKey: data.game.focusKey });
|
|
||||||
oneShot('click');
|
|
||||||
};
|
|
||||||
|
|
||||||
useShortcuts(data.game.focusKey, () =>
|
|
||||||
{
|
|
||||||
const options: Shortcut[] = [{
|
|
||||||
label: "Details",
|
|
||||||
button: GamePadButtonCode.A,
|
|
||||||
action: event => handleAction({ event, focusKey: data.game.focusKey })
|
|
||||||
}];
|
|
||||||
|
|
||||||
if (data.onQuickAction || data.game.onQuickAction)
|
|
||||||
{
|
|
||||||
options.push({ label: "Play", button: GamePadButtonCode.X, action: event => handleAltAction({ event, focusKey: data.game.focusKey }) });
|
|
||||||
}
|
|
||||||
|
|
||||||
return options;
|
|
||||||
}, [data.onQuickAction, data.game.onQuickAction, data.game.focusKey]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardElement
|
<CardElement
|
||||||
|
|
@ -112,12 +92,7 @@ export function CardList (data: {
|
||||||
>
|
>
|
||||||
<FocusContext.Provider value={focusKey}>
|
<FocusContext.Provider value={focusKey}>
|
||||||
{data.games.map((g, i) => <LocalCardElement
|
{data.games.map((g, i) => <LocalCardElement
|
||||||
key={g.id}
|
key={g.id} onFocus={data.onFocus} game={g} onAction={() => data.onSelectGame?.(g.id)} i={i} />)}
|
||||||
onFocus={data.onFocus}
|
|
||||||
game={g}
|
|
||||||
onAction={() => data.onSelectGame?.(g.id)}
|
|
||||||
i={i}
|
|
||||||
/>)}
|
|
||||||
{data.finalElement}
|
{data.finalElement}
|
||||||
</FocusContext.Provider>
|
</FocusContext.Provider>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ export default function CollectionList (data: {
|
||||||
id: string,
|
id: string,
|
||||||
setBackground: (url: string) => void;
|
setBackground: (url: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
onFocus?: GameCardFocusHandler;
|
||||||
onSelect?: (id: string) => void;
|
onSelect?: (id: string) => void;
|
||||||
saveChildFocus?: 'session' | 'local';
|
saveChildFocus?: 'session' | 'local';
|
||||||
} & FocusParams)
|
})
|
||||||
{
|
{
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: collections } = useSuspenseQuery(getCollectionsQuery);
|
const { data: collections } = useSuspenseQuery(getCollectionsQuery);
|
||||||
|
|
@ -36,7 +37,7 @@ export default function CollectionList (data: {
|
||||||
id: `${g.id.source}@${g.id.id}`,
|
id: `${g.id.source}@${g.id.id}`,
|
||||||
title: g.name,
|
title: g.name,
|
||||||
focusKey: `collection-${g.id}`,
|
focusKey: `collection-${g.id}`,
|
||||||
previewUrls: `${RPC_URL(__HOST__)}${g.path_platform_cover}`,
|
previewUrl: `${RPC_URL(__HOST__)}${g.path_platform_cover}`,
|
||||||
badges: [
|
badges: [
|
||||||
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
|
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
|
||||||
{g.game_count}
|
{g.game_count}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
import { HeaderButton, StickyHeaderUI } from './Header';
|
import { HeaderButton, StickyHeaderUI } from './Header';
|
||||||
import { GameList } from './GameList';
|
import { GameList } from './GameList';
|
||||||
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 { FloatingShortcuts } from './Shortcuts';
|
||||||
import { AutoFocus } from './AutoFocus';
|
import { AutoFocus } from './AutoFocus';
|
||||||
import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts';
|
import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts';
|
||||||
import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared';
|
import { GameListFilterSchema, GameListFilterType } from '@/shared/constants';
|
||||||
import { HandleGoBack } from '../scripts/utils';
|
import { HandleGoBack } from '../scripts/utils';
|
||||||
import LoadingCardList from './LoadingCardList';
|
import LoadingCardList from './LoadingCardList';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { gameFiltersQuery, gameQuery } from '../scripts/queries/romm';
|
import { gameFiltersQuery, gameQuery } from '../scripts/queries/romm';
|
||||||
import { useRouter } from '@tanstack/react-router';
|
import { useNavigate, useRouter } from '@tanstack/react-router';
|
||||||
import SelectMenu from './SelectMenu';
|
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';
|
import SideFilters from './SideFilters';
|
||||||
|
|
||||||
export interface CollectionsDetailParams
|
export interface CollectionsDetailParams
|
||||||
|
|
@ -68,7 +75,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
||||||
<div className='absolute top-0 bottom-0 left-0 right-0 bg-radial from-base-100 to-base-300 -z-1'></div>
|
<div className='absolute top-0 bottom-0 left-0 right-0 bg-radial from-base-100 to-base-300 -z-1'></div>
|
||||||
<div className='mobile:hidden bg-noise'></div>
|
<div className='mobile:hidden bg-noise'></div>
|
||||||
<div className='mobile:hidden bg-dots'></div>
|
<div className='mobile:hidden bg-dots'></div>
|
||||||
{!!finalFilter && data.title}
|
{finalFilter && data.title}
|
||||||
{<Suspense fallback={<LoadingCardList grid placeholderCount={data.countHint ?? 8} id={`${focusKey}-list`} />}>
|
{<Suspense fallback={<LoadingCardList grid placeholderCount={data.countHint ?? 8} id={`${focusKey}-list`} />}>
|
||||||
<GameList
|
<GameList
|
||||||
key={`${data.id}-${JSON.stringify(finalFilter)}`}
|
key={`${data.id}-${JSON.stringify(finalFilter)}`}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export function ContextList (data: {
|
||||||
{
|
{
|
||||||
const context = useContext(ContextDialogContext);
|
const context = useContext(ContextDialogContext);
|
||||||
return <ul className={twMerge("list gap-1", data.className)}>
|
return <ul className={twMerge("list gap-1", data.className)}>
|
||||||
{data.options?.map((o, i) => <OptionElement className="list-row" key={`${o.id}-${i}`} {...o} />)}
|
{data.options?.map((o, i) => <OptionElement className="list-row" key={i} {...o} />)}
|
||||||
{data.showCloseButton !== false && <div className="divider m-0 "></div>}
|
{data.showCloseButton !== false && <div className="divider m-0 "></div>}
|
||||||
{data.showCloseButton !== false && <OptionElement disabled={data.disableCloseButton} className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />}
|
{data.showCloseButton !== false && <OptionElement disabled={data.disableCloseButton} className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />}
|
||||||
</ul>;
|
</ul>;
|
||||||
|
|
@ -40,7 +40,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
|
||||||
};
|
};
|
||||||
const { ref, focusSelf, focusKey } = useFocusable({
|
const { ref, focusSelf, focusKey } = useFocusable({
|
||||||
focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id),
|
focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id),
|
||||||
onEnterPress: handleAction,
|
onEnterPress: data.shortcuts ? undefined : handleAction,
|
||||||
onFocus: handleFocus,
|
onFocus: handleFocus,
|
||||||
trackChildren: typeof data.content !== 'string'
|
trackChildren: typeof data.content !== 'string'
|
||||||
});
|
});
|
||||||
|
|
@ -64,7 +64,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
|
||||||
className={
|
className={
|
||||||
twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}>
|
twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}>
|
||||||
<FocusContext value={focusKey}>
|
<FocusContext value={focusKey}>
|
||||||
<div className={twMerge("flex bg-base-200 in-data-[selected=true]:border-4 in-focused:border-4 border-base-300 w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl gap-2 in-focused:font-semibold focusable light:not-in-data-[selected=true]:control-mouse:hover:bg-base-100 dark:not-in-data-[selected=true]:control-mouse:hover:bg-base-300 in-focused:z-100",
|
<div className={twMerge("flex bg-base-200 in-data-[selected=true]:border-4 in-focused:border-4 border-base-300 w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl gap-2 in-focused:font-semibold focusable not-active:control-mouse:hover:bg-base-300 in-focused:z-100",
|
||||||
data.className,
|
data.className,
|
||||||
colors[data.type],
|
colors[data.type],
|
||||||
"in-focused:bg-base-content in-focused:text-base-100")}>
|
"in-focused:bg-base-content in-focused:text-base-100")}>
|
||||||
|
|
@ -166,7 +166,7 @@ export function ContextDialog (data: {
|
||||||
}] : [], [data.open]);
|
}] : [], [data.open]);
|
||||||
|
|
||||||
return <dialog ref={ref} open={data.open} closedby="any" className={
|
return <dialog ref={ref} open={data.open} closedby="any" className={
|
||||||
twMerge("fixed modal cursor-pointer bg-base-300/60 not-mobile:backdrop-blur-md backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
|
twMerge("fixed modal cursor-pointer bg-base-300/80 not-mobile:backdrop-blur-md backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
|
||||||
classNames({ "opacity-0": !data.open }), data.backdropClassName)
|
classNames({ "opacity-0": !data.open }), data.backdropClassName)
|
||||||
}
|
}
|
||||||
onClick={handleClose}>
|
onClick={handleClose}>
|
||||||
|
|
@ -174,7 +174,7 @@ export function ContextDialog (data: {
|
||||||
<ContextDialogContext value={{ id: data.id, close: handleClose }} >
|
<ContextDialogContext value={{ id: data.id, close: handleClose }} >
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] max-h-[80vh] overflow-y-auto cursor-auto not-mobile:backdrop-blur-2xl not-mobile:drop-shadow-2xl",
|
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] max-h-[80vh] overflow-y-auto cursor-auto not-mobile:backdrop-blur-2xl",
|
||||||
data.open ? "animate-scale-delayed" : "opacity-0",
|
data.open ? "animate-scale-delayed" : "opacity-0",
|
||||||
data.className)
|
data.className)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue