Compare commits

..

40 commits

Author SHA1 Message Date
5593985884
chore: Fixed tests 2026-05-15 15:07:51 +03:00
9141fb35d4
feat: Implemented link game importing
feat: Implemented download page for downloading roms from various sources using plugins. Added support for internet archive external plugin.
feat: Added tasks page to track running tasks/downloads
feat: Added tanstack caching
feat: Added quick play action Fixes #6
feat: Added quick emulator launch action
fix: Made task queue only support 1 task per group and task ID should now be unique
2026-05-15 13:50:55 +03:00
9a3e605625
chore(release): 1.6.0
All checks were successful
Build and Upload Canary / build (push) Successful in 5m30s
2026-05-10 02:53:05 +03:00
2e78ddf08e
refactor: moved to commit-and-tag-version 2026-05-10 02:51:49 +03:00
38cb752552
feat: Implemented public plugin system accessible from the store.
feat: Implemented external ryujinx integration plugin
refactor: moved sdk types and schemas to own workspace package
fix: Fixed emulator launch with no game
2026-05-10 02:21:01 +03:00
9051834ace
chore: Updated packages 2026-05-07 14:43:48 +03:00
f82bf1215a
doc: Added discord link 2026-05-07 04:08:17 +03:00
11c4a802e4
chore(release): 1.5.0
All checks were successful
Build and Upload Canary / build (push) Successful in 5m16s
2026-05-05 23:04:57 +03:00
c9cf0b827c
doc: Added icon in readme 2026-05-05 23:03:23 +03:00
4da717c26d
fix: Navigation blocking now working with focuesed input fields
fix: Added warning to loging with lookup provider for better UX
feat: Added ROMM Client API Token in plugin settings
2026-05-05 22:24:15 +03:00
7029477392
doc: Added plugin dev info 2026-05-05 03:10:49 +03:00
04e332d91e
refactor: added type generation from schema for sdk with comments 2026-05-05 02:32:07 +03:00
2683d46b16
refactor: Removed the use of d.ts files to support SDK generation for public plugins 2026-05-05 01:21:22 +03:00
06b7e4074d
feat: Implemented local game import (with a wizard)
feat: Implemented a radial virtual gamepad keyboard.
fix: Fixed shortcuts for file explorer
2026-05-04 14:59:43 +03:00
e54a6ac8f0
docs: Added some more promo images 2026-04-27 20:08:10 +03:00
79b627ed31
docs: Updated readme and added gif screenshot 2026-04-27 01:20:54 +03:00
c23521bf94
docs: Updated screenshots and readme 2026-04-26 16:25:12 +03:00
1653e49465
chore(release): 1.4.0
All checks were successful
Build and Upload Canary / build (push) Successful in 5m36s
2026-04-26 15:46:22 +03:00
ae196e11d6
fix: Made self update work on windows 2026-04-26 15:46:03 +03:00
cf84f40a17
feat: added update notes and moved update to own tab
feat: added update info for emulators
2026-04-26 14:56:54 +03:00
813785f4f3
feat: Bundled NW.js with appimages
feat: Implemented self update
feat: Added rclone saves for emulators
fix: Fixed auto focus in builds
feat: Added helper cards on empty library
2026-04-26 03:26:15 +03:00
587956c792
Merge branch 'master' of github.com:simeonradivoev/gameflow-deck 2026-04-22 18:32:19 +03:00
701f882136
Added nw.js launch options 2026-04-22 18:31:32 +03:00
7bd0ebdcca
fix: logins now refresh on plugins load
feat: Added tar archive support
fix: Downloaded games and emulator execute permission now updated
fix: Fixed rclone for linux
fix: on screen keyaboard only now shows up when using a gamepad or touch
2026-04-21 23:21:50 +03:00
6aacec2c0d
fix: Fixed a bunch of issues on linux
fix: Removed archive when unzipping with stream zip fallback
2026-04-20 02:14:37 +03:00
7065e64722
refactor: removed store version constant 2026-04-18 10:47:38 +03:00
c09fbd3dc8
fix: Fixed tests
feat: Added RClone integration
feat: Implemented plugin settings
feat: Updated minimal store version
test: Fixed tests
feat: Moved store and igdb and es-de to their own plugins
2026-04-17 21:21:14 +03:00
444d8c4c27
feat: Implemented filtering and searching 2026-04-12 22:19:24 +03:00
4806f3487a
feat: Added way to update the local games from romm when IDs change based on IGDB or Retro Achievement ID
Fixes #2
2026-04-10 02:00:11 +03:00
7948bd24fa
feat: Implemented romm saves for dolphin and xenia
feat: Implemented save backups for emulatorjs
fix: Added support for rar archives
fix: Moved to individual ini adjustments for pcsx2 and ppsspp to allow for user editing of configs
2026-04-09 17:15:37 +03:00
54dd9256e3
feat: implemented haptics
feat: Implemented a select menu
fix: Only used audio clips compile
2026-04-07 15:28:56 +03:00
02a4f2c9a9
refactor: Removed unused vars and imports 2026-04-06 00:13:53 +03:00
05fafced07
feat: Added more ways to detect duplicates
feat: Added resolution and widescreen settings
feat: Added Xenia and Xemu integration
2026-04-06 00:05:00 +03:00
764691fc86
fix: Made store downloads extract in their own folder
feat: Implemented cemu integration
2026-04-05 12:46:50 +03:00
09b8b9c6f8
feat: Implemented emulator launching
Fixes #1
2026-04-04 03:13:09 +03:00
04d5856f7d
fix: Fixed emulator details buttons not showing 2026-04-03 23:18:29 +03:00
34db717ec5
feat: Implemented emulator versions and updating 2026-04-03 23:02:22 +03:00
a69147a4f7
feat: Implemented dolphin integration 2026-04-02 14:20:30 +03:00
edbc390d14
feat: Implemented audio effects 2026-04-01 21:20:34 +03:00
fe0ab3b498
build: updated lock file 2026-03-31 19:21:05 +03:00
359 changed files with 16086 additions and 4053 deletions

2
.config/appimage/AppRun Normal file
View file

@ -0,0 +1,2 @@
#!/bin/bash
exec "$APPDIR/usr/bin/{{BINARY_NAME}}" "$@"

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>{{APP_ID}}</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>{{LICENSE}}</project_license>
<name>{{APP_NAME}}</name>
<summary>Retro gaming frontend designed for handheld and controllers</summary>
<developer id="com.simeonradivoev">
<name>Simeon Radivoev</name>
</developer>
<description>
<p>A Cross-Platform Retro gaming frontend designed for handheld and controllers. Focused on building a simple user experience and intuitive UI.</p>
</description>
<categories>
<category>Game</category>
</categories>
<recommends>
<internet>always</internet>
</recommends>
<launchable type="desktop-id">{{APP_ID}}.desktop</launchable>
<url type="homepage">https://github.com/simeonradivoev/gameflow-deck</url>
<url type="bugtracker">https://github.com/simeonradivoev/gameflow-deck/issues</url>
<url type="donation">https://github.com/sponsors/simeonradivoev</url>
<screenshots>
<screenshot type="default">
<image>https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/yObFD2LySH.jpg</image>
</screenshot>
<screenshot>
<caption>Game Details</caption>
<image>https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/3nhuKCK6E3.jpg</image>
</screenshot>
<screenshot>
<caption>The Settings Panel</caption>
<image>https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/GL7SkQbHIY.png</image>
</screenshot>
<screenshot>
<caption>Emulator Details</caption>
<image>https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/xNj7scPEDQ.png</image>
</screenshot>
<screenshot>
<caption>Gameflow Store</caption>
<image>https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/CpBLzTNM6N.png</image>
</screenshot>
</screenshots>
<releases>
{{{RELEASES}}}
</releases>
<content_rating type="oars-1.0" />
<provides>
<id>{{APP_ID}}.desktop</id>
<binary>gameflow</binary>
</provides>
</component>

View file

@ -0,0 +1,10 @@
[Desktop Entry]
X-AppImage-Name={{APP_NAME}}
X-AppImage-Version={{VERSION}}
X-AppImage-Arch={{ARCH}}
Name={{APP_NAME}}
Comment={{DESCRIPTION}}
Exec=gameflow
Icon=gameflow
Type=Application
Categories=Game;

View file

