Compare commits

...

59 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
d24dc89515
chore(release): 1.3.0
Some checks failed
Build and Upload Canary / build (push) Failing after 1m27s
2026-03-31 19:17:48 +03:00
bb8f716201
fix: missing gitlab as download type 2026-03-31 19:16:24 +03:00
4271f268c3
test: made tests work in windows 2026-03-31 04:44:42 +03:00
8a0be8c913
test: Added download test and made app more testable in general
fix: Store Downloads not properly working on steam deck
fix: Removed linux shortcuts implementation
2026-03-31 03:11:02 +03:00
58d3c31c56
build: Added 7zip to work with appimage 2026-03-30 21:01:23 +03:00
b4e9112989
fix: Added keyboard focus shortcut 2026-03-30 20:51:48 +03:00
ccc5a05ed7
fix: Issues with launching and installation on the steam deck 2026-03-30 20:00:08 +03:00
dc0f2d150a
fix: ditched sdl and moved to xinput for windows for less ram usage 2026-03-30 02:02:12 +03:00
90d6711935
fix: switched to node-7z
fix: switched to bun spawn but with windowsVerbatimArguments
feat: Added ppsspp integration
feat: Added focusing controls for windows
feat: Added shortcut to kill emulators
2026-03-29 22:18:05 +03:00
a7eb655a48
fix: Manual checking for system info to fix bug in library 2026-03-28 20:55:45 +02:00
816d50ae4d
fix: Fixed romm login, now uses token
feat: Moved romm to internal plugin
fix: Made focusing and navigation more reliable
fix: Loading errors on first time launch
2026-03-28 17:32:51 +02:00
7c10f4e4c2
fix: Fixed browser referencing main and getting called twice when in dev mode 2026-03-25 22:14:45 +02:00
a78e75335f
feat First implementation of plugins system
feat: Added PCSX2 integration
feat: Revamped UI a bit made it look better on light mode
2026-03-25 21:51:10 +02:00
d85268fad7
doc: Added license 2026-03-22 16:39:30 +02:00
91ee719633
feat: moved to npm package for the store 2026-03-22 16:34:33 +02:00
3750e9ed8f
feat: Implemented emulator installation
feat: Updated romm API version
feat: Updated es-de rules
feat: Added tabs to game details
refactor: returned to global query definitions to help with typescript performance
2026-03-22 01:11:21 +02:00
cf6fff6fac
refactor: moved queries to their own file 2026-03-17 12:57:11 +02:00
364bc9d0be
doc: updated screenshots 2026-03-15 17:41:56 +02:00
acadfe04ad
doc: Updated readme 2026-03-15 17:23:45 +02:00
402 changed files with 26064 additions and 5510 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",
"runtime": "org.kde.Platform",
"runtime-version": "6.10",
"sdk": "org.kde.Sdk",
"runtime": "org.freedesktop.Platform",
"runtime-version": "25.08",
"sdk": "org.freedesktop.Sdk",
"command": "/app/bin/gameflow",
"base": "io.qt.qtwebengine.BaseApp",
"base-version": "6.10",
"finish-args": [
"--share=ipc",
"--share=network",
"--socket=pulseaudio",
"--socket=wayland",
"--socket=inherit-wayland-socket",
"--socket=x11",
"--socket=fallback-x11",
"--socket=session-bus",
"--socket=system-bus",
"--device=all",
"--filesystem=host",
"--filesystem=home",
"--filesystem=~/.steam/steam:rw",
"--filesystem=~/.steam:rw",
"--filesystem=~/.var/app/com.valvesoftware.Steam:rw",
"--filesystem=/run/udev:ro",
"--filesystem=/run/media",
"--filesystem=xdg-documents",
"--filesystem=xdg-desktop",
"--filesystem=xdg-run/gamescope-0:rw",
"--env=PKG_CONFIG_LIBDIR=/app/lib",
"--env=FLATPAK_BUILD=true",
"--allow=devel"
"--allow=devel",
"--talk-name=org.freedesktop.portal.OpenURI",
"--talk-name=org.freedesktop.Flatpak",
"--talk-name=org.a11y.Bus"
],
"modules": [
{
@ -29,7 +42,6 @@
"mkdir -p /app/lib",
"install -Dm644 256x256.png /app/share/icons/hicolor/256x256/apps/com.simeonradivoev.gameflow-deck.png",
"install -Dm644 com.simeonradivoev.gameflow-deck.desktop /app/share/applications/com.simeonradivoev.gameflow-deck.desktop",
"mv libvips-cpp.so.* /app/lib",
"mv * /app/share/gameflow/",
"mv /app/share/gameflow/gameflow /app/bin",
"mv /app/share/gameflow/bun /app/bin",
@ -39,15 +51,15 @@
"sources": [
{
"type": "dir",
"path": "../build/linux"
"path": "../../build/linux"
},
{
"type": "file",
"path": "../flatpak/com.simeonradivoev.gameflow-deck.desktop"
"path": "com.simeonradivoev.gameflow-deck.desktop"
},
{
"type": "file",
"path": "../src/mainview/assets/256x256.png"
"path": "../../src/mainview/public/256x256.png"
},
{
"type": "script",
@ -72,23 +84,22 @@
"only-arches": [
"aarch64"
]
},
{
"type": "file",
"path": "../node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.8.17.3",
"only-arches": [
"x86_64"
]
}
]
},
{
"name": "webview",
"buildsystem": "cmake-ninja",
"name": "NW.js",
"buildsystem": "simple",
"build-commands": [
"mkdir -p /app/bin/nw",
"mv * /app/bin/nw",
"chmod +x /app/bin/nw/nw"
],
"sources": [
{
"type": "dir",
"path": "../flatpak/webview"
"type": "archive",
"url": "https://dl.nwjs.io/v0.110.1/nwjs-v0.110.1-linux-x64.tar.gz",
"sha256": "d9a9ed2255e9ee87c9dd1860d9c7a479cea5279dcd80d3e80e23b083d325554a"
}
]
}

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
*.webp 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.png (Stored with Git LFS) vendored Normal file

Binary file not shown.

BIN
.github/screenshots/4MtAe7Wkev.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/7s0842oAC9.png (Stored with Git LFS) vendored

Binary file not shown.

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

Binary file not shown.

BIN
.github/screenshots/CpBLzTNM6N.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/FHMzJjGOs6.png (Stored with Git LFS) vendored

Binary file not shown.

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

Binary file not shown.

BIN
.github/screenshots/J5BHVZBh7k.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 Normal file

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

View file

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

6
.gitignore vendored
View file

@ -27,3 +27,9 @@ downloads
.flatpak-builder
gameflow-deck.code-workspace
.env.local
src/tests/mock-roms/db.sqlite
src/tests/mock-roms/store
src/tests/mock-config
bin
.config/flatpak/repo
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"
}
]
}

11
.vscode/settings.json vendored
View file