@ -1,23 +1,36 @@
{ {
"app-id": "com.simeonradivoev.gameflow-deck", "app-id": "com.simeonradivoev.gameflow-deck",
"runtime": "org.kde.Platform", "runtime": "org.freedesktop.Platform",
"runtime-version": "6.10", "runtime-version": "25.08",
"sdk": "org.kde.Sdk", "sdk": "org.freedesktop.Sdk",
"command": "/app/bin/gameflow", "command": "/app/bin/gameflow",
"base": "io.qt.qtwebengine.BaseApp",
"base-version": "6.10",
"finish-args": [ "finish-args": [
"--share=ipc", "--share=ipc",
"--share=network", "--share=network",
"--socket=pulseaudio", "--socket=pulseaudio",
"--socket=wayland", "--socket=wayland",
"--socket=inherit-wayland-socket",
"--socket=x11", "--socket=x11",
"--socket=fallback-x11",
"--socket=session-bus",
"--socket=system-bus",
"--device=all", "--device=all",
"--filesystem=host", "--filesystem=host",
"--filesystem=home", "--filesystem=home",
"--filesystem=~/.steam/steam:rw",
"--filesystem=~/.steam:rw",
"--filesystem=~/.var/app/com.valvesoftware.Steam:rw",
"--filesystem=/run/udev:ro",
"--filesystem=/run/media",
"--filesystem=xdg-documents",
"--filesystem=xdg-desktop",
"--filesystem=xdg-run/gamescope-0:rw",
"--env=PKG_CONFIG_LIBDIR=/app/lib", "--env=PKG_CONFIG_LIBDIR=/app/lib",
"--env=FLATPAK_BUILD=true", "--env=FLATPAK_BUILD=true",
"--allow=devel" "--allow=devel",
"--talk-name=org.freedesktop.portal.OpenURI",
"--talk-name=org.freedesktop.Flatpak",
"--talk-name=org.a11y.Bus"
], ],
"modules": [ "modules": [
{ {
@ -29,7 +42,6 @@
"mkdir -p /app/lib", "mkdir -p /app/lib",
"install -Dm644 256x256.png /app/share/icons/hicolor/256x256/apps/com.simeonradivoev.gameflow-deck.png", "install -Dm644 256x256.png /app/share/icons/hicolor/256x256/apps/com.simeonradivoev.gameflow-deck.png",
"install -Dm644 com.simeonradivoev.gameflow-deck.desktop /app/share/applications/com.simeonradivoev.gameflow-deck.desktop", "install -Dm644 com.simeonradivoev.gameflow-deck.desktop /app/share/applications/com.simeonradivoev.gameflow-deck.desktop",
"mv libvips-cpp.so.* /app/lib",
"mv * /app/share/gameflow/", "mv * /app/share/gameflow/",
"mv /app/share/gameflow/gameflow /app/bin", "mv /app/share/gameflow/gameflow /app/bin",
"mv /app/share/gameflow/bun /app/bin", "mv /app/share/gameflow/bun /app/bin",
@ -39,15 +51,15 @@
"sources": [ "sources": [
{ {
"type": "dir", "type": "dir",
"path": "../build/linux" "path": "../../build/linux"
}, },
{ {
"type": "file", "type": "file",
"path": "../flatpak/com.simeonradivoev.gameflow-deck.desktop" "path": "com.simeonradivoev.gameflow-deck.desktop"
}, },
{ {
"type": "file", "type": "file",
"path": "../src/mainview/public/256x256.png" "path": "../../src/mainview/public/256x256.png"
}, },
{ {
"type": "script", "type": "script",
@ -72,23 +84,22 @@
"only-arches": [ "only-arches": [
"aarch64" "aarch64"
] ]
},
{
"type": "file",
"path": "../node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.8.17.3",
"only-arches": [
"x86_64"
]
} }
] ]
}, },
{ {
"name": "webview", "name": "NW.js",
"buildsystem": "cmake-ninja", "buildsystem": "simple",
"build-commands": [
"mkdir -p /app/bin/nw",
"mv * /app/bin/nw",
"chmod +x /app/bin/nw/nw"
],
"sources": [ "sources": [
{ {
"type": "dir", "type": "archive",
"path": "../flatpak/webview" "url": "https://dl.nwjs.io/v0.110.1/nwjs-v0.110.1-linux-x64.tar.gz",
"sha256": "d9a9ed2255e9ee87c9dd1860d9c7a479cea5279dcd80d3e80e23b083d325554a"
} }
] ]
} }

View file

@ -1,35 +0,0 @@
cmake_minimum_required(VERSION 3.16)
project(SimpleWebView LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Required for Qt WebEngine
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
find_package(Qt6 REQUIRED COMPONENTS
Core
Widgets
WebEngineWidgets,
Gamepad
)
add_executable(webview
main.cpp
)
target_link_libraries(webview PRIVATE
Qt6::Core
Qt6::Widgets
Qt6::WebEngineWidgets,
Qt6::Gamepad
)
# Install binary into Flatpak prefix (/app)
install(TARGETS webview
RUNTIME DESTINATION bin
)

View file

@ -1,14 +0,0 @@
#include <QApplication>
#include <QWebEngineView>
#include <QWebEngineSettings>
#include <QUrl>
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
if (argc < 2) { return 1; }
QWebEngineView view;
view.setUrl(QUrl(argv[1]));
view.showFullScreen();
return app.exec();
}

3
.gitattributes vendored
View file

@ -4,3 +4,6 @@
*.gif filter=lfs diff=lfs merge=lfs -text *.gif filter=lfs diff=lfs merge=lfs -text
*.webp filter=lfs diff=lfs merge=lfs -text *.webp filter=lfs diff=lfs merge=lfs -text
*.svg filter=lfs diff=lfs merge=lfs -text *.svg filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
*.mp3 filter=lfs diff=lfs merge=lfs -text
*.ogg filter=lfs diff=lfs merge=lfs -text

BIN
.github/screenshots/3d screenshot.png (Stored with Git LFS) vendored Normal file

Binary file not shown.

BIN
.github/screenshots/3nhuKCK6E3.jpg (Stored with Git LFS) vendored

Binary file not shown.

BIN
.github/screenshots/3nhuKCK6E3.png (Stored with Git LFS) vendored Normal file

Binary file not shown.

BIN
.github/screenshots/6wz3gW8c2h.png (Stored with Git LFS) vendored Normal file

Binary file not shown.

BIN
.github/screenshots/EWPHmIBEE5.png (Stored with Git LFS) vendored

Binary file not shown.

BIN
.github/screenshots/GL7SkQbHIY.png (Stored with Git LFS) vendored

Binary file not shown.

BIN
.github/screenshots/MMeJxl4IXr.png (Stored with Git LFS) vendored Normal file

Binary file not shown.

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 Normal file

Binary file not shown.

BIN
.github/screenshots/mockup-1777308293568.png (Stored with Git LFS) vendored Normal file

Binary file not shown.

BIN
.github/screenshots/rBY2mgTLy0.png (Stored with Git LFS) vendored Normal file

Binary file not shown.

BIN
.github/screenshots/xNj7scPEDQ.png (Stored with Git LFS) vendored

Binary file not shown.

BIN
.github/screenshots/yObFD2LySH.jpg (Stored with Git LFS) vendored

Binary file not shown.

BIN
.github/screenshots/zEQxtzhPGx.png (Stored with Git LFS) vendored Normal file

Binary file not shown.

View file

@ -83,7 +83,7 @@ jobs:
with: with:
type: "zip" type: "zip"
directory: ${{ github.workspace }} directory: ${{ github.workspace }}
filename: "Gameflow-Windows.zip" filename: "Gameflow-win32-x64.zip"
path: "canary-build-Windows" path: "canary-build-Windows"
- name: Publish Release - name: Publish Release
@ -96,4 +96,4 @@ jobs:
omitBodyDuringUpdate: true omitBodyDuringUpdate: true
replacesArtifacts: true replacesArtifacts: true
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
artifacts: "${{ github.workspace }}/canary-build-*/*.AppImage,${{ github.workspace }}/Gameflow-Windows.zip" artifacts: "${{ github.workspace }}/canary-build-*/*.AppImage,${{ github.workspace }}/Gameflow-*.zip"

5
.gitignore vendored
View file

@ -28,5 +28,8 @@ 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
xenia.log

18
.versionrc Normal file
View file

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

View file

@ -6,17 +6,22 @@
"files.watcherExclude": { "files.watcherExclude": {
"**/*.gen.*": true, "**/*.gen.*": true,
"src/mainview/gen/*": true, "src/mainview/gen/*": true,
"**/build": true,
"**/.config/flatpack/repo/**": true,
"**/.flatpak-builder/**": true,
}, },
"search.exclude": { "search.exclude": {
"**/*.gen.*": true, "**/*.gen.*": true,
".flatpak-builder/**/*": true, "**/.flatpak-builder": true,
"**/.config/flatpack/repo/**": true,
"**/build": true,
"src/mainview/gen/*": true, "src/mainview/gen/*": true,
}, },
"npm.scriptRunner": "bun", "npm.scriptRunner": "bun",
"npm.exclude": [ "npm.exclude": [
"**/.flatpak-builder/**/*", "**/.flatpak-builder/**/*",
"**/build/flatpack/**", "**/build/flatpack/**",
"**/flatpack/repo/**", "**/.config/flatpack/repo/**",
], ],
"editor.formatOnSave": true, "editor.formatOnSave": true,
"[typescriptreact]": { "[typescriptreact]": {

View file

@ -1,6 +1,52 @@
# Changelog # Changelog
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
## [1.6.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.5.0...v1.6.0) (2026-05-09)
### Features
* Implemented public plugin system accessible from the store. ([38cb752](https://github.com/simeonradivoev/gameflow-deck/commit/38cb7525527b5ad4f6eb284cdad0001fd87eaf7e))
## [1.5.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.4.0...v1.5.0) (2026-05-05)
### 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)
### Features
* Added more ways to detect duplicates ([05fafce](https://github.com/simeonradivoev/gameflow-deck/commit/05fafced07c853deb656d7c17d05184c42ee507c))
* added update notes and moved update to own tab ([cf84f40](https://github.com/simeonradivoev/gameflow-deck/commit/cf84f40a174b8f242ca58fb6fe02eefab46ff442))
* Added way to update the local games from romm when IDs change based on IGDB or Retro Achievement ID ([4806f34](https://github.com/simeonradivoev/gameflow-deck/commit/4806f3487a577ab8e7c66907e5b640d95ab8a46c)), closes [#2](https://github.com/simeonradivoev/gameflow-deck/issues/2)
* Bundled NW.js with appimages ([813785f](https://github.com/simeonradivoev/gameflow-deck/commit/813785f4f3d292a87cc4a6b86dc152c43572d2c8))
* Implemented audio effects ([edbc390](https://github.com/simeonradivoev/gameflow-deck/commit/edbc390d144bf44da35d0f5383ec36eb25c34d1b))
* Implemented dolphin integration ([a69147a](https://github.com/simeonradivoev/gameflow-deck/commit/a69147a4f73cf626b92622a8ee22b54f538d41a9))
* Implemented emulator launching ([09b8b9c](https://github.com/simeonradivoev/gameflow-deck/commit/09b8b9c6f850cea3b897308925faf9be02cefa1a)), closes [#1](https://github.com/simeonradivoev/gameflow-deck/issues/1)
* Implemented emulator versions and updating ([34db717](https://github.com/simeonradivoev/gameflow-deck/commit/34db717ec5cbcf8b1ae54fbda33bf9a78f01bd17))
* Implemented filtering and searching ([444d8c4](https://github.com/simeonradivoev/gameflow-deck/commit/444d8c4c278c6032b37f44a884cb6d7bf0b54c85))
* implemented haptics ([54dd925](https://github.com/simeonradivoev/gameflow-deck/commit/54dd9256e361877d0950a84061d9402616706352))
* Implemented romm saves for dolphin and xenia ([7948bd2](https://github.com/simeonradivoev/gameflow-deck/commit/7948bd24fabfc01b7be358f06fcd58c8795826c7))
### Bug Fixes
* Fixed a bunch of issues on linux ([6aacec2](https://github.com/simeonradivoev/gameflow-deck/commit/6aacec2c0de253a71599e261e07aff53055cdb1e))
* Fixed emulator details buttons not showing ([04d5856](https://github.com/simeonradivoev/gameflow-deck/commit/04d5856f7d71c944c82877d2a1457facea4b6d31))
* Fixed tests ([c09fbd3](https://github.com/simeonradivoev/gameflow-deck/commit/c09fbd3dc88891227eda2b9f3bd9ac45621c00ea))
* logins now refresh on plugins load ([7bd0ebd](https://github.com/simeonradivoev/gameflow-deck/commit/7bd0ebdcca1843076911547ec1098cbaae9e2414))
* Made self update work on windows ([ae196e1](https://github.com/simeonradivoev/gameflow-deck/commit/ae196e11d616b9813dba11f64e7c844077686db8))
* Made store downloads extract in their own folder ([764691f](https://github.com/simeonradivoev/gameflow-deck/commit/764691fc8610fafebc93a69ca24f74bcac42a898))
## [1.3.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.2.1...v1.3.0) (2026-03-31) ## [1.3.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.2.1...v1.3.0) (2026-03-31)

View file

@ -4,50 +4,78 @@ 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 doesn't have most of its major features implemented yet. > This app is actively in development, it is constantly changing and improving.
> It will have an opinionated design and will be used as an experiment in discovering a good UX. > It will have an opinionated design and will be used as an experiment in discovering a good UX.
## Community
Join us on Discord, where you can ask questions, submit ideas and get help.
[![](https://invidget.switchblade.xyz/R9KakhY67d)](https://discord.gg/R9KakhY67d)
## Features ## Features
### Integrations ### Integrations
- **[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.
- **[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 - **Emulators** - (WIP) Download and install emulators and automatically configure them from a list of supported in the store. Some even come with advanced features like cloud saves.
- **Free Curated Games** - Download free curreted games and homebrew roms without ever leaving the app - **Free Curated Games** - Download free curated games and homebrew roms without ever leaving the app
### Others ### 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 existing system browser to launch the front end, so no need to include a whole web browser. - **Lightweight** - It uses the window's webview as a frontend, reducing build size and ram usage.
- On Windows it first uses webview2 then your browser - On Windows it first uses webview2 then your browser
- On linux it uses WebKitGTK or a browser even from flatpak - On linux it does ship with NW.js to work on most distros. A big one is the steam deck missing WebKitGTK.
- Not tested on Mac yet - 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 games. - **Automatic Emulator Discovery** - Using the configs of the excellent ES-DE to discover installed emulators and launch roms. You can bring your existing configurations.
- Easy fallback configuration with built in file browser. - 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.
- **Dark and Light** - Dark and light themes for your preference.
## Screenshots ## Screenshots
<img src=".github/screenshots/Pkazk0RufB.png" width="25%"></img> <img src=".github/screenshots/3d screenshot.png" title="Home Screen Showing games sorted by latest activity" width="25%"></img>
<img src=".github/screenshots/3nhuKCK6E3.jpg" width="25%"></img> <img src=".github/screenshots/3nhuKCK6E3.png" title="Game Details." width="25%"></img>
<img src=".github/screenshots/yObFD2LySH.jpg" width="25%"></img> <img src=".github/screenshots/yObFD2LySH.jpg" title="Home Screen in dark mode" width="25%"></img>
<img src=".github/screenshots/GL7SkQbHIY.png" width="25%"></img> <img src=".github/screenshots/GL7SkQbHIY.png" title="Plugins Page" width="25%"></img>
<img src=".github/screenshots/CpBLzTNM6N.png" width="25%"></img> <img src=".github/screenshots/CpBLzTNM6N.png" title="Store Home Page" width="25%"></img>
<img src=".github/screenshots/xNj7scPEDQ.png" width="25%"></img> <img src=".github/screenshots/xNj7scPEDQ.png" title="Store emulator details" 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 currated free experiences. I'm also sick of the profit driven nature of games and promotions. - I'm sick of closed source and private store fronts, and want a way to share community curated free experiences. I'm also sick of the profit driven nature of games and promotions.
- Being self contained, I want to avoid writing as little as possible to system and contain and manage settings in a custom changeable directory. This was mainly a side-effect of having the low storage steam deck and always running out of space on my internal hard drive.
## Usage
There are currently 2 ways of getting games. One is logging in through romm and importing your games from there. The other is the store (it's a bit limited right now). I might add local import of roms since IGDB login is already implemented.
The app created a default folder in your home folder. You can move it. It stores everything there. From downloaded roms, emulators and configs.
## Existing Setups
The game should work pretty well with existing emulators one has installed. It uses the ES-DE config to find installed emulators. Only downside is more advanced integrations won't work, as they are mainly used for store emulators where the app has more control over, plus I don't want to mess up existing setups.
But given it's an existing setup, say from emudeck it won't matter much as it's already configured say for the steam deck.
## Development ## Development
@ -78,6 +106,17 @@ Focused on building a simple user experience and intuitive UI as a curated commu
- `bun run openapi-ts` generated the openapi client calls from romm's API - `bun run 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
@ -90,3 +129,10 @@ Focused on building a simple user experience and intuitive UI as a curated commu
- [elysia](https://elysiajs.com/) for the APIs - [elysia](https://elysiajs.com/) for the APIs
- [webview](https://github.com/webview/webview) for launching existing system webviews instead of full browser if possible. - [webview](https://github.com/webview/webview) for launching existing system webviews instead of full browser if possible.
- [emulatorjs](https://emulatorjs.org/) for playing lots of roms inside the app without having to deal with external emulators - [emulatorjs](https://emulatorjs.org/) for playing lots of roms inside the app without having to deal with external emulators
### Credits
- UI Sounds
- [CC BY 4.0 - Credit: JC Sounds](https://opengameart.org/content/jc-sounds-ui-utility-pack-vol-1)
- [Sounds by: Chhoff](https://chhoffmusic.itch.io/classic-ui-sfx)
- [UI Sound Effects by lolurio](https://lolurio.itch.io/lolurios-free-cozy-ui-sfx)

944
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,31 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_games` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`source_id` text,
`source` text,
`igdb_id` integer,
`name` text,
`ra_id` integer,
`path_fs` text,
`main_glob` text,
`last_played` integer,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`metadata` text DEFAULT '{}' NOT NULL,
`slug` text,
`platform_id` integer NOT NULL,
`cover` blob,
`type` text,
`summary` text,
`version` text,
`version_source` text,
`version_system` text,
FOREIGN KEY (`platform_id`) REFERENCES `platforms`(`id`) ON UPDATE cascade ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_games`("id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "main_glob", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", "version", "version_source", "version_system") SELECT "id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", NULL, "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", NULL, NULL, NULL FROM `games`;--> statement-breakpoint
DROP TABLE `games`;--> statement-breakpoint
ALTER TABLE `__new_games` RENAME TO `games`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `games_igdb_id_unique` ON `games` (`igdb_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `games_ra_id_unique` ON `games` (`ra_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `games_slug_unique` ON `games` (`slug`);

View file

@ -0,0 +1,479 @@
{
"version": "6",
"dialect": "sqlite",
"id": "40569ae5-facd-4680-bd48-fe70c5abf498",
"prevId": "6dc00b41-64d7-4cb3-ad90-f9e8e35af643",
"tables": {
"collections": {
"name": "collections",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"collections_games": {
"name": "collections_games",
"columns": {
"collection_id": {
"name": "collection_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_id": {
"name": "game_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"collections_games_collection_id_collections_id_fk": {
"name": "collections_games_collection_id_collections_id_fk",
"tableFrom": "collections_games",
"tableTo": "collections",
"columnsFrom": [
"collection_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
},
"collections_games_game_id_games_id_fk": {
"name": "collections_games_game_id_games_id_fk",
"tableFrom": "collections_games",
"tableTo": "games",
"columnsFrom": [
"game_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"games": {
"name": "games",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"source_id": {
"name": "source_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"igdb_id": {
"name": "igdb_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ra_id": {
"name": "ra_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"path_fs": {
"name": "path_fs",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"main_glob": {
"name": "main_glob",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_played": {
"name": "last_played",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'{}'"
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"platform_id": {
"name": "platform_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cover": {
"name": "cover",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"version": {
"name": "version",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"version_source": {
"name": "version_source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"version_system": {
"name": "version_system",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"games_igdb_id_unique": {
"name": "games_igdb_id_unique",
"columns": [
"igdb_id"
],
"isUnique": true
},
"games_ra_id_unique": {
"name": "games_ra_id_unique",
"columns": [
"ra_id"
],
"isUnique": true
},
"games_slug_unique": {
"name": "games_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {
"games_platform_id_platforms_id_fk": {
"name": "games_platform_id_platforms_id_fk",
"tableFrom": "games",
"tableTo": "platforms",
"columnsFrom": [
"platform_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"platforms": {
"name": "platforms",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"igdb_id": {
"name": "igdb_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"igdb_slug": {
"name": "igdb_slug",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"moby_id": {
"name": "moby_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"es_slug": {
"name": "es_slug",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ra_id": {
"name": "ra_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cover": {
"name": "cover",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"family_name": {
"name": "family_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"platforms_igdb_id_unique": {
"name": "platforms_igdb_id_unique",
"columns": [
"igdb_id"
],
"isUnique": true
},
"platforms_igdb_slug_unique": {
"name": "platforms_igdb_slug_unique",
"columns": [
"igdb_slug"
],
"isUnique": true
},
"platforms_moby_id_unique": {
"name": "platforms_moby_id_unique",
"columns": [
"moby_id"
],
"isUnique": true
},
"platforms_es_slug_unique": {
"name": "platforms_es_slug_unique",
"columns": [
"es_slug"
],
"isUnique": true
},
"platforms_ra_id_unique": {
"name": "platforms_ra_id_unique",
"columns": [
"ra_id"
],
"isUnique": true
},
"platforms_slug_unique": {
"name": "platforms_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"screenshots": {
"name": "screenshots",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"game_id": {
"name": "game_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"screenshots_game_id_games_id_fk": {
"name": "screenshots_game_id_games_id_fk",
"tableFrom": "screenshots",
"tableTo": "games",
"columnsFrom": [
"game_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -15,6 +15,13 @@
"when": 1772998956867, "when": 1772998956867,
"tag": "0001_outstanding_silk_fever", "tag": "0001_outstanding_silk_fever",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1776111721964,
"tag": "0002_flowery_rocket_raccoon",
"breakpoints": true
} }
] ]
} }

View file

@ -1,17 +1,26 @@
{ {
"name": "com.simeonradivoev.gameflow-deck", "name": "com.simeonradivoev.gameflow-deck",
"displayName": "Gameflow", "displayName": "Gameflow",
"version": "1.3.0", "author": {
"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",
"bin": "gameflow", "bin": "gameflow",
"license": "AGPL-3.0",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/simeonradivoev/gameflow-deck" "url": "https://github.com/simeonradivoev/gameflow-deck"
}, },
"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'",
@ -21,8 +30,8 @@
"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:prod:dynamic": "NODE_ENV=production NON_COMPILED=true 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",
"run:build-action": "act workflow_dispatch --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .forgejo/workflows/build.yml", "run:build-action": "act workflow_dispatch --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .forgejo/workflows/build.yml",
@ -33,99 +42,116 @@
"flatpak:generate-sources": "bun run ./scripts/generate-flatpak-sources.ts", "flatpak:generate-sources": "bun run ./scripts/generate-flatpak-sources.ts",
"flatpak:override": "flatpak override org.flatpak.Builder --filesystem=host --device=all", "flatpak:override": "flatpak override org.flatpak.Builder --filesystem=host --device=all",
"flatpak:restore": "flatpak override --reset --user org.flatpak.Builder", "flatpak:restore": "flatpak override --reset --user org.flatpak.Builder",
"flatpak:build": "flatpak run org.flatpak.Builder build/flatpak flatpak/com.simeonradivoev.gameflow-deck.json --repo=.config/flatpak/repo --force-clean", "flatpak:build": "FLATPAK_BUILD=true NODE_ENV=production NON_COMPILED=true bun run build && flatpak run org.flatpak.Builder ../gameflow-flatpak/build/flatpak .config/flatpak/com.simeonradivoev.gameflow-deck.json --repo=.config/flatpak/repo --state-dir=../gameflow-flatpak/state --force-clean",
"flatpak:install": "bun run flatpak:build && flatpak --user install --reinstall \"$PWD/.config/flatpak/repo\" com.simeonradivoev.gameflow-deck", "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": "standard-version --sign", "version:generate": "commit-and-tag-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",
"build:audiosprites": "bun ./scripts/generate-audio-sprites.ts",
"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.1", "@elysiajs/cors": "^1.4.2",
"@elysiajs/eden": "^1.4.6", "@elysiajs/eden": "^1.4.9",
"@jimp/wasm-webp": "^1.6.0", "@jimp/wasm-webp": "^1.6.1",
"@phalcode/ts-igdb-client": "^1.0.26",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"conf": "^15.0.2", "conf": "^15.1.0",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.2",
"elysia": "^1.4.22", "elysia": "^1.4.28",
"fs-extra": "^11.3.3", "fs-extra": "^11.3.5",
"get-folder-size": "^5.0.0", "get-folder-size": "^5.0.0",
"ini": "^6.0.0", "ini": "^6.0.0",
"jimp": "^1.6.0", "jimp": "^1.6.1",
"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.10", "node-downloader-helper": "^2.1.11",
"node-stream-zip": "^1.15.0", "node-stream-zip": "^1.15.0",
"node-unrar-js": "^2.0.2",
"npm-check-updates": "^22.2.0",
"open": "^11.0.0", "open": "^11.0.0",
"p-queue": "^9.2.0",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"systeminformation": "^5.31.5", "slugify": "^1.6.9",
"tapable": "^2.3.0", "smol-toml": "^1.6.1",
"tough-cookie": "^6.0.0", "systeminformation": "^5.31.6",
"tapable": "^2.3.3",
"tough-cookie": "^6.0.1",
"tough-cookie-file-store": "^3.3.0", "tough-cookie-file-store": "^3.3.0",
"ts-igdb-client": "^0.4.2",
"unzip-stream": "^0.3.4", "unzip-stream": "^0.3.4",
"webview-bun": "^2.4.0", "webview-bun": "^2.4.0",
"zod": "^4.3.6" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@ap0nia/eden": "^1.0.0-next.22", "@ap0nia/eden": "^1.6.1",
"@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.0", "@hey-api/openapi-ts": "^0.91.1",
"@noriginmedia/norigin-spatial-navigation": "^2.3.0", "@noriginmedia/norigin-spatial-navigation": "^3.1.0",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-form": "^1.28.0", "@tailwindcss/vite": "^4.3.0",
"@tanstack/react-query": "^5.90.20", "@tanstack/react-form": "^1.32.0",
"@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-query": "^5.100.10",
"@tanstack/react-router": "^1.157.16", "@tanstack/react-query-devtools": "^5.100.10",
"@tanstack/react-router-devtools": "^1.154.12", "@tanstack/react-query-persist-client": "^5.100.10",
"@tanstack/react-router-ssr-query": "^1.157.17", "@tanstack/react-router": "^1.169.2",
"@tanstack/router-plugin": "^1.157.16", "@tanstack/react-router-devtools": "^1.166.13",
"@tanstack/zod-adapter": "^1.162.4", "@tanstack/react-router-ssr-query": "^1.166.12",
"@tanstack/router-plugin": "^1.167.35",
"@tanstack/zod-adapter": "^1.166.9",
"@types/adm-zip": "^0.5.8", "@types/adm-zip": "^0.5.8",
"@types/audiosprite": "^0.7.3",
"@types/bun": "latest", "@types/bun": "latest",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/howler": "^2.2.12", "@types/howler": "^2.2.12",
"@types/ini": "^4.1.1", "@types/ini": "^4.1.1",
"@types/json-schema": "^7.0.15",
"@types/mustache": "^4.2.6", "@types/mustache": "^4.2.6",
"@types/node-7z": "^2.1.11", "@types/node-7z": "^2.1.11",
"@types/react": "^19.2.9", "@types/rclone.js": "^0.6.3",
"@types/react": "^19.2.14",
"@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.1.2", "@vitejs/plugin-react": "^5.2.0",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.17",
"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",
"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.14", "daisyui": "^5.5.19",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.10",
"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",
"react": "^19.2.4", "pretty-ms": "^9.3.0",
"react-dom": "^19.2.4", "react": "^19.2.6",
"react-error-boundary": "^6.1.0", "react-dom": "^19.2.6",
"react-error-boundary": "^6.1.1",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-qr-code": "^2.0.18", "react-markdown": "^10.1.0",
"sass-embedded": "^1.97.3", "react-qr-code": "^2.0.21",
"standard-version": "^9.5.0", "sass-embedded": "^1.99.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.3.0",
"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.1", "vite": "^7.3.3",
"vite-plugin-svg-icons-ng": "^1.5.2", "vite-plugin-svg-icons-ng": "^1.9.1",
"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"
} }

View file

@ -4,11 +4,11 @@ import fs from 'node:fs/promises';
import { appBuilderPath, } from 'app-builder-bin'; import { appBuilderPath, } from 'app-builder-bin';
import path from 'node:path'; import path from 'node:path';
import { ensureDir } from "fs-extra"; import { ensureDir } from "fs-extra";
import mustache from "mustache";
const APP_DIR = process.env.BUILD_DIR ?? `./build/${process.platform}`; const APP_DIR = process.env.BUILD_DIR ?? `./build/${process.platform}`;
const BINARY_NAME = pkg.bin; const BINARY_NAME = pkg.bin;
const ICON = "./src/mainview/public/256x256.png"; const ICON = "./src/mainview/public/256x256.png";
const DESKTOP = "./flatpak/com.simeonradivoev.gameflow-deck.desktop";
const TMP_FOLDER = "."; const TMP_FOLDER = ".";
const APP_NAME = pkg.displayName ?? pkg.name; const APP_NAME = pkg.displayName ?? pkg.name;
@ -27,24 +27,45 @@ await fs.rename(path.join(APPDIR, `usr`, 'share', BINARY_NAME), path.join(APPDIR
await fs.rename(path.join(APPDIR, `usr`, 'share', `libwebview-${process.arch}.so`), path.join(APPDIR, `usr`, 'lib', `libwebview-${process.arch}.so`)); await fs.rename(path.join(APPDIR, `usr`, 'share', `libwebview-${process.arch}.so`), path.join(APPDIR, `usr`, 'lib', `libwebview-${process.arch}.so`));
await fs.rename(path.join(APPDIR, `usr`, 'share', `7za`), path.join(APPDIR, `usr`, 'bin', `7za`)); await fs.rename(path.join(APPDIR, `usr`, 'share', `7za`), path.join(APPDIR, `usr`, 'bin', `7za`));
await fs.writeFile(path.join(APPDIR, `${APP_ID}.desktop`), `[Desktop Entry] if (!await fs.exists('./bin/nw/nw'))
Version=${pkg.version} {
X-AppImage-Name=${APP_NAME} await import('./download-nw');
X-AppImage-Version=${pkg.version} }
X-AppImage-Arch=${process.arch}
Name=${APP_NAME}
Comment=${pkg.description}
Exec=${APP_ID}.AppImage
Icon=.DirIcon
Type=Application
Categories=Game;
`);
await Bun.write(path.join(APPDIR, "AppRun"), `#!/bin/bash await ensureDir(path.join(APPDIR, `usr`, 'lib', 'nw'));
APPDIR="$(dirname "$(readlink -f "$0")")" await fs.cp('./bin/nw', path.join(APPDIR, `usr`, 'lib', 'nw'), { recursive: true });
APPIMAGE=true await fs.symlink(path.join(APPDIR, `usr`, 'lib', 'nw', 'nw'), path.join(APPDIR, `usr`, `bin`, 'nw'));
exec "$APPDIR/usr/bin/${BINARY_NAME}" "$@"
`); const templateVars = {
APP_NAME,
VERSION: pkg.version,
ARCH: process.arch,
DESCRIPTION: pkg.description,
APP_ID,
BINARY_NAME,
LICENSE: pkg.license
};
const desktopFileTemplate = await fs.readFile('./.config/appimage/com.simeonradivoev.gameflow-deck.desktop', 'utf8');
const raw = await $`git tag --sort=-version:refname`.text().then(d => d.trim());
const tags = raw.split('\n').filter(t => t.match(/^\d+\.\d+\.\d+$/));
console.log("tags", tags);
console.log(">>> Updating Release History...");
const releases = await Promise.all(tags.map(async tag =>
{
const date = await $`git log -1 --format=%as ${tag}`.text().then(d => d.trim());
const version = tag.replace(/^v/, '');
return ` <release version="${version}" date="${date}"/>`;
}));
const appStreamTemplate = await fs.readFile('./.config/appimage/com.simeonradivoev.gameflow-deck.appdata.xml', 'utf8');
await ensureDir(path.join(APPDIR, 'usr', 'share', 'metainfo'));
await fs.writeFile(path.join(APPDIR, 'usr', 'share', 'metainfo', `${APP_ID}.appdata.xml`), mustache.render(appStreamTemplate, { ...templateVars, RELEASES: releases }));
const appRunTemplate = await fs.readFile(`./.config/appimage/AppRun`, 'utf8');
await Bun.write(path.join(APPDIR, "AppRun"), mustache.render(appRunTemplate, templateVars));
await $`chmod +x ${APPDIR}/AppRun`; await $`chmod +x ${APPDIR}/AppRun`;
console.log(">>> Building AppImage..."); console.log(">>> Building AppImage...");
@ -52,7 +73,7 @@ const config = {
productName: pkg.displayName, productName: pkg.displayName,
productFilename: pkg.name, productFilename: pkg.name,
executableName: BINARY_NAME, executableName: BINARY_NAME,
desktopEntry: DESKTOP, desktopEntry: mustache.render(desktopFileTemplate, templateVars),
icons: [ icons: [
{ {
file: ICON, file: ICON,
@ -67,7 +88,7 @@ const config = {
// Remove the build dir, mainly to help with CIs // Remove the build dir, mainly to help with CIs
await fs.rm(APP_DIR, { recursive: true }); await fs.rm(APP_DIR, { recursive: true });
await ensureDir(APP_DIR); await ensureDir(APP_DIR);
const OUTPUT = path.resolve(APP_DIR, `${APP_NAME}.AppImage`); const OUTPUT = path.resolve(APP_DIR, `${APP_NAME}-${process.platform}-${process.arch}.AppImage`);
const STAGE = path.resolve(TMP_FOLDER, `${APP_ID}.stage`); const STAGE = path.resolve(TMP_FOLDER, `${APP_ID}.stage`);
await ensureDir(STAGE); await ensureDir(STAGE);
@ -86,8 +107,9 @@ const proc = Bun.spawn([
}); });
const code = await proc.exited; const code = await proc.exited;
await fs.rm(APPDIR, { recursive: true, force: true });
await fs.rm(STAGE, { recursive: true, force: true }); await fs.rm(STAGE, { recursive: true, force: true });
await fs.rm(APPDIR, { recursive: true, force: true });
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);
console.log(`\n Done!`); console.log(`\n Done!`);

View file

@ -2,50 +2,44 @@ import EventEmitter from "events";
import browser from '../src/bun/browser'; import browser from '../src/bun/browser';
import { tmpdir } from "os"; import { tmpdir } from "os";
import path from "path"; import path from "path";
import { createInterface } from "readline"; import { watch } from "fs";
import { Readable } from "stream"; import { sleep } from "bun";
const events = new EventEmitter(); const events = new EventEmitter();
const abortController = new AbortController(); const abortController = new AbortController();
let restarting = false;
process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=9222"; process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=9222";
process.env.NODE_ENV = "development"; process.env.NODE_ENV = "development";
let retries = 0;
function spawnServer () function spawnServer ()
{ {
const s = Bun.spawn(["bun", '--watch', '--install=fallback', "run", "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], { const s = Bun.spawn(["bun", '--install=fallback', "run", "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], {
env: { env: {
...process.env, ...process.env,
HEADLESS: "true", HEADLESS: "true",
}, },
stdout: "pipe", stdout: 'inherit',
stderr: "inherit", stderr: 'inherit',
stdin: "pipe", stdin: 'inherit',
signal: abortController.signal, signal: abortController.signal,
killSignal: 'SIGUSR1', killSignal: 'SIGKILL',
ipc (message, subprocess, handle)
{
if (message === 'focus')
{
events.emit('focus');
} else if (message === 'exitapp')
{
events.emit('exitapp');
}
},
onExit (subprocess, exitCode, signalCode) onExit (subprocess, exitCode, signalCode)
{ {
if (exitCode === 1 && retries <= 3) if (!restarting)
{
server = spawnServer();
retries++;
} else
{ {
console.log("Existing Dev With", exitCode);
process.exit(); process.exit();
} }
}
});
const rl = createInterface({ input: Readable.fromWeb(s.stdout as any) });
rl.on('line', e =>
{
if (e === 'focus')
{
events.emit('focus');
} else
{
console.log(e);
} }
}); });
return s; return s;
@ -56,9 +50,10 @@ function spawnBrowser ()
try try
{ {
return browser(events, process.env.FORCE_BROWSER === "true", { return browser(events, {
configPath: path.join(tmpdir(), 'gameflow'), configPath: path.join(tmpdir(), 'gameflow'),
isSteamDeckGameMode: false isSteamDeckGameMode: false,
forceBrowser: process.env.FORCE_BROWSER === "true"
}); });
} catch (error) } catch (error)
{ {
@ -66,13 +61,44 @@ function spawnBrowser ()
}; };
} }
let server = spawnServer(); async function restart ()
{
if (server)
{
restarting = true;
server.kill();
await server.exited;
server = undefined;
console.log("Old Server stopped");
}
server = spawnServer();
await sleep(1000);
console.log("New Server started");
restarting = false;
}
watch("./src/bun", { recursive: true }, (event, filename) =>
{
if (restarting) return;
console.log(`[watcher] ${event}: ${filename} — restarting...`);
restart();
});
watch("./src/packages", { recursive: true }, (event, filename) =>
{
if (restarting) return;
console.log(`[watcher] ${event}: ${filename} — restarting...`);
restart();
});
let server: Bun.Subprocess | undefined = spawnServer();
if (!process.env.HEADLESS) if (!process.env.HEADLESS)
{ {
spawnBrowser()?.then(async e => spawnBrowser()?.then(async e =>
{ {
console.log("Sending exit Signal to server"); if (!server) return;
await server.stdin.write('shutdown\n'); abortController.abort();
await server.stdin.flush(); await server.exited;
}); });
} }

54
scripts/download-nw.ts Normal file
View file

@ -0,0 +1,54 @@
import { ensureDir, remove } from "fs-extra";
import StreamZip from "node-stream-zip";
import { spawnSync } from "node:child_process";
import fs from 'node:fs/promises';
const VERSION = "0.110.1";
const platformMap: Record<string, string> = {
"win32": "win",
"darwin": "osx"
};
const extMap: Record<string, string> = {
"win32": "zip",
"linux": "tar.gz",
"darwin": "zip"
};
console.log("Removing old download");
await remove('./bin/nw');
const downloadUrl = `https://dl.nwjs.io/v${VERSION}/nwjs-sdk-v${VERSION}-${platformMap[process.platform] ?? process.platform}-${process.arch}.${extMap[process.platform]}`;
console.log("Starting NW download from", downloadUrl);
const response = await fetch(downloadUrl);
if (!response.ok) throw new Error(response.statusText);
const downlodPath = `./bin/nw.${extMap[process.platform]}`;
await ensureDir('./bin');
await Bun.write(downlodPath, response);
console.log("Downloaded NW to", downlodPath);
if (downlodPath.endsWith('.zip'))
{
await extractZip(downlodPath, './bin');
}
else if (downlodPath.endsWith(".tar.gz"))
{
const result = spawnSync("tar", ["-xvf", downlodPath, "-C", './bin'], { stdio: "inherit" });
if (result.status !== 0) console.error("tar extraction failed");
}
console.log('Renaming to nw');
await fs.rename(`./bin/nwjs-sdk-v${VERSION}-${platformMap[process.platform] ?? process.platform}-${process.arch}`, './bin/nw');
await fs.rm(downlodPath);
async function extractZip (src: string, outDir: string)
{
console.log(`Extracting zip -> ${outDir}`);
const zip = new StreamZip.async({ file: src });
const entries = await zip.entries();
const total = Object.keys(entries).length;
await zip.extract(null, outDir);
await zip.close();
console.log(`Extracted ${total} files.`);
}

View file

@ -0,0 +1,34 @@
CREATE TABLE `commands` (
`system` text,
`label` text,
`command` text NOT NULL,
FOREIGN KEY (`system`) REFERENCES `systems`(`name`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `emulators` (
`name` text PRIMARY KEY NOT NULL,
`fullname` text,
`systempath` text DEFAULT (json_array()) NOT NULL,
`staticpath` text DEFAULT (json_array()) NOT NULL,
`corepath` text DEFAULT (json_array()) NOT NULL,
`androidpackage` text DEFAULT (json_array()) NOT NULL,
`winregistrypath` text DEFAULT (json_array()) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `emulators_name_unique` ON `emulators` (`name`);--> statement-breakpoint
CREATE TABLE `systemMappings` (
`source` text,
`sourceSlug` text,
`sourceId` integer,
`system` text NOT NULL,
FOREIGN KEY (`system`) REFERENCES `systems`(`name`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `systems` (
`name` text PRIMARY KEY NOT NULL,
`fullname` text,
`path` text,
`extension` text DEFAULT (json_array()) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `systems_name_unique` ON `systems` (`name`);

View file

@ -0,0 +1,234 @@
{
"version": "6",
"dialect": "sqlite",
"id": "b4ee710f-eaa5-4bbb-9e69-13d490c7142c",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"commands": {
"name": "commands",
"columns": {
"system": {
"name": "system",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"command": {
"name": "command",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"commands_system_systems_name_fk": {
"name": "commands_system_systems_name_fk",
"tableFrom": "commands",
"tableTo": "systems",
"columnsFrom": [
"system"
],
"columnsTo": [
"name"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"emulators": {
"name": "emulators",
"columns": {
"name": {
"name": "name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"fullname": {
"name": "fullname",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"systempath": {
"name": "systempath",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(json_array())"
},
"staticpath": {
"name": "staticpath",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(json_array())"
},
"corepath": {
"name": "corepath",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(json_array())"
},
"androidpackage": {
"name": "androidpackage",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(json_array())"
},
"winregistrypath": {
"name": "winregistrypath",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(json_array())"
}
},
"indexes": {
"emulators_name_unique": {
"name": "emulators_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"systemMappings": {
"name": "systemMappings",
"columns": {
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sourceSlug": {
"name": "sourceSlug",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sourceId": {
"name": "sourceId",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"system": {
"name": "system",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"systemMappings_system_systems_name_fk": {
"name": "systemMappings_system_systems_name_fk",
"tableFrom": "systemMappings",
"tableTo": "systems",
"columnsFrom": [
"system"
],
"columnsTo": [
"name"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"systems": {
"name": "systems",
"columns": {
"name": {
"name": "name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"fullname": {
"name": "fullname",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"extension": {
"name": "extension",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(json_array())"
}
},
"indexes": {
"systems_name_unique": {
"name": "systems_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1776039605377,
"tag": "0000_sparkling_banshee",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,30 @@
import audioSprite from 'audiosprite';
import path from 'node:path';
import { soundMap } from '../src/mainview/scripts/audio/audioConstants';
var allFiles = await Array.fromAsync(new Bun.Glob('*.{ogg,wav}').scan({ cwd: './src/sounds' }));
const files = Object.values(soundMap).map(v =>
{
const existingFile = allFiles.find(f => f.startsWith(v.key));
if (!existingFile) throw new Error(`Could not find file for sound ${v.key}`);
const filePath = path.join(path.resolve('./src/sounds'), existingFile);
return filePath;
});
console.log("Loaded", files.join(","));
await new Promise((resolve) =>
{
audioSprite(files,
{
output: path.resolve('./src/mainview/assets/sounds'),
path: path.resolve('./src/sounds'),
format: 'howler',
export: 'ogg'
},
async function (err, obj: any)
{
if (err) return console.error(err);
delete obj.urls;
Bun.file('./src/mainview/assets/sounds.json').write(JSON.stringify(obj, null, 2)).then(r => resolve(true));
});
});

View file

@ -96,12 +96,18 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
}); });
const rommMapping = rommPlatforms.data?.find(p => const rommMapping = rommPlatforms.data?.find(p =>
p.slug === (customMappings as any)[name] || {
p.slug === name || const custom = (customMappings as any)[name];
p.igdb_slug === name || if (Array.isArray(custom) && custom.some(m => m === p.slug))
p.hltb_slug === name || {
p.moby_slug === name || return true;
p.display_name === fullname }
return p.slug === custom ||
p.slug === name ||
p.igdb_slug === name ||
p.display_name === fullname;
}
); );
const mappings: { const mappings: {

View file

@ -1,6 +1,5 @@
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();

View file

@ -1,5 +1,5 @@
import { TaskQueue } from "./task-queue"; import { TaskQueue, AppEventMap } from "@simeonradivoev/gameflow-sdk";
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 { SettingsSchema, SettingsType } from "@shared/constants"; import { SettingsType, SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared';
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";
@ -18,13 +18,12 @@ import EventEmitter from "node:events";
import { appPath } from "../utils"; import { appPath } from "../utils";
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
import { ensureDir } from "fs-extra"; import { ensureDir } from "fs-extra";
import UpdateStoreJob from "./jobs/update-store";
import { getStoreFolder } from "./store/services/gamesService";
import { PluginManager } from "./plugins/plugin-manager"; import { PluginManager } from "./plugins/plugin-manager";
import registerPlugins from "./plugins/register-plugins"; import registerPlugins from "./plugins/register-plugins";
import controls from './controls/controls'; import controls from './controls/controls';
import { RunAPIServer } from "./rpc"; import { RunAPIServer } from "./rpc";
import { RunBunServer } from "../server"; import { RunBunServer } from "../server";
import ReloadPluginsJob from "./jobs/reload-plugins-job";
export let config: Conf<SettingsType>; export let config: Conf<SettingsType>;
export let customEmulators: Conf<Record<string, string>>; export let customEmulators: Conf<Record<string, string>>;
@ -43,6 +42,8 @@ export let events: EventEmitter<AppEventMap>;
let controlsHandle: { cleanup: () => void; }; let controlsHandle: { cleanup: () => void; };
let api: { cleanup: () => Promise<void>; }; let api: { cleanup: () => Promise<void>; };
let bunServer: { cleanup: () => Promise<void>; } | undefined; let bunServer: { cleanup: () => Promise<void>; } | undefined;
let cleannedUp = false;
let cleaningUp = false;
export async function load () export async function load ()
{ {
@ -56,6 +57,7 @@ export async function load ()
windowSize: { width: 1280, height: 800 } windowSize: { width: 1280, height: 800 }
}), }),
}); });
customEmulators = new Conf<Record<string, string>>({ customEmulators = new Conf<Record<string, string>>({
projectName: projectPackage.name, projectName: projectPackage.name,
projectSuffix: 'bun', projectSuffix: 'bun',
@ -72,7 +74,7 @@ export async function load ()
console.log("Config Path Located At: ", config.path); console.log("Config Path Located At: ", config.path);
console.log("Custom Emulator Paths Located At: ", customEmulators.path); console.log("Custom Emulator Paths Located At: ", customEmulators.path);
console.log("App Directory is ", process.env.APPDIR); console.log("App Directory is ", process.env.APPDIR);
console.log("Store Directory is ", getStoreFolder()); console.log("Cache Path is ", cachePath);
cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite'); cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite');
fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json')); fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
@ -84,18 +86,21 @@ export async function load ()
emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema }); emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
await reloadDatabase(); await reloadDatabase();
plugins = new PluginManager(); plugins = new PluginManager();
await registerPlugins(plugins);
api = await RunAPIServer(); api = await RunAPIServer();
await registerPlugins(plugins);
taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob());
controlsHandle = await controls(); controlsHandle = await controls();
if (!process.env.PUBLIC_ACCESS) bunServer = await RunBunServer(); if (!process.env.PUBLIC_ACCESS) bunServer = await RunBunServer();
config.onDidChange('downloadPath', () => reloadDatabase()); config.onDidChange('downloadPath', () => reloadDatabase());
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v })); config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
} }
export async function cleanup () export async function cleanup ()
{ {
if (cleaningUp) throw new Error("Already Cleaning Up");
cleaningUp = true;
if (cleannedUp) throw new Error("Already Cleaned Up. Skipping");
console.log("Cleaning Up"); console.log("Cleaning Up");
await bunServer?.cleanup(); await bunServer?.cleanup();
await api.cleanup(); await api.cleanup();
@ -108,6 +113,14 @@ export async function cleanup ()
config._closeWatcher(); config._closeWatcher();
customEmulators._closeWatcher(); customEmulators._closeWatcher();
console.log("Finished Cleaning Up"); console.log("Finished Cleaning Up");
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 ()
@ -120,6 +133,7 @@ export async function reloadDatabase ()
db = drizzle(sqlite, { schema }); db = drizzle(sqlite, { schema });
cache = drizzle(cacheSqlite, { schema: cacheSchema }); cache = drizzle(cacheSqlite, { schema: cacheSchema });
migrate(db!, { migrationsFolder: appPath("./drizzle") }); migrate(db!, { migrationsFolder: appPath("./drizzle") });
sqlite.run("PRAGMA foreign_keys = ON;");
await cache.run(` await cache.run(`
CREATE TABLE IF NOT EXISTS item_cache ( CREATE TABLE IF NOT EXISTS item_cache (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,

View file

@ -1,5 +1,5 @@
import Elysia, { status } from "elysia"; import Elysia, { status } from "elysia";
import { config, events, jar, plugins, taskQueue } from "./app"; import { config, events, plugins, taskQueue } from "./app";
import z from "zod"; import z from "zod";
import { getCurrentUserApiUsersMeGet, tokenApiTokenPost, UserSchema } from "@clients/romm"; import { getCurrentUserApiUsersMeGet, tokenApiTokenPost, UserSchema } from "@clients/romm";
import secrets from '../api/secrets'; import secrets from '../api/secrets';
@ -46,67 +46,7 @@ export default new Elysia()
return status(res.status, res.statusText); return status(res.status, res.statusText);
}) })
.get('/login/twitch', async () => .get('/login/twitch', checkLoginAndRefreshTwitch)
{
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
if (!access_token)
{
return status('Not Found', "Not Logged In");
}
const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${access_token}` } });
if (res.ok)
{
return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; };
}
if (!process.env.TWITCH_CLIENT_ID)
{
return status("Not Found", "Twitch Client ID not set");
}
const refresh_token = await secrets.get({ service: 'gamflow_twitch', name: "refresh_token" });
if (!refresh_token)
{
return status("Not Found", "Refresh Token Not Found");
}
// refresh token
const refreshResponse = await fetch('https://id.twitch.tv/oauth2/token', {
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({
client_id: process.env.TWITCH_CLIENT_ID,
access_token,
grant_type: "refresh_token",
refresh_token
})
});
if (refreshResponse.ok)
{
const data: {
access_token: string,
refresh_token: string,
token_type: string;
expires_in: number;
} = await refreshResponse.json();
await secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token });
await secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token });
await secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() });
await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' });
events.emit('notification', { message: "Twitch Refresh Successful", type: 'success' });
const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${data.access_token}` } });
if (res.ok)
{
return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; };
}
}
return status(400, res.statusText);
})
.post('/login/romm/qr', async () => .post('/login/romm/qr', async () =>
{ {
if (taskQueue.hasActiveOfType(LoginJob)) if (taskQueue.hasActiveOfType(LoginJob))
@ -123,47 +63,7 @@ export default new Elysia()
return data.data as UserSchema; return data.data as UserSchema;
}) })
.post('/login/romm', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) }) .post('/login/romm', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
.get('/login/romm', async () => .get('/login/romm', checkLoginAndRefreshRomm,
{
const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' });
if (!access_token)
{
return { hasLogin: false };
}
const expires_in = await secrets.get({ service: 'gameflow', name: "romm_expires_in" });
if (expires_in)
{
const date = new Date(expires_in);
if (date > new Date())
{
return { hasLogin: true };
}
}
const refresh_token = await secrets.get({ service: 'gameflow', name: "romm_refresh_token" });
if (!refresh_token)
{
return { hasLogin: false };
}
const refreshResponse = await tokenApiTokenPost({ body: { grant_type: "refresh_token", refresh_token: refresh_token } });
if (refreshResponse.response.ok && refreshResponse.data)
{
await secrets.set({ service: 'gameflow', name: 'romm_access_token', value: refreshResponse.data.access_token });
if (refreshResponse.data.refresh_token)
await secrets.set({ service: 'gameflow', name: 'romm_refresh_token', value: refreshResponse.data.refresh_token });
await secrets.set({ service: 'gameflow', name: 'romm_expires_in', value: new Date(new Date().getTime() + refreshResponse.data.expires * 1000).toString() });
await plugins.hooks.auth.loginComplete.promise({ service: 'romm' });
events.emit('notification', { message: "Romm Refresh Successful", type: 'success' });
return { hasLogin: true };
}
return status(refreshResponse.response.status, refreshResponse.response.statusText) as any;
},
{ response: z.object({ hasLogin: z.boolean() }) }) { response: z.object({ hasLogin: z.boolean() }) })
.post('/logout/romm', async () => .post('/logout/romm', async () =>
{ {
@ -174,6 +74,109 @@ export default new Elysia()
}, { response: z.any() }); }, { response: z.any() });
export async function checkLoginAndRefreshTwitch ()
{
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
if (!access_token)
{
return status('Not Found', "Not Logged In");
}
const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${access_token}` } });
if (res.ok)
{
return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; };
}
if (!process.env.TWITCH_CLIENT_ID)
{
return status("Not Found", "Twitch Client ID not set");
}
const refresh_token = await secrets.get({ service: 'gamflow_twitch', name: "refresh_token" });
if (!refresh_token)
{
return status("Not Found", "Refresh Token Not Found");
}
// refresh token
const refreshResponse = await fetch('https://id.twitch.tv/oauth2/token', {
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({
client_id: process.env.TWITCH_CLIENT_ID,
access_token,
grant_type: "refresh_token",
refresh_token
})
});
if (refreshResponse.ok)
{
const data: {
access_token: string,
refresh_token: string,
token_type: string;
expires_in: number;
} = await refreshResponse.json();
await secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token });
await secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token });
await secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() });
await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' });
events.emit('notification', { message: "Twitch Refresh Successful", type: 'success' });
const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${data.access_token}` } });
if (res.ok)
{
return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; };
}
}
return status(400, res.statusText);
}
export async function checkLoginAndRefreshRomm ()
{
const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' });
if (!access_token)
{
return { hasLogin: false };
}
const expires_in = await secrets.get({ service: 'gameflow', name: "romm_expires_in" });
if (expires_in)
{
const date = new Date(expires_in);
if (date > new Date())
{
return { hasLogin: true };
}
}
const refresh_token = await secrets.get({ service: 'gameflow', name: "romm_refresh_token" });
if (!refresh_token)
{
return { hasLogin: false };
}
const refreshResponse = await tokenApiTokenPost({ body: { grant_type: "refresh_token", refresh_token: refresh_token } });
if (refreshResponse.response.ok && refreshResponse.data)
{
await secrets.set({ service: 'gameflow', name: 'romm_access_token', value: refreshResponse.data.access_token });
if (refreshResponse.data.refresh_token)
await secrets.set({ service: 'gameflow', name: 'romm_refresh_token', value: refreshResponse.data.refresh_token });
await secrets.set({ service: 'gameflow', name: 'romm_expires_in', value: new Date(new Date().getTime() + refreshResponse.data.expires * 1000).toString() });
await plugins.hooks.auth.loginComplete.promise({ service: 'romm' });
events.emit('notification', { message: "Romm Refresh Successful", type: 'success' });
return { hasLogin: true };
}
return status(refreshResponse.response.status, refreshResponse.response.statusText) as any;
}
export async function tryLoginAndSave ({ host, username, password }: { host: string, username: string, password: string; }) export async function tryLoginAndSave ({ host, username, password }: { host: string, username: string, password: string; })
{ {
@ -181,7 +184,7 @@ export async function tryLoginAndSave ({ host, username, password }: { host: str
body: { body: {
password, password,
username, username,
scope: 'me.read roms.read platforms.read assets.read firmware.read roms.user.read collections.read me.write roms.user.write' scope: 'me.read roms.read platforms.read assets.read assets.write firmware.read roms.user.read collections.read me.write roms.user.write'
}, baseUrl: host }, baseUrl: host
}); });

View file

@ -1,7 +1,9 @@
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 "@/shared/constants"; import { GithubReleaseSchema } from '@simeonradivoev/gameflow-sdk/shared';
import PQueue from "p-queue";
import z from "zod";
export const CACHE_KEYS = { export const CACHE_KEYS = {
ROM_PLATFORMS: 'rom-platforms', ROM_PLATFORMS: 'rom-platforms',
@ -9,17 +11,21 @@ export const CACHE_KEYS = {
STORE_GAME_MANIFEST: 'store-game-manifest' STORE_GAME_MANIFEST: 'store-game-manifest'
} as const; } as const;
export async function getOrCached<T> (key: string, getter: () => Promise<T>, options?: { expireMs?: number; }): Promise<T> // we aggressively cache github data so burst of calls is fine.
export const githubRequestQueue = new PQueue({ intervalCap: 60, interval: 1000 * 60 * 60, strict: true });
export async function getOrCached<T> (key: string, getter: (lastValue: T | undefined) => Promise<T>, options?: { expireMs?: number; force?: boolean; }): Promise<T>
{ {
const cached = await cache.query.item_cache.findFirst({ where: eq(cacheSchema.item_cache.key, key) }); const cached = await cache.query.item_cache.findFirst({ where: eq(cacheSchema.item_cache.key, key) });
const updated_at = new Date(); const updated_at = new Date();
if (cached && cached.expire_at > updated_at) if (cached && cached.expire_at > updated_at && !options?.force)
{ {
return cached.data as T; return cached.data as T;
} }
const data = await getter(); const data = await getter(cached?.data as T);
if (data === undefined) return data;
const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000); const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000);
@ -34,12 +40,15 @@ export async function getOrCached<T> (key: string, getter: () => Promise<T>, opt
return data; return data;
} }
export async function getOrCachedGithubRelease (path: string) export async function getOrCachedGithubRelease (path: string, forceCheck?: boolean)
{ {
return getOrCached(`github-release-${path}`, async () => return getOrCached<z.infer<typeof GithubReleaseSchema>>(`github-release-${path}`, () => githubRequestQueue.add(async () =>
{ {
const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, { method: "GET" }); const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, {
method: "GET"
});
if (!response.ok) throw new Error(response.statusText); if (!response.ok) throw new Error(response.statusText);
return GithubReleaseSchema.parseAsync(await response.json()); const release = await GithubReleaseSchema.parseAsync(await response.json());
}); return release;
}), { expireMs: 1000 * 60 * 60, force: forceCheck });
} }

View file

@ -5,9 +5,10 @@ import games from "./games/games";
import platforms from "./games/platforms"; import platforms from "./games/platforms";
import auth from "./auth"; import auth from "./auth";
import collections from "./games/collections"; import collections from "./games/collections";
import emulatorjs from "./emulatorjs/emulatorjs";
export default new Elysia({ prefix: "/api/romm" }) export default new Elysia({ prefix: "/api/romm" })
.use([games, platforms, collections, auth]) .use([games, platforms, collections, auth, emulatorjs])
.all("/*", async ({ request, set }) => .all("/*", async ({ request, set }) =>
{ {
set.headers["cross-origin-resource-policy"] = 'cross-origin'; set.headers["cross-origin-resource-policy"] = 'cross-origin';

View file

@ -14,8 +14,8 @@ export default async function Initialize ()
const launchGameTask = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob); const launchGameTask = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob);
if (launchGameTask) if (launchGameTask)
{ {
launchGameTask.abort('exit');
taskQueue.waitForJob(LaunchGameJob.id).then(() => setTimeout(() => events.emit('focus'), 300)); taskQueue.waitForJob(LaunchGameJob.id).then(() => setTimeout(() => events.emit('focus'), 300));
launchGameTask.abort('exit');
} else } else
{ {
events.emit('focus'); events.emit('focus');

View file

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

View file

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

View file

@ -1,4 +1,12 @@
// ES-DE to emulator JS mapping // ES-DE to emulator JS mapping
import Elysia, { status } from "elysia";
import z from "zod";
import path from 'node:path';
import { config, events, plugins } from "../app";
import { getLocalGame, updateLocalLastPlayed } from "../games/services/statusService";
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> = {
"atari5200": "atari5200", "atari5200": "atari5200",
@ -43,4 +51,57 @@ export const cores: Record<string, string> = {
"plus4": "plus4", "plus4": "plus4",
"vic20": "vic20", "vic20": "vic20",
"dos": "dos" "dos": "dos"
}; };
export default new Elysia({ prefix: '/emulatorjs' })
.put('/save', async ({ body: { save, screenshot } }) =>
{
await Bun.write(path.join(config.get('downloadPath'), 'saves', "EMULATORJS", save.name), save);
}, {
body: z.object({
save: z.file(),
screenshot: z.file().optional()
})
}).get('/load', async ({ query: { filePath } }) =>
{
return Bun.file(path.join(config.get('downloadPath'), 'saves', "EMULATORJS", filePath));
}, { query: z.object({ filePath: z.string() }) })
.post('/post_play/:source/:id', async ({ params: { source, id }, body: { save } }) =>
{
const localGame = await getLocalGame(source, id);
if (!localGame) return status("Not Found");
const changedSaveFiles: Record<string, SaveFileChange> = {};
if (save)
{
const savesPath = path.join(config.get('downloadPath'), 'saves', "EMULATORJS");
const saveFile = path.join(savesPath, save.name);
await Bun.write(saveFile, save);
changedSaveFiles.gameflow = { subPath: save.name, cwd: savesPath, shared: false };
events.emit('notification', { message: "Save Backed Up", type: "success", icon: "save" });
}
await updateLocalLastPlayed(localGame.id);
await plugins.hooks.games.postPlay.promise({
source,
id,
saveFolderSlots: { 'emulatorjs': { cwd: path.join(config.get('downloadPath'), "saves", "EMULATORJS") } },
gameInfo: { platformSlug: localGame?.platform.slug },
changedSaveFiles: [],
validChangedSaveFiles: changedSaveFiles,
command: {
id: "EMULATORJS",
command: "",
emulator: "EMULATORJS",
valid: true,
metadata: {
romPath: localGame?.path_fs ?? undefined,
emulatorBin: undefined,
emulatorDir: undefined
}
}
});
}, {
body: z.object({
save: z.file().optional()
})
});

View file

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

View file

@ -1,25 +1,29 @@
import Elysia, { status } from "elysia"; import Elysia, { status } from "elysia";
import { config, db, emulatorsDb, plugins, taskQueue } from "../app"; import { config, db, emulatorsDb, plugins, taskQueue } from "../app";
import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm"; 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 { GameListFilterSchema, SERVER_URL } from "@shared/constants"; import { SERVER_URL } from "@shared/constants";
import { CommandEntry, DownloadLookupEntry, DownloadsLookupFilterValues, GameListFilterSchema } from '@simeonradivoev/gameflow-sdk/shared';
import { InstallJob } from "../jobs/install-job"; import { InstallJob } from "../jobs/install-job";
import path from "node:path"; import path from "node:path";
import { convertLocalToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService"; import buildStatusResponse, { customUpdate, fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService";
import { errorToResponse } from "elysia/adapter/bun/handler"; import { errorToResponse } from "elysia/adapter/bun/handler";
import { getEmulatorsForSystem, launchCommand } from "./services/launchGameService"; import { launchCommand } from "./services/launchGameService";
import { getErrorMessage, SeededRandom } from "@/bun/utils"; import { getErrorMessage, SeededRandom } from "@/bun/utils";
import { defaultFormats, defaultPlugins } from 'jimp'; import { defaultFormats, defaultPlugins } from 'jimp';
import { createJimp } from "@jimp/core"; import { createJimp } from "@jimp/core";
import webp from "@jimp/wasm-webp"; import webp from "@jimp/wasm-webp";
import * as emulatorSchema from '@schema/emulators'; import * as emulatorSchema from '@schema/emulators';
import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService"; import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService";
import { host } from "@/bun/utils/host"; 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 { 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({
@ -57,8 +61,15 @@ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width,
if (typeof img === 'string') if (typeof img === 'string')
{ {
const rommFetch = await fetch(img); const res = await fetch(img);
return rommFetch;
return new Response(res.body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("Content-Type") ?? "image/jpeg",
"Cache-Control": "public, max-age=86400",
},
});
} }
return img; return img;
@ -135,97 +146,142 @@ export default new Elysia()
{ {
const games: FrontEndGameType[] = []; const games: FrontEndGameType[] = [];
if (query.source === 'store') const where: any[] = [];
let localGamesSet: Set<string> | undefined;
if (query.platform_slug)
{ {
const shuffledGames = await getShuffledStoreGames(); where.push(eq(schema.platforms.slug, query.platform_slug));
set.headers['x-max-items'] = shuffledGames.length; } else if (query.platform_id && query.platform_source === 'local')
const storeGames = await Promise.all(shuffledGames {
.slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length)) where.push(eq(schema.platforms.id, query.platform_id));
.map(async (e) => }
else if (query.platform_id && query.platform_source)
{
const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: query.platform_id ? String(query.platform_id) : undefined });
if (platform)
{
where.push(eq(schema.platforms.slug, platform?.slug));
}
}
if (query.search)
{
where.push(like(schema.games.name, query.search));
}
if (query.source)
{
where.push(eq(schema.games.source, query.source));
}
const ordering: any[] = [];
if (query.orderBy)
{
switch (query.orderBy)
{
case 'added':
ordering.push(desc(schema.games.created_at));
break;
case 'activity':
ordering.push(sql`MAX(COALESCE(${schema.games.created_at}, '1970-01-01'), COALESCE(${schema.games.last_played}, '1970-01-01')) DESC`);
break;
case 'name':
ordering.push(desc(schema.games.name));
break;
case "release":
ordering.push(sql`json_extract(${schema.games.metadata}, '$.first_release_date') DESC NULLS LAST`);
break;
}
}
const localGames = await db.select({
...getTableColumns(schema.games),
platform: schema.platforms,
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
})
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
.groupBy(schema.games.id)
.orderBy(...ordering)
.where(and(...where));
localGamesSet = new Set(
localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`)
.concat(localGames.filter(g => !!g.igdb_id).map(g => `igdb@${g.igdb_id}`))
);
function localGameExistsPredicate (game: { id: FrontEndId, igdb_id?: number | null, ra_id?: number | null; })
{
if (localGamesSet?.has(`${game.id.source}@${game.id.id}`)) return true;
if (game.igdb_id && localGamesSet?.has(`igdb@${game.igdb_id}`)) return true;
if (game.ra_id && localGamesSet?.has(`ra@${game.ra_id}`)) return true;
return false;
}
if (query.collection_id)
{
// Collections are just a remote thing for now.
const remoteGames: FrontEndGameTypeWithIds[] = [];
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
games.push(...remoteGames.map(g =>
{
if (localGameExistsPredicate(g))
{ {
const system = path.dirname(e.path); return convertLocalToFrontend(localGames.find(g => localGameExistsPredicate({ id: { id: g.source_id ?? '', source: g.source ?? '' }, igdb_id: g.igdb_id, ra_id: g.ra_id }))!);
const id = path.basename(e.path, path.extname(e.path)); }
else
{
return g;
}
}));
const localGame = await db.select({
...getTableColumns(schema.games),
platform: schema.platforms,
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
})
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
.groupBy(schema.games.id)
.where(and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)));
if (localGame.length > 0) return convertLocalToFrontend(localGame[0]);
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
}));
games.push(...storeGames.filter(g => g !== undefined));
} else } else
{ {
const where: any[] = []; games.push(...localGames.slice(query.offset, query.limit !== undefined ? ((query.offset ?? 0) + query.limit) : undefined).filter(g =>
let localGamesSet: Set<string> | undefined;
if (query.platform_slug)
{ {
where.push(eq(schema.platforms.slug, query.platform_slug)); if (query.genres && query.genres.length > 0)
} else if (query.platform_id && query.platform_source === 'local')
{
where.push(eq(schema.platforms.id, query.platform_id));
}
else if (query.platform_id && query.platform_source)
{
const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: String(query.platform_id) });
if (platform)
{ {
where.push(eq(schema.platforms.slug, platform?.slug)); if (!g.metadata) return false;
if (!g.metadata.genres) return false;
if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false;
} }
}
if (query.source) return true;
}).map(g =>
{ {
where.push(eq(schema.games.source, query.source)); return convertLocalToFrontend(g);
} }));
const localGames = await db.select({ if (query.localOnly !== true)
...getTableColumns(schema.games),
platform: schema.platforms,
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
})
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
.groupBy(schema.games.id)
.where(and(...where));
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
if (!query.collection_id)
{ {
games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).map(g => const remoteGames: FrontEndGameTypeWithIds[] = [];
{ const remoteGameSet = new Set<string>();
return convertLocalToFrontend(g);
}));
const remoteGames: FrontEndGameType[] = [];
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`))); games.push(...remoteGames.filter(g =>
} else
{
const remoteGames: FrontEndGameType[] = [];
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
games.push(...remoteGames.map(g =>
{ {
if (localGamesSet?.has(`${g.id.source}@${g.id.id}`)) if (localGameExistsPredicate(g))
{ {
return convertLocalToFrontend(localGames.find(l => l.source === g.id.source && l.source_id === g.id.id)!); return false;
} else
{
return g;
} }
if (g.igdb_id)
{
const igdbId = `igdb@${g.igdb_id}`;
if (remoteGameSet.has(igdbId)) return false;
remoteGameSet.add(igdbId);
}
if (g.ra_id)
{
const raId = `ra@${g.ra_id}`;
if (remoteGameSet.has(raId)) return false;
remoteGameSet.add(raId);
}
return true;
})); }));
} }
} }
@ -243,6 +299,9 @@ export default new Elysia()
case 'name': case 'name':
games.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); games.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
break; break;
case "release":
games.sort((a, b) => (b.metadata.first_release_date?.getTime() ?? 0) - (a.metadata.first_release_date?.getTime() ?? 0));
break;
} }
} }
@ -251,27 +310,55 @@ export default new Elysia()
}, { }, {
query: GameListFilterSchema, query: GameListFilterSchema,
}) })
.get('/rom/:source/:id', async ({ params: { id, source } }) => .get('/games/filters', async ({ query: { source } }) =>
{ {
const localGame = await db.query.games.findFirst({ const filterSets: FrontEndFilterSets = {
where: getLocalGameMatch(id, source), age_ratings: new Set(),
columns: { path_fs: true } player_counts: new Set(),
languages: new Set(),
companies: new Set(),
genres: new Set()
};
let filter: any = undefined;
if (source) filter = eq(schema.games.source, source);
const local_metadata = await db.query.games.findMany({ columns: { metadata: true }, where: filter });
local_metadata.forEach(game =>
{
game.metadata.age_ratings?.forEach(r => filterSets.age_ratings.add(r));
game.metadata.genres?.forEach(r => filterSets.genres.add(r));
game.metadata.companies?.forEach(r => filterSets.companies.add(r));
if (game.metadata.player_count)
filterSets.player_counts.add(game.metadata.player_count);
}); });
if (!localGame?.path_fs) await plugins.hooks.games.fetchFilters.promise({ filters: filterSets, source });
const filters: FrontEndFilterLists = {
age_ratings: Array.from(filterSets.age_ratings),
player_counts: Array.from(filterSets.player_counts),
languages: Array.from(filterSets.languages),
companies: Array.from(filterSets.companies),
genres: Array.from(filterSets.genres)
};
return filters;
}, {
query: z.object({ source: z.string().optional() })
})
.get('/rom/:source/:id', async ({ params: { id, source } }) =>
{
const filePaths = await plugins.hooks.games.fetchRomFiles.promise({ source, id });
if (!filePaths || filePaths.length <= 0)
{ {
return status("Not Found"); return status("Not Found", "No Valid Roms Found");
} }
const downloadPath = config.get('downloadPath'); return Bun.file(filePaths[0]);
const path_fs = path.join(downloadPath, localGame.path_fs);
const stats = await fs.stat(path_fs);
if (stats.isDirectory())
{
return status("Not Found", "Rom is a folder");
}
return Bun.file(path_fs);
}, { }, {
params: z.object({ source: z.string(), id: z.string() }) params: z.object({ source: z.string(), id: z.string() })
}) })
@ -286,38 +373,61 @@ export default new Elysia()
const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) }); const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) });
if (systemMapping) if (systemMapping)
{ {
const emulatorNames = await getEmulatorsForSystem(systemMapping.system); const emulatorNames: string[] = [];
const emulators = await Promise.all(emulatorNames.map(n => getStoreEmulatorPackage(n).then(e => ({ name: n, data: e })))); await plugins.hooks.emulators.findEmulatorForSystem.promise({ system: systemMapping.system, emulators: emulatorNames });
sourceData.emulators = await Promise.all(emulators.map(async ({ name, data }) => sourceData.emulators = (await Promise.all(emulatorNames.map(async name =>
{ {
if (data) if (name === 'EMULATORJS')
{
const systems = await buildStoreFrontendEmulatorSystems(data);
return { ...await convertStoreEmulatorToFrontend(data, 0, systems), store_exists: true };
}
else if (name === 'EMULATORJS')
{ {
return { return {
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: 'https://emulatorjs.org/logo/EmulatorJS.png',
systems: [], systems: await Promise.all(Object.keys(cores).map(async c =>
gameCount: 0 {
} satisfies FrontEndGameTypeDetailedEmulator; const mapping = await emulatorsDb.query.systemMappings.findFirst({
} where (fields, operators)
else {
{ return operators.and(operators.eq(fields.source, "romm"), operators.eq(fields.system, c));
return { }, columns: { sourceSlug: true }
name: name, });
logo: "", const system: EmulatorSystem = {
systems: [], id: c,
name: c,
iconUrl: `/api/romm/image/romm/assets/platforms/${mapping?.sourceSlug}.svg`
};
return system;
})),
gameCount: 0, gameCount: 0,
validSources: [] source: 'local',
integrations: []
} satisfies FrontEndGameTypeDetailedEmulator; } satisfies FrontEndGameTypeDetailedEmulator;
} }
})); const foundEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: name });
const execPaths: EmulatorSourceEntryType[] = [];
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: name, sources: execPaths });
const integrations = findEmulatorPluginIntegration(id, execPaths);
if (foundEmulator)
{
foundEmulator.validSources = execPaths;
foundEmulator.integrations = integrations;
return foundEmulator;
}
return {
name: name,
logo: "",
source: 'local',
systems: [],
gameCount: 0,
validSources: execPaths,
integrations: integrations
} satisfies FrontEndGameTypeDetailedEmulator;
}))).filter(e => !!e);
} }
} }
@ -344,17 +454,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 } }) => .post('/game/:source/:id/install', async ({ params: { id, source }, body }) =>
{ {
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)); return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, body));
} 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(),
response: z.any() response: z.any()
}) })
.delete('/game/:source/:id/install', async ({ params: { id, source } }) => .delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
@ -370,7 +481,56 @@ export default new Elysia()
params: z.object({ id: z.string(), source: z.string() }), params: z.object({ id: z.string(), source: z.string() }),
response: z.any() response: z.any()
}) })
.post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) => .get('/game/:source/:id/validate', async ({ params: { id, source } }) =>
{
const valid = await validateGameSource(source, id);
return { valid: valid.valid, reason: valid.reason };
})
.post('/game/:source/:id/fix_source', async ({ params: { id, source } }) =>
{
return fixSource(source, id);
})
.post('/game/:source/:id/update', async ({ params: { id, source } }) =>
{
return update(source, id);
})
.post('/game/:source/:id/update', async ({ params: { id, source }, body }) =>
{
return customUpdate(source, id, body.source, body.id);
}, { body: z.object({ source: z.string(), id: z.string() }) })
.get('/lookup', async ({ query: { search } }) =>
{
const matches = 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)
@ -383,11 +543,11 @@ export default new Elysia()
{ {
try try
{ {
const validCommand = body.command_id ? validCommands.commands.find(c => c.id === body.command_id) : validCommands.commands[0]; const validCommand = command_id ? validCommands.commands.find(c => c.id === command_id) : validCommands.commands[0];
if (validCommand) 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.
await launchCommand(validCommand, source, id, validCommands.gameId); await launchCommand(validCommand, validCommands.gameId, validCommands.source, validCommands.sourceId);
return { type: 'application', command: null }; return { type: 'application', command: null };
} else } else
{ {
@ -428,8 +588,6 @@ export default new Elysia()
const emulator = await getStoreEmulatorPackage(id); const emulator = await getStoreEmulatorPackage(id);
if (!emulator) return status("Not Found"); if (!emulator) return status("Not Found");
const systems = await buildStoreFrontendEmulatorSystems(emulator); const systems = await buildStoreFrontendEmulatorSystems(emulator);
const systemsIdSet = new Set(systems.map(s => s.id));
const games: FrontEndGameType[] = []; const games: FrontEndGameType[] = [];
@ -456,28 +614,6 @@ export default new Elysia()
await plugins.hooks.games.fetchRecommendedGamesForEmulator.promise({ emulator, systems, games: remoteGames }); await plugins.hooks.games.fetchRecommendedGamesForEmulator.promise({ emulator, systems, games: remoteGames });
games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`))); games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`)));
const gamesManifest = await getStoreGameManifest();
const storeGames = await Promise.all(gamesManifest
.filter(g => systemsIdSet.has(path.dirname(g.path)))
.map(async (e) =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) });
if (localGame)
{
return undefined;
}
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
}));
games.push(...storeGames.filter(g => g !== undefined).slice(0, 3));
return games; return games;
}) })
.get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) => .get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) =>
@ -485,10 +621,10 @@ export default new Elysia()
const sourceData = await getSourceGameDetailed(source, id); const sourceData = await getSourceGameDetailed(source, id);
if (!sourceData) return status("Not Found"); if (!sourceData) return status("Not Found");
const sourceCompaniesSet = new Set(sourceData.companies); const sourceCompaniesSet = new Set(sourceData.metadata.companies);
const sourceGenresSet = new Set(sourceData.genres); const sourceGenresSet = new Set(sourceData.metadata.genres);
const esSystem = sourceData.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug)), columns: { system: true } }) : undefined;
const games: (FrontEndGameType & { metadata?: any; })[] = []; const games: (FrontEndGameType & { metadata?: any; })[] = [];
@ -499,37 +635,9 @@ export default new Elysia()
const localGamesSourceSet = new Set(localGames.filter(g => g.source).map(g => `${g.source}@${g.source_id}`)); const localGamesSourceSet = new Set(localGames.filter(g => g.source).map(g => `${g.source}@${g.source_id}`));
games.push(...localGames.map(g => ({ ...convertLocalToFrontend(g), metadata: g.metadata }))); games.push(...localGames.map(g => convertLocalToFrontend(g)));
const shuffledGames = await getShuffledStoreGames();
const storeGames = await Promise.all(shuffledGames
.filter(g =>
{
const system = path.dirname(g.path);
const id = path.basename(g.path, path.extname(g.path));
if (localGamesSourceSet.has(`${system}@${id}`))
return false;
if (esSystem)
{
if (path.dirname(g.path) === esSystem.system) return true;
}
return false;
})
.map(async (e) =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
}));
if (storeGames)
{
games.push(...storeGames.slice(0, 3));
}
const remoteGames: (FrontEndGameType & { metadata?: any; })[] = []; const remoteGames: (FrontEndGameType & { metadata?: any; })[] = [];
plugins.hooks.games.fetchRecommendedGamesForGame.promise({ plugins.hooks.games.fetchRecommendedGamesForGame.promise({
@ -582,4 +690,57 @@ 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;
}); });

View file

@ -1,8 +1,10 @@
import Elysia, { status } from "elysia"; import Elysia, { status } from "elysia";
import z from "zod"; import z from "zod";
import { and, count, eq, getTableColumns, not } from "drizzle-orm"; import { and, count, eq, getTableColumns, not, notExists, or } from "drizzle-orm";
import { db, plugins } from "../app"; import { config, db, plugins } from "../app";
import * as schema from "@schema/app"; import * as schema from "@schema/app";
import { findPlatform } from "./services/utils";
import { FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared";
export default new Elysia() export default new Elysia()
.get('/platforms', async () => .get('/platforms', async () =>
@ -91,9 +93,11 @@ 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");
return remotePlatform; const local = await db.query.platforms.findFirst({ where: or(eq(schema.platforms.slug, remotePlatform?.slug), eq(schema.platforms.name, remotePlatform?.name)) });
return { ...remotePlatform, hasLocal: !!local };
} }
}, { params: z.object({ source: z.string(), id: z.string() }) }).get('/platform/local/:id/cover', async ({ params: { id }, set }) => }, { params: z.object({ source: z.string(), id: z.string() }) })
.get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
{ {
set.headers["cross-origin-resource-policy"] = 'cross-origin'; set.headers["cross-origin-resource-policy"] = 'cross-origin';
@ -112,4 +116,70 @@ export default new Elysia()
set.headers["content-type"] = coverBlob.cover_type; set.headers["content-type"] = coverBlob.cover_type;
} }
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 } }) =>
{
const where: any[] = [];
if (source === 'local')
{
where.push(eq(schema.platforms.id, Number(id)));
} else
{
const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id });
if (remotePlatform)
{
where.push(eq(schema.platforms.slug, remotePlatform.slug));
}
}
const localPlatform = await db.query.platforms.findFirst({
where: or(...where)
});
if (!localPlatform) return status("Not Found");
const platformLookup = await plugins.hooks.games.platformLookup.promise({
slug: localPlatform.slug
});
let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${localPlatform.slug}.svg`);
if (!platformCover.ok && platformLookup?.url_logo)
{
platformCover = await fetch(platformLookup.url_logo);
}
await db.update(schema.platforms).set({
name: platformLookup?.name,
cover: Buffer.from(await platformCover.arrayBuffer()),
cover_type: platformCover.headers.get('content-type'),
}).where(eq(schema.platforms.id, localPlatform.id));
})
.delete('/platform/local/:id', async ({ params: { id } }) =>
{
const deleted = await db.delete(schema.platforms).where(and(eq(schema.platforms.id, Number(id)),
notExists(
db
.select()
.from(schema.games)
.where(eq(schema.games.platform_id, Number(id)))
))).returning();
if (deleted.length <= 0) return status("Not Found");
})
.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."
}
});

View file

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

View file

@ -1,20 +1,19 @@
import { RPC_URL, } from "@shared/constants"; import { config, db, plugins, taskQueue } from "../../app";
import { config, customEmulators, db, emulatorsDb, plugins, taskQueue } from "../../app"; import { eq } from "drizzle-orm";
import { getValidLaunchCommands } from "./launchGameService"; import { getErrorMessage } from "@/bun/utils";
import * as emulatorSchema from '@schema/emulators'; import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils";
import { and, eq } from "drizzle-orm";
import { getErrorMessage, hashFile } from "@/bun/utils";
import { checkFiles, getLocalGameMatch } from "./utils";
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { getStoreGameFromId } from "../../store/services/gamesService";
import { cores } from "../../emulatorjs/emulatorjs";
import { host } from "@/bun/utils/host";
import Elysia from "elysia"; import Elysia from "elysia";
import z from "zod"; import z from "zod";
import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { InstallJob, InstallJobStates } from "../../jobs/install-job";
import { LaunchGameJob } from "../../jobs/launch-game-job"; import { LaunchGameJob } from "../../jobs/launch-game-job";
import * as appSchema from "@schema/app";
import { RPC_URL } from "@/shared/constants";
import { DownloadSourceSchema } from '@simeonradivoev/gameflow-sdk/shared';
import { host } from "@/bun/utils/host";
import { CommandEntry, FrontEndId, GameLookup, GameStatusType, LocalDownloadFileEntry } from "@simeonradivoev/gameflow-sdk/shared";
class CommandSearchError extends Error export class CommandSearchError extends Error
{ {
constructor(status: GameStatusType, message: string) constructor(status: GameStatusType, message: string)
{ {
@ -26,7 +25,15 @@ class CommandSearchError extends Error
export async function getLocalGame (source: string, id: string) export async function getLocalGame (source: string, id: string)
{ {
const localGame = await db.query.games.findFirst({ const localGame = await db.query.games.findFirst({
columns: { id: true, path_fs: true }, columns: {
id: true,
path_fs: true,
source: true,
source_id: true,
igdb_id: true,
ra_id: true,
main_glob: true
},
where: getLocalGameMatch(id, source), where: getLocalGameMatch(id, source),
with: { with: {
platform: { columns: { slug: true } } platform: { columns: { slug: true } }
@ -36,62 +43,243 @@ export async function getLocalGame (source: string, id: string)
return localGame; return localGame;
} }
export async function getValidLaunchCommandsForGame (source: string, id: string) /** 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)
{
const localGame = await getLocalGame(source, id);
if (!localGame) throw new Error("Could not find Local Game");
if (!localGame.source || !localGame.source_id) throw new Error("Game has not source defined");
const sourceGame = await getSourceGameDetailed(localGame.source, localGame.source_id, { sourceOnly: true });
if (!sourceGame) throw new Error("Could not find source game");
await db.transaction(async (tx) =>
{
await tx.delete(appSchema.screenshots).where(eq(appSchema.screenshots.game_id, localGame.id));
const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)];
if (paths_screenshots.length <= 0 && sourceGame.igdb_id)
{
const matches = new Map<string, GameLookup[]>();
await plugins.hooks.games.gameLookup.promise(matches, { source: 'igdb', id: String(sourceGame.igdb_id) });
if (matches.size > 0)
{
const firstMatches = matches.values().next().value;
if (firstMatches && firstMatches.length > 0)
{
paths_screenshots.push(...firstMatches[0].screenshotUrls);
}
}
}
// pre-fetch screenshots
const screenshots = await Promise.all(paths_screenshots.map(s => fetch(s)));
if (screenshots.length > 0)
{
await tx.insert(appSchema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
{
const screenshot: typeof appSchema.screenshots.$inferInsert = {
game_id: localGame.id,
content: Buffer.from(await response.arrayBuffer()),
type: response.headers.get('content-type')
};
return screenshot;
})));
}
await tx.update(appSchema.games).set({
metadata: {
age_ratings: sourceGame.metadata.age_ratings,
genres: sourceGame.metadata.genres,
player_count: sourceGame.metadata.player_count ?? undefined,
companies: sourceGame.metadata.companies,
game_modes: sourceGame.metadata.game_modes,
average_rating: sourceGame.metadata.average_rating ?? undefined,
first_release_date: sourceGame.metadata.first_release_date?.getTime() ?? undefined,
}
}).where(eq(appSchema.games.id, localGame.id));
});
}
export async function fixSource (source: string, id: string)
{
const valid = await validateGameSource(source, id);
if (!valid.valid)
{
if (!valid.localGame) throw new Error("No Local Game");
if (!valid.localGame.source) throw new Error("No Valid Source");
const foundGame = await plugins.hooks.games.searchGame.promise({
igdb_id: valid.localGame.igdb_id ?? undefined,
ra_id: valid.localGame.ra_id ?? undefined,
source: valid.localGame.source
});
if (foundGame)
{
await db.update(appSchema.games).set({
source: foundGame.id.source,
source_id: foundGame.id.id,
metadata: {
age_ratings: foundGame.metadata.age_ratings,
genres: foundGame.metadata.genres,
player_count: foundGame.metadata.player_count ?? undefined,
companies: foundGame.metadata.companies,
game_modes: foundGame.metadata.game_modes,
average_rating: foundGame.metadata.average_rating ?? undefined,
first_release_date: foundGame.metadata.first_release_date?.getTime() ?? undefined,
}
}).where(eq(appSchema.games.id, valid.localGame.id));
return true;
} else
{
throw new Error("Could not find Source Game");
}
} else
{
throw new Error("Game Source Already Valid");
}
}
export async function validateGameSource (source: string, id: string): Promise<{
valid: boolean,
localGame?: { id: number; igdb_id: number | null; ra_id: number | null; source: string | null; },
reason?: string;
}>
{
const localGame = await getLocalGame(source, id);
if (!localGame) return { valid: true };
if (localGame.source && localGame.source_id)
{
const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id });
if (!sourceGame) return { valid: false, reason: "Source Missing", localGame };
// Store should be immutable
if (localGame.source !== 'store' && sourceGame.igdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined))
{
return { valid: false, reason: "Metadata Missmatch", localGame };
}
}
return { valid: true, localGame };
}
export async function updateLocalLastPlayed (id: number)
{
await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(id)));
}
export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined>
{ {
const localGame = await getLocalGame(source, id); const localGame = await getLocalGame(source, id);
if (localGame) if (localGame)
{ {
const rommPlatform = localGame.platform.slug; const commands = await plugins.hooks.games.buildLaunchCommands.promise({
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform), eq(emulatorSchema.systemMappings.source, 'romm')) }); source: localGame.source,
sourceId: localGame.source_id,
id: { source: 'local', id: String(localGame.id) },
systemSlug: localGame.platform.slug,
gamePath: localGame.path_fs,
mainGlob: localGame.main_glob,
});
if (esPlatform) if (commands instanceof Error || !commands) return commands;
const validCommand = commands.find(c => c.valid);
if (validCommand)
{ {
if (localGame.path_fs) return {
{ commands: commands.filter(c => c.valid),
try gameId: { id: String(localGame.id), source: 'local' },
{ source: localGame.source ?? source,
const commands = await getValidLaunchCommands({ systemSlug: esPlatform.system, gamePath: localGame.path_fs }); sourceId: localGame.source_id ? String(localGame.source_id) : id,
};
if (cores[esPlatform.system])
{
const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`;
commands.push({
id: 'EMULATORJS',
label: "Emulator JS",
command: `core=${cores[esPlatform.system]}&gameUrl=${encodeURIComponent(gameUrl)}`,
valid: true,
emulator: 'EMULATORJS',
metadata: {
romPath: gameUrl
}
});
}
const validCommand = commands.find(c => c.valid);
if (validCommand)
{
return { commands: commands.filter(c => c.valid), gameId: localGame.id, source: source, sourceId: id };
}
else
{
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`);
}
} catch (error)
{
console.error(error);
return new CommandSearchError('error', getErrorMessage(error));
}
} else
{
return new CommandSearchError('error', 'Missing Path');
}
} }
else else
{ {
return new CommandSearchError('error', `Missing Platform ${localGame.platform.slug}`); return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`);
} }
} 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
{
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`);
}
} }
return undefined; return undefined;
@ -106,7 +294,7 @@ export default function buildStatusResponse ()
z.object({ status: z.literal('refresh'), localId: z.number().optional() }), z.object({ status: z.literal('refresh'), localId: z.number().optional() }),
z.object({ status: z.literal(['queued']) }), z.object({ status: z.literal(['queued']) }),
z.object({ status: z.literal('playing'), details: z.string() }), z.object({ status: z.literal('playing'), details: z.string() }),
z.object({ status: z.literal('install'), details: z.string() }), z.object({ status: z.literal('install'), details: z.string(), sources: DownloadSourceSchema.array() }),
z.object({ status: z.literal('present'), details: z.string() }), z.object({ status: z.literal('present'), details: z.string() }),
z.object({ status: z.literal(['download', 'extract']), progress: z.number() }), z.object({ status: z.literal(['download', 'extract']), progress: z.number() }),
]), ]),
@ -120,7 +308,7 @@ export default function buildStatusResponse ()
}, },
async open (ws) async open (ws)
{ {
sendLatests(); sendLatests().catch(e => ws.send({ status: 'error', error: JSON.stringify(e) }));
const installJobId = InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }); const installJobId = InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id });
async function sendLatests () async function sendLatests ()
@ -143,6 +331,7 @@ export default function buildStatusResponse ()
} }
else else
{ {
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source) });
const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id); const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id);
if (validCommand) if (validCommand)
{ {
@ -159,9 +348,11 @@ export default function buildStatusResponse ()
}); });
} }
} else if (ws.data.params.source === 'store') } else if (!localGame && ws.data.params.source === 'store')
{ {
const storeGame = await getStoreGameFromId(ws.data.params.id); const downloads = await plugins.hooks.games.fetchDownloads.promise({ source: ws.data.params.source, id: ws.data.params.id });
const sources = downloads?.map(d => ({ id: d.id, name: d.id })) ?? [];
/*const storeGame = await getStoreGame(ws.data.params.id);
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
const size = Number(fileResponse.headers.get('content-length')); const size = Number(fileResponse.headers.get('content-length'));
const stats = await fs.statfs(config.get('downloadPath')); const stats = await fs.statfs(config.get('downloadPath'));
@ -172,19 +363,22 @@ export default function buildStatusResponse ()
} else } else
{ {
ws.send({ status: 'install', details: 'Install' }); ws.send({ status: 'install', details: 'Install' });
} }*/
} else
ws.send({ status: 'install', details: 'Install', sources });
} else if (!localGame)
{ {
const files = await plugins.hooks.games.fetchDownloads.promise({ const files = await plugins.hooks.games.fetchDownloads.promise({
source: ws.data.params.source, source: ws.data.params.source,
id: ws.data.params.id id: ws.data.params.id
}); });
const sources = files?.map(d => ({ id: d.id, name: d.id })) ?? [];
let filesChecked: LocalDownloadFileEntry[] | undefined; let filesChecked: LocalDownloadFileEntry[] | undefined;
if (files) if (files && files.length)
{ {
filesChecked = await checkFiles(files.files, !!files.extract_path); filesChecked = await checkFiles(files[0].files, !!files[0].extract_path);
} }
if (filesChecked && !filesChecked.some(f => f.exists === false || f.matches === false)) if (filesChecked && !filesChecked.some(f => f.exists === false || f.matches === false))
@ -199,15 +393,16 @@ export default function buildStatusResponse ()
ws.send({ status: 'error', error: "Not Enough Free Space" }); ws.send({ status: 'error', error: "Not Enough Free Space" });
} else if (filesChecked?.some(f => f.exists === true && f.matches === false)) } else if (filesChecked?.some(f => f.exists === true && f.matches === false))
{ {
ws.send({ status: 'install', details: 'Some Files Present, Install' }); ws.send({ status: 'install', details: 'Some Files Present, Install', sources });
} }
else else
{ {
ws.send({ status: 'install', details: 'Install' }); ws.send({ status: 'install', details: 'Install', sources });
} }
} }
} else
{
ws.send({ status: 'error', error: "No Way To Launch" });
} }
} }
} }

View file

@ -2,24 +2,31 @@ 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 } from "drizzle-orm"; import { and, eq, or } from "drizzle-orm";
import * as schema from "@schema/app"; import * as schema from "@schema/app";
import { StoreGameType } from "@shared/constants"; import { RPC_URL } from "@shared/constants";
import { DetailedRomSchema, getCurrentUserApiUsersMeGet, getRomApiRomsIdGet, SimpleRomSchema } from "@clients/romm"; import { hashFile } from "@/bun/utils";
import { host } from "@/bun/utils/host";
import * as emulatorSchema from "@schema/emulators"; import * as emulatorSchema from "@schema/emulators";
import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService"; import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared";
import { hashFile, isSteamDeck, isSteamDeckGameMode } from "@/bun/utils";
export async function calculateSize (installPath: string | null) export async function calculateSize (installPath: string | null)
{ {
if (!installPath) return null; if (!installPath) return null;
return (await getFolderSize(path.join(config.get('downloadPath'), installPath))).size; const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath);
return (await getFolderSize(finalPath)).size;
} }
export async function checkInstalled (installPath: string | null) export async function checkInstalled (installPath: string | null)
{ {
if (!installPath) return false; if (!installPath) return false;
return fs.exists(path.join(config.get('downloadPath'), installPath)); const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath);
return fs.exists(finalPath);
}
export function getScreenshotLocalGameMatch (id: string, source: string)
{
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id));
} }
export function getLocalGameMatch (id: string, source: string) export function getLocalGameMatch (id: string, source: string)
@ -33,10 +40,10 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & {
}) })
{ {
const game: FrontEndGameType = { const game: FrontEndGameType = {
platform_display_name: g.platform?.name ?? "Local", platform_display_name: g.platform?.name ?? null,
id: { id: String(g.id), source: 'local' }, id: { id: String(g.id), source: 'local' },
updated_at: g.created_at, updated_at: g.created_at,
path_cover: `/api/romm/game/local/${g.id}/cover`, path_covers: [`/api/romm/game/local/${g.id}/cover`],
source_id: g.source_id, source_id: g.source_id,
source: g.source, source: g.source,
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`, path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
@ -46,22 +53,29 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & {
slug: g.slug, slug: g.slug,
name: g.name, name: g.name,
platform_id: g.platform_id, platform_id: g.platform_id,
platform_slug: g.platform?.slug ?? null platform_slug: g.platform?.slug ?? null,
metadata: {
first_release_date: g.metadata?.first_release_date !== undefined ? new Date(g.metadata?.first_release_date) : null
}
}; };
return game; return game;
} }
export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & { export async function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & {
platform?: typeof schema.platforms.$inferSelect | null; platform?: { name: string | null, slug: string | null; } | null;
screenshotIds?: number[]; screenshotIds?: number[];
}) })
{ {
const exists = await checkInstalled(g.path_fs);
const fileSize = await calculateSize(g.path_fs);
const game: FrontEndGameTypeDetailed = { const game: FrontEndGameTypeDetailed = {
platform_display_name: g.platform?.name ?? "Local", platform_display_name: g.platform?.name ?? "Local",
id: { id: String(g.id), source: 'local' }, id: { id: String(g.id), source: 'local' },
updated_at: g.created_at, updated_at: g.created_at,
path_cover: `/api/romm/game/local/${g.id}/cover`, path_covers: [`/api/romm/game/local/${g.id}/cover`],
source_id: g.source_id, source_id: g.source_id,
source: g.source, source: g.source,
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`, path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
@ -73,70 +87,28 @@ export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSel
platform_id: g.platform_id, platform_id: g.platform_id,
platform_slug: g.platform?.slug ?? null, platform_slug: g.platform?.slug ?? null,
summary: g.summary, summary: g.summary,
fs_size_bytes: 0, fs_size_bytes: fileSize,
missing: false, missing: !exists,
local: true local: true,
ra_id: g.ra_id,
version: g.version,
version_source: g.version_source,
version_system: g.version_system,
igdb_id: g.igdb_id,
metadata: {
genres: g.metadata.genres ?? [],
companies: g.metadata.companies ?? [],
game_modes: g.metadata.game_modes ?? [],
age_ratings: g.metadata.age_ratings ?? [],
player_count: g.metadata.player_count ?? null,
average_rating: g.metadata.average_rating ?? null,
first_release_date: g.metadata.first_release_date ? new Date(g.metadata.first_release_date) : null
}
}; };
return game; return game;
} }
export async function convertStoreToFrontend (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameType>
{
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
where: and(eq(emulatorSchema.systemMappings.system, system), eq(emulatorSchema.systemMappings.source, 'romm'))
});
const platformDef = await emulatorsDb.query.systems.findFirst({
where: eq(emulatorSchema.systems.name, system),
columns: { fullname: true }
});
const gameId = `${system}@${id}`;
const game: FrontEndGameType = {
platform_display_name: platformDef?.fullname ?? system,
path_platform_cover: `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`,
id: { source: 'store', id: gameId },
source: null,
source_id: null,
path_fs: null,
path_cover: `/api/romm/image?url=${encodeURIComponent(storeGame.pictures.titlescreens?.[0])}`,
last_played: null,
updated_at: new Date(),
slug: null,
name: storeGame.title,
platform_id: null,
platform_slug: rommSystem?.sourceSlug ?? system,
paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? []
};
return game;
}
export async function convertStoreToFrontendDetailed (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameTypeDetailed>
{
let size: number | null = null;
try
{
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
size = Number(fileResponse.headers.get('content-length'));
} catch (error)
{
console.error(error);
}
const detailed: FrontEndGameTypeDetailed = {
...await convertStoreToFrontend(system, id, storeGame),
summary: storeGame.description,
fs_size_bytes: size,
missing: false,
local: false,
};
return detailed;
}
export async function getLocalGameDetailed (match: any) export async function getLocalGameDetailed (match: any)
{ {
const localGame = await db.query.games.findFirst({ const localGame = await db.query.games.findFirst({
@ -149,35 +121,13 @@ export async function getLocalGameDetailed (match: any)
if (localGame) if (localGame)
{ {
const exists = await checkInstalled(localGame.path_fs); return convertLocalToFrontendDetailed({ ...localGame, screenshotIds: localGame.screenshots.map(s => s.id) });
const fileSize = await calculateSize(localGame.path_fs);
const game: FrontEndGameTypeDetailed = {
path_cover: `/api/romm/game/local/${localGame.id}/cover`,
updated_at: localGame.created_at,
id: { id: String(localGame.id), source: 'local' },
path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`,
fs_size_bytes: fileSize ?? null,
paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`),
local: true,
missing: !exists,
platform_display_name: localGame.platform?.name,
summary: localGame.summary,
source: localGame.source,
source_id: localGame.source_id,
path_fs: localGame.path_fs,
last_played: localGame.last_played,
slug: localGame.slug,
name: localGame.name,
platform_id: localGame.platform_id,
platform_slug: localGame.platform.slug
};
return game;
} }
return undefined; return undefined;
} }
export async function getSourceGameDetailed (source: string, id: string) export async function getSourceGameDetailed (source: string, id: string, options?: { sourceOnly?: boolean; })
{ {
if (source === 'local') if (source === 'local')
{ {
@ -189,30 +139,13 @@ export async function getSourceGameDetailed (source: string, id: string)
{ {
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source)); const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
if (source === 'store') const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame });
if (localGame && options?.sourceOnly !== true)
{ {
const gameId = extractStoreGameSourceId(id); return localGame;
const storeGame = await getStoreGame(gameId.system, gameId.id);
if (!storeGame) return undefined;
const storeFrontendGame = await convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame);
if (localGame)
{
return { ...storeFrontendGame, ...localGame };
}
return storeFrontendGame;
} else
{
const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame });
if (remoteGame)
{
return remoteGame;
} else if (localGame)
{
return localGame;
}
} }
return undefined; return remoteGame;
} }
} }
@ -241,4 +174,333 @@ 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;
} }

View file

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

View file

@ -1,10 +0,0 @@
import { AsyncSeriesBailHook } from "tapable";
export class EmulatorHooks
{
fetchBiosDownload = new AsyncSeriesBailHook<[ctx: {
emulator: string;
systems: EmulatorSystem[];
biosFolder: string;
}], { auth?: string, files: DownloadFileEntry[]; } | undefined>(['ctx']);
}

View file

@ -1,64 +0,0 @@
import { EmulatorPackageType, GameListFilterType } from '@/shared/constants';
import { SyncBailHook, AsyncSeriesHook, SyncWaterfallHook, AsyncSeriesBailHook, AsyncHook, AsyncParallelHook, SyncHook, AsyncSeriesWaterfallHook } from 'tapable';
export class GameHooks
{
/** override the launch command for an emulator
* @param ctx.autoValidCommands The auto generated command for example based on the ES-DE listing
* @param ctx.emulator The emulator ID if any
* @param ctx.game.source The source of the game
* @param ctx.game.sourceId The ID of the source. This could be for example the ROMM ID the game was
* @returns The argument list to be used when running the emulator.
* If no emulator bin in the command entry is found the actual command will be used as the bin.
*/
emulatorLaunch = new AsyncSeriesBailHook<[ctx: {
autoValidCommand: CommandEntry;
game: {
source: string;
id: number;
};
}], string[] | undefined>(['ctx']);
/**
* 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: {
query: GameListFilterType;
games: FrontEndGameType[];
}]>(['ctx']);
fetchGame = new AsyncSeriesBailHook<[ctx: {
source: string;
localGame?: FrontEndGameTypeDetailed;
id: string;
}], FrontEndGameTypeDetailed | undefined>(['ctx']);
/** Get download file URLs
* @param ctx.checksum Check if file already exists using checksums
*/
fetchDownloads = new AsyncSeriesBailHook<[ctx: {
source: string;
id: string;
}], DownloadInfo | undefined>(['ctx']);
fetchRecommendedGamesForGame = new AsyncSeriesHook<[ctx: {
game: FrontEndGameTypeDetailed,
games: (FrontEndGameType & { metadata?: any; })[];
}]>(['ctx']);
fetchRecommendedGamesForEmulator = new AsyncSeriesHook<[cts: {
emulator: EmulatorPackageType;
systems: EmulatorSystem[];
games: FrontEndGameType[];
}]>(['ctx']);
fetchPlatform = new AsyncSeriesBailHook<[ctx: {
source: string;
id: string;
}], FrontEndPlatformType | undefined>(['ctx']);
platformLookup = new AsyncSeriesBailHook<[ctx: {
source: string;
id: string;
}], { slug: string; } | undefined>(['ctx']);
fetchPlatforms = new AsyncSeriesHook<[ctx: {
platforms: FrontEndPlatformType[];
}]>(['ctx']);
updatePlayed = new AsyncSeriesWaterfallHook<[ctx: { source: string, id: string; }], boolean>(["ctx"]);
fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']);
fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']);
}

View file

@ -1,35 +1,44 @@
import z from "zod"; import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
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";
export class BiosDownloadJob implements IJob<z.infer<typeof BiosDownloadJob.dataSchema>, "download"> interface BiosDownloadJobData extends DownloadJobData
{
emulator: string;
}
export class BiosDownloadJob implements IJob<BiosDownloadJobData, "download">
{ {
static id = "bios-download-job" as const; static 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";
emulator: string; data: BiosDownloadJobData;
dryRun: boolean; dryRun: boolean;
constructor(emulator: string, init?: { dryRun?: boolean; }) constructor(emulator: string, init?: { dryRun?: boolean; })
{ {
this.emulator = emulator; this.data = {
emulator,
name: "Download Emulator Bios"
};
this.dryRun = init?.dryRun ?? false; this.dryRun = init?.dryRun ?? false;
} }
async start (context: JobContext<IJob<z.infer<typeof BiosDownloadJob.dataSchema>, "download">, z.infer<typeof BiosDownloadJob.dataSchema>, "download">) async start (context: JobContext<IJob<BiosDownloadJobData, "download">, BiosDownloadJobData, "download">)
{ {
const emulator = await getStoreEmulatorPackage(this.emulator); const emulator = await getStoreEmulatorPackage(this.data.emulator);
if (!emulator) throw new Error("Could Not Find Emulator"); 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.emulator); const biosFolder = path.join(config.get('downloadPath'), "bios", this.data.emulator);
await ensureDir(biosFolder); await ensureDir(biosFolder);
const files = await plugins.hooks.emulators.fetchBiosDownload.promise({ emulator: this.emulator, systems, biosFolder }); const files = await plugins.hooks.emulators.fetchBiosDownload.promise({ emulator: this.data.emulator, systems, biosFolder });
if (!files) throw new Error("Could not find source to download from"); if (!files) throw new Error("Could not find source to download from");
@ -45,9 +54,12 @@ export class BiosDownloadJob implements IJob<z.infer<typeof BiosDownloadJob.data
const downloader = new Downloader('bios-download', files.files, biosFolder, { 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;
}, },
}); });
@ -57,6 +69,6 @@ export class BiosDownloadJob implements IJob<z.infer<typeof BiosDownloadJob.data
exposeData () exposeData ()
{ {
return { emulator: this.emulator }; return this.data;
} }
} }

View file

@ -1,66 +1,54 @@
import { EmulatorPackageType } from "@/shared/constants"; import { DownloadJobData, EmulatorPackageType } from '@simeonradivoev/gameflow-sdk/shared';
import { getStoreEmulatorPackage } from "../store/services/gamesService"; import { getStoreEmulatorPackage } from "../store/services/gamesService";
import { IJob, JobContext } from "../task-queue"; import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
import z from "zod"; import { config, plugins } from "../app";
import { Glob } from "bun";
import { config } from "../app";
import path from 'node:path'; import path from 'node:path';
import { getOrCachedGithubRelease } from "../cache";
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 { simulateProgress } from "@/bun/utils"; import { isArchive, simulateProgress } from "@/bun/utils";
import { path7za } from "7zip-bin"; import { path7za } from "7zip-bin";
import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService";
import { $ } from "bun";
import { EmulatorSourceEntryType } from "@simeonradivoev/gameflow-sdk/shared";
type EmulatorDownloadStates = "download" | "extract"; type EmulatorDownloadStates = "download" | "extract";
export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates> interface EmulatorDownloadJobData extends DownloadJobData
{
emulator: string;
}
export class EmulatorDownloadJob implements IJob<EmulatorDownloadJobData, EmulatorDownloadStates>
{ {
static id = "download-emulator" as const; static 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;
data: EmulatorDownloadJobData = {
name: "Download Emulator",
emulator: ""
};
constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; }) constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; isUpdate?: boolean; })
{ {
this.emulator = emulator; this.data.emulator = emulator;
this.downloadSource = downloadSource; this.downloadSource = downloadSource;
this.dryRun = init?.dryRun ?? false; this.dryRun = init?.dryRun ?? false;
this.isUpdate = init?.isUpdate ?? false;
} }
async start (context: JobContext<EmulatorDownloadJob, z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>) async start (context: JobContext<EmulatorDownloadJob, EmulatorDownloadJobData, EmulatorDownloadStates>)
{ {
this.emulatorPackage = await getStoreEmulatorPackage(this.emulator); this.emulatorPackage = await getStoreEmulatorPackage(this.data.emulator);
if (!this.emulatorPackage) throw new Error("Emulator not found"); if (!this.emulatorPackage) throw new Error("Emulator not found");
if (!this.emulatorPackage.downloads) throw new Error("Emulator has no downloads"); this.data.name = this.emulatorPackage.name;
this.data.preview_url = this.emulatorPackage.logo;
const { url, info } = await getEmulatorDownload(this.emulatorPackage, this.downloadSource);
const validDownloads = this.emulatorPackage.downloads[`${process.platform}:${process.arch}`]; const emulatorsFolder = getEmulatorPath(this.data.emulator);
if (!validDownloads) throw new Error(`Now downloads in ${this.emulatorPackage.name} for platform ${process.platform}:${process.arch}`);
const validDownload = validDownloads.find(d => d.type === this.downloadSource);
if (!validDownload) throw new Error(`Download type ${this.downloadSource} not found`);
let downloadUrl: URL;
if (validDownload.type === 'github')
{
console.log("Trying To Download from ", `https://api.github.com/repos/${validDownload.path}/releases/latest`);
const latestRelease = await getOrCachedGithubRelease(validDownload.path);
const glob = new Glob(validDownload.pattern);
const validAsset = latestRelease.assets.find(a => glob.match(a.name));
if (!validAsset) throw new Error("Could Not Find Valid Asset");
downloadUrl = new URL(validAsset.browser_download_url);
} else if (validDownload.type === 'direct')
{
downloadUrl = new URL(validDownload.url);
} else
{
throw new Error("Download Type Unsupported");
}
const emulatorsFolder = path.join(config.get('downloadPath'), "emulators", this.emulator);
if (this.dryRun) if (this.dryRun)
{ {
@ -69,41 +57,54 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
} else } else
{ {
const tmpFolder = path.join(config.get("downloadPath"), ".tmp"); const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
const downloader = new Downloader(this.emulator, const downloader = new Downloader(this.data.emulator,
[{ url: new URL(downloadUrl), file_name: path.basename(downloadUrl.pathname), file_path: this.emulator }], [{ url, file_name: path.basename(url.pathname), file_path: this.data.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 isArchive = destinationPaths[0].endsWith('.7z') || destinationPaths[0].endsWith('.zip'); const archive = isArchive(destinationPaths[0]);
const isAppImage = destinationPaths[0].endsWith(".AppImage"); const isAppImage = destinationPaths[0].endsWith(".AppImage");
if (!isArchive && !isAppImage) if (!archive && !isAppImage)
{ {
throw new Error("Invalid Download Type"); throw new Error("Invalid Download Type");
} }
if (isArchive) if (archive)
{ {
if (destinationPaths[0]) if (destinationPaths[0])
{ {
let destinationPath = destinationPaths[0]; let destinationPath = destinationPaths[0];
await new Promise((resolve, reject) => if (destinationPath.endsWith('.tar'))
{ {
const seven = Seven.extractFull(destinationPath, emulatorsFolder, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true, noRootDuplication: true }); context.setProgress(0, "extract");
seven.on('progress', p => context.setProgress(p.percent, "extract")); await ensureDir(emulatorsFolder);
seven.on('error', e => reject(e)); await $`tar -xf ${destinationPath} -C ${emulatorsFolder}`;
seven.on('end', () => resolve(true)); await fs.rm(destinationPath, { recursive: true });
}); } else
await fs.rm(destinationPath, { recursive: true }); {
await new Promise((resolve, reject) =>
{
const seven = Seven.extractFull(destinationPath, emulatorsFolder, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true, noRootDuplication: true });
seven.on('progress', p => context.setProgress(p.percent, "extract"));
seven.on('error', e => reject(e));
seven.on('end', () => resolve(true));
});
await fs.rm(destinationPath, { recursive: true });
}
// check if 1 root folder we need to get rid of // check if 1 root folder we need to get rid of
const contents = await fs.readdir(emulatorsFolder); const contents = await fs.readdir(emulatorsFolder);
@ -127,6 +128,19 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
await fs.rename(destPath, path.join(emulatorsFolder, path.basename(destPath))); await fs.rename(destPath, path.join(emulatorsFolder, path.basename(destPath)));
} }
} }
await Bun.write(`${emulatorsFolder}.json`, JSON.stringify(info, null, 3));
const execs: EmulatorSourceEntryType[] = [];
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: this.data.emulator, sources: execs });
await plugins.hooks.emulators.emulatorPostInstall.promise({
emulator: this.data.emulator,
emulatorPackage: this.emulatorPackage,
path: execs.find(e => e.type === 'store')?.binPath ?? emulatorsFolder,
info,
update: this.isUpdate
});
} }
} }
@ -134,7 +148,7 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
exposeData () exposeData ()
{ {
return { emulator: this.emulator }; return this.data;
} }
} }

View file

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

View file

@ -0,0 +1,143 @@
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,
}
});
}
}

View file

@ -1,30 +1,23 @@
import { IJob, JobContext } from "../task-queue"; import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
import { and, eq, or } from 'drizzle-orm';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import * as schema from "@schema/app";
import * as emulatorSchema from "@schema/emulators";
import path from 'node:path'; import path from 'node:path';
import { config, db, emulatorsDb, events, plugins } from "../app"; import { config, events, plugins } from "../app";
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
import * as igdb from 'ts-igdb-client';
import secrets from "../secrets";
import { simulateProgress } from "@/bun/utils"; 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 } from "../games/services/utils"; import { checkFiles, createLocalGame, downloadGame } from "../games/services/utils";
import { ensureDir } from "fs-extra"; import { ensureDir } from "fs-extra";
import { path7za } from "7zip-bin"; import { DownloadInfo, DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
interface JobConfig interface JobConfig
{ {
dryRun?: boolean; dryRun?: boolean;
dryDownload?: boolean; dryDownload?: boolean;
downloadId?: string;
} }
export type InstallJobStates = 'download' | 'extract'; export type InstallJobStates = 'download' | 'extract';
export class InstallJob implements IJob<never, InstallJobStates> export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
{ {
static id = "install-job" as const; static 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}`;
@ -35,6 +28,10 @@ export class InstallJob implements IJob<never, 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)
{ {
@ -43,91 +40,47 @@ export class InstallJob implements IJob<never, InstallJobStates>
this.source = source; this.source = source;
} }
public async start (cx: JobContext<InstallJob, never, InstallJobStates>) public async start (cx: JobContext<InstallJob, DownloadJobData, 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;
switch (this.source)
{
case 'store':
const game = await getStoreGameFromId(this.gameId);
const gameId = extractStoreGameSourceId(this.gameId);
info = {
coverUrl: game.pictures.titlescreens[0],
screenshotUrls: game.pictures.screenshots,
files: [{
url: new URL(game.file),
file_path: `roms/${game.system}`,
file_name: path.basename(decodeURI(game.file)),
size: 0
}],
slug: this.gameId,
source_id: this.gameId,
name: game.title,
summary: game.description,
system_slug: gameId.system,
extract_path: path.join('roms', gameId.system),
};
break;
default:
info = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId });
break;
}
if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
const files = await checkFiles(info.files, !!info.extract_path);
if (this.config?.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 headers: Record<string, string> = {}; const downloadedFiles = await downloadGame({
if (info.auth) downloads: files.filter(f => !f.exists || !f.matches),
headers['Authorization'] = info.auth; extract_path: info.extract_path,
const downloader = new Downloader(`game-${this.source}-${this.gameId}`, path_fs: info.path_fs,
files.filter(f => !f.exists || !f.matches), abortSignal: cx.abortSignal,
config.get('downloadPath'), auth: info.auth,
id: `game-${this.source}-${this.gameId}`,
setProgress: (process, state, info) =>
{ {
signal: cx.abortSignal, cx.setProgress(process, state);
headers, this.data.downloaded = info.downloaded;
onProgress (stats) this.data.speed = info.speed;
{ this.data.total = info.total;
cx.setProgress(stats.progress, 'download'); },
}, });
});
const downloadedFiles = await downloader.start(); if (downloadedFiles)
if (info.extract_path && downloadedFiles) finalFiles.push(...downloadedFiles);
{
let progress = 0;
const progressDelta = 1 / downloadedFiles.length;
for (const filePath of downloadedFiles)
{
const extractPath = path.join(config.get('downloadPath'), info.extract_path);
await new Promise((resolve, reject) =>
{
const seven = Seven.extractFull(filePath, extractPath, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true });
seven.on('progress', p =>
{
cx.setProgress(progress + p.percent * progressDelta, "extract");
});
seven.on('error', e => reject(e));
seven.on('end', async () =>
{
await fs.rm(filePath);
resolve(true);
});
});
progress += progressDelta * 100;
}
}
} }
if (this.config?.dryDownload === true && info.extract_path) if (this.config?.dryDownload === true && info.extract_path)
@ -138,138 +91,34 @@ export class InstallJob implements IJob<never, 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());
if (cx.abortSignal.aborted) return; cx.abortSignal.throwIfAborted();
await db.transaction(async (tx) => this.localGameId = await createLocalGame({
{ cover,
// Search for existing platform coverType: coverResponse.headers.get('content-type'),
const platformSearch = [eq(schema.platforms.slug, info.system_slug)]; system_slug: info.system_slug,
const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, info.system_slug)]; source_id: info.source_id,
source: this.source,
if (info.platform) slug: info.slug,
{ path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined),
if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id)); summary: info.summary,
if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug)); igdb_id: info.igdb_id,
if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id)); ra_id: info.ra_id,
if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id)); name: info.name,
main_glob: info.main_glob,
esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, 'romm')); version: info.version,
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.slug)); version_source: info.version_source,
} screenshotUrls: info.screenshotUrls,
version_system: info.version_system,
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ metadata: info.metadata,
with: { system: true }, platform: info.platform
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 platformCover = await fetch(`https://demo.romm.app/assets/platforms/${info.system_slug}.svg`);
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,
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
};
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
if (info.screenshotUrls.length <= 0 && process.env.TWITCH_CLIENT_ID)
{
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
if (access_token)
{
const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token);
const { data } = await client.request('artworks').pipe(igdb.fields(['game', 'url']), igdb.where('game', '=', info.igdb_id)).execute();
info.screenshotUrls.push(...data.filter(s => s.url).map(s => s.url!));
}
}
// 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);
} }
events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 });
} }
} }

View file

@ -3,21 +3,24 @@ 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 UpdateStoreJob from "./update-store"; import EnsureStore from "./ensure-store";
import { EmulatorDownloadJob } from "./emulator-download-job"; import { EmulatorDownloadJob } from "./emulator-download-job";
import { getErrorMessage } from "@/bun/utils"; import { getErrorMessage } from "@/bun/utils";
import { IJob } from "../task-queue"; import { BaseEvent, IJob } from "@simeonradivoev/gameflow-sdk/task-queue";
import { LaunchGameJob } from "./launch-game-job"; import { 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 { FrontEndJob } from "@simeonradivoev/gameflow-sdk/shared";
function registerJob< function registerJob<
const Path extends string, const Path extends string,
const Schema extends z.ZodTypeAny, Schema,
const Query extends z.ZodTypeAny,
const States extends string, const States extends string,
T extends IJob<z.infer<Schema>, States> > (_job: {
> (_job: { id: Path; dataSchema: Schema; query?: (q: any) => string; } & (new (...args: any[]) => T)) id: Path;
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', [
@ -29,9 +32,10 @@ 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: _job.dataSchema data: z.custom<Schema>()
}), }),
z.object({ type: z.literal(['completed', 'ended']), data: _job.dataSchema }), z.object({ type: z.literal(['completed', 'ended']), data: z.custom<Schema>() }),
z.object({ type: z.literal('waiting') }),
z.object({ type: z.literal('error'), error: z.string() }) z.object({ type: z.literal('error'), error: z.string() })
]), ]),
open (ws) open (ws)
@ -40,7 +44,10 @@ 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?.() }); ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() as Schema });
} else
{
ws.send({ type: 'waiting' });
} }
(ws.data as any).cleanup = [ (ws.data as any).cleanup = [
@ -97,10 +104,88 @@ 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(LoginJob)) .use(registerJob(LoginJob))
.use(registerJob(TwitchLoginJob)) .use(registerJob(TwitchLoginJob))
.use(registerJob(UpdateStoreJob)) .use(registerJob(EnsureStore))
.use(registerJob(LaunchGameJob))
.use(registerJob(BiosDownloadJob)) .use(registerJob(BiosDownloadJob))
.use(registerJob(InstallJob)) .use(registerJob(InstallJob))
.use(registerJob(ReloadPluginsJob))
.use(registerJob(EmulatorDownloadJob)); .use(registerJob(EmulatorDownloadJob));

View file

@ -1,143 +1,272 @@
import z from "zod"; import z from "zod";
import { IJob, JobContext } from "../task-queue"; import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema"; import { ActiveGameSchema, ActiveGameType } from "@simeonradivoev/gameflow-sdk";
import { 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, sql } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { updateLocalLastPlayed } from "../games/services/statusService";
import { getErrorMessage } from "@/bun/utils";
import { CommandEntry, FrontEndId, SaveSlots } from "@simeonradivoev/gameflow-sdk/shared";
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing"> export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, string>
{ {
static id = "launch-game" as const; static id = "launch-game" as const;
static dataSchema = z.optional(ActiveGameSchema); static dataSchema = z.nullable(ActiveGameSchema);
group = "launch-game"; group = "launch-game";
activeGame?: ActiveGameType; activeGame: ActiveGameType | null;
gameId: number; gameId: FrontEndId;
validCommand: CommandEntry; validCommand: CommandEntry;
gameSource: string; gameSource?: string;
gameSourceId: string; gameSourceId?: string;
changedSaveFiles: Map<string, { subPath: string, cwd: string; }>;
saveSlots: SaveSlots = {};
constructor(gameId: number, validCommand: CommandEntry, source: string, sourceId: string) constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string)
{ {
this.gameId = gameId; this.gameId = gameId;
this.validCommand = validCommand; this.validCommand = validCommand;
this.gameSource = source; this.gameSource = source;
this.gameSourceId = sourceId; this.gameSourceId = sourceId;
this.activeGame = null;
this.changedSaveFiles = new Map();
} }
async start (context: JobContext<IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">, z.infer<typeof LaunchGameJob.dataSchema>, "playing">) async postPlay (gameInfo: { platformSlug?: string; })
{ {
const localGame = await db.query.games.findFirst({ if (this.gameId.source === 'local')
where: eq(appSchema.games.id, this.gameId), columns: { {
name: true, await updateLocalLastPlayed(Number(this.gameId.id));
source_id: true, }
source: true
} const source = this.gameSource ?? this.gameId.source;
const id = this.gameSourceId ?? this.gameId.id;
await new Promise(async (resolve) =>
{
await plugins.hooks.games.postPlay.promise(
{
source,
id,
command: this.validCommand,
changedSaveFiles: Array.from(this.changedSaveFiles.values()),
validChangedSaveFiles: {},
saveFolderSlots: this.saveSlots,
gameInfo
}).catch(e =>
{
console.error(e);
events.emit('notification', { message: getErrorMessage(e), type: 'error' });
}).then(() => resolve(false));
const timeoutHandler = () => resolve(false);
setTimeout(timeoutHandler, 5000);
}); });
}
prePlay (setProgress: (progress: number, state: string) => void, gameInfo: { platformSlug?: string; })
{
return plugins.hooks.games.prePlay.promise({
source: this.gameSource ?? this.gameId.source,
id: this.gameSourceId ?? this.gameId.id,
saveFolderSlots: this.saveSlots,
command: this.validCommand,
setProgress: setProgress,
gameInfo
});
}
async start (context: JobContext<IJob<z.infer<typeof LaunchGameJob.dataSchema>, string>, z.infer<typeof LaunchGameJob.dataSchema>, string>)
{
let gameInfo: { name?: string, source_id?: string, source?: string; platformSlug?: string; } | undefined = undefined;
if (this.gameId.source === 'emulator')
{
gameInfo = { name: this.gameId.id };
} else
{
const localGame = await db.query.games.findFirst({
where: eq(appSchema.games.id, Number(this.gameId.id)), columns: {
name: true,
source_id: true,
source: true,
},
with: {
platform: {
columns: {
es_slug: true,
slug: true
}
}
}
});
if (localGame)
gameInfo = {
name: localGame.name ?? undefined,
source_id: localGame.source_id ?? undefined,
source: localGame.source ?? undefined,
platformSlug: localGame.platform.es_slug ?? localGame.platform.slug
};
}
const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({ const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({
autoValidCommand: this.validCommand, autoValidCommand: this.validCommand,
game: { source: this.gameSource, id: this.gameId } game: {
source: this.gameSource,
sourceId: this.gameSourceId,
id: this.gameId,
platformSlug: gameInfo?.platformSlug
},
dryRun: false
}); });
await new Promise((resolve, reject) => await new Promise(async (resolve, reject) =>
{ {
let game: any; try
if (!commandArgs)
{ {
// ES-DE commands require shell execution. Some emulators fail otherwise. let game: any;
const spawnGame = spawn(this.validCommand.command, { if (!commandArgs)
shell: true,
cwd: this.validCommand.startDir,
signal: context.abortSignal
});
spawnGame.stdout.on('data', data => console.log(data));
spawnGame.on('close', (code) =>
{ {
resolve(code); await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }).catch(e => reject(e));
});
spawnGame.on('error', e => if (Array.isArray(this.validCommand.command))
{
let command = this.validCommand.command;
if (process.env.FLATPAK_BUILD) command = ['flatpak-spawn', '--host', `--directory=${config.get('downloadPath')}`, ...command];
const bunGame = Bun.spawn(command, {
cwd: this.validCommand.startDir,
signal: context.abortSignal,
env: {
...process.env,
...this.validCommand.env
},
onExit (subprocess, exitCode, signalCode, error)
{
if (error)
{
console.error(error);
reject(error);
} else
{
resolve(true);
}
},
});
context.setProgress(0, "playing");
game = bunGame;
} else
{
let command = this.validCommand.command;
if (process.env.FLATPAK_BUILD) command = `flatpak-spawn --host --directory=${config.get('downloadPath')} ${command}`;
// ES-DE commands require shell execution. Some emulators fail otherwise.
const spawnGame = spawn(command, {
shell: this.validCommand.shell ?? true,
cwd: this.validCommand.startDir,
signal: context.abortSignal,
env: {
...process.env,
...this.validCommand.env
},
});
context.setProgress(0, "playing");
spawnGame.stdout.on('data', data => console.log(data));
spawnGame.on('close', (code) =>
{
resolve(code);
});
spawnGame.on('error', e =>
{
console.error(e);
resolve(1);
});
game = spawnGame;
}
}
else if (this.validCommand.metadata.emulatorBin)
{ {
console.error(e); this.saveSlots = commandArgs.savesPath ?? {};
reject(e);
});
game = spawnGame; await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug });
}
else if (this.validCommand.metadata.emulatorBin)
{
// We have full control over launching integrated emulators better to use bun spawn
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs], {
cwd: this.validCommand.startDir,
signal: context.abortSignal
});
bunGame.exited.then(resolve).catch(e => let command = [this.validCommand.metadata.emulatorBin, ...commandArgs.args];
if (process.env.FLATPAK_BUILD) command = ['flatpak-spawn', '--host', `--directory=${config.get('downloadPath')}`, ...command];
// We have full control over launching integrated emulators better to use bun spawn
const bunGame = Bun.spawn(command, {
cwd: this.validCommand.startDir,
signal: context.abortSignal,
env: {
...process.env,
...commandArgs.env
},
onExit (subprocess, exitCode, signalCode, error)
{
if (error)
{
console.error(error);
reject(error);
} else
{
resolve(true);
}
},
});
context.setProgress(0, "playing");
// TODO: this isn't really useful, maybe add it later if needed
/*if (commandArgs.savesPath && await fs.exists(commandArgs.savesPath))
{
const savesWatcher = watch(commandArgs.savesPath, { recursive: true, signal: context.abortSignal });
console.log("Starting To Watch", commandArgs.savesPath, "for save file changes");
savesWatcher.on('change', (type, filename) =>
{
if (typeof filename === 'string')
{
console.log("Save File Changed", filename);
this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath! });
}
});
bunGame.exited.then(() =>
{
savesWatcher.close();
console.log("Closing Save File Watching for", commandArgs.savesPath);
});
}*/
game = bunGame;
} else
{ {
console.error(e); reject(new Error("No Emulator Bin"));
reject(e); return;
}); }
game = bunGame;
} else
{
reject(new Error("No Emulator Bin"));
return;
}
this.activeGame = { this.activeGame = {
process: game, process: game,
name: localGame?.name ?? "Unknown", name: gameInfo?.name ?? "Unknown",
gameId: this.gameId, gameId: this.gameId,
command: this.validCommand source: this.gameSource,
}; sourceId: this.gameSourceId,
command: this.validCommand
const updatePlayed = async (source: string, id: string) => };
} catch (e)
{ {
await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, this.gameId)); context.abort(e);
await plugins.hooks.games.updatePlayed.promise({ source, id }).then(v => resolve(e);
{
if (v) events.emit('notification', { message: "Updated Last Played", type: 'success' });
});
};
if (this.gameSource !== 'local')
{
updatePlayed(this.gameSource, this.gameSourceId);
}
else if (localGame?.source && localGame?.source !== 'local' && localGame.source_id)
{
updatePlayed(localGame.source, localGame.source_id);
} }
}); });
/* Old spawn lanching, cases issues, needs to be ran as shell await this.postPlay({ platformSlug: gameInfo?.platformSlug });
const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]);
const game = setActiveGame({
process: Bun.spawn({
cmd,
env: {
...process.env
},
onExit (subprocess, exitCode, signalCode, error)
{
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
},
stdin: "ignore",
stdout: "inherit",
stderr: "inherit",
}),
name: localGame?.name ?? "Unknown",
gameId: validCommand.gameId,
command: validCommand.command.command
});
await game.process.exited;
if (game.process.exitCode && game.process.exitCode > 0)
{
return status('Internal Server Error');
}*/
} }
exposeData () exposeData ()

View file

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

View file

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

View file

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

View file

@ -0,0 +1,121 @@
import z from "zod";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
import { events } from "../app";
import { Downloader } from "@/bun/utils/downloader";
import path from 'node:path';
import os from "node:os";
import winUpdateScript from '@/bun/utils/update-gameflow-windows.bat' with { type: "text" };
import linuxUpdateScript from '@/bun/utils/update-gameflow-linux.sh' with { type: "text" };
import mustache from "mustache";
import pkg from '~/package.json';
import { sleep } from "bun";
export default class SelfUpdateJob implements IJob<never, string>
{
static id = "self-update-job" as const;
static dataSchema = z.never();
group = "self-update";
async downloadUpdate (url: URL, dest: string | undefined, filename: string, ctx: JobContext<IJob<never, string>, never, string>)
{
const downloader = new Downloader('update',
[{
url: url,
file_path: "",
file_name: filename
}],
dest,
{
onProgress (stats)
{
ctx.setProgress(stats.progress, "Downloading Update");
},
});
return downloader.start();
}
async start (context: JobContext<IJob<never, string>, never, string>)
{
context.setProgress(0, "Downloading Update");
await sleep(1000);
const latest = await fetch('https://api.github.com/repos/simeonradivoev/gameflow-deck/releases/latest');
if (latest.ok)
{
const data = await latest.json();
let validAsset: any | undefined;
switch (process.platform)
{
case "win32":
validAsset = data.assets.find((e: any) => new Bun.Glob(`Gameflow-${process.platform}-${process.arch}.zip`).match(e.name));
if (!validAsset)
{
validAsset = data.assets.find((e: any) => new Bun.Glob(`Gameflow-*.zip`).match(e.name));
}
break;
case "linux":
validAsset = data.assets.find((e: any) => new Bun.Glob(`Gameflow-${process.platform}-${process.arch}.AppImage`).match(e.name));
if (!validAsset)
{
validAsset = data.assets.find((e: any) => new Bun.Glob(`*.AppImage`).match(e.name));
}
break;
default:
events.emit('notification', { message: "Unsupported Platfrom", title: 'Failed Update', type: "error" });
return;
}
if (!validAsset)
{
events.emit('notification', { message: "Could not find download", title: 'Failed Update', type: "error" });
return;
}
console.log("Found Download", validAsset.browser_download_url);
console.log("Starting Download");
switch (process.platform)
{
case "linux":
const appimage = process.env.APPIMAGE;
if (!appimage)
{
events.emit('notification', {
message: "Only AppImage supported",
title: 'Failed Update',
type: 'error'
});
return;
}
const linuxDownloads = await this.downloadUpdate(new URL(validAsset.browser_download_url), undefined, path.basename(appimage), context);
if (!linuxDownloads) return;
const shPath = path.join(os.tmpdir(), "update-gameflow.sh");
await Bun.write(shPath, mustache.render(linuxUpdateScript, {
tempFile: linuxDownloads[0],
appImagePath: appimage
}));
context.setProgress(0, "Restarting App To Update");
events.emit('exitapp');
Bun.spawn(["bash", shPath], { detached: true });
process.exit(0);
case "win32":
const winDownloads = await this.downloadUpdate(new URL(validAsset.browser_download_url), undefined, "Gameflow-update.zip", context);
if (!winDownloads) return;
const batPath = path.join(os.tmpdir(), "update-gameflow.bat");
await Bun.write(batPath, mustache.render(winUpdateScript, {
tempFile: winDownloads[0],
installDir: path.dirname(process.execPath),
extractDir: path.join(os.tmpdir(), 'gameflow-update-extract'),
exePath: `${pkg.bin}.exe`
}));
context.setProgress(0, "Restarting App To Update");
events.emit('exitapp');
Bun.spawn(["cmd", "/c", "start", "cmd", "/c", batPath], { detached: true });
process.exit(0);
}
} else
{
events.emit('notification', { message: latest.statusText, title: 'Failed Update', type: "error" });
}
}
}