@ -6,17 +6,22 @@
"files.watcherExclude": {
"**/*.gen.*": true,
"src/mainview/gen/*": true,
"**/build": true,
"**/.config/flatpack/repo/**": true,
"**/.flatpak-builder/**": true,
},
"search.exclude": {
"**/*.gen.*": true,
".flatpak-builder/**/*": true,
"**/.flatpak-builder": true,
"**/.config/flatpack/repo/**": true,
"**/build": true,
"src/mainview/gen/*": true,
},
"npm.scriptRunner": "bun",
"npm.exclude": [
"**/.flatpak-builder/**/*",
"**/build/flatpack/**",
"**/flatpack/repo/**",
"**/.config/flatpack/repo/**",
],
"editor.formatOnSave": true,
"[typescriptreact]": {
@ -30,9 +35,11 @@
"cSpell.words": [
"elysia",
"elysiajs",
"emulatorjs",
"gameflow",
"hackolade",
"keytar",
"mainview",
"norigin",
"noriginmedia",
"romm"

5
.vscode/tasks.json vendored
View file

@ -38,11 +38,6 @@
"label": "Start Dev (Hot Reload)",
"type": "shell",
"command": "bun run dev:hmr",
"options": {
"env": {
"FORCE_BROWSER": "false"
}
},
"isBackground": true,
"problemMatcher": [],
"presentation": {

View file

@ -1,6 +1,72 @@
# 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)
### Features
* Implemented emulator installation ([3750e9e](https://github.com/simeonradivoev/gameflow-deck/commit/3750e9ed8fc1c0919aade9e45a0189838f12b16d))
* moved to npm package for the store ([91ee719](https://github.com/simeonradivoev/gameflow-deck/commit/91ee7196332313518324cf7195f64d0e92b2cc8b))
### Bug Fixes
* Added keyboard focus shortcut ([b4e9112](https://github.com/simeonradivoev/gameflow-deck/commit/b4e911298935483bec7e315d2eebee47562bd448))
* ditched sdl and moved to xinput for windows for less ram usage ([dc0f2d1](https://github.com/simeonradivoev/gameflow-deck/commit/dc0f2d150a37bebefa76988f98d8766f530f44b4))
* Fixed browser referencing main and getting called twice when in dev mode ([7c10f4e](https://github.com/simeonradivoev/gameflow-deck/commit/7c10f4e4c2b4996e784be051132233a854270250))
* Fixed romm login, now uses token ([816d50a](https://github.com/simeonradivoev/gameflow-deck/commit/816d50ae4d61723e67a0980ca310561ead661a68))
* Issues with launching and installation on the steam deck ([ccc5a05](https://github.com/simeonradivoev/gameflow-deck/commit/ccc5a05ed7010adea77eea9190f3149b67702b39))
* Manual checking for system info to fix bug in library ([a7eb655](https://github.com/simeonradivoev/gameflow-deck/commit/a7eb655a48c6976baa18bb4cde96c989ce8cd375))
* missing gitlab as download type ([bb8f716](https://github.com/simeonradivoev/gameflow-deck/commit/bb8f7162018f7a320be76128d09da82ccac1a896))
* switched to node-7z ([90d6711](https://github.com/simeonradivoev/gameflow-deck/commit/90d67119355baa64bd992c9d4e9d11036706bbc9))
### [1.2.1](https://github.com/simeonradivoev/gameflow-deck/compare/v1.2.0...v1.2.1) (2026-03-15)

661
LICENSE Normal file
View file

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

101
README.md
View file

@ -1,41 +1,81 @@
# Gameflow Deck
A Cross-Platform Retro gaming frontend designed for handheld and controllers.
Focused on building a simple user experience and intuitive UI.
A Cross-Platform open source Retro gaming frontend designed for handheld and controllers.
Focused on building a simple user experience and intuitive UI as a curated community driven experience.
> [!WARNING]
> This app is actively in development, it doesn't have most of its critical features implemented yet.
> This app is actively in development, it is constantly changing and improving.
> It will have an opinionated design and will be used as an experiment in discovering a good UX.
## Community
Join us on Discord, where you can ask questions, submit ideas and get help.
[![](https://invidget.switchblade.xyz/R9KakhY67d)](https://discord.gg/R9KakhY67d)
## Features
- **Cross Platform**: Can run on multiple platforms. Built with web technologies and bun backend.
- **[Romm](https://github.com/rommapp/romm) Support**: Has integration with romm.
- **Lightweight**: It uses the existing system browser to launch the front end, so no need to include a whole web browser.
### Integrations
- **[ROMM](https://github.com/rommapp/romm)** - download, sync and update roms and platforms.
- Show Achievements and sync playtime.
- Experimental save syncing
- **[Emulator JS](https://github.com/EmulatorJS/EmulatorJS)** - play your games with emulator js right within the app. Uses RetroArch cores.
- **[RClone](https://github.com/rclone/rclone)** - sync saves between devices or cloud. Some Emulators and store games support it.
- **[UMU](https://github.com/Open-Wine-Components/umu-launcher)** - UMU Launcher for playing windows games on linux without needing steam. (Only used for store games for now)
### Store
- **Emulators** - (WIP) Download and install emulators and automatically configure them from a list of supported in the store. Some even come with advanced features like cloud saves.
- **Free Curated Games** - Download free curated games and homebrew roms without ever leaving the app
### Others
- **Cross Platform** - Can run on multiple platforms. Built with web technologies and bun backend.
- **Steam Deck Support** - Extensively tested with the steam deck. It can use flatpak installed browsers.
- **Lightweight** - It uses the window's webview as a frontend, reducing build size and ram usage.
- On Windows it first uses webview2 then your browser
- On linux it uses WebKitGTK or a browser even from flatpak
- On linux it does ship with NW.js to work on most distros. A big one is the steam deck missing WebKitGTK.
- Not tested on Mac yet
- **Steam Deck Support**: Extensively tested with the steam deck. It can use flatpak installed browsers.
- Automatic Keyboard prompts
- **Great for Controllers**: The UI is inspired by the switch and works great with joysticks and dpads.
- **Automatic Download** Downloads roms from ROMM automatically
- **Automatic Emulator Discovery** Using the configs of the excellent ES-DE to discover installed emulators and launch games.
- **Great for Controllers** - The UI is inspired by the switch and works great with joysticks and dpads.
- **Automatic Downloads** - Downloads roms from ROMM automatically
- **Automatic Emulator Discovery** - Using the configs of the excellent ES-DE to discover installed emulators and launch roms. You can bring your existing configurations.
- Easy fallback configuration with built in file browser.
- **Responsive Layout** Optimized mainly for the steam deck with responsive layout support and dynamic switching of inputs.
- **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
<img src=".github/screenshots/7s0842oAC9.png" width="25%"></img>
<img src=".github/screenshots/FHMzJjGOs6.png" width="25%"></img>
<img src=".github/screenshots/EWPHmIBEE5.png" width="25%"></img>
<img src=".github/screenshots/J5BHVZBh7k.png" width="25%"></img>
<img src=".github/screenshots/8jipsHiLST.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.png" title="Game Details." width="25%"></img>
<img src=".github/screenshots/yObFD2LySH.jpg" title="Home Screen in dark mode" width="25%"></img>
<img src=".github/screenshots/GL7SkQbHIY.png" title="Plugins Page" width="25%"></img>
<img src=".github/screenshots/CpBLzTNM6N.png" title="Store Home Page" 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
I want to build an open and free platform where you can play and discover new hidden gems from the past.
I plan to add a free store where you can download all your needed emulators, the goal is to not have to leave the UI for anything.
I really want to add matrix chat support in the app for engaging with your favorite community. Having access to so many nodejs libraries would make it quite straight forward.
- I want to build an open and free platform where you can play and discover new hidden gems from the past.
- I plan to add a free store where you can download all your needed emulators, the goal is to not have to leave the UI for anything.
- I really want to add matrix chat support in the app for engaging with your favorite community. Having access to so many nodejs libraries would make it quite straight forward.
- I'm sick of closed source and private store fronts, and want a way to share community 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
@ -66,6 +106,17 @@ I really want to add matrix chat support in the app for engaging with your favor
- `bun run openapi-ts` generated the openapi client calls from romm's API
- `bun run package:windows` builds an package to be distributed on windows
- `bun run package:linux` builds an AppImage to be distributed on linux
- `bun run test` run tests
- `bun run download:chromium` downloads degoogled chromium to use as the frontend
- `bun run download:nwjs` downloads NW.js to use as a frontend.
## 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
@ -77,3 +128,11 @@ I really want to add matrix chat support in the app for engaging with your favor
- [Tanstack](https://tanstack.com/) router and query for navigation and data
- [elysia](https://elysiajs.com/) for the APIs
- [webview](https://github.com/webview/webview) for launching existing system webviews instead of full browser if possible.
- [emulatorjs](https://emulatorjs.org/) for playing lots of roms inside the app without having to deal with external emulators
### 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)

965
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,
"tag": "0001_outstanding_silk_fever",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1776111721964,
"tag": "0002_flowery_rocket_raccoon",
"breakpoints": true
}
]
}

View file

@ -1,117 +1,157 @@
{
"name": "com.simeonradivoev.gameflow-deck",
"displayName": "Gameflow",
"version": "1.2.1",
"author": {
"name": "Simeon Radivoev",
"email": "work@simeonradivoev.com",
"url": "https://simeonradivoev.com"
},
"version": "1.6.0",
"description": "Game Launcher",
"icon": "./src/mainview/assets/icon.svg",
"main": "./src/bun/index.ts",
"bin": "gameflow",
"license": "AGPL-3.0",
"repository": {
"type": "git",
"url": "https://github.com/simeonradivoev/gameflow-deck"
},
"packageManager": "bun@1.3.9",
"type": "module",
"workspaces": [
"./src/packages/gameflow-sdk"
],
"scripts": {
"dev": "NODE_ENV=development bun run build:vite && bun run ./scripts/dev.ts",
"dev": "NODE_ENV=development bun run build:vite && conc 'bun run ./scripts/dev.ts'",
"dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run ./scripts/dev.ts'",
"build:vite": "vite build",
"dev:bun:hmr": "PUBLIC_ACCESS=true NODE_ENV=development conc 'bun run hmr' 'bun run --watch ./src/bun/index.ts'",
"dev:bun": "NODE_ENV=development bun run build:vite && conc 'bun run ./src/bun/index.ts'",
"build:vite": "bun run --bun vite build",
"build:prod:vite": "NODE_ENV=production bun run build:vite",
"build:dev:vite": "NODE_ENV=development bun run build:vite",
"build": "bun run build:vite && bun run ./scripts/package-bun.ts",
"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:dynamic": "NODE_ENV=production NON_COMPILED=true bun run build",
"build:linux": "TARGET=bun-linux-x64 bun run build",
"openapi-ts": "bun run ./scripts/romm/openapi-ts.ts",
"run:build-action": "act workflow_dispatch --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .forgejo/workflows/build.yml",
"hmr": "vite --port 5173",
"hmr": "bun run --bun vite --port 5173",
"drizzle:generate": "bunx drizzle-kit generate",
"test": "bun test",
"mappings:generate": "bun run drizzle-kit generate --dialect=sqlite --schema=./src/bun/api/schema/emulators.ts --out=./scripts/drizzle/es-de && bun run ./scripts/generate-es-de-mapping.ts",
"flatpak:generate-sources": "bun run ./scripts/generate-flatpak-sources.ts",
"flatpak:override": "flatpak override org.flatpak.Builder --filesystem=host --device=all",
"flatpak:restore": "flatpak override --reset --user org.flatpak.Builder",
"flatpak:build": "flatpak run org.flatpak.Builder build/flatpak flatpak/com.simeonradivoev.gameflow-deck.json --repo=.config/flatpak/repo --force-clean",
"flatpak:build": "FLATPAK_BUILD=true NODE_ENV=production NON_COMPILED=true bun run build && flatpak run org.flatpak.Builder ../gameflow-flatpak/build/flatpak .config/flatpak/com.simeonradivoev.gameflow-deck.json --repo=.config/flatpak/repo --state-dir=../gameflow-flatpak/state --force-clean",
"flatpak:install": "bun run flatpak:build && flatpak --user install --reinstall \"$PWD/.config/flatpak/repo\" com.simeonradivoev.gameflow-deck",
"build:prod:appimage": "bun run build:prod && bun run ./scripts/build-appimage.ts",
"build:dev:appimage": "bun run build && bun run ./scripts/build-appimage.ts",
"version:generate": "standard-version --sign",
"version:generate": "commit-and-tag-version --sign",
"package:Linux": "bun run build:prod:appimage",
"package:Windows": "bun run build:prod"
"package:Windows": "bun run build:prod",
"download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium",
"download:nwjs": "bun scripts/download-nw.ts",
"build:audiosprites": "bun ./scripts/generate-audio-sprites.ts",
"tsc": "tsc --noEmit",
"publish:sdk": "bun publish --cwd ./src/packages/gameflow-sdk/ --access public"
},
"dependencies": {
"7zip-bin": "^5.2.0",
"@auth/core": "^0.34.3",
"@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.6",
"@elysiajs/static": "^1.4.7",
"@jimp/wasm-webp": "^1.6.0",
"@elysiajs/cors": "^1.4.2",
"@elysiajs/eden": "^1.4.9",
"@jimp/wasm-webp": "^1.6.1",
"@phalcode/ts-igdb-client": "^1.0.26",
"cheerio": "^1.2.0",
"conf": "^15.0.2",
"drizzle-orm": "^0.45.1",
"elysia": "^1.4.22",
"fs-extra": "^11.3.3",
"conf": "^15.1.0",
"drizzle-orm": "^0.45.2",
"elysia": "^1.4.28",
"fs-extra": "^11.3.5",
"get-folder-size": "^5.0.0",
"jimp": "^1.6.0",
"ini": "^6.0.0",
"jimp": "^1.6.1",
"mustache": "^4.2.0",
"node-7z": "^3.0.0",
"node-disk-info": "^1.3.0",
"node-downloader-helper": "^2.1.10",
"node-downloader-helper": "^2.1.11",
"node-stream-zip": "^1.15.0",
"node-unrar-js": "^2.0.2",
"npm-check-updates": "^22.2.0",
"open": "^11.0.0",
"p-queue": "^9.2.0",
"pathe": "^2.0.3",
"systeminformation": "^5.31.1",
"tough-cookie": "^6.0.0",
"slugify": "^1.6.9",
"smol-toml": "^1.6.1",
"systeminformation": "^5.31.6",
"tapable": "^2.3.3",
"tough-cookie": "^6.0.1",
"tough-cookie-file-store": "^3.3.0",
"ts-igdb-client": "^0.4.2",
"unzip-stream": "^0.3.4",
"webview-bun": "^2.4.0",
"zod": "^4.3.6"
"zod": "^4.4.3"
},
"devDependencies": {
"@ap0nia/eden": "^1.0.0-next.22",
"@ap0nia/eden": "^1.6.1",
"@ap0nia/eden-tanstack-query": "^1.0.0-next.22",
"@emulatorjs/emulatorjs": "^4.2.3",
"@hey-api/openapi-ts": "^0.91.0",
"@noriginmedia/norigin-spatial-navigation": "^2.3.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-form": "^1.28.0",
"@tanstack/react-query": "^5.90.20",
"@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-router": "^1.157.16",
"@tanstack/react-router-devtools": "^1.154.12",
"@tanstack/react-router-ssr-query": "^1.157.17",
"@tanstack/router-plugin": "^1.157.16",
"@tanstack/zod-adapter": "^1.162.4",
"@hey-api/openapi-ts": "^0.91.1",
"@noriginmedia/norigin-spatial-navigation": "^3.1.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-form": "^1.32.0",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-query-devtools": "^5.100.10",
"@tanstack/react-query-persist-client": "^5.100.10",
"@tanstack/react-router": "^1.169.2",
"@tanstack/react-router-devtools": "^1.166.13",
"@tanstack/react-router-ssr-query": "^1.166.12",
"@tanstack/router-plugin": "^1.167.35",
"@tanstack/zod-adapter": "^1.166.9",
"@types/adm-zip": "^0.5.8",
"@types/audiosprite": "^0.7.3",
"@types/bun": "latest",
"@types/fs-extra": "^11.0.4",
"@types/react": "^19.2.9",
"@types/howler": "^2.2.12",
"@types/ini": "^4.1.1",
"@types/json-schema": "^7.0.15",
"@types/mustache": "^4.2.6",
"@types/node-7z": "^2.1.11",
"@types/rclone.js": "^0.6.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/unzip-stream": "^0.3.4",
"@vitejs/plugin-react": "^5.1.2",
"@vitejs/plugin-react": "^5.2.0",
"adm-zip": "^0.5.17",
"animate.css": "^4.1.1",
"app-builder-bin": "^5.0.0-alpha.13",
"audiosprite": "^0.7.2",
"babel-plugin-react-compiler": "^1.0.0",
"classnames": "^2.5.1",
"commit-and-tag-version": "^12.7.3",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"daisyui": "^5.5.14",
"drizzle-kit": "^0.31.9",
"dts-bundle-generator": "^9.5.1",
"daisyui": "^5.5.19",
"drizzle-kit": "^0.31.10",
"eden-tanstack-query": "^0.0.9",
"howler": "^2.2.4",
"idb-keyval": "^6.2.2",
"lucide-react": "^0.563.0",
"pretty-bytes": "^7.1.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-error-boundary": "^6.1.0",
"pretty-ms": "^9.3.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-error-boundary": "^6.1.1",
"react-hot-toast": "^2.6.0",
"react-qr-code": "^2.0.18",
"sass-embedded": "^1.97.3",
"standard-version": "^9.5.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"react-markdown": "^10.1.0",
"react-qr-code": "^2.0.21",
"sass-embedded": "^1.99.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.3",
"usehooks-ts": "^3.1.1",
"vite": "^7.3.1",
"vite-plugin-svg-icons-ng": "^1.5.2",
"vite": "^7.3.3",
"vite-plugin-svg-icons-ng": "^1.9.1",
"vite-static-assets-plugin": "^1.2.2",
"vite-tsconfig-paths": "^6.1.1"
}

View file

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

View file

@ -1,48 +1,48 @@
// watcher.ts - run this instead of --watch
import EventEmitter from "events";
import browser from '../src/bun/browser';
import { tmpdir } from "os";
import path from "path";
import { watch } from "fs";
import { sleep } from "bun";
const events = new EventEmitter();
const abortController = new AbortController();
let restarting = false;
process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=9222";
process.env.NODE_ENV = "development";
let retries = 0;
function spawnServer ()
{
return Bun.spawn(["bun", "run", '--watch', "--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: {
...Bun.env,
...process.env,
HEADLESS: "true",
},
stdout: "inherit",
stderr: "inherit",
stdin: "pipe",
stdout: 'inherit',
stderr: 'inherit',
stdin: 'inherit',
signal: abortController.signal,
killSignal: 'SIGUSR1',
killSignal: 'SIGKILL',
ipc (message, subprocess, handle)
{
if (message.type === 'exitapp')
if (message === 'focus')
{
events.emit('focus');
} else if (message === 'exitapp')
{
events.emit('exitapp');
}
},
onExit (subprocess, exitCode, signalCode)
{
if (exitCode === 1 && retries <= 3)
{
server = spawnServer();
retries++;
} else
if (!restarting)
{
console.log("Existing Dev With", exitCode);
process.exit();
}
}
});
return s;
}
function spawnBrowser ()
@ -50,17 +50,55 @@ function spawnBrowser ()
try
{
return browser(events, Bun.env.FORCE_BROWSER === "true", { configPath: path.join(tmpdir(), 'gameflow') });
return browser(events, {
configPath: path.join(tmpdir(), 'gameflow'),
isSteamDeckGameMode: false,
forceBrowser: process.env.FORCE_BROWSER === "true"
});
} catch (error)
{
console.error(error);
};
}
let server = spawnServer();
spawnBrowser()?.then(async e =>
async function restart ()
{
console.log("Sending exit Signal to server");
await server.stdin.write('shutdown\n');
await server.stdin.flush();
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)
{
spawnBrowser()?.then(async e =>
{
if (!server) return;
abortController.abort();
await server.exited;
});
}

View file

@ -0,0 +1,283 @@
#!/usr/bin/env bun
/**
* download-chromium.ts
* Downloads the latest ungoogled-chromium for the current platform + arch.
* Skips the download if the binary is already present and up to date.
*
* Usage: bun download-chromium.ts [--out=./chromium] [--force]
* In package.json scripts: "prebuild": "bun scripts/download-chromium.ts"
*/
import { mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import StreamZip from "node-stream-zip";
// --- Config ------------------------------------------------------------------
const GITHUB_API = "https://api.github.com";
const VERSION_FILE = ".chromium-version";
const REPOS: Record<string, string> = {
linux: "ungoogled-software/ungoogled-chromium-portablelinux",
darwin: "ungoogled-software/ungoogled-chromium-macos",
win32: "ungoogled-software/ungoogled-chromium-windows",
};
const PLATFORM_MAP: Record<string, string> = {
linux: "linux",
win32: "windows",
darwin: 'macos'
};
const ARCH_MAP: Record<string, Record<string, string>> = {
linux: { x64: "x86_64", arm64: "arm64" },
darwin: { x64: "x86_64", arm64: "arm64" },
win32: { x64: "x64", arm64: "arm64" },
};
const PREFERRED_EXT: Record<string, string[]> = {
linux: [".tar.xz"],
darwin: [".dmg", ".zip"],
win32: [".zip"],
};
/** The expected binary path per platform after extraction */
function getBinaryPath (outDir: string, version: string, platform: string, arch: string): string
{
const subFolder = `ungoogled-chromium_${version}_${PLATFORM_MAP[platform]}_${ARCH_MAP[platform][arch]}`;
if (platform === "linux")
{
return path.join(outDir, subFolder, "chrome");
}
if (platform === "darwin") return path.join(outDir, "Chromium.app");
return path.join(outDir, subFolder, "chrome.exe");
}
// --- Helpers -----------------------------------------------------------------
function log (msg: string)
{
process.stdout.write(`\x1b[36m[chromium]\x1b[0m ${msg}\n`);
}
function err (msg: string): never
{
process.stderr.write(`\x1b[31m[error]\x1b[0m ${msg}\n`);
process.exit(1);
}
async function ghFetch (url: string)
{
const headers: Record<string, string> = { "User-Agent": "bun-chromium-downloader" };
const token = process.env.GITHUB_TOKEN;
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(url, { headers });
if (!res.ok) err(`GitHub API error ${res.status}: ${url}`);
return res.json();
}
async function readVersionCache (outDir: string): Promise<string | null>
{
const file = path.join(outDir, VERSION_FILE);
if (!existsSync(file)) return null;
return (await Bun.file(file).text()).trim();
}
async function writeVersionCache (outDir: string, version: string)
{
await Bun.write(path.join(outDir, VERSION_FILE), version);
}
async function downloadWithProgress (url: string, dest: string)
{
log(`Downloading -> ${dest}`);
const res = await fetch(url);
if (!res.ok) err(`Download failed: ${res.status} ${url}`);
const total = Number(res.headers.get("content-length") ?? 0);
let received = 0;
const writer = Bun.file(dest).writer();
const reader = res.body!.getReader();
while (true)
{
const { done, value } = await reader.read();
if (done) break;
writer.write(value);
received += value.length;
if (total > 0)
{
const pct = ((received / total) * 100).toFixed(1);
const mb = (received / 1e6).toFixed(1);
const totalMb = (total / 1e6).toFixed(1);
process.stdout.write(`\r ${pct}% ${mb} / ${totalMb} MB `);
}
}
await writer.end();
process.stdout.write("\n");
log("Download complete.");
}
async function extractZip (src: string, outDir: string)
{
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();
log(`Extracted ${total} files.`);
}
function extractNative (src: string, outDir: string)
{
if (src.endsWith(".AppImage"))
{
const dest = path.join(outDir, "chromium.AppImage");
spawnSync("cp", [src, dest]);
spawnSync("chmod", ["+x", dest]);
log(`AppImage ready at: ${dest}`);
return;
}
if (src.endsWith(".tar.xz"))
{
const result = spawnSync("tar", ["-xJf", src, "-C", outDir], { stdio: "inherit" });
if (result.status !== 0) err("tar extraction failed");
return;
}
if (src.endsWith(".dmg"))
{
log("Mounting DMG...");
const mount = spawnSync("hdiutil", ["attach", src, "-nobrowse", "-quiet"], {
encoding: "utf8",
});
if (mount.status !== 0) err("hdiutil mount failed");
const mountLine = mount.stdout.split("\n").find((l) => l.includes("/Volumes/"));
const mountPoint = mountLine?.split("\t").at(-1)?.trim();
if (!mountPoint) err("Could not find DMG mount point");
spawnSync("cp", ["-R", mountPoint!, outDir], { stdio: "inherit" });
spawnSync("hdiutil", ["detach", mountPoint!, "-quiet"]);
log(`DMG contents copied to: ${outDir}`);
return;
}
err(`Unknown archive format: ${src}`);
}
// --- Main --------------------------------------------------------------------
async function main ()
{
const platform = process.platform;
const arch = process.arch;
const force = process.argv.includes("--force");
const outArg = process.argv.find(a => a.startsWith("--out="))?.slice(6)
?? "./chromium";
const outDir = path.resolve(outArg);
log(`Platform: ${platform} Arch: ${arch}`);
const repo = REPOS[platform];
if (!repo) err(`Unsupported platform: ${platform}`);
const archStr = ARCH_MAP[platform]?.[arch];
if (!archStr) err(`Unsupported arch "${arch}" on ${platform}`);
// Fetch latest version (lightweight — just the tag, no asset download yet)
log(`Checking latest release from ${repo}...`);
const release = await ghFetch(`${GITHUB_API}/repos/${repo}/releases/latest`);
const version: string = release.tag_name ?? release.name ?? "unknown";
log(`Latest version: ${version}`);
// Check if already downloaded and up to date
if (!force)
{
const cachedVersion = await readVersionCache(outDir);
const assets: Array<{ name: string; }> = release.assets ?? [];
const preferred = PREFERRED_EXT[platform] ?? [];
let assetName: string | undefined;
for (const ext of preferred)
{
assetName = assets.find(a => a.name.includes(archStr) && a.name.endsWith(ext))?.name;
if (assetName) break;
}
if (!assetName) assetName = assets.find(a => a.name.includes(archStr))?.name;
if (cachedVersion === version)
{
const binaryPath = getBinaryPath(outDir, cachedVersion, platform, arch);
if (existsSync(binaryPath))
{
log(`Already up to date (${version}). Skipping download.`);
log(`Binary: ${binaryPath}`);
return;
} else
{
log(`Version matches but binary missing — re-downloading.`);
}
} else if (cachedVersion)
{
log(`New version available: ${cachedVersion} -> ${version}`);
}
} else
{
log("--force flag set, re-downloading.");
}
// Pick asset to download
const assets: Array<{ name: string; browser_download_url: string; }> = release.assets ?? [];
if (assets.length === 0) err("No assets found in the latest release.");
const preferred = PREFERRED_EXT[platform] ?? [];
let chosen: (typeof assets)[0] | undefined;
for (const ext of preferred)
{
chosen = assets.find(a => a.name.includes(archStr) && a.name.endsWith(ext));
if (chosen) break;
}
if (!chosen) chosen = assets.find(a => a.name.includes(archStr));
if (!chosen)
{
log("Available assets:");
for (const a of assets) log(` ${a.name}`);
err(`No asset found matching arch "${archStr}" on ${platform}.`);
}
log(`Selected asset: ${chosen.name}`);
if (!existsSync(outDir)) await mkdir(outDir, { recursive: true });
const tmpFile = path.join(outDir, chosen.name);
await downloadWithProgress(chosen.browser_download_url, tmpFile);
const { unlink } = await import("node:fs/promises");
if (chosen.name.endsWith(".zip"))
{
await extractZip(tmpFile, outDir);
await unlink(tmpFile);
} else
{
extractNative(tmpFile, outDir);
if (!chosen.name.endsWith(".AppImage"))
{
await unlink(tmpFile);
}
}
// Save version so next run can skip
await writeVersionCache(outDir, version);
log(`\nDone! Chromium ${version} extracted to: ${outDir}`);
const binaryPath = getBinaryPath(outDir, version, platform, arch);
log(`Binary: ${binaryPath}`);
}
main().catch((e) => err(String(e)));

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

@ -6,8 +6,6 @@ import { Database } from "bun:sqlite";
import * as schema from '../src/bun/api/schema/emulators';
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { drizzle } from "drizzle-orm/bun-sqlite";
import path from 'node:path';
import { ensureDir } from 'fs-extra';
/** get all latest supported romm platforms */
const rommPlatforms = await getSupportedPlatformsEndpointApiPlatformsSupportedGet({ baseUrl: "https://demo.romm.app" });
@ -57,6 +55,7 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
const emulators = $r('ruleList emulator').toArray().map(s =>
{
const $emulator = $r(s);
const comment = $emulator.contents().toArray().find((node) => node.type === 'comment');
const $systempath = $emulator.find('rule[type=systempath] entry');
const $staticpath = $emulator.find('rule[type=staticpath] entry');
const $corepath = $emulator.find('rule[type=corepath] entry');
@ -66,12 +65,14 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
const emulatorName = $emulator.attr('name');
const emulator: typeof schema.emulators.$inferInsert = {
name: emulatorName!,
fullname: comment?.data.trim(),
systempath: $systempath.toArray().map(p => $r(p).text()),
staticpath: $staticpath.toArray().map(p => $r(p).text()),
corepath: $corepath.toArray().map(p => $r(p).text()),
androidpackage: $androidpackage.toArray().map(p => $r(p).text()),
winregistrypath: $winregistrypath.toArray().map(p => $r(p).text()),
};
return emulator;
});
@ -95,12 +96,18 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
});
const rommMapping = rommPlatforms.data?.find(p =>
p.slug === (customMappings as any)[name] ||
p.slug === name ||
p.igdb_slug === name ||
p.hltb_slug === name ||
p.moby_slug === name ||
p.display_name === fullname
{
const custom = (customMappings as any)[name];
if (Array.isArray(custom) && custom.some(m => m === p.slug))
{
return true;
}
return p.slug === custom ||
p.slug === name ||
p.igdb_slug === name ||
p.display_name === fullname;
}
);
const mappings: {
@ -143,6 +150,7 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
commands,
mappings
};
return system;
}));

View file

@ -1,6 +1,5 @@
import { $ } from "bun";
const lockfile = Bun.argv[2] ?? "bun.lockb";
const output = Bun.argv[3] ?? ".config/flatpak/sources.gen.json";
const text = await $`bun ./bun.lockb --hash: 0000000000000000-0000000000000000-0000000000000000-0000000000000000`.text();

View file

@ -1,17 +1,24 @@
import fs from "node:fs/promises";
import path, { } from "node:path";
import os from "node:os";
import app from '../package.json';
const system = getPlatform();
const buildSubDir = process.env.BUILD_DIR ?? `./build/${system.platform}`;
const compileOption: Bun.CompileBuildOptions = {
outfile: "gameflow",
execArgv: ['--windows-hide-console'],
autoloadTsconfig: true,
autoloadPackageJson: true,
autoloadDotenv: true,
autoloadBunfig: true,
windows: {
hideConsole: true,
icon: './src/mainview/public/favicon.ico',
title: app.displayName,
description: app.description,
version: app.version
},
};
if (process.env.TARGET)
@ -19,17 +26,37 @@ if (process.env.TARGET)
compileOption.target = process.env.TARGET as any;
}
let webviewLib = "libwebview.dll";
if (process.platform === 'linux' && system.arch === 'x64')
webviewLib = "libwebview-x64.so";
if (process.platform === 'linux' && system.arch === 'arm64')
webviewLib = "libwebview-arm64.so";
if (process.platform === 'darwin')
webviewLib = "libwebview-arm64.dylib";
let zip: string | undefined;
let zipPath: string = '';
let zipNodePath: string | undefined;
let webviewLib: string | undefined;
switch (process.platform)
{
case "win32":
zip = "7za.exe";
zipNodePath = "win";
webviewLib = `libwebview.dll`;
break;
case "linux":
zip = "7za";
zipNodePath = 'linux';
webviewLib = `libwebview-${system.arch}.so`;
break;
case "darwin":
zip = "7za";
zipNodePath = 'mac';
webviewLib = `libwebview-${system.arch}.dylib`;
break;
}
if (!webviewLib) throw new Error("Could not find webviewlib");
let webviewLibPath = '.';
if (process.env.APPIMAGE === "true")
{
webviewLibPath = `./usr/lib`;
zipPath = './usr/bin';
}
await Bun.build({
entrypoints: ["./src/bun/index.ts", `./src/bun/webview/${system.platform}.ts`],
@ -40,6 +67,7 @@ await Bun.build({
define: {
"process.env.IS_BINARY": "true",
"process.env.WEBVIEW_PATH": `${webviewLibPath}/${webviewLib}`,
"process.env.ZIP7_PATH": `"${zip}"`
},
minify: process.env.NODE_ENV !== 'development',
sourcemap: process.env.NODE_ENV === 'development' ? 'inline' : "linked",
@ -63,12 +91,16 @@ await Bun.build({
}
}
});
build.onEnd(async () =>
build.onEnd(async (b) =>
{
await fs.cp('./dist', `${buildSubDir}/dist`, { recursive: true });
await fs.cp('./drizzle', `${buildSubDir}/drizzle`, { recursive: true });
await fs.cp(`./vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, `${buildSubDir}/vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, { recursive: true });
await fs.cp(path.join(`node_modules/webview-bun/build/`, webviewLib), path.join(buildSubDir, webviewLib));
await fs.cp(`node_modules/7zip-bin/${zipNodePath}/${process.arch}`, buildSubDir, { recursive: true, errorOnExist: false });
if (await fs.exists('bin/chromium'))
await fs.cp('bin/chromium', `${buildSubDir}/bin/chromium`, { recursive: true, errorOnExist: false });
});
},
}]

File diff suppressed because one or more lines are too long

View file

@ -1,90 +1,126 @@
import { TaskQueue } from "./task-queue";
import { TaskQueue, AppEventMap } from "@simeonradivoev/gameflow-sdk";
import { Database } from "bun:sqlite";
import { CookieJar } from 'tough-cookie';
import FileCookieStore from 'tough-cookie-file-store';
import path from 'node:path';
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
import Conf from "conf";
import projectPackage from '~/package.json';
import { Notification, SettingsSchema, SettingsType } from "@shared/constants";
import { SettingsType, SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared';
import { client } from "@clients/romm/client.gen";
import * as schema from "@schema/app";
import cacheSchema from "@schema/cache";
import * as emulatorSchema from "@schema/emulators";
import { login, logout } from "./auth";
import os from 'node:os';
import { ActiveGame } from "../types/types";
import EventEmitter from "node:events";
import { ErrorLike } from "bun";
import { appPath, getErrorMessage } from "../utils";
import { appPath } from "../utils";
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
import { ensureDir } from "fs-extra";
import UpdateStoreJob from "./jobs/update-store";
import { PluginManager } from "./plugins/plugin-manager";
import registerPlugins from "./plugins/register-plugins";
import controls from './controls/controls';
import { RunAPIServer } from "./rpc";
import { RunBunServer } from "../server";
import ReloadPluginsJob from "./jobs/reload-plugins-job";
export const config = new Conf<SettingsType>({
projectName: projectPackage.name,
projectSuffix: 'bun',
schema: Object.fromEntries(Object.entries(SettingsSchema.shape).map(([key, schema]) => [key, schema.toJSONSchema() as any])) as any,
defaults: SettingsSchema.parse({
downloadPath: path.join(os.homedir(), "gameflow"),
windowSize: { width: 1280, height: 800 }
} satisfies SettingsType),
});
export const customEmulators = new Conf<Record<string, string>>({
projectName: projectPackage.name,
projectSuffix: 'bun',
configName: 'custom-emulators',
rootSchema: {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
});
console.log("Config Path Located At: ", config.path);
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
console.log("App Directory is ", process.env.APPDIR);
const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
export const jar = new CookieJar(fileCookieStore);
export let config: Conf<SettingsType>;
export let customEmulators: Conf<Record<string, string>>;
export let fileCookieStore: FileCookieStore;
export let jar: CookieJar;
let sqlite: Database;
export const cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite');
export let cachePath: string;
let cacheSqlite: Database;
export let db: DrizzleSqliteDODatabase<typeof schema>;
export let cache: DrizzleSqliteDODatabase<typeof cacheSchema>;
await reloadDatabase();
const emulatorsSqlite = new Database(appPath(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`), { readonly: true });
export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
export const taskQueue = new TaskQueue();
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
await login();
export let activeGame: ActiveGame | undefined;
export function setActiveGame (game: ActiveGame)
let emulatorsSqlite: Database;
export let emulatorsDb: BunSQLiteDatabase<typeof emulatorSchema> & { $client: Database; };
export let taskQueue: TaskQueue;
export let plugins: PluginManager;
export let events: EventEmitter<AppEventMap>;
let controlsHandle: { cleanup: () => void; };
let api: { cleanup: () => Promise<void>; };
let bunServer: { cleanup: () => Promise<void>; } | undefined;
let cleannedUp = false;
let cleaningUp = false;
export async function load ()
{
if (activeGame) throw new Error("Only one active game at a time");
return activeGame = game;
config = new Conf<SettingsType>({
projectName: projectPackage.name,
projectSuffix: 'bun',
cwd: process.env.CONFIG_CWD,
schema: Object.fromEntries(Object.entries(SettingsSchema.shape).map(([key, schema]) => [key, schema.toJSONSchema() as any])) as any,
defaults: SettingsSchema.parse({
downloadPath: process.env.DEFAULT_DOWNLOAD_PATH ?? path.join(os.homedir(), "gameflow"),
windowSize: { width: 1280, height: 800 }
}),
});
customEmulators = new Conf<Record<string, string>>({
projectName: projectPackage.name,
projectSuffix: 'bun',
cwd: process.env.CONFIG_CWD,
configName: 'custom-emulators',
rootSchema: {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
});
console.log("Config Path Located At: ", config.path);
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
console.log("App Directory is ", process.env.APPDIR);
console.log("Cache Path is ", cachePath);
cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite');
fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
jar = new CookieJar(fileCookieStore);
taskQueue = new TaskQueue();
events = new EventEmitter<AppEventMap>();
emulatorsSqlite = new Database(appPath(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`), { readonly: true });
emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
await reloadDatabase();
plugins = new PluginManager();
api = await RunAPIServer();
await registerPlugins(plugins);
taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob());
controlsHandle = await controls();
if (!process.env.PUBLIC_ACCESS) bunServer = await RunBunServer();
config.onDidChange('downloadPath', () => reloadDatabase());
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
}
export const events = new EventEmitter<AppEventMap>();
events.addListener('activegameexit', ({ error }) =>
{
activeGame = undefined;
if (error)
{
events.emit('notification', { message: getErrorMessage(error), type: 'error' });
}
});
config.onDidChange('downloadPath', () => reloadDatabase());
taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
export async function cleanup ()
{
if (cleaningUp) throw new Error("Already Cleaning Up");
cleaningUp = true;
if (cleannedUp) throw new Error("Already Cleaned Up. Skipping");
console.log("Cleaning Up");
await bunServer?.cleanup();
await api.cleanup();
await taskQueue.close();
sqlite.close();
await logout();
await plugins.cleanup();
controlsHandle.cleanup();
cacheSqlite.close();
emulatorsSqlite.close();
sqlite.close();
config._closeWatcher();
customEmulators._closeWatcher();
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 ()
@ -97,7 +133,8 @@ export async function reloadDatabase ()
db = drizzle(sqlite, { schema });
cache = drizzle(cacheSqlite, { schema: cacheSchema });
migrate(db!, { migrationsFolder: appPath("./drizzle") });
cache.run(`
sqlite.run("PRAGMA foreign_keys = ON;");
await cache.run(`
CREATE TABLE IF NOT EXISTS item_cache (
key TEXT PRIMARY KEY,
data TEXT NOT NULL,
@ -107,9 +144,3 @@ export async function reloadDatabase ()
`);
}
interface AppEventMap
{
activegameexit: [{ source: string, id: string, subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
exitapp: [];
notification: [Notification];
}

View file

@ -1,8 +1,7 @@
import Elysia, { sse, status } from "elysia";
import { config, events, jar, taskQueue } from "./app";
import Elysia, { status } from "elysia";
import { config, events, plugins, taskQueue } from "./app";
import z from "zod";
import { client } from "@clients/romm/client.gen";
import { loginApiLoginPost, logoutApiLogoutPost } from "@clients/romm";
import { getCurrentUserApiUsersMeGet, tokenApiTokenPost, UserSchema } from "@clients/romm";
import secrets from '../api/secrets';
import { LoginJob } from "./jobs/login-job";
import TwitchLoginJob from "./jobs/twitch-login-job";
@ -43,68 +42,12 @@ export default new Elysia()
await secrets.delete({ service: 'gamflow_twitch', name: 'refresh_token' });
await secrets.delete({ service: 'gamflow_twitch', name: 'expires_in' });
await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' });
return status(res.status, res.statusText);
})
.get('/login/twitch', async () =>
{
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
if (!access_token)
{
return status('Not Found', "Not Logged In");
}
const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${access_token}` } });
if (res.ok)
{
return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; };
}
if (!process.env.TWITCH_CLIENT_ID)
{
return status("Not Found", "Twitch Client ID not set");
}
const refresh_token = await secrets.get({ service: 'gamflow_twitch', name: "refresh_token" });
if (!refresh_token)
{
return status("Not Found", "Refresh Token Not Found");
}
// refresh token
const refreshResponse = await fetch('https://id.twitch.tv/oauth2/token', {
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({
client_id: process.env.TWITCH_CLIENT_ID,
access_token,
grant_type: "refresh_token",
refresh_token
})
});
if (refreshResponse.ok)
{
const data: {
access_token: string,
refresh_token: string,
token_type: string;
expires_in: number;
} = await refreshResponse.json();
await secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token });
await secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token });
await secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() });
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', async () =>
.get('/login/twitch', checkLoginAndRefreshTwitch)
.post('/login/romm/qr', async () =>
{
if (taskQueue.hasActiveOfType(LoginJob))
{
@ -113,117 +56,150 @@ export default new Elysia()
return taskQueue.enqueue(LoginJob.id, new LoginJob());
})
.post('/login', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
.get('/login', async () =>
.get('/user/romm', async () =>
{
const credentials = await secrets.get({ service: 'gameflow', name: 'romm' });
return { hasPassword: !!credentials };
}, { response: z.object({ hasPassword: z.boolean() }) })
.post('/logout', async () =>
const data = await getCurrentUserApiUsersMeGet();
if (data.error) return status("Internal Server Error", data.response.statusText);
return data.data as UserSchema;
})
.post('/login/romm', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
.get('/login/romm', checkLoginAndRefreshRomm,
{ response: z.object({ hasLogin: z.boolean() }) })
.post('/logout/romm', async () =>
{
await secrets.delete({ service: 'gameflow', name: 'romm' });
await logout();
const rommAddress = config.get('rommAddress');
if (rommAddress)
{
const cookies = await jar.getCookies(rommAddress);
cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key));
}
await secrets.delete({ service: 'gameflow', name: 'romm_access_token' });
await secrets.delete({ service: 'gameflow', name: 'romm_refresh_token' });
await secrets.delete({ service: 'gameflow', name: 'romm_expires_in' });
return status(200);
}, { response: z.any() });
async function updateClient ()
export async function checkLoginAndRefreshTwitch ()
{
client.setConfig({
baseUrl: config.get('rommAddress'), headers: {
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
}
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; })
{
if (config.has('rommAddress') && config.has('rommUser'))
const response = await tokenApiTokenPost({
body: {
password,
username,
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
});
if (response.response.ok && response.data)
{
await logout();
const oldRommAddress = config.get('rommAddress');
if (oldRommAddress)
await secrets.set({ service: 'gameflow', name: 'romm_access_token', value: response.data.access_token });
await secrets.set({ service: 'gameflow', name: 'romm_expires_in', value: new Date(new Date().getTime() + response.data.expires * 1000).toString() });
if (response.data.refresh_token)
{
const cookies = await jar.getCookies(oldRommAddress);
await Promise.all(cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key)));
await secrets.set({ service: 'gameflow', name: 'romm_refresh_token', value: response.data.refresh_token });
}
}
const response = await login({ rommAddress: host, rommUser: username, rommPassword: password });
if (response?.code === 200)
{
config.set('rommAddress', host);
config.set('rommUser', username);
await secrets.set({ service: 'gameflow', name: 'romm', value: password });
await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' });
}
return response;
}
export async function logout ()
{
if (!config.has('rommAddress'))
{
return;
}
const rommAddress = config.get('rommAddress');
if (rommAddress)
{
console.log("Logging Out of ROMM");
try
{
await logoutApiLogoutPost({
baseUrl: rommAddress, headers: {
'cookie': await jar.getCookieString(rommAddress)
}
});
await jar.store.removeCookie(new URL(rommAddress).host, null, "romm_session");
} catch (error)
{
console.error("Failed to logout of ROMM ", error);
}
}
}
export async function login (data?: { rommAddress?: string, rommUser?: string, rommPassword?: string; })
{
const address = data?.rommAddress ?? config.get('rommAddress');
const user = data?.rommUser ?? config.get('rommUser');
const password = data?.rommPassword ?? await secrets.get({ service: 'gameflow', name: "romm" });
if (!address || !user)
{
console.warn("Romm not setup");
return status(404);
}
const rommAddress = config.get('rommAddress');
const rommUser = config.get('rommUser');
if (rommAddress && rommUser)
{
console.log("Logging In to ROMM");
if (password === null)
{
return status(404, "No Found Password");
}
const loginResponse = await loginApiLoginPost({ baseUrl: rommAddress, auth: `${rommUser}:${password}` });
if (loginResponse.response.status === 200)
{
loginResponse.response.headers.getSetCookie().map(c => jar.setCookie(c, rommAddress));
await updateClient();
return status(200, loginResponse.response.statusText);
} else
{
console.error("Could not Login to Romm: ", loginResponse.response.statusText);
return status(loginResponse.response.status, loginResponse.response.statusText);
}
}
}

View file

@ -1,6 +1,9 @@
import { eq } from "drizzle-orm";
import { cache } from "./app";
import cacheSchema from "@schema/cache";
import { GithubReleaseSchema } from '@simeonradivoev/gameflow-sdk/shared';
import PQueue from "p-queue";
import z from "zod";
export const CACHE_KEYS = {
ROM_PLATFORMS: 'rom-platforms',
@ -8,17 +11,21 @@ export const CACHE_KEYS = {
STORE_GAME_MANIFEST: 'store-game-manifest'
} 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 updated_at = new Date();
if (cached && cached.expire_at > updated_at)
if (cached && cached.expire_at > updated_at && !options?.force)
{
return cached.data as T;
}
const data = await getter();
const data = await getter(cached?.data as T);
if (data === undefined) return data;
const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000);
@ -32,3 +39,16 @@ export async function getOrCached<T> (key: string, getter: () => Promise<T>, opt
return data;
}
export async function getOrCachedGithubRelease (path: string, forceCheck?: boolean)
{
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"
});
if (!response.ok) throw new Error(response.statusText);
const release = await GithubReleaseSchema.parseAsync(await response.json());
return release;
}), { expireMs: 1000 * 60 * 60, force: forceCheck });
}

View file

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

View file

@ -0,0 +1,66 @@
import { LaunchGameJob } from '../jobs/launch-game-job';
import { events, taskQueue } from '../app';
import { GamepadManager } from './manager';
export default async function Initialize ()
{
let startSelectPressed = false;
let endPressed = false;
const manager = new GamepadManager();
function handleFocus ()
{
const launchGameTask = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob);
if (launchGameTask)
{
taskQueue.waitForJob(LaunchGameJob.id).then(() => setTimeout(() => events.emit('focus'), 300));
launchGameTask.abort('exit');
} else
{
events.emit('focus');
}
}
const loop = setInterval(() =>
{
for (const pad of manager.getGamepads())
{
const state = pad.update();
if (!state) continue;
if (state.buttons.START && state.buttons.SELECT)
{
if (!startSelectPressed)
{
startSelectPressed = true;
handleFocus();
}
} else
{
startSelectPressed = false;
}
}
const keyboard = manager.getKeyboard();
const keyState = keyboard.update();
if (keyState?.keys.End && keyState?.keys.LeftControl)
{
if (!endPressed)
{
endPressed = true;
handleFocus();
}
} else
{
endPressed = false;
}
}, 100);
return {
cleanup: () =>
{
clearInterval(loop);
}
};
}

View file

@ -0,0 +1,34 @@
// ./gamepad/index.ts
import type { IGamepadBackend, GamepadState } from "./types";
export class Gamepad
{
private index: number;
private backend: IGamepadBackend | undefined;
constructor(index = 0)
{
this.index = index;
}
async init ()
{
if (process.platform === "win32")
{
const { GamepadWindows } = await import("./windows");
this.backend = new GamepadWindows(this.index);
}
}
update (): GamepadState | null
{
return this.backend?.update() ?? null;
}
close ()
{
this.backend?.close?.();
}
}

View file

@ -0,0 +1,22 @@
import type { IKeyboardBackend, KeyboardState } from "./types";
export class Keybaord
{
private backend: IKeyboardBackend | undefined;
async init ()
{
if (process.platform === "win32")
{
const { KeyboardWindows } = await import("./windows");
this.backend = new KeyboardWindows();
} else
{
}
}
update (): KeyboardState | null
{
return this.backend?.update() ?? null;
}
}

View file

@ -0,0 +1,23 @@
import { IGamepadBackend, GamepadState } from "./types";
export class GamepadLinux implements IGamepadBackend
{
constructor(index = 0)
{
}
update (): GamepadState | null
{
return null;
}
isConnected ()
{
return false;
}
close ()
{
}
}

View file

@ -0,0 +1,65 @@
import { Gamepad } from "./gamepad";
import { platform } from "os";
import { Keybaord } from "./keyboard";
export class GamepadManager
{
private gamepads: Gamepad[] = [];
private keyboard: Keybaord;
private scanInterval: any;
constructor()
{
this.scanGamepads();
this.keyboard = new Keybaord();
this.keyboard.init();
// scan every second for new/disconnected devices
this.scanInterval = setInterval(async () => this.scanGamepads(), 1000);
}
private async scanGamepads ()
{
const max = platform() === "win32" ? 4 : 8; // max controllers
for (let i = 0; i < max; i++)
{
if (!this.gamepads[i])
{
try
{
const pad = new Gamepad(i);
await pad.init();
if (pad.update())
{
this.gamepads[i] = pad;
console.log(`Gamepad ${i} connected`);
}
} catch { }
} else
{
const connected = this.gamepads[i].update() !== null;
if (!connected)
{
console.log(`Gamepad ${i} disconnected`);
this.gamepads[i].close();
delete this.gamepads[i];
}
}
}
}
getKeyboard ()
{
return this.keyboard;
}
getGamepads ()
{
return this.gamepads.filter(Boolean);
}
stop ()
{
clearInterval(this.scanInterval);
for (const pad of this.gamepads) pad.close?.();
}
}

View file

@ -0,0 +1,53 @@
export type ButtonName =
| "A" | "B" | "X" | "Y"
| "UP" | "DOWN" | "LEFT" | "RIGHT"
| "LB" | "RB"
| "START" | "SELECT"
| "L3" | "R3";
export type KeyCode =
| "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"
| "KeyW" | "KeyA" | "KeyS" | "KeyD"
| "Enter" | "Escape" | "Space" | "End" | "LeftShift" | "RightShift" | "LeftControl" | "RightControl" | "LeftAlt" | "RightAlt";
export interface KeyboardState
{
keys: Record<KeyCode, boolean>;
}
export interface IKeyboardBackend
{
update (): KeyboardState;
}
export interface Stick
{
x: number; // -1 → 1
y: number; // -1 → 1
}
export interface Triggers
{
left: number; // 0 → 1
right: number; // 0 → 1
}
export interface GamepadState
{
buttons: Record<ButtonName, boolean>;
leftStick: Stick;
rightStick: Stick;
triggers: Triggers;
}
export interface IGamepadBackend
{
/** Polls the current state; returns null if disconnected */
update (): GamepadState | null;
/** Optional: release resources (like closing fd on Linux) */
close?(): void;
/** Optional: check if the gamepad is still connected */
isConnected?(): boolean;
}

View file

@ -0,0 +1,117 @@
import { IGamepadBackend, GamepadState, ButtonName, IKeyboardBackend, KeyboardState, KeyCode } from "./types";
import { dlopen, FFIType } from "bun:ffi";
const xinput = dlopen("xinput1_4.dll", {
XInputGetState: { args: [FFIType.u32, FFIType.ptr], returns: FFIType.u32 },
});
const user32 = dlopen("user32.dll", {
GetAsyncKeyState: {
args: [FFIType.i32],
returns: FFIType.i16,
},
});
// Virtual key codes
const VK: Record<KeyCode, number> = {
ArrowUp: 0x26,
ArrowDown: 0x28,
ArrowLeft: 0x25,
ArrowRight: 0x27,
KeyW: 0x57,
KeyA: 0x41,
KeyS: 0x53,
KeyD: 0x44,
Enter: 0x0D,
Escape: 0x1B,
Space: 0x20,
End: 0x23,
LeftShift: 0xA0,
RightShift: 0xA1,
LeftControl: 0xA2,
RightControl: 0xA3,
LeftAlt: 0xA4,
RightAlt: 0xA5,
};
const ERROR_SUCCESS = 0;
export class KeyboardWindows implements IKeyboardBackend
{
private keys: Record<KeyCode, boolean> = {} as any;
update (): KeyboardState
{
const next: Record<KeyCode, boolean> = {} as any;
// default all keys to false
// poll keys globally
for (const vkStr in VK)
{
const vk = Number(VK[vkStr as KeyCode]);
const key = vkStr;
const state = user32.symbols.GetAsyncKeyState(vk);
if ((state & 0x8000) !== 0)
{
next[key as KeyCode] = true;
}
}
this.keys = next;
return { keys: this.keys };
}
}
export class GamepadWindows implements IGamepadBackend
{
private index: number;
private buffer = new ArrayBuffer(16);
private view = new DataView(this.buffer);
private currButtons = 0;
constructor(index = 0) { this.index = index; }
update (): GamepadState | null
{
const res = xinput.symbols.XInputGetState(this.index, this.buffer);
if (res !== ERROR_SUCCESS) return null;
this.prevButtons = this.currButtons;
this.currButtons = this.view.getUint16(4, true);
const btns: Record<ButtonName, boolean> = {
A: (this.currButtons & 0x1000) !== 0,
B: (this.currButtons & 0x2000) !== 0,
X: (this.currButtons & 0x4000) !== 0,
Y: (this.currButtons & 0x8000) !== 0,
UP: (this.currButtons & 0x0001) !== 0,
DOWN: (this.currButtons & 0x0002) !== 0,
LEFT: (this.currButtons & 0x0004) !== 0,
RIGHT: (this.currButtons & 0x0008) !== 0,
LB: (this.currButtons & 0x0100) !== 0,
RB: (this.currButtons & 0x0200) !== 0,
START: (this.currButtons & 0x0010) !== 0,
SELECT: (this.currButtons & 0x0020) !== 0,
L3: (this.currButtons & 0x0040) !== 0,
R3: (this.currButtons & 0x0080) !== 0,
};
return {
buttons: btns,
leftStick: { x: this.view.getInt16(6, true) / 32767, y: this.view.getInt16(8, true) / 32767 },
rightStick: { x: this.view.getInt16(10, true) / 32767, y: this.view.getInt16(12, true) / 32767 },
triggers: { left: this.view.getUint8(14) / 255, right: this.view.getUint8(15) / 255 },
};
}
isConnected ()
{
const res = xinput.symbols.XInputGetState(this.index, this.buffer);
return res === ERROR_SUCCESS;
}
}

View file

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

View file

@ -1,4 +1,12 @@
// 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
export const cores: Record<string, string> = {
"atari5200": "atari5200",
@ -44,3 +52,56 @@ export const cores: Record<string, string> = {
"vic20": "vic20",
"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

@ -0,0 +1,16 @@
import Elysia, { status } from "elysia";
import { plugins } from "../app";
import { FrontEndCollection } from "@simeonradivoev/gameflow-sdk/shared";
export default new Elysia()
.get('/collections', async () =>
{
const collections: FrontEndCollection[] = [];
await plugins.hooks.games.fetchCollections.promise({ collections });
return collections;
}).get('/collection/:source/:id', async ({ params: { source, id } }) =>
{
const collection = await plugins.hooks.games.fetchCollection.promise({ source, id });
if (!collection) return status("Not Found");
return collection;
});

View file

@ -1,22 +1,29 @@
import Elysia, { status } from "elysia";
import { activeGame, config, db, events, taskQueue } from "../app";
import { and, eq, getTableColumns, sql } from "drizzle-orm";
import { config, db, emulatorsDb, plugins, taskQueue } from "../app";
import { and, desc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm";
import z from "zod";
import * as schema from "@schema/app";
import fs from "node:fs/promises";
import { FrontEndGameType, FrontEndGameTypeDetailed, GameListFilterSchema } from "@shared/constants";
import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
import { SERVER_URL } from "@shared/constants";
import { CommandEntry, DownloadLookupEntry, DownloadsLookupFilterValues, GameListFilterSchema } from '@simeonradivoev/gameflow-sdk/shared';
import { InstallJob } from "../jobs/install-job";
import path from "node:path";
import { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameMatch } from "./services/utils";
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
import buildStatusResponse, { customUpdate, fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService";
import { errorToResponse } from "elysia/adapter/bun/handler";
import { launchCommand } from "./services/launchGameService";
import { getErrorMessage } from "@/bun/utils";
import { getErrorMessage, SeededRandom } from "@/bun/utils";
import { defaultFormats, defaultPlugins } from 'jimp';
import { createJimp } from "@jimp/core";
import webp from "@jimp/wasm-webp";
import { extractStoreGameSourceId, getStoreGame, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
import * as emulatorSchema from '@schema/emulators';
import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
import { host } from "@/bun/utils/host";
import { LaunchGameJob } from "../jobs/launch-game-job";
import { cores } from "../emulatorjs/emulatorjs";
import { findEmulatorPluginIntegration } from "../store/services/emulatorsService";
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
const Jimp = createJimp({
@ -26,29 +33,43 @@ const Jimp = createJimp({
async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height, noBlur }: { blur?: number, width?: number, height?: number; noBlur?: boolean; })
{
if (blur && !noBlur)
{
const jimp = await Jimp.read(img);
if (width)
{
jimp.resize({ w: width, h: height });
}
if (height)
{
jimp.resize({ w: width, h: height });
}
if (blur)
{
jimp.blur(blur);
}
return jimp.getBuffer('image/png');
try
{
if ((blur && !noBlur))
{
const jimp = await Jimp.read(img);
if (blur && !noBlur)
{
jimp.blur(blur);
}
if (width)
{
jimp.resize({ w: width, h: height });
} else if (height)
{
jimp.resize({ w: width, h: height });
}
return jimp.getBuffer('image/png');
}
} catch (e)
{
}
if (typeof img === 'string')
{
const rommFetch = await fetch(img);
return rommFetch;
const res = await fetch(img);
return new Response(res.body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("Content-Type") ?? "image/jpeg",
"Cache-Control": "public, max-age=86400",
},
});
}
return img;
@ -123,10 +144,30 @@ export default new Elysia()
})
.get('/games', async ({ query, set }) =>
{
const games: FrontEndGameType[] = [];
const where: any[] = [];
let localGamesSet: Set<string> | undefined;
if (query.platform_slug)
{
where.push(eq(schema.platforms.slug, query.platform_slug));
} else if (query.platform_id && query.platform_source === 'local')
{
where.push(eq(schema.platforms.id, query.platform_id));
}
else if (query.platform_id && query.platform_source)
{
const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: query.platform_id ? String(query.platform_id) : undefined });
if (platform)
{
where.push(eq(schema.platforms.slug, platform?.slug));
}
}
if (query.search)
{
where.push(like(schema.games.name, query.search));
}
if (query.source)
@ -134,189 +175,272 @@ export default new Elysia()
where.push(eq(schema.games.source, query.source));
}
const games: FrontEndGameType[] = [];
let localGamesSet: Set<string> | undefined;
const ordering: any[] = [];
if (!query.collection_id)
if (query.orderBy)
{
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)
.offset(query.offset ?? 0)
.limit(query.limit ?? 50)
.where(and(...where));
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;
}
}
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
games.push(...localGames.map(g =>
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))
{
return convertLocalToFrontend(localGames.find(g => localGameExistsPredicate({ id: { id: g.source_id ?? '', source: g.source ?? '' }, igdb_id: g.igdb_id, ra_id: g.ra_id }))!);
}
else
{
return g;
}
}));
} else
{
games.push(...localGames.slice(query.offset, query.limit !== undefined ? ((query.offset ?? 0) + query.limit) : undefined).filter(g =>
{
if (query.genres && query.genres.length > 0)
{
if (!g.metadata) return false;
if (!g.metadata.genres) return false;
if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false;
}
return true;
}).map(g =>
{
return convertLocalToFrontend(g);
}));
}
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
{
const rommGames = await getRomsApiRomsGet({
query: {
platform_ids: query.platform_id ? [query.platform_id] : undefined,
collection_id: query.collection_id,
limit: query.limit,
offset: query.offset
}, throwOnError: true
});
games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(`romm@${g.id}`)).map(g =>
if (query.localOnly !== true)
{
return convertRomToFrontend(g);
}));
}
if (query.source === 'store')
{
const gamesManifest = await getStoreGameManifest();
set.headers['x-max-items'] = gamesManifest.filter(g => g.type === 'blob').length;
const storeGames = await Promise.all(gamesManifest
.slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), gamesManifest.length))
.map(async (e) =>
const remoteGames: FrontEndGameTypeWithIds[] = [];
const remoteGameSet = new Set<string>();
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
games.push(...remoteGames.filter(g =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) });
if (localGame)
if (localGameExistsPredicate(g))
{
return undefined;
return false;
}
const storeGame = await getStoreGameFromPath(e.path);
if (g.igdb_id)
{
const igdbId = `igdb@${g.igdb_id}`;
if (remoteGameSet.has(igdbId)) return false;
remoteGameSet.add(igdbId);
}
return convertStoreToFrontend(system, id, storeGame);
if (g.ra_id)
{
const raId = `ra@${g.ra_id}`;
if (remoteGameSet.has(raId)) return false;
remoteGameSet.add(raId);
}
return true;
}));
games.push(...storeGames.filter(g => g !== undefined));
}
}
if (query.orderBy)
{
switch (query.orderBy)
{
case 'added':
games.sort((a, b) => b.updated_at.getTime() - a.updated_at.getTime());
break;
case 'activity':
games.sort((a, b) => Math.max(b.updated_at.getTime(), b.last_played?.getTime() ?? 0) - Math.max(a.updated_at.getTime(), a.last_played?.getTime() ?? 0));
break;
case 'name':
games.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
break;
case "release":
games.sort((a, b) => (b.metadata.first_release_date?.getTime() ?? 0) - (a.metadata.first_release_date?.getTime() ?? 0));
break;
}
}
return { games };
}, {
query: GameListFilterSchema,
})
.get('/rom/:source/:id', async ({ params: { id, source } }) =>
.get('/games/filters', async ({ query: { source } }) =>
{
const localGame = await db.query.games.findFirst({
where: getLocalGameMatch(id, source),
columns: { path_fs: true }
const filterSets: FrontEndFilterSets = {
age_ratings: new Set(),
player_counts: new Set(),
languages: new Set(),
companies: new Set(),
genres: new Set()
};
let filter: any = undefined;
if (source) filter = eq(schema.games.source, source);
const local_metadata = await db.query.games.findMany({ columns: { metadata: true }, where: filter });
local_metadata.forEach(game =>
{
game.metadata.age_ratings?.forEach(r => filterSets.age_ratings.add(r));
game.metadata.genres?.forEach(r => filterSets.genres.add(r));
game.metadata.companies?.forEach(r => filterSets.companies.add(r));
if (game.metadata.player_count)
filterSets.player_counts.add(game.metadata.player_count);
});
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');
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(filePaths[0]);
return Bun.file(path_fs);
}, {
params: z.object({ source: z.string(), id: z.string() })
})
.get('/game/:source/:id', async ({ params: { source, id } }) =>
{
async function getLocalGameDetailed (match: any)
const sourceData = await getSourceGameDetailed(source, id);
if (sourceData)
{
const localGame = await db.query.games.findFirst({
where: match,
with: {
screenshots: { columns: { id: true } },
platform: { columns: { name: true, slug: true } }
}
});
if (localGame)
if (sourceData.platform_slug)
{
const exists = await checkInstalled(localGame.path_fs);
const fileSize = await calculateSize(localGame.path_fs);
const game: FrontEndGameTypeDetailed = {
path_cover: `/api/romm/game/local/${localGame.id}/cover`,
updated_at: localGame.created_at,
id: { id: String(localGame.id), source: 'local' },
path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`,
fs_size_bytes: fileSize ?? null,
paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`),
local: true,
missing: !exists,
platform_display_name: localGame.platform?.name,
summary: localGame.summary,
source: localGame.source,
source_id: localGame.source_id,
path_fs: localGame.path_fs,
last_played: localGame.last_played,
slug: localGame.slug,
name: localGame.name,
platform_id: localGame.platform_id,
platform_slug: localGame.platform.slug
};
return game;
}
return undefined;
}
if (source === 'local')
{
const localGame = await getLocalGameDetailed(eq(schema.games.id, Number(id)));
if (localGame) return localGame;
return status('Not Found');
}
else
{
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
if (localGame) return localGame;
if (source === 'romm')
{
const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } });
if (rom.data)
const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) });
if (systemMapping)
{
const romGame = convertRomToFrontendDetailed(rom.data);
return romGame;
const emulatorNames: string[] = [];
await plugins.hooks.emulators.findEmulatorForSystem.promise({ system: systemMapping.system, emulators: emulatorNames });
sourceData.emulators = (await Promise.all(emulatorNames.map(async name =>
{
if (name === 'EMULATORJS')
{
return {
name: 'EMULATORJS',
validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }],
logo: 'https://emulatorjs.org/logo/EmulatorJS.png',
systems: await Promise.all(Object.keys(cores).map(async c =>
{
const mapping = await emulatorsDb.query.systemMappings.findFirst({
where (fields, operators)
{
return operators.and(operators.eq(fields.source, "romm"), operators.eq(fields.system, c));
}, columns: { sourceSlug: true }
});
const system: EmulatorSystem = {
id: c,
name: c,
iconUrl: `/api/romm/image/romm/assets/platforms/${mapping?.sourceSlug}.svg`
};
return system;
})),
gameCount: 0,
source: 'local',
integrations: []
} satisfies FrontEndGameTypeDetailedEmulator;
}
const foundEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: name });
const execPaths: EmulatorSourceEntryType[] = [];
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: name, sources: execPaths });
const integrations = findEmulatorPluginIntegration(id, execPaths);
if (foundEmulator)
{
foundEmulator.validSources = execPaths;
foundEmulator.integrations = integrations;
return foundEmulator;
}
return {
name: name,
logo: "",
source: 'local',
systems: [],
gameCount: 0,
validSources: execPaths,
integrations: integrations
} satisfies FrontEndGameTypeDetailedEmulator;
}))).filter(e => !!e);
}
return status("Not Found", rom.response);
}
else if (source === 'store')
{
const gameId = extractStoreGameSourceId(id);
const storeGame = await getStoreGame(gameId.system, gameId.id);
if (!storeGame) return status("Not Found");
return convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame);
}
return sourceData;
} else
{
return status("Not Found");
}
}, {
params: z.object({ source: z.string(), id: z.string() })
})
.get('/status/:source/:id', async ({ params: { source, id }, set }) =>
{
set.headers["content-type"] = 'text/event-stream';
set.headers["cache-control"] = 'no-cache';
set.headers['connection'] = 'keep-alive';
return buildStatusResponse(source, id);
}, {
response: z.any(),
params: z.object({ id: z.string(), source: z.string() }),
query: z.object({ isLocal: z.boolean().optional() })
})
.use(buildStatusResponse())
.delete('/game/:source/:id', async ({ params: { source, id } }) =>
{
const deleted = await db.delete(schema.games).where(getLocalGameMatch(id, source)).returning({ path_fs: schema.games.path_fs });
@ -330,26 +454,83 @@ export default new Elysia()
}, {
params: z.object({ id: z.string(), source: z.string() }),
})
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
.post('/game/:source/:id/install', async ({ params: { id, source }, body }) =>
{
if (!taskQueue.hasActive())
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
{
if (source === 'romm' || source === 'store')
{
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id));
return status(200);
}
return status('Not Implemented');
return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, body));
} else
{
return status('Not Implemented');
}
}, {
params: z.object({ id: z.string(), source: z.string() }),
body: z.object({ downloadId: z.string().optional() }).optional(),
response: z.any()
})
.post('/game/:source/:id/play', async ({ params: { id, source }, query, set }) =>
.delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
{
const job = taskQueue.findJob(InstallJob.query({ source, id }), InstallJob);
if (job)
{
job.abort('cancel');
return status('OK');
}
return status('Not Found');
}, {
params: z.object({ id: z.string(), source: z.string() }),
response: z.any()
})
.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);
if (validCommands)
@ -362,11 +543,11 @@ export default new Elysia()
{
try
{
const validCommand = query.command_id ? validCommands.commands.find(c => c.id === query.command_id) : validCommands.commands[0];
const validCommand = command_id ? validCommands.commands.find(c => c.id === command_id) : validCommands.commands[0];
if (validCommand)
{
// launch command waits for the game to exit, we don't want that.
launchCommand(validCommand.command, source, id, validCommands.gameId);
await launchCommand(validCommand, validCommands.gameId, validCommands.source, validCommands.sourceId);
return { type: 'application', command: null };
} else
{
@ -382,18 +563,15 @@ export default new Elysia()
}
}, {
params: z.object({ id: z.string(), source: z.string() }),
query: z.object({ command_id: z.number().or(z.string()).optional() }),
body: z.object({ command_id: z.number().or(z.string()).optional() }),
response: z.object({ type: z.enum(['emulatorjs', 'application']), command: z.string().nullable() })
})
.post("/stop", async ({ }) =>
{
if (activeGame)
const job = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob);
if (job)
{
events.emit('activegameexit', {
source: 'local', id: String(activeGame.gameId),
exitCode: null,
signalCode: null
});
job.abort('cancel');
}
})
.get('/emulatorjs/data/cores/*', async ({ params }) =>
@ -401,7 +579,168 @@ export default new Elysia()
const res = await fetch(`https://cdn.emulatorjs.org/latest/data/cores/${params['*']}`);
return res;
})
.get('/emulatorjs/data/*', async ({ params }) =>
.get('/emulatorjs/data/*', async () =>
{
return status("Not Found");
})
.get('/recommended/games/emulator/:id', async ({ params: { id } }) =>
{
const emulator = await getStoreEmulatorPackage(id);
if (!emulator) return status("Not Found");
const systems = await buildStoreFrontendEmulatorSystems(emulator);
const games: FrontEndGameType[] = [];
let localGamesSet: Set<string> | undefined;
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)
.where(inArray(schema.platforms.slug, systems.map(s => s.id)));
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
games.push(...localGames.map(g =>
{
return convertLocalToFrontend(g);
}).slice(0, 3));
const remoteGames: FrontEndGameType[] = [];
await plugins.hooks.games.fetchRecommendedGamesForEmulator.promise({ emulator, systems, games: remoteGames });
games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`)));
return games;
})
.get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) =>
{
const sourceData = await getSourceGameDetailed(source, id);
if (!sourceData) return status("Not Found");
const sourceCompaniesSet = new Set(sourceData.metadata.companies);
const sourceGenresSet = new Set(sourceData.metadata.genres);
const games: (FrontEndGameType & { metadata?: any; })[] = [];
const localGames = await db.select({ ...getTableColumns(schema.games), platform: schema.platforms })
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.groupBy(schema.games.id);
const localGamesSourceSet = new Set(localGames.filter(g => g.source).map(g => `${g.source}@${g.source_id}`));
games.push(...localGames.map(g => convertLocalToFrontend(g)));
const remoteGames: (FrontEndGameType & { metadata?: any; })[] = [];
plugins.hooks.games.fetchRecommendedGamesForGame.promise({
game: sourceData, games: remoteGames
});
games.push(...remoteGames.filter(g => !localGamesSourceSet.has(`${g.id.source}@${g.id.id}`)));
const random = new SeededRandom(Math.round(new Date().getTime() / 1000 / 60 / 60));
const rankedGames = games.filter(g =>
{
if (sourceData.source && g.id.id === sourceData.source_id && g.id.source === sourceData.source)
{
return false;
}
if (g.id.id === sourceData.id.id && g.id.source === sourceData.id.source)
{
return false;
}
return true;
}).map(g =>
{
let rank = random.next();
if (g.platform_slug === sourceData.platform_slug)
rank += 1;
if (g.id.source === 'local')
rank -= 0.2;
if (g.metadata)
{
if (g.metadata.companies instanceof Array && g.metadata.companies.some((c: string) => sourceCompaniesSet.has(c)))
{
rank += 1;
}
if (g.metadata.genres instanceof Array && g.metadata.genres.some((g: string) => sourceGenresSet.has(g)))
{
rank += 1;
}
}
return { rank: rank, game: g };
});
rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank);
return rankedGames.map(g => g.game).slice(0, 10);
})
.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,19 +1,14 @@
import Elysia, { status } from "elysia";
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm";
import z from "zod";
import { count, eq, getTableColumns } from "drizzle-orm";
import { db } from "../app";
import { FrontEndPlatformType } from "@shared/constants";
import { and, count, eq, getTableColumns, not, notExists, or } from "drizzle-orm";
import { config, db, plugins } from "../app";
import * as schema from "@schema/app";
import { CACHE_KEYS, getOrCached } from "../cache";
import { findPlatform } from "./services/utils";
import { FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared";
export default new Elysia()
.get('/platforms', async () =>
{
const platforms: FrontEndPlatformType[] = [];
let rommPlatformsSet: Set<string> | undefined;
const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e));
const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) })
.from(schema.platforms)
.leftJoin(schema.games, eq(schema.games.platform_id, schema.platforms.id))
@ -21,31 +16,31 @@ export default new Elysia()
const localPlatformSet = new Set(localPlatforms.filter(p => p.game_count > 0).map(p => p.slug));
if (rommPlatforms)
const remotePlatforms: FrontEndPlatformType[] = [];
await plugins.hooks.games.fetchPlatforms.promise({ platforms: remotePlatforms });
await Promise.all(remotePlatforms.map(async p =>
{
const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p =>
p.hasLocal = localPlatformSet.has(p.slug);
if (p.paths_screenshots.length <= 0)
{
const game = await getRomsApiRomsGet({ query: { platform_ids: [p.id] } });
const platform: FrontEndPlatformType = {
slug: p.slug,
name: p.display_name,
family_name: p.family_name,
path_cover: `/api/romm/image/romm/assets/platforms/${p.slug}.svg`,
game_count: p.rom_count,
updated_at: new Date(p.updated_at),
id: { source: 'romm', id: String(p.id) },
hasLocal: localPlatformSet.has(p.slug),
paths_screenshots: game.data?.items[0]?.merged_screenshots.map(s => `/api/romm/image/romm/${s}`) ?? []
};
const localScreenshots = await db.select({ id: schema.screenshots.id }).from(schema.games).leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)).where(eq(schema.platforms.slug, p.slug)).leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)).limit(1);
return platform;
}));
if (localScreenshots)
p.paths_screenshots.push(...localScreenshots.map(s => `/api/romm/screenshot/${s.id}`));
}
rommPlatformsSet = new Set(rommPlatforms.map(p => p.slug));
platforms.push(...frontEndPlatforms);
}
const localGames = await db.select({ id: schema.games.id, source: schema.games.source, souceId: schema.games.source_id }).from(schema.games).leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)).where(and(eq(schema.platforms.slug, p.slug), not(eq(schema.games.source, 'romm')))).groupBy(schema.games.id);
p.game_count += localGames.length;
}));
platforms.push(...await Promise.all(localPlatforms.filter(p => !rommPlatformsSet?.has(p.slug)).map(async p =>
const platformSlugSet = new Set(remotePlatforms.map(p => p.slug));
const platforms: FrontEndPlatformType[] = [];
platforms.push(...remotePlatforms);
platforms.push(...await Promise.all(localPlatforms.filter(p => !platformSlugSet?.has(p.slug)).map(async p =>
{
const game = await db.query.games.findFirst({ where: eq(schema.games.platform_id, p.id) });
let screenshots: { id: number; }[] = [];
@ -73,31 +68,9 @@ export default new Elysia()
return { platforms };
}).get('/platforms/:source/:id', async ({ params: { source, id } }) =>
{
if (source === 'romm')
if (source === 'local')
{
const { data: rommPlatform, response } = await getPlatformApiPlatformsIdGet({ path: { id } });
if (rommPlatform)
{
const platform: FrontEndPlatformType = {
slug: rommPlatform.slug,
name: rommPlatform.display_name,
family_name: rommPlatform.family_name,
path_cover: `/api/romm/image/romm/assets/platforms/${rommPlatform.slug}.svg`,
game_count: rommPlatform.rom_count,
updated_at: new Date(rommPlatform.updated_at),
id: { source: 'romm', id: String(rommPlatform.id) },
paths_screenshots: [],
hasLocal: false
};
return platform;
}
return status("Not Found", response);
}
else if (source === 'local')
{
const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, id) });
const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, Number(id)) });
if (localPlatform)
{
const platform: FrontEndPlatformType = {
@ -116,10 +89,15 @@ export default new Elysia()
}
return status("Not Found");
} else
{
const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id });
if (!remotePlatform) return status("Not Found");
const local = await db.query.platforms.findFirst({ where: or(eq(schema.platforms.slug, remotePlatform?.slug), eq(schema.platforms.name, remotePlatform?.name)) });
return { ...remotePlatform, hasLocal: !!local };
}
return status("Not Implemented");
}, { params: z.object({ source: z.string(), id: z.coerce.number() }) }).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';
@ -138,4 +116,70 @@ export default new Elysia()
set.headers["content-type"] = coverBlob.cover_type;
}
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,350 +1,72 @@
import path from 'node:path';
import { which } from 'bun';
import { Glob } from 'bun';
import fs from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import * as schema from '@schema/emulators';
import * as appSchema from "@schema/app";
import { eq } from 'drizzle-orm';
import { activeGame, config, db, emulatorsDb, events, setActiveGame } from '../../app';
import os from 'node:os';
import { $ } from 'bun';
import { spawn } from 'node:child_process';
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
import { CommandEntry } from '@/shared/constants';
import { existsSync } from 'node:fs';
import { config, taskQueue } from '../../app';
import { LaunchGameJob } from '../../jobs/launch-game-job';
import { getStoreEmulatorPackage } from '../../store/services/gamesService';
import { getOrCachedScoopPackage } from '../../store/services/emulatorsService';
import { CommandEntry, EmulatorSourceEntryType, FrontEndId } from '@simeonradivoev/gameflow-sdk/shared';
export const varRegex = /%([^%]+)%/g;
export async function launchCommand (validCommand: string, source: string, sourceId: string, id: number)
export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string)
{
if (activeGame && activeGame.process?.killed === false)
if (taskQueue.hasActiveOfType(LaunchGameJob))
{
throw new Error(`${activeGame.name} currently running`);
throw new Error(`Game currently running`);
}
const localGame = await db.query.games.findFirst({
where: eq(appSchema.games.id, id), columns: {
name: true,
source_id: true,
source: true
}
});
await new Promise((resolve, reject) =>
{
const game = spawn(validCommand, {
shell: true
});
game.stdout.on('data', data => console.log(data));
game.on('close', (code) =>
{
events.emit('activegameexit', { source, id: sourceId, exitCode: code, signalCode: null });
resolve(code);
});
game.on('error', e =>
{
console.error(e);
events.emit('notification', { message: e.message, type: 'error' });
reject(e);
});
setActiveGame({
process: game,
name: localGame?.name ?? "Unknown",
gameId: id,
command: validCommand
});
function updateRommProps (id: number)
{
updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } });
events.emit('notification', { message: "Updated Last Played", type: 'success' });
}
if (source === 'romm')
{
updateRommProps(Number(sourceId));
}
else if (localGame?.source === 'romm' && localGame.source_id)
{
updateRommProps(Number(localGame.source_id));
}
});
/* Old spawn lanching, cases issues, needs to be ran as shell
const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]);
const game = setActiveGame({
process: Bun.spawn({
cmd,
env: {
...process.env
},
onExit (subprocess, exitCode, signalCode, error)
{
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
},
stdin: "ignore",
stdout: "inherit",
stderr: "inherit",
}),
name: localGame?.name ?? "Unknown",
gameId: validCommand.gameId,
command: validCommand.command.command
});
await game.process.exited;
if (game.process.exitCode && game.process.exitCode > 0)
{
return status('Internal Server Error');
}*/
taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId));
}
export async function getValidLaunchCommands (data: {
systemSlug: string;
gamePath: string;
customEmulatorConfig: {
get: (id: string) => string | undefined,
has: (id: string) => boolean,
};
}): Promise<CommandEntry[]>
export async function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): Promise<EmulatorSourceEntryType | undefined>
{
const system = await emulatorsDb.query.systems.findFirst({
with: { commands: true },
where: eq(schema.systems.name, data.systemSlug)
});
if (!system)
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
const storeExecName = emulator?.systempath.find(name => existsSync(path.join(storeEmulatorFolder, name)));
if (storeExecName)
{
throw new Error(`Could not find system '${data.systemSlug}'`);
return { binPath: path.join(storeEmulatorFolder, storeExecName), rootPath: storeEmulatorFolder, exists: true, type: "store" };
}
if (!system.extension || system.extension.length <= 0)
const storeEmulator = await getStoreEmulatorPackage(id);
if (storeEmulator?.downloads)
{
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}}`)))
const storeExecName = (await Promise.all(storeEmulator.downloads[`${process.platform}:${process.arch}`].map(async dl =>
{
validFiles.push(file);
}
if (validFiles.length <= 0)
{
throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`);
}
} else
{
if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase())))
{
validFiles.push(gamePath);
}
else
{
throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`);
}
}
const formattedCommands = await Promise.all(system.commands.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%': $.escape(rom),
'%ROMRAW%': validFiles[0],
'%ROMRAWWIN%': $.escape(validFiles[0].replace('/', '\\')),
'%ESPATH%': $.escape(path.dirname(Bun.main)),
'%ROMPATH%': $.escape(gamePath),
'%BASENAME%': $.escape(path.basename(validFiles[0], path.extname(validFiles[0]))),
'%FILENAME%': $.escape(path.basename(validFiles[0]))
};
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (subscring, injectFile: string) =>
{
try
// glob file search causes issues so do manual search
if (await fs.exists(storeEmulatorFolder))
{
const resolvedInjectFile = injectFile.replace(varRegex, (a) =>
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')
{
return staticVars[a] ?? a;
});
if (existsSync(resolvedInjectFile))
{
const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' });
return rawContents.split('\n').map(v => v.replace('\r', '')).join(' ');
const data = await getOrCachedScoopPackage(id, dl.url);
if (data)
{
bin = data.bin;
}
}
return '';
} catch (error)
{
return '';
const files = (await fs.readdir(storeEmulatorFolder))
.filter(f =>
{
if (glob && glob.match(f)) return true;
if (bin && f === bin) return true;
});
return files.map(f => path.join(storeEmulatorFolder, f));
}
});
return [];
const matches = Array.from(cmd.matchAll(varRegex));
const varList = await Promise.all(matches.map(async ([value]) =>
}))).flatMap(f => f);
if (storeExecName.length > 0)
{
if (value.startsWith("%EMULATOR_"))
{
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
let exec = await findExecByName(emulatorName);
if (data.customEmulatorConfig.has(emulatorName))
{
exec = { path: data.customEmulatorConfig.get(emulatorName)!, type: 'custom' };
}
emulator = emulatorName;
return [[value, exec ? exec.path : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec.path)) : undefined]];
}
const key = value[0].substring(1, value.length - 1);
return [[value, process.env[key]]];
}));
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
vars['%ESCAPESPECIALS%'] = "";
vars['%HIDEWINDOW%'] = '';
// missing variable
const invalid = Object.entries(vars).find(c => c[1] === undefined);
const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
return {
id: index,
label: label ?? undefined,
command: formattedCommand,
valid: !invalid, emulator
} satisfies CommandEntry;
}));
return formattedCommands.filter(c => !!c);
}
export async function findExecByName (emulatorName: string)
{
const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) });
if (!emulator)
{
throw new Error(`Could not find emulator ${emulatorName}`);
}
return findExec(emulator);
}
export async function findExec (emulator: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
{
if (os.platform() === 'win32')
{
const regValues = emulator.winregistrypath;
if (regValues.length > 0)
{
for (const node of regValues)
{
const registryValue = await readRegistryValue(node);
if (registryValue)
{
return { path: registryValue, type: 'registry' };
}
}
return { binPath: storeExecName[0], rootPath: storeEmulatorFolder, exists: true, type: 'store' };
}
}
const systempaths = emulator.systempath;
if (systempaths.length > 0)
{
const systemPath = await resolveSystemPath(systempaths);
if (systemPath)
{
return { path: systemPath, type: 'system' };
}
}
const staticPaths = emulator.staticpath;
if (staticPaths.length > 0)
{
const staticPath = await resolveStaticPath(staticPaths);
if (staticPath)
{
return { path: staticPath, type: 'static' };
}
}
return undefined;
}
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,18 +1,19 @@
import { GameInstallProgress, GameStatusType, RPC_URL, } from "@shared/constants";
import { activeGame, config, customEmulators, db, events, taskQueue } from "../../app";
import { getValidLaunchCommands } from "./launchGameService";
import * as schema from '@schema/app';
import { config, db, plugins, taskQueue } from "../../app";
import { eq } from "drizzle-orm";
import { getErrorMessage } from "@/bun/utils";
import { getLocalGameMatch } from "./utils";
import { getRomApiRomsIdGet } from "@/clients/romm";
import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils";
import fs from 'node:fs/promises';
import { ErrorLike } from "elysia/universal";
import { getStoreGameFromId } from "../../store/services/gamesService";
import { cores } from "../../emulatorjs/emulatorjs";
import Elysia from "elysia";
import z from "zod";
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
import { LaunchGameJob } from "../../jobs/launch-game-job";
import * as appSchema from "@schema/app";
import { RPC_URL } from "@/shared/constants";
import { 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)
{
@ -23,217 +24,447 @@ class CommandSearchError extends Error
export async function getLocalGame (source: string, id: string)
{
const localGames = await db.select({ id: schema.games.id, path_fs: schema.games.path_fs, platform_slug: schema.platforms.es_slug })
.from(schema.games)
.where(getLocalGameMatch(id, source))
.leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id));
const localGame = await db.query.games.findFirst({
columns: {
id: true,
path_fs: true,
source: true,
source_id: true,
igdb_id: true,
ra_id: true,
main_glob: true
},
where: getLocalGameMatch(id, source),
with: {
platform: { columns: { slug: true } }
}
});
if (localGames.length > 0)
{
return localGames[0];
}
return undefined;
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);
if (localGame)
{
if (localGame.platform_slug)
const commands = await plugins.hooks.games.buildLaunchCommands.promise({
source: localGame.source,
sourceId: localGame.source_id,
id: { source: 'local', id: String(localGame.id) },
systemSlug: localGame.platform.slug,
gamePath: localGame.path_fs,
mainGlob: localGame.main_glob,
});
if (commands instanceof Error || !commands) return commands;
const validCommand = commands.find(c => c.valid);
if (validCommand)
{
if (localGame.path_fs)
{
try
{
const commands = await getValidLaunchCommands({ systemSlug: localGame.platform_slug, customEmulatorConfig: customEmulators, gamePath: localGame.path_fs });
if (cores[localGame.platform_slug])
{
const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`;
commands.push({
id: 'emulatorjs',
label: "Emulator JS", command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`, valid: true, emulator: 'emulatorjs'
});
}
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');
}
return {
commands: commands.filter(c => c.valid),
gameId: { id: String(localGame.id), source: 'local' },
source: localGame.source ?? source,
sourceId: localGame.source_id ? String(localGame.source_id) : id,
};
}
else
{
return new CommandSearchError('error', 'Missing Platform');
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;
}
export default async function buildStatusResponse (source: string, id: string)
export default function buildStatusResponse ()
{
let cleanup: (() => void) | undefined;
let closed = false;
return new Response(new ReadableStream({
async start (controller)
return new Elysia().ws('/status/:source/:id', {
response: z.discriminatedUnion('status', [
z.object({ status: z.literal('error'), error: z.unknown() }),
z.object({ status: z.literal('installed'), commands: z.array(z.any()), details: z.string().optional() }),
z.object({ status: z.literal('refresh'), localId: z.number().optional() }),
z.object({ status: z.literal(['queued']) }),
z.object({ status: z.literal('playing'), details: z.string() }),
z.object({ status: z.literal('install'), details: z.string(), sources: DownloadSourceSchema.array() }),
z.object({ status: z.literal('present'), details: z.string() }),
z.object({ status: z.literal(['download', 'extract']), progress: z.number() }),
]),
message (ws, data)
{
const encoder = new TextEncoder();
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh' | 'ping')
if (data === 'cancel')
{
if (closed) return;
const evntString = event ? `event: ${event}\n` : '';
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
const activeTask = taskQueue.findJob(InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }), InstallJob);
activeTask?.abort('cancel');
}
await sendLatests();
// seems to help with issue of buffers not flushing, keeping the connection open forcefully
const keepAlive = setInterval(() =>
{
if (closed) return clearInterval(keepAlive);
try
{
enqueue({}, 'ping');
} catch
{
closed = true;
clearInterval(keepAlive);
}
}, 15000);
const sourceId = `${source}-${id}`;
},
async open (ws)
{
sendLatests().catch(e => ws.send({ status: 'error', error: JSON.stringify(e) }));
const installJobId = InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id });
async function sendLatests ()
{
if (closed) return;
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), columns: { id: true } });
const activeTask = taskQueue.findJob(`install-rom-${source}-${id}`);
if (ws.readyState > 1) return;
const activeTask = taskQueue.findJob(InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }), InstallJob);
if (activeTask)
{
enqueue({
progress: activeTask.progress,
status: activeTask.state as any
});
if (activeTask.status === 'queued')
{
ws.send({ status: 'queued' });
} else
{
ws.send({ status: activeTask.state as InstallJobStates, progress: activeTask.progress });
}
} else if (activeGame && activeGame.gameId === localGame?.id)
} else if (taskQueue.hasActiveOfType(LaunchGameJob))
{
enqueue({ status: 'playing' as GameStatusType, details: 'Playing' });
ws.send({ status: 'playing', details: 'Playing' });
}
else
{
const validCommand = await getValidLaunchCommandsForGame(source, id);
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source) });
const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id);
if (validCommand)
{
if (validCommand instanceof Error)
{
enqueue({ status: validCommand.name as GameStatusType, error: validCommand.message });
ws.send({ status: 'error', error: validCommand.message });
}
else
{
enqueue({ status: 'installed', details: validCommand.commands[0].label, commands: validCommand.commands });
ws.send({
status: 'installed',
details: validCommand.commands[0].label,
commands: validCommand.commands
});
}
}
else if (source === 'romm')
} else if (!localGame && ws.data.params.source === 'store')
{
// TODO: Add Caching
const remoteGame = await getRomApiRomsIdGet({ path: { id: Number(id) } });
const stats = await fs.statfs(config.get('downloadPath'));
if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail)
{
enqueue({ status: 'error', error: "Not Enough Free Space" });
} else
{
enqueue({ status: 'install', details: 'Install' });
}
} else if (source === 'store')
{
const storeGame = await getStoreGameFromId(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 size = Number(fileResponse.headers.get('content-length'));
const stats = await fs.statfs(config.get('downloadPath'));
if (size > stats.bsize * stats.bavail)
{
enqueue({ status: 'error', error: "Not Enough Free Space" });
ws.send({ status: 'error', error: "Not Enough Free Space" });
} else
{
enqueue({ status: 'install', details: 'Install' });
ws.send({ status: 'install', details: 'Install' });
}*/
ws.send({ status: 'install', details: 'Install', sources });
} else if (!localGame)
{
const files = await plugins.hooks.games.fetchDownloads.promise({
source: ws.data.params.source,
id: ws.data.params.id
});
const sources = files?.map(d => ({ id: d.id, name: d.id })) ?? [];
let filesChecked: LocalDownloadFileEntry[] | undefined;
if (files && files.length)
{
filesChecked = await checkFiles(files[0].files, !!files[0].extract_path);
}
if (filesChecked && !filesChecked.some(f => f.exists === false || f.matches === false))
{
ws.send({ status: 'present', details: "Files Exist On Disk, Import" });
} else
{
const size = filesChecked?.filter(f => f.exists !== true || f.matches !== true).reduce((p, f) => p += f.size ?? 0, 0);
const stats = await fs.statfs(config.get('downloadPath'));
if (size && size > stats.bsize * stats.bavail)
{
ws.send({ status: 'error', error: "Not Enough Free Space" });
} else if (filesChecked?.some(f => f.exists === true && f.matches === false))
{
ws.send({ status: 'install', details: 'Some Files Present, Install', sources });
}
else
{
ws.send({ status: 'install', details: 'Install', sources });
}
}
} else
{
ws.send({ status: 'error', error: "No Way To Launch" });
}
}
}
const dispose: Function[] = [];
const handleActiveExit = async (data: { error?: ErrorLike; }) =>
const handleActiveExit = async (data: { error?: unknown; }) =>
{
if (data.error)
{
enqueue({
ws.send({
status: 'error',
error: data.error
}, 'error');
});
}
await sendLatests();
};
events.on('activegameexit', handleActiveExit);
dispose.push(() => events.off('activegameexit', handleActiveExit));
dispose.push(taskQueue.on('progress', ({ id, progress, state }) =>
dispose.push(taskQueue.on('progress', (data) =>
{
if (id.endsWith(sourceId))
if (data.id === installJobId)
{
enqueue({ progress, status: state as any });
ws.send({ status: data.job.state as InstallJobStates, progress: data.progress });
}
}));
dispose.push(taskQueue.on('completed', ({ id }) =>
dispose.push(taskQueue.on('queued', (data) =>
{
if (id.endsWith(sourceId))
if (data.id === installJobId)
{
enqueue({}, 'refresh');
ws.send({ status: 'queued' });
}
}));
dispose.push(taskQueue.on('error', ({ id, error }) =>
dispose.push(taskQueue.on('ended', (data) =>
{
if (id.endsWith(sourceId))
if (data.id === installJobId)
{
enqueue({
ws.send({ status: 'refresh', localId: (data.job.job as InstallJob).localGameId });
} else if (data.job.job instanceof LaunchGameJob)
{
handleActiveExit({});
}
}));
dispose.push(taskQueue.on('error', (data) =>
{
if (data.id === installJobId)
{
ws.send({
status: 'error',
error: getErrorMessage(error)
}, 'error');
error: getErrorMessage(data.error)
});
} else if (data.job.job instanceof LaunchGameJob)
{
handleActiveExit({ error: data.error });
}
}));
cleanup = () =>
(ws.data as any).cleanup = () =>
{
closed = true;
dispose.forEach(f => f());
};
},
cancel (reason)
close (ws, code, reason)
{
cleanup?.();
cleanup = undefined;
(ws.data as any).cleanup?.();
},
}));
});
}