View file

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

View file

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

View file

@ -1,50 +0,0 @@
import { ensureDir } from "fs-extra";
import { IJob, JobContext } from "../task-queue";
import { getStoreRootFolder } from "../store/services/gamesService";
import { STORE_VERSION } from "@/shared/constants";
import { tmpdir } from "node:os";
import path from "node:path";
import z from "zod";
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 ?? STORE_VERSION;
}
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("Updating Store");
const proc = Bun.spawn([process.execPath, "add", `${this.packageName}@${this.storeVersion}`, "--production", "--registry", this.registry.href], {
cwd: storeFolder,
stdout: 'pipe',
stderr: 'pipe',
env: {
BUN_BE_BUN: "1",
BUN_INSTALL_CACHE_DIR: tempCache
}
});
const stdout = await new Response(proc.stdout).text();
console.log(stdout);
const stderr = await new Response(proc.stderr).text();
if (stderr)
console.error(stderr);
await proc.exited;
}
}

View file

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

View file

@ -0,0 +1,46 @@
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import path from 'node:path';
import { config } from "@/bun/api/app";
export default class CEMUIntegration implements PluginType
{
emulator = 'CEMU';
async load (ctx: PluginLoadingContextType)
{
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] };
});
ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) =>
{
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
validChangedSaveFiles[this.emulator] = {
cwd: saveFolderSlots[this.emulator].cwd,
shared: true,
subPath: '*.{tga,xml,dat}',
isGlob: true
};
});
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
{
const args: string[] = [];
args.push(`--fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`);
const savesPath = path.join(config.get('downloadPath'), "saves", this.emulator);
args.push(`--mlc=${savesPath}`);
if (ctx.autoValidCommand.metadata.romPath)
{
args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`);
}
return { args, savesPath: { [this.emulator]: { cwd: savesPath } } };
});
}
}

View file

@ -0,0 +1,15 @@
{
"name": "com.simeonradivoev.gameflow.cemu",
"displayName": "CEMU Integration",
"version": "0.0.1",
"description": "CEMU Emulator Integration",
"main": "./cemu.ts",
"icon": "https://upload.wikimedia.org/wikipedia/commons/9/9e/Cemu_Emulator_Official_Logo.png",
"category": "emulators",
"keywords": [
"integration",
"emulator",
"wiiu",
"cemu"
]
}

View file

@ -0,0 +1,87 @@
import { config } from "@/bun/api/app";
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import path from 'node:path';
import desc from './package.json';
import { ensureDir } from "fs-extra";
import { getSavePaths, getType } from "./utils";
export default class DOLPHINIntegration implements PluginType
{
emulator = 'DOLPHIN';
async load (ctx: PluginLoadingContextType)
{
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "resolution", "fullscreen", "saves"] };
});
ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
{
await Bun.write(path.join(ctx.path, "portable.txt"), "");
});
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
{
const args: string[] = [];
const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator);
args.push(`--user=${storageFolder}`);
args.push(`--config=Dolphin.Display.Fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`);
args.push(`--config=Dolphin.General.ISOPath0=${path.join(config.get('downloadPath'), 'roms', 'gc')}`);
args.push(`--config=Dolphin.General.ISOPath1=${path.join(config.get('downloadPath'), 'roms', 'wii')}`);
args.push(`--config=Dolphin.Interface.ConfirmStop=False`);
args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`);
args.push(`--config=Dolphin.Analytics.PermissionAsked=True`);
const resolution = config.get('emulatorResolution');
const resolutionMapping = {
"720p": 2,
"1080p": 3,
"1440p": 4,
"4k": 6
};
args.push(`--config=GFX.Settings.InternalResolution=${resolutionMapping[resolution] ?? 1}`);
args.push(`--config=GFX.Settings.wideScreenHack=${config.get('emulatorWidescreen') ? "True" : "False"}`);
args.push(`--config=GFX.Settings.AspectRatio=${config.get('emulatorWidescreen') ? "1" : "0"}`);
const savesPath = path.join(config.get('downloadPath'), "saves", this.emulator);
args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`);
args.push(`--config=Dolphin.General.WiiSDCardSyncFolder=${path.join(savesPath, 'WiiSDSync')}`);
args.push(`--config=Dolphin.GBA.SavesPath=${path.join(savesPath, 'GBA')}`);
args.push(`--config=Dolphin.Core.GCIFolderAPath=${path.join(savesPath, 'GC')}`);
if (!ctx.dryRun)
{
await ensureDir(path.join(savesPath, 'GC', "JAP"));
await ensureDir(path.join(savesPath, 'GC', "EUR"));
await ensureDir(path.join(savesPath, 'GC', "USA"));
}
let finalSavesPath: string | undefined = undefined;
if (ctx.autoValidCommand.metadata.romPath)
{
args.push("--batch");
args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`);
finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder;
return { args, savesPath: { [this.emulator]: { cwd: finalSavesPath } } };
}
return { args };
});
ctx.hooks.games.postPlay.tap({ name: desc.name }, async ({ validChangedSaveFiles, saveFolderSlots, command }) =>
{
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
validChangedSaveFiles[this.emulator] = {
cwd: saveFolderSlots[this.emulator].cwd,
subPath: await getSavePaths(command.metadata.romPath, saveFolderSlots.dolphin.cwd, command.metadata.emulatorDir),
shared: false
};
});
}
}

View file

@ -0,0 +1,16 @@
{
"name": "com.simeonradivoev.gameflow.dolphin",
"displayName": "DOLPHIN Integration",
"version": "0.0.1",
"description": "DOLPHIN Emulator Integration",
"main": "./dolphin.ts",
"icon": "https://upload.wikimedia.org/wikipedia/commons/5/53/Dolphin_Emulator_Logo_Refresh.svg",
"category": "emulators",
"keywords": [
"integration",
"emulator",
"wii",
"gc",
"dolphin"
]
}

View file

@ -0,0 +1,164 @@
import { join } from "path";
import { platform } from "os";
import fs from "node:fs/promises";
import path from "node:path";
type DolphinLocation =
| { type: "path"; toolPath: string; }
| { type: "appimage"; appImagePath: string; };
async function findDolphinTool (bundledDir?: string): Promise<DolphinLocation>
{
const os = platform();
const toolName = os === "win32" ? "DolphinTool.exe" : "dolphin-tool";
if (bundledDir)
{
if (os === "linux")
{
const glob = new Bun.Glob("*.AppImage");
for await (const file of glob.scan(bundledDir))
{
return { type: "appimage", appImagePath: join(bundledDir, file) };
}
throw new Error(`No AppImage found in ${bundledDir}`);
} else
{
return { type: "path", toolPath: join(bundledDir, toolName) };
}
}
// Fallback 1: check PATH
const inPath = Bun.which(toolName);
if (inPath) return { type: "path", toolPath: inPath };
// Fallback 2: platform default install locations
if (os === "win32")
{
const candidates = [
"C:/Program Files/Dolphin/DolphinTool.exe",
"C:/Program Files (x86)/Dolphin/DolphinTool.exe",
];
for (const candidate of candidates)
{
if (await Bun.file(candidate).exists())
{
return { type: "path", toolPath: candidate };
}
}
} else if (os === "darwin")
{
const candidate = "/Applications/Dolphin.app/Contents/MacOS/dolphin-tool";
if (await Bun.file(candidate).exists())
{
return { type: "path", toolPath: candidate };
}
} else if (os === "linux")
{
const home = process.env.HOME ?? "";
const candidates = [
join(home, "Applications/Dolphin-x86_64.AppImage"),
join(home, "Applications/Dolphin.AppImage"),
"/opt/Dolphin-x86_64.AppImage",
];
for (const candidate of candidates)
{
if (await Bun.file(candidate).exists())
{
return { type: "appimage", appImagePath: candidate };
}
}
}
throw new Error(`Could not find ${toolName}. Install Dolphin or pass its folder path explicitly.`);
}
async function runDolphinTool (args: string[], location: DolphinLocation): Promise<string>
{
if (location.type === "path")
{
const proc = Bun.spawnSync([location.toolPath, ...args]);
if (!proc.success) throw new Error(`dolphin-tool failed: ${proc.stderr.toString()}`);
return proc.stdout.toString();
} else
{
const mount = Bun.spawn([location.appImagePath, "--appimage-mount"], {
stdout: "pipe",
stderr: "pipe",
});
const mountPoint = (await new Response(mount.stdout).text()).trim();
try
{
const proc = Bun.spawnSync([`${mountPoint}/usr/bin/dolphin-tool`, ...args]);
if (!proc.success) throw new Error(`dolphin-tool failed: ${proc.stderr.toString()}`);
return proc.stdout.toString();
} finally
{
mount.kill();
}
}
}
async function readGameId (romPath: string, location: DolphinLocation): Promise<string>
{
const output = await runDolphinTool(["header", "-i", romPath], location);
const match = output.match(/Game ID:\s*(\w{6})/);
if (!match) throw new Error("Could not read game ID");
return match[1];
}
function getRegion (regionCode: string)
{
switch (regionCode)
{
case "E": return "USA";
case "P": return "EUR";
case "J": return "JAP";
default: return "USA";
}
}
async function getGCSavePaths (romPath: string, savesPath: string, location: DolphinLocation)
{
const gameId = await readGameId(romPath, location);
const region = getRegion(gameId[3]);
const makerCode = gameId.slice(4, 6); // e.g. "01" or "7D" — already the right format
const gameCode = gameId.slice(0, 4); // e.g. "GZLE" or "GM5E"
const cardPath = join(savesPath, "GC", region);
const glob = new Bun.Glob(`${makerCode}-${gameCode}-*.gci`);
const saves: string[] = [];
for await (const file of glob.scan(cardPath))
{
saves.push(path.join("GC", region, file));
}
return saves;
}
export async function getType (romPath: string, bundledEmulatorDir?: string): Promise<"gamecube" | "wii">
{
const location = await findDolphinTool(bundledEmulatorDir);
const gameId = await readGameId(romPath, location);
const isGameCube = gameId[0] === "G" || gameId[0] === "D";
return isGameCube ? "gamecube" : "wii";
}
export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise<string[]>
{
const location = await findDolphinTool(bundledEmulatorDir);
const gameId = await readGameId(romPath, location);
const isGameCube = gameId[0] === "G" || gameId[0] === "D";
if (isGameCube)
{
return getGCSavePaths(romPath, savesPath, location);
} else
{
const folder = Buffer.from(gameId.slice(0, 4), "ascii").toString("hex").toUpperCase();
const rootFolder = join(savesPath, "Wii", "title", "00010000", folder);
const files = await fs.readdir(rootFolder, { recursive: true });
return files.map(f => path.join("Wii", "title", "00010000", f));
}
}