View file

@ -1,23 +1,32 @@
import getFolderSize from "get-folder-size";
import fs from "node:fs/promises";
import path from "node:path";
import { config, db, emulatorsDb } from "../../app";
import { and, eq } from "drizzle-orm";
import { config, db, emulatorsDb, plugins } from "../../app";
import { and, eq, or } from "drizzle-orm";
import * as schema from "@schema/app";
import { FrontEndGameType, FrontEndGameTypeDetailed, StoreGameType } from "@shared/constants";
import { DetailedRomSchema, SimpleRomSchema } from "@clients/romm";
import { RPC_URL } from "@shared/constants";
import { hashFile } from "@/bun/utils";
import { host } from "@/bun/utils/host";
import * as emulatorSchema from "@schema/emulators";
import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared";
export async function calculateSize (installPath: string | null)
{
if (!installPath) return null;
return (await getFolderSize(path.join(config.get('downloadPath'), installPath))).size;
const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath);
return (await getFolderSize(finalPath)).size;
}
export async function checkInstalled (installPath: string | null)
{
if (!installPath) return false;
return fs.exists(path.join(config.get('downloadPath'), installPath));
const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath);
return fs.exists(finalPath);
}
export function getScreenshotLocalGameMatch (id: string, source: string)
{
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)
@ -25,38 +34,16 @@ export function getLocalGameMatch (id: string, source: string)
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id));
}
export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType
{
const game: FrontEndGameType = {
id: { id: String(rom.id), source: 'romm' },
path_cover: `/api/romm/image/romm${rom.path_cover_large}`,
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
updated_at: new Date(rom.updated_at),
slug: rom.slug,
platform_id: rom.platform_id,
platform_display_name: rom.platform_display_name,
name: rom.name,
path_fs: null,
path_platform_cover: `/api/romm/image/romm/assets/platforms/${rom.platform_slug}.svg`,
source: null,
source_id: null,
paths_screenshots: rom.merged_screenshots.map(s => `/api/romm/image/romm/${s}`),
platform_slug: rom.platform_slug
};
return game;
}
export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & {
platform?: typeof schema.platforms.$inferSelect | null;
screenshotIds?: number[];
})
{
const game: FrontEndGameType = {
platform_display_name: g.platform?.name ?? "Local",
platform_display_name: g.platform?.name ?? null,
id: { id: String(g.id), source: 'local' },
updated_at: g.created_at,
path_cover: `/api/romm/game/local/${g.id}/cover`,
path_covers: [`/api/romm/game/local/${g.id}/cover`],
source_id: g.source_id,
source: g.source,
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
@ -66,22 +53,29 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & {
slug: g.slug,
name: g.name,
platform_id: g.platform_id,
platform_slug: g.platform?.slug ?? null
platform_slug: g.platform?.slug ?? null,
metadata: {
first_release_date: g.metadata?.first_release_date !== undefined ? new Date(g.metadata?.first_release_date) : null
}
};
return game;
}
export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & {
platform?: typeof schema.platforms.$inferSelect | null;
export async function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & {
platform?: { name: string | null, slug: string | null; } | null;
screenshotIds?: number[];
})
{
const exists = await checkInstalled(g.path_fs);
const fileSize = await calculateSize(g.path_fs);
const game: FrontEndGameTypeDetailed = {
platform_display_name: g.platform?.name ?? "Local",
id: { id: String(g.id), source: 'local' },
updated_at: g.created_at,
path_cover: `/api/romm/game/local/${g.id}/cover`,
path_covers: [`/api/romm/game/local/${g.id}/cover`],
source_id: g.source_id,
source: g.source,
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
@ -93,94 +87,420 @@ export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSel
platform_id: g.platform_id,
platform_slug: g.platform?.slug ?? null,
summary: g.summary,
fs_size_bytes: 0,
missing: false,
local: true
fs_size_bytes: fileSize,
missing: !exists,
local: true,
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;
}
export async function convertStoreToFrontend (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameType>
export async function getLocalGameDetailed (match: any)
{
let size: number | null = null;
try
{
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
size = Number(fileResponse.headers.get('content-length'));
} catch (error)
{
console.error(error);
}
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
where: and(eq(emulatorSchema.systemMappings.system, system), eq(emulatorSchema.systemMappings.source, 'romm'))
const localGame = await db.query.games.findFirst({
where: match,
with: {
screenshots: { columns: { id: true } },
platform: { columns: { name: true, slug: true } }
}
});
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: 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
if (localGame)
{
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
size = Number(fileResponse.headers.get('content-length'));
} catch (error)
{
console.error(error);
return convertLocalToFrontendDetailed({ ...localGame, screenshotIds: localGame.screenshots.map(s => s.id) });
}
const detailed: FrontEndGameTypeDetailed = {
...await convertStoreToFrontend(system, id, storeGame),
summary: storeGame.description,
fs_size_bytes: size,
missing: false,
local: false,
};
return detailed;
return undefined;
}
export function convertRomToFrontendDetailed (rom: DetailedRomSchema)
export async function getSourceGameDetailed (source: string, id: string, options?: { sourceOnly?: boolean; })
{
const detailed: FrontEndGameTypeDetailed = {
...convertRomToFrontend(rom),
summary: rom.summary,
fs_size_bytes: rom.fs_size_bytes,
local: false,
missing: rom.missing_from_fs
};
if (rom.merged_ra_metadata?.achievements)
if (source === 'local')
{
detailed.achievements = {
unlocked: rom.merged_ra_metadata.achievements?.map(a => a.num_awarded).length,
total: rom.merged_ra_metadata.achievements.length
const localGame = await getLocalGameDetailed(eq(schema.games.id, Number(id)));
if (localGame) return localGame;
return undefined;
}
else
{
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame });
if (localGame && options?.sourceOnly !== true)
{
return localGame;
}
return remoteGame;
}
}
export async function checkFiles (files: DownloadFileEntry[], isArchive: boolean): Promise<LocalDownloadFileEntry[]>
{
return Promise.all(files.map(async f =>
{
// file is either zip or doesn't support sha checking
if (!f.sha1 || isArchive) return { ...f, exists: false, matches: false } satisfies LocalDownloadFileEntry;
const localPath = path.join(config.get('downloadPath'), f.file_path, f.file_name);
if (await fs.exists(localPath))
{
if (f.size && f.size !== (await fs.stat(localPath)).size)
{
return { ...f, exists: true, matches: false } satisfies LocalDownloadFileEntry;
}
const existingHash = await hashFile(localPath, 'sha1');
if (existingHash === f.sha1)
{
return { ...f, exists: true, matches: true } satisfies LocalDownloadFileEntry;
} else
{
return { ...f, exists: true, 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`
};
}
return detailed;
}
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

@ -0,0 +1,74 @@
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
import { config, plugins } from "../app";
import { simulateProgress } from "@/bun/utils";
import { Downloader } from "@/bun/utils/downloader";
import path from 'node:path';
import { ensureDir } from "fs-extra";
import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
import { DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
interface BiosDownloadJobData extends DownloadJobData
{
emulator: string;
}
export class BiosDownloadJob implements IJob<BiosDownloadJobData, "download">
{
static id = "bios-download-job" as const;
static query = (q: { id: string; }) => `${BiosDownloadJob.id}-${q.id}`;
group: string = "bios-download";
data: BiosDownloadJobData;
dryRun: boolean;
constructor(emulator: string, init?: { dryRun?: boolean; })
{
this.data = {
emulator,
name: "Download Emulator Bios"
};
this.dryRun = init?.dryRun ?? false;
}
async start (context: JobContext<IJob<BiosDownloadJobData, "download">, BiosDownloadJobData, "download">)
{
const emulator = await getStoreEmulatorPackage(this.data.emulator);
if (!emulator) throw new Error("Could Not Find Emulator");
this.data.name = `${emulator.name} Bios`;
this.data.preview_url = emulator.logo;
const systems = await buildStoreFrontendEmulatorSystems(emulator);
const biosFolder = path.join(config.get('downloadPath'), "bios", this.data.emulator);
await ensureDir(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 (this.dryRun)
{
await simulateProgress((p) => context.setProgress(p, 'download'), context.abortSignal);
} else
{
const headers: Record<string, string> = {};
if (files.auth)
headers['Authorization'] = files.auth;
const downloader = new Downloader('bios-download', files.files, biosFolder, {
signal: context.abortSignal,
headers,
onProgress: (stats) =>
{
context.setProgress(stats.progress, "download");
this.data.downloaded = stats.downloaded;
this.data.speed = stats.speed;
this.data.total = stats.total;
},
});
await downloader.start();
}
}
exposeData ()
{
return this.data;
}
}

View file

@ -0,0 +1,155 @@
import { DownloadJobData, EmulatorPackageType } from '@simeonradivoev/gameflow-sdk/shared';
import { getStoreEmulatorPackage } from "../store/services/gamesService";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
import { config, plugins } from "../app";
import path from 'node:path';
import Seven from 'node-7z';
import fs from "node:fs/promises";
import { Downloader } from "@/bun/utils/downloader";
import { ensureDir, move } from "fs-extra";
import { isArchive, simulateProgress } from "@/bun/utils";
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";
interface EmulatorDownloadJobData extends DownloadJobData
{
emulator: string;
}
export class EmulatorDownloadJob implements IJob<EmulatorDownloadJobData, EmulatorDownloadStates>
{
static id = "download-emulator" as const;
downloadSource: string;
emulatorPackage?: EmulatorPackageType;
dryRun: boolean;
isUpdate: boolean;
data: EmulatorDownloadJobData = {
name: "Download Emulator",
emulator: ""
};
constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; isUpdate?: boolean; })
{
this.data.emulator = emulator;
this.downloadSource = downloadSource;
this.dryRun = init?.dryRun ?? false;
this.isUpdate = init?.isUpdate ?? false;
}
async start (context: JobContext<EmulatorDownloadJob, EmulatorDownloadJobData, EmulatorDownloadStates>)
{
this.emulatorPackage = await getStoreEmulatorPackage(this.data.emulator);
if (!this.emulatorPackage) throw new Error("Emulator not found");
this.data.name = this.emulatorPackage.name;
this.data.preview_url = this.emulatorPackage.logo;
const { url, info } = await getEmulatorDownload(this.emulatorPackage, this.downloadSource);
const emulatorsFolder = getEmulatorPath(this.data.emulator);
if (this.dryRun)
{
await simulateProgress(p => context.setProgress(p, "download"), context.abortSignal);
await simulateProgress(p => context.setProgress(p, "extract"), context.abortSignal);
} else
{
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
const downloader = new Downloader(this.data.emulator,
[{ url, file_name: path.basename(url.pathname), file_path: this.data.emulator }],
tmpFolder,
{
signal: context.abortSignal,
onProgress: (stats) =>
{
context.setProgress(stats.progress, 'download');
this.data.total = stats.total;
this.data.downloaded = stats.downloaded;
this.data.speed = stats.speed;
},
});
const destinationPaths = await downloader.start();
context.abortSignal.throwIfAborted();
if (destinationPaths)
{
const archive = isArchive(destinationPaths[0]);
const isAppImage = destinationPaths[0].endsWith(".AppImage");
if (!archive && !isAppImage)
{
throw new Error("Invalid Download Type");
}
if (archive)
{
if (destinationPaths[0])
{
let destinationPath = destinationPaths[0];
if (destinationPath.endsWith('.tar'))
{
context.setProgress(0, "extract");
await ensureDir(emulatorsFolder);
await $`tar -xf ${destinationPath} -C ${emulatorsFolder}`;
await fs.rm(destinationPath, { recursive: true });
} else
{
await new Promise((resolve, reject) =>
{
const seven = Seven.extractFull(destinationPath, emulatorsFolder, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true, noRootDuplication: true });
seven.on('progress', p => context.setProgress(p.percent, "extract"));
seven.on('error', e => reject(e));
seven.on('end', () => resolve(true));
});
await fs.rm(destinationPath, { recursive: true });
}
// check if 1 root folder we need to get rid of
const contents = await fs.readdir(emulatorsFolder);
if (contents.length === 1)
{
const stat = await fs.stat(path.join(emulatorsFolder, contents[0]));
if (stat.isDirectory())
{
console.log("Found 1 root folder, using that instead");
const tmpEmulatorsFolder = `${emulatorsFolder} (1)`;
await move(path.join(emulatorsFolder, contents[0]), tmpEmulatorsFolder, { overwrite: true });
await move(tmpEmulatorsFolder, emulatorsFolder, { overwrite: true });
}
}
}
} else
{
await ensureDir(emulatorsFolder);
for (const destPath of destinationPaths)
{
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
});
}
}
}
exposeData ()
{
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,333 +1,124 @@
import { IJob, JobContext } from "../task-queue";
import { mkdir } from 'node:fs/promises';
import { and, eq, or } from 'drizzle-orm';
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
import fs from 'node:fs/promises';
import * as schema from "@schema/app";
import * as emulatorSchema from "@schema/emulators";
import path from 'node:path';
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet, PlatformSchema } from "@clients/romm";
import { config, db, emulatorsDb, jar } from "../app";
import unzip from 'unzip-stream';
import { Readable, Transform } from "node:stream";
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
import * as igdb from 'ts-igdb-client';
import secrets from "../secrets";
import { config, events, plugins } from "../app";
import { simulateProgress } from "@/bun/utils";
import z from "zod";
import { checkFiles, createLocalGame, downloadGame } from "../games/services/utils";
import { ensureDir } from "fs-extra";
import { DownloadInfo, DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
interface JobConfig
{
dryRun?: boolean;
dryDownload?: boolean;
downloadId?: string;
}
export class InstallJob implements IJob
export type InstallJobStates = 'download' | 'extract';
export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
{
static id = "install-job" as const;
static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`;
static dataSchema = z.never();
public gameId: string;
public source: string;
public sourceId: string;
public config?: JobConfig;
static id = "install-job" as const;
// The local game ID of newly created entry, if successful
public localGameId?: number;
public group = InstallJob.id;
public localPath?: string;
data: DownloadJobData = {
name: "Install Game"
};
constructor(id: string, source: string, sourceId: string, config?: JobConfig)
constructor(id: string, source: string, config?: JobConfig)
{
this.gameId = id;
this.config = config;
this.sourceId = sourceId;
this.source = source;
}
public async start (cx: JobContext)
public async start (cx: JobContext<InstallJob, DownloadJobData, InstallJobStates>)
{
cx.setProgress(0, 'download');
fs.mkdir(config.get('downloadPath'), { recursive: true });
await fs.mkdir(config.get('downloadPath'), { recursive: true });
const downloadPath = config.get('downloadPath');
const finalFiles: string[] = [];
let info: DownloadInfo | undefined;
if (this.config?.dryRun !== true)
{
const downloadPath = config.get('downloadPath');
const allDownloads = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId });
info = allDownloads?.[0];
let downloadUrl: URL;
let cookie: string = '';
let screenshotUrls: string[];
let coverUrl: string;
let rommPlatform: PlatformSchema | undefined;
let slug: string | null;
let path_fs: string | undefined;
let summary: string | null;
let name: string | null;
let last_played: Date | null;
let igdb_id: number | null;
let ra_id: number | null;
let source_id: string;
let system_slug: string;
let extract_path: string;
if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
switch (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))
{
case 'romm':
const rom = (await getRomApiRomsIdGet({ path: { id: Number(this.gameId) }, throwOnError: true })).data;
rommPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data;
const rommAddress = config.get('rommAddress');
coverUrl = `${rommAddress}${rom.path_cover_large}`;
screenshotUrls = rom.merged_screenshots.map(s => `${config.get('rommAddress')}${s}`);
last_played = rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null;
igdb_id = rom.igdb_id;
ra_id = rom.ra_id;
summary = rom.summary;
name = rom.name;
path_fs = path.join(rom.fs_path, rom.fs_name);
source_id = String(rom.id);
slug = rom.slug;
system_slug = rommPlatform.slug;
extract_path = '';
downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
downloadUrl.searchParams.set('rom_ids', String(this.gameId));
cookie = await jar.getCookieString(config.get('rommAddress') ?? '');
break;
case 'store':
const game = await getStoreGameFromId(this.gameId);
const gameId = extractStoreGameSourceId(this.gameId);
coverUrl = game.pictures.titlescreens[0];
screenshotUrls = game.pictures.screenshots;
downloadUrl = new URL(game.file);
slug = this.gameId;
source_id = this.gameId;
name = game.title;
summary = game.description;
system_slug = gameId.system;
extract_path = 'roms', gameId.system;
break;
default:
throw new Error("Unsupported source");
}
if (this.config?.dryDownload !== true)
{
/*
// download files for rom
const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
downloadUrl.searchParams.set('rom_ids', String(this.id));
const downloader = new DownloaderHelper(downloadUrl.href, downloadPath, {
headers: {
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
},
fileName: `${this.id}.zip`,
// Romm doesn't support resume download
override: true
});
cx.abortSignal.addEventListener('abort', downloader.stop);
downloader.on('progress.throttled', e =>
{
cx.setProgress(e.progress, 'download');
});
downloader.on('error', (e) =>
{
cx.abort(e);
});
const finishPromise = new Promise<string>(resolve =>
{
downloader.on("end", ({ filePath }) => resolve(filePath));
});
await downloader.start().catch(err => console.error(err));
const zipFilePath = await finishPromise;
cx.setProgress(0, 'extract');
const zip = new StreamZip.async({ file: zipFilePath });
const totalCount = await zip.entriesCount;
let extractCount = 0;
zip.on('extract', async (entry, file) =>
{
console.log(`Extracted ${entry.name} to ${file}`);
cx.setProgress(extractCount / totalCount * 100, 'extract');
extractCount++;
});
await zip.extract(null, downloadPath);
await zip.close();
await fs.rm(zipFilePath);*/
cx.setProgress(0, 'download');
const res = await fetch(downloadUrl, {
headers: {
cookie: cookie
const downloadedFiles = await downloadGame({
downloads: files.filter(f => !f.exists || !f.matches),
extract_path: info.extract_path,
path_fs: info.path_fs,
abortSignal: cx.abortSignal,
auth: info.auth,
id: `game-${this.source}-${this.gameId}`,
setProgress: (process, state, info) =>
{
cx.setProgress(process, state);
this.data.downloaded = info.downloaded;
this.data.speed = info.speed;
this.data.total = info.total;
},
});
const totalBytes = Number(res.headers.get("content-length")) || 0;
let bytesReceived = 0;
const progressStream = new Transform({
transform (chunk, encoding, callback)
{
bytesReceived += chunk.length;
if (totalBytes > 0)
{
const percent = (bytesReceived / totalBytes) * 100;
cx.setProgress(percent, 'download');
}
this.push(chunk);
callback();
}
});
await new Promise((resolve, reject) =>
{
const extract = unzip.Extract({ path: path.join(downloadPath, extract_path), });
(extract as any).unzipStream.on('entry', (entry: any) =>
{
if (!path_fs)
path_fs = path.join(extract_path, entry.path);
});
Readable.fromWeb(res.body as any).pipe(progressStream)
.pipe(extract)
.on('close', resolve)
.on('error', reject);
});
if (downloadedFiles)
finalFiles.push(...downloadedFiles);
}
if (this.config?.dryDownload === true)
if (this.config?.dryDownload === true && info.extract_path)
{
await mkdir(path.join(downloadPath, extract_path), { recursive: true });
await ensureDir(path.join(downloadPath, info.extract_path));
}
const coverResponse = await fetch(coverUrl);
const coverResponse = await fetch(info.coverUrl);
const cover = Buffer.from(await coverResponse.arrayBuffer());
if (cx.abortSignal.aborted) return;
await db.transaction(async (tx) =>
{
// Search for existing platform
const platformSearch = [eq(schema.platforms.slug, system_slug)];
const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, system_slug)];
if (rommPlatform)
{
if (rommPlatform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, rommPlatform.igdb_id));
if (rommPlatform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, rommPlatform.igdb_slug));
if (rommPlatform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, rommPlatform.ra_id));
if (rommPlatform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, rommPlatform.moby_id));
esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, 'romm'));
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform.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 platformCover = await fetch(`https://demo.romm.app/assets/platforms/${system_slug}.svg`);
if (!esPlatform && !rommPlatform)
{
// 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: rommPlatform?.slug ?? esPlatform?.system.name ?? '',
igdb_id: rommPlatform?.igdb_id,
igdb_slug: rommPlatform?.igdb_slug,
ra_id: rommPlatform?.ra_id,
cover: Buffer.from(await platformCover.arrayBuffer()),
cover_type: platformCover.headers.get('content-type'),
name: rommPlatform?.name ?? esPlatform?.system.fullname ?? '',
family_name: rommPlatform?.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,
source: this.source,
slug,
path_fs,
last_played: last_played,
platform_id: platformId,
igdb_id: igdb_id,
ra_id: ra_id,
summary: summary,
name,
cover,
cover_type: coverResponse.headers.get('content-type')
};
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
if (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', '=', igdb_id)).execute();
screenshotUrls.push(...data.filter(s => s.url).map(s => s.url!));
}
}
// pre-fetch screenshots
const screenshots = await Promise.all(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;
})));
}
cx.abortSignal.throwIfAborted();
this.localGameId = await createLocalGame({
cover,
coverType: coverResponse.headers.get('content-type'),
system_slug: info.system_slug,
source_id: info.source_id,
source: this.source,
slug: info.slug,
path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined),
summary: info.summary,
igdb_id: info.igdb_id,
ra_id: info.ra_id,
name: info.name,
main_glob: info.main_glob,
version: info.version,
version_source: info.version_source,
screenshotUrls: info.screenshotUrls,
version_system: info.version_system,
metadata: info.metadata,
platform: info.platform
});
}
if (this.source && this.gameId) await plugins.hooks.games.postInstall.promise({ source: this.source, id: this.gameId, files: finalFiles, info });
events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 });
} else
{
await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal);
}
}
}