View file

@ -21,7 +21,6 @@ CdvdShareWrite = false
EnablePatches = true EnablePatches = true
EnableCheats = false EnableCheats = false
EnablePINE = false EnablePINE = false
EnableWideScreenPatches = false
EnableNoInterlacingPatches = false EnableNoInterlacingPatches = false
EnableRecordingTools = true EnableRecordingTools = true
EnableGameFixes = true EnableGameFixes = true
@ -92,7 +91,7 @@ VsyncEnable = 0
FramerateNTSC = 59.94 FramerateNTSC = 59.94
FrameratePAL = 50 FrameratePAL = 50
SyncToHostRefreshRate = false SyncToHostRefreshRate = false
AspectRatio = Auto 4:3/3:2 AspectRatio = {{ASPECT_RATIO}}
FMVAspectRatioSwitch = Off FMVAspectRatioSwitch = Off
ScreenshotSize = 0 ScreenshotSize = 0
ScreenshotFormat = 0 ScreenshotFormat = 0
@ -168,7 +167,6 @@ linear_present_mode = 1
deinterlace_mode = 0 deinterlace_mode = 0
OsdScale = 100 OsdScale = 100
Renderer = 14 Renderer = 14
upscale_multiplier = 1
mipmap_hw = -1 mipmap_hw = -1
accurate_blending_unit = 1 accurate_blending_unit = 1
crc_hack_level = -1 crc_hack_level = -1
@ -371,18 +369,6 @@ Multitap2_Slot4_Enable = false
Multitap2_Slot4_Filename = Mcd-Multitap2-Slot04.ps2 Multitap2_Slot4_Filename = Mcd-Multitap2-Slot04.ps2
[Folders]
Bios = {{{BIOS_PATH}}}
Snapshots = {{{SNAPSHOTS_PATH}}}
SaveStates = {{{SAVE_STATES_PATH}}}
MemoryCards = {{{MEMORY_CARDS_PATH}}}
Cache = {{{CACHE_PATH}}}
Covers = {{{COVERS_PATH}}}
Logs = logs
Textures = {{{TEXTURES_PATH}}}
Videos = videos
[InputSources] [InputSources]
Keyboard = true Keyboard = true
Mouse = true Mouse = true
@ -488,6 +474,3 @@ RDown = SDL-1/+RightY
RLeft = SDL-1/-RightX RLeft = SDL-1/-RightX
LargeMotor = SDL-1/LargeMotor LargeMotor = SDL-1/LargeMotor
SmallMotor = SDL-1/SmallMotor SmallMotor = SDL-1/SmallMotor
[GameList]
RecursivePaths = {{{RECURSIVE_PATHS}}}

View file

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

View file

@ -1,34 +1,87 @@
import { config, db } from "@/bun/api/app"; import { config } from "@/bun/api/app";
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import configFile from './PCSX2.ini' with { type: 'file' }; import defaultConfig from './PCSX2.ini' with { type: 'file' };
import Mustache from 'mustache';
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 { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared";
export default class PCSX2Integration implements PluginType export default class PCSX2Integration implements PluginType
{ {
load (ctx: PluginContextType) emulator = "PCSX2";
async load (ctx: PluginLoadingContextType)
{ {
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{ {
if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir) const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"];
if (ctx.source?.type === 'store')
{ {
const args = ["-batch"]; return {
if (config.get('launchInFullscreen')) id: desc.name,
{ supportLevel: "full",
args.push("-fullscreen"); capabilities: [...baseCapabilities, "config", "resolution"]
} };
args.push(...["-bigpicture", "-portable", "--", ctx.autoValidCommand.metadata.romPath]); }
else
{
return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] };
}
});
const configFileContents = await Bun.file(configFile).text(); ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) =>
{
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
validChangedSaveFiles[this.emulator] = {
cwd: saveFolderSlots[this.emulator].cwd,
shared: true,
subPath: '*.ps2',
isGlob: true,
fixedSize: true
};
});
const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2'); ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); {
const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); const args: string[] = [];
if (ctx.autoValidCommand.metadata.romPath)
{
args.push(ctx.autoValidCommand.metadata.romPath);
args.push("-batch");
}
if (config.get('launchInFullscreen'))
{
args.push("-fullscreen");
}
args.push(...["-bigpicture", "-portable", "--"]);
const view = { if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun)
{
let pscx2Path = '';
if (process.platform === 'win32')
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis');
else
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis');
const configPath = path.join(pscx2Path, 'PCSX2.ini');
const existingConfigFile = Bun.file(configPath);
const configFile = await existingConfigFile.exists() ? ini.parse(await existingConfigFile.text()) : ini.parse(await Bun.file(defaultConfig).text());
const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator);
const savesFolder = path.join(config.get('downloadPath'), "saves", this.emulator);
const resolutionMapping = {
"720p": 2,
"1080p": 3,
"1440p": 4,
"4k": 6,
};
const paths = {
BIOS_PATH: biosFolder, BIOS_PATH: biosFolder,
SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'), SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'),
SAVE_STATES_PATH: path.join(savesFolder, 'states'), SAVE_STATES_PATH: path.join(savesFolder, 'states'),
@ -36,21 +89,37 @@ export default class PCSX2Integration implements PluginType
CACHE_PATH: path.join(storageFolder, 'cache'), CACHE_PATH: path.join(storageFolder, 'cache'),
COVERS_PATH: path.join(storageFolder, 'covers'), COVERS_PATH: path.join(storageFolder, 'covers'),
TEXTURES_PATH: path.join(storageFolder, 'textures'), TEXTURES_PATH: path.join(storageFolder, 'textures'),
VIDEOS_PATH: path.join(storageFolder, 'videos'),
LOGS_PATH: path.join(storageFolder, 'logs'),
RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'),
}; };
await Promise.all(Object.values(view).map(p => ensureDir(p))); await Promise.all(Object.values(paths).map(p => ensureDir(p)));
let pscx2Path = ''; configFile.EmuCore ??= {};
if (process.platform === 'win32') configFile.EmuCore.EnableWideScreenPatches = config.get('emulatorWidescreen');
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); configFile['EmuCore/GS'] ??= {};
else configFile['EmuCore/GS'].AspectRatio = config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2";
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis'); configFile['EmuCore/GS'].upscale_multiplier = resolutionMapping[config.get('emulatorResolution')] ?? 1;
configFile.Folders ??= {};
configFile.Folders.Bios = paths.BIOS_PATH;
configFile.Folders.Snapshots = paths.SNAPSHOTS_PATH;
configFile.Folders.SaveStates = paths.SAVE_STATES_PATH;
configFile.Folders.MemoryCards = paths.MEMORY_CARDS_PATH;
configFile.Folders.Cache = paths.CACHE_PATH;
configFile.Folders.Covers = paths.COVERS_PATH;
configFile.Folders.Textures = paths.TEXTURES_PATH;
configFile.Folders.Videos = paths.VIDEOS_PATH;
configFile.Folders.Logs = paths.LOGS_PATH;
configFile.GameList ??= {};
configFile.GameList.RecursivePaths = paths.RECURSIVE_PATHS;
await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); await Bun.write(configPath, ini.stringify(configFile));
return args; return { args, savesPath: { [this.emulator]: { cwd: paths.MEMORY_CARDS_PATH } } };
} }
return { args };
}); });
} }
} }

View file

@ -96,7 +96,6 @@ HardwareTransform = True
SoftwareSkinning = True SoftwareSkinning = True
TextureFiltering = 1 TextureFiltering = 1
BufferFiltering = 1 BufferFiltering = 1
InternalResolution = 3
AndroidHwScale = 1 AndroidHwScale = 1
HighQualityDepth = 1 HighQualityDepth = 1
FrameSkip = 0 FrameSkip = 0
@ -109,7 +108,6 @@ AnisotropyLevel = 4
VertexDecCache = False VertexDecCache = False
TextureBackoffCache = False TextureBackoffCache = False
TextureSecondaryCache = False TextureSecondaryCache = False
FullScreen = True
FullScreenMulti = False FullScreenMulti = False
SmallDisplayZoomType = 2 SmallDisplayZoomType = 2
SmallDisplayOffsetX = 0.500000 SmallDisplayOffsetX = 0.500000

View file

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

View file

@ -1,4 +1,4 @@
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
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' };
@ -9,40 +9,93 @@ import path from "node:path";
import Mustache from "mustache"; import Mustache from "mustache";
import { ensureDir } from "fs-extra"; import { ensureDir } from "fs-extra";
import { homedir } from "node:os"; import { homedir } from "node:os";
import ini from 'ini';
import fs from 'node:fs/promises';
import { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared";
export default class PCSX2Integration implements PluginType export default class PPSSPPIntegration implements PluginType
{ {
load (ctx: PluginContextType) emulator = "PPSSPP";
{
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) =>
{
if (ctx.autoValidCommand.emulator === 'PPSSPP' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir)
{
const args = [ctx.autoValidCommand.metadata.romPath, "--escape-exit", "--pause-menu-exit"];
if (config.get('launchInFullscreen'))
{
args.push("--fullscreen");
}
let confPath: string | undefined = undefined; async load (ctx: PluginLoadingContextType)
let controlsPath: string | undefined = undefined; {
ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
{
const stat = await fs.stat(ctx.path);
if (stat.isDirectory())
{
await Bun.write(path.join(ctx.path, "portable.txt"), "");
if (process.platform === 'win32')
{
await Bun.write(path.join(ctx.path, "installed.txt"), path.join(config.get('downloadPath'), 'saves', this.emulator));
}
}
});
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"];
if (ctx.source?.type === 'store')
{
return {
id: desc.name,
supportLevel: "full",
capabilities: [...baseCapabilities, "config", "resolution"]
};
}
else
{
return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] };
}
});
ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) =>
{
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
validChangedSaveFiles[this.emulator] = {
cwd: saveFolderSlots[this.emulator].cwd,
shared: true,
subPath: '*.{SFO,sfo,PNG,png}',
isGlob: true
};
});
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
{
const args: string[] = [];
if (ctx.autoValidCommand.metadata.romPath)
{
args.push(ctx.autoValidCommand.metadata.romPath);
}
args.push("--escape-exit", "--pause-menu-exit");
if (config.get('launchInFullscreen'))
{
args.push("--fullscreen");
}
if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun)
{
let defaultConfigPath: string | undefined = undefined;
let defaultControlsPath: string | undefined = undefined;
switch (process.platform) switch (process.platform)
{ {
case "win32": case "win32":
confPath = configFilePathWin32; defaultConfigPath = configFilePathWin32;
controlsPath = configControlsFilePathWin32; defaultControlsPath = configControlsFilePathWin32;
break; break;
case 'linux': case 'linux':
confPath = configFilePathLinux; defaultConfigPath = configFilePathLinux;
controlsPath = configControlsFilePathLinux; defaultControlsPath = configControlsFilePathLinux;
break; break;
} }
let ppssppPath = ''; let ppssppPath = '';
if (process.platform === 'win32') if (process.platform === 'win32')
{ {
ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'); ppssppPath = path.join(config.get('downloadPath'), 'saves', this.emulator, 'PSP', 'SYSTEM');
} else } else
{ {
//TODO: Use way to set custom memstick path when they support it //TODO: Use way to set custom memstick path when they support it
@ -52,20 +105,43 @@ export default class PCSX2Integration implements PluginType
ensureDir(ppssppPath); ensureDir(ppssppPath);
if (confPath) if (defaultConfigPath)
{ {
const configFileContents = await Bun.file(confPath).text(); const resolutionMapping: Record<string, number> = {
await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {})); "720p": 2,
"1080p": 4,
"1440p": 6,
"4k": 8
};
const configPath = path.join(ppssppPath, 'ppsspp.ini');
const configFile = Bun.file(configPath);
const ppssppConfig = await configFile.exists() ? ini.parse(await configFile.text()) : ini.parse(await Bun.file(defaultConfigPath).text());
ppssppConfig.Graphics ??= {};
ppssppConfig.Graphics.InternalResolution = resolutionMapping[config.get('emulatorResolution')] ?? 0;
ppssppConfig.Graphics.FullScreen = config.get('launchInFullscreen');
await Bun.write(configPath, ini.stringify(ppssppConfig));
} }
if (controlsPath) if (defaultControlsPath)
{ {
const controlsFileContents = await Bun.file(controlsPath).text(); const controlsFileContents = await Bun.file(defaultControlsPath).text();
await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {}));
} }
return args; return {
args,
savesPath: {
[this.emulator]: {
cwd: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA")
}
}
};
} }
return { args };
}); });
} }
} }

View file

@ -96,7 +96,6 @@ HardwareTransform = True
SoftwareSkinning = True SoftwareSkinning = True
TextureFiltering = 1 TextureFiltering = 1
BufferFiltering = 1 BufferFiltering = 1
InternalResolution = 3
AndroidHwScale = 1 AndroidHwScale = 1
HighQualityDepth = 1 HighQualityDepth = 1
FrameSkip = 0 FrameSkip = 0
@ -109,7 +108,6 @@ AnisotropyLevel = 4
VertexDecCache = False VertexDecCache = False
TextureBackoffCache = False TextureBackoffCache = False
TextureSecondaryCache = False TextureSecondaryCache = False
FullScreen = True
FullScreenMulti = False FullScreenMulti = False
SmallDisplayZoomType = 2 SmallDisplayZoomType = 2
SmallDisplayOffsetX = 0.500000 SmallDisplayOffsetX = 0.500000

View file

@ -0,0 +1,15 @@
{
"name": "com.simeonradivoev.gameflow.xemu",
"displayName": "XEMU Integration",
"version": "0.0.1",
"description": "XEMU Emulator Integration",
"main": "./xemu.ts",
"icon": "https://upload.wikimedia.org/wikipedia/commons/8/8e/Xemu_logo_green.svg",
"category": "emulators",
"keywords": [
"integration",
"emulator",
"xbox",
"xemu"
]
}

View file

@ -0,0 +1,74 @@
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import { config } from "@/bun/api/app";
import path from "node:path";
import toml, { TomlTable } from 'smol-toml';
import fs from 'node:fs/promises';
import bin from './eeprom.bin' with { type: 'file' };
export default class XEMUIntegration implements PluginType
{
emulator = 'XEMU';
async load (ctx: PluginLoadingContextType)
{
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] };
});
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
{
const args: string[] = [];
if (config.get('launchInFullscreen'))
{
args.push("-full-screen");
}
if (ctx.autoValidCommand.metadata.romPath)
{
args.push("-dvd_path");
args.push(ctx.autoValidCommand.metadata.romPath);
}
const configPath = path.join(config.get('downloadPath'), 'storage', this.emulator, 'xemu.toml');
let configFile: { general: TomlTable & { misc: TomlTable; }, sys: TomlTable & { files: TomlTable; }; } = { general: { misc: {} }, sys: { files: {} } };
if (await Bun.file(configPath).exists())
{
configFile = toml.parse(await Bun.file(configPath).text()) as any;
}
configFile.general.misc ??= {};
configFile.general.misc.skip_boot_anim = true;
configFile.general.show_welcome = false;
configFile.general.games_dir = path.join(config.get('downloadPath'), 'roms', 'xbox');
configFile.sys.mem_limit = '128';
const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
if (await fs.exists(biosFolder))
{
const biosPaths = (await fs.readdir(biosFolder));
const flash = biosPaths.find(f => f.endsWith('.bin') && !f.includes('mcpx'));
const bootrom = biosPaths.find(f => f.endsWith('.bin') && f.includes('mcpx'));
const hardDrive = biosPaths.find(f => f.endsWith('qcow2'));
if (flash) configFile.sys.files.flashrom_path = path.join(biosFolder, flash);
if (bootrom) configFile.sys.files.bootrom_path = path.join(biosFolder, bootrom);
if (hardDrive) configFile.sys.files.hdd_path = path.join(biosFolder, hardDrive);
}
if (!ctx.dryRun)
{
const eepromPath = path.join(config.get('downloadPath'), "storage", this.emulator, 'eeprom.bin');
await Bun.write(eepromPath, await Bun.file(bin).arrayBuffer());
configFile.sys.files.eeprom_path = eepromPath;
await Bun.write(configPath, toml.stringify(configFile));
args.push("-config_path");
args.push(configPath);
}
return { args };
});
}
}

View file

@ -0,0 +1,16 @@
{
"name": "com.simeonradivoev.gameflow.xenia",
"displayName": "XENIA Integration",
"version": "0.0.1",
"description": "XENIA Emulator Integration",
"main": "./xenia.ts",
"icon": "https://xenia.jp/images/logo-256x256.png",
"category": "emulators",
"keywords": [
"integration",
"emulator",
"xbox360",
"xenia",
"xenia-edge"
]
}

View file

@ -0,0 +1,140 @@
import { join } from "path";
const SECTOR_SIZE = 0x800;
const MAGIC = "MICROSOFT*XBOX*MEDIA";
const PARTITION_OFFSETS: Record<string, number> = {
XSF: 0x0,
GDF: 0xFD90000,
XGD3: 0x2080000,
};
async function readBytes (file: ReturnType<typeof Bun.file>, offset: number, length: number): Promise<Buffer>
{
return Buffer.from(await file.slice(offset, offset + length).arrayBuffer());
}
async function parseTitleIdFromXexReader (
read: (offset: number, length: number) => Promise<Buffer>
): Promise<string>
{
// Read just the fixed header (magic + flags + offsets + header count)
const header = await read(0, 0x18);
if (header.toString("ascii", 0, 4) !== "XEX2")
{
throw new Error("Not a valid XEX2 file");
}
const headerCount = header.readUInt32BE(0x14);
const EXEC_INFO_KEY = 0x40006;
// Read the optional header table
const table = await read(0x18, headerCount * 8);
for (let i = 0; i < headerCount; i++)
{
const key = table.readUInt32BE(i * 8);
const valueOrOffset = table.readUInt32BE(i * 8 + 4);
if (key === EXEC_INFO_KEY)
{
// valueOrOffset is a file offset — read the exec info struct there
// TitleID is at +0x0C within it
const execInfo = await read(valueOrOffset, 0x18);
return execInfo.readUInt32BE(0x0C)
.toString(16).toUpperCase().padStart(8, "0");
}
}
throw new Error("Execution info header not found in XEX");
}
async function titleIdFromXexFile (xexPath: string): Promise<string>
{
const file = Bun.file(xexPath);
return parseTitleIdFromXexReader((offset, length) =>
readBytes(file, offset, length)
);
}
async function titleIdFromIso (isoPath: string): Promise<string>
{
const file = Bun.file(isoPath);
const fileSize = file.size;
for (const partitionOffset of Object.values(PARTITION_OFFSETS))
{
const vdOffset = partitionOffset + 0x20 * SECTOR_SIZE;
if (vdOffset + 28 > fileSize) continue;
const vd = await readBytes(file, vdOffset, 28);
if (vd.toString("ascii", 0, 20) !== MAGIC) continue;
const rootSector = vd.readUInt32LE(20);
const rootSize = vd.readUInt32LE(24);
const rootOffset = partitionOffset + rootSector * SECTOR_SIZE;
const dir = await readBytes(file, rootOffset, rootSize);
let pos = 0;
while (pos < dir.length)
{
if (dir[pos] === 0xFF) break;
if (pos + 14 > dir.length) break;
const nameLen = dir[pos + 13];
if (nameLen === 0 || nameLen === 0xFF) break;
if (pos + 14 + nameLen > dir.length) break;
const name = dir.toString("ascii", pos + 14, pos + 14 + nameLen);
const fileSector = dir.readUInt32LE(pos + 4);
if (name.toLowerCase() === "default.xex")
{
const xexBase = partitionOffset + fileSector * SECTOR_SIZE;
// Reader that translates relative XEX offsets to absolute ISO offsets
return parseTitleIdFromXexReader((offset, length) =>
readBytes(file, xexBase + offset, length)
);
}
const entryLen = 14 + nameLen;
pos += (entryLen + 3) & ~3;
}
}
throw new Error("Not a valid Xbox 360 ISO or default.xex not found");
}
async function titleIdFromFolder (folderPath: string): Promise<string>
{
return titleIdFromXexFile(join(folderPath, "default.xex"));
}
type XeniaRomType = "iso" | "xex" | "folder";
function detectRomType (romPath: string): XeniaRomType
{
const lower = romPath.toLowerCase();
if (lower.endsWith(".iso")) return "iso";
if (lower.endsWith(".xex")) return "xex";
return "folder"; // extracted game folder containing default.xex
}
async function getTitleId (romPath: string): Promise<string>
{
switch (detectRomType(romPath))
{
case "iso": return titleIdFromIso(romPath);
case "xex": return titleIdFromXexFile(romPath);
case "folder": return titleIdFromFolder(romPath);
}
}
export async function getXeniaSavePaths (
romPath: string,
xeniaDir: string
): Promise<string>
{
const titleId = await getTitleId(romPath);
return join(xeniaDir, titleId);
};

View file

@ -0,0 +1,102 @@
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import { GameflowHooks } from "@simeonradivoev/gameflow-sdk";
import { config } from "@/bun/api/app";
import path from "node:path";
import { ensureDir } from "fs-extra";
import toml, { TomlTable } from 'smol-toml';
import fs from 'node:fs/promises';
import { getXeniaSavePaths } from "./utils";
export default class XENIAIntegration implements PluginType
{
emulator = 'XENIA';
emulatorEdge = 'XENIA-EDGE';
async handlePostInstall (ctx: Parameters<typeof GameflowHooks.prototype.emulators.emulatorPostInstall.callAsync>['0'])
{
await Bun.write(path.join(ctx.path, "portable.txt"), "");
}
async handleLaunch (ctx: Parameters<typeof GameflowHooks.prototype.games.emulatorLaunch.callAsync>['0']):
ReturnType<typeof GameflowHooks.prototype.games.emulatorLaunch.promise>
{
const args: string[] = [];
if (ctx.autoValidCommand.metadata.romPath)
{
args.push(ctx.autoValidCommand.metadata.romPath);
}
const configPath = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, `${ctx.autoValidCommand.emulator}.toml`);
args.push(`--config`, configPath);
if (config.get('launchInFullscreen'))
{
args.push(`--fullscreen`);
}
if (!ctx.dryRun)
{
await ensureDir(path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!));
let configFile: TomlTable & { Storage: TomlTable, GPU: TomlTable, Display: TomlTable; } = { Storage: {}, GPU: {}, Display: {} };
if (await fs.exists(configPath))
{
configFile = toml.parse(await Bun.file(configPath).text()) as any;
}
const resolutionMapping = {
"720p": 1,
"1080p": 2,
"1440p": 3,
"4k": 3
};
configFile.Display.fullscreen = config.get('launchInFullscreen');
configFile.GPU.draw_resolution_scale_x = resolutionMapping[config.get('emulatorResolution')] ?? 1;
configFile.GPU.draw_resolution_scale_y = resolutionMapping[config.get('emulatorResolution')] ?? 1;
const savesPath = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!);
await ensureDir(savesPath);
configFile.Storage.content_root = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!);
configFile.Storage.storage_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'config');
configFile.Storage.cache_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'cache');
await Bun.write(configPath, toml.stringify(configFile));
let finalSavesPath: string | undefined = undefined;
if (ctx.autoValidCommand.metadata.romPath)
{
finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath);
return { args, savesPath: { [this.emulator]: { cwd: finalSavesPath } } };
}
return { args };
};
return { args };
}
handleEmulatorLaunchSupport (ctx: Parameters<typeof GameflowHooks.prototype.games.emulatorLaunchSupport.callAsync>['0']):
ReturnType<typeof GameflowHooks.prototype.games.emulatorLaunchSupport.call>
{
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves"] };
}
async load (ctx: PluginLoadingContextType)
{
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, this.handleEmulatorLaunchSupport);
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulatorEdge }, this.handleEmulatorLaunchSupport);
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, this.handleLaunch);
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulatorEdge }, this.handleLaunch);
ctx.hooks.games.postPlay.tap({ name: desc.name }, async ({ validChangedSaveFiles, saveFolderSlots, command }) =>
{
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
const files = await fs.readdir(saveFolderSlots[this.emulator].cwd, { recursive: true });
validChangedSaveFiles.xenia = { cwd: saveFolderSlots[this.emulator].cwd, subPath: files, shared: false };
});
}
}

View file

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

View file

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

View file

@ -0,0 +1,13 @@
{
"name": "com.simeonradivoev.gameflow.rclone",
"displayName": "Rclone Integration",
"version": "0.0.1",
"description": "Rclone integration for syncing saves",
"main": "./rclone.ts",
"icon": "data:image/svg+xml,%3Csvg%20role%3D%22img%22%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3ERclone%3C%2Ftitle%3E%3Cpath%20d%3D%22M11.842.6258C9.3647.6813%206.9754%201.9906%205.646%204.2933c-.7593%201.3144-1.0647%202.7662-.966%204.1745a7.99%207.99%200%200%201%202.6568-.4541l1.4705-.0013c-.0093-.5594.1245-1.1284.4245-1.6482.8827-1.5284%202.837-2.0522%204.3654-1.1695%201.5284.8824%202.0519%202.8366%201.1695%204.365l-1.4782%202.5647%201.1955%202.0714%202.3914-.0004%201.4775-2.5655c2.0262-3.5088.8239-7.9959-2.6853-10.0217C14.4614.9118%2013.1396.5967%2011.842.6258m-1.5451%208.073-2.9605.0029C3.2844%208.7017%200%2011.9867%200%2016.0383c0%204.052%203.2844%207.3367%207.3364%207.3367%201.5174%200%202.9267-.4609%204.0967-1.2497a8%208%200%200%201-1.72-2.0748l-.7368-1.273c-.4799.288-1.0392.4565-1.6395.4565-1.765%200-3.1958-1.4307-3.1958-3.1958%200-1.7647%201.4307-3.1954%203.1958-3.1954l2.96-.0022%201.1962-2.0708zm9.587.7475a7.99%207.99%200%200%201-.935%202.5278l-.7344%201.2745c.4892.2717.915.6719%201.2153%201.192.8823%201.528.3585%203.4826-1.1699%204.365-1.528.8823-3.4828.3588-4.3651-1.1696l-1.482-2.5628h-2.3915L8.8256%2017.144l1.483%202.5626c2.0262%203.5091%206.513%204.7112%2010.022%202.685%203.5089-2.0257%204.7112-6.5125%202.6853-10.0216-.7588-1.3144-1.863-2.3052-3.132-2.9237%22%20%2F%3E%3C%2Fsvg%3E",
"category": "saves",
"keywords": [
"integration",
"rclone"
]
}

View file

@ -0,0 +1,473 @@
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import { config, db, events } from "@/bun/api/app";
import path from 'node:path';
import unzip from 'unzip-stream';
import { ensureDir } from "fs-extra";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import fs from 'node:fs/promises';
import { randomUUIDv7 } from "bun";
import z from "zod";
import { createInterface } from "node:readline";
import { getLocalGameMatch } from "@/bun/api/games/services/utils";
import { getErrorMessage } from "@/bun/utils";
const DefaultLocalName = "Default_Local";
const SettingsSchema = z.object({
runWebGui: z.boolean()
.default(false)
.describe("Run the Web GUI that can be accessed at http://localhost:5572")
.meta({ title: "Run Web GUI" }),
globalConfig: z.boolean().default(false).describe("Use the Global Config file if already setup"),
webGuiPassword: z.string().optional().readonly().describe("Randomly Generated. Read Only. Username is gameflow"),
remoteName: z.string().default(DefaultLocalName),
verboseLog: z.boolean()
.default(false)
.describe("Show detailed log of operation for debugging")
.meta({ $comment: JSON.stringify({ category: "debug" }) }),
importSaves: z.boolean().default(true).describe("Import Saves From the Destination. This will override local saves"),
exportSaves: z.boolean().default(true).describe("Export saves to remove. This will sync current saves with remote")
});
type SettingsType = z.infer<typeof SettingsSchema>;
const loginTokenUrlRegex = /http:\/\/[\w\d:\-@\[\]\.\/?=]+/gm;
export default class RcloneIntegration implements PluginType<SettingsType>
{
settingsSchema = SettingsSchema;
rclonePath: string | undefined;
server: Bun.Subprocess | undefined;
password: string;
user = "gameflow";
loginUrl: string | undefined = undefined;
eventsNames = [{
id: "open-web-gui",
title: "Open Web GUI",
description: "Open Web GUI",
action: "Open"
}, {
id: "refresh",
title: "Refresh Sources",
action: "Refresh"
}];
constructor()
{
this.password = randomUUIDv7();
}
async onEvent (id: string)
{
switch (id)
{
case "open-web-gui":
return { openTab: this.loginUrl };
break;
case "refresh":
await this.refresh();
return { reload: true };
break;
}
}
async setup (ctx: PluginLoadingContextType<SettingsType>)
{
ctx.zodRegistry.add(SettingsSchema.shape.runWebGui, { requiresRestart: true });
ctx.zodRegistry.add(SettingsSchema.shape.globalConfig, { requiresRestart: true });
const toolsPath = path.join(config.get('downloadPath'), "tools");
await ensureDir(toolsPath);
const binaryMap: Record<string, string> = {
win32: '**/rclone.exe',
linux: 'rclone-*/rclone',
darwin: 'rclone-*/rclone'
};
const existingRclones = await Array.fromAsync(fs.glob(binaryMap[process.platform], { cwd: toolsPath }));
if (existingRclones[0])
{
this.rclonePath = path.join(toolsPath, existingRclones[0]);
await this.startServer(ctx);
return;
}
ctx.setProgress(0.5, "Downloading RClone");
const platformMap: Record<string, string> = {
linux: "linux",
win32: "windows",
darwin: "osx"
};
const archMap: Record<string, string> = {
x64: "amd64",
arm64: "arm64"
};
const downloadUrl = `https://downloads.rclone.org/rclone-current-${platformMap[process.platform]}-${archMap[process.arch]}.zip`;
console.log("Starting Download", downloadUrl);
const rcCloseZip = await fetch(downloadUrl);
await ensureDir(toolsPath);
await pipeline(Readable.fromWeb(rcCloseZip.body as any), unzip.Extract({ path: toolsPath }));
const dests = await Array.fromAsync(fs.glob(binaryMap[process.platform], { cwd: toolsPath }));
if (dests[0])
{
this.rclonePath = path.join(toolsPath, dests[0]);
await fs.chmod(this.rclonePath, 0o755);
await this.startServer(ctx);
return;
}
}
async refresh ()
{
try
{
const data = await this.request('/config/listremotes', {});
z.globalRegistry.add(SettingsSchema.shape.remoteName, {
examples: [''].concat(...data.remotes),
description: "The name of the remote to sync with"
});
} catch (error)
{
events.emit('notification', { message: getErrorMessage(error), type: 'error' });
z.globalRegistry.add(SettingsSchema.shape.remoteName, {
examples: [''],
description: "The name of the remote to sync with"
});
}
}
async startServer (ctx: PluginLoadingContextType<SettingsType>)
{
const args: string[] = [];
if (ctx.config.get('runWebGui'))
{
args.push("--rc-web-gui");
args.push("--rc-web-gui-no-open-browser");
}
if (ctx.config.get(''))
{
args.push('-vv');
}
let env: Record<string, string> | undefined = undefined;
if (!ctx.config.get('globalConfig'))
{
env = { RCLONE_CONFIG: path.join(config.get('downloadPath'), 'tools', 'config', 'rclone', 'rclone.conf') };
}
ctx.config.set('webGuiPassword', this.password);
this.server = Bun.spawn([this.rclonePath!, "rcd", '--use-json-log', `--rc-user=${this.user}`, ...args, `--rc-pass=${this.password}`, "--rc-addr", "localhost:5572"], {
stdout: "pipe",
stderr: "pipe",
env
});
const rl = createInterface({ input: Readable.fromWeb(this.server.stderr as any) });
rl.on('line', e =>
{
try
{
const data = JSON.parse(e);
if (data.level === 'error')
{
console.error(data.msg);
} else if (data.level === 'critical')
{
console.error(data.msg);
}
else
{
console.log(e);
if (loginTokenUrlRegex.test(data.msg))
{
this.loginUrl = (data.msg as string).match(loginTokenUrlRegex)?.find(e => e);
}
}
} catch (error)
{
console.log(e);
}
});
await new Promise((resolve, reject) =>
{
const handleResolve = (line: string) =>
{
try
{
const data = JSON.parse(line);
if (!loginTokenUrlRegex.test(data.msg)) return;
rl.off('line', handleResolve);
resolve(data);
} catch (error)
{
}
};
rl.on('line', handleResolve);
setTimeout(() => { reject("Timeout"); }, 5000);
});
await this.refresh();
}
async request (path: string, body: any)
{
const response = await fetch(`http://localhost:5572${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${Buffer.from(`${this.user}:${this.password}`).toString('base64')}`
},
body: JSON.stringify(body)
});
const data = await response.json();
if (response.ok)
{
return data;
} else
{
throw new Error(response.statusText, { cause: data });
}
}
async cleanup ()
{
await new Promise((resolve) =>
{
this.request('/core/quit', {}).catch(e =>
{
this.server?.kill("SIGKILL");
this.server = undefined;
});
setTimeout(() =>
{
this.request('/core/quit', { exitCode: 9 }).then(e =>
{
resolve(false);
this.server = undefined;
}).catch(e =>
{
resolve(false);
this.server?.kill("SIGKILL");
this.server = undefined;
});
}, 5000);
this.server?.exited.then(() => resolve(true));
});
}
async load (ctx: PluginLoadingContextType<SettingsType>)
{
await this.setup(ctx);
ctx.hooks.games.prePlay.tapPromise({
name: desc.name,
stage: 10,
}, async ({ source, id, setProgress, saveFolderSlots, command }) =>
{
if (!this.rclonePath || !saveFolderSlots || !ctx.config.get('importSaves')) return;
const destination = source === 'store' ? [source, id] : command.emulator ? [command.emulator] : undefined;
if (!destination) return;
const remoteName = ctx.config.get('remoteName');
for await (const [slot, { cwd }] of Object.entries(saveFolderSlots))
{
let supportsMetadata = true;
let src: string;
if (remoteName && remoteName !== DefaultLocalName)
{
src = `${remoteName}:gameflow/saves/${destination.join('/')}/${slot}`;
const exists = await this.request('/operations/stat', {
fs: `${remoteName}:`,
remote: `gameflow/saves/${destination.join('/')}/${slot}`
}).catch(e => undefined);
if (!exists || !exists.item) return;
const remote = await this.request('/operations/fsinfo', {
fs: `${remoteName}:`
});
supportsMetadata = !remote.ReadMetadata;
if (supportsMetadata)
{
console.warn("Remote", remoteName, "does not support metadata");
}
} else
{
src = path.join(config.get('downloadPath'), 'saves', ...destination, slot);
if (!await fs.exists(path.join(config.get('downloadPath'), 'saves', ...destination, slot))) return;
}
const job = await this.request('/sync/copy', {
srcFs: src,
dstFs: cwd,
createEmptySrcDirs: true,
_async: true,
_config: {
CheckFirst: true,
Metadata: true,
NoCheckDest: supportsMetadata
}
}).catch(e =>
{
events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' });
return undefined;
});;
await new Promise(async (resolve, reject) =>
{
setProgress(0, "RClone: Syncing Saves");
const checkInterval = setInterval(async () =>
{
const stat = await this.request('/job/status', { jobid: job.jobid });
if (stat.finished)
{
clearInterval(checkInterval);
console.log(stat.output);
resolve(true);
} else if (stat.error)
{
reject(stat.error);
} else
{
setProgress(stat.progress, "RClone: Syncing Saves");
}
}, 500);
});
}
});
ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) =>
{
if (!this.rclonePath || !ctx.config.get('exportSaves')) return;
const local = await db.query.games.findFirst({ where: getLocalGameMatch(id, source) });
console.log("Save Files", Object.values(validChangedSaveFiles).flatMap(c => Array.isArray(c.subPath) ? c.subPath : [c.subPath]).join(","));
const destination = source === 'store' ? [source, id] : command.emulator ? [command.emulator] : undefined;
if (!destination) return;
const remoteName = ctx.config.get('remoteName');
await Promise.all(Object.entries(validChangedSaveFiles).map(async ([slot, change]) =>
{
let suportsMetadata = false;
let dest: string;
if (remoteName && remoteName !== DefaultLocalName)
{
dest = `${remoteName}:gameflow/saves/${destination.join('/')}/${slot}`;
const remote = await this.request('/operations/fsinfo', {
fs: `${remoteName}:`
});
suportsMetadata = !remote.ReadMetadata;
if (suportsMetadata)
{
console.warn("Remote", remoteName, "does not support metadata");
}
} else
{
dest = path.join(config.get('downloadPath'), 'saves', ...destination, slot);
}
const filter = {
IncludeRule: Array.isArray(change.subPath) ?
change.subPath.map(s =>
{
if (change.isGlob) return s;
else s.replaceAll('\\', '/');
}) :
[change.isGlob ? change.subPath : change.subPath.replaceAll('\\', '/')]
};
let jobid: number | undefined = undefined;
if (change.fixedSize)
{
await this.request('/sync/copy', {
srcFs: change.cwd,
dstFs: dest,
createEmptySrcDirs: true,
_async: true,
_config: {
NoCheckDest: true
},
_filter: filter
})
.then(job => jobid = job.jobid)
.catch(e =>
{
events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' });
return undefined;
});
} else
{
await this.request('/sync/sync', {
srcFs: change.cwd,
dstFs: dest,
createEmptySrcDirs: true,
_async: true,
_config: {
CheckSum: true,
CheckFirst: true,
Metadata: true,
MetadataSet: {
igdb_id: local?.igdb_id ? String(local?.igdb_id) : undefined,
ra_id: local?.ra_id ? String(local?.ra_id) : undefined
}
},
_filter: filter
})
.then(job => jobid = job.jobid)
.catch(e =>
{
events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' });
return undefined;
});
}
if (!jobid) return;
await new Promise(async (resolve, reject) =>
{
const checkInterval = setInterval(async () =>
{
const stat = await this.request('/job/status', { jobid });
if (stat.finished)
{
clearInterval(checkInterval);
console.log(stat.output);
resolve(true);
} else if (stat.error)
{
reject(stat.error);
} else
{
}
}, 500);
});
const stats = await this.request('/core/stats', {
group: `job/${jobid}`
});
if (stats.transfers > 0)
{
events.emit('notification', { message: "RClone: Save Synced", type: 'success', icon: 'save' });
}
}));
});
}
}