View file

@ -1,61 +1,89 @@
import Elysia from "elysia";
import z, { } from "zod";
import z, { _ZodType } from "zod";
import { taskQueue } from "../app";
import { LoginJob } from "./login-job";
import TwitchLoginJob from "./twitch-login-job";
import UpdateStoreJob from "./update-store";
import EnsureStore from "./ensure-store";
import { EmulatorDownloadJob } from "./emulator-download-job";
import { getErrorMessage } from "@/bun/utils";
import { BaseEvent, IJob } from "@simeonradivoev/gameflow-sdk/task-queue";
import { LaunchGameJob } from "./launch-game-job";
import { BiosDownloadJob } from "./bios-download-job";
import { InstallJob } from "./install-job";
import ReloadPluginsJob from "./reload-plugins-job";
import { FrontEndJob } from "@simeonradivoev/gameflow-sdk/shared";
function registerJob<const Path extends string, TS, T extends { id: Path, dataSchema?: TS; }> (job: T, path: Path, dataSchema: TS)
function registerJob<
const Path extends string,
Schema,
const States extends string,
> (_job: {
id: Path;
query?: (q: any) => string;
} & (new (...args: any[]) => IJob<Schema, States>))
{
return new Elysia().ws(path, {
return new Elysia().ws(_job.id, {
body: z.discriminatedUnion('type', [
z.object({ type: z.literal('cancel') })
]),
query: z.record(z.string(), z.any()),
response: z.discriminatedUnion('type', [
z.object({
type: z.literal(['data', 'started', 'progress']),
status: z.string(),
state: z.string().optional(),
progress: z.number(),
data: dataSchema
data: z.custom<Schema>()
}),
z.object({ type: z.literal(['completed', 'ended']) }),
z.object({ type: z.literal('error'), error: z.unknown() })
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() })
]),
open (ws)
{
const job = taskQueue.findJob(path);
const jobId = (_job.query ? _job.query(ws.data.query) : _job.id);
const job = taskQueue.findJob(jobId, _job);
if (job)
{
ws.send({ type: 'data', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() as Schema });
} else
{
ws.send({ type: 'waiting' });
}
(ws.data as any).cleanup = [
taskQueue.on('started', ({ id, job }) =>
{
if (id === path)
if (id === jobId)
{
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
ws.send({ type: 'started', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
}
}),
taskQueue.on('progress', ({ id, job }) =>
{
if (id === path)
if (id === jobId)
{
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
ws.send({ type: 'progress', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
}
}),
taskQueue.on('completed', ({ id }) =>
taskQueue.on('completed', ({ id, job }) =>
{
if (id === path)
if (id === jobId)
{
ws.send({ type: 'completed' });
ws.send({ type: 'completed', data: job.job.exposeData?.() });
}
}),
taskQueue.on('ended', ({ id, job }) =>
{
if (id === jobId)
{
ws.send({ type: 'ended', data: job.job.exposeData?.() });
}
}),
taskQueue.on('error', ({ id, error }) =>
{
if (id === path)
if (id === jobId)
{
ws.send({ type: 'error', error: error });
ws.send({ type: 'error', error: getErrorMessage(error) });
}
})
];
@ -64,17 +92,100 @@ function registerJob<const Path extends string, TS, T extends { id: Path, dataSc
{
(ws.data as any).cleanup.forEach((d: Function) => d());
},
message (ws, message)
message (_, message)
{
if (message.type === 'cancel')
{
taskQueue.findJob(path)?.abort('cancel');
const jobId = (_job.query ? _job.query(this.query) : _job.id);
taskQueue.findJob(jobId, _job)?.abort('cancel');
}
},
});
}
export const jobs = new Elysia({ prefix: '/api/jobs' })
.use(registerJob(LoginJob, LoginJob.id, LoginJob.dataSchema))
.use(registerJob(TwitchLoginJob, TwitchLoginJob.id, TwitchLoginJob.dataSchema))
.use(registerJob(UpdateStoreJob, UpdateStoreJob.id, undefined));
.ws('/list', {
response: z.discriminatedUnion('type', [
z.object({ type: z.literal("allJobs"), active: z.custom<FrontEndJob>().array(), queued: z.custom<FrontEndJob>().array() }),
z.object({ type: z.literal("started"), job: z.custom<FrontEndJob>() }),
z.object({ type: z.literal("progress"), job: z.custom<FrontEndJob>() }),
z.object({ type: z.literal("queued"), job: z.custom<FrontEndJob>() }),
z.object({ type: z.literal("aborted"), id: z.string() }),
z.object({ type: z.literal("ended"), id: z.string() }),
]),
body: z.discriminatedUnion('type', [
z.object({ type: z.literal("cancel"), id: z.string() })
]),
message (ws, message)
{
switch (message.type)
{
case "cancel":
taskQueue.cancelJob(message.id);
break;
}
},
open (ws)
{
ws.send({
type: 'allJobs',
active: taskQueue.getActiveJobs().map(j =>
{
const job: FrontEndJob = {
id: j.id,
data: j.job.exposeData?.(),
progress: j.progress,
state: j.state,
status: j.status
};
return job;
}),
queued: taskQueue.getQueuedJobs()?.map(j =>
{
const job: FrontEndJob = {
id: j.id,
data: j.job.exposeData?.(),
progress: j.progress,
state: j.state,
status: j.status
};
return job;
}) ?? []
});
(ws.data as any).dispose = [taskQueue.on('started', (e: BaseEvent) =>
{
ws.send({ type: "started", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
}),
taskQueue.on('progress', (e: BaseEvent) =>
{
ws.send({ type: "progress", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
}),
taskQueue.on('queued', (e: BaseEvent) =>
{
ws.send({ type: "queued", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
}),
taskQueue.on('abort', (e: BaseEvent) =>
{
ws.send({ type: "aborted", id: e.id });
}),
taskQueue.on('ended', (e: BaseEvent) =>
{
ws.send({ type: "ended", id: e.id });
})];
},
close (ws, code, reason)
{
(ws.data as any).dispose.forEach((d: any) => d());
},
})
.use(registerJob(LaunchGameJob))
.use(registerJob(LoginJob))
.use(registerJob(TwitchLoginJob))
.use(registerJob(EnsureStore))
.use(registerJob(BiosDownloadJob))
.use(registerJob(InstallJob))
.use(registerJob(ReloadPluginsJob))
.use(registerJob(EmulatorDownloadJob));

View file

@ -0,0 +1,277 @@
import z from "zod";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
import { ActiveGameSchema, ActiveGameType } from "@simeonradivoev/gameflow-sdk";
import { config, db, events, plugins } from "../app";
import * as appSchema from "@schema/app";
import { eq } from "drizzle-orm";
import { spawn } from 'node:child_process';
import { updateLocalLastPlayed } from "../games/services/statusService";
import { getErrorMessage } from "@/bun/utils";
import { CommandEntry, FrontEndId, SaveSlots } from "@simeonradivoev/gameflow-sdk/shared";
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, string>
{
static id = "launch-game" as const;
static dataSchema = z.nullable(ActiveGameSchema);
group = "launch-game";
activeGame: ActiveGameType | null;
gameId: FrontEndId;
validCommand: CommandEntry;
gameSource?: string;
gameSourceId?: string;
changedSaveFiles: Map<string, { subPath: string, cwd: string; }>;
saveSlots: SaveSlots = {};
constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string)
{
this.gameId = gameId;
this.validCommand = validCommand;
this.gameSource = source;
this.gameSourceId = sourceId;
this.activeGame = null;
this.changedSaveFiles = new Map();
}
async postPlay (gameInfo: { platformSlug?: string; })
{
if (this.gameId.source === 'local')
{
await updateLocalLastPlayed(Number(this.gameId.id));
}
const source = this.gameSource ?? this.gameId.source;
const id = this.gameSourceId ?? this.gameId.id;
await 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({
autoValidCommand: this.validCommand,
game: {
source: this.gameSource,
sourceId: this.gameSourceId,
id: this.gameId,
platformSlug: gameInfo?.platformSlug
},
dryRun: false
});
await new Promise(async (resolve, reject) =>
{
try
{
let game: any;
if (!commandArgs)
{
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }).catch(e => reject(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)
{
this.saveSlots = commandArgs.savesPath ?? {};
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug });
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
{
reject(new Error("No Emulator Bin"));
return;
}
this.activeGame = {
process: game,
name: gameInfo?.name ?? "Unknown",
gameId: this.gameId,
source: this.gameSource,
sourceId: this.gameSourceId,
command: this.validCommand
};
} catch (e)
{
context.abort(e);
resolve(e);
}
});
await this.postPlay({ platformSlug: gameInfo?.platformSlug });
}
exposeData ()
{
return this.activeGame;
}
}

View file

@ -1,5 +1,5 @@
import Elysia, { status } from "elysia";
import { IJob, JobContext } from "../task-queue";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
import { host, localIp } from "@/bun/utils/host";
import cors from "@elysiajs/cors";
@ -8,7 +8,7 @@ import { config } from "../app";
import z from "zod";
import { delay } from "@/shared/utils";
export class LoginJob implements IJob
export class LoginJob implements IJob<z.infer<typeof LoginJob.dataSchema>, "base">
{
endsAt: Date;
startedAt: Date;
@ -25,7 +25,7 @@ export class LoginJob implements IJob
exposeData = (): z.infer<typeof LoginJob.dataSchema> => ({ endsAt: this.endsAt, startedAt: this.startedAt, url: this.url });
async start (context: JobContext): Promise<any>
async start (context: JobContext<LoginJob, z.infer<typeof LoginJob.dataSchema>, "base">): Promise<void>
{
const loginServer = new Elysia({ serve: { hostname: localIp, port: LOGIN_PORT } })
.use(cors())
@ -37,7 +37,7 @@ export class LoginJob implements IJob
.post(`/login`, async ({ body }) =>
{
const response = await tryLoginAndSave(body as any);
if (response?.code === 200)
if (response.response.ok)
{
context.abort("success");
return status("Accepted");

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,8 +1,9 @@
import { IJob, JobContext } from "../task-queue";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
import secrets from "../secrets";
import open from "open";
import z from "zod";
import { delay } from "@/shared/utils";
import { plugins } from "../app";
interface TwitchDevice
@ -16,7 +17,9 @@ interface TwitchDevice
verification_uri: string;
}
export default class TwitchLoginJob implements IJob
type States = "Retrieving Device" | "Waiting For Authentication";
export default class TwitchLoginJob implements IJob<z.infer<typeof TwitchLoginJob.dataSchema>, States>
{
twitchScopes = "analytics:read:extensions analytics:read:games user:read:email";
device?: TwitchDevice;
@ -38,7 +41,7 @@ export default class TwitchLoginJob implements IJob
user_code: this.device.user_code
}) : undefined;
async start (context: JobContext): Promise<any>
async start (context: JobContext<TwitchLoginJob, z.infer<typeof TwitchLoginJob.dataSchema>, States>): Promise<any>
{
context.setProgress(0, "Retrieving Device");
let res = await fetch("https://id.twitch.tv/oauth2/device", {
@ -92,6 +95,8 @@ export default class TwitchLoginJob implements IJob
secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token });
secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token });
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' });
break;
}
else if (res.status !== 400)

View file

@ -1,76 +0,0 @@
import { ensureDir } from "fs-extra";
import { IJob, JobContext } from "../task-queue";
import { getStoreFolder } from "../store/store";
export default class UpdateStoreJob implements IJob
{
static id = "update-store" as const;
static origin = "https://github.com/simeonradivoev/gameflow-store.git";
static branch = "master";
async gitCommand (commands: string[], dir: string)
{
const proc = Bun.spawn(['git', ...commands], {
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [output] = await Promise.all([
new Response(proc.stdout).text(),
proc.exited,
]);
return output.trim();
}
async isGitRepo (dir: string)
{
return (await this.gitCommand(["rev-parse", "--is-inside-work-tree"], dir)) === 'true';
}
async getOrigin (dir: string)
{
const origin = await this.gitCommand(["remote", "get-url", "origin"], dir);
return origin;
}
async hasChanges (dir: string)
{
return (await this.gitCommand(["status", "--porcelain"], dir)).length > 0;
}
async start (context: JobContext)
{
const storeFolder = getStoreFolder();
await ensureDir(storeFolder);
context.setProgress(10);
if (await this.isGitRepo(storeFolder))
{
const existingOrigin = await this.getOrigin(storeFolder);
if (existingOrigin !== UpdateStoreJob.origin)
{
throw new Error(`Git Repo in downloads is not valid. It has origin of ${existingOrigin}. Repo must be of ${UpdateStoreJob.origin}`);
}
// check for uncommitted changes
const status = await this.gitCommand([" status", "--porcelain"], storeFolder);
if (status.length > 0)
{
console.log("Cleaning local changes...");
await this.gitCommand(["reset", "--hard"], storeFolder);
await this.gitCommand(["clean", "-fd"], storeFolder);
}
// fetch & reset to remote
await this.gitCommand(["fetch", "origin"], storeFolder);
await this.gitCommand(["reset", "--hard", `origin/${UpdateStoreJob.branch}`], storeFolder);
console.log("Shop Repo updated");
} else
{
context.setProgress(50);
await this.gitCommand(["clone", "--depth", "1", "--branch", UpdateStoreJob.branch, UpdateStoreJob.origin, '.'], storeFolder);
context.setProgress(100);
}
}
}

View file

@ -1,4 +1,5 @@
import { Notification } from '@shared/constants';
import { FrontendNotification } from '@simeonradivoev/gameflow-sdk/shared';
import { events } from './app';
export default function buildNotificationsStream ()
@ -10,7 +11,7 @@ export default function buildNotificationsStream ()
{
const encoder = new TextEncoder();
function enqueue (data: Notification, event?: 'notification')
function enqueue (data: FrontendNotification, event?: 'notification')
{
const evntString = event ? `event: ${event}\n` : '';
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
@ -30,7 +31,7 @@ export default function buildNotificationsStream ()
}
}, 15000);
const notificationHandler = (notification: Notification) =>
const notificationHandler = (notification: FrontendNotification) =>
{
enqueue(notification, 'notification');
};

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

@ -0,0 +1,476 @@
[UI]
SettingsVersion = 1
InhibitScreensaver = true
ConfirmShutdown = true
StartPaused = false
PauseOnFocusLoss = false
StartFullscreen = false
DoubleClickTogglesFullscreen = true
HideMouseCursor = false
RenderToSeparateWindow = false
HideMainWindowWhenRunning = false
DisableWindowResize = false
Theme = darkfusion
SetupWizardIncomplete = false
[EmuCore]
CdvdVerboseReads = false
CdvdDumpBlocks = false
CdvdShareWrite = false
EnablePatches = true
EnableCheats = false
EnablePINE = false
EnableNoInterlacingPatches = false
EnableRecordingTools = true
EnableGameFixes = true
SaveStateOnShutdown = false
EnableDiscordPresence = false
InhibitScreensaver = true
ConsoleToStdio = false
HostFs = false
BackupSavestate = true
SavestateZstdCompression = true
McdEnableEjection = true
McdFolderAutoManage = true
WarnAboutUnsafeSettings = true
GzipIsoIndexTemplate = $(f).pindex.tmp
BlockDumpSaveDirectory =
EnableFastBoot = true
[EmuCore/Speedhacks]
EECycleRate = 0
EECycleSkip = 0
fastCDVD = false
IntcStat = true
WaitLoop = true
vuFlagHack = true
vuThread = true
vu1Instant = true
[EmuCore/CPU]
FPU.DenormalsAreZero = true
FPU.FlushToZero = true
FPU.Roundmode = 3
AffinityControlMode = 0
VU0.DenormalsAreZero = true
VU0.FlushToZero = true
VU0.Roundmode = 3
VU1.DenormalsAreZero = true
VU1.FlushToZero = true
VU1.Roundmode = 3
[EmuCore/CPU/Recompiler]
EnableEE = true
EnableIOP = true
EnableEECache = false
EnableVU0 = true
EnableVU1 = true
EnableFastmem = true
PauseOnTLBMiss = false
vu0Overflow = true
vu0ExtraOverflow = false
vu0SignOverflow = false
vu0Underflow = false
vu1Overflow = true
vu1ExtraOverflow = false
vu1SignOverflow = false
vu1Underflow = false
fpuOverflow = true
fpuExtraOverflow = false
fpuFullMode = false
[EmuCore/GS]
VsyncQueueSize = 2
FrameLimitEnable = true
VsyncEnable = 0
FramerateNTSC = 59.94
FrameratePAL = 50
SyncToHostRefreshRate = false
AspectRatio = {{ASPECT_RATIO}}
FMVAspectRatioSwitch = Off
ScreenshotSize = 0
ScreenshotFormat = 0
ScreenshotQuality = 50
StretchY = 100
CropLeft = 0
CropTop = 0
CropRight = 0
CropBottom = 0
pcrtc_antiblur = true
disable_interlace_offset = false
pcrtc_offsets = false
pcrtc_overscan = false
IntegerScaling = false
UseDebugDevice = false
UseBlitSwapChain = false
disable_shader_cache = false
DisableDualSourceBlend = false
DisableFramebufferFetch = false
DisableThreadedPresentation = false
SkipDuplicateFrames = false
OsdShowMessages = true
OsdShowSpeed = false
OsdShowFPS = false
OsdShowCPU = false
OsdShowGPU = false
OsdShowResolution = false
OsdShowGSStats = false
OsdShowIndicators = true
OsdShowSettings = false
OsdShowInputs = false
OsdShowFrameTimes = false
HWSpinGPUForReadbacks = false
HWSpinCPUForReadbacks = false
paltex = false
autoflush_sw = true
preload_frame_with_gs_data = false
mipmap = true
UserHacks = false
UserHacks_align_sprite_X = false
UserHacks_AutoFlush = false
UserHacks_CPU_FB_Conversion = false
UserHacks_ReadTCOnClose = false
UserHacks_DisableDepthSupport = false
UserHacks_DisablePartialInvalidation = false
UserHacks_Disable_Safe_Features = false
UserHacks_merge_pp_sprite = false
UserHacks_WildHack = false
UserHacks_TextureInsideRt = 0
UserHacks_TargetPartialInvalidation = false
UserHacks_EstimateTextureRegion = false
fxaa = false
ShadeBoost = false
dump = false
save = false
savef = false
savet = false
savez = false
DumpReplaceableTextures = false
DumpReplaceableMipmaps = false
DumpTexturesWithFMVActive = false
DumpDirectTextures = true
DumpPaletteTextures = true
LoadTextureReplacements = false
LoadTextureReplacementsAsync = true
PrecacheTextureReplacements = false
EnableVideoCapture = true
EnableVideoCaptureParameters = false
VideoCaptureAutoResolution = false
EnableAudioCapture = true
EnableAudioCaptureParameters = false
linear_present_mode = 1
deinterlace_mode = 0
OsdScale = 100
Renderer = 14
mipmap_hw = -1
accurate_blending_unit = 1
crc_hack_level = -1
filter = 2
texture_preloading = 2
GSDumpCompression = 2
HWDownloadMode = 0
CASMode = 0
CASSharpness = 50
dithering_ps2 = 2
MaxAnisotropy = 0
extrathreads = 3
extrathreads_height = 4
TVShader = 0
UserHacks_SkipDraw_Start = 0
UserHacks_SkipDraw_End = 0
UserHacks_Half_Bottom_Override = -1
UserHacks_HalfPixelOffset = 0
UserHacks_round_sprite_offset = 0
UserHacks_TCOffsetX = 0
UserHacks_TCOffsetY = 0
UserHacks_CPUSpriteRenderBW = 0
UserHacks_CPUCLUTRender = 0
UserHacks_GPUTargetCLUTMode = 0
TriFilter = -1
OverrideTextureBarriers = -1
OverrideGeometryShaders = -1
ShadeBoost_Brightness = 50
ShadeBoost_Contrast = 50
ShadeBoost_Saturation = 50
png_compression_level = 1
saven = 0
savel = 5000
CaptureContainer = mp4
VideoCaptureCodec =
VideoCaptureParameters =
AudioCaptureCodec =
AudioCaptureParameters =
VideoCaptureBitrate = 6000
VideoCaptureWidth = 640
VideoCaptureHeight = 480
AudioCaptureBitrate = 160
Adapter = (Default)
HWDumpDirectory =
SWDumpDirectory =
[SPU2/Debug]
Global_Enable = false
Show_Messages = false
Show_Messages_Key_On_Off = false
Show_Messages_Voice_Off = false
Show_Messages_DMA_Transfer = false
Show_Messages_AutoDMA = false
Show_Messages_Overruns = false
Show_Messages_CacheStats = false
Log_Register_Access = false
Log_DMA_Transfers = false
Log_WAVE_Output = false
Dump_Info = false
Dump_Memory = false
Dump_Regs = false
[SPU2/Mixing]
FinalVolume = 100
[SPU2/Output]
OutputModule = cubeb
BackendName =
DeviceName =
Latency = 60
OutputLatency = 20
OutputLatencyMinimal = false
SynchMode = 0
SpeakerConfiguration = 0
DplDecodingLevel = 0
[DEV9/Eth]
EthEnable = false
EthApi = Unset
EthDevice =
EthLogDNS = false
InterceptDHCP = false
PS2IP = 0.0.0.0
Mask = 0.0.0.0
Gateway = 0.0.0.0
DNS1 = 0.0.0.0
DNS2 = 0.0.0.0
AutoMask = true
AutoGateway = true
ModeDNS1 = Auto
ModeDNS2 = Auto
[DEV9/Eth/Hosts]
Count = 0
[DEV9/Hdd]
HddEnable = false
HddFile = DEV9hdd.raw
HddSizeSectors = 83886080
[EmuCore/Gamefixes]
VuAddSubHack = false
FpuMulHack = false
FpuNegDivHack = false
XgKickHack = false
EETimingHack = false
InstantDMAHack = false
SoftwareRendererFMVHack = false
SkipMPEGHack = false
OPHFlagHack = false
DMABusyHack = false
VIFFIFOHack = false
VIF1StallHack = false
GIFFIFOHack = false
GoemonTlbHack = false
IbitHack = false
VUSyncHack = false
VUOverflowHack = false
BlitInternalFPSHack = false
FullVU0SyncHack = false
[EmuCore/Profiler]
Enabled = false
RecBlocks_EE = true
RecBlocks_IOP = true
RecBlocks_VU0 = true
RecBlocks_VU1 = true
[EmuCore/Debugger]
ShowDebuggerOnStart = false
AlignMemoryWindowStart = true
FontWidth = 8
FontHeight = 12
WindowWidth = 0
WindowHeight = 0
MemoryViewBytesPerRow = 16
[EmuCore/TraceLog]
Enabled = false
EE.bitset = 0
IOP.bitset = 0
[USB1]
Type = None
[USB2]
Type = None
[Achievements]
Enabled = false
TestMode = false
UnofficialTestMode = false
RichPresence = true
ChallengeMode = false
Leaderboards = true
Notifications = true
SoundEffects = true
PrimedIndicators = true
[Filenames]
BIOS =
[Framerate]
NominalScalar = 1
TurboScalar = 2
SlomoScalar = 0.5
[MemoryCards]
Slot1_Enable = true
Slot1_Filename = Mcd001.ps2
Slot2_Enable = true
Slot2_Filename = Mcd002.ps2
Multitap1_Slot2_Enable = false
Multitap1_Slot2_Filename = Mcd-Multitap1-Slot02.ps2
Multitap1_Slot3_Enable = false
Multitap1_Slot3_Filename = Mcd-Multitap1-Slot03.ps2
Multitap1_Slot4_Enable = false
Multitap1_Slot4_Filename = Mcd-Multitap1-Slot04.ps2
Multitap2_Slot2_Enable = false
Multitap2_Slot2_Filename = Mcd-Multitap2-Slot02.ps2
Multitap2_Slot3_Enable = false
Multitap2_Slot3_Filename = Mcd-Multitap2-Slot03.ps2
Multitap2_Slot4_Enable = false
Multitap2_Slot4_Filename = Mcd-Multitap2-Slot04.ps2
[InputSources]
Keyboard = true
Mouse = true
SDL = true
SDLControllerEnhancedMode = false
[Hotkeys]
ToggleFullscreen = SDL-0/Start & SDL-0/LeftStick
CycleInterlaceMode = Keyboard/F5
CycleMipmapMode = Keyboard/Insert
GSDumpMultiFrame = Keyboard/Control & Keyboard/Shift & Keyboard/F8
Screenshot = Keyboard/F8
GSDumpSingleFrame = Keyboard/Shift & Keyboard/F8
ZoomIn = Keyboard/Control & Keyboard/Plus
ZoomOut = Keyboard/Control & Keyboard/Minus
InputRecToggleMode = Keyboard/Shift & Keyboard/R
LoadStateFromSlot = SDL-0/Back & SDL-0/LeftShoulder
SaveStateToSlot = SDL-0/Back & SDL-0/RightShoulder
ShutdownVM = SDL-0/Back & SDL-0/Start
ToggleFrameLimit = Keyboard/F4
TogglePause = SDL-0/Back & SDL-0/A
ToggleSlowMotion = SDL-0/Back & SDL-0/+LeftTrigger
ToggleTurbo = SDL-0/Back & SDL-0/+RightTrigger
HoldTurbo = Keyboard/Period
ResetVM = SDL-0/Back & SDL-0/LeftStick
OpenPauseMenu = SDL-0/Back & SDL-0/RightStick
IncreaseUpscaleMultiplier = SDL-0/Start & SDL-0/DPadUp
DecreaseUpscaleMultiplier = SDL-0/Start & SDL-0/DPadDown
CycleAspectRatio = SDL-0/Start & SDL-0/DPadRight
ToggleSoftwareRendering = SDL-0/Start & SDL-0/DPadLeft
ToggleSoftwareRendering = Keyboard/F9
NextSaveStateSlot = SDL-0/Start & SDL-0/RightShoulder
PreviousSaveStateSlot = SDL-0/Start & SDL-0/LeftShoulder
[Pad1]
Type = DualShock2
Deadzone = 0.000000
AxisScale = 1.330000
LargeMotorScale = 1.000000
SmallMotorScale = 1.000000
PressureModifier = 0.5
Up = SDL-0/DPadUp
Right = SDL-0/DPadRight
Down = SDL-0/DPadDown
Left = SDL-0/DPadLeft
Triangle = SDL-0/Y
Circle = SDL-0/B
Cross = SDL-0/A
Square = SDL-0/X
Select = SDL-0/Back
Start = SDL-0/Start
L1 = SDL-0/LeftShoulder
L2 = SDL-0/+LeftTrigger
R1 = SDL-0/RightShoulder
R2 = SDL-0/+RightTrigger
L3 = SDL-0/LeftStick
R3 = SDL-0/RightStick
LUp = SDL-0/-LeftY
LRight = SDL-0/+LeftX
LDown = SDL-0/+LeftY
LLeft = SDL-0/-LeftX
RUp = SDL-0/-RightY
RRight = SDL-0/+RightX
RDown = SDL-0/+RightY
RLeft = SDL-0/-RightX
Analog = SDL-0/Guide
LargeMotor = SDL-0/LargeMotor
SmallMotor = SDL-0/SmallMotor
Pressure = Keyboard/S
[Pad2]
Type = DualShock2
Deadzone = 0.000000
AxisScale = 1.330000
LargeMotorScale = 1.000000
SmallMotorScale = 1.000000
PressureModifier = 0.300000
Up = SDL-1/DPadUp
Right = SDL-1/DPadRight
Down = SDL-1/DPadDown
Left = SDL-1/DPadLeft
Triangle = SDL-1/Y
Circle = SDL-1/B
Cross = SDL-1/A
Square = SDL-1/X
Select = SDL-1/Back
Start = SDL-1/Start
L1 = SDL-1/LeftShoulder
L2 = SDL-1/+LeftTrigger
R1 = SDL-1/RightShoulder
R2 = SDL-1/+RightTrigger
L3 = SDL-1/LeftStick
R3 = SDL-1/RightStick
Analog = SDL-1/Guide
LUp = SDL-1/-LeftY
LRight = SDL-1/+LeftX
LDown = SDL-1/+LeftY
LLeft = SDL-1/-LeftX
RUp = SDL-1/-RightY
RRight = SDL-1/+RightX
RDown = SDL-1/+RightY
RLeft = SDL-1/-RightX
LargeMotor = SDL-1/LargeMotor
SmallMotor = SDL-1/SmallMotor

View file

@ -0,0 +1,15 @@
{
"name": "com.simeonradivoev.gameflow.pcsx2",
"displayName": "PCSX2 Integration",
"version": "0.0.1",
"description": "PCSX2 Emulator Integration",
"main": "./pcsx2.ts",
"icon": "https://upload.wikimedia.org/wikipedia/commons/2/2b/PCSX2_logo4.png",
"category": "emulators",
"keywords": [
"integration",
"emulator",
"ps2",
"pcsx2"
]
}

View file

@ -0,0 +1,125 @@
import { config } from "@/bun/api/app";
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import defaultConfig from './PCSX2.ini' with { type: 'file' };
import path from 'node:path';
import { ensureDir } from "fs-extra";
import desc from './package.json';
import ini from 'ini';
import { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared";
export default class PCSX2Integration implements PluginType
{
emulator = "PCSX2";
async load (ctx: PluginLoadingContextType)
{
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: '*.ps2',
isGlob: true,
fixedSize: 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("-batch");
}
if (config.get('launchInFullscreen'))
{
args.push("-fullscreen");
}
args.push(...["-bigpicture", "-portable", "--"]);
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,
SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'),
SAVE_STATES_PATH: path.join(savesFolder, 'states'),
MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'),
CACHE_PATH: path.join(storageFolder, 'cache'),
COVERS_PATH: path.join(storageFolder, 'covers'),
TEXTURES_PATH: path.join(storageFolder, 'textures'),
VIDEOS_PATH: path.join(storageFolder, 'videos'),
LOGS_PATH: path.join(storageFolder, 'logs'),
RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'),
};
await Promise.all(Object.values(paths).map(p => ensureDir(p)));
configFile.EmuCore ??= {};
configFile.EmuCore.EnableWideScreenPatches = config.get('emulatorWidescreen');
configFile['EmuCore/GS'] ??= {};
configFile['EmuCore/GS'].AspectRatio = config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2";
configFile['EmuCore/GS'].upscale_multiplier = resolutionMapping[config.get('emulatorResolution')] ?? 1;
configFile.Folders ??= {};
configFile.Folders.Bios = paths.BIOS_PATH;
configFile.Folders.Snapshots = paths.SNAPSHOTS_PATH;
configFile.Folders.SaveStates = paths.SAVE_STATES_PATH;
configFile.Folders.MemoryCards = paths.MEMORY_CARDS_PATH;
configFile.Folders.Cache = paths.CACHE_PATH;
configFile.Folders.Covers = paths.COVERS_PATH;
configFile.Folders.Textures = paths.TEXTURES_PATH;
configFile.Folders.Videos = paths.VIDEOS_PATH;
configFile.Folders.Logs = paths.LOGS_PATH;
configFile.GameList ??= {};
configFile.GameList.RecursivePaths = paths.RECURSIVE_PATHS;
await Bun.write(configPath, ini.stringify(configFile));
return { args, savesPath: { [this.emulator]: { cwd: paths.MEMORY_CARDS_PATH } } };
}
return { args };
});
}
}

View file

@ -0,0 +1,27 @@
[ControlMapping]
Up = 10-19
Down = 10-20
Left = 10-21
Right = 10-22
Circle = 10-190
Cross = 10-189
Square = 10-191
Triangle = 10-188
Start = 10-197
Select = 10-196
L = 10-193
R = 10-192
An.Up = 10-4003
An.Down = 10-4002
An.Left = 10-4001
An.Right = 10-4000
Fast-forward = 1-193:10-4010,1-135
Rewind = 10-196:10-4008
Save State = 10-196:10-192,1-132
Load State = 10-196:10-193,1-133
Previous Slot = 10-197:10-193,1-137
Next Slot = 10-197:10-192,1-136
Pause = 10-196:10-107,1-111
Screenshot = 10-196:10-190
Exit App = 10-196:10-197
SpeedToggle = 10-196:10-4010

View file

@ -0,0 +1,477 @@
[General]
FirstRun = False
RunCount = 4
Enable Logging = True
AutoRun = True
Browse = False
IgnoreBadMemAccess = True
CurrentDirectory = /home
ShowDebuggerOnLoad = False
CheckForNewVersion = True
Language = en_US
ForceLagSync2 = False
DiscordPresence = True
UISound = False
AutoLoadSaveState = 0
EnableCheats = True
CwCheatRefreshRate = 77
CwCheatScrollPosition = 0.000000
GameListScrollPosition = 0.000000
ScreenshotsAsPNG = False
UseFFV1 = False
DumpFrames = False
DumpVideoOutput = False
DumpAudio = False
SaveLoadResetsAVdumping = False
StateSlot = 0
EnableStateUndo = True
StateLoadUndoGame = NA
StateUndoLastSaveGame = NA
StateUndoLastSaveSlot = -5
RewindFlipFrequency = 0
ShowOnScreenMessage = True
ShowRegionOnGameIcon = False
ShowIDOnGameIcon = True
GameGridScale = 1.000000
GridView1 = True
GridView2 = False
GridView3 = False
RightAnalogUp = 0
RightAnalogDown = 0
RightAnalogLeft = 0
RightAnalogRight = 0
RightAnalogPress = 0
RightAnalogCustom = False
RightAnalogDisableDiagonal = False
SwipeUp = 0
SwipeDown = 0
SwipeLeft = 0
SwipeRight = 0
SwipeSensitivity = 1.000000
SwipeSmoothing = 0.300000
DoubleTapGesture = 0
GestureControlEnabled = False
ReportingHost = default
AutoSaveSymbolMap = False
CacheFullIsoInRam = False
RemoteISOPort = 0
LastRemoteISOServer =
LastRemoteISOPort = 0
RemoteISOManualConfig = False
RemoteShareOnStartup = False
RemoteISOSubdir = /
RemoteDebuggerOnStartup = False
InternalScreenRotation = 1
BackgroundAnimation = 1
PauseWhenMinimized = False
DumpDecryptedEboots = False
MemStickInserted = True
EnablePlugins = True
[CPU]
CPUCore = 1
SeparateSASThread = True
SeparateIOThread = True
IOTimingMethod = 0
FastMemoryAccess = True
FunctionReplacements = True
HideSlowWarnings = False
HideStateWarnings = False
PreloadFunctions = False
JitDisableFlags = 0x00000000
CPUSpeed = 0
[Graphics]
EnableCardboardVR = False
CardboardScreenSize = 50
CardboardXShift = 0
CardboardYShift = 0
ShowFPSCounter = 0
GraphicsBackend = 3 (VULKAN)
FailedGraphicsBackends =
DisabledGraphicsBackends =
VulkanDevice =
CameraDevice =
RenderingMode = 1
SoftwareRenderer = False
HardwareTransform = True
SoftwareSkinning = True
TextureFiltering = 1
BufferFiltering = 1
AndroidHwScale = 1
HighQualityDepth = 1
FrameSkip = 0
FrameSkipType = 0
AutoFrameSkip = False
FrameRate = 0
FrameRate2 = -1
UnthrottlingMode = CONTINUOUS
AnisotropyLevel = 4
VertexDecCache = False
TextureBackoffCache = False
TextureSecondaryCache = False
FullScreenMulti = False
SmallDisplayZoomType = 2
SmallDisplayOffsetX = 0.500000
SmallDisplayOffsetY = 0.500000
SmallDisplayZoomLevel = 1.000000
ImmersiveMode = True
SustainedPerformanceMode = False
IgnoreScreenInsets = True
ReplaceTextures = True
SaveNewTextures = False
IgnoreTextureFilenames = False
TexScalingLevel = 1
TexScalingType = 0
TexDeposterize = False
TexHardwareScaling = False
VSyncInterval = False
BloomHack = 0
SplineBezierQuality = 2
HardwareTessellation = False
TextureShader = Off
ShaderChainRequires60FPS = False
MemBlockTransferGPU = True
DisableSlowFramebufEffects = False
FragmentTestCache = True
LogFrameDrops = False
InflightFrames = 2
RenderDuplicateFrames = False
[Sound]
Enable = True
AudioBackend = 0
ExtraAudioBuffering = False
GlobalVolume = 10
ReverbVolume = 10
AltSpeedVolume = -1
AudioDevice =
AutoAudioDevice = False
[Control]
HapticFeedback = False
ShowTouchCross = True
ShowTouchCircle = True
ShowTouchSquare = True
ShowTouchTriangle = True
Custom0Mapping = 0x0000000000000000
Custom0Image = 0
Custom0Shape = 0
Custom0Toggle = False
Custom1Mapping = 0x0000000000000000
Custom1Image = 1
Custom1Shape = 0
Custom1Toggle = False
Custom2Mapping = 0x0000000000000000
Custom2Image = 2
Custom2Shape = 0
Custom2Toggle = False
Custom3Mapping = 0x0000000000000000
Custom3Image = 3
Custom3Shape = 0
Custom3Toggle = False
Custom4Mapping = 0x0000000000000000
Custom4Image = 4
Custom4Shape = 0
Custom4Toggle = False
Custom5Mapping = 0x0000000000000000
Custom5Image = 0
Custom5Shape = 1
Custom5Toggle = False
Custom6Mapping = 0x0000000000000000
Custom6Image = 1
Custom6Shape = 1
Custom6Toggle = False
Custom7Mapping = 0x0000000000000000
Custom7Image = 2
Custom7Shape = 1
Custom7Toggle = False
Custom8Mapping = 0x0000000000000000
Custom8Image = 3
Custom8Shape = 1
Custom8Toggle = False
Custom9Mapping = 0x0000000000000000
Custom9Image = 4
Custom9Shape = 1
Custom9Toggle = False
ShowTouchPause = False
ShowTouchControls = False
DisableDpadDiagonals = False
GamepadOnlyFocused = False
TouchButtonStyle = 1
TouchButtonOpacity = 65
TouchButtonHideSeconds = 20
AutoCenterTouchAnalog = False
AnalogAutoRotSpeed = 8.000000
TouchSnapToGrid = False
TouchSnapGridSize = 64
ActionButtonSpacing2 = 1.000000
ActionButtonCenterX = -1.000000
ActionButtonCenterY = -1.000000
ActionButtonScale = 1.150000
DPadX = -1.000000
DPadY = -1.000000
DPadScale = 1.150000
ShowTouchDpad = True
DPadSpacing = 1.000000
StartKeyX = -1.000000
StartKeyY = -1.000000
StartKeyScale = 1.150000
ShowTouchStart = True
SelectKeyX = -1.000000
SelectKeyY = -1.000000
SelectKeyScale = 1.150000
ShowTouchSelect = True
UnthrottleKeyX = -1.000000
UnthrottleKeyY = -1.000000
UnthrottleKeyScale = 1.150000
ShowTouchUnthrottle = True
LKeyX = -1.000000
LKeyY = -1.000000
LKeyScale = 1.150000
ShowTouchLTrigger = True
RKeyX = -1.000000
RKeyY = -1.000000
RKeyScale = 1.150000
ShowTouchRTrigger = True
AnalogStickX = -1.000000
AnalogStickY = -1.000000
AnalogStickScale = 1.150000
ShowAnalogStick = True
RightAnalogStickX = -1.000000
RightAnalogStickY = -1.000000
RightAnalogStickScale = 1.150000
ShowRightAnalogStick = False
fcombo0X = -1.000000
fcombo0Y = -1.000000
comboKeyScale0 = 1.150000
ShowComboKey0 = False
fcombo1X = -1.000000
fcombo1Y = -1.000000
comboKeyScale1 = 1.150000
ShowComboKey1 = False
fcombo2X = -1.000000
fcombo2Y = -1.000000
comboKeyScale2 = 1.150000
ShowComboKey2 = False
fcombo3X = -1.000000
fcombo3Y = -1.000000
comboKeyScale3 = 1.150000
ShowComboKey3 = False
fcombo4X = -1.000000
fcombo4Y = -1.000000
comboKeyScale4 = 1.150000
ShowComboKey4 = False
fcombo5X = -1.000000
fcombo5Y = -1.000000
comboKeyScale5 = 1.150000
ShowComboKey5 = False
fcombo6X = -1.000000
fcombo6Y = -1.000000
comboKeyScale6 = 1.150000
ShowComboKey6 = False
fcombo7X = -1.000000
fcombo7Y = -1.000000
comboKeyScale7 = 1.150000
ShowComboKey7 = False
fcombo8X = -1.000000
fcombo8Y = -1.000000
comboKeyScale8 = 1.150000
ShowComboKey8 = False
fcombo9X = -1.000000
fcombo9Y = -1.000000
comboKeyScale9 = 1.150000
ShowComboKey9 = False
AnalogDeadzone = 0.150000
AnalogInverseDeadzone = 0.000000
AnalogSensitivity = 1.100000
AnalogIsCircular = False
AnalogLimiterDeadzone = 0.600000
LeftStickHeadScale = 1.000000
RightStickHeadScale = 1.000000
HideStickBackground = False
UseMouse = False
MapMouse = False
ConfineMap = False
MouseSensitivity = 0.100000
MouseSmoothing = 0.900000
SystemControls = True
AllowMappingCombos = True
[Network]
EnableWlan = False
EnableAdhocServer = False
proAdhocServer = socom.cc
PortOffset = 10000
MinTimeout = 0
ForcedFirstConnect = False
EnableUPnP = False
UPnPUseOriginalPort = False
EnableNetworkChat = False
ChatButtonPosition = 0
ChatScreenPosition = 0
EnableQuickChat = True
QuickChat1 = Quick Chat 1
QuickChat2 = Quick Chat 2
QuickChat3 = Quick Chat 3
QuickChat4 = Quick Chat 4
QuickChat5 = Quick Chat 5
[SystemParam]
PSPModel = 1
PSPFirmwareVersion = 660
NickName = PPSSPP
MacAddress = ec:fd:62:d4:ec:73
Language = 1
ParamTimeFormat = 0
ParamDateFormat = 0
TimeZone = 0
DayLightSavings = False
ButtonPreference = 1
LockParentalLevel = 0
WlanAdhocChannel = 0
WlanPowerSave = False
EncryptSave = True
SavedataUpgradeVersion = True
MemStickSize = 16
[Debugger]
DisasmWindowX = -1
DisasmWindowY = -1
DisasmWindowW = -1
DisasmWindowH = -1
GEWindowX = -1
GEWindowY = -1
GEWindowW = -1
GEWindowH = -1
ConsoleWindowX = -1
ConsoleWindowY = -1
FontWidth = 8
FontHeight = 12
DisplayStatusBar = True
ShowBottomTabTitles = True
ShowDeveloperMenu = False
SkipDeadbeefFilling = False
FuncHashMap = False
MemInfoDetailed = False
DrawFrameGraph = False
[Upgrade]
UpgradeMessage =
UpgradeVersion =
DismissedVersion =
[Theme]
ItemStyleFg = 0xffffffff
ItemStyleBg = 0x55000000
ItemFocusedStyleFg = 0xffffffff
ItemFocusedStyleBg = 0xffedc24c
ItemDownStyleFg = 0xffffffff
ItemDownStyleBg = 0xffbd9939
ItemDisabledStyleFg = 0x80eeeeee
ItemDisabledStyleBg = 0x55e0d4af
ItemHighlightedStyleFg = 0xffffffff
ItemHighlightedStyleBg = 0x55bdbb39
ButtonStyleFg = 0xffffffff
ButtonStyleBg = 0x55000000
ButtonFocusedStyleFg = 0xffffffff
ButtonFocusedStyleBg = 0xffedc24c
ButtonDownStyleFg = 0xffffffff
ButtonDownStyleBg = 0xffbd9939
ButtonDisabledStyleFg = 0x80eeeeee
ButtonDisabledStyleBg = 0x55e0d4af
ButtonHighlightedStyleFg = 0xffffffff
ButtonHighlightedStyleBg = 0x55bdbb39
HeaderStyleFg = 0xffffffff
InfoStyleFg = 0xffffffff
InfoStyleBg = 0x00000000
PopupTitleStyleFg = 0xffe3be59
PopupStyleFg = 0xffffffff
PopupStyleBg = 0xff303030
[Recent]
MaxRecent = 60
[Log]
SYSTEMEnabled = True
SYSTEMLevel = 2
BOOTEnabled = True
BOOTLevel = 2
COMMONEnabled = True
COMMONLevel = 2
CPUEnabled = True
CPULevel = 2
FILESYSEnabled = True
FILESYSLevel = 2
G3DEnabled = True
G3DLevel = 2
HLEEnabled = True
HLELevel = 2
JITEnabled = True
JITLevel = 2
LOADEREnabled = True
LOADERLevel = 2
MEEnabled = True
MELevel = 2
MEMMAPEnabled = True
MEMMAPLevel = 2
SASMIXEnabled = True
SASMIXLevel = 2
SAVESTATEEnabled = True
SAVESTATELevel = 2
FRAMEBUFEnabled = True
FRAMEBUFLevel = 2
AUDIOEnabled = True
AUDIOLevel = 2
IOEnabled = True
IOLevel = 2
SCEAUDIOEnabled = True
SCEAUDIOLevel = 2
SCECTRLEnabled = True
SCECTRLLevel = 2
SCEDISPEnabled = True
SCEDISPLevel = 2
SCEFONTEnabled = True
SCEFONTLevel = 2
SCEGEEnabled = True
SCEGELevel = 2
SCEINTCEnabled = True
SCEINTCLevel = 2
SCEIOEnabled = True
SCEIOLevel = 2
SCEKERNELEnabled = True
SCEKERNELLevel = 2
SCEMODULEEnabled = True
SCEMODULELevel = 2
SCENETEnabled = True
SCENETLevel = 2
SCERTCEnabled = True
SCERTCLevel = 2
SCESASEnabled = True
SCESASLevel = 2
SCEUTILEnabled = True
SCEUTILLevel = 2
SCEMISCEnabled = True
SCEMISCLevel = 2
ACHIEVEMENTSEnabled = True
ACHIEVEMENTSLevel = 2
HTTPEnabled = True
HTTPLevel = 2
PRINTFEnabled = True
PRINTFLevel = 2
[PostShaderSetting]
BloomSettingValue1 = 0.600000
BloomSettingValue2 = 0.500000
CartoonSettingValue1 = 0.500000
ColorCorrectionSettingValue1 = 1.000000
ColorCorrectionSettingValue2 = 1.000000
ColorCorrectionSettingValue3 = 1.000000
ColorCorrectionSettingValue4 = 1.000000
ScanlinesSettingValue1 = 1.000000
ScanlinesSettingValue2 = 0.500000
SharpenSettingValue1 = 1.500000
[Achievements]
AchievementsEnable = False
AchievementsChallengeMode = False
AchievementsEncoreMode = False
AchievementsUnofficial = False
AchievementsLogBadMemReads = False
AchievementsUserName =
AchievementsSoundEffects = True
AchievementsUnlockAudioFile =
AchievementsLeaderboardSubmitAudioFile =
AchievementsLeaderboardTrackerPos = 3
AchievementsLeaderboardStartedOrFailedPos = 3
AchievementsLeaderboardSubmittedPos = 3
AchievementsProgressPos = 3
AchievementsChallengePos = 3
AchievementsUnlockedPos = 4

View file

@ -0,0 +1,15 @@
{
"name": "com.simeonradivoev.gameflow.ppsspp",
"displayName": "PPSSPP Integration",
"version": "0.0.1",
"description": "PPSSPP Emulator Integration",
"main": "./ppsspp.ts",
"icon": "https://www.ppsspp.org/static/img/platform/ppsspp-icon.png",
"category": "emulators",
"keywords": [
"integration",
"emulator",
"psp",
"ppsspp"
]
}

View file

@ -0,0 +1,147 @@
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
import desc from './package.json';
import { config } from "@/bun/api/app";
import configFilePathWin32 from './win32/ppsspp.ini' with { type: 'file' };
import configControlsFilePathWin32 from './win32/controls.ini' with { type: 'file' };
import configFilePathLinux from './linux/ppsspp.ini' with { type: 'file' };
import configControlsFilePathLinux from './linux/controls.ini' with { type: 'file' };
import path from "node:path";
import Mustache from "mustache";
import { ensureDir } from "fs-extra";
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 PPSSPPIntegration implements PluginType
{
emulator = "PPSSPP";
async load (ctx: PluginLoadingContextType)
{
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)
{
case "win32":
defaultConfigPath = configFilePathWin32;
defaultControlsPath = configControlsFilePathWin32;
break;
case 'linux':
defaultConfigPath = configFilePathLinux;
defaultControlsPath = configControlsFilePathLinux;
break;
}
let ppssppPath = '';
if (process.platform === 'win32')
{
ppssppPath = path.join(config.get('downloadPath'), 'saves', this.emulator, 'PSP', 'SYSTEM');
} else
{
//TODO: Use way to set custom memstick path when they support it
ensureDir(path.join(homedir(), '.config', 'ppsspp'));
ppssppPath = path.join(homedir(), '.config', 'ppsspp', 'PSP', 'SYSTEM');
}
ensureDir(ppssppPath);
if (defaultConfigPath)
{
const resolutionMapping: Record<string, number> = {
"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 (defaultControlsPath)
{
const controlsFileContents = await Bun.file(defaultControlsPath).text();
await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {}));
}
return {
args,
savesPath: {
[this.emulator]: {
cwd: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA")
}
}
};
}
return { args };
});
}
}

View file

@ -0,0 +1,23 @@
[ControlMapping]
Up = 20-19
Down = 20-20
Left = 20-21
Right = 20-22
Circle = 20-97
Cross = 20-96
Square = 20-100
Triangle = 20-99
Start = 20-108
Select = 20-109
L = 20-102
R = 20-103
An.Up = 20-4002
An.Down = 20-4003
An.Left = 20-4001
An.Right = 20-4000
Fast-forward = 20-109:20-4036
Rewind = 20-109:20-4034
Save State = 20-109:20-103
Load State = 20-109:20-102
Home = 20-108:20-109
Exit App = 20-3:20-108

View file

@ -0,0 +1,470 @@
[General]
FirstRun = False
RunCount = 4
Enable Logging = True
AutoRun = True
Browse = False
IgnoreBadMemAccess = True
CurrentDirectory = C:/Emulation/roms/psp
ShowDebuggerOnLoad = False
CheckForNewVersion = True
Language = en_US
ForceLagSync2 = False
DiscordPresence = True
UISound = False
AutoLoadSaveState = 0
EnableCheats = True
CwCheatRefreshRate = 77
CwCheatScrollPosition = 0.000000
GameListScrollPosition = 0.000000
ScreenshotsAsPNG = False
UseFFV1 = False
DumpFrames = False
DumpVideoOutput = False
DumpAudio = False
SaveLoadResetsAVdumping = False
StateSlot = 0
EnableStateUndo = True
StateLoadUndoGame = NA
StateUndoLastSaveGame = NA
StateUndoLastSaveSlot = -5
RewindFlipFrequency = 0
ShowOnScreenMessage = True
ShowRegionOnGameIcon = False
ShowIDOnGameIcon = False
GameGridScale = 1.000000
GridView1 = True
GridView2 = True
GridView3 = False
RightAnalogUp = 0
RightAnalogDown = 0
RightAnalogLeft = 0
RightAnalogRight = 0
RightAnalogPress = 0
RightAnalogCustom = False
RightAnalogDisableDiagonal = False
SwipeUp = 0
SwipeDown = 0
SwipeLeft = 0
SwipeRight = 0
SwipeSensitivity = 1.000000
SwipeSmoothing = 0.300000
DoubleTapGesture = 0
GestureControlEnabled = False
ReportingHost = default
AutoSaveSymbolMap = False
CacheFullIsoInRam = False
RemoteISOPort = 0
LastRemoteISOServer =
LastRemoteISOPort = 0
RemoteISOManualConfig = False
RemoteShareOnStartup = False
RemoteISOSubdir = /
RemoteDebuggerOnStartup = False
InternalScreenRotation = 1
BackgroundAnimation = 1
PauseWhenMinimized = False
DumpDecryptedEboots = False
MemStickInserted = True
EnablePlugins = True
[CPU]
CPUCore = 1
SeparateSASThread = True
SeparateIOThread = True
IOTimingMethod = 0
FastMemoryAccess = True
FunctionReplacements = True
HideSlowWarnings = False
HideStateWarnings = False
PreloadFunctions = False
JitDisableFlags = 0x00000000
CPUSpeed = 0
[Graphics]
EnableCardboardVR = False
CardboardScreenSize = 50
CardboardXShift = 0
CardboardYShift = 0
ShowFPSCounter = 0
GraphicsBackend = 3 (VULKAN)
FailedGraphicsBackends =
DisabledGraphicsBackends =
VulkanDevice =
CameraDevice =
RenderingMode = 1
SoftwareRenderer = False
HardwareTransform = True
SoftwareSkinning = True
TextureFiltering = 1
BufferFiltering = 1
AndroidHwScale = 1
HighQualityDepth = 1
FrameSkip = 0
FrameSkipType = 0
AutoFrameSkip = False
FrameRate = 0
FrameRate2 = -1
UnthrottlingMode = CONTINUOUS
AnisotropyLevel = 4
VertexDecCache = False
TextureBackoffCache = False
TextureSecondaryCache = False
FullScreenMulti = False
SmallDisplayZoomType = 2
SmallDisplayOffsetX = 0.500000
SmallDisplayOffsetY = 0.500000
SmallDisplayZoomLevel = 1.000000
ImmersiveMode = True
SustainedPerformanceMode = False
IgnoreScreenInsets = True
ReplaceTextures = True
SaveNewTextures = False
IgnoreTextureFilenames = False
TexScalingLevel = 1
TexScalingType = 0
TexDeposterize = False
TexHardwareScaling = False
VSyncInterval = False
BloomHack = 0
SplineBezierQuality = 2
HardwareTessellation = False
TextureShader = Off
ShaderChainRequires60FPS = False
MemBlockTransferGPU = True
DisableSlowFramebufEffects = False
FragmentTestCache = True
LogFrameDrops = False
InflightFrames = 2
RenderDuplicateFrames = False
[Sound]
Enable = True
AudioBackend = 0
ExtraAudioBuffering = False
GlobalVolume = 10
ReverbVolume = 10
AltSpeedVolume = -1
AudioDevice =
AutoAudioDevice = False
[Control]
HapticFeedback = False
ShowTouchCross = True
ShowTouchCircle = True
ShowTouchSquare = True
ShowTouchTriangle = True
Custom0Mapping = 0x0000000000000000
Custom0Image = 0
Custom0Shape = 0
Custom0Toggle = False
Custom1Mapping = 0x0000000000000000
Custom1Image = 1
Custom1Shape = 0
Custom1Toggle = False
Custom2Mapping = 0x0000000000000000
Custom2Image = 2
Custom2Shape = 0
Custom2Toggle = False
Custom3Mapping = 0x0000000000000000
Custom3Image = 3
Custom3Shape = 0
Custom3Toggle = False
Custom4Mapping = 0x0000000000000000
Custom4Image = 4
Custom4Shape = 0
Custom4Toggle = False
Custom5Mapping = 0x0000000000000000
Custom5Image = 0
Custom5Shape = 1
Custom5Toggle = False
Custom6Mapping = 0x0000000000000000
Custom6Image = 1
Custom6Shape = 1
Custom6Toggle = False
Custom7Mapping = 0x0000000000000000
Custom7Image = 2
Custom7Shape = 1
Custom7Toggle = False
Custom8Mapping = 0x0000000000000000
Custom8Image = 3
Custom8Shape = 1
Custom8Toggle = False
Custom9Mapping = 0x0000000000000000
Custom9Image = 4
Custom9Shape = 1
Custom9Toggle = False
ShowTouchPause = False
ShowTouchControls = False
DisableDpadDiagonals = False
GamepadOnlyFocused = False
TouchButtonStyle = 1
TouchButtonOpacity = 65
TouchButtonHideSeconds = 20
AutoCenterTouchAnalog = False
AnalogAutoRotSpeed = 8.000000
TouchSnapToGrid = False
TouchSnapGridSize = 64
ActionButtonSpacing2 = 1.000000
ActionButtonCenterX = -1.000000
ActionButtonCenterY = -1.000000
ActionButtonScale = 1.150000
DPadX = -1.000000
DPadY = -1.000000
DPadScale = 1.150000
ShowTouchDpad = True
DPadSpacing = 1.000000
StartKeyX = -1.000000
StartKeyY = -1.000000
StartKeyScale = 1.150000
ShowTouchStart = True
SelectKeyX = -1.000000
SelectKeyY = -1.000000
SelectKeyScale = 1.150000
ShowTouchSelect = True
UnthrottleKeyX = -1.000000
UnthrottleKeyY = -1.000000
UnthrottleKeyScale = 1.150000
ShowTouchUnthrottle = True
LKeyX = -1.000000
LKeyY = -1.000000
LKeyScale = 1.150000
ShowTouchLTrigger = True
RKeyX = -1.000000
RKeyY = -1.000000
RKeyScale = 1.150000
ShowTouchRTrigger = True
AnalogStickX = -1.000000
AnalogStickY = -1.000000
AnalogStickScale = 1.150000
ShowAnalogStick = True
RightAnalogStickX = -1.000000
RightAnalogStickY = -1.000000
RightAnalogStickScale = 1.150000
ShowRightAnalogStick = False
fcombo0X = -1.000000
fcombo0Y = -1.000000
comboKeyScale0 = 1.150000
ShowComboKey0 = False
fcombo1X = -1.000000
fcombo1Y = -1.000000
comboKeyScale1 = 1.150000
ShowComboKey1 = False
fcombo2X = -1.000000
fcombo2Y = -1.000000
comboKeyScale2 = 1.150000
ShowComboKey2 = False
fcombo3X = -1.000000
fcombo3Y = -1.000000
comboKeyScale3 = 1.150000
ShowComboKey3 = False
fcombo4X = -1.000000
fcombo4Y = -1.000000
comboKeyScale4 = 1.150000
ShowComboKey4 = False
fcombo5X = -1.000000
fcombo5Y = -1.000000
comboKeyScale5 = 1.150000
ShowComboKey5 = False
fcombo6X = -1.000000
fcombo6Y = -1.000000
comboKeyScale6 = 1.150000
ShowComboKey6 = False
fcombo7X = -1.000000
fcombo7Y = -1.000000
comboKeyScale7 = 1.150000
ShowComboKey7 = False
fcombo8X = -1.000000
fcombo8Y = -1.000000
comboKeyScale8 = 1.150000
ShowComboKey8 = False
fcombo9X = -1.000000
fcombo9Y = -1.000000
comboKeyScale9 = 1.150000
ShowComboKey9 = False
AnalogDeadzone = 0.150000
AnalogInverseDeadzone = 0.000000
AnalogSensitivity = 1.100000
AnalogIsCircular = False
AnalogLimiterDeadzone = 0.600000
LeftStickHeadScale = 1.000000
RightStickHeadScale = 1.000000
HideStickBackground = False
UseMouse = False
MapMouse = False
ConfineMap = False
MouseSensitivity = 0.100000
MouseSmoothing = 0.900000
SystemControls = True
AllowMappingCombos = True
[Network]
EnableWlan = False
EnableAdhocServer = False
proAdhocServer = socom.cc
PortOffset = 10000
MinTimeout = 0
ForcedFirstConnect = False
EnableUPnP = False
UPnPUseOriginalPort = False
EnableNetworkChat = False
ChatButtonPosition = 0
ChatScreenPosition = 0
EnableQuickChat = True
QuickChat1 = Quick Chat 1
QuickChat2 = Quick Chat 2
QuickChat3 = Quick Chat 3
QuickChat4 = Quick Chat 4
QuickChat5 = Quick Chat 5
[SystemParam]
PSPModel = 1
PSPFirmwareVersion = 660
NickName = PPSSPP
MacAddress = ec:fd:62:d4:ec:73
Language = 1
ParamTimeFormat = 0
ParamDateFormat = 0
TimeZone = 0
DayLightSavings = False
ButtonPreference = 1
LockParentalLevel = 0
WlanAdhocChannel = 0
WlanPowerSave = False
EncryptSave = True
SavedataUpgradeVersion = True
MemStickSize = 16
[Debugger]
DisasmWindowX = -1
DisasmWindowY = -1
DisasmWindowW = -1
DisasmWindowH = -1
GEWindowX = -1
GEWindowY = -1
GEWindowW = -1
GEWindowH = -1
ConsoleWindowX = -1
ConsoleWindowY = -1
FontWidth = 8
FontHeight = 12
DisplayStatusBar = True
ShowBottomTabTitles = True
ShowDeveloperMenu = False
SkipDeadbeefFilling = False
FuncHashMap = False
MemInfoDetailed = False
DrawFrameGraph = False
[Upgrade]
UpgradeMessage =
UpgradeVersion =
DismissedVersion =
[Theme]
ItemStyleFg = 0xffffffff
ItemStyleBg = 0x55000000
ItemFocusedStyleFg = 0xffffffff
ItemFocusedStyleBg = 0xffedc24c
ItemDownStyleFg = 0xffffffff
ItemDownStyleBg = 0xffbd9939
ItemDisabledStyleFg = 0x80eeeeee
ItemDisabledStyleBg = 0x55e0d4af
ItemHighlightedStyleFg = 0xffffffff
ItemHighlightedStyleBg = 0x55bdbb39
ButtonStyleFg = 0xffffffff
ButtonStyleBg = 0x55000000
ButtonFocusedStyleFg = 0xffffffff
ButtonFocusedStyleBg = 0xffedc24c
ButtonDownStyleFg = 0xffffffff
ButtonDownStyleBg = 0xffbd9939
ButtonDisabledStyleFg = 0x80eeeeee
ButtonDisabledStyleBg = 0x55e0d4af
ButtonHighlightedStyleFg = 0xffffffff
ButtonHighlightedStyleBg = 0x55bdbb39
HeaderStyleFg = 0xffffffff
InfoStyleFg = 0xffffffff
InfoStyleBg = 0x00000000
PopupTitleStyleFg = 0xffe3be59
PopupStyleFg = 0xffffffff
PopupStyleBg = 0xff303030
[Recent]
MaxRecent = 60
[Log]
SYSTEMEnabled = True
SYSTEMLevel = 2
BOOTEnabled = True
BOOTLevel = 2
COMMONEnabled = True
COMMONLevel = 2
CPUEnabled = True
CPULevel = 2
FILESYSEnabled = True
FILESYSLevel = 2
G3DEnabled = True
G3DLevel = 2
HLEEnabled = True
HLELevel = 2
JITEnabled = True
JITLevel = 2
LOADEREnabled = True
LOADERLevel = 2
MEEnabled = True
MELevel = 2
MEMMAPEnabled = True
MEMMAPLevel = 2
SASMIXEnabled = True
SASMIXLevel = 2
SAVESTATEEnabled = True
SAVESTATELevel = 2
FRAMEBUFEnabled = True
FRAMEBUFLevel = 2
AUDIOEnabled = True
AUDIOLevel = 2
IOEnabled = True
IOLevel = 2
SCEAUDIOEnabled = True
SCEAUDIOLevel = 2
SCECTRLEnabled = True
SCECTRLLevel = 2
SCEDISPEnabled = True
SCEDISPLevel = 2
SCEFONTEnabled = True
SCEFONTLevel = 2
SCEGEEnabled = True
SCEGELevel = 2
SCEINTCEnabled = True
SCEINTCLevel = 2
SCEIOEnabled = True
SCEIOLevel = 2
SCEKERNELEnabled = True
SCEKERNELLevel = 2
SCEMODULEEnabled = True
SCEMODULELevel = 2
SCENETEnabled = True
SCENETLevel = 2
SCERTCEnabled = True
SCERTCLevel = 2
SCESASEnabled = True
SCESASLevel = 2
SCEUTILEnabled = True
SCEUTILLevel = 2
SCEMISCEnabled = True
SCEMISCLevel = 2
[PostShaderSetting]
BloomSettingValue1 = 0.600000
BloomSettingValue2 = 0.500000
CartoonSettingValue1 = 0.500000
ColorCorrectionSettingValue1 = 1.000000
ColorCorrectionSettingValue2 = 1.000000
ColorCorrectionSettingValue3 = 1.000000
ColorCorrectionSettingValue4 = 1.000000
ScanlinesSettingValue1 = 1.000000
ScanlinesSettingValue2 = 0.500000
SharpenSettingValue1 = 1.500000
[Achievements]
AchievementsEnable = False
AchievementsChallengeMode = False
AchievementsEncoreMode = False
AchievementsUnofficial = False
AchievementsLogBadMemReads = False
AchievementsSoundEffects = True
AchievementsUnlockAudioFile =
AchievementsLeaderboardSubmitAudioFile =
AchievementsLeaderboardTrackerPos = 3
AchievementsLeaderboardStartedOrFailedPos = 3
AchievementsLeaderboardSubmittedPos = 3
AchievementsProgressPos = 3
AchievementsChallengePos = 3
AchievementsUnlockedPos = 4

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