View file

@ -0,0 +1,125 @@
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import secrets from "@/bun/api/secrets";
import PQueue from 'p-queue';
import * as igdb from '@phalcode/ts-igdb-client';
import { checkLoginAndRefreshTwitch } from "@/bun/api/auth";
import { GameLookup } from "@simeonradivoev/gameflow-sdk/shared";
export default class IgdbIntegration implements PluginType
{
queue: PQueue;
constructor()
{
this.queue = new PQueue({ concurrency: 8, interval: 1000, intervalCap: 4, strict: true });
}
async apiCall<T> (subPath: string, query: string)
{
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
const headers = new Headers({
"Client-ID": process.env.TWITCH_CLIENT_ID ?? '',
Authorization: `Bearer ${access_token}`,
Accept: "application/json"
});
const response = await this.queue.add(() => fetch(`https://api.igdb.com/v4${subPath}`, {
headers: headers,
method: "POST",
body: query
}));
if (response.ok)
{
return response.json() as T;
}
}
async cleanup ()
{
this.queue.clear();
}
async load (ctx: PluginLoadingContextType)
{
await checkLoginAndRefreshTwitch();
ctx.hooks.games.gameLookup.tapPromise(desc.name, async (matches, { source, id, search }) =>
{
if (!process.env.TWITCH_CLIENT_ID) return matches;
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
if (!access_token)
{
return matches;
}
if ((source === 'igdb' && id) || search)
{
const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token);
const { data: games } = await this.queue.add(() => client.request('games')
.pipe(...(search ? [igdb.search(search)] : []),
igdb.fields(['id', 'name', 'summary', 'screenshots.image_id', 'slug', 'first_release_date', 'rating', 'genres.name', 'involved_companies.company.name', 'keywords.name', 'game_modes.name', 'cover.image_id', 'age_ratings.rating_category.rating', 'platforms.name', 'platforms.abbreviation', 'platforms.slug']),
...(source === 'igdb' && id ? [igdb.where('id', '=', Number(id))] : []),
igdb.limit(10)).execute());
matches.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 }) =>
{
let query: string | undefined = undefined;
if (source && id)
{
if (source !== 'igdb') return;
query = `fields name, slug, platform_logo.image_id, platform_logo.url, platform_family.name; where id = ${id};`;
}
else if (slug)
{
query = `fields name, slug, platform_logo.image_id, platform_logo.url, platform_family.name; where slug = "${slug}";`;
}
if (query)
{
const data = await this.apiCall<[any]>('/platforms', query);
if (!data || data.length <= 0) return;
return {
slug: data[0].slug,
url_logo: `https://images.igdb.com/igdb/image/upload/t_logo_med/${data[0].platform_logo.image_id}.png`,
name: data[0].name,
family_name: data[0].platform_family?.name
};
}
});
}
}

View file

@ -0,0 +1,13 @@
{
"name": "com.simeonradivoev.gameflow.igdb",
"displayName": "IGDB Integration",
"version": "0.0.1",
"description": "IGDB Metadata Integration",
"main": "./igdb.ts",
"icon": "data:image/svg+xml,%3Csvg%20role%3D%22img%22%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EIGDB%3C%2Ftitle%3E%3Cpath%20d%3D%22M24%206.228c-8%20.002-16%200-24%200v11.543a88.875%2088.875%200%200%201%202.271-.333%2074.051%2074.051%200%200%201%2017.038-.28c1.57.153%203.134.363%204.69.614V6.228zm-.706.707v10.013a74.747%2074.747%200%200%200-22.588%200V6.934h22.588ZM7.729%208.84a2.624%202.624%200%200%200-1.857.72%202.55%202.55%200%200%200-.73%201.33c-.098.5-.063%201.03.112%201.51.177.488.515.917.954%201.196.547.354%201.224.472%201.865.401a3.242%203.242%200%200%200%201.786-.777c-.003-.724.002-1.449-.002-2.173-.725.004-1.45-.002-2.174.003.003.317%200%20.634.001.951h1.105c.002.236%200%20.473.002.71-.268.196-.603.286-.932.298-.32.02-.65-.05-.922-.225a1.464%201.464%200%200%201-.59-.744c-.18-.499-.134-1.085.163-1.53.23-.355.619-.61%201.043-.647a1.8%201.8%200%200%201%201.012.206c.152.082.286.192.424.295.228-.281.461-.559.692-.838a3.033%203.033%200%200%200-.595-.403c-.418-.212-.892-.285-1.357-.283Zm11.66.086c-.093%200-.187.002-.28%200-.68.002-1.359-.004-2.038.003.003%201.666%200%203.332.002%204.998h2.497c.239-.002.478-.034.709-.097.276-.076.546-.208.742-.422.194-.208.297-.492.304-.776.016-.278-.032-.572-.195-.804-.175-.252-.453-.408-.734-.514.211-.122.407-.285.521-.505.134-.246.149-.535.117-.807a1.156%201.156%200%200%200-.436-.73c-.264-.207-.599-.304-.93-.334a2.757%202.757%200%200%200-.279-.012Zm-16.715%200v5.002h1.102V8.927c-.368-.002-.735%200-1.102%200zm8.524%200v5.002h2.016a2.87%202.87%200%200%200%201.07-.211%202.445%202.445%200%200%200%201.174-.993c.34-.555.429-1.244.292-1.876a2.367%202.367%200%200%200-.828-1.338c-.478-.387-1.096-.577-1.707-.584h-2.017zm6.949.967c.392.002.784-.001%201.176.002.183.011.38.054.51.19.11.112.136.28.112.43a.436.436%200%200%201-.22.316%201.082%201.082%200%200%201-.483.116c-.365.002-.73-.001-1.094.001-.002-.351%200-.703-.001-1.054zm-5.031.026c.28%200%20.567.053.815.19.274.149.491.396.607.685.113.272.138.574.107.865a1.456%201.456%200%200%201-.335.786%201.425%201.425%200%200%201-.865.466c-.168.031-.34.022-.51.023h-.632V9.92h.813zm5.03%201.948h1.36c.174.006.354.035.505.127.11.066.191.18.212.308.025.15.004.32-.099.44-.102.12-.258.176-.409.2-.172.032-.348.02-.522.022-.35-.001-.698.002-1.047-.001v-1.096z%22%20%2F%3E%3C%2Fsvg%3E",
"category": "sources",
"keywords": [
"integration",
"igdb"
]
}

View file

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

View file

@ -1,41 +1,74 @@
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json'; import desc from './package.json';
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomsApiRomsGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, PlatformSchema, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
import { config } 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, isSteamDeckGameMode } from "@/bun/utils"; import { hashFile, isArchive, 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";
import { client } from "@/clients/romm/client.gen"; import { client } from "@/clients/romm/client.gen";
import { validateGameSource } from "@/bun/api/games/services/statusService";
import z from "zod";
import { checkLoginAndRefreshRomm } from "@/bun/api/auth";
import { DownloadFileEntry, DownloadInfo, FrontEndCollection, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, FrontEndGameTypeWithIds, FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared";
import Conf from "conf";
export default class RommIntegration implements PluginType const SettingsSchema = z.object({
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>;
export default class RommIntegration implements PluginType<SettingsType>
{ {
settingsSchema = SettingsSchema;
isSteamDeck = false; isSteamDeck = false;
orderByMap: Record<string, string> = {
added: "created_at",
activity: "created_at",
name: "name",
release: "metadatum.first_release_date"
};
async updateClient () async checkRemote ()
{
if (!config.has('rommAddress')) return false;
return true;
}
async getAccessToken (config: Conf<SettingsType>)
{
if (process.env.ROMM_CLIENT_TOKEN) return process.env.ROMM_CLIENT_TOKEN;
const client_token = await 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'),
async auth (auth) auth: (auth) =>
{ {
if (auth.scheme === 'bearer') if (auth.scheme === 'bearer')
{ {
return (await secrets.get({ service: 'gameflow', name: 'romm_access_token' })) ?? undefined; return this.getAccessToken(pluginConfig);
} }
} }
}); });
} }
async getAuthToken () async getAuthToken (config: Conf<SettingsType>)
{ {
return getAuthToken({ return getAuthToken({
scheme: 'bearer', scheme: 'bearer',
type: "http" type: "http"
}, async (a) => (await secrets.get({ service: "gameflow", name: 'romm_access_token' })) ?? undefined); }, async (a) => this.getAccessToken(config));
} }
async getAllRommPlatforms () async getAllRommPlatforms ()
@ -47,9 +80,12 @@ export default class RommIntegration implements PluginType
{ {
const game: FrontEndGameType = { const game: FrontEndGameType = {
id: { id: String(rom.id), source: 'romm' }, id: { id: String(rom.id), source: 'romm' },
path_cover: `/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`, path_covers: [`/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`],
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null, last_played: rom.rom_user.last_played !== null ? new Date(rom.rom_user.last_played) : null,
updated_at: new Date(rom.created_at), updated_at: new Date(rom.created_at),
metadata: {
first_release_date: rom.metadatum.first_release_date !== null ? new Date(rom.metadatum.first_release_date) : null,
},
slug: rom.slug, slug: rom.slug,
platform_id: rom.platform_id, platform_id: rom.platform_id,
platform_display_name: rom.platform_display_name, platform_display_name: rom.platform_display_name,
@ -73,9 +109,17 @@ export default class RommIntegration implements PluginType
fs_size_bytes: rom.fs_size_bytes, fs_size_bytes: rom.fs_size_bytes,
local: false, local: false,
missing: rom.missing_from_fs, missing: rom.missing_from_fs,
genres: rom.metadatum.genres, igdb_id: rom.igdb_id,
companies: rom.metadatum.companies, ra_id: rom.ra_id,
release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined metadata: {
age_ratings: rom.metadatum.age_ratings,
genres: rom.metadatum.genres,
companies: rom.metadatum.companies,
game_modes: rom.metadatum.game_modes,
player_count: rom.metadatum.player_count,
average_rating: rom.metadatum.average_rating,
first_release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : null
}
}; };
const userData = await getCurrentUserApiUsersMeGet(); const userData = await getCurrentUserApiUsersMeGet();
@ -108,62 +152,75 @@ export default class RommIntegration implements PluginType
return detailed; return detailed;
} }
async setup () async load (ctx: PluginLoadingContextType<SettingsType>)
{ {
this.isSteamDeck = isSteamDeckGameMode(); this.isSteamDeck = isSteamDeckGameMode();
await this.updateClient(); ctx.setProgress(0, "Logging Into Romm");
} await this.updateClient(ctx.config);
await checkLoginAndRefreshRomm();
await this.updateClient(ctx.config);
load (ctx: PluginContextType)
{
ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) => ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) =>
{ {
if (!await this.checkRemote()) return;
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm')) if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
{ {
const orderByMap: Record<string, string> = {
added: "created_at",
activity: "created_at",
name: "name"
};
const rommGames = await getRomsApiRomsGet({ const rommGames = await getRomsApiRomsGet({
query: { query: {
platform_ids: query.platform_id ? [query.platform_id] : undefined, platform_ids: query.platform_id ? [query.platform_id] : undefined,
collection_id: query.collection_id, collection_id: query.collection_id,
limit: query.limit, limit: query.limit,
offset: query.offset, offset: query.offset,
order_by: orderByMap[query.orderBy ?? ''] order_by: this.orderByMap[query.orderBy ?? ''],
with_filter_values: false,
genres: query.genres,
genres_logic: "all",
age_ratings: query.age_ratings,
search_term: query.search,
}, throwOnError: true }, throwOnError: true
}); });
games.push(...rommGames.data.items.map(g => games.push(...rommGames.data.items.map(g =>
{ {
return this.convertRomToFrontend(g); const game: FrontEndGameTypeWithIds = {
...this.convertRomToFrontend(g),
igdb_id: g.igdb_id,
ra_id: g.ra_id
};
return game;
})); }));
} }
}); });
ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) => ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) =>
{ {
if (service !== 'romm') return; if (!await this.checkRemote()) return;
await this.updateClient(); if (source && source !== 'romm') return;
const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true });
rommFilters.data.age_ratings.forEach(r => filters.age_ratings.add(r));
rommFilters.data.companies.forEach(r => filters.companies.add(r));
rommFilters.data.languages.forEach(r => filters.languages.add(r));
rommFilters.data.player_counts.forEach(r => filters.player_counts.add(r));
rommFilters.data.genres.forEach(r => filters.genres.add(r));
}); });
ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id, localGame }) => ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) =>
{ {
if (!await this.checkRemote()) return;
if (service !== 'romm') return;
await this.updateClient(ctx.config);
});
ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) =>
{
if (!await this.checkRemote()) return;
if (source !== 'romm') return; if (source !== 'romm') return;
const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } }); const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } });
if (rom.data) if (rom.data)
{ {
const romGame = await this.convertRomToFrontendDetailed(rom.data); const romGame = await this.convertRomToFrontendDetailed(rom.data);
if (localGame)
{
return {
...romGame,
...localGame,
};
}
return romGame; return romGame;
} }
@ -172,6 +229,7 @@ export default class RommIntegration implements PluginType
ctx.hooks.games.fetchDownloads.tapPromise(desc.name, async ({ source, id }) => ctx.hooks.games.fetchDownloads.tapPromise(desc.name, async ({ source, id }) =>
{ {
if (!await this.checkRemote()) return;
if (source !== 'romm') return; if (source !== 'romm') return;
const rom = (await getRomApiRomsIdGet({ path: { id: Number(id) }, throwOnError: true })).data; const rom = (await getRomApiRomsIdGet({ path: { id: Number(id) }, throwOnError: true })).data;
@ -181,8 +239,9 @@ export default class RommIntegration implements PluginType
const files = await Promise.all(rom.files.map(async f => const files = await Promise.all(rom.files.map(async f =>
{ {
getRomContentApiRomsIdContentFileNameGet;
const file: DownloadFileEntry = { const file: DownloadFileEntry = {
url: new URL(`${config.get('rommAddress')}/api/romsfiles/${f.id}/content/${f.file_name}`), url: new URL(`${config.get('rommAddress')}/api/roms/${f.id}/files/content/${f.file_name}`),
file_name: f.file_name, file_name: f.file_name,
file_path: f.file_path, file_path: f.file_path,
size: f.file_size_bytes, size: f.file_size_bytes,
@ -191,8 +250,21 @@ export default class RommIntegration implements PluginType
return file; return file;
})); }));
let extract_path: string | undefined = undefined;
let path_fs = path.join(rom.fs_path, rom.fs_name);
if (files.length === 1)
{
if (isArchive(files[0].file_name))
{
extract_path = '.';
path_fs = path.join(rom.fs_path, rom.slug ?? rom.fs_name_no_ext);
}
}
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
@ -204,21 +276,24 @@ export default class RommIntegration implements PluginType
ra_id: rom.ra_id ?? undefined, ra_id: rom.ra_id ?? undefined,
summary: rom.summary ?? undefined, summary: rom.summary ?? undefined,
name: rom.name ?? "Unknown", name: rom.name ?? "Unknown",
path_fs: path.join(rom.fs_path, rom.fs_name), path_fs,
source_id: String(rom.id), source_id: String(rom.id),
slug: rom.slug ?? undefined, slug: rom.slug ?? undefined,
system_slug: rommPlatform.slug, system_slug: rommPlatform.slug,
metadata: rom.metadatum, metadata: rom.metadatum,
files, files,
auth: await this.getAuthToken() auth: await this.getAuthToken(ctx.config),
extract_path,
id: "romm"
}; };
return info; return [info];
}); });
ctx.hooks.emulators.fetchBiosDownload.tapPromise(desc.name, async ({ systems, biosFolder }) => ctx.hooks.emulators.fetchBiosDownload.tapPromise(desc.name, async ({ systems, biosFolder }) =>
{ {
if (!await this.checkRemote()) return;
const files: DownloadFileEntry[] = []; const files: DownloadFileEntry[] = [];
const allRommPlatforms = await this.getAllRommPlatforms(); const allRommPlatforms = await this.getAllRommPlatforms();
@ -244,21 +319,22 @@ export default class RommIntegration implements PluginType
} }
} }
if (files.length > 0) return { files, auth: await this.getAuthToken() }; if (files.length > 0) return { files, auth: await this.getAuthToken(ctx.config) };
}); });
ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) => ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) =>
{ {
if (!await this.checkRemote()) return;
const rommPlatforms = await this.getAllRommPlatforms(); const rommPlatforms = await this.getAllRommPlatforms();
if (rommPlatforms) if (rommPlatforms)
{ {
const rommPlatform = rommPlatforms.find(p => p.slug === game.platform_slug); const rommPlatform = rommPlatforms.find(p => p.slug === game.platform_slug);
if (rommPlatform) if (rommPlatform)
{ {
const rommGames = await getRomsApiRomsGet({ query: { genres: game.genres, genres_logic: 'any' } }); const rommGames = await getRomsApiRomsGet({ query: { genres: game.metadata.genres, genres_logic: 'any' } });
if (rommGames.data) if (rommGames.data)
{ {
games.push(...rommGames.data.items.map(g => ({ ...this.convertRomToFrontend(g), metadata: g.metadatum }))); games.push(...rommGames.data.items.map(g => ({ ...this.convertRomToFrontend(g) })));
} }
} }
} }
@ -266,7 +342,7 @@ export default class RommIntegration implements PluginType
ctx.hooks.games.fetchRecommendedGamesForEmulator.tapPromise(desc.name, async ({ emulator, games, systems }) => ctx.hooks.games.fetchRecommendedGamesForEmulator.tapPromise(desc.name, async ({ emulator, games, systems }) =>
{ {
if (!await this.checkRemote()) return;
const rommPlatforms = await this.getAllRommPlatforms(); const rommPlatforms = await this.getAllRommPlatforms();
const systemsRommSlugSet = new Set(systems.filter(s => s.romm_slug).map(s => s.romm_slug!)); const systemsRommSlugSet = new Set(systems.filter(s => s.romm_slug).map(s => s.romm_slug!));
if (rommPlatforms) if (rommPlatforms)
@ -296,6 +372,7 @@ export default class RommIntegration implements PluginType
ctx.hooks.games.fetchPlatform.tapPromise(desc.name, async ({ source, id }) => ctx.hooks.games.fetchPlatform.tapPromise(desc.name, async ({ source, id }) =>
{ {
if (!await this.checkRemote()) return;
if (source !== 'romm') return; if (source !== 'romm') return;
const { data: rommPlatform } = await getPlatformApiPlatformsIdGet({ path: { id: Number(id) } }); const { data: rommPlatform } = await getPlatformApiPlatformsIdGet({ path: { id: Number(id) } });
if (rommPlatform) if (rommPlatform)
@ -318,7 +395,13 @@ export default class RommIntegration implements PluginType
ctx.hooks.games.fetchPlatforms.tapPromise(desc.name, async ({ platforms }) => ctx.hooks.games.fetchPlatforms.tapPromise(desc.name, async ({ platforms }) =>
{ {
const rommPlatforms = await this.getAllRommPlatforms(); if (!await this.checkRemote()) return;
const rommPlatforms = await this.getAllRommPlatforms().catch(e =>
{
console.error(e);
return undefined;
});
if (rommPlatforms) if (rommPlatforms)
{ {
const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p => const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p =>
@ -352,16 +435,139 @@ export default class RommIntegration implements PluginType
} }
}); });
ctx.hooks.games.updatePlayed.tapPromise(desc.name, async ({ source, id }) => ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderSlots, setProgress }) =>
{ {
if (source !== 'romm') return false; if (!await this.checkRemote()) return;
if (source !== 'romm' || !ctx.config.get('savesSync')) return;
if (!saveFolderSlots) return;
for await (const [slot, { cwd }] of Object.entries(saveFolderSlots))
{
setProgress(0, "saves");
const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } });
if (saveFiles.error)
{
console.error(saveFiles.error);
} else
{
const rommSlot = saveFiles.data.slots.find(s => s.slot === 'gameflow' && s.latest.file_name_no_tags === slot);
if (rommSlot)
{
const auth = await this.getAuthToken(ctx.config);
const headers: Record<string, string> = {};
if (auth)
headers['Authorization'] = auth;
const saveResponse = await fetch(`${config.get('rommAddress')}${rommSlot.latest.download_path}`, { headers });
if (!saveResponse.ok)
{
console.error("Error downloading save", saveResponse.statusText);
return;
}
const saveArchive = new Bun.Archive(await saveResponse.blob());
setProgress(50, "saves");
const count = await saveArchive.extract(cwd);
setProgress(100, "saves");
console.log("Loaded", count, "save files");
}
}
setProgress(100, "saves");
await Bun.sleep(1000);
}
});
// Should run after emulators decide on saves
ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) =>
{
if (!await this.checkRemote()) return;
if (source !== 'romm' || !ctx.config.get('savesSync')) return;
const sourceValidation = await validateGameSource(source, id);
if (!sourceValidation.valid)
{
console.warn("Invalid Source", sourceValidation.reason, "Skipping updates");
return;
}
/*const finalSavePaths = validChangedSaveFiles.filter(f => !f.shared && !f.isGlob).flatMap(s => Array.isArray(s.subPath) ? s.subPath.map(p => ({ cwd: s.cwd, subPath: p })) : [{ cwd: s.cwd, subPath: s.subPath }]);
const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } });
if (saveFiles.error)
{
console.error(saveFiles.error);
} else if (saveFolderPath)
{
for (let i = 0; i < saveFiles.data.slots.length; i++)
{
const slot = saveFiles.data.slots[i];
const savePath = path.join(saveFolderPath, slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`);
if (await fs.exists(savePath))
{
const stat = await fs.stat(savePath);
if (stat.mtimeMs > new Date(slot.latest.updated_at).getTime())
{
const subPath = path.join(slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`);
if (!finalSavePaths.some(f => f.subPath === subPath))
{
// Add newer files to the list, maybe they were changed offscreen.
finalSavePaths.push({ subPath, cwd: saveFolderPath });
}
}
}
}
}*/
const finalSavePaths = Object.entries(validChangedSaveFiles).filter(([slot, change]) => !change.isGlob && !change.shared);
if (finalSavePaths.length > 0)
{
console.log("Files Changed:", finalSavePaths.map(([slot, change]) => Array.isArray(change.subPath) ? change.subPath.join(',') : change.subPath)?.join(", "));
await Promise.all(finalSavePaths.map(async ([slot, change]) =>
{
const savesArray = Array.isArray(change.subPath) ? change.subPath : [change.subPath];
// TODO: handle directories
const archive = new Bun.Archive(Object.fromEntries(savesArray.map(s => [s, Bun.file(path.join(change.cwd, s))])));
const data: FormData = new FormData();
data.append('saveFile', await archive.blob(), slot);
const url = new URL(`${config.get('rommAddress')}/api/saves`);
url.searchParams.set('rom_id', id);
url.searchParams.set('slot', slot);
url.searchParams.set('autocleanup', "true");
url.searchParams.set('autocleanup_limit', "2");
if (command.emulator)
url.searchParams.set('emulator', command.emulator);
url.searchParams.set('overwrite', "true");
const auth = await this.getAuthToken(ctx.config);
const headers: Record<string, string> = {};
if (auth)
headers['Authorization'] = auth;
const response = await fetch(url, {
body: data,
method: "POST",
headers
});
if (!response.ok) console.error(response.statusText);
}));
events.emit('notification', { message: "Saves Uploaded", icon: 'upload', type: "success" });
}
const resp = await updateRomUserApiRomsIdPropsPut({ path: { id: Number(id) }, body: { update_last_played: true } }); const resp = await updateRomUserApiRomsIdPropsPut({ path: { id: Number(id) }, body: { update_last_played: true } });
if (resp.error) console.error(resp.error); if (resp.error) console.error(resp.error);
return resp.response.ok; events.emit('notification', { message: "Updated Played", type: "success", icon: "clock" });
}); });
ctx.hooks.games.fetchCollections.tapPromise(desc.name, async ({ collections }) => ctx.hooks.games.fetchCollections.tapPromise(desc.name, async ({ collections }) =>
{ {
if (!await this.checkRemote()) return;
const rommCollections = await getCollectionsApiCollectionsGet(); const rommCollections = await getCollectionsApiCollectionsGet();
if (rommCollections.response.ok && rommCollections.data) if (rommCollections.response.ok && rommCollections.data)
{ {
@ -382,6 +588,7 @@ export default class RommIntegration implements PluginType
ctx.hooks.games.fetchCollection.tapPromise(desc.name, async ({ source, id }) => ctx.hooks.games.fetchCollection.tapPromise(desc.name, async ({ source, id }) =>
{ {
if (!await this.checkRemote()) return;
if (source !== 'romm') return; if (source !== 'romm') return;
const collection = await getCollectionApiCollectionsIdGet({ path: { id: Number(id) } }); const collection = await getCollectionApiCollectionsIdGet({ path: { id: Number(id) } });
if (collection.data) if (collection.data)
@ -398,11 +605,35 @@ export default class RommIntegration implements PluginType
}); });
ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id }) => ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) =>
{ {
if (!await this.checkRemote()) return;
let platform: PlatformSchema | undefined = undefined;
if (id && source)
{
if (source !== 'romm') return;
const platforms = await this.getAllRommPlatforms();
platform = platforms.find(p => p.id === Number(id));
} else if (slug)
{
const platforms = await this.getAllRommPlatforms();
platform = platforms.find(p => p.slug === slug);
}
if (!platform) return;
return { slug: platform?.slug, url_logo: platform.url_logo, name: platform.display_name, family_name: platform.family_name ?? undefined };
});
ctx.hooks.games.searchGame.tapPromise(desc.name, async ({ source, igdb_id, ra_id }) =>
{
if (!await this.checkRemote()) return;
if (source !== 'romm') return; if (source !== 'romm') return;
const platforms = await this.getAllRommPlatforms(); const roms = await getRomByMetadataProviderApiRomsByMetadataProviderGet({ query: { igdb_id, ra_id } });
return platforms.find(p => p.id === Number(id)); if (roms.error) throw roms.error;
if (!roms.data) return;
return this.convertRomToFrontendDetailed(roms.data);
}); });
} }
} }

View file

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

